[IMP] translation contexts

A new directive t-tanslation-context and a new family of directives (of the form
t-translation-context-...) are introduced to allow to translate terms in contexts.

Set t-translation-context="fr" on a node makes every call of the translation
function to be done with "fr" as seconde parameter when translating an
attribute/content within that node or its children (if no closer
t-translation-context directive is found).
A directive t-translation-context-attr="fr" can be used on a node to target its
attribute "attr". For example, if a div has an attribute title="a title",
use t-translation-context-title="pt" will make "a title" to be translated
in the context "pt". Note that this takes precedence over any other directive
t-translation-context found on a parent (or the div itself).
The translation function is in charge of the interpretation of the context: OWL
does not associate any meaning with a translation context.
This commit is contained in:
Mathieu Duckerts-Antoine
2025-01-10 13:46:33 +01:00
committed by Géry Debongnie
parent b365ea5c9c
commit a9be149e1e
8 changed files with 646 additions and 43 deletions
+52 -14
View File
@@ -24,6 +24,7 @@ import {
ASTTOut,
ASTTPortal,
ASTTranslation,
ASTTranslationContext,
ASTTSet,
ASTType,
Attrs,
@@ -35,7 +36,7 @@ type BlockType = "block" | "text" | "multi" | "list" | "html" | "comment";
const whitespaceRE = /\s+/g;
export interface Config {
translateFn?: (s: string) => string;
translateFn?: (s: string, translationCtx: string) => string;
translatableAttributes?: string[];
dev?: boolean;
}
@@ -171,6 +172,7 @@ interface Context {
forceNewBlock: boolean;
isLast?: boolean;
translate: boolean;
translationCtx: string;
tKeyExpr: string | null;
nameSpace?: string;
tModelSelectedExpr?: string;
@@ -185,6 +187,7 @@ function createContext(parentCtx: Context, params?: Partial<Context>): Context {
index: 0,
forceNewBlock: true,
translate: parentCtx.translate,
translationCtx: parentCtx.translationCtx,
tKeyExpr: null,
nameSpace: parentCtx.nameSpace,
tModelSelectedExpr: parentCtx.tModelSelectedExpr,
@@ -263,7 +266,7 @@ export class CodeGenerator {
target = new CodeTarget("template");
templateName?: string;
dev: boolean;
translateFn: (s: string) => string;
translateFn: (s: string, translationCtx: string) => string;
translatableAttributes: string[] = TRANSLATABLE_ATTRS;
ast: AST;
staticDefs: { id: string; expr: string }[] = [];
@@ -303,6 +306,7 @@ export class CodeGenerator {
forceNewBlock: false,
isLast: true,
translate: true,
translationCtx: "",
tKeyExpr: null,
});
// define blocks and utility functions
@@ -457,9 +461,9 @@ export class CodeGenerator {
.join("");
}
translate(str: string): string {
translate(str: string, translationCtx: string): string {
const match = translationRE.exec(str) as any;
return match[1] + this.translateFn(match[2]) + match[3];
return match[1] + this.translateFn(match[2], translationCtx) + match[3];
}
/**
@@ -501,6 +505,8 @@ export class CodeGenerator {
return this.compileTSlot(ast, ctx);
case ASTType.TTranslation:
return this.compileTTranslation(ast, ctx);
case ASTType.TTranslationContext:
return this.compileTTranslationContext(ast, ctx);
case ASTType.TPortal:
return this.compileTPortal(ast, ctx);
}
@@ -542,7 +548,7 @@ export class CodeGenerator {
let value = ast.value;
if (value && ctx.translate !== false) {
value = this.translate(value);
value = this.translate(value, ctx.translationCtx);
}
if (!ctx.inPreTag) {
value = value.replace(whitespaceRE, " ");
@@ -631,7 +637,8 @@ export class CodeGenerator {
}
}
} else if (this.translatableAttributes.includes(key)) {
attrs[key] = this.translateFn(ast.attrs[key]);
const attrTranslationCtx = ast.attrsTranslationCtx?.[key] || ctx.translationCtx;
attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx);
} else {
expr = `"${ast.attrs[key]}"`;
attrName = key;
@@ -1104,7 +1111,7 @@ export class CodeGenerator {
let value: string;
if (ast.defaultValue) {
const defaultValue = toStringExpression(
ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue
ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue
);
if (ast.value) {
value = `withDefault(${expr}, ${defaultValue})`;
@@ -1139,9 +1146,15 @@ export class CodeGenerator {
* "some-prop" "state" "'some-prop': ctx['state']"
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/
formatProp(name: string, value: string): string {
formatProp(
name: string,
value: string,
attrsTranslationCtx: { [name: string]: string } | null,
translationCtx: string
): string {
if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value));
const attrTranslationCtx = attrsTranslationCtx?.[name] || translationCtx;
value = toStringExpression(this.translateFn(value, attrTranslationCtx));
} else {
value = this.captureExpression(value);
}
@@ -1163,8 +1176,14 @@ export class CodeGenerator {
return `${name}: ${value || undefined}`;
}
formatPropObject(obj: { [prop: string]: any }): string[] {
return Object.entries(obj).map(([k, v]) => this.formatProp(k, v));
formatPropObject(
obj: { [prop: string]: any },
attrsTranslationCtx: { [name: string]: string } | null,
translationCtx: string
): string[] {
return Object.entries(obj).map(([k, v]) =>
this.formatProp(k, v, attrsTranslationCtx, translationCtx)
);
}
getPropString(props: string[], dynProps: string | null): string {
@@ -1181,7 +1200,9 @@ export class CodeGenerator {
let { block } = ctx;
// props
const hasSlotsProp = "slots" in (ast.props || {});
const props: string[] = ast.props ? this.formatPropObject(ast.props) : [];
const props: string[] = ast.props
? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx)
: [];
// slots
let slotDef: string = "";
@@ -1205,7 +1226,13 @@ export class CodeGenerator {
params.push(`__scope: "${scope}"`);
}
if (ast.slots[slotName].attrs) {
params.push(...this.formatPropObject(ast.slots[slotName].attrs!));
params.push(
...this.formatPropObject(
ast.slots[slotName].attrs!,
ast.slots[slotName].attrsTranslationCtx,
ctx.translationCtx
)
);
}
const slotInfo = `{${params.join(", ")}}`;
slotStr.push(`'${slotName}': ${slotInfo}`);
@@ -1332,7 +1359,9 @@ export class CodeGenerator {
key = this.generateComponentKey(key);
}
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
const props = ast.attrs
? this.formatPropObject(ast.attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) {
const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
@@ -1365,6 +1394,15 @@ export class CodeGenerator {
}
return null;
}
compileTTranslationContext(ast: ASTTranslationContext, ctx: Context): string | null {
if (ast.content) {
return this.compileAST(
ast.content,
Object.assign({}, ctx, { translationCtx: ast.translationCtx })
);
}
return null;
}
compileTPortal(ast: ASTTPortal, ctx: Context): string {
if (!this.staticDefs.find((d) => d.id === "Portal")) {
this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
+84 -5
View File
@@ -27,6 +27,7 @@ export const enum ASTType {
TSlot,
TCallBlock,
TTranslation,
TTranslationContext,
TPortal,
}
@@ -56,6 +57,7 @@ export interface ASTDomNode {
tag: string;
content: AST[];
attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
ref: string | null;
on: EventHandlers | null;
model: TModelInfo | null;
@@ -127,6 +129,7 @@ interface SlotDefinition {
scope: string | null;
on: EventHandlers | null;
attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
}
export interface ASTComponent {
@@ -136,6 +139,7 @@ export interface ASTComponent {
dynamicProps: string | null;
on: EventHandlers | null;
props: { [name: string]: string } | null;
propsTranslationCtx: { [name: string]: string } | null;
slots: { [name: string]: SlotDefinition } | null;
}
@@ -143,6 +147,7 @@ export interface ASTSlot {
type: ASTType.TSlot;
name: string;
attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
on: EventHandlers | null;
defaultContent: AST | null;
}
@@ -168,6 +173,12 @@ export interface ASTTranslation {
content: AST | null;
}
export interface ASTTranslationContext {
type: ASTType.TTranslationContext;
content: AST | null;
translationCtx: string;
}
export interface ASTTPortal {
type: ASTType.TPortal;
target: string;
@@ -192,6 +203,7 @@ export type AST =
| ASTLog
| ASTDebug
| ASTTranslation
| ASTTranslationContext
| ASTTPortal;
// -----------------------------------------------------------------------------
@@ -245,6 +257,7 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
@@ -368,6 +381,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
const nodeAttrsNames = node.getAttributeNames();
let attrs: ASTDomNode["attrs"] = null;
let attrsTranslationCtx: ASTDomNode["attrsTranslationCtx"] = null;
let on: EventHandlers | null = null;
let model: TModelInfo | null = null;
@@ -428,6 +442,10 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
throw new OwlError(`Invalid attribute: '${attr}'`);
} else if (attr === "xmlns") {
ns = value;
} else if (attr.startsWith("t-translation-context-")) {
const attrName = attr.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else if (attr !== "t-name") {
if (attr.startsWith("t-") && !attr.startsWith("t-att")) {
throw new OwlError(`Unknown QWeb directive: '${attr}'`);
@@ -450,6 +468,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
tag: tagName,
dynamicTag,
attrs,
attrsTranslationCtx,
on,
ref,
content: children,
@@ -609,7 +628,15 @@ function parseTCall(node: Element, ctx: ParsingContext): AST | null {
if (ast && ast.type === ASTType.TComponent) {
return {
...ast,
slots: { default: { content: tcall, scope: null, on: null, attrs: null } },
slots: {
default: {
content: tcall,
scope: null,
on: null,
attrs: null,
attrsTranslationCtx: null,
},
},
};
}
}
@@ -744,9 +771,14 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
let on: ASTComponent["on"] = null;
let props: ASTComponent["props"] = null;
let propsTranslationCtx: ASTComponent["propsTranslationCtx"] = null;
for (let name of node.getAttributeNames()) {
const value = node.getAttribute(name)!;
if (name.startsWith("t-")) {
if (name.startsWith("t-translation-context-")) {
const attrName = name.slice(22);
propsTranslationCtx = propsTranslationCtx || {};
propsTranslationCtx[attrName] = value;
} else if (name.startsWith("t-")) {
if (name.startsWith("t-on-")) {
on = on || {};
on[name.slice(5)] = value;
@@ -794,12 +826,17 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
const slotAst = parseNode(slotNode, ctx);
let on: SlotDefinition["on"] = null;
let attrs: Attrs | null = null;
let attrsTranslationCtx: Attrs | null = null;
let scope: string | null = null;
for (let attributeName of slotNode.getAttributeNames()) {
const value = slotNode.getAttribute(attributeName)!;
if (attributeName === "t-slot-scope") {
scope = value;
continue;
} else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else if (attributeName.startsWith("t-on-")) {
on = on || {};
on[attributeName.slice(5)] = value;
@@ -809,7 +846,7 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
}
}
slots = slots || {};
slots[name] = { content: slotAst, on, attrs, scope };
slots[name] = { content: slotAst, on, attrs, attrsTranslationCtx, scope };
}
// default slot
@@ -817,10 +854,25 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
slots = slots || {};
// t-set-slot="default" has priority over content
if (defaultContent && !slots.default) {
slots.default = { content: defaultContent, on, attrs: null, scope: defaultSlotScope };
slots.default = {
content: defaultContent,
on,
attrs: null,
attrsTranslationCtx: null,
scope: defaultSlotScope,
};
}
}
return { type: ASTType.TComponent, name, isDynamic, dynamicProps, props, slots, on };
return {
type: ASTType.TComponent,
name,
isDynamic,
dynamicProps,
props,
propsTranslationCtx,
slots,
on,
};
}
// -----------------------------------------------------------------------------
@@ -834,12 +886,17 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
const name = node.getAttribute("t-slot")!;
node.removeAttribute("t-slot");
let attrs: Attrs | null = null;
let attrsTranslationCtx: Attrs | null = null;
let on: ASTComponent["on"] = null;
for (let attributeName of node.getAttributeNames()) {
const value = node.getAttribute(attributeName)!;
if (attributeName.startsWith("t-on-")) {
on = on || {};
on[attributeName.slice(5)] = value;
} else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else {
attrs = attrs || {};
attrs[attributeName] = value;
@@ -849,11 +906,16 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
type: ASTType.TSlot,
name,
attrs,
attrsTranslationCtx,
on,
defaultContent: parseChildNodes(node, ctx),
};
}
// -----------------------------------------------------------------------------
// Translation
// -----------------------------------------------------------------------------
function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
if (node.getAttribute("t-translation") !== "off") {
return null;
@@ -865,6 +927,23 @@ function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
};
}
// -----------------------------------------------------------------------------
// Translation Context
// -----------------------------------------------------------------------------
function parseTTranslationContext(node: Element, ctx: ParsingContext): AST | null {
const translationCtx = node.getAttribute("t-translation-context");
if (!translationCtx) {
return null;
}
node.removeAttribute("t-translation-context");
return {
type: ASTType.TTranslationContext,
content: parseNode(node, ctx),
translationCtx,
};
}
// -----------------------------------------------------------------------------
// Portal
// -----------------------------------------------------------------------------
+2 -2
View File
@@ -12,7 +12,7 @@ const bdom = { text, createBlock, list, multi, html, toggler, comment };
export interface TemplateSetConfig {
dev?: boolean;
translatableAttributes?: string[];
translateFn?: (s: string) => string;
translateFn?: (s: string, translationCtx: string) => string;
templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives;
@@ -27,7 +27,7 @@ export class TemplateSet {
rawTemplates: typeof globalTemplates = Object.create(globalTemplates);
templates: { [name: string]: Template } = {};
getRawTemplate?: (s: string) => Element | Function | string | void;
translateFn?: (s: string) => string;
translateFn?: (s: string, translationCtx: string) => string;
translatableAttributes?: string[];
Portal = Portal;
customDirectives: customDirectives;