diff --git a/src/app.ts b/src/app.ts index 6b26f019..85060cf5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,6 +23,12 @@ export class App extends TemplateSet { if (params.env) { this.env = params.env; } + if (params.translateFn) { + this.translateFn = params.translateFn; + } + if (params.translatableAttributes) { + this.translatableAttributes = params.translatableAttributes; + } } mount(target: HTMLElement): Promise> { diff --git a/src/qweb/compiler.ts b/src/qweb/compiler.ts index c94e1845..7ab544f1 100644 --- a/src/qweb/compiler.ts +++ b/src/qweb/compiler.ts @@ -18,6 +18,7 @@ import { ASTTKey, ASTTRaw, ASTTSet, + ASTTranslation, ASTType, parse, } from "./parser"; @@ -27,8 +28,14 @@ export type TemplateFunction = (blocks: any, utils: any) => Template; type BlockType = "block" | "text" | "multi" | "list" | "html"; -export function compileTemplate(template: string, name?: string): TemplateFunction { - const compiler = new QWebCompiler(template, name); +export interface CompileOptions { + name?: string; + translateFn?: (s: string) => string; + translatableAttributes?: string[]; +} + +export function compileTemplate(template: string, options?: CompileOptions): TemplateFunction { + const compiler = new QWebCompiler(template, options); return compiler.compile(); } @@ -97,7 +104,7 @@ class BlockDescription { asXmlString() { // Can't use outerHTML on text/comment nodes // append dom to any element and use innerHTML instead - const t = xmlDoc.createElement('t'); + const t = xmlDoc.createElement("t"); t.appendChild(this.dom!); return t.innerHTML; } @@ -114,6 +121,19 @@ interface Context { forceNewBlock: boolean; preventRoot?: boolean; isLast?: boolean; + translate: boolean; +} + +function createContext(parentCtx: Context, params?: Partial) { + return Object.assign( + { + block: null, + index: 0, + forceNewBlock: true, + translate: parentCtx.translate, + }, + params + ); } class CodeTarget { @@ -139,6 +159,9 @@ class CodeTarget { } } +export const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"]; +const translationRE = /^(\s*)([\s\S]+?)(\s*)$/; + export class QWebCompiler { blocks: BlockDescription[] = []; nextId = 1; @@ -154,15 +177,18 @@ export class QWebCompiler { target = new CodeTarget("main"); templateName: string; template: string; + translateFn: (s: string) => string; + translatableAttributes: string[]; ast: AST; staticCalls: { id: string; template: string }[] = []; - constructor(template: string, name?: string) { + constructor(template: string, options: CompileOptions = {}) { this.template = template; + this.translateFn = options.translateFn || ((s: string) => s); + this.translatableAttributes = options.translatableAttributes || TRANSLATABLE_ATTRS; this.ast = parse(template); - // console.warn(this.ast); - if (name) { - this.templateName = name; + if (options.name) { + this.templateName = options.name; } else { if (template.length > 250) { this.templateName = template.slice(0, 250) + "..."; @@ -177,9 +203,14 @@ export class QWebCompiler { this.isDebug = ast.type === ASTType.TDebug; BlockDescription.nextBlockId = 1; BlockDescription.nextDataId = 1; - this.compileAST(ast, { block: null, index: 0, forceNewBlock: false, isLast: true }); + this.compileAST(ast, { + block: null, + index: 0, + forceNewBlock: false, + isLast: true, + translate: true, + }); const code = this.generateCode(); - // console.warn(code); return new Function("bdom, helpers", code) as TemplateFunction; } @@ -282,13 +313,9 @@ export class QWebCompiler { this.addLine(` let cache = ctx.cache || {};`); this.addLine(` let nextCache = ctx.cache = {};`); } - // if (this.shouldDefineKey0) { - // this.addLine(` let key0;`); - // } for (let line of mainCode) { this.addLine(line); } - // console.warn(this.target.code.join('\n')) if (!this.target.hasRoot) { throw new Error("missing root block"); } @@ -383,6 +410,9 @@ export class QWebCompiler { case ASTType.TSlot: this.compileTSlot(ast, ctx); break; + case ASTType.TTranslation: + this.compileTTranslation(ast, ctx); + break; } } @@ -415,15 +445,22 @@ export class QWebCompiler { compileText(ast: ASTText, ctx: Context) { let { block, forceNewBlock } = ctx; + + let value = ast.value; + if (value && ctx.translate !== false) { + const match = translationRE.exec(value) as any; + value = match[1] + this.translateFn(match[2]) + match[3]; + } + if (!block || forceNewBlock) { block = this.createBlock(block, "text", ctx); - this.insertBlock(`text(\`${ast.value}\`)`, block, { + this.insertBlock(`text(\`${value}\`)`, block, { ...ctx, forceNewBlock: forceNewBlock && !block, }); } else { const createFn = ast.type === ASTType.Text ? xmlDoc.createTextNode : xmlDoc.createComment; - block.insert(createFn.call(xmlDoc, ast.value)); + block.insert(createFn.call(xmlDoc, value)); } } @@ -463,7 +500,6 @@ export class QWebCompiler { block = this.createBlock(block, "block", ctx); this.blocks.push(block); } - // attributes const attrs: { [key: string]: string } = {}; for (let key in ast.attrs) { @@ -471,7 +507,6 @@ export class QWebCompiler { let expr = interpolate(ast.attrs[key]); const idx = block!.insertData(expr); attrs["block-attribute-" + idx] = key.slice(7); - // console.warn('ccc', staticAttrs) } else if (key.startsWith("t-att")) { let expr = compileExpr(ast.attrs[key]); const idx = block!.insertData(expr); @@ -480,6 +515,8 @@ export class QWebCompiler { } else { attrs[`block-attribute-${idx}`] = key.slice(6); } + } else if (this.translatableAttributes.includes(key)) { + attrs[key] = this.translateFn(ast.attrs[key]); } else { attrs[key] = ast.attrs[key]; } @@ -522,12 +559,12 @@ export class QWebCompiler { const children = ast.content; for (let i = 0; i < children.length; i++) { const child = ast.content[i]; - const subCtx: Context = { - block: block, + const subCtx: Context = createContext(ctx, { + block, index: block!.childNumber, forceNewBlock: false, isLast: ctx.isLast && i === children.length - 1, - }; + }); this.compileAST(child, subCtx); } block!.currentDom = initialDom; @@ -582,7 +619,7 @@ export class QWebCompiler { let expr = ast.expr === "0" ? "ctx[zero]" : compileExpr(ast.expr); if (ast.body) { const nextId = BlockDescription.nextBlockId; - const subCtx: Context = { block: null, index: 0, forceNewBlock: true }; + const subCtx: Context = createContext(ctx); this.compileAST({ type: ASTType.Multi, content: ast.body }, subCtx); expr = `withDefault(${expr}, b${nextId})`; } @@ -603,7 +640,7 @@ export class QWebCompiler { this.addLine(`if (${compileExpr(ast.condition)}) {`); this.target.indentLevel++; this.insertAnchor(block!); - const subCtx: Context = { block: block, index: currentIndex, forceNewBlock: true }; + const subCtx: Context = createContext(ctx, { block, index: currentIndex }); this.compileAST(ast.content, subCtx); this.target.indentLevel--; if (ast.tElif) { @@ -611,11 +648,7 @@ export class QWebCompiler { this.addLine(`} else if (${compileExpr(clause.condition)}) {`); this.target.indentLevel++; this.insertAnchor(block); - const subCtx: Context = { - block: block, - index: currentIndex, - forceNewBlock: true, - }; + const subCtx: Context = createContext(ctx, { block, index: currentIndex }); this.compileAST(clause.content, subCtx); this.target.indentLevel--; } @@ -624,11 +657,7 @@ export class QWebCompiler { this.addLine(`} else {`); this.target.indentLevel++; this.insertAnchor(block); - const subCtx: Context = { - block: block, - index: currentIndex, - forceNewBlock: true, - }; + const subCtx: Context = createContext(ctx, { block, index: currentIndex }); this.compileAST(ast.tElse, subCtx); this.target.indentLevel--; } @@ -708,11 +737,7 @@ export class QWebCompiler { this.addLine("}"); } - const subCtx: Context = { - block: block, //collectionBlock, - index: loopVar, - forceNewBlock: true, - }; + const subCtx: Context = createContext(ctx, { block, index: loopVar }); this.compileAST(ast.body, subCtx); if (!ast.key) { console.warn( @@ -755,13 +780,13 @@ export class QWebCompiler { for (let i = 0, l = ast.content.length; i < l; i++) { const child = ast.content[i]; const isTSet = child.type === ASTType.TSet; - const subCtx: Context = { - block: block, - index: index, + const subCtx: Context = createContext(ctx, { + block, + index, forceNewBlock: !isTSet, preventRoot: ctx.preventRoot, isLast: ctx.isLast && i === l - 1, - }; + }); this.compileAST(child, subCtx); if (!isTSet) { index++; @@ -795,7 +820,7 @@ export class QWebCompiler { if (ast.body) { this.addLine(`ctx = Object.create(ctx);`); const nextId = BlockDescription.nextBlockId; - const subCtx: Context = { block: null, index: 0, forceNewBlock: true, preventRoot: true }; + const subCtx: Context = createContext(ctx, { preventRoot: true }); this.compileAST({ type: ASTType.Multi, content: ast.body }, subCtx); if (nextId !== BlockDescription.nextBlockId) { this.addLine(`ctx[zero] = b${nextId};`); @@ -820,7 +845,6 @@ export class QWebCompiler { } else { const id = this.generateId(`callTemplate_`); this.staticCalls.push({ id, template: subTemplate }); - // console.warn('coucoup', this.target.hasRoot) block = this.createBlock(block, "multi", ctx); this.insertBlock(`${id}(ctx, node, ${key})`, block!, { ...ctx, forceNewBlock: !block }); } @@ -844,7 +868,7 @@ export class QWebCompiler { this.shouldProtectScope = true; const expr = ast.value ? compileExpr(ast.value || "") : "null"; if (ast.body) { - const subCtx: Context = { block: null, index: 0, forceNewBlock: true }; + const subCtx: Context = createContext(ctx); const nextId = `b${BlockDescription.nextBlockId}`; this.compileAST({ type: ASTType.Multi, content: ast.body }, subCtx); const value = ast.value ? (nextId ? `withDefault(${expr}, ${nextId})` : expr) : nextId; @@ -914,7 +938,7 @@ export class QWebCompiler { slot.signature = "ctx => (node, key) => {"; this.functions.push(slot); this.target = slot; - const subCtx: Context = { block: null, index: 0, forceNewBlock: true }; + const subCtx: Context = createContext(ctx); this.compileAST(ast.slots[slotName], subCtx); if (this.hasRef) { slot.signature = "ctx => node => {"; @@ -974,7 +998,7 @@ export class QWebCompiler { slot.signature = "ctx => {"; this.functions.push(slot); const initialTarget = this.target; - const subCtx: Context = { block: null, index: 0, forceNewBlock: true }; + const subCtx: Context = createContext(ctx); this.target = slot; this.compileAST(ast.defaultContent, subCtx); this.target = initialTarget; @@ -994,4 +1018,10 @@ export class QWebCompiler { block = this.createBlock(block, "multi", ctx); this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false }); } + + compileTTranslation(ast: ASTTranslation, ctx: Context) { + if (ast.content) { + this.compileAST(ast.content, Object.assign({}, ctx, { translate: false })); + } + } } diff --git a/src/qweb/parser.ts b/src/qweb/parser.ts index dc65ee03..e563d1b6 100644 --- a/src/qweb/parser.ts +++ b/src/qweb/parser.ts @@ -19,6 +19,7 @@ export const enum ASTType { TLog, TSlot, TCallBlock, + TTranslation, } export interface ASTText { @@ -131,6 +132,11 @@ export interface ASTLog { content: AST | null; } +export interface ASTTranslation { + type: ASTType.TTranslation; + content: AST | null; +} + export type AST = | ASTText | ASTComment @@ -147,7 +153,8 @@ export type AST = | ASTSlot | ASTTCallBlock | ASTLog - | ASTDebug; + | ASTDebug + | ASTTranslation; // ----------------------------------------------------------------------------- // Parser @@ -179,6 +186,7 @@ function parseNode(node: ChildNode, ctx: ParsingContext): AST | null { parseTCallBlock(node, ctx) || parseTEscNode(node, ctx) || parseTKey(node, ctx) || + parseTTranslation(node, ctx) || parseTSlot(node, ctx) || parseTRawNode(node, ctx) || parseComponent(node, ctx) || @@ -457,6 +465,7 @@ function hasNoComponent(ast: AST): boolean { return hasNoComponent(ast.content); case ASTType.TDebug: case ASTType.TLog: + case ASTType.TTranslation: return ast.content ? hasNoComponent(ast.content) : true; case ASTType.TForEach: return ast.hasNoComponent; @@ -711,6 +720,17 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null { }; } +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), + }; +} + // ----------------------------------------------------------------------------- // helpers // ----------------------------------------------------------------------------- diff --git a/src/qweb/template_helpers.ts b/src/qweb/template_helpers.ts index 36dfa76d..8c178974 100644 --- a/src/qweb/template_helpers.ts +++ b/src/qweb/template_helpers.ts @@ -1,7 +1,6 @@ -// import { compileTemplate, Template } from "./qweb/index"; import { BDom, createBlock, html, list, multi, text, toggler } from "../blockdom"; +import { compileTemplate, Template } from "./compiler"; import { component } from "../component/component_node"; -import { Template, compileTemplate } from "./compiler"; const bdom = { text, createBlock, list, multi, html, toggler, component }; @@ -82,6 +81,8 @@ export const UTILS = { export class TemplateSet { rawTemplates: { [name: string]: string } = Object.create(globalTemplates); templates: { [name: string]: Template } = {}; + translateFn?: (s: string) => string; + translatableAttributes?: string[]; utils: typeof UTILS; constructor() { @@ -107,7 +108,11 @@ export class TemplateSet { if (rawTemplate === undefined) { throw new Error(`Missing template: "${name}"`); } - const templateFn = compileTemplate(rawTemplate, name); + const templateFn = compileTemplate(rawTemplate, { + name, + translateFn: this.translateFn, + translatableAttributes: this.translatableAttributes, + }); // first add a function to lazily get the template, in case there is a // recursive call to the template name diff --git a/tests/helpers.ts b/tests/helpers.ts index 4c21dc4d..d74d4722 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,23 +1,22 @@ import { + App, + Component, onDestroyed, - onWillPatch, - onWillUnmount, onMounted, onPatched, - onWillStart, - onWillUpdateProps, - useComponent, - status, onRender, - Component, + onWillPatch, + onWillStart, + onWillUnmount, + onWillUpdateProps, + status, + useComponent, } from "../src"; -import { TemplateSet, globalTemplates, UTILS } from "../src/qweb/template_helpers"; -import { blockDom } from "../src"; import { BDom } from "../src/blockdom"; -// import { mountBlock } from "../src/blockdom/block"; -import { compileTemplate, Template } from "../src/qweb/compiler"; +import { blockDom } from "../src"; +import { CompileOptions, compileTemplate, Template } from "../src/qweb/compiler"; +import { globalTemplates, TemplateSet, UTILS } from "../src/qweb/template_helpers"; import { xml } from "../src/tags"; -// import { UTILS } from "../src/template_utils"; const mount = blockDom.mount; @@ -37,8 +36,17 @@ export function makeTestFixture() { return fixture; } -export function snapshotTemplateCode(template: string) { - expect(compileTemplate(template).toString()).toMatchSnapshot(); +export function snapshotTemplateCode(template: string, options?: CompileOptions) { + expect(compileTemplate(template, options).toString()).toMatchSnapshot(); +} + +export function snapshotApp(app: App) { + const Root = app.Root; + const template = app.rawTemplates[Root.template]; + snapshotTemplateCode(template, { + translateFn: app.translateFn, + translatableAttributes: app.translatableAttributes, + }); } export async function nextTick(): Promise { diff --git a/tests/qweb/__snapshots__/translation.test.ts.snap b/tests/qweb/__snapshots__/translation.test.ts.snap new file mode 100644 index 00000000..e6372da1 --- /dev/null +++ b/tests/qweb/__snapshots__/translation.test.ts.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`translation support can translate node content 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`
mot
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`translation support does not translate node content if disabled 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`
motword
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`translation support some attributes are translated 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`

mot

mot

mot

mot

mot

\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`translation support translation is done on the trimmed text, with extra spaces readded after 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`
mot
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`translation support can set translatable attributes 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`
text
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; diff --git a/tests/qweb/parser.test.ts b/tests/qweb/parser.test.ts index 1ed8e5ce..93817400 100644 --- a/tests/qweb/parser.test.ts +++ b/tests/qweb/parser.test.ts @@ -1126,4 +1126,47 @@ describe("qweb parser", () => { name: "myBlock", }); }); + + // --------------------------------------------------------------------------- + // t-translation + // --------------------------------------------------------------------------- + test('t-translation="off"', async () => { + expect(parse(`word`)).toEqual({ + type: ASTType.TTranslation, + content: { + type: ASTType.Text, + value: "word", + }, + }); + + expect(parse(`
word
`)).toEqual({ + body: { + content: { + attrs: {}, + content: [ + { + type: 0, + value: "word", + }, + ], + on: {}, + ref: null, + tag: "div", + type: 2, + }, + type: 16, + }, + collection: "list", + elem: "item", + hasNoComponent: true, + hasNoFirst: true, + hasNoIndex: true, + hasNoLast: true, + hasNoValue: true, + isOnlyChild: false, + key: null, + memo: "", + type: 9, + }); + }); }); diff --git a/tests/qweb/translation.test.ts b/tests/qweb/translation.test.ts new file mode 100644 index 00000000..2ef4008e --- /dev/null +++ b/tests/qweb/translation.test.ts @@ -0,0 +1,116 @@ +import { App, Component } from "../../src"; +import { makeTestFixture, snapshotApp } from "../helpers"; +import { xml } from "../../src/tags"; + +let fixture: HTMLElement; + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +describe("translation support", () => { + test("can translate node content", async () => { + class SomeComponent extends Component { + static template = xml`
word
`; + } + + const app = new App(SomeComponent); + app.configure({ + translateFn: (expr: string) => (expr === "word" ? "mot" : expr), + }); + const comp = await app.mount(fixture); + + const el = comp.el as HTMLElement; + + expect(el.outerHTML).toBe("
mot
"); + snapshotApp(app); + }); + + test("does not translate node content if disabled", async () => { + class SomeComponent extends Component { + static template = xml` +
+ word + word +
+ `; + } + + const app = new App(SomeComponent); + app.configure({ + translateFn: (expr: string) => (expr === "word" ? "mot" : expr), + }); + const comp = await app.mount(fixture); + + const el = comp.el as HTMLElement; + + expect(el.outerHTML).toBe("
motword
"); + snapshotApp(app); + }); + + test("some attributes are translated", async () => { + class SomeComponent extends Component { + static template = xml` +
+

word

+

word

+

word

+

word

+

word

+
+ `; + } + + const app = new App(SomeComponent); + app.configure({ + translateFn: (expr: string) => (expr === "word" ? "mot" : expr), + }); + const comp = await app.mount(fixture); + + const el = comp.el as HTMLElement; + + expect(el.outerHTML).toBe( + '

mot

mot

mot

mot

mot

' + ); + snapshotApp(app); + }); + + test("can set translatable attributes", async () => { + class SomeComponent extends Component { + static template = xml` +
text
+ `; + } + + const app = new App(SomeComponent); + app.configure({ + translateFn: (expr: string) => (expr === "word" ? "mot" : expr), + translatableAttributes: ["potato"], + }); + const comp = await app.mount(fixture); + + const el = comp.el as HTMLElement; + expect(el.outerHTML).toBe('
text
'); + snapshotApp(app); + }); + + test("translation is done on the trimmed text, with extra spaces readded after", async () => { + class SomeComponent extends Component { + static template = xml` +
word
+ `; + } + + const translateFn = jest.fn((expr: string) => (expr === "word" ? "mot" : expr)); + + const app = new App(SomeComponent); + app.configure({ translateFn }); + const comp = await app.mount(fixture); + + const el = comp.el as HTMLElement; + + expect(el.outerHTML).toBe("
mot
"); + expect(translateFn).toHaveBeenCalledWith("word"); + snapshotApp(app); + }); +});