mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
b25e988628
Firefox will not serialize the xmlns attributes of node inside XMLDocuments, see https://bugzilla.mozilla.org/show_bug.cgi?id=175946 it will only output an xmlns attribute if the node being serialized has a non-null namespaceURI. When compiling templates, we currently use a special attribute, "block-ns" to keep track of the namespace, but we do not make use of the namespace to create the elements that will be serialized to the compiled block string. This causes a difference in behaviour between Chrome and Firefox: since we end up with an Element with two attributes: the block-ns attribute and the xmlns attribute. Chrome will serialize both, but Firefox will only serialize the block-ns because the Element does not have a namespaceURI. To fix this issue, we get rid of the block-ns magic attribute completely, and use xmlns instead. We also use the namespace when creating intermediate elements that will be used to create the serialized block string: the namespace is only used to create the elements but *not* set as an attribute, as this would cause chrome to serialize it twice, causing a duplicate attribute error when parsing it further down the line. When creating the template element for the block at runtime, the xmlns attribute is used both as the namespace with which to create the element, and set as an attribute, this doesn't cause issues when serializing later because the namespaceURI is never serialized as an attribute when serializing an HTML document[1], so we avoid the double-serialization in Chrome, and when doing HTML serialization, Firefox will correctly serialize the attribute. It's desirable that the xmlns is set as an attribute to allow users to use owl to render SVG, and then use the HTML serialization of it as a SVG even outside the context of an HTML document (eg to generate an SVG file or an SVG data URL). [1]: https://w3c.github.io/DOM-Parsing/#xml-serialization
1012 lines
28 KiB
TypeScript
1012 lines
28 KiB
TypeScript
import { OwlError } from "../common/owl_error";
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// AST Type definition
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export type EventHandlers = { [eventName: string]: string };
|
|
export type Attrs = { [attrs: string]: string };
|
|
|
|
export const enum ASTType {
|
|
Text,
|
|
Comment,
|
|
DomNode,
|
|
Multi,
|
|
TEsc,
|
|
TIf,
|
|
TSet,
|
|
TCall,
|
|
TOut,
|
|
TForEach,
|
|
TKey,
|
|
TComponent,
|
|
TDebug,
|
|
TLog,
|
|
TSlot,
|
|
TCallBlock,
|
|
TTranslation,
|
|
TPortal,
|
|
}
|
|
|
|
export interface ASTText {
|
|
type: ASTType.Text;
|
|
value: string;
|
|
}
|
|
|
|
export interface ASTComment {
|
|
type: ASTType.Comment;
|
|
value: string;
|
|
}
|
|
|
|
interface TModelInfo {
|
|
baseExpr: string;
|
|
expr: string;
|
|
targetAttr: string;
|
|
eventType: "change" | "click" | "input";
|
|
shouldTrim: boolean;
|
|
shouldNumberize: boolean;
|
|
hasDynamicChildren: boolean;
|
|
specialInitTargetAttr: string | null;
|
|
}
|
|
|
|
export interface ASTDomNode {
|
|
type: ASTType.DomNode;
|
|
tag: string;
|
|
content: AST[];
|
|
attrs: Attrs | null;
|
|
ref: string | null;
|
|
on: EventHandlers | null;
|
|
model: TModelInfo | null;
|
|
dynamicTag: string | null;
|
|
ns: string | null;
|
|
}
|
|
|
|
export interface ASTMulti {
|
|
type: ASTType.Multi;
|
|
content: AST[];
|
|
}
|
|
|
|
export interface ASTTEsc {
|
|
type: ASTType.TEsc;
|
|
expr: string;
|
|
defaultValue: string;
|
|
}
|
|
|
|
export interface ASTTOut {
|
|
type: ASTType.TOut;
|
|
expr: string;
|
|
body: AST[] | null;
|
|
}
|
|
|
|
export interface ASTTif {
|
|
type: ASTType.TIf;
|
|
condition: string;
|
|
content: AST;
|
|
tElif: { condition: string; content: AST }[] | null;
|
|
tElse: AST | null;
|
|
}
|
|
|
|
export interface ASTTSet {
|
|
type: ASTType.TSet;
|
|
name: string;
|
|
value: string | null; // value defined in attribute
|
|
defaultValue: string | null; // value defined in body, if text
|
|
body: AST[] | null; // content of body if not text
|
|
}
|
|
|
|
export interface ASTTForEach {
|
|
type: ASTType.TForEach;
|
|
collection: string;
|
|
elem: string;
|
|
body: AST;
|
|
memo: string;
|
|
hasNoFirst: boolean;
|
|
hasNoLast: boolean;
|
|
hasNoIndex: boolean;
|
|
hasNoValue: boolean;
|
|
key: string | null;
|
|
}
|
|
|
|
export interface ASTTKey {
|
|
type: ASTType.TKey;
|
|
expr: string;
|
|
content: AST;
|
|
}
|
|
|
|
export interface ASTTCall {
|
|
type: ASTType.TCall;
|
|
name: string;
|
|
body: AST[] | null;
|
|
context: string | null;
|
|
}
|
|
|
|
interface SlotDefinition {
|
|
content: AST | null;
|
|
scope: string | null;
|
|
on: EventHandlers | null;
|
|
attrs: Attrs | null;
|
|
}
|
|
|
|
export interface ASTComponent {
|
|
type: ASTType.TComponent;
|
|
name: string;
|
|
isDynamic: boolean;
|
|
dynamicProps: string | null;
|
|
on: EventHandlers | null;
|
|
props: { [name: string]: string } | null;
|
|
slots: { [name: string]: SlotDefinition } | null;
|
|
}
|
|
|
|
export interface ASTSlot {
|
|
type: ASTType.TSlot;
|
|
name: string;
|
|
attrs: Attrs | null;
|
|
on: EventHandlers | null;
|
|
defaultContent: AST | null;
|
|
}
|
|
|
|
export interface ASTTCallBlock {
|
|
type: ASTType.TCallBlock;
|
|
name: string;
|
|
}
|
|
|
|
export interface ASTDebug {
|
|
type: ASTType.TDebug;
|
|
content: AST | null;
|
|
}
|
|
|
|
export interface ASTLog {
|
|
type: ASTType.TLog;
|
|
expr: string;
|
|
content: AST | null;
|
|
}
|
|
|
|
export interface ASTTranslation {
|
|
type: ASTType.TTranslation;
|
|
content: AST | null;
|
|
}
|
|
|
|
export interface ASTTPortal {
|
|
type: ASTType.TPortal;
|
|
target: string;
|
|
content: AST;
|
|
}
|
|
|
|
export type AST =
|
|
| ASTText
|
|
| ASTComment
|
|
| ASTDomNode
|
|
| ASTMulti
|
|
| ASTTEsc
|
|
| ASTTif
|
|
| ASTTSet
|
|
| ASTTCall
|
|
| ASTTOut
|
|
| ASTTForEach
|
|
| ASTTKey
|
|
| ASTComponent
|
|
| ASTSlot
|
|
| ASTTCallBlock
|
|
| ASTLog
|
|
| ASTDebug
|
|
| ASTTranslation
|
|
| ASTTPortal;
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Parser
|
|
// -----------------------------------------------------------------------------
|
|
const cache: WeakMap<Element, AST> = new WeakMap();
|
|
|
|
export function parse(xml: string | Element): AST {
|
|
if (typeof xml === "string") {
|
|
const elem = parseXML(`<t>${xml}</t>`).firstChild as Element;
|
|
return _parse(elem);
|
|
}
|
|
let ast = cache.get(xml);
|
|
if (!ast) {
|
|
// we clone here the xml to prevent modifying it in place
|
|
ast = _parse(xml.cloneNode(true) as Element);
|
|
cache.set(xml, ast);
|
|
}
|
|
return ast;
|
|
}
|
|
|
|
function _parse(xml: Element): AST {
|
|
normalizeXML(xml);
|
|
const ctx = { inPreTag: false };
|
|
return parseNode(xml, ctx) || { type: ASTType.Text, value: "" };
|
|
}
|
|
|
|
interface ParsingContext {
|
|
tModelInfo?: TModelInfo | null;
|
|
nameSpace?: string;
|
|
inPreTag: boolean;
|
|
}
|
|
|
|
function parseNode(node: Node, ctx: ParsingContext): AST | null {
|
|
if (!(node instanceof Element)) {
|
|
return parseTextCommentNode(node, ctx);
|
|
}
|
|
return (
|
|
parseTDebugLog(node, ctx) ||
|
|
parseTForEach(node, ctx) ||
|
|
parseTIf(node, ctx) ||
|
|
parseTPortal(node, ctx) ||
|
|
parseTCall(node, ctx) ||
|
|
parseTCallBlock(node, ctx) ||
|
|
parseTEscNode(node, ctx) ||
|
|
parseTOutNode(node, ctx) ||
|
|
parseTKey(node, ctx) ||
|
|
parseTTranslation(node, ctx) ||
|
|
parseTSlot(node, ctx) ||
|
|
parseComponent(node, ctx) ||
|
|
parseDOMNode(node, ctx) ||
|
|
parseTSetNode(node, ctx) ||
|
|
parseTNode(node, ctx)
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// <t /> tag
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTNode(node: Element, ctx: ParsingContext): AST | null {
|
|
if (node.tagName !== "t") {
|
|
return null;
|
|
}
|
|
return parseChildNodes(node, ctx);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Text and Comment Nodes
|
|
// -----------------------------------------------------------------------------
|
|
const lineBreakRE = /[\r\n]/;
|
|
|
|
function parseTextCommentNode(node: Node, ctx: ParsingContext): AST | null {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
let value = node.textContent || "";
|
|
if (!ctx.inPreTag && lineBreakRE.test(value) && !value.trim()) {
|
|
return null;
|
|
}
|
|
|
|
return { type: ASTType.Text, value };
|
|
} else if (node.nodeType === Node.COMMENT_NODE) {
|
|
return { type: ASTType.Comment, value: node.textContent || "" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// debugging
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTDebugLog(node: Element, ctx: ParsingContext): AST | null {
|
|
if (node.hasAttribute("t-debug")) {
|
|
node.removeAttribute("t-debug");
|
|
return {
|
|
type: ASTType.TDebug,
|
|
content: parseNode(node, ctx),
|
|
};
|
|
}
|
|
|
|
if (node.hasAttribute("t-log")) {
|
|
const expr = node.getAttribute("t-log")!;
|
|
node.removeAttribute("t-log");
|
|
return {
|
|
type: ASTType.TLog,
|
|
expr,
|
|
content: parseNode(node, ctx),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Regular dom node
|
|
// -----------------------------------------------------------------------------
|
|
const hasDotAtTheEnd = /\.[\w_]+\s*$/;
|
|
const hasBracketsAtTheEnd = /\[[^\[]+\]\s*$/;
|
|
|
|
const ROOT_SVG_TAGS = new Set(["svg", "g", "path"]);
|
|
|
|
function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
|
|
const { tagName } = node;
|
|
const dynamicTag = node.getAttribute("t-tag");
|
|
node.removeAttribute("t-tag");
|
|
if (tagName === "t" && !dynamicTag) {
|
|
return null;
|
|
}
|
|
if (tagName.startsWith("block-")) {
|
|
throw new OwlError(`Invalid tag name: '${tagName}'`);
|
|
}
|
|
ctx = Object.assign({}, ctx);
|
|
if (tagName === "pre") {
|
|
ctx.inPreTag = true;
|
|
}
|
|
|
|
let ns = !ctx.nameSpace && ROOT_SVG_TAGS.has(tagName) ? "http://www.w3.org/2000/svg" : null;
|
|
const ref = node.getAttribute("t-ref");
|
|
node.removeAttribute("t-ref");
|
|
|
|
const nodeAttrsNames = node.getAttributeNames();
|
|
let attrs: ASTDomNode["attrs"] = null;
|
|
let on: EventHandlers | null = null;
|
|
let model: TModelInfo | null = null;
|
|
|
|
for (let attr of nodeAttrsNames) {
|
|
const value = node.getAttribute(attr)!;
|
|
if (attr === "t-on" || attr === "t-on-") {
|
|
throw new OwlError("Missing event name with t-on directive");
|
|
}
|
|
if (attr.startsWith("t-on-")) {
|
|
on = on || {};
|
|
on[attr.slice(5)] = value;
|
|
} else if (attr.startsWith("t-model")) {
|
|
if (!["input", "select", "textarea"].includes(tagName)) {
|
|
throw new OwlError(
|
|
"The t-model directive only works with <input>, <textarea> and <select>"
|
|
);
|
|
}
|
|
|
|
let baseExpr, expr;
|
|
if (hasDotAtTheEnd.test(value)) {
|
|
const index = value.lastIndexOf(".");
|
|
baseExpr = value.slice(0, index);
|
|
expr = `'${value.slice(index + 1)}'`;
|
|
} else if (hasBracketsAtTheEnd.test(value)) {
|
|
const index = value.lastIndexOf("[");
|
|
baseExpr = value.slice(0, index);
|
|
expr = value.slice(index + 1, -1);
|
|
} else {
|
|
throw new OwlError(`Invalid t-model expression: "${value}" (it should be assignable)`);
|
|
}
|
|
|
|
const typeAttr = node.getAttribute("type");
|
|
const isInput = tagName === "input";
|
|
const isSelect = tagName === "select";
|
|
const isCheckboxInput = isInput && typeAttr === "checkbox";
|
|
const isRadioInput = isInput && typeAttr === "radio";
|
|
const hasLazyMod = attr.includes(".lazy");
|
|
const hasNumberMod = attr.includes(".number");
|
|
const hasTrimMod = attr.includes(".trim");
|
|
const eventType = isRadioInput ? "click" : isSelect || hasLazyMod ? "change" : "input";
|
|
|
|
model = {
|
|
baseExpr,
|
|
expr,
|
|
targetAttr: isCheckboxInput ? "checked" : "value",
|
|
specialInitTargetAttr: isRadioInput ? "checked" : null,
|
|
eventType,
|
|
hasDynamicChildren: false,
|
|
shouldTrim: hasTrimMod,
|
|
shouldNumberize: hasNumberMod,
|
|
};
|
|
if (isSelect) {
|
|
// don't pollute the original ctx
|
|
ctx = Object.assign({}, ctx);
|
|
ctx.tModelInfo = model;
|
|
}
|
|
} else if (attr.startsWith("block-")) {
|
|
throw new OwlError(`Invalid attribute: '${attr}'`);
|
|
} else if (attr === "xmlns") {
|
|
ns = value;
|
|
} else if (attr !== "t-name") {
|
|
if (attr.startsWith("t-") && !attr.startsWith("t-att")) {
|
|
throw new OwlError(`Unknown QWeb directive: '${attr}'`);
|
|
}
|
|
const tModel = ctx.tModelInfo;
|
|
if (tModel && ["t-att-value", "t-attf-value"].includes(attr)) {
|
|
tModel.hasDynamicChildren = true;
|
|
}
|
|
attrs = attrs || {};
|
|
attrs[attr] = value;
|
|
}
|
|
}
|
|
if (ns) {
|
|
ctx.nameSpace = ns;
|
|
}
|
|
|
|
const children = parseChildren(node, ctx);
|
|
return {
|
|
type: ASTType.DomNode,
|
|
tag: tagName,
|
|
dynamicTag,
|
|
attrs,
|
|
on,
|
|
ref,
|
|
content: children,
|
|
model,
|
|
ns,
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-esc
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTEscNode(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-esc")) {
|
|
return null;
|
|
}
|
|
const escValue = node.getAttribute("t-esc")!;
|
|
node.removeAttribute("t-esc");
|
|
const tesc: AST = {
|
|
type: ASTType.TEsc,
|
|
expr: escValue,
|
|
defaultValue: node.textContent || "",
|
|
};
|
|
let ref = node.getAttribute("t-ref");
|
|
node.removeAttribute("t-ref");
|
|
const ast = parseNode(node, ctx);
|
|
if (!ast) {
|
|
return tesc;
|
|
}
|
|
if (ast.type === ASTType.DomNode) {
|
|
return {
|
|
...ast,
|
|
ref,
|
|
content: [tesc],
|
|
};
|
|
}
|
|
return tesc;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-out
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTOutNode(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-out") && !node.hasAttribute("t-raw")) {
|
|
return null;
|
|
}
|
|
if (node.hasAttribute("t-raw")) {
|
|
console.warn(
|
|
`t-raw has been deprecated in favor of t-out. If the value to render is not wrapped by the "markup" function, it will be escaped`
|
|
);
|
|
}
|
|
const expr = (node.getAttribute("t-out") || node.getAttribute("t-raw"))!;
|
|
node.removeAttribute("t-out");
|
|
node.removeAttribute("t-raw");
|
|
|
|
const tOut: AST = { type: ASTType.TOut, expr, body: null };
|
|
const ref = node.getAttribute("t-ref");
|
|
node.removeAttribute("t-ref");
|
|
const ast = parseNode(node, ctx);
|
|
if (!ast) {
|
|
return tOut;
|
|
}
|
|
if (ast.type === ASTType.DomNode) {
|
|
tOut.body = ast.content.length ? ast.content : null;
|
|
return {
|
|
...ast,
|
|
ref,
|
|
content: [tOut],
|
|
};
|
|
}
|
|
|
|
return tOut;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-foreach and t-key
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTForEach(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-foreach")) {
|
|
return null;
|
|
}
|
|
const html = node.outerHTML;
|
|
const collection = node.getAttribute("t-foreach")!;
|
|
node.removeAttribute("t-foreach");
|
|
const elem = node.getAttribute("t-as") || "";
|
|
node.removeAttribute("t-as");
|
|
const key = node.getAttribute("t-key");
|
|
if (!key) {
|
|
throw new OwlError(
|
|
`"Directive t-foreach should always be used with a t-key!" (expression: t-foreach="${collection}" t-as="${elem}")`
|
|
);
|
|
}
|
|
node.removeAttribute("t-key");
|
|
const memo = node.getAttribute("t-memo") || "";
|
|
node.removeAttribute("t-memo");
|
|
const body = parseNode(node, ctx);
|
|
|
|
if (!body) {
|
|
return null;
|
|
}
|
|
|
|
const hasNoTCall = !html.includes("t-call");
|
|
const hasNoFirst = hasNoTCall && !html.includes(`${elem}_first`);
|
|
const hasNoLast = hasNoTCall && !html.includes(`${elem}_last`);
|
|
const hasNoIndex = hasNoTCall && !html.includes(`${elem}_index`);
|
|
const hasNoValue = hasNoTCall && !html.includes(`${elem}_value`);
|
|
|
|
return {
|
|
type: ASTType.TForEach,
|
|
collection,
|
|
elem,
|
|
body,
|
|
memo,
|
|
key,
|
|
hasNoFirst,
|
|
hasNoLast,
|
|
hasNoIndex,
|
|
hasNoValue,
|
|
};
|
|
}
|
|
|
|
function parseTKey(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-key")) {
|
|
return null;
|
|
}
|
|
const key = node.getAttribute("t-key")!;
|
|
node.removeAttribute("t-key");
|
|
const body = parseNode(node, ctx);
|
|
if (!body) {
|
|
return null;
|
|
}
|
|
return { type: ASTType.TKey, expr: key, content: body };
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-call
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTCall(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-call")) {
|
|
return null;
|
|
}
|
|
const subTemplate = node.getAttribute("t-call")!;
|
|
const context = node.getAttribute("t-call-context");
|
|
node.removeAttribute("t-call");
|
|
node.removeAttribute("t-call-context");
|
|
|
|
if (node.tagName !== "t") {
|
|
const ast = parseNode(node, ctx);
|
|
const tcall: AST = { type: ASTType.TCall, name: subTemplate, body: null, context };
|
|
if (ast && ast.type === ASTType.DomNode) {
|
|
ast.content = [tcall];
|
|
return ast;
|
|
}
|
|
if (ast && ast.type === ASTType.TComponent) {
|
|
return {
|
|
...ast,
|
|
slots: { default: { content: tcall, scope: null, on: null, attrs: null } },
|
|
};
|
|
}
|
|
}
|
|
const body = parseChildren(node, ctx);
|
|
|
|
return {
|
|
type: ASTType.TCall,
|
|
name: subTemplate,
|
|
body: body.length ? body : null,
|
|
context,
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-call-block
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTCallBlock(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-call-block")) {
|
|
return null;
|
|
}
|
|
const name = node.getAttribute("t-call-block")!;
|
|
return {
|
|
type: ASTType.TCallBlock,
|
|
name,
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-if
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTIf(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-if")) {
|
|
return null;
|
|
}
|
|
const condition = node.getAttribute("t-if")!;
|
|
node.removeAttribute("t-if");
|
|
const content = parseNode(node, ctx) || { type: ASTType.Text, value: "" };
|
|
|
|
let nextElement = node.nextElementSibling;
|
|
// t-elifs
|
|
const tElifs: any[] = [];
|
|
while (nextElement && nextElement.hasAttribute("t-elif")) {
|
|
const condition = nextElement.getAttribute("t-elif");
|
|
nextElement.removeAttribute("t-elif");
|
|
const tElif = parseNode(nextElement, ctx);
|
|
const next = nextElement.nextElementSibling;
|
|
nextElement.remove();
|
|
nextElement = next;
|
|
if (tElif) {
|
|
tElifs.push({ condition, content: tElif });
|
|
}
|
|
}
|
|
|
|
// t-else
|
|
let tElse: AST | null = null;
|
|
if (nextElement && nextElement.hasAttribute("t-else")) {
|
|
nextElement.removeAttribute("t-else");
|
|
tElse = parseNode(nextElement, ctx);
|
|
nextElement.remove();
|
|
}
|
|
|
|
return {
|
|
type: ASTType.TIf,
|
|
condition,
|
|
content,
|
|
tElif: tElifs.length ? tElifs : null,
|
|
tElse,
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// t-set directive
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTSetNode(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-set")) {
|
|
return null;
|
|
}
|
|
const name = node.getAttribute("t-set")!;
|
|
const value = node.getAttribute("t-value") || null;
|
|
const defaultValue = node.innerHTML === node.textContent ? node.textContent || null : null;
|
|
let body: AST[] | null = null;
|
|
if (node.textContent !== node.innerHTML) {
|
|
body = parseChildren(node, ctx);
|
|
}
|
|
return { type: ASTType.TSet, name, value, defaultValue, body };
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Components
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Error messages when trying to use an unsupported directive on a component
|
|
const directiveErrorMap = new Map([
|
|
[
|
|
"t-ref",
|
|
"t-ref is no longer supported on components. Consider exposing only the public part of the component's API through a callback prop.",
|
|
],
|
|
["t-att", "t-att makes no sense on component: props are already treated as expressions"],
|
|
[
|
|
"t-attf",
|
|
"t-attf is not supported on components: use template strings for string interpolation in props",
|
|
],
|
|
]);
|
|
|
|
function parseComponent(node: Element, ctx: ParsingContext): AST | null {
|
|
let name = node.tagName;
|
|
const firstLetter = name[0];
|
|
let isDynamic = node.hasAttribute("t-component");
|
|
|
|
if (isDynamic && name !== "t") {
|
|
throw new OwlError(
|
|
`Directive 't-component' can only be used on <t> nodes (used on a <${name}>)`
|
|
);
|
|
}
|
|
|
|
if (!(firstLetter === firstLetter.toUpperCase() || isDynamic)) {
|
|
return null;
|
|
}
|
|
if (isDynamic) {
|
|
name = node.getAttribute("t-component")!;
|
|
node.removeAttribute("t-component");
|
|
}
|
|
|
|
const dynamicProps = node.getAttribute("t-props");
|
|
node.removeAttribute("t-props");
|
|
|
|
const defaultSlotScope = node.getAttribute("t-slot-scope");
|
|
node.removeAttribute("t-slot-scope");
|
|
let on: ASTComponent["on"] = null;
|
|
|
|
let props: ASTComponent["props"] = null;
|
|
for (let name of node.getAttributeNames()) {
|
|
const value = node.getAttribute(name)!;
|
|
if (name.startsWith("t-")) {
|
|
if (name.startsWith("t-on-")) {
|
|
on = on || {};
|
|
on[name.slice(5)] = value;
|
|
} else {
|
|
const message = directiveErrorMap.get(name.split("-").slice(0, 2).join("-"));
|
|
throw new OwlError(message || `unsupported directive on Component: ${name}`);
|
|
}
|
|
} else {
|
|
props = props || {};
|
|
props[name] = value;
|
|
}
|
|
}
|
|
|
|
let slots: ASTComponent["slots"] | null = null;
|
|
if (node.hasChildNodes()) {
|
|
const clone = <Element>node.cloneNode(true);
|
|
|
|
// named slots
|
|
const slotNodes = Array.from(clone.querySelectorAll("[t-set-slot]"));
|
|
for (let slotNode of slotNodes) {
|
|
if (slotNode.tagName !== "t") {
|
|
throw new OwlError(
|
|
`Directive 't-set-slot' can only be used on <t> nodes (used on a <${slotNode.tagName}>)`
|
|
);
|
|
}
|
|
const name = slotNode.getAttribute("t-set-slot")!;
|
|
|
|
// check if this is defined in a sub component (in which case it should
|
|
// be ignored)
|
|
let el = slotNode.parentElement!;
|
|
let isInSubComponent = false;
|
|
while (el !== clone) {
|
|
if (el!.hasAttribute("t-component") || el!.tagName[0] === el!.tagName[0].toUpperCase()) {
|
|
isInSubComponent = true;
|
|
break;
|
|
}
|
|
el = el.parentElement!;
|
|
}
|
|
if (isInSubComponent) {
|
|
continue;
|
|
}
|
|
|
|
slotNode.removeAttribute("t-set-slot");
|
|
slotNode.remove();
|
|
const slotAst = parseNode(slotNode, ctx);
|
|
let on: SlotDefinition["on"] = null;
|
|
let attrs: Attrs | null = null;
|
|
let scope: string | null = null;
|
|
for (let attributeName of slotNode.getAttributeNames()) {
|
|
const value = slotNode.getAttribute(attributeName)!;
|
|
if (attributeName === "t-slot-scope") {
|
|
scope = value;
|
|
continue;
|
|
} else if (attributeName.startsWith("t-on-")) {
|
|
on = on || {};
|
|
on[attributeName.slice(5)] = value;
|
|
} else {
|
|
attrs = attrs || {};
|
|
attrs[attributeName] = value;
|
|
}
|
|
}
|
|
slots = slots || {};
|
|
slots[name] = { content: slotAst, on, attrs, scope };
|
|
}
|
|
|
|
// default slot
|
|
const defaultContent = parseChildNodes(clone, ctx);
|
|
slots = slots || {};
|
|
// t-set-slot="default" has priority over content
|
|
if (defaultContent && !slots.default) {
|
|
slots.default = { content: defaultContent, on, attrs: null, scope: defaultSlotScope };
|
|
}
|
|
}
|
|
return { type: ASTType.TComponent, name, isDynamic, dynamicProps, props, slots, on };
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Slots
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-slot")) {
|
|
return null;
|
|
}
|
|
const name = node.getAttribute("t-slot")!;
|
|
node.removeAttribute("t-slot");
|
|
let attrs: Attrs | null = null;
|
|
let on: ASTComponent["on"] = null;
|
|
for (let attributeName of node.getAttributeNames()) {
|
|
const value = node.getAttribute(attributeName)!;
|
|
if (attributeName.startsWith("t-on-")) {
|
|
on = on || {};
|
|
on[attributeName.slice(5)] = value;
|
|
} else {
|
|
attrs = attrs || {};
|
|
attrs[attributeName] = value;
|
|
}
|
|
}
|
|
return {
|
|
type: ASTType.TSlot,
|
|
name,
|
|
attrs,
|
|
on,
|
|
defaultContent: parseChildNodes(node, ctx),
|
|
};
|
|
}
|
|
|
|
function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
|
|
if (node.getAttribute("t-translation") !== "off") {
|
|
return null;
|
|
}
|
|
node.removeAttribute("t-translation");
|
|
return {
|
|
type: ASTType.TTranslation,
|
|
content: parseNode(node, ctx),
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Portal
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function parseTPortal(node: Element, ctx: ParsingContext): AST | null {
|
|
if (!node.hasAttribute("t-portal")) {
|
|
return null;
|
|
}
|
|
const target = node.getAttribute("t-portal")!;
|
|
node.removeAttribute("t-portal");
|
|
const content = parseNode(node, ctx);
|
|
if (!content) {
|
|
return {
|
|
type: ASTType.Text,
|
|
value: "",
|
|
};
|
|
}
|
|
return {
|
|
type: ASTType.TPortal,
|
|
target,
|
|
content,
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse all the child nodes of a given node and return a list of ast elements
|
|
*/
|
|
function parseChildren(node: Element, ctx: ParsingContext): AST[] {
|
|
const children: AST[] = [];
|
|
for (let child of node.childNodes) {
|
|
const childAst = parseNode(child, ctx);
|
|
if (childAst) {
|
|
if (childAst.type === ASTType.Multi) {
|
|
children.push(...childAst.content);
|
|
} else {
|
|
children.push(childAst);
|
|
}
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
|
|
/**
|
|
* Parse all the child nodes of a given node and return an ast if possible.
|
|
* In the case there are multiple children, they are wrapped in a astmulti.
|
|
*/
|
|
function parseChildNodes(node: Element, ctx: ParsingContext): AST | null {
|
|
const children = parseChildren(node, ctx);
|
|
switch (children.length) {
|
|
case 0:
|
|
return null;
|
|
case 1:
|
|
return children[0];
|
|
default:
|
|
return { type: ASTType.Multi, content: children };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalizes the content of an Element so that t-if/t-elif/t-else directives
|
|
* immediately follow one another (by removing empty text nodes or comments).
|
|
* Throws an error when a conditional branching statement is malformed. This
|
|
* function modifies the Element in place.
|
|
*
|
|
* @param el the element containing the tree that should be normalized
|
|
*/
|
|
function normalizeTIf(el: Element) {
|
|
let tbranch = el.querySelectorAll("[t-elif], [t-else]");
|
|
for (let i = 0, ilen = tbranch.length; i < ilen; i++) {
|
|
let node = tbranch[i];
|
|
let prevElem = node.previousElementSibling!;
|
|
let pattr = (name: string) => prevElem.getAttribute(name);
|
|
let nattr = (name: string) => +!!node.getAttribute(name);
|
|
if (prevElem && (pattr("t-if") || pattr("t-elif"))) {
|
|
if (pattr("t-foreach")) {
|
|
throw new OwlError(
|
|
"t-if cannot stay at the same level as t-foreach when using t-elif or t-else"
|
|
);
|
|
}
|
|
if (
|
|
["t-if", "t-elif", "t-else"].map(nattr).reduce(function (a, b) {
|
|
return a + b;
|
|
}) > 1
|
|
) {
|
|
throw new OwlError("Only one conditional branching directive is allowed per node");
|
|
}
|
|
// All text (with only spaces) and comment nodes (nodeType 8) between
|
|
// branch nodes are removed
|
|
let textNode;
|
|
while ((textNode = node.previousSibling) !== prevElem) {
|
|
if (textNode!.nodeValue!.trim().length && textNode!.nodeType !== 8) {
|
|
throw new OwlError("text is not allowed between branching directives");
|
|
}
|
|
textNode!.remove();
|
|
}
|
|
} else {
|
|
throw new OwlError(
|
|
"t-elif and t-else directives must be preceded by a t-if or t-elif directive"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalizes the content of an Element so that t-esc directives on components
|
|
* are removed and instead places a <t t-esc=""> as the default slot of the
|
|
* component. Also throws if the component already has content. This function
|
|
* modifies the Element in place.
|
|
*
|
|
* @param el the element containing the tree that should be normalized
|
|
*/
|
|
function normalizeTEscTOut(el: Element) {
|
|
for (const d of ["t-esc", "t-out"]) {
|
|
const elements = [...el.querySelectorAll(`[${d}]`)].filter(
|
|
(el) => el.tagName[0] === el.tagName[0].toUpperCase() || el.hasAttribute("t-component")
|
|
);
|
|
for (const el of elements) {
|
|
if (el.childNodes.length) {
|
|
throw new OwlError(`Cannot have ${d} on a component that already has content`);
|
|
}
|
|
const value = el.getAttribute(d);
|
|
el.removeAttribute(d);
|
|
const t = el.ownerDocument.createElement("t");
|
|
if (value != null) {
|
|
t.setAttribute(d, value);
|
|
}
|
|
el.appendChild(t);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalizes the tree inside a given element and do some preliminary validation
|
|
* on it. This function modifies the Element in place.
|
|
*
|
|
* @param el the element containing the tree that should be normalized
|
|
*/
|
|
function normalizeXML(el: Element) {
|
|
normalizeTIf(el);
|
|
normalizeTEscTOut(el);
|
|
}
|
|
|
|
/**
|
|
* Parses an XML string into an XML document, throwing errors on parser errors
|
|
* instead of returning an XML document containing the parseerror.
|
|
*
|
|
* @param xml the string to parse
|
|
* @returns an XML document corresponding to the content of the string
|
|
*/
|
|
function parseXML(xml: string): XMLDocument {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xml, "text/xml");
|
|
if (doc.getElementsByTagName("parsererror").length) {
|
|
let msg = "Invalid XML in template.";
|
|
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
|
|
if (parsererrorText) {
|
|
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
|
|
const re = /\d+/g;
|
|
const firstMatch = re.exec(parsererrorText);
|
|
if (firstMatch) {
|
|
const lineNumber = Number(firstMatch[0]);
|
|
const line = xml.split("\n")[lineNumber - 1];
|
|
const secondMatch = re.exec(parsererrorText);
|
|
if (line && secondMatch) {
|
|
const columnIndex = Number(secondMatch[0]) - 1;
|
|
if (line[columnIndex]) {
|
|
msg +=
|
|
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
|
|
`${line}\n${"-".repeat(columnIndex - 1)}^`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
throw new OwlError(msg);
|
|
}
|
|
|
|
return doc;
|
|
}
|