From 3a4d1ef1ba53364f97bdae225703bd9fd631c092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 17 Jan 2019 18:08:39 +0100 Subject: [PATCH] basic qweb vdom implementation --- README.md | 11 +- demo/src/env.ts | 2 +- demo/src/main.ts | 27 +- package.json | 5 +- src/core/qweb.ts | 13 + src/core/qweb_vdom.ts | 620 ++++++++++++++++++++++++++++++++++++++++ src/core/widget.ts | 32 ++- src/prout.d.ts | 1 + tests/qweb_vdom.test.ts | 520 +++++++++++++++++++++++++++++++++ 9 files changed, 1210 insertions(+), 21 deletions(-) create mode 100644 src/core/qweb_vdom.ts create mode 100644 src/prout.d.ts create mode 100644 tests/qweb_vdom.test.ts diff --git a/README.md b/README.md index 67cae907..a35ebe84 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,13 @@ npm run demo:serve dist/demo - *watch* recompile the typescript files as soon as they are changed - *serve* starts a live-server pointing to the dist/demo (which will reload the - page whenever the files are changed) \ No newline at end of file + page whenever the files are changed) + +## Notes + +Before even thinking about using this in a real scenario: + +- check qweb tests and see if it is reasonable +- Note: the compilation of a template should have a unique node (but sub templates + can have multiple roots) +- remove the "if (${exprID} || ${exprID} === 0) {" \ No newline at end of file diff --git a/demo/src/env.ts b/demo/src/env.ts index 9702a495..ebbf8e16 100644 --- a/demo/src/env.ts +++ b/demo/src/env.ts @@ -1,5 +1,5 @@ import { Env } from "../../src/core/widget"; -import QWeb from "../../src/core/qweb"; +import QWeb from "../../src/core/qweb_vdom"; const qweb = new QWeb(); diff --git a/demo/src/main.ts b/demo/src/main.ts index e9bc27b9..ddce4218 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,11 +1,22 @@ /// -import RootWidget from "./RootWidget"; -import env from "./env"; +import QWeb from "../../src/core/qweb_vdom"; +import {init} from "../../src/libs/snabbdom/src/snabbdom" +import h from "../../src/libs/snabbdom/src/h" +import sdProps from "../../src/libs/snabbdom/src/modules/props" +import sdListeners from "../../src/libs/snabbdom/src/modules/eventlisteners" -document.addEventListener("DOMContentLoaded", async function() { - const rootWidget = new RootWidget(null); - rootWidget.setEnvironment(env); - const mainDiv = document.getElementById("app")!; - await rootWidget.mount(mainDiv); -}); +const patch = init([sdProps, sdListeners]); + +(window).h = h; +(window).patch = patch; +(window).QWeb = QWeb; +// import RootWidget from "./RootWidget"; +// import env from "./env"; + +// document.addEventListener("DOMContentLoaded", async function() { +// const rootWidget = new RootWidget(null); +// rootWidget.setEnvironment(env); +// const mainDiv = document.getElementById("app")!; +// await rootWidget.mount(mainDiv); +// }); diff --git a/package.json b/package.json index faf33f8a..8761f0a2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "bundle:core": "tsc -m es6 --lib es2017,dom --target esnext --outdir dist src/bundles/core.ts && rollup dist/bundles/core.js --file dist/bundle-core.js --format iife", "bundle:core:watch": "npm run bundle:core -- --watch", "demo:build": "cp -r demo/ dist/demo && npm run demo:build:js", - "demo:build:js": "tsc -m amd --lib es2017,dom --target esnext --outfile dist/demo/main.js demo/src/main.ts", + "demo:build:js": "tsc --allowjs -m amd --lib es2017,dom --target esnext --outfile dist/demo/main.js demo/src/main.ts", "predemo:watch": "npm run demo:build", "demo:watch": "npm run demo:build:js -- --watch", "demo:serve": "live-server dist/demo/" @@ -27,11 +27,12 @@ "devDependencies": { "@types/jest": "^23.3.12", "jest": "^23.6.0", + "live-server": "^1.2.1", "rollup": "^1.1.0", "rollup-plugin-typescript2": "^0.19.0", + "snabbdom-to-html": "^5.1.1", "source-map-support": "^0.5.10", "ts-jest": "^23.10.5", - "live-server": "^1.2.1", "typescript": "^3.2.2" }, "dependencies": { diff --git a/src/core/qweb.ts b/src/core/qweb.ts index e29e3b6b..92c6f1b4 100644 --- a/src/core/qweb.ts +++ b/src/core/qweb.ts @@ -1,6 +1,19 @@ import { escape } from "./utils"; import directives from "./qweb_directives"; import { Directive } from "./qweb_directives"; +import {init} from "../libs/snabbdom/src/snabbdom" +import sdProps from "../libs/snabbdom/src/modules/props" +import sdListeners from "../libs/snabbdom/src/modules/eventlisteners" +import h from "../libs/snabbdom/src/h" +// const init = require("../libs/snabbdom/dist/snabbdom.js"); +// import {init} from '../libs/snabbdom/dist/snabbdom.js'; + +const patch = init([sdProps, sdListeners]); +(window).h = h; +(window).patch = patch; + + +// console.log(init); type RawTemplate = string; type Template = (context: any) => DocumentFragment; diff --git a/src/core/qweb_vdom.ts b/src/core/qweb_vdom.ts new file mode 100644 index 00000000..cdc5686f --- /dev/null +++ b/src/core/qweb_vdom.ts @@ -0,0 +1,620 @@ +import { VNode } from "../libs/snabbdom/src/vnode"; +import h from "../libs/snabbdom/src/h"; + +export type EvalContext = { [key: string]: any }; +type RawTemplate = string; +type ParsedTemplate = Document; +type CompiledTemplate = (context: EvalContext) => VNode; +const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date".split( + "," +); + +// Compilation Context +export class Context { + nextID: number = 1; + code: string[] = []; + variables: { [key: string]: any } = {}; + escaping: boolean = false; + parentNode: number | null = null; + rootNode: number | null = null; + indentLevel: number = 0; + rootContext: Context; + caller: Element | undefined; + + constructor() { + this.rootContext = this; + this.addLine("let h = this.h"); + } + + generateID(): number { + const id = this.rootContext.nextID++; + return id; + } + + withParent(node: number): Context { + const newContext: Context = Object.create(this); + newContext.parentNode = node; + if (!this.rootContext.rootNode) { + this.rootContext.rootNode = node; + } + return newContext; + } + + withVariables(variables: { [key: string]: any }) { + const newContext = Object.create(this); + newContext.variables = Object.create(variables); + return newContext; + } + + withCaller(node: Element): Context { + const newContext = Object.create(this); + newContext.caller = node; + return newContext; + } + + withEscaping(): Context { + const newContext = Object.create(this); + newContext.escaping = true; + return newContext; + } + + indent() { + this.indentLevel++; + } + + dedent() { + this.indentLevel--; + } + + addLine(line: string) { + const prefix = new Array(this.indentLevel).join("\t"); + const lastChar = line[line.length - 1]; + const suffix = lastChar !== "}" && lastChar !== "{" ? ";" : ""; + this.code.push(prefix + line + suffix); + } + + getValue(val: any): any { + return val in this.variables ? this.getValue(this.variables[val]) : val; + } +} + +export default class QWeb { + rawTemplates: { [name: string]: RawTemplate } = {}; + parsedTemplates: { [name: string]: ParsedTemplate } = {}; + templates: { [name: string]: CompiledTemplate } = {}; + h = h; + exprCache: { [key: string]: string } = {}; + directives: Directive[] = []; + + constructor() { + [ + forEachDirective, + escDirective, + rawDirective, + setDirective, + elseDirective, + elifDirective, + ifDirective, + callDirective + ].forEach(d => this.addDirective(d)); + } + + addDirective(dir: Directive) { + this.directives.push(dir); + this.directives.sort((d1, d2) => d1.priority - d2.priority); + } + + /** + * Add a template to the internal template map. Note that it is not + * immediately compiled. + */ + addTemplate(name: string, template: RawTemplate) { + if (name in this.rawTemplates) { + return; + } + this.rawTemplates[name] = template; + const parser = new DOMParser(); + const doc = parser.parseFromString(template, "text/xml"); + if (!doc.firstChild) { + throw new Error("Invalid template (should not be empty)"); + } + if (doc.firstChild.nodeName === "parsererror") { + throw new Error("Invalid XML in template"); + } + let tbranch = doc.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 = function(name) { + return prevElem.getAttribute(name); + }; + let nattr = function(name) { + return +!!node.getAttribute(name); + }; + if (prevElem && (pattr("t-if") || pattr("t-elif"))) { + if (pattr("t-foreach")) { + throw new Error( + "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 Error( + "Only one conditional branching directive is allowed per node" + ); + } + // All text nodes between branch nodes are removed + let textNode; + while ((textNode = node.previousSibling) !== prevElem) { + if (textNode.nodeValue.trim().length) { + throw new Error("text is not allowed between branching directives"); + } + textNode.remove(); + } + } else { + throw new Error( + "t-elif and t-else directives must be preceded by a t-if or t-elif directive" + ); + } + } + + this.parsedTemplates[name] = doc; + } + + /** + * Render a template + * + * @param {string} name the template should already have been added + */ + render(name: string, context: any = {}): VNode { + const template = this.templates[name] || this._compile(name); + return template(context); + } + + _compile(name: string): CompiledTemplate { + if (name in this.templates) { + return this.templates[name]; + } + + const doc = this.parsedTemplates[name]; + + let ctx = new Context(); + + const mainNode = doc.firstChild!; + this._compileNode(mainNode, ctx); + + if (!ctx.rootNode) { + throw new Error("A template should have one root node"); + } + ctx.addLine(`return vn${ctx.rootNode}`); + const functionCode = ctx.code.join("\n"); + if ((mainNode).attributes.hasOwnProperty("t-debug")) { + console.log( + `Template: ${this.rawTemplates[name]}\nCompiled code:\n` + functionCode + ); + } + const template: CompiledTemplate = (new Function( + "context", + functionCode + ) as CompiledTemplate).bind(this); + this.templates[name] = template; + return template; + } + + /** + * Generate code from an xml node + * + */ + _compileNode(node: ChildNode, ctx: Context) { + if (!(node instanceof Element)) { + // this is a text node, there are no directive to apply + let text = node.textContent!; + ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`})`); + return; + } + + const attributes = (node).attributes; + + const validDirectives: { + directive: Directive; + value: string; + fullName: string; + }[] = []; + + for (let directive of this.directives) { + // const value = attributes[i].textContent!; + let fullName; + let value; + for (let i = 0; i < attributes.length; i++) { + const name = attributes[i].name; + if ( + name === "t-" + directive.name || + name.startsWith("t-" + directive.name + "-") + ) { + fullName = name; + value = attributes[i].textContent; + } + } + if (fullName) { + validDirectives.push({ directive, value, fullName }); + } + } + + for (let { directive, value, fullName } of validDirectives) { + if (directive.atNodeEncounter) { + const isDone = directive.atNodeEncounter({ + node, + qweb: this, + ctx, + fullName, + value + }); + if (isDone) { + return; + } + } + } + + if (node.nodeName !== "t") { + let nodeID = this._compileGenericNode(node, ctx); + ctx = ctx.withParent(nodeID); + + for (let { directive, value, fullName } of validDirectives) { + if (directive.atNodeCreation) { + directive.atNodeCreation({ + node, + qweb: this, + ctx, + fullName, + value, + nodeID + }); + } + } + } + + this._compileChildren(node, ctx); + + for (let { directive, value, fullName } of validDirectives) { + if (directive.finalize) { + directive.finalize({ node, qweb: this, ctx, fullName, value }); + } + } + } + + _compileGenericNode(node: ChildNode, ctx: Context): number { + // nodeType 1 is generic tag + if (node.nodeType !== 1) { + throw new Error("unsupported node type"); + } + const attributes = (node).attributes; + const attrs: string[] = []; + const tattrs: number[] = []; + for (let i = 0; i < attributes.length; i++) { + const name = attributes[i].name; + const value = attributes[i].textContent!; + + // regular attributes + if (!name.startsWith("t-")) { + const attID = ctx.generateID(); + ctx.addLine(`let _${attID} = '${value}'`); + attrs.push(`${name}: _${attID}`); + } + + // dynamic attributes + if (name.startsWith("t-att-")) { + const attName = name.slice(6); + const formattedValue = this._formatExpression(value!); + const attID = ctx.generateID(); + ctx.addLine(`let _${attID} = ${formattedValue}`); + attrs.push(`${attName}: _${attID}`); + } + + if (name.startsWith("t-attf-")) { + const attName = name.slice(7); + const formattedExpr = value!.replace( + /\{\{.*?\}\}/g, + s => "${" + this._formatExpression(s.slice(2, -2)) + "}" + ); + const attID = ctx.generateID(); + ctx.addLine(`let _${attID} = \`${formattedExpr}\``); + attrs.push(`${attName}: _${attID}`); + } + + // t-att= attributes + if (name === "t-att") { + let id = ctx.generateID(); + ctx.addLine(`let _${id} = ${this._formatExpression(value!)}`); + tattrs.push(id); + } + } + let nodeID = ctx.generateID(); + let p = + attrs.length + tattrs.length > 0 ? `{attrs:{${attrs.join(",")}}}` : "{}"; + ctx.addLine(`let c${nodeID} = [], p${nodeID} = ${p}`); + + for (let id of tattrs) { + ctx.addLine(`if (_${id} instanceof Array) {`); + ctx.indent(); + ctx.addLine(`p${nodeID}.attrs[_${id}[0]] = _${id}[1]`); + ctx.dedent(); + ctx.addLine(`} else {`); + ctx.indent(); + ctx.addLine(`for (let key in _${id}) {`); + ctx.indent(); + ctx.addLine(`p${nodeID}.attrs[key] = _${id}[key]`); + ctx.dedent(); + ctx.addLine(`}`); + ctx.dedent(); + ctx.addLine(`}`); + } + ctx.addLine(` + let vn${nodeID} = h('${node.nodeName}', p${nodeID}, c${nodeID})`); + if (ctx.parentNode) { + ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID})`); + } + + return nodeID; + } + + _compileChildren(node: ChildNode, ctx: Context) { + if (node.childNodes.length > 0) { + for (let child of Array.from(node.childNodes)) { + this._compileNode(child, ctx); + } + } + } + + _formatExpression(e: string): string { + if (e in this.exprCache) { + return this.exprCache[e]; + } + // Thanks CHM for this code... + const chars = e.split(""); + let instring = ""; + let invar = ""; + let invarPos = 0; + let r = ""; + chars.push(" "); + for (var i = 0, ilen = chars.length; i < ilen; i++) { + var c = chars[i]; + if (instring.length) { + if (c === instring && chars[i - 1] !== "\\") { + instring = ""; + } + } else if (c === '"' || c === "'") { + instring = c; + } else if (c.match(/[a-zA-Z_\$]/) && !invar.length) { + invar = c; + invarPos = i; + continue; + } else if (c.match(/\W/) && invar.length) { + // TODO: Should check for possible spaces before dot + if (chars[invarPos - 1] !== "." && RESERVED_WORDS.indexOf(invar) < 0) { + invar = "context['" + invar + "']"; + } + r += invar; + invar = ""; + } else if (invar.length) { + invar += c; + continue; + } + r += c; + } + const result = r.slice(0, -1); + this.exprCache[e] = result; + return result; + } +} + +//------------------------------------------------------------------------------ +// QWeb Directives +//------------------------------------------------------------------------------ +interface CompilationInfo { + nodeID?: number; + node: Element; + qweb: QWeb; + ctx: Context; + fullName: string; + value: string; +} + +export interface Directive { + name: string; + priority: number; + // if return true, then directive is fully applied and there is no need to + // keep processing node. Otherwise, we keep going. + atNodeEncounter?(info: CompilationInfo): boolean; + atNodeCreation?(info: CompilationInfo); + finalize?(info: CompilationInfo); +} + +function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) { + if (value === "0" && ctx.caller) { + qweb._compileNode(ctx.caller, ctx); + return; + } + + if (typeof value === "string") { + const exprID = ctx.generateID(); + ctx.addLine(`let e${exprID} = ${qweb._formatExpression(value)}`); + ctx.addLine(`if (e${exprID} || e${exprID} === 0) {`); + ctx.indent(); + let text = `e${exprID}`; + + if (!ctx.parentNode) { + throw new Error("Should not have a text node without a parent"); + } + if (ctx.escaping) { + ctx.addLine(`c${ctx.parentNode}.push({text: ${text}})`); + } else { + ctx.addLine(`p${ctx.parentNode}.hook = { + create: (_, n) => n.elm.innerHTML = e${exprID}, + update: (_, n) => n.elm.innerHTML = e${exprID}, + }`); // p${ctx.parentNode}.elm.innerHTML = e${exprID} + } + ctx.dedent(); + if (node.childNodes.length) { + ctx.addLine("} else {"); + ctx.indent(); + qweb._compileChildren(node, ctx); + ctx.dedent(); + } + ctx.addLine("}"); + return; + } + if (value instanceof NodeList) { + for (let node of Array.from(value)) { + qweb._compileNode(node, ctx); + } + } +} + +const escDirective: Directive = { + name: "esc", + priority: 70, + atNodeEncounter({ node, qweb, ctx }): boolean { + if (node.nodeName !== "t") { + let nodeID = qweb._compileGenericNode(node, ctx); + ctx = ctx.withParent(nodeID); + } + let value = ctx.getValue(node.getAttribute("t-esc")!); + compileValueNode(value, node, qweb, ctx.withEscaping()); + return true; + } +}; + +const rawDirective: Directive = { + name: "raw", + priority: 80, + atNodeEncounter({ node, qweb, ctx }): boolean { + if (node.nodeName !== "t") { + let nodeID = qweb._compileGenericNode(node, ctx); + ctx = ctx.withParent(nodeID); + } + let value = ctx.getValue(node.getAttribute("t-raw")!); + compileValueNode(value, node, qweb, ctx); + return true; + } +}; + +const setDirective: Directive = { + name: "set", + priority: 60, + atNodeEncounter({ node, ctx }): boolean { + const variable = node.getAttribute("t-set")!; + let value = node.getAttribute("t-value")!; + if (value) { + ctx.variables[variable] = value; + } else { + ctx.variables[variable] = node.childNodes; + } + return true; + } +}; + +const ifDirective: Directive = { + name: "if", + priority: 20, + atNodeEncounter({ node, qweb, ctx }): boolean { + let cond = ctx.getValue(node.getAttribute("t-if")!); + ctx.addLine(`if (${qweb._formatExpression(cond)}) {`); + ctx.indent(); + return false; + }, + finalize({ ctx }) { + ctx.dedent(); + ctx.addLine(`}`); + } +}; + +const elifDirective: Directive = { + name: "elif", + priority: 30, + atNodeEncounter({ node, qweb, ctx }): boolean { + let cond = ctx.getValue(node.getAttribute("t-elif")!); + ctx.addLine(`else if (${qweb._formatExpression(cond)}) {`); + ctx.indent(); + return false; + }, + finalize({ ctx }) { + ctx.dedent(); + ctx.addLine(`}`); + } +}; + +const elseDirective: Directive = { + name: "else", + priority: 40, + atNodeEncounter({ ctx }): boolean { + ctx.addLine(`else {`); + ctx.indent(); + return false; + }, + finalize({ ctx }) { + ctx.dedent(); + ctx.addLine(`}`); + } +}; + +const callDirective: Directive = { + name: "call", + priority: 50, + atNodeEncounter({ node, qweb, ctx }): boolean { + if (node.nodeName !== "t") { + throw new Error("Invalid tag for t-call directive (should be 't')"); + } + const subTemplate = node.getAttribute("t-call")!; + const nodeTemplate = qweb.parsedTemplates[subTemplate]; + const nodeCopy = node.cloneNode(true); + nodeCopy.removeAttribute("t-call"); + + // extract variables from nodecopy + const tempCtx = new Context(); + qweb._compileNode(nodeCopy, tempCtx); + const vars = Object.assign({}, ctx.variables, tempCtx.variables); + const subCtx = ctx.withCaller(nodeCopy).withVariables(vars); + + qweb._compileNode(nodeTemplate.firstChild!, subCtx); + return true; + } +}; + +const forEachDirective: Directive = { + name: "foreach", + priority: 10, + atNodeEncounter({ node, qweb, ctx }): boolean { + const elems = node.getAttribute("t-foreach")!; + const name = node.getAttribute("t-as")!; + let arrayID = ctx.generateID(); + ctx.addLine(`let _${arrayID} = ${qweb._formatExpression(elems)}`); + ctx.addLine( + `if (typeof _${arrayID} === 'number') { _${arrayID} = Array.from(Array(_${arrayID}).keys())}` + ); + let keysID = ctx.generateID(); + ctx.addLine( + `let _${keysID} = _${arrayID} instanceof Array ? _${arrayID} : Object.keys(_${arrayID})` + ); + let valuesID = ctx.generateID(); + ctx.addLine( + `let _${valuesID} = _${arrayID} instanceof Array ? _${arrayID} : Object.values(_${arrayID})` + ); + ctx.addLine(`for (let i = 0; i < _${keysID}.length; i++) {`); + ctx.indent(); + ctx.addLine(`context.${name}_first = i === 0`); + ctx.addLine(`context.${name}_last = i === _${keysID}.length - 1`); + ctx.addLine(`context.${name}_parity = i % 2 === 0 ? 'even' : 'odd'`); + ctx.addLine(`context.${name}_index = i`); + ctx.addLine(`context.${name} = _${keysID}[i]`); + ctx.addLine(`context.${name}_value = _${valuesID}[i]`); + const nodeCopy = node.cloneNode(true); + nodeCopy.removeAttribute("t-foreach"); + qweb._compileNode(nodeCopy, ctx); + ctx.dedent(); + ctx.addLine("}"); + return true; + } +}; \ No newline at end of file diff --git a/src/core/widget.ts b/src/core/widget.ts index 5f017267..879ff3c9 100644 --- a/src/core/widget.ts +++ b/src/core/widget.ts @@ -1,4 +1,10 @@ -import QWeb from "./qweb"; +import QWeb from "./qweb_vdom"; + +// import {init} from "../libs/snabbdom/src/snabbdom" +// import sdProps from "../libs/snabbdom/src/modules/props" +// import sdListeners from "../libs/snabbdom/src/modules/eventlisteners" + +// const patch = init([sdProps, sdListeners]); export interface Env { qweb: QWeb; @@ -60,6 +66,12 @@ export default class Widget { this.env = Object.create(env); } + /** + * DOCSTRIGN + * + * @param {Object} newState + * @memberof Widget + */ async updateState(newState: Object) { Object.assign(this.state, newState); await this.render(); @@ -70,14 +82,16 @@ export default class Widget { //-------------------------------------------------------------------------- async render() { - const fragment = await this.env!.qweb.render(this.name, this); - this._setElement(fragment.firstChild!); + // const vnode = await this.env!.qweb.render(this.name, this); + // patch(this.el, vnode); + + // this._setElement(fragment.firstChild!); } - private _setElement(el: ChildNode) { - if (this.el) { - this.el.replaceWith(el); - } - this.el = el; - } + // private _setElement(el: ChildNode) { + // if (this.el) { + // this.el.replaceWith(el); + // } + // this.el = el; + // } } diff --git a/src/prout.d.ts b/src/prout.d.ts new file mode 100644 index 00000000..39b91804 --- /dev/null +++ b/src/prout.d.ts @@ -0,0 +1 @@ +declare module "snabbdom-to-html" diff --git a/tests/qweb_vdom.test.ts b/tests/qweb_vdom.test.ts new file mode 100644 index 00000000..f7207bc2 --- /dev/null +++ b/tests/qweb_vdom.test.ts @@ -0,0 +1,520 @@ +import QWeb, { EvalContext } from "../src/core/qweb_vdom"; +import { init } from "../src/libs/snabbdom/src/snabbdom"; +import sdAttributes from "../src/libs/snabbdom/src/modules/attributes"; +import sdListeners from "../src/libs/snabbdom/src/modules/eventlisteners"; + +const patch = init([sdAttributes, sdListeners]); + +function qwebRender(qweb: QWeb, t: string, context: EvalContext = {}): string { + const vnode = qweb.render(t, context); + const node = document.createElement(vnode.sel!); + patch(node, vnode); + return node.outerHTML; +} + +function renderToString(t: string, context: EvalContext = {}): string { + const qweb = new QWeb(); + qweb.addTemplate("test", t); + return qwebRender(qweb, "test", context); +} + +describe("static templates", () => { + test("empty div", () => { + const template = "
"; + const expected = template; + expect(renderToString(template)).toBe(expected); + }); + + test("div with a text node", () => { + const template = "
word
"; + const result = renderToString(template); + expect(result).toBe(template); + }); + + test("div with a span child node", () => { + const template = "
word
"; + const result = renderToString(template); + expect(result).toBe(template); + }); +}); + +describe("error handling", () => { + test("invalid xml", () => { + const qweb = new QWeb(); + + expect(() => qweb.addTemplate("test", "
")).toThrow( + "Invalid XML in template" + ); + }); + + test("template with only text node", () => { + const template = `text`; + + expect(() => renderToString(template)).toThrow( + "A template should have one root node" + ); + }); +}); + +describe("t-esc", () => { + test("literal", () => { + const template = ``; + const result = renderToString(template); + expect(result).toBe("ok"); + }); + + test("variable", () => { + const template = ``; + const result = renderToString(template, { var: "ok" }); + expect(result).toBe("ok"); + }); + + test.skip("escaping", () => { + const template = ``; + const result = renderToString(template, { var: "" }); + expect(result).toBe("<ok>"); + }); + + test("escaping on a node", () => { + const template = ``; + const result = renderToString(template); + expect(result).toBe("ok"); + }); + + test("escaping on a node with a body", () => { + const template = `nope`; + const result = renderToString(template); + expect(result).toBe("ok"); + }); + + test("escaping on a node with a body, as a default", () => { + const template = `nope`; + const result = renderToString(template); + expect(result).toBe("nope"); + }); +}); + +describe("t-raw", () => { + test("literal", () => { + const template = ``; + const result = renderToString(template); + expect(result).toBe("ok"); + }); + + test("variable", () => { + const template = ``; + const result = renderToString(template, { var: "ok" }); + expect(result).toBe("ok"); + }); + + test("not escaping", () => { + const template = `
`; + const result = renderToString(template, { var: "" }); + expect(result).toBe("
"); + }); +}); + +describe("t-set", () => { + test("set from attribute literal", () => { + const template = `
`; + const result = renderToString(template); + expect(result).toBe("
ok
"); + }); + + test("set from body literal", () => { + const template = `
ok
`; + const result = renderToString(template); + expect(result).toBe("
ok
"); + }); + + test("set from attribute lookup", () => { + const template = `
`; + const result = renderToString(template, { value: "ok" }); + expect(result).toBe("
ok
"); + }); + + test("set from body lookup", () => { + const template = `
`; + const result = renderToString(template, { value: "ok" }); + expect(result).toBe("
ok
"); + }); + + test("set from empty body", () => { + const template = `
`; + const result = renderToString(template); + expect(result).toBe("
"); + }); + + test("value priority", () => { + const template = `
2
`; + const result = renderToString(template); + expect(result).toBe("
1
"); + }); + + test("evaluate value expression", () => { + const template = `
`; + const result = renderToString(template); + expect(result).toBe("
3
"); + }); + + test("evaluate value expression, part 2", () => { + const template = `
`; + const result = renderToString(template, { somevariable: 43 }); + expect(result).toBe("
45
"); + }); +}); + +describe("t-if", () => { + test("boolean value true condition", () => { + const template = `
ok
`; + const result = renderToString(template, { condition: true }); + expect(result).toBe("
ok
"); + }); + + test("boolean value false condition", () => { + const template = `
fail
`; + const result = renderToString(template, { condition: false }); + expect(result).toBe("
"); + }); + + test("boolean value condition missing", () => { + const template = `fail`; + const result = renderToString(template); + expect(result).toBe(""); + }); + + test("boolean value condition elif", () => { + const template = ` +
black pearl + yellow submarine + red is dead + beer
+ `; + const result = renderToString(template, { color: "red" }); + expect(result.trim()).toBe("
red is dead
"); + }); + + test("boolean value condition else", () => { + const template = ` +
+ begin + ok + ok-else + end +
+ `; + const result = renderToString(template, { condition: true }); + expect(result).toBe( + "
\n begin\n ok\n end\n
" + ); + }); + + test("boolean value condition false else", () => { + const template = ` +
beginfail + fail-elseend
+ `; + const result = renderToString(template, { condition: false }); + expect(result).toBe( + "
beginfail-elseend
" + ); + }); +}); + +describe("attributes", () => { + test("static attributes", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template)).toBe(expected); + }); + + test("static attributes on void elements", () => { + const template = `Test`; + const expected = `Test`; + expect(renderToString(template)).toBe(expected); + }); + + test("dynamic attributes", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template)).toBe(expected); + }); + + test("fixed variable", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value: "ok" })).toBe(expected); + }); + + test("dynamic attribute falsy variable ", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value: false })).toBe(expected); + }); + + test("tuple literal", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template)).toBe(expected); + }); + + test("tuple variable", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value: ["foo", "bar"] })).toBe(expected); + }); + + test("object", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value: { a: 1, b: 2, c: 3 } })).toBe( + expected + ); + }); + + test("format literal", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template)).toBe(expected); + }); + + test("format value", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value: "a" })).toBe(expected); + }); + + test("format expression", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value: 5 })).toBe(expected); + }); + + test("format multiple", () => { + const template = `
`; + const expected = `
`; + expect(renderToString(template, { value1: 0, value2: 1, value3: 2 })).toBe( + expected + ); + }); + + xit("various escapes", () => { + // need to think about this... This one does not pass, but I am not sure it is + // a correct test + const template = ` +
+ `; + const expected = `
`; + expect( + renderToString(template, { bar: 0, baz: 1, qux: { qux: "<>" } }) + ).toBe(expected); + }); +}); + +describe("t-call (template calling", () => { + test("basic caller", () => { + const qweb = new QWeb(); + qweb.addTemplate("_basic-callee", "
ok
"); + qweb.addTemplate("caller", ''); + const expected = "
ok
"; + expect(qwebRender(qweb, "caller")).toBe(expected); + }); + + test("t-call not allowed on a non t node", () => { + const qweb = new QWeb(); + qweb.addTemplate("_basic-callee", "ok"); + qweb.addTemplate("caller", '
'); + expect(() => qwebRender(qweb, "caller")).toThrow('Invalid tag'); + }); + + test("with unused body", () => { + const qweb = new QWeb(); + qweb.addTemplate("_basic-callee", "
ok
"); + qweb.addTemplate("caller", 'WHEEE'); + const expected = "
ok
"; + expect(qwebRender(qweb, "caller")).toBe(expected); + }); + + test("with unused setbody", () => { + const qweb = new QWeb(); + qweb.addTemplate("_basic-callee", "
ok
"); + qweb.addTemplate( + "caller", + '' + ); + const expected = "
ok
"; + expect(qwebRender(qweb, "caller")).toBe(expected); + }); + + test("with used body", () => { + const qweb = new QWeb(); + qweb.addTemplate("_callee-printsbody", '

'); + qweb.addTemplate("caller", 'ok'); + const expected = "

ok

"; + expect(qwebRender(qweb, "caller")).toBe(expected); + }); + + test("with used set body", () => { + const qweb = new QWeb(); + qweb.addTemplate("_callee-uses-foo", ''); + qweb.addTemplate( + "caller", + ` + ` + ); + const expected = "ok"; + expect(qwebRender(qweb, "caller")).toBe(expected); + }); + + test("inherit context", () => { + const qweb = new QWeb(); + qweb.addTemplate("_callee-uses-foo", ''); + qweb.addTemplate( + "caller", + ` +
` + ); + const expected = "
1
"; + expect(qwebRender(qweb, "caller")).toBe(expected); + }); + + test("scoped parameters", () => { + const qweb = new QWeb(); + qweb.addTemplate("_basic-callee", `ok`); + qweb.addTemplate( + "caller", + ` +
+ + + + +
+ ` + ); + const expected = "
ok
"; + expect(qwebRender(qweb, "caller").replace(/\s/g, '')).toBe(expected); + }); +}); + +describe("foreach", () => { + test("iterate on items", () => { + const template = ` +
+ + [: ] + +
`; + const expected = `
[0:33][1:22][2:11]
`; + expect(renderToString(template).replace(/\s/g, '')).toBe(expected); + }); + + test("iterate on items (on a element node)", () => { + const template = ` +
+ +
`; + const expected = `
12
`; + expect(renderToString(template).replace(/\s/g, '')).toBe(expected); + }); + + test("iterate, position", () => { + const template = ` +
+ + - first last () + +
`; + const expected = `
-first(even)-(odd)-(even)-(odd)-last(even)
`; + expect(renderToString(template).replace(/\s/g, '')).toBe(expected); + }); + + test("iterate, integer param", () => { + const template = `
+ [: ] +
`; + const expected = `
[0:00][1:11][2:22]
`; + expect(renderToString(template).replace(/\s/g, '')).toBe(expected); + }); + + test("iterate, dict param", () => { + const template = ` +
+ + [: - ] + +
`; + const expected = `
[0:a1-even][1:b2-odd][2:c3-even]
`; + expect( + renderToString(template, { value: { a: 1, b: 2, c: 3 } }).replace(/\s/g, '') + ).toBe(expected); + }); +}); + +describe("misc", () => { + test("global", () => { + const qweb = new QWeb(); + qweb.addTemplate("_callee-asc", ``); + qweb.addTemplate( + "_callee-uses-foo", + `foo default` + ); + qweb.addTemplate( + "_callee-asc-toto", + `
toto default
` + ); + qweb.addTemplate( + "caller", + ` +
+ + + + + + + + + + + + +
+ ` + ); + const expected = ` +
+ + 4 + + aaa + foo default + + bbb + + + 5 + + aaa + foo default + + bbb + + + 6 + + aaa + foo default + + bbb + + +
toto default
+
+ `.trim(); + expect(qwebRender(qweb, "caller").trim()).toBe(expected); + }); +});