mirror of https://github.com/vuejs/core.git
feat(compiler-core): support specifying root namespace when parsing
This commit is contained in:
parent
a1b10a21ac
commit
40f72d5e50
|
@ -16,12 +16,13 @@ import { PropsExpression } from './transforms/transformElement'
|
||||||
import { ImportItem, TransformContext } from './transform'
|
import { ImportItem, TransformContext } from './transform'
|
||||||
|
|
||||||
// Vue template is a platform-agnostic superset of HTML (syntax only).
|
// Vue template is a platform-agnostic superset of HTML (syntax only).
|
||||||
// More namespaces like SVG and MathML are declared by platform specific
|
// More namespaces can be declared by platform specific compilers.
|
||||||
// compilers.
|
|
||||||
export type Namespace = number
|
export type Namespace = number
|
||||||
|
|
||||||
export const enum Namespaces {
|
export const enum Namespaces {
|
||||||
HTML
|
HTML,
|
||||||
|
SVG,
|
||||||
|
MATH_ML
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum NodeTypes {
|
export const enum NodeTypes {
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
import { ElementNode, Namespace, TemplateChildNode, ParentNode } from './ast'
|
import {
|
||||||
|
ElementNode,
|
||||||
|
Namespace,
|
||||||
|
TemplateChildNode,
|
||||||
|
ParentNode,
|
||||||
|
Namespaces
|
||||||
|
} from './ast'
|
||||||
import { CompilerError } from './errors'
|
import { CompilerError } from './errors'
|
||||||
import {
|
import {
|
||||||
NodeTransform,
|
NodeTransform,
|
||||||
|
@ -16,7 +22,24 @@ export interface ErrorHandlingOptions {
|
||||||
export interface ParserOptions
|
export interface ParserOptions
|
||||||
extends ErrorHandlingOptions,
|
extends ErrorHandlingOptions,
|
||||||
CompilerCompatOptions {
|
CompilerCompatOptions {
|
||||||
|
/**
|
||||||
|
* Base mode is platform agnostic and only parses HTML-like template syntax,
|
||||||
|
* treating all tags the same way. Specific tag parsing behavior can be
|
||||||
|
* configured by higher-level compilers.
|
||||||
|
*
|
||||||
|
* HTML mode adds additional logic for handling special parsing behavior in
|
||||||
|
* `<script>`, `<style>`,`<title>` and `<html>`, plus SVG and MathML
|
||||||
|
* namespaces. The logic is handled inside compiler-core for efficiency.
|
||||||
|
*
|
||||||
|
* SFC mode treats content of all root-level tags except `<template>` as plain
|
||||||
|
* text.
|
||||||
|
*/
|
||||||
parseMode?: 'base' | 'html' | 'sfc'
|
parseMode?: 'base' | 'html' | 'sfc'
|
||||||
|
/**
|
||||||
|
* Specify the root namepsace to use when parsing a tempalte.
|
||||||
|
* Defaults to `Namepsaces.HTML` (0).
|
||||||
|
*/
|
||||||
|
ns?: Namespaces
|
||||||
/**
|
/**
|
||||||
* e.g. platform native elements, e.g. `<div>` for browsers
|
* e.g. platform native elements, e.g. `<div>` for browsers
|
||||||
*/
|
*/
|
||||||
|
@ -40,7 +63,11 @@ export interface ParserOptions
|
||||||
/**
|
/**
|
||||||
* Get tag namespace
|
* Get tag namespace
|
||||||
*/
|
*/
|
||||||
getNamespace?: (tag: string, parent: ElementNode | undefined) => Namespace
|
getNamespace?: (
|
||||||
|
tag: string,
|
||||||
|
parent: ElementNode | undefined,
|
||||||
|
rootNamespace: Namespace
|
||||||
|
) => Namespace
|
||||||
/**
|
/**
|
||||||
* @default ['{{', '}}']
|
* @default ['{{', '}}']
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -40,6 +40,7 @@ type MergedParserOptions = Omit<Required<ParserOptions>, OptionalOptions> &
|
||||||
|
|
||||||
export const defaultParserOptions: MergedParserOptions = {
|
export const defaultParserOptions: MergedParserOptions = {
|
||||||
parseMode: 'base',
|
parseMode: 'base',
|
||||||
|
ns: Namespaces.HTML,
|
||||||
delimiters: [`{{`, `}}`],
|
delimiters: [`{{`, `}}`],
|
||||||
getNamespace: () => Namespaces.HTML,
|
getNamespace: () => Namespaces.HTML,
|
||||||
isVoidTag: NO,
|
isVoidTag: NO,
|
||||||
|
@ -107,7 +108,7 @@ const tokenizer = new Tokenizer(stack, {
|
||||||
currentElement = {
|
currentElement = {
|
||||||
type: NodeTypes.ELEMENT,
|
type: NodeTypes.ELEMENT,
|
||||||
tag: name,
|
tag: name,
|
||||||
ns: currentOptions.getNamespace(name, stack[0]),
|
ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
|
||||||
tagType: ElementTypes.ELEMENT, // will be refined on tag close
|
tagType: ElementTypes.ELEMENT, // will be refined on tag close
|
||||||
props: [],
|
props: [],
|
||||||
children: [],
|
children: [],
|
||||||
|
|
|
@ -6,9 +6,10 @@ import {
|
||||||
ElementTypes,
|
ElementTypes,
|
||||||
InterpolationNode,
|
InterpolationNode,
|
||||||
AttributeNode,
|
AttributeNode,
|
||||||
ConstantTypes
|
ConstantTypes,
|
||||||
|
Namespaces
|
||||||
} from '@vue/compiler-core'
|
} from '@vue/compiler-core'
|
||||||
import { parserOptions, DOMNamespaces } from '../src/parserOptions'
|
import { parserOptions } from '../src/parserOptions'
|
||||||
|
|
||||||
describe('DOM parser', () => {
|
describe('DOM parser', () => {
|
||||||
describe('Text', () => {
|
describe('Text', () => {
|
||||||
|
@ -264,7 +265,7 @@ describe('DOM parser', () => {
|
||||||
|
|
||||||
expect(element).toStrictEqual({
|
expect(element).toStrictEqual({
|
||||||
type: NodeTypes.ELEMENT,
|
type: NodeTypes.ELEMENT,
|
||||||
ns: DOMNamespaces.HTML,
|
ns: Namespaces.HTML,
|
||||||
tag: 'img',
|
tag: 'img',
|
||||||
tagType: ElementTypes.ELEMENT,
|
tagType: ElementTypes.ELEMENT,
|
||||||
props: [],
|
props: [],
|
||||||
|
@ -324,21 +325,21 @@ describe('DOM parser', () => {
|
||||||
const ast = parse('<html>test</html>', parserOptions)
|
const ast = parse('<html>test</html>', parserOptions)
|
||||||
const element = ast.children[0] as ElementNode
|
const element = ast.children[0] as ElementNode
|
||||||
|
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('SVG namespace', () => {
|
test('SVG namespace', () => {
|
||||||
const ast = parse('<svg>test</svg>', parserOptions)
|
const ast = parse('<svg>test</svg>', parserOptions)
|
||||||
const element = ast.children[0] as ElementNode
|
const element = ast.children[0] as ElementNode
|
||||||
|
|
||||||
expect(element.ns).toBe(DOMNamespaces.SVG)
|
expect(element.ns).toBe(Namespaces.SVG)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('MATH_ML namespace', () => {
|
test('MATH_ML namespace', () => {
|
||||||
const ast = parse('<math>test</math>', parserOptions)
|
const ast = parse('<math>test</math>', parserOptions)
|
||||||
const element = ast.children[0] as ElementNode
|
const element = ast.children[0] as ElementNode
|
||||||
|
|
||||||
expect(element.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(element.ns).toBe(Namespaces.MATH_ML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('SVG in MATH_ML namespace', () => {
|
test('SVG in MATH_ML namespace', () => {
|
||||||
|
@ -350,8 +351,8 @@ describe('DOM parser', () => {
|
||||||
const elementAnnotation = elementMath.children[0] as ElementNode
|
const elementAnnotation = elementMath.children[0] as ElementNode
|
||||||
const elementSvg = elementAnnotation.children[0] as ElementNode
|
const elementSvg = elementAnnotation.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementMath.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(elementMath.ns).toBe(Namespaces.MATH_ML)
|
||||||
expect(elementSvg.ns).toBe(DOMNamespaces.SVG)
|
expect(elementSvg.ns).toBe(Namespaces.SVG)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('html text/html in MATH_ML namespace', () => {
|
test('html text/html in MATH_ML namespace', () => {
|
||||||
|
@ -364,8 +365,8 @@ describe('DOM parser', () => {
|
||||||
const elementAnnotation = elementMath.children[0] as ElementNode
|
const elementAnnotation = elementMath.children[0] as ElementNode
|
||||||
const element = elementAnnotation.children[0] as ElementNode
|
const element = elementAnnotation.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementMath.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(elementMath.ns).toBe(Namespaces.MATH_ML)
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('html application/xhtml+xml in MATH_ML namespace', () => {
|
test('html application/xhtml+xml in MATH_ML namespace', () => {
|
||||||
|
@ -377,8 +378,8 @@ describe('DOM parser', () => {
|
||||||
const elementAnnotation = elementMath.children[0] as ElementNode
|
const elementAnnotation = elementMath.children[0] as ElementNode
|
||||||
const element = elementAnnotation.children[0] as ElementNode
|
const element = elementAnnotation.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementMath.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(elementMath.ns).toBe(Namespaces.MATH_ML)
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('mtext malignmark in MATH_ML namespace', () => {
|
test('mtext malignmark in MATH_ML namespace', () => {
|
||||||
|
@ -390,8 +391,8 @@ describe('DOM parser', () => {
|
||||||
const elementText = elementMath.children[0] as ElementNode
|
const elementText = elementMath.children[0] as ElementNode
|
||||||
const element = elementText.children[0] as ElementNode
|
const element = elementText.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementMath.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(elementMath.ns).toBe(Namespaces.MATH_ML)
|
||||||
expect(element.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(element.ns).toBe(Namespaces.MATH_ML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('mtext and not malignmark tag in MATH_ML namespace', () => {
|
test('mtext and not malignmark tag in MATH_ML namespace', () => {
|
||||||
|
@ -400,8 +401,8 @@ describe('DOM parser', () => {
|
||||||
const elementText = elementMath.children[0] as ElementNode
|
const elementText = elementMath.children[0] as ElementNode
|
||||||
const element = elementText.children[0] as ElementNode
|
const element = elementText.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementMath.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(elementMath.ns).toBe(Namespaces.MATH_ML)
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('foreignObject tag in SVG namespace', () => {
|
test('foreignObject tag in SVG namespace', () => {
|
||||||
|
@ -413,8 +414,8 @@ describe('DOM parser', () => {
|
||||||
const elementForeignObject = elementSvg.children[0] as ElementNode
|
const elementForeignObject = elementSvg.children[0] as ElementNode
|
||||||
const element = elementForeignObject.children[0] as ElementNode
|
const element = elementForeignObject.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementSvg.ns).toBe(DOMNamespaces.SVG)
|
expect(elementSvg.ns).toBe(Namespaces.SVG)
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('desc tag in SVG namespace', () => {
|
test('desc tag in SVG namespace', () => {
|
||||||
|
@ -423,8 +424,8 @@ describe('DOM parser', () => {
|
||||||
const elementDesc = elementSvg.children[0] as ElementNode
|
const elementDesc = elementSvg.children[0] as ElementNode
|
||||||
const element = elementDesc.children[0] as ElementNode
|
const element = elementDesc.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementSvg.ns).toBe(DOMNamespaces.SVG)
|
expect(elementSvg.ns).toBe(Namespaces.SVG)
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('title tag in SVG namespace', () => {
|
test('title tag in SVG namespace', () => {
|
||||||
|
@ -433,8 +434,8 @@ describe('DOM parser', () => {
|
||||||
const elementTitle = elementSvg.children[0] as ElementNode
|
const elementTitle = elementSvg.children[0] as ElementNode
|
||||||
const element = elementTitle.children[0] as ElementNode
|
const element = elementTitle.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementSvg.ns).toBe(DOMNamespaces.SVG)
|
expect(elementSvg.ns).toBe(Namespaces.SVG)
|
||||||
expect(element.ns).toBe(DOMNamespaces.HTML)
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('SVG in HTML namespace', () => {
|
test('SVG in HTML namespace', () => {
|
||||||
|
@ -442,8 +443,8 @@ describe('DOM parser', () => {
|
||||||
const elementHtml = ast.children[0] as ElementNode
|
const elementHtml = ast.children[0] as ElementNode
|
||||||
const element = elementHtml.children[0] as ElementNode
|
const element = elementHtml.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementHtml.ns).toBe(DOMNamespaces.HTML)
|
expect(elementHtml.ns).toBe(Namespaces.HTML)
|
||||||
expect(element.ns).toBe(DOMNamespaces.SVG)
|
expect(element.ns).toBe(Namespaces.SVG)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('MATH in HTML namespace', () => {
|
test('MATH in HTML namespace', () => {
|
||||||
|
@ -451,8 +452,20 @@ describe('DOM parser', () => {
|
||||||
const elementHtml = ast.children[0] as ElementNode
|
const elementHtml = ast.children[0] as ElementNode
|
||||||
const element = elementHtml.children[0] as ElementNode
|
const element = elementHtml.children[0] as ElementNode
|
||||||
|
|
||||||
expect(elementHtml.ns).toBe(DOMNamespaces.HTML)
|
expect(elementHtml.ns).toBe(Namespaces.HTML)
|
||||||
expect(element.ns).toBe(DOMNamespaces.MATH_ML)
|
expect(element.ns).toBe(Namespaces.MATH_ML)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('root ns', () => {
|
||||||
|
const ast = parse('<foreignObject><test/></foreignObject>', {
|
||||||
|
...parserOptions,
|
||||||
|
ns: Namespaces.SVG
|
||||||
|
})
|
||||||
|
const elementForieng = ast.children[0] as ElementNode
|
||||||
|
const element = elementForieng.children[0] as ElementNode
|
||||||
|
|
||||||
|
expect(elementForieng.ns).toBe(Namespaces.SVG)
|
||||||
|
expect(element.ns).toBe(Namespaces.HTML)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,14 +1,8 @@
|
||||||
import { ParserOptions, ElementNode, NodeTypes } from '@vue/compiler-core'
|
import { ParserOptions, NodeTypes, Namespaces } from '@vue/compiler-core'
|
||||||
import { isVoidTag, isHTMLTag, isSVGTag } from '@vue/shared'
|
import { isVoidTag, isHTMLTag, isSVGTag } from '@vue/shared'
|
||||||
import { TRANSITION, TRANSITION_GROUP } from './runtimeHelpers'
|
import { TRANSITION, TRANSITION_GROUP } from './runtimeHelpers'
|
||||||
import { decodeHtmlBrowser } from './decodeHtmlBrowser'
|
import { decodeHtmlBrowser } from './decodeHtmlBrowser'
|
||||||
|
|
||||||
export const enum DOMNamespaces {
|
|
||||||
HTML = 0 /* Namespaces.HTML */,
|
|
||||||
SVG,
|
|
||||||
MATH_ML
|
|
||||||
}
|
|
||||||
|
|
||||||
export const parserOptions: ParserOptions = {
|
export const parserOptions: ParserOptions = {
|
||||||
parseMode: 'html',
|
parseMode: 'html',
|
||||||
isVoidTag,
|
isVoidTag,
|
||||||
|
@ -16,7 +10,7 @@ export const parserOptions: ParserOptions = {
|
||||||
isPreTag: tag => tag === 'pre',
|
isPreTag: tag => tag === 'pre',
|
||||||
decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined,
|
decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined,
|
||||||
|
|
||||||
isBuiltInComponent: (tag: string): symbol | undefined => {
|
isBuiltInComponent: tag => {
|
||||||
if (tag === 'Transition' || tag === 'transition') {
|
if (tag === 'Transition' || tag === 'transition') {
|
||||||
return TRANSITION
|
return TRANSITION
|
||||||
} else if (tag === 'TransitionGroup' || tag === 'transition-group') {
|
} else if (tag === 'TransitionGroup' || tag === 'transition-group') {
|
||||||
|
@ -25,12 +19,12 @@ export const parserOptions: ParserOptions = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// https://html.spec.whatwg.org/multipage/parsing.html#tree-construction-dispatcher
|
// https://html.spec.whatwg.org/multipage/parsing.html#tree-construction-dispatcher
|
||||||
getNamespace(tag: string, parent: ElementNode | undefined): DOMNamespaces {
|
getNamespace(tag, parent, rootNamespace) {
|
||||||
let ns = parent ? parent.ns : DOMNamespaces.HTML
|
let ns = parent ? parent.ns : rootNamespace
|
||||||
if (parent && ns === DOMNamespaces.MATH_ML) {
|
if (parent && ns === Namespaces.MATH_ML) {
|
||||||
if (parent.tag === 'annotation-xml') {
|
if (parent.tag === 'annotation-xml') {
|
||||||
if (tag === 'svg') {
|
if (tag === 'svg') {
|
||||||
return DOMNamespaces.SVG
|
return Namespaces.SVG
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
parent.props.some(
|
parent.props.some(
|
||||||
|
@ -42,31 +36,31 @@ export const parserOptions: ParserOptions = {
|
||||||
a.value.content === 'application/xhtml+xml')
|
a.value.content === 'application/xhtml+xml')
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ns = DOMNamespaces.HTML
|
ns = Namespaces.HTML
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
/^m(?:[ions]|text)$/.test(parent.tag) &&
|
/^m(?:[ions]|text)$/.test(parent.tag) &&
|
||||||
tag !== 'mglyph' &&
|
tag !== 'mglyph' &&
|
||||||
tag !== 'malignmark'
|
tag !== 'malignmark'
|
||||||
) {
|
) {
|
||||||
ns = DOMNamespaces.HTML
|
ns = Namespaces.HTML
|
||||||
}
|
}
|
||||||
} else if (parent && ns === DOMNamespaces.SVG) {
|
} else if (parent && ns === Namespaces.SVG) {
|
||||||
if (
|
if (
|
||||||
parent.tag === 'foreignObject' ||
|
parent.tag === 'foreignObject' ||
|
||||||
parent.tag === 'desc' ||
|
parent.tag === 'desc' ||
|
||||||
parent.tag === 'title'
|
parent.tag === 'title'
|
||||||
) {
|
) {
|
||||||
ns = DOMNamespaces.HTML
|
ns = Namespaces.HTML
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ns === DOMNamespaces.HTML) {
|
if (ns === Namespaces.HTML) {
|
||||||
if (tag === 'svg') {
|
if (tag === 'svg') {
|
||||||
return DOMNamespaces.SVG
|
return Namespaces.SVG
|
||||||
}
|
}
|
||||||
if (tag === 'math') {
|
if (tag === 'math') {
|
||||||
return DOMNamespaces.MATH_ML
|
return Namespaces.MATH_ML
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ns
|
return ns
|
||||||
|
|
|
@ -15,7 +15,8 @@ import {
|
||||||
PlainElementNode,
|
PlainElementNode,
|
||||||
JSChildNode,
|
JSChildNode,
|
||||||
TextCallNode,
|
TextCallNode,
|
||||||
ConstantTypes
|
ConstantTypes,
|
||||||
|
Namespaces
|
||||||
} from '@vue/compiler-core'
|
} from '@vue/compiler-core'
|
||||||
import {
|
import {
|
||||||
isVoidTag,
|
isVoidTag,
|
||||||
|
@ -31,7 +32,6 @@ import {
|
||||||
isKnownSvgAttr,
|
isKnownSvgAttr,
|
||||||
isBooleanAttr
|
isBooleanAttr
|
||||||
} from '@vue/shared'
|
} from '@vue/shared'
|
||||||
import { DOMNamespaces } from '../parserOptions'
|
|
||||||
|
|
||||||
export const enum StringifyThresholds {
|
export const enum StringifyThresholds {
|
||||||
ELEMENT_WITH_BINDING_COUNT = 5,
|
ELEMENT_WITH_BINDING_COUNT = 5,
|
||||||
|
@ -148,11 +148,11 @@ const getHoistedNode = (node: TemplateChildNode) =>
|
||||||
node.codegenNode.hoisted
|
node.codegenNode.hoisted
|
||||||
|
|
||||||
const dataAriaRE = /^(data|aria)-/
|
const dataAriaRE = /^(data|aria)-/
|
||||||
const isStringifiableAttr = (name: string, ns: DOMNamespaces) => {
|
const isStringifiableAttr = (name: string, ns: Namespaces) => {
|
||||||
return (
|
return (
|
||||||
(ns === DOMNamespaces.HTML
|
(ns === Namespaces.HTML
|
||||||
? isKnownHtmlAttr(name)
|
? isKnownHtmlAttr(name)
|
||||||
: ns === DOMNamespaces.SVG
|
: ns === Namespaces.SVG
|
||||||
? isKnownSvgAttr(name)
|
? isKnownSvgAttr(name)
|
||||||
: false) || dataAriaRE.test(name)
|
: false) || dataAriaRE.test(name)
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue