[ADD] Translation feature

This commit brings back the possibility to translate text nodes and
the attributes "label", "title", "placeholder", and "alt" in an app
configured with a suitable translation function.

It is also possible to deactivate translations under a node via
the directive t-translation="off".

For flexibility it is possible to define the list of translatable
attributes in the app.
This commit is contained in:
Mathieu Duckerts-Antoine
2021-10-13 16:49:34 +02:00
committed by Aaron Bohy
parent 4415cc8932
commit 0a544bd7e8
8 changed files with 363 additions and 64 deletions
+6
View File
@@ -23,6 +23,12 @@ export class App<T extends typeof Component = any> 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<InstanceType<T>> {
+76 -46
View File
@@ -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<Context>) {
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 }));
}
}
}
+21 -1
View File
@@ -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
// -----------------------------------------------------------------------------
+8 -3
View File
@@ -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
+22 -14
View File
@@ -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<void> {
@@ -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(\`<div>mot</div>\`);
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(\`<div><span>mot</span><span>word</span></div>\`);
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(\`<div><p label=\\"mot\\">mot</p><p title=\\"mot\\">mot</p><p placeholder=\\"mot\\">mot</p><p alt=\\"mot\\">mot</p><p something=\\"word\\">mot</p></div>\`);
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(\`<div> mot </div>\`);
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(\`<div tomato=\\"word\\" potato=\\"mot\\" title=\\"word\\">text</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
+43
View File
@@ -1126,4 +1126,47 @@ describe("qweb parser", () => {
name: "myBlock",
});
});
// ---------------------------------------------------------------------------
// t-translation
// ---------------------------------------------------------------------------
test('t-translation="off"', async () => {
expect(parse(`<t t-translation="off">word</t>`)).toEqual({
type: ASTType.TTranslation,
content: {
type: ASTType.Text,
value: "word",
},
});
expect(parse(`<div t-foreach="list" t-translation="off" t-as="item">word</div>`)).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,
});
});
});
+116
View File
@@ -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`<div>word</div>`;
}
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("<div>mot</div>");
snapshotApp(app);
});
test("does not translate node content if disabled", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<span>word</span>
<span t-translation="off">word</span>
</div>
`;
}
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("<div><span>mot</span><span>word</span></div>");
snapshotApp(app);
});
test("some attributes are translated", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<p label="word">word</p>
<p title="word">word</p>
<p placeholder="word">word</p>
<p alt="word">word</p>
<p something="word">word</p>
</div>
`;
}
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(
'<div><p label="mot">mot</p><p title="mot">mot</p><p placeholder="mot">mot</p><p alt="mot">mot</p><p something="word">mot</p></div>'
);
snapshotApp(app);
});
test("can set translatable attributes", async () => {
class SomeComponent extends Component {
static template = xml`
<div tomato="word" potato="word" title="word">text</div>
`;
}
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('<div tomato="word" potato="mot" title="word">text</div>');
snapshotApp(app);
});
test("translation is done on the trimmed text, with extra spaces readded after", async () => {
class SomeComponent extends Component {
static template = xml`
<div> word </div>
`;
}
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("<div> mot </div>");
expect(translateFn).toHaveBeenCalledWith("word");
snapshotApp(app);
});
});