initial commit

This commit is contained in:
Géry Debongnie
2019-01-16 11:28:05 +01:00
commit 0f3b2d10da
10 changed files with 1293 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
import { escape } from "./utils";
import directives from "./qweb_directives";
import { Directive } from "./qweb_directives";
type RawTemplate = string;
type Template = (context: any) => DocumentFragment;
// Evaluation Context
export type EvalContext = { [key: string]: any };
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: string | undefined;
indentLevel: number = 0;
rootContext: Context;
caller: Element | undefined;
fragmentID: string;
constructor() {
this.rootContext = this;
this.fragmentID = this.generateID();
}
generateID(): string {
const id = `_${this.rootContext.nextID}`;
this.rootContext.nextID++;
return id;
}
withParent(node: string): Context {
const newContext = Object.create(this);
newContext.parentNode = 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--;
}
addNode(nodeID: string) {
if (this.parentNode) {
this.addLine(`${this.parentNode}.appendChild(${nodeID})`);
} else {
this.addLine(`${this.fragmentID}.appendChild(${nodeID})`);
}
}
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);
}
}
/**
* Template rendering engine
*/
export default class QWeb {
rawTemplates: { [name: string]: RawTemplate } = {};
nodeTemplates: { [name: string]: Document } = {};
templates: { [name: string]: Template } = {};
escape: ((str: string) => string) = escape;
exprCache: { [key: string]: string } = {};
directives: Directive[] = [];
constructor() {
directives.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.nodeTemplates[name] = doc;
}
/**
* Render a template
*
* @param {string} name the template should already have been added
*/
render(name: string, context: any = {}): DocumentFragment {
const template = this.templates[name] || this._compile(name);
return template(context);
}
renderToString(name: string, context: EvalContext = {}): string {
const node = this.render(name, context);
return this._renderNodeToString(node);
}
_renderNodeToString(node: Node): string {
switch (node.nodeType) {
case 3: // text node
return node.textContent!;
case 11: // document.fragment
const children = Array.from((<DocumentFragment>node).childNodes);
return children.map(this._renderNodeToString).join("");
default:
return (<HTMLElement>node).outerHTML;
}
}
_compile(name: string): Template {
if (name in this.templates) {
return this.templates[name];
}
const doc = this.nodeTemplates[name];
let ctx = new Context();
const mainNode = doc.firstChild!;
ctx.addLine(`let ${ctx.fragmentID} = document.createDocumentFragment()`);
this._compileNode(mainNode, ctx);
ctx.addLine(`return ${ctx.fragmentID}`);
const functionCode = ctx.code.join("\n");
if ((<Element>mainNode).attributes.hasOwnProperty("t-debug")) {
console.log(
`Template: ${this.rawTemplates[name]}\nCompiled code:\n` + functionCode
);
}
const template: Template = (new Function(
"context",
functionCode
) as Template).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!;
let nodeID = ctx.generateID();
ctx.addLine(`let ${nodeID} = document.createTextNode(\`${text}\`)`);
ctx.addNode(nodeID);
return;
}
const attributes = (<Element>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 });
}
}
}
_getValue(val: any, ctx: Context): any {
if (val in ctx.variables) {
return this._getValue(ctx.variables[val], ctx);
}
return val;
}
_compileChildren(node: ChildNode, ctx: Context) {
if (node.childNodes.length > 0) {
for (let child of Array.from(node.childNodes)) {
this._compileNode(child, ctx);
}
}
}
_compileGenericNode(node: ChildNode, ctx: Context): string {
let nodeID: string | undefined;
switch (node.nodeType) {
case 1: // generic tag;
nodeID = ctx.generateID();
ctx.addLine(
`let ${nodeID} = document.createElement('${node.nodeName}')`
);
const attributes = (<Element>node).attributes;
for (let i = 0; i < attributes.length; i++) {
const name = attributes[i].name;
const value = attributes[i].textContent!;
if (!name.startsWith("t-")) {
ctx.addLine(
`${nodeID}.setAttribute('${name}', '${escape(value)}')`
);
}
if (name.startsWith("t-att-")) {
const attName = name.slice(6);
const formattedValue = this._formatExpression(value!);
const attID = ctx.generateID();
ctx.addLine(`let ${attID} = ${formattedValue}`);
ctx.addLine(
`if (${attID}) {${nodeID}.setAttribute('${attName}', ${attID})}`
);
}
if (name.startsWith("t-attf-")) {
const exprName = name.slice(7);
const formattedExpr = value!.replace(
/\{\{.*?\}\}/g,
s => "${" + this._formatExpression(s.slice(2, -2)) + "}"
);
ctx.addLine(
`${nodeID}.setAttribute('${exprName}', \`${formattedExpr}\`)`
);
}
if (name === "t-att") {
const id = ctx.generateID();
ctx.addLine(`let ${id} = ${this._formatExpression(value!)}`);
ctx.addLine(`if (${id} instanceof Array) {`);
ctx.indent();
ctx.addLine(`${nodeID}.setAttribute(${id}[0], ${id}[1])`);
ctx.dedent();
ctx.addLine(`} else {`);
ctx.indent();
ctx.addLine(`for (let key in ${id}) {`);
ctx.indent();
ctx.addLine(`${nodeID}.setAttribute(key, ${id}[key])`);
ctx.dedent();
ctx.addLine(`}`);
ctx.dedent();
ctx.addLine(`}`);
}
}
break;
default:
throw new Error("unknown node type");
}
ctx.addNode(nodeID);
return nodeID;
}
_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;
}
}
+231
View File
@@ -0,0 +1,231 @@
import QWeb from "./qweb";
import { Context } from "./qweb";
interface CompilationInfo {
nodeID?: string;
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);
}
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 nodes = Array.from(node.childNodes);
for (let i = 0; i < nodes.length; i++) {
qweb._compileNode(nodes[i], ctx);
}
ctx.dedent();
ctx.addLine("}");
return true;
}
};
const ifDirective: Directive = {
name: "if",
priority: 20,
atNodeEncounter({ node, qweb, ctx }): boolean {
let cond = qweb._getValue(node.getAttribute("t-if")!, ctx);
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 = qweb._getValue(node.getAttribute("t-elif")!, ctx);
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 {
const subTemplate = node.getAttribute("t-call")!;
const nodeTemplate = qweb.nodeTemplates[subTemplate];
const nodeCopy = <Element>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 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;
}
};
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 ${exprID} = ${qweb._formatExpression(value)}`);
ctx.addLine(`if (${exprID} || ${exprID} === 0) {`);
ctx.indent();
let text = exprID;
if (ctx.escaping) {
text = `this.escape(${text})`;
}
const nodeID = ctx.generateID();
ctx.addLine(`let ${nodeID} = document.createTextNode(${text})`);
ctx.addNode(nodeID);
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(<ChildNode>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 = qweb._getValue(node.getAttribute("t-esc")!, ctx);
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 = qweb._getValue(node.getAttribute("t-raw")!, ctx);
compileValueNode(value, node, qweb, ctx);
return true;
}
};
const onDirective: Directive = {
name: "on",
priority: 90,
atNodeCreation({ ctx, fullName, value, nodeID }) {
const eventName = fullName.slice(5);
let extraArgs;
let handler = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
ctx.addLine(
`${nodeID}.addEventListener('${eventName}', context['${handler}'].bind(context${
extraArgs ? ", " + extraArgs : ""
}))`
);
}
};
export default [
forEachDirective,
ifDirective,
elifDirective,
elseDirective,
callDirective,
setDirective,
escDirective,
rawDirective,
onDirective
];
+28
View File
@@ -0,0 +1,28 @@
export function escape(str: string | number | undefined): string {
if (str === undefined) {
return "";
}
if (typeof str === "number") {
return String(str);
}
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&#x27;")
.replace(/`/g, "&#x60;");
}
/**
* Remove trailing and leading spaces
*/
export function htmlTrim(s: string): string {
let result = s.replace(/(^\s+|\s+$)/g, "");
if (s[0] === ' ') {
result = ' ' + result;
}
if (result !== ' ' && s[s.length - 1] === ' ') {
result = result + ' ';
}
return result;
}