From 5d4a38ad0f4cbf16dbcc0309034ee82710090220 Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Fri, 19 Nov 2021 11:31:04 +0100 Subject: [PATCH] [IMP] svg namespace support --- src/blockdom/block_compiler.ts | 29 +++-- src/compiler/code_generator.ts | 4 + src/compiler/parser.ts | 21 ++-- tests/blockdom/namespace.test.ts | 72 +++++++++++ tests/compiler/__snapshots__/svg.test.ts.snap | 57 +++++++++ tests/compiler/parser.test.ts | 119 +++++++++++++++++- tests/compiler/svg.test.ts | 42 ++++--- .../__snapshots__/basics.test.ts.snap | 4 +- 8 files changed, 310 insertions(+), 38 deletions(-) create mode 100644 tests/blockdom/namespace.test.ts create mode 100644 tests/compiler/__snapshots__/svg.test.ts.snap diff --git a/src/blockdom/block_compiler.ts b/src/blockdom/block_compiler.ts index dda42947..728fe6b5 100644 --- a/src/blockdom/block_compiler.ts +++ b/src/blockdom/block_compiler.ts @@ -116,6 +116,7 @@ interface IntermediateTree { forceRef?: boolean; refIdx?: number; refN: number; + currentNS: string | null; } function buildTree( @@ -124,9 +125,10 @@ function buildTree( domParentTree: IntermediateTree | null = null ): IntermediateTree { switch (node.nodeType) { - case 1: { + case Node.ELEMENT_NODE: { // HTMLElement let isActive = false; + let currentNS = parent && parent.currentNS; const tagName = (node as Element).tagName; let el: Node | undefined = undefined; const info: DynamicInfo[] = []; @@ -143,11 +145,18 @@ function buildTree( el = document.createTextNode(""); isActive = true; } - if (!el) { - el = document.createElement(tagName); + const attrs = (node as Element).attributes; + const ns = attrs.getNamedItem("block-ns"); + if (ns) { + attrs.removeNamedItem("block-ns"); + currentNS = ns.value; } - if (el instanceof HTMLElement) { - const attrs = (node as Element).attributes; + if (!el) { + el = currentNS + ? document.createElementNS(currentNS, tagName) + : document.createElement(tagName); + } + if (el instanceof Element) { for (let i = 0; i < attrs.length; i++) { const attrName = attrs[i].name; const attrValue = attrs[i].value; @@ -193,13 +202,14 @@ function buildTree( el, info, refN: isActive ? 1 : 0, + currentNS, }; if (node.firstChild) { const childNode = node.childNodes[0]; if ( node.childNodes.length === 1 && - childNode.nodeType === 1 && + childNode.nodeType === Node.ELEMENT_NODE && (childNode as Element).tagName.startsWith("block-child-") ) { const tagName = (childNode as Element).tagName; @@ -227,11 +237,11 @@ function buildTree( } return tree; } - case 3: - case 8: { + case Node.TEXT_NODE: + case Node.COMMENT_NODE: { // text node or comment node const el = - node.nodeType === 3 + node.nodeType === Node.TEXT_NODE ? document.createTextNode(node.textContent!) : document.createComment(node.textContent!); return { @@ -241,6 +251,7 @@ function buildTree( el, info: [], refN: 0, + currentNS: null, }; } } diff --git a/src/compiler/code_generator.ts b/src/compiler/code_generator.ts index fdf540d1..11251e0b 100644 --- a/src/compiler/code_generator.ts +++ b/src/compiler/code_generator.ts @@ -497,6 +497,10 @@ export class CodeGenerator { } // attributes const attrs: { [key: string]: string } = {}; + if (ast.ns) { + // specific namespace uri + attrs["block-ns"] = ast.ns; + } for (let key in ast.attrs) { if (key.startsWith("t-attf")) { let expr = interpolate(ast.attrs[key]); diff --git a/src/compiler/parser.ts b/src/compiler/parser.ts index a70bb2f4..93bd8c17 100644 --- a/src/compiler/parser.ts +++ b/src/compiler/parser.ts @@ -49,6 +49,7 @@ export interface ASTDomNode { shouldTrim: boolean; shouldNumberize: boolean; } | null; + ns: string | null; } export interface ASTMulti { @@ -171,11 +172,12 @@ export type AST = // ----------------------------------------------------------------------------- interface ParsingContext { inPreTag: boolean; + inSVG: boolean; } export function parse(xml: string | Node): AST { const node = xml instanceof Element ? xml : parseXML(`${xml}`).firstChild!; - const ctx = { inPreTag: false }; + const ctx = { inPreTag: false, inSVG: false }; const ast = parseNode(node, ctx); if (!ast) { return { type: ASTType.Text, value: "" }; @@ -240,7 +242,7 @@ const lineBreakRE = /[\r\n]/; const whitespaceRE = /\s+/g; function parseTextCommentNode(node: ChildNode, ctx: ParsingContext): AST | null { - if (node.nodeType === 3) { + if (node.nodeType === Node.TEXT_NODE) { let value = node.textContent || ""; if (!ctx.inPreTag) { if (lineBreakRE.test(value) && !value.trim()) { @@ -250,7 +252,7 @@ function parseTextCommentNode(node: ChildNode, ctx: ParsingContext): AST | null } return { type: ASTType.Text, value }; - } else if (node.nodeType === 8) { + } else if (node.nodeType === Node.COMMENT_NODE) { return { type: ASTType.Comment, value: node.textContent || "" }; } return null; @@ -289,18 +291,18 @@ const hasBracketsAtTheEnd = /\[[^\[]+\]\s*$/; function parseDOMNode(node: Element, ctx: ParsingContext): AST | null { const { tagName } = node; - let dynamicTag = null; - if (node.hasAttribute("t-tag")) { - dynamicTag = node.getAttribute("t-tag"); - node.removeAttribute("t-tag"); - } + const dynamicTag = node.getAttribute("t-tag"); + node.removeAttribute("t-tag"); if (tagName === "t" && !dynamicTag) { return null; } const children: AST[] = []; if (tagName === "pre") { - ctx = { inPreTag: true }; + ctx.inPreTag = true; } + const shouldAddSVGNS = tagName === "svg" || (tagName === "g" && !ctx.inSVG); + ctx.inSVG = ctx.inSVG || shouldAddSVGNS; + const ns = shouldAddSVGNS ? "http://www.w3.org/2000/svg" : null; const ref = node.getAttribute("t-ref"); node.removeAttribute("t-ref"); @@ -381,6 +383,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null { ref, content: children, model, + ns, }; } diff --git a/tests/blockdom/namespace.test.ts b/tests/blockdom/namespace.test.ts new file mode 100644 index 00000000..4d2a7d4f --- /dev/null +++ b/tests/blockdom/namespace.test.ts @@ -0,0 +1,72 @@ +import { createBlock, mount } from "../../src/blockdom"; +import { makeTestFixture } from "./helpers"; + +//------------------------------------------------------------------------------ +// Setup and helpers +//------------------------------------------------------------------------------ + +const XHTML_URI = "http://www.w3.org/1999/xhtml"; +const SVG_URI = "http://www.w3.org/2000/svg"; +let fixture: HTMLElement; + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +afterEach(() => { + fixture.remove(); +}); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe("namespace", () => { + test("default namespace is xhtml", () => { + const block = createBlock(``); + const tree = block(); + mount(tree, fixture); + expect(fixture.innerHTML).toBe(""); + expect(fixture.firstElementChild!.namespaceURI).toBe(XHTML_URI); + }); + + test("namespace can be changed with block-ns", () => { + const block = createBlock(``); + const tree = block(); + mount(tree, fixture); + expect(fixture.innerHTML).toBe(""); + expect(fixture.firstElementChild!.namespaceURI).toBe(SVG_URI); + }); + + test("namespace is kept for children", () => { + const block = createBlock( + `` + ); + const tree = block(); + mount(tree, fixture); + expect(fixture.innerHTML).toBe( + "" + ); + const parent = fixture.firstElementChild!; + const child1 = parent.firstElementChild!; + const subchild = child1.firstElementChild!; + const child2 = child1.nextElementSibling!; + expect(parent.namespaceURI).toBe(SVG_URI); + expect(child1.namespaceURI).toBe(SVG_URI); + expect(child2.namespaceURI).toBe(SVG_URI); + expect(subchild.namespaceURI).toBe(SVG_URI); + }); + + test("various namespaces in same block", () => { + const block = createBlock(``); + const tree = block(); + mount(tree, fixture); + expect(fixture.innerHTML).toBe(""); + const none = fixture.firstElementChild!; + const one = none.firstElementChild!; + const two = one.nextElementSibling!; + expect(none.namespaceURI).toBe(XHTML_URI); + expect(one.namespaceURI).toBe("one"); + expect(two.namespaceURI).toBe("two"); + }); +}); diff --git a/tests/compiler/__snapshots__/svg.test.ts.snap b/tests/compiler/__snapshots__/svg.test.ts.snap new file mode 100644 index 00000000..852b23e4 --- /dev/null +++ b/tests/compiler/__snapshots__/svg.test.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`properly support svg add proper namespace to g tags 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\` \`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`properly support svg add proper namespace to svg 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\` \`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`properly support svg namespace to g tags not added if already in svg namespace 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`properly support svg namespace to svg tags added even if already in svg namespace 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; diff --git a/tests/compiler/parser.test.ts b/tests/compiler/parser.test.ts index e32f805e..e2cee68a 100644 --- a/tests/compiler/parser.test.ts +++ b/tests/compiler/parser.test.ts @@ -46,6 +46,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, }); }); @@ -73,6 +74,7 @@ describe("qweb parser", () => { ref: null, model: null, content: [], + ns: null, }); }); @@ -86,6 +88,7 @@ describe("qweb parser", () => { ref: null, model: null, content: [{ type: ASTType.Text, value: "some text" }], + ns: null, }); }); @@ -98,6 +101,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.Text, value: "some text" }, { @@ -108,6 +112,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "inside" }], }, ], @@ -126,6 +131,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [], }, { @@ -136,6 +142,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [], }, ], @@ -155,6 +162,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [], }, ], @@ -170,10 +178,83 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "foo" }], }); }); + test("svg dom node", async () => { + expect( + parse( + `` + ) + ).toEqual({ + attrs: { + height: "90px", + width: "100px", + }, + content: [ + { + attrs: { + cx: "50", + cy: "50", + fill: "yellow", + r: "4", + stroke: "green", + "stroke-width": "1", + }, + content: [], + dynamicTag: null, + model: null, + ns: null, + on: {}, + ref: null, + tag: "circle", + type: 2, + }, + ], + dynamicTag: null, + model: null, + ns: "http://www.w3.org/2000/svg", + on: {}, + ref: null, + tag: "svg", + type: 2, + }); + expect( + parse(``) + ).toEqual({ + attrs: {}, + content: [ + { + attrs: { + cx: "50", + cy: "50", + fill: "yellow", + r: "4", + stroke: "green", + "stroke-width": "1", + }, + content: [], + dynamicTag: null, + model: null, + ns: null, + on: {}, + ref: null, + tag: "circle", + type: 2, + }, + ], + dynamicTag: null, + model: null, + ns: "http://www.w3.org/2000/svg", + on: {}, + ref: null, + tag: "g", + type: 2, + }); + }); + // --------------------------------------------------------------------------- // t-esc // --------------------------------------------------------------------------- @@ -200,6 +281,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "text", defaultValue: "" }], }); }); @@ -221,6 +303,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "text", defaultValue: "hey" }], }); }); @@ -262,6 +345,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TOut, expr: "text", body: null }], }); }); @@ -275,6 +359,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.TOut, expr: "text", body: [{ type: ASTType.Text, value: "body" }] }, ], @@ -294,6 +379,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.TIf, @@ -321,6 +407,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "hey" }], }, tElif: null, @@ -397,6 +484,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.Text, @@ -415,6 +503,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "elif" }], }, }, @@ -427,6 +516,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.Text, @@ -474,6 +564,7 @@ describe("qweb parser", () => { dynamicTag: null, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "ok" }], }, ], @@ -493,6 +584,7 @@ describe("qweb parser", () => { dynamicTag: null, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "ok" }], }, { type: ASTType.Text, value: "abc" }, @@ -527,6 +619,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.TIf, @@ -601,6 +694,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }], }, memo: "", @@ -646,6 +740,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }], }, memo: "", @@ -681,6 +776,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }], }, }, @@ -715,6 +811,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "category.name", defaultValue: "" }], }, memo: "", @@ -736,6 +833,7 @@ describe("qweb parser", () => { model: null, tag: "div", dynamicTag: null, + ns: null, content: [ { type: ASTType.TForEach, @@ -781,6 +879,7 @@ describe("qweb parser", () => { ref: null, model: null, attrs: {}, + ns: null, content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }], }, memo: "", @@ -888,6 +987,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [ { type: ASTType.TCall, @@ -925,6 +1025,7 @@ describe("qweb parser", () => { on: { click: "add" }, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "Click" }], }); }); @@ -1003,6 +1104,7 @@ describe("qweb parser", () => { ref: null, model: null, on: {}, + ns: null, }, { type: ASTType.DomNode, @@ -1013,6 +1115,7 @@ describe("qweb parser", () => { ref: null, model: null, on: {}, + ns: null, }, ], }, @@ -1203,6 +1306,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "hey" }], }, }); @@ -1220,6 +1324,7 @@ describe("qweb parser", () => { on: {}, ref: null, model: null, + ns: null, content: [{ type: ASTType.Text, value: "hey" }], }, }); @@ -1238,6 +1343,7 @@ describe("qweb parser", () => { on: {}, ref: "name", model: null, + ns: null, content: [{ type: ASTType.Text, value: "hey" }], }); }); @@ -1251,6 +1357,7 @@ describe("qweb parser", () => { on: {}, ref: "name", model: null, + ns: null, content: [ { type: ASTType.TOut, expr: "text", body: [{ type: ASTType.Text, value: "body" }] }, ], @@ -1266,6 +1373,7 @@ describe("qweb parser", () => { on: {}, ref: "name", model: null, + ns: null, content: [{ type: ASTType.TEsc, expr: "text", defaultValue: "body" }], }); }); @@ -1309,7 +1417,8 @@ describe("qweb parser", () => { model: null, tag: "div", dynamicTag: null, - type: 2, + type: ASTType.DomNode, + ns: null, }, type: 16, }, @@ -1339,6 +1448,7 @@ describe("qweb parser", () => { ref: null, tag: "input", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1357,6 +1467,7 @@ describe("qweb parser", () => { ref: null, tag: "input", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1375,6 +1486,7 @@ describe("qweb parser", () => { ref: null, tag: "input", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1394,6 +1506,7 @@ describe("qweb parser", () => { ref: null, tag: "textarea", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1412,6 +1525,7 @@ describe("qweb parser", () => { ref: null, tag: "input", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1430,6 +1544,7 @@ describe("qweb parser", () => { ref: null, tag: "input", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1448,6 +1563,7 @@ describe("qweb parser", () => { ref: null, tag: "input", dynamicTag: null, + ns: null, model: { baseExpr: "state", expr: "'stuff'", @@ -1472,6 +1588,7 @@ describe("qweb parser", () => { tag: "div", dynamicTag: "theTag", model: null, + ns: null, }); }); }); diff --git a/tests/compiler/svg.test.ts b/tests/compiler/svg.test.ts index a3113efe..b4d1fce0 100644 --- a/tests/compiler/svg.test.ts +++ b/tests/compiler/svg.test.ts @@ -1,22 +1,30 @@ +import { renderToString, snapshotEverything } from "../helpers"; + +// NB: check the snapshots to see where the SVG namespaces are added +snapshotEverything(); + describe("properly support svg", () => { - test.skip("add proper namespace to svg", () => { - // qweb.addTemplate( - // "test", - // ` ` - // ); - // expect(renderToString(qweb, "test")).toBe( - // ` ` - // ); + test("add proper namespace to svg", () => { + const template = ` `; + expect(renderToString(template)).toBe( + ` ` + ); }); - test.skip("add proper namespace to g tags", () => { - // this is necessary if one wants to use components in a svg - // qweb.addTemplate( - // "test", - // ` ` - // ); - // expect(renderToString(qweb, "test")).toBe( - // ` ` - // ); + test("add proper namespace to g tags", () => { + const template = ` `; + expect(renderToString(template)).toBe( + ` ` + ); + }); + + test("namespace to g tags not added if already in svg namespace", () => { + const template = ``; + expect(renderToString(template)).toBe(``); + }); + + test("namespace to svg tags added even if already in svg namespace", () => { + const template = ``; + expect(renderToString(template)).toBe(``); }); }); diff --git a/tests/components/__snapshots__/basics.test.ts.snap b/tests/components/__snapshots__/basics.test.ts.snap index 94ce15bf..41d5ff4b 100644 --- a/tests/components/__snapshots__/basics.test.ts.snap +++ b/tests/components/__snapshots__/basics.test.ts.snap @@ -1192,7 +1192,7 @@ exports[`support svg components add proper namespace to svg 1`] = ` let { text, createBlock, list, multi, html, toggler, component } = bdom; let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers; - let block1 = createBlock(\`\`); + let block1 = createBlock(\`\`); return function template(ctx, node, key = \\"\\") { return block1(); @@ -1206,7 +1206,7 @@ exports[`support svg components add proper namespace to svg 2`] = ` let { text, createBlock, list, multi, html, toggler, component } = bdom; let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber, safeOutput } = helpers; - let block1 = createBlock(\`\`); + let block1 = createBlock(\`\`); return function template(ctx, node, key = \\"\\") { let b2 = component(\`GComp\`, {}, key + \`__1\`, node, ctx);