Compare commits

..

11 Commits

Author SHA1 Message Date
Abdallah (ameb) 2b943e7278 Update templates.md
an => a
2025-05-13 09:43:58 +04:00
Louis Wicket (wil) 89cb00cc83 [IMP] compiler: make human-readable ARIA attributes translatable
ARIA attributes containing human-readable text should be translated.
This commit adds human-readable ARIA attributes to the list of the
attributes translated by OWL.
2025-04-03 21:40:27 +02:00
Romeo Fragomeli 56041bc133 [REL] v2.7.0
# v2.7.0

 - [IMP] runtime/utils: export htmlEscape and add tests
 - [FIX] utils: Correct validation of mount target in shadow DOM/iframe
 - [IMP] runtime: add markup tag function
2025-03-26 13:58:50 +01:00
Sébastien Theys e788e361c7 [IMP] runtime/utils: export htmlEscape and add tests
markup tag function requires markup awareness to determine whether a
given parameter should be escaped or not.

This implies that pre-escaped content should be properly marked'ed up to
avoid double escaping. Having to manually wrap all calls to escape
with markup is cumbersome and prone to issues (on top of having to be
validated by the security team for no reason).

This commit introduces a markup-aware escape function to resolve those
issues.
2025-03-26 13:52:35 +01:00
Achraf (abz) 9d378b0e7b [FIX] utils: Correct validation of mount target in shadow DOM/iframe
Previously, `validateTarget` only checked if the target element or its
host (if it was a ShadowRoot) was directly contained in the document body.
This failed in cases where the target element was nested inside a shadow
DOM, which itself was attached to the document.

This commit introduces a new helper `isAttachedToDocument` that
traverses through parent nodes and shadow roots to ensure that the
target is ultimately attached to the given document.
Additionally, it now throws a clear error if `document.defaultView` is
missing, indicating that the target document is detached or invalid.

This ensures proper validation of mount targets, including complex
scenarios with shadow roots and iframes.
2025-03-25 21:11:39 +01:00
Louis Wicket (wil) fd3c194525 [IMP] runtime: add markup tag function
Allows markup to be called as a tag function. The interpolated strings
are then safely escaped for injection in HTML code.

Example usage:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
const value = markup`<b>${maliciousInput}</b>`;
// no problem, maliciousInput is properly escaped
```
2025-03-25 15:39:26 +01:00
Romeo Fragomeli ac9ccb81ca [REL] v2.6.1
# v2.6.1

 - [FIX] code generator: prevent AST change
 - [IMP] runtime: simplify info message when running in dev mode
2025-03-05 09:38:08 +01:00
Michaël Mattiello 2b5cea944b [FIX] code generator: prevent AST change
This commit removes an AST change during the code generation of slots.
Before, the `compileTSlot` function deleted the `t-props` attribute
directly on `ast.attrs`. This creates wrong code when compiling a
second time as the `t-props` attribute does not exist anymore.
2025-03-05 09:27:43 +01:00
Géry Debongnie cf8039f643 [IMP] runtime: simplify info message when running in dev mode
The owl dev info message may be useful, but does not bring that much
value. Also, this is even slightly annoying while debugging odoo, since
it is common to have to go to dev mode, and the message takes some
visual space, which is a distraction.  In this commit, we simplify it to
just warn that owl is in dev mode.
2025-01-16 15:55:11 +01:00
Romeo Fragomeli aec2373e6d [REL] v2.6.0
# v2.6.0

 - [IMP] translation contexts
2025-01-15 11:40:33 +01:00
Mathieu Duckerts-Antoine a9be149e1e [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.
2025-01-15 11:01:53 +01:00
22 changed files with 1183 additions and 114 deletions
+13 -2
View File
@@ -57,7 +57,7 @@ extensions.
For reference, here is a list of all standard QWeb directives: For reference, here is a list of all standard QWeb directives:
| Name | Description | | Name | Description |
| ------------------------------ | --------------------------------------------------------------- | | ------------------------------ | ----------------------------------------------------------------------- |
| `t-esc` | [Outputting safely a value](#outputting-data) | | `t-esc` | [Outputting safely a value](#outputting-data) |
| `t-out` | [Outputting value, possibly without escaping](#outputting-data) | | `t-out` | [Outputting value, possibly without escaping](#outputting-data) |
| `t-set`, `t-value` | [Setting variables](#setting-variables) | | `t-set`, `t-value` | [Setting variables](#setting-variables) |
@@ -67,6 +67,8 @@ For reference, here is a list of all standard QWeb directives:
| `t-call` | [Rendering sub templates](#sub-templates) | | `t-call` | [Rendering sub templates](#sub-templates) |
| `t-debug`, `t-log` | [Debugging](#debugging) | | `t-debug`, `t-log` | [Debugging](#debugging) |
| `t-translation` | [Disabling the translation of a node](translations.md) | | `t-translation` | [Disabling the translation of a node](translations.md) |
| `t-translation-context` | [Context of translations within a node](translations.md) |
| `t-translation-context-*` | [Context of translation for a specific node attribute](translations.md) |
The component system in Owl requires additional directives, to express various The component system in Owl requires additional directives, to express various
needs. Here is a list of all Owl specific directives: needs. Here is a list of all Owl specific directives:
@@ -191,6 +193,15 @@ The first `t-out` will act as a `t-esc` directive, which means that the content
of `value1` will be escaped. However, since `value2` has been tagged as a markup, of `value1` will be escaped. However, since `value2` has been tagged as a markup,
this will be injected as html. this will be injected as html.
`markup` can also be used as a tag function, allowing the interpolated values to
be safely escaped:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
// <b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>
const value = markup`<b>${maliciousInput}</b>`;
```
### Setting Variables ### Setting Variables
QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ... QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ...
@@ -669,7 +680,7 @@ class MyComponent extends Component {
mount(MyComponent, document.body); mount(MyComponent, document.body);
``` ```
This function simply generates an unique string id, and register the template This function simply generates a unique string id, and register the template
under that id in the internals of Owl, then return the id. under that id in the internals of Owl, then return the id.
## Rendering svg ## Rendering svg
+34 -2
View File
@@ -1,17 +1,28 @@
# 🦉 Translations 🦉 # 🦉 Translations 🦉
If properly setup, Owl can translate all rendered templates. To do If properly setup, Owl can translate all rendered templates. To do
so, it needs a translate function, which takes a string and returns a string. so, it needs a translate function, which takes
- a string (the term to translate)
- a string (the translation context of the term)
and returns a string.
For example: For example:
```js ```js
const translations = { const translations = {
fr: {
hello: "bonjour", hello: "bonjour",
yes: "oui", yes: "oui",
no: "non", no: "non",
},
pt: {
hello: "bom dia",
yes: "sim",
no: "não",
},
}; };
const translateFn = (str) => translations[str] || str; const translateFn = (str, ctx) => translations[ctx]?.[str] || str;
const app = new App(Root, { templates, tranaslateFn }); const app = new App(Root, { templates, tranaslateFn });
// ... // ...
@@ -27,6 +38,11 @@ Once setup, all rendered templates will be translated using `translateFn`:
`placeholder`, `label` and `alt`, `placeholder`, `label` and `alt`,
- translating text nodes can be disabled with the special attribute `t-translation`, - translating text nodes can be disabled with the special attribute `t-translation`,
if its value is `off`. if its value is `off`.
- the translate function receives as second parameter a context that can be used
to contextualized the translation. That context can be set globally on a node
and its children by using `t-translation-context`. If a specific node
attribute `x` needs another context, that context can be specified with a
special directive `t-translation-context-x`.
So, with the above `translateFn`, the following templates: So, with the above `translateFn`, the following templates:
@@ -46,6 +62,22 @@ will be rendered as:
<input placeholder="bonjour" other="yes"/> <input placeholder="bonjour" other="yes"/>
``` ```
and the following template:
```xml
<div t-translation-context="fr" title="hello">hello</div>
<div>Are you sure?</div>
<input t-translation-context-placeholder="pt" placeholder="hello" other="yes"/>
```
will be rendered as:
```xml
<div title="bonjour">bonjour</div>
<div>Are you sure?</div>
<input placeholder="bom dia" other="yes"/>
```
Note that the translation is done during the compilation of the template, not Note that the translation is done during the compilation of the template, not
when it is rendered. when it is rendered.
+173 -42
View File
@@ -276,13 +276,39 @@ function inOwnerDocument(el) {
const rootNode = el.getRootNode(); const rootNode = el.getRootNode();
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
} }
/**
* Determine whether the given element is contained in a specific root documnet:
* either directly or with a shadow root in between or in an iframe.
*/
function isAttachedToDocument(element, documentElement) {
let current = element;
const shadowRoot = documentElement.defaultView.ShadowRoot;
while (current) {
if (current === documentElement) {
return true;
}
if (current.parentNode) {
current = current.parentNode;
}
else if (current instanceof shadowRoot && current.host) {
current = current.host;
}
else {
return false;
}
}
return false;
}
function validateTarget(target) { function validateTarget(target) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes // Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument; const document = target && target.ownerDocument;
if (document) { if (document) {
if (!document.defaultView) {
throw new OwlError("Cannot mount a component: the target document is not attached to a window (defaultView is missing)");
}
const HTMLElement = document.defaultView.HTMLElement; const HTMLElement = document.defaultView.HTMLElement;
if (target instanceof HTMLElement || target instanceof ShadowRoot) { if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) { if (!isAttachedToDocument(target, document)) {
throw new OwlError("Cannot mount a component on a detached dom node"); throw new OwlError("Cannot mount a component on a detached dom node");
} }
return; return;
@@ -319,12 +345,40 @@ async function loadFile(url) {
*/ */
class Markup extends String { class Markup extends String {
} }
/* function htmlEscape(str) {
* Marks a value as safe, that is, a value that can be injected as HTML directly. if (str instanceof Markup) {
* It should be used to wrap the value passed to a t-out directive to allow a raw rendering. return str;
*/ }
function markup(value) { if (str === undefined) {
return new Markup(value); return markup("");
}
if (typeof str === "number") {
return markup(String(str));
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].forEach((pairs) => {
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
});
return markup(str);
}
function markup(valueOrStrings, ...placeholders) {
if (!Array.isArray(valueOrStrings)) {
return new Markup(valueOrStrings);
}
const strings = valueOrStrings;
let acc = "";
let i = 0;
for (; i < placeholders.length; ++i) {
acc += strings[i] + htmlEscape(placeholders[i]);
}
acc += strings[i];
return new Markup(acc);
} }
function createEventHandler(rawEvent) { function createEventHandler(rawEvent) {
@@ -3738,6 +3792,7 @@ function createContext(parentCtx, params) {
index: 0, index: 0,
forceNewBlock: true, forceNewBlock: true,
translate: parentCtx.translate, translate: parentCtx.translate,
translationCtx: parentCtx.translationCtx,
tKeyExpr: null, tKeyExpr: null,
nameSpace: parentCtx.nameSpace, nameSpace: parentCtx.nameSpace,
tModelSelectedExpr: parentCtx.tModelSelectedExpr, tModelSelectedExpr: parentCtx.tModelSelectedExpr,
@@ -3840,6 +3895,7 @@ class CodeGenerator {
forceNewBlock: false, forceNewBlock: false,
isLast: true, isLast: true,
translate: true, translate: true,
translationCtx: "",
tKeyExpr: null, tKeyExpr: null,
}); });
// define blocks and utility functions // define blocks and utility functions
@@ -3977,9 +4033,9 @@ class CodeGenerator {
}) })
.join(""); .join("");
} }
translate(str) { translate(str, translationCtx) {
const match = translationRE.exec(str); const match = translationRE.exec(str);
return match[1] + this.translateFn(match[2]) + match[3]; return match[1] + this.translateFn(match[2], translationCtx) + match[3];
} }
/** /**
* @returns the newly created block name, if any * @returns the newly created block name, if any
@@ -4020,7 +4076,9 @@ class CodeGenerator {
return this.compileTSlot(ast, ctx); return this.compileTSlot(ast, ctx);
case 16 /* TTranslation */: case 16 /* TTranslation */:
return this.compileTTranslation(ast, ctx); return this.compileTTranslation(ast, ctx);
case 17 /* TPortal */: case 17 /* TTranslationContext */:
return this.compileTTranslationContext(ast, ctx);
case 18 /* TPortal */:
return this.compileTPortal(ast, ctx); return this.compileTPortal(ast, ctx);
} }
} }
@@ -4058,7 +4116,7 @@ class CodeGenerator {
let { block, forceNewBlock } = ctx; let { block, forceNewBlock } = ctx;
let value = ast.value; let value = ast.value;
if (value && ctx.translate !== false) { if (value && ctx.translate !== false) {
value = this.translate(value); value = this.translate(value, ctx.translationCtx);
} }
if (!ctx.inPreTag) { if (!ctx.inPreTag) {
value = value.replace(whitespaceRE, " "); value = value.replace(whitespaceRE, " ");
@@ -4093,6 +4151,7 @@ class CodeGenerator {
return `[${modifiersCode}${this.captureExpression(handler)}, ctx]`; return `[${modifiersCode}${this.captureExpression(handler)}, ctx]`;
} }
compileTDomNode(ast, ctx) { compileTDomNode(ast, ctx) {
var _a;
let { block, forceNewBlock } = ctx; let { block, forceNewBlock } = ctx;
const isNewBlock = !block || forceNewBlock || ast.dynamicTag !== null || ast.ns; const isNewBlock = !block || forceNewBlock || ast.dynamicTag !== null || ast.ns;
let codeIdx = this.target.code.length; let codeIdx = this.target.code.length;
@@ -4148,7 +4207,8 @@ class CodeGenerator {
} }
} }
else if (this.translatableAttributes.includes(key)) { else if (this.translatableAttributes.includes(key)) {
attrs[key] = this.translateFn(ast.attrs[key]); const attrTranslationCtx = ((_a = ast.attrsTranslationCtx) === null || _a === void 0 ? void 0 : _a[key]) || ctx.translationCtx;
attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx);
} }
else { else {
expr = `"${ast.attrs[key]}"`; expr = `"${ast.attrs[key]}"`;
@@ -4592,7 +4652,7 @@ class CodeGenerator {
else { else {
let value; let value;
if (ast.defaultValue) { if (ast.defaultValue) {
const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue); const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue);
if (ast.value) { if (ast.value) {
value = `withDefault(${expr}, ${defaultValue})`; value = `withDefault(${expr}, ${defaultValue})`;
} }
@@ -4626,9 +4686,10 @@ class CodeGenerator {
* "some-prop" "state" "'some-prop': ctx['state']" * "some-prop" "state" "'some-prop': ctx['state']"
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])" * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/ */
formatProp(name, value) { formatProp(name, value, attrsTranslationCtx, translationCtx) {
if (name.endsWith(".translate")) { if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value)); const attrTranslationCtx = (attrsTranslationCtx === null || attrsTranslationCtx === void 0 ? void 0 : attrsTranslationCtx[name]) || translationCtx;
value = toStringExpression(this.translateFn(value, attrTranslationCtx));
} }
else { else {
value = this.captureExpression(value); value = this.captureExpression(value);
@@ -4650,8 +4711,8 @@ class CodeGenerator {
name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`; name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`;
return `${name}: ${value || undefined}`; return `${name}: ${value || undefined}`;
} }
formatPropObject(obj) { formatPropObject(obj, attrsTranslationCtx, translationCtx) {
return Object.entries(obj).map(([k, v]) => this.formatProp(k, v)); return Object.entries(obj).map(([k, v]) => this.formatProp(k, v, attrsTranslationCtx, translationCtx));
} }
getPropString(props, dynProps) { getPropString(props, dynProps) {
let propString = `{${props.join(",")}}`; let propString = `{${props.join(",")}}`;
@@ -4664,7 +4725,9 @@ class CodeGenerator {
let { block } = ctx; let { block } = ctx;
// props // props
const hasSlotsProp = "slots" in (ast.props || {}); const hasSlotsProp = "slots" in (ast.props || {});
const props = ast.props ? this.formatPropObject(ast.props) : []; const props = ast.props
? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx)
: [];
// slots // slots
let slotDef = ""; let slotDef = "";
if (ast.slots) { if (ast.slots) {
@@ -4687,7 +4750,7 @@ class CodeGenerator {
params.push(`__scope: "${scope}"`); params.push(`__scope: "${scope}"`);
} }
if (ast.slots[slotName].attrs) { 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(", ")}}`; const slotInfo = `{${params.join(", ")}}`;
slotStr.push(`'${slotName}': ${slotInfo}`); slotStr.push(`'${slotName}': ${slotInfo}`);
@@ -4792,15 +4855,16 @@ class CodeGenerator {
isMultiple = isMultiple || this.slotNames.has(ast.name); isMultiple = isMultiple || this.slotNames.has(ast.name);
this.slotNames.add(ast.name); this.slotNames.add(ast.name);
} }
const dynProps = ast.attrs ? ast.attrs["t-props"] : null; const attrs = { ...ast.attrs };
if (ast.attrs) { const dynProps = attrs["t-props"];
delete ast.attrs["t-props"]; delete attrs["t-props"];
}
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key"; let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
if (isMultiple) { if (isMultiple) {
key = this.generateComponentKey(key); key = this.generateComponentKey(key);
} }
const props = ast.attrs ? this.formatPropObject(ast.attrs) : []; const props = ast.attrs
? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps); const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) { if (ast.defaultContent) {
const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx); const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
@@ -4833,6 +4897,12 @@ class CodeGenerator {
} }
return null; return null;
} }
compileTTranslationContext(ast, ctx) {
if (ast.content) {
return this.compileAST(ast.content, Object.assign({}, ctx, { translationCtx: ast.translationCtx }));
}
return null;
}
compileTPortal(ast, ctx) { compileTPortal(ast, ctx) {
if (!this.staticDefs.find((d) => d.id === "Portal")) { if (!this.staticDefs.find((d) => d.id === "Portal")) {
this.staticDefs.push({ id: "Portal", expr: `app.Portal` }); this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
@@ -4902,6 +4972,7 @@ function parseNode(node, ctx) {
parseTOutNode(node, ctx) || parseTOutNode(node, ctx) ||
parseTKey(node, ctx) || parseTKey(node, ctx) ||
parseTTranslation(node, ctx) || parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) || parseTSlot(node, ctx) ||
parseComponent(node, ctx) || parseComponent(node, ctx) ||
parseDOMNode(node, ctx) || parseDOMNode(node, ctx) ||
@@ -5010,6 +5081,7 @@ function parseDOMNode(node, ctx) {
node.removeAttribute("t-ref"); node.removeAttribute("t-ref");
const nodeAttrsNames = node.getAttributeNames(); const nodeAttrsNames = node.getAttributeNames();
let attrs = null; let attrs = null;
let attrsTranslationCtx = null;
let on = null; let on = null;
let model = null; let model = null;
for (let attr of nodeAttrsNames) { for (let attr of nodeAttrsNames) {
@@ -5070,6 +5142,11 @@ function parseDOMNode(node, ctx) {
else if (attr === "xmlns") { else if (attr === "xmlns") {
ns = value; ns = value;
} }
else if (attr.startsWith("t-translation-context-")) {
const attrName = attr.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
}
else if (attr !== "t-name") { else if (attr !== "t-name") {
if (attr.startsWith("t-") && !attr.startsWith("t-att")) { if (attr.startsWith("t-") && !attr.startsWith("t-att")) {
throw new OwlError(`Unknown QWeb directive: '${attr}'`); throw new OwlError(`Unknown QWeb directive: '${attr}'`);
@@ -5091,6 +5168,7 @@ function parseDOMNode(node, ctx) {
tag: tagName, tag: tagName,
dynamicTag, dynamicTag,
attrs, attrs,
attrsTranslationCtx,
on, on,
ref, ref,
content: children, content: children,
@@ -5231,7 +5309,15 @@ function parseTCall(node, ctx) {
if (ast && ast.type === 11 /* TComponent */) { if (ast && ast.type === 11 /* TComponent */) {
return { return {
...ast, ...ast,
slots: { default: { content: tcall, scope: null, on: null, attrs: null } }, slots: {
default: {
content: tcall,
scope: null,
on: null,
attrs: null,
attrsTranslationCtx: null,
},
},
}; };
} }
} }
@@ -5346,9 +5432,15 @@ function parseComponent(node, ctx) {
node.removeAttribute("t-slot-scope"); node.removeAttribute("t-slot-scope");
let on = null; let on = null;
let props = null; let props = null;
let propsTranslationCtx = null;
for (let name of node.getAttributeNames()) { for (let name of node.getAttributeNames()) {
const value = node.getAttribute(name); 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-")) { if (name.startsWith("t-on-")) {
on = on || {}; on = on || {};
on[name.slice(5)] = value; on[name.slice(5)] = value;
@@ -5392,6 +5484,7 @@ function parseComponent(node, ctx) {
const slotAst = parseNode(slotNode, ctx); const slotAst = parseNode(slotNode, ctx);
let on = null; let on = null;
let attrs = null; let attrs = null;
let attrsTranslationCtx = null;
let scope = null; let scope = null;
for (let attributeName of slotNode.getAttributeNames()) { for (let attributeName of slotNode.getAttributeNames()) {
const value = slotNode.getAttribute(attributeName); const value = slotNode.getAttribute(attributeName);
@@ -5399,6 +5492,11 @@ function parseComponent(node, ctx) {
scope = value; scope = value;
continue; continue;
} }
else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
}
else if (attributeName.startsWith("t-on-")) { else if (attributeName.startsWith("t-on-")) {
on = on || {}; on = on || {};
on[attributeName.slice(5)] = value; on[attributeName.slice(5)] = value;
@@ -5409,17 +5507,32 @@ function parseComponent(node, ctx) {
} }
} }
slots = slots || {}; slots = slots || {};
slots[name] = { content: slotAst, on, attrs, scope }; slots[name] = { content: slotAst, on, attrs, attrsTranslationCtx, scope };
} }
// default slot // default slot
const defaultContent = parseChildNodes(clone, ctx); const defaultContent = parseChildNodes(clone, ctx);
slots = slots || {}; slots = slots || {};
// t-set-slot="default" has priority over content // t-set-slot="default" has priority over content
if (defaultContent && !slots.default) { 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: 11 /* TComponent */, name, isDynamic, dynamicProps, props, slots, on }; return {
type: 11 /* TComponent */,
name,
isDynamic,
dynamicProps,
props,
propsTranslationCtx,
slots,
on,
};
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Slots // Slots
@@ -5431,6 +5544,7 @@ function parseTSlot(node, ctx) {
const name = node.getAttribute("t-slot"); const name = node.getAttribute("t-slot");
node.removeAttribute("t-slot"); node.removeAttribute("t-slot");
let attrs = null; let attrs = null;
let attrsTranslationCtx = null;
let on = null; let on = null;
for (let attributeName of node.getAttributeNames()) { for (let attributeName of node.getAttributeNames()) {
const value = node.getAttribute(attributeName); const value = node.getAttribute(attributeName);
@@ -5438,6 +5552,11 @@ function parseTSlot(node, ctx) {
on = on || {}; on = on || {};
on[attributeName.slice(5)] = value; on[attributeName.slice(5)] = value;
} }
else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
}
else { else {
attrs = attrs || {}; attrs = attrs || {};
attrs[attributeName] = value; attrs[attributeName] = value;
@@ -5447,10 +5566,14 @@ function parseTSlot(node, ctx) {
type: 14 /* TSlot */, type: 14 /* TSlot */,
name, name,
attrs, attrs,
attrsTranslationCtx,
on, on,
defaultContent: parseChildNodes(node, ctx), defaultContent: parseChildNodes(node, ctx),
}; };
} }
// -----------------------------------------------------------------------------
// Translation
// -----------------------------------------------------------------------------
function parseTTranslation(node, ctx) { function parseTTranslation(node, ctx) {
if (node.getAttribute("t-translation") !== "off") { if (node.getAttribute("t-translation") !== "off") {
return null; return null;
@@ -5462,6 +5585,21 @@ function parseTTranslation(node, ctx) {
}; };
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Translation Context
// -----------------------------------------------------------------------------
function parseTTranslationContext(node, ctx) {
const translationCtx = node.getAttribute("t-translation-context");
if (!translationCtx) {
return null;
}
node.removeAttribute("t-translation-context");
return {
type: 17 /* TTranslationContext */,
content: parseNode(node, ctx),
translationCtx,
};
}
// -----------------------------------------------------------------------------
// Portal // Portal
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function parseTPortal(node, ctx) { function parseTPortal(node, ctx) {
@@ -5478,7 +5616,7 @@ function parseTPortal(node, ctx) {
}; };
} }
return { return {
type: 17 /* TPortal */, type: 18 /* TPortal */,
target, target,
content, content,
}; };
@@ -5620,7 +5758,7 @@ function compile(template, options = {
} }
// do not modify manually. This file is generated by the release script. // do not modify manually. This file is generated by the release script.
const version = "2.5.3"; const version = "2.7.0";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Scheduler // Scheduler
@@ -5715,13 +5853,6 @@ class Scheduler {
Scheduler.requestAnimationFrame = window.requestAnimationFrame.bind(window); Scheduler.requestAnimationFrame = window.requestAnimationFrame.bind(window);
let hasBeenLogged = false; let hasBeenLogged = false;
const DEV_MSG = () => {
const hash = window.owl ? window.owl.__info__.hash : "master";
return `Owl is running in 'dev' mode.
This is not suitable for production use.
See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration for more information.`;
};
const apps = new Set(); const apps = new Set();
window.__OWL_DEVTOOLS__ || (window.__OWL_DEVTOOLS__ = { apps, Fiber, RootFiber, toRaw, reactive }); window.__OWL_DEVTOOLS__ || (window.__OWL_DEVTOOLS__ = { apps, Fiber, RootFiber, toRaw, reactive });
class App extends TemplateSet { class App extends TemplateSet {
@@ -5738,7 +5869,7 @@ class App extends TemplateSet {
} }
this.warnIfNoStaticProps = config.warnIfNoStaticProps || false; this.warnIfNoStaticProps = config.warnIfNoStaticProps || false;
if (this.dev && !config.test && !hasBeenLogged) { if (this.dev && !config.test && !hasBeenLogged) {
console.info(DEV_MSG()); console.info(`Owl is running in 'dev' mode.`);
hasBeenLogged = true; hasBeenLogged = true;
} }
const env = config.env || {}; const env = config.env || {};
@@ -6095,9 +6226,9 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
}); });
}; };
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml }; export { App, Component, EventBus, OwlError, __info__, batched, blockDom, htmlEscape, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
__info__.date = '2025-01-10T10:10:53.709Z'; __info__.date = '2025-03-26T12:58:40.935Z';
__info__.hash = 'b31fa81'; __info__.hash = 'e788e36';
__info__.url = 'https://github.com/odoo/owl'; __info__.url = 'https://github.com/odoo/owl';
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@odoo/owl", "name": "@odoo/owl",
"version": "2.5.3", "version": "2.7.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@odoo/owl", "name": "@odoo/owl",
"version": "2.5.3", "version": "2.7.0",
"description": "Odoo Web Library (OWL)", "description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js", "main": "dist/owl.cjs.js",
"module": "dist/owl.es.js", "module": "dist/owl.es.js",
+65 -19
View File
@@ -24,6 +24,7 @@ import {
ASTTOut, ASTTOut,
ASTTPortal, ASTTPortal,
ASTTranslation, ASTTranslation,
ASTTranslationContext,
ASTTSet, ASTTSet,
ASTType, ASTType,
Attrs, Attrs,
@@ -35,7 +36,7 @@ type BlockType = "block" | "text" | "multi" | "list" | "html" | "comment";
const whitespaceRE = /\s+/g; const whitespaceRE = /\s+/g;
export interface Config { export interface Config {
translateFn?: (s: string) => string; translateFn?: (s: string, translationCtx: string) => string;
translatableAttributes?: string[]; translatableAttributes?: string[];
dev?: boolean; dev?: boolean;
} }
@@ -171,6 +172,7 @@ interface Context {
forceNewBlock: boolean; forceNewBlock: boolean;
isLast?: boolean; isLast?: boolean;
translate: boolean; translate: boolean;
translationCtx: string;
tKeyExpr: string | null; tKeyExpr: string | null;
nameSpace?: string; nameSpace?: string;
tModelSelectedExpr?: string; tModelSelectedExpr?: string;
@@ -185,6 +187,7 @@ function createContext(parentCtx: Context, params?: Partial<Context>): Context {
index: 0, index: 0,
forceNewBlock: true, forceNewBlock: true,
translate: parentCtx.translate, translate: parentCtx.translate,
translationCtx: parentCtx.translationCtx,
tKeyExpr: null, tKeyExpr: null,
nameSpace: parentCtx.nameSpace, nameSpace: parentCtx.nameSpace,
tModelSelectedExpr: parentCtx.tModelSelectedExpr, tModelSelectedExpr: parentCtx.tModelSelectedExpr,
@@ -251,7 +254,16 @@ class CodeTarget {
} }
} }
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"]; const TRANSLATABLE_ATTRS = [
"alt",
"aria-label",
"aria-placeholder",
"aria-roledescription",
"aria-valuetext",
"label",
"placeholder",
"title",
];
const translationRE = /^(\s*)([\s\S]+?)(\s*)$/; const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
export class CodeGenerator { export class CodeGenerator {
@@ -263,7 +275,7 @@ export class CodeGenerator {
target = new CodeTarget("template"); target = new CodeTarget("template");
templateName?: string; templateName?: string;
dev: boolean; dev: boolean;
translateFn: (s: string) => string; translateFn: (s: string, translationCtx: string) => string;
translatableAttributes: string[] = TRANSLATABLE_ATTRS; translatableAttributes: string[] = TRANSLATABLE_ATTRS;
ast: AST; ast: AST;
staticDefs: { id: string; expr: string }[] = []; staticDefs: { id: string; expr: string }[] = [];
@@ -303,6 +315,7 @@ export class CodeGenerator {
forceNewBlock: false, forceNewBlock: false,
isLast: true, isLast: true,
translate: true, translate: true,
translationCtx: "",
tKeyExpr: null, tKeyExpr: null,
}); });
// define blocks and utility functions // define blocks and utility functions
@@ -457,9 +470,9 @@ export class CodeGenerator {
.join(""); .join("");
} }
translate(str: string): string { translate(str: string, translationCtx: string): string {
const match = translationRE.exec(str) as any; 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 +514,8 @@ export class CodeGenerator {
return this.compileTSlot(ast, ctx); return this.compileTSlot(ast, ctx);
case ASTType.TTranslation: case ASTType.TTranslation:
return this.compileTTranslation(ast, ctx); return this.compileTTranslation(ast, ctx);
case ASTType.TTranslationContext:
return this.compileTTranslationContext(ast, ctx);
case ASTType.TPortal: case ASTType.TPortal:
return this.compileTPortal(ast, ctx); return this.compileTPortal(ast, ctx);
} }
@@ -542,7 +557,7 @@ export class CodeGenerator {
let value = ast.value; let value = ast.value;
if (value && ctx.translate !== false) { if (value && ctx.translate !== false) {
value = this.translate(value); value = this.translate(value, ctx.translationCtx);
} }
if (!ctx.inPreTag) { if (!ctx.inPreTag) {
value = value.replace(whitespaceRE, " "); value = value.replace(whitespaceRE, " ");
@@ -631,7 +646,8 @@ export class CodeGenerator {
} }
} }
} else if (this.translatableAttributes.includes(key)) { } 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 { } else {
expr = `"${ast.attrs[key]}"`; expr = `"${ast.attrs[key]}"`;
attrName = key; attrName = key;
@@ -1104,7 +1120,7 @@ export class CodeGenerator {
let value: string; let value: string;
if (ast.defaultValue) { if (ast.defaultValue) {
const defaultValue = toStringExpression( const defaultValue = toStringExpression(
ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue
); );
if (ast.value) { if (ast.value) {
value = `withDefault(${expr}, ${defaultValue})`; value = `withDefault(${expr}, ${defaultValue})`;
@@ -1139,9 +1155,15 @@ export class CodeGenerator {
* "some-prop" "state" "'some-prop': ctx['state']" * "some-prop" "state" "'some-prop': ctx['state']"
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])" * "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")) { if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value)); const attrTranslationCtx = attrsTranslationCtx?.[name] || translationCtx;
value = toStringExpression(this.translateFn(value, attrTranslationCtx));
} else { } else {
value = this.captureExpression(value); value = this.captureExpression(value);
} }
@@ -1163,8 +1185,14 @@ export class CodeGenerator {
return `${name}: ${value || undefined}`; return `${name}: ${value || undefined}`;
} }
formatPropObject(obj: { [prop: string]: any }): string[] { formatPropObject(
return Object.entries(obj).map(([k, v]) => this.formatProp(k, v)); 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 { getPropString(props: string[], dynProps: string | null): string {
@@ -1181,7 +1209,9 @@ export class CodeGenerator {
let { block } = ctx; let { block } = ctx;
// props // props
const hasSlotsProp = "slots" in (ast.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 // slots
let slotDef: string = ""; let slotDef: string = "";
@@ -1205,7 +1235,13 @@ export class CodeGenerator {
params.push(`__scope: "${scope}"`); params.push(`__scope: "${scope}"`);
} }
if (ast.slots[slotName].attrs) { 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(", ")}}`; const slotInfo = `{${params.join(", ")}}`;
slotStr.push(`'${slotName}': ${slotInfo}`); slotStr.push(`'${slotName}': ${slotInfo}`);
@@ -1323,16 +1359,17 @@ export class CodeGenerator {
isMultiple = isMultiple || this.slotNames.has(ast.name); isMultiple = isMultiple || this.slotNames.has(ast.name);
this.slotNames.add(ast.name); this.slotNames.add(ast.name);
} }
const dynProps = ast.attrs ? ast.attrs["t-props"] : null; const attrs = { ...ast.attrs };
if (ast.attrs) { const dynProps = attrs["t-props"];
delete ast.attrs["t-props"]; delete attrs["t-props"];
}
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key"; let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
if (isMultiple) { if (isMultiple) {
key = this.generateComponentKey(key); key = this.generateComponentKey(key);
} }
const props = ast.attrs ? this.formatPropObject(ast.attrs) : []; const props = ast.attrs
? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps); const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) { if (ast.defaultContent) {
const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx); const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
@@ -1365,6 +1402,15 @@ export class CodeGenerator {
} }
return null; 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 { compileTPortal(ast: ASTTPortal, ctx: Context): string {
if (!this.staticDefs.find((d) => d.id === "Portal")) { if (!this.staticDefs.find((d) => d.id === "Portal")) {
this.staticDefs.push({ id: "Portal", expr: `app.Portal` }); this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
+84 -5
View File
@@ -27,6 +27,7 @@ export const enum ASTType {
TSlot, TSlot,
TCallBlock, TCallBlock,
TTranslation, TTranslation,
TTranslationContext,
TPortal, TPortal,
} }
@@ -56,6 +57,7 @@ export interface ASTDomNode {
tag: string; tag: string;
content: AST[]; content: AST[];
attrs: Attrs | null; attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
ref: string | null; ref: string | null;
on: EventHandlers | null; on: EventHandlers | null;
model: TModelInfo | null; model: TModelInfo | null;
@@ -127,6 +129,7 @@ interface SlotDefinition {
scope: string | null; scope: string | null;
on: EventHandlers | null; on: EventHandlers | null;
attrs: Attrs | null; attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
} }
export interface ASTComponent { export interface ASTComponent {
@@ -136,6 +139,7 @@ export interface ASTComponent {
dynamicProps: string | null; dynamicProps: string | null;
on: EventHandlers | null; on: EventHandlers | null;
props: { [name: string]: string } | null; props: { [name: string]: string } | null;
propsTranslationCtx: { [name: string]: string } | null;
slots: { [name: string]: SlotDefinition } | null; slots: { [name: string]: SlotDefinition } | null;
} }
@@ -143,6 +147,7 @@ export interface ASTSlot {
type: ASTType.TSlot; type: ASTType.TSlot;
name: string; name: string;
attrs: Attrs | null; attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
on: EventHandlers | null; on: EventHandlers | null;
defaultContent: AST | null; defaultContent: AST | null;
} }
@@ -168,6 +173,12 @@ export interface ASTTranslation {
content: AST | null; content: AST | null;
} }
export interface ASTTranslationContext {
type: ASTType.TTranslationContext;
content: AST | null;
translationCtx: string;
}
export interface ASTTPortal { export interface ASTTPortal {
type: ASTType.TPortal; type: ASTType.TPortal;
target: string; target: string;
@@ -192,6 +203,7 @@ export type AST =
| ASTLog | ASTLog
| ASTDebug | ASTDebug
| ASTTranslation | ASTTranslation
| ASTTranslationContext
| ASTTPortal; | ASTTPortal;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -245,6 +257,7 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
parseTOutNode(node, ctx) || parseTOutNode(node, ctx) ||
parseTKey(node, ctx) || parseTKey(node, ctx) ||
parseTTranslation(node, ctx) || parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) || parseTSlot(node, ctx) ||
parseComponent(node, ctx) || parseComponent(node, ctx) ||
parseDOMNode(node, ctx) || parseDOMNode(node, ctx) ||
@@ -368,6 +381,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
const nodeAttrsNames = node.getAttributeNames(); const nodeAttrsNames = node.getAttributeNames();
let attrs: ASTDomNode["attrs"] = null; let attrs: ASTDomNode["attrs"] = null;
let attrsTranslationCtx: ASTDomNode["attrsTranslationCtx"] = null;
let on: EventHandlers | null = null; let on: EventHandlers | null = null;
let model: TModelInfo | 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}'`); throw new OwlError(`Invalid attribute: '${attr}'`);
} else if (attr === "xmlns") { } else if (attr === "xmlns") {
ns = value; ns = value;
} else if (attr.startsWith("t-translation-context-")) {
const attrName = attr.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else if (attr !== "t-name") { } else if (attr !== "t-name") {
if (attr.startsWith("t-") && !attr.startsWith("t-att")) { if (attr.startsWith("t-") && !attr.startsWith("t-att")) {
throw new OwlError(`Unknown QWeb directive: '${attr}'`); throw new OwlError(`Unknown QWeb directive: '${attr}'`);
@@ -450,6 +468,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
tag: tagName, tag: tagName,
dynamicTag, dynamicTag,
attrs, attrs,
attrsTranslationCtx,
on, on,
ref, ref,
content: children, content: children,
@@ -609,7 +628,15 @@ function parseTCall(node: Element, ctx: ParsingContext): AST | null {
if (ast && ast.type === ASTType.TComponent) { if (ast && ast.type === ASTType.TComponent) {
return { return {
...ast, ...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 on: ASTComponent["on"] = null;
let props: ASTComponent["props"] = null; let props: ASTComponent["props"] = null;
let propsTranslationCtx: ASTComponent["propsTranslationCtx"] = null;
for (let name of node.getAttributeNames()) { for (let name of node.getAttributeNames()) {
const value = node.getAttribute(name)!; 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-")) { if (name.startsWith("t-on-")) {
on = on || {}; on = on || {};
on[name.slice(5)] = value; on[name.slice(5)] = value;
@@ -794,12 +826,17 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
const slotAst = parseNode(slotNode, ctx); const slotAst = parseNode(slotNode, ctx);
let on: SlotDefinition["on"] = null; let on: SlotDefinition["on"] = null;
let attrs: Attrs | null = null; let attrs: Attrs | null = null;
let attrsTranslationCtx: Attrs | null = null;
let scope: string | null = null; let scope: string | null = null;
for (let attributeName of slotNode.getAttributeNames()) { for (let attributeName of slotNode.getAttributeNames()) {
const value = slotNode.getAttribute(attributeName)!; const value = slotNode.getAttribute(attributeName)!;
if (attributeName === "t-slot-scope") { if (attributeName === "t-slot-scope") {
scope = value; scope = value;
continue; continue;
} else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else if (attributeName.startsWith("t-on-")) { } else if (attributeName.startsWith("t-on-")) {
on = on || {}; on = on || {};
on[attributeName.slice(5)] = value; on[attributeName.slice(5)] = value;
@@ -809,7 +846,7 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
} }
} }
slots = slots || {}; slots = slots || {};
slots[name] = { content: slotAst, on, attrs, scope }; slots[name] = { content: slotAst, on, attrs, attrsTranslationCtx, scope };
} }
// default slot // default slot
@@ -817,10 +854,25 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
slots = slots || {}; slots = slots || {};
// t-set-slot="default" has priority over content // t-set-slot="default" has priority over content
if (defaultContent && !slots.default) { 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")!; const name = node.getAttribute("t-slot")!;
node.removeAttribute("t-slot"); node.removeAttribute("t-slot");
let attrs: Attrs | null = null; let attrs: Attrs | null = null;
let attrsTranslationCtx: Attrs | null = null;
let on: ASTComponent["on"] = null; let on: ASTComponent["on"] = null;
for (let attributeName of node.getAttributeNames()) { for (let attributeName of node.getAttributeNames()) {
const value = node.getAttribute(attributeName)!; const value = node.getAttribute(attributeName)!;
if (attributeName.startsWith("t-on-")) { if (attributeName.startsWith("t-on-")) {
on = on || {}; on = on || {};
on[attributeName.slice(5)] = value; on[attributeName.slice(5)] = value;
} else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else { } else {
attrs = attrs || {}; attrs = attrs || {};
attrs[attributeName] = value; attrs[attributeName] = value;
@@ -849,11 +906,16 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
type: ASTType.TSlot, type: ASTType.TSlot,
name, name,
attrs, attrs,
attrsTranslationCtx,
on, on,
defaultContent: parseChildNodes(node, ctx), defaultContent: parseChildNodes(node, ctx),
}; };
} }
// -----------------------------------------------------------------------------
// Translation
// -----------------------------------------------------------------------------
function parseTTranslation(node: Element, ctx: ParsingContext): AST | null { function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
if (node.getAttribute("t-translation") !== "off") { if (node.getAttribute("t-translation") !== "off") {
return null; 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 // Portal
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+1 -10
View File
@@ -29,15 +29,6 @@ export interface AppConfig<P, E> extends TemplateSetConfig, RootConfig<P, E> {
let hasBeenLogged = false; let hasBeenLogged = false;
export const DEV_MSG = () => {
const hash = (window as any).owl ? (window as any).owl.__info__.hash : "master";
return `Owl is running in 'dev' mode.
This is not suitable for production use.
See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration for more information.`;
};
const apps = new Set<App>(); const apps = new Set<App>();
declare global { declare global {
@@ -88,7 +79,7 @@ export class App<
} }
this.warnIfNoStaticProps = config.warnIfNoStaticProps || false; this.warnIfNoStaticProps = config.warnIfNoStaticProps || false;
if (this.dev && !config.test && !hasBeenLogged) { if (this.dev && !config.test && !hasBeenLogged) {
console.info(DEV_MSG()); console.info(`Owl is running in 'dev' mode.`);
hasBeenLogged = true; hasBeenLogged = true;
} }
const env = config.env || {}; const env = config.env || {};
+1 -1
View File
@@ -41,7 +41,7 @@ export { useComponent, useState } from "./component_node";
export { status } from "./status"; export { status } from "./status";
export { reactive, markRaw, toRaw } from "./reactivity"; export { reactive, markRaw, toRaw } from "./reactivity";
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks"; export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
export { batched, EventBus, whenReady, loadFile, markup } from "./utils"; export { batched, EventBus, htmlEscape, whenReady, loadFile, markup } from "./utils";
export { export {
onWillStart, onWillStart,
onMounted, onMounted,
+2 -2
View File
@@ -12,7 +12,7 @@ const bdom = { text, createBlock, list, multi, html, toggler, comment };
export interface TemplateSetConfig { export interface TemplateSetConfig {
dev?: boolean; dev?: boolean;
translatableAttributes?: string[]; translatableAttributes?: string[];
translateFn?: (s: string) => string; translateFn?: (s: string, translationCtx: string) => string;
templates?: string | Document | Record<string, string>; templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void; getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives; customDirectives?: customDirectives;
@@ -27,7 +27,7 @@ export class TemplateSet {
rawTemplates: typeof globalTemplates = Object.create(globalTemplates); rawTemplates: typeof globalTemplates = Object.create(globalTemplates);
templates: { [name: string]: Template } = {}; templates: { [name: string]: Template } = {};
getRawTemplate?: (s: string) => Element | Function | string | void; getRawTemplate?: (s: string) => Element | Function | string | void;
translateFn?: (s: string) => string; translateFn?: (s: string, translationCtx: string) => string;
translatableAttributes?: string[]; translatableAttributes?: string[];
Portal = Portal; Portal = Portal;
customDirectives: customDirectives; customDirectives: customDirectives;
+74 -4
View File
@@ -35,13 +35,43 @@ export function inOwnerDocument(el?: HTMLElement) {
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
} }
/**
* Determine whether the given element is contained in a specific root documnet:
* either directly or with a shadow root in between or in an iframe.
*/
function isAttachedToDocument(
element: HTMLElement | ShadowRoot,
documentElement: Document
): boolean {
let current: Node = element;
const shadowRoot = documentElement.defaultView!.ShadowRoot;
while (current) {
if (current === documentElement) {
return true;
}
if (current.parentNode) {
current = current.parentNode;
} else if (current instanceof shadowRoot && current.host) {
current = current.host;
} else {
return false;
}
}
return false;
}
export function validateTarget(target: HTMLElement | ShadowRoot) { export function validateTarget(target: HTMLElement | ShadowRoot) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes // Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument; const document = target && target.ownerDocument;
if (document) { if (document) {
const HTMLElement = document.defaultView!.HTMLElement; if (!document.defaultView) {
throw new OwlError(
"Cannot mount a component: the target document is not attached to a window (defaultView is missing)"
);
}
const HTMLElement = document.defaultView.HTMLElement;
if (target instanceof HTMLElement || target instanceof ShadowRoot) { if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) { if (!isAttachedToDocument(target, document)) {
throw new OwlError("Cannot mount a component on a detached dom node"); throw new OwlError("Cannot mount a component on a detached dom node");
} }
return; return;
@@ -81,10 +111,50 @@ export async function loadFile(url: string): Promise<string> {
*/ */
export class Markup extends String {} export class Markup extends String {}
export function htmlEscape(str: any): Markup {
if (str instanceof Markup) {
return str;
}
if (str === undefined) {
return markup("");
}
if (typeof str === "number") {
return markup(String(str));
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].forEach((pairs) => {
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
});
return markup(str);
}
/* /*
* Marks a value as safe, that is, a value that can be injected as HTML directly. * Marks a value as safe, that is, a value that can be injected as HTML directly.
* It should be used to wrap the value passed to a t-out directive to allow a raw rendering. * It should be used to wrap the value passed to a t-out directive to allow a raw rendering.
*
* If called as a tag function, the interpolated strings are escaped.
*/ */
export function markup(value: any) { export function markup(strings: TemplateStringsArray, ...placeholders: unknown[]): Markup;
return new Markup(value); export function markup(value: string): Markup;
export function markup(
valueOrStrings: string | TemplateStringsArray,
...placeholders: unknown[]
): Markup {
if (!Array.isArray(valueOrStrings)) {
return new Markup(valueOrStrings);
}
const strings = valueOrStrings;
let acc = "";
let i = 0;
for (; i < placeholders.length; ++i) {
acc += strings[i] + htmlEscape(placeholders[i]);
}
acc += strings[i];
return new Markup(acc);
} }
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script. // do not modify manually. This file is generated by the release script.
export const version = "2.5.3"; export const version = "2.7.0";
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-slot compile t-props correctly multiple time 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { callSlot } = helpers;
return function template(ctx, node, key = \\"\\") {
return callSlot(ctx, node, key, 'default', false, Object.assign({}, {a:1}));
}
}"
`;
@@ -1,5 +1,128 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`translation context body of t-sets are translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"label\\", \`traduit\`);
const b2 = text(ctx['label']);
return multi([b2]);
}
}"
`;
exports[`translation context default slot params and content translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { callSlot } = helpers;
let block1 = createBlock(\`<div><block-child-0/></div>\`);
function defaultContent1(ctx, node, key = \\"\\") {
return text(\` foo \`);
}
return function template(ctx, node, key = \\"\\") {
const b3 = callSlot(ctx, node, key, 'default', false, {param: \`param\`,title: \`título\`}, defaultContent1.bind(this));
return block1([], [b3]);
}
}"
`;
exports[`translation context props with modifier .translate are translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`ChildComponent\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({text: \`jeu\`}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`translation context props with modifier .translate are translated in context 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<span><block-text-0/></span>\`);
return function template(ctx, node, key = \\"\\") {
let txt1 = ctx['props'].text;
return block1([txt1]);
}
}"
`;
exports[`translation context slot attrs and text contents are translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { capture, markRaw } = helpers;
const comp1 = app.createComponent(\`ChildComponent\`, true, true, false, []);
function slot1(ctx, node, key = \\"\\") {
return text(\`jeu\`);
}
return function template(ctx, node, key = \\"\\") {
const ctx1 = capture(ctx);
return comp1({slots: markRaw({'a': {__render: slot1.bind(this), __ctx: ctx1, title: \`título\`}})}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`translation context slot attrs and text contents are translated in context 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { callSlot } = helpers;
let block1 = createBlock(\`<div><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
const b2 = callSlot(ctx, node, key, 'a', false, {});
return block1([], [b2]);
}
}"
`;
exports[`translation context translation of attributes in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div title=\\"titre\\" label=\\"game\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`translation context translation of text in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block2 = createBlock(\`<div>word</div>\`);
let block3 = createBlock(\`<div>mot</div>\`);
return function template(ctx, node, key = \\"\\") {
const b2 = block2();
const b3 = block3();
return multi([b2, b3]);
}
}"
`;
exports[`translation support body of t-sets are translated 1`] = ` exports[`translation support body of t-sets are translated 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
+210 -4
View File
@@ -43,6 +43,7 @@ describe("qweb parser", () => {
dynamicTag: null, dynamicTag: null,
content: [], content: [],
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -70,6 +71,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -84,6 +86,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -98,6 +101,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -109,6 +113,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -128,6 +133,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -139,6 +145,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -156,6 +163,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -181,6 +189,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -201,6 +210,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -223,6 +233,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -246,6 +257,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -262,6 +274,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: { class: "abc" }, attrs: { class: "abc" },
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -280,6 +293,7 @@ describe("qweb parser", () => {
height: "90px", height: "90px",
width: "100px", width: "100px",
}, },
attrsTranslationCtx: null,
content: [ content: [
{ {
attrs: { attrs: {
@@ -290,6 +304,7 @@ describe("qweb parser", () => {
stroke: "green", stroke: "green",
"stroke-width": "1", "stroke-width": "1",
}, },
attrsTranslationCtx: null,
content: [], content: [],
dynamicTag: null, dynamicTag: null,
model: null, model: null,
@@ -312,6 +327,7 @@ describe("qweb parser", () => {
parse(`<g><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/></g>`) parse(`<g><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/></g>`)
).toEqual({ ).toEqual({
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [ content: [
{ {
attrs: { attrs: {
@@ -322,6 +338,7 @@ describe("qweb parser", () => {
stroke: "green", stroke: "green",
"stroke-width": "1", "stroke-width": "1",
}, },
attrsTranslationCtx: null,
content: [], content: [],
dynamicTag: null, dynamicTag: null,
model: null, model: null,
@@ -348,6 +365,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
content: [ content: [
@@ -356,6 +374,7 @@ describe("qweb parser", () => {
tag: "pre", tag: "pre",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
content: [], content: [],
@@ -391,6 +410,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -413,6 +433,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -455,6 +476,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -469,6 +491,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -489,6 +512,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -530,6 +554,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -607,6 +632,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -626,6 +652,7 @@ describe("qweb parser", () => {
tag: "h1", tag: "h1",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -639,6 +666,7 @@ describe("qweb parser", () => {
tag: "h2", tag: "h2",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -685,6 +713,7 @@ describe("qweb parser", () => {
{ {
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
@@ -705,6 +734,7 @@ describe("qweb parser", () => {
{ {
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
@@ -742,6 +772,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -811,6 +842,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -853,6 +885,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -887,6 +920,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -920,6 +954,7 @@ describe("qweb parser", () => {
"t-att-selected": "category.id==options.active_category_id", "t-att-selected": "category.id==options.active_category_id",
"t-att-value": "category.id", "t-att-value": "category.id",
}, },
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -940,6 +975,7 @@ describe("qweb parser", () => {
).toEqual({ ).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -987,6 +1023,7 @@ describe("qweb parser", () => {
ref: null, ref: null,
model: null, model: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
ns: null, ns: null,
content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }], content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }],
}, },
@@ -1010,6 +1047,7 @@ describe("qweb parser", () => {
name: "Comp", name: "Comp",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
slots: null, slots: null,
on: null, on: null,
}, },
@@ -1099,6 +1137,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -1139,6 +1178,7 @@ describe("qweb parser", () => {
tag: "button", tag: "button",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: { click: "add" }, on: { click: "add" },
ref: null, ref: null,
model: null, model: null,
@@ -1175,6 +1215,7 @@ describe("qweb parser", () => {
tag: "select", tag: "select",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
content: [ content: [
@@ -1183,6 +1224,7 @@ describe("qweb parser", () => {
tag: "option", tag: "option",
dynamicTag: null, dynamicTag: null,
attrs: { value: "1" }, attrs: { value: "1" },
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
content: [], content: [],
@@ -1212,6 +1254,7 @@ describe("qweb parser", () => {
tag: "select", tag: "select",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
content: [ content: [
@@ -1220,6 +1263,7 @@ describe("qweb parser", () => {
tag: "option", tag: "option",
dynamicTag: null, dynamicTag: null,
attrs: { "t-att-value": "valueVar" }, attrs: { "t-att-value": "valueVar" },
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
content: [], content: [],
@@ -1251,6 +1295,7 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: null, slots: null,
isDynamic: false, isDynamic: false,
@@ -1263,6 +1308,7 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: { a: "1", b: "'b'" }, props: { a: "1", b: "'b'" },
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: null, slots: null,
@@ -1275,6 +1321,7 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: "state", dynamicProps: "state",
props: { a: "1" }, props: { a: "1" },
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: null, slots: null,
@@ -1287,6 +1334,7 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: { click: "someMethod" }, on: { click: "someMethod" },
slots: null, slots: null,
@@ -1329,12 +1377,14 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
default: { default: {
content: { type: ASTType.Text, value: "foo" }, content: { type: ASTType.Text, value: "foo" },
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
}, },
@@ -1350,12 +1400,14 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
default: { default: {
content: { type: ASTType.Text, value: "foo" }, content: { type: ASTType.Text, value: "foo" },
attrs: { param: "param" }, attrs: { param: "param" },
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
}, },
@@ -1370,6 +1422,7 @@ describe("qweb parser", () => {
isDynamic: false, isDynamic: false,
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: { slots: {
default: { default: {
@@ -1381,6 +1434,7 @@ describe("qweb parser", () => {
tag: "span", tag: "span",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
ref: null, ref: null,
model: null, model: null,
@@ -1392,6 +1446,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
ref: null, ref: null,
model: null, model: null,
@@ -1401,6 +1456,7 @@ describe("qweb parser", () => {
], ],
}, },
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
}, },
@@ -1415,9 +1471,11 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
on: null, on: null,
props: null, props: null,
propsTranslationCtx: null,
slots: { slots: {
mySlot: { mySlot: {
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: null, content: null,
on: null, on: null,
scope: null, scope: null,
@@ -1434,9 +1492,16 @@ describe("qweb parser", () => {
isDynamic: false, isDynamic: false,
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: { slots: {
name: { content: { type: ASTType.Text, value: "foo" }, attrs: null, on: null, scope: null }, name: {
content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
}, },
}); });
}); });
@@ -1448,11 +1513,13 @@ describe("qweb parser", () => {
isDynamic: false, isDynamic: false,
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: { slots: {
name: { name: {
content: { type: ASTType.Text, value: "foo" }, content: { type: ASTType.Text, value: "foo" },
attrs: { param: "param" }, attrs: { param: "param" },
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
}, },
@@ -1469,12 +1536,14 @@ describe("qweb parser", () => {
isDynamic: false, isDynamic: false,
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: { slots: {
name: { name: {
content: { type: ASTType.Text, value: "foo" }, content: { type: ASTType.Text, value: "foo" },
on: { click: "doStuff" }, on: { click: "doStuff" },
attrs: null, attrs: null,
attrsTranslationCtx: null,
scope: null, scope: null,
}, },
}, },
@@ -1493,16 +1562,24 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
default: { default: {
content: { type: ASTType.Text, value: " " }, content: { type: ASTType.Text, value: " " },
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
name: {
content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
}, },
name: { content: { type: ASTType.Text, value: "foo" }, attrs: null, on: null, scope: null },
}, },
}); });
}); });
@@ -1518,11 +1595,24 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
a: { content: { type: ASTType.Text, value: "foo" }, attrs: null, on: null, scope: null }, a: {
b: { content: { type: ASTType.Text, value: "bar" }, attrs: null, on: null, scope: null }, content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
b: {
content: { type: ASTType.Text, value: "bar" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
}, },
}); });
}); });
@@ -1533,6 +1623,7 @@ describe("qweb parser", () => {
name: "myComponent", name: "myComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: true, isDynamic: true,
on: null, on: null,
slots: null, slots: null,
@@ -1545,6 +1636,7 @@ describe("qweb parser", () => {
name: "mycomponent", name: "mycomponent",
dynamicProps: null, dynamicProps: null,
props: { a: "1", b: "'b'" }, props: { a: "1", b: "'b'" },
propsTranslationCtx: null,
isDynamic: true, isDynamic: true,
on: null, on: null,
slots: null, slots: null,
@@ -1557,6 +1649,7 @@ describe("qweb parser", () => {
name: "mycomponent", name: "mycomponent",
dynamicProps: "state", dynamicProps: "state",
props: { a: "1" }, props: { a: "1" },
propsTranslationCtx: null,
isDynamic: true, isDynamic: true,
on: null, on: null,
slots: null, slots: null,
@@ -1587,12 +1680,14 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
default: { default: {
content: { body: null, name: "subTemplate", type: ASTType.TCall, context: null }, content: { body: null, name: "subTemplate", type: ASTType.TCall, context: null },
attrs: null, attrs: null,
attrsTranslationCtx: null,
scope: null, scope: null,
on: null, on: null,
}, },
@@ -1613,11 +1708,13 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
default: { default: {
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
content: { content: {
@@ -1626,11 +1723,13 @@ describe("qweb parser", () => {
name: "Child", name: "Child",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: { slots: {
brol: { brol: {
content: { type: ASTType.Text, value: "coucou" }, content: { type: ASTType.Text, value: "coucou" },
attrs: null, attrs: null,
attrsTranslationCtx: null,
scope: null, scope: null,
on: null, on: null,
}, },
@@ -1654,11 +1753,13 @@ describe("qweb parser", () => {
name: "MyComponent", name: "MyComponent",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
isDynamic: false, isDynamic: false,
on: null, on: null,
slots: { slots: {
default: { default: {
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
content: { content: {
@@ -1667,11 +1768,13 @@ describe("qweb parser", () => {
name: "Child", name: "Child",
dynamicProps: null, dynamicProps: null,
props: null, props: null,
propsTranslationCtx: null,
on: null, on: null,
slots: { slots: {
brol: { brol: {
content: { type: ASTType.Text, value: "coucou" }, content: { type: ASTType.Text, value: "coucou" },
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
scope: null, scope: null,
}, },
@@ -1691,6 +1794,7 @@ describe("qweb parser", () => {
type: ASTType.TSlot, type: ASTType.TSlot,
name: "default", name: "default",
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
defaultContent: null, defaultContent: null,
}); });
@@ -1701,6 +1805,7 @@ describe("qweb parser", () => {
type: ASTType.TSlot, type: ASTType.TSlot,
name: "header", name: "header",
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
defaultContent: { type: ASTType.Text, value: "default content" }, defaultContent: { type: ASTType.Text, value: "default content" },
}); });
@@ -1711,6 +1816,7 @@ describe("qweb parser", () => {
type: ASTType.TSlot, type: ASTType.TSlot,
name: "default", name: "default",
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: { "click.prevent": "doSomething" }, on: { "click.prevent": "doSomething" },
defaultContent: null, defaultContent: null,
}); });
@@ -1728,6 +1834,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -1746,6 +1853,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: null, ref: null,
model: null, model: null,
@@ -1765,6 +1873,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: "name", ref: "name",
model: null, model: null,
@@ -1779,6 +1888,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: "name", ref: "name",
model: null, model: null,
@@ -1795,6 +1905,7 @@ describe("qweb parser", () => {
tag: "div", tag: "div",
dynamicTag: null, dynamicTag: null,
attrs: null, attrs: null,
attrsTranslationCtx: null,
on: null, on: null,
ref: "name", ref: "name",
model: null, model: null,
@@ -1831,6 +1942,7 @@ describe("qweb parser", () => {
body: { body: {
content: { content: {
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [ content: [
{ {
type: ASTType.Text, type: ASTType.Text,
@@ -1859,6 +1971,92 @@ describe("qweb parser", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// t-translation-context
// ---------------------------------------------------------------------------
test('t-translation-context="fr"', async () => {
expect(parse(`<t t-translation-context="fr">word</t>`)).toEqual({
type: ASTType.TTranslationContext,
content: {
type: ASTType.Text,
value: "word",
},
translationCtx: "fr",
});
expect(parse(`<div t-translation-context="fr">word</div>`)).toEqual({
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
type: 0,
value: "word",
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "div",
type: ASTType.DomNode,
},
translationCtx: "fr",
type: ASTType.TTranslationContext,
});
});
// ---------------------------------------------------------------------------
// t-translation-context-attr
// ---------------------------------------------------------------------------
test('t-translation-context="fr" and t-translation-context-title="pt" for a div attr title', async () => {
expect(
parse(
`<div t-translation-context="fr" title="hello" t-translation-context-title="pt">word</div>`
)
).toEqual({
content: {
attrs: { title: "hello" },
attrsTranslationCtx: { title: "pt" },
content: [
{
type: 0,
value: "word",
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "div",
type: ASTType.DomNode,
},
translationCtx: "fr",
type: ASTType.TTranslationContext,
});
});
test('t-translation-context-title="fr" for component prop title', async () => {
expect(parse(`<Comp title="hello" t-translation-context-title="fr" />`)).toEqual({
dynamicProps: null,
isDynamic: false,
name: "Comp",
on: null,
props: {
title: "hello",
},
propsTranslationCtx: {
title: "fr",
},
slots: null,
type: ASTType.TComponent,
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// t-model // t-model
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1866,6 +2064,7 @@ describe("qweb parser", () => {
expect(parse(`<input t-model="state.stuff" />`)).toEqual({ expect(parse(`<input t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -1886,6 +2085,7 @@ describe("qweb parser", () => {
expect(parse(`<input t-model="state['stuff']" />`)).toEqual({ expect(parse(`<input t-model="state['stuff']" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -1906,6 +2106,7 @@ describe("qweb parser", () => {
expect(parse(`<input t-model.lazy.trim.number="state.stuff" />`)).toEqual({ expect(parse(`<input t-model.lazy.trim.number="state.stuff" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -1927,6 +2128,7 @@ describe("qweb parser", () => {
expect(parse(`<textarea t-model="state.stuff" />`)).toEqual({ expect(parse(`<textarea t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -1947,6 +2149,7 @@ describe("qweb parser", () => {
expect(parse(`<input type="checkbox" t-model="state.stuff" />`)).toEqual({ expect(parse(`<input type="checkbox" t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: { type: "checkbox" }, attrs: { type: "checkbox" },
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -1967,6 +2170,7 @@ describe("qweb parser", () => {
expect(parse(`<input type="radio" t-model="state.stuff" />`)).toEqual({ expect(parse(`<input type="radio" t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: { type: "radio" }, attrs: { type: "radio" },
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -1987,6 +2191,7 @@ describe("qweb parser", () => {
expect(parse(`<input type="radio" t-model.lazy.trim.number="state.stuff" />`)).toEqual({ expect(parse(`<input type="radio" t-model.lazy.trim.number="state.stuff" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: { type: "radio" }, attrs: { type: "radio" },
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
@@ -2012,6 +2217,7 @@ describe("qweb parser", () => {
expect(parse(`<div t-tag="theTag" />`)).toEqual({ expect(parse(`<div t-tag="theTag" />`)).toEqual({
type: ASTType.DomNode, type: ASTType.DomNode,
attrs: null, attrs: null,
attrsTranslationCtx: null,
content: [], content: [],
on: null, on: null,
ref: null, ref: null,
+15
View File
@@ -0,0 +1,15 @@
import { parseXML } from "../../src/common/utils";
import { compile } from "../../src/compiler";
describe("t-slot", () => {
test("compile t-props correctly multiple time", () => {
const template = `<t t-slot="default" t-props="{ a: 1 }"/>`;
const parsedTemplate = parseXML(template).firstChild as Element;
const fn1 = compile(parsedTemplate);
expect(fn1.toString()).toMatchSnapshot();
const fn2 = compile(parsedTemplate);
expect(fn2.toString()).toBe(fn1.toString());
});
});
+125 -2
View File
@@ -86,7 +86,7 @@ describe("translation support", () => {
await mount(SomeComponent, fixture, { translateFn }); await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div> mot </div>"); expect(fixture.innerHTML).toBe("<div> mot </div>");
expect(translateFn).toHaveBeenCalledWith("word"); expect(translateFn).toHaveBeenCalledWith("word", "");
}); });
test("translation works, even if initial string has inner consecutive white space", async () => { test("translation works, even if initial string has inner consecutive white space", async () => {
@@ -97,7 +97,7 @@ describe("translation support", () => {
const translateFn = jest.fn((expr: string) => (expr === "some word" ? "un mot" : expr)); const translateFn = jest.fn((expr: string) => (expr === "some word" ? "un mot" : expr));
await mount(SomeComponent, fixture, { translateFn }); await mount(SomeComponent, fixture, { translateFn });
expect(translateFn).toHaveBeenCalledWith("some word"); expect(translateFn).toHaveBeenCalledWith("some word", "");
expect(fixture.innerHTML).toBe("<div>un mot</div>"); expect(fixture.innerHTML).toBe("<div>un mot</div>");
}); });
@@ -171,3 +171,126 @@ describe("translation support", () => {
expect(fixture.innerHTML).toBe("translated"); expect(fixture.innerHTML).toBe("translated");
}); });
}); });
describe("translation context", () => {
test("translation of text in context", async () => {
class SomeComponent extends Component {
static template = xml`
<div>word</div>
<div t-translation-context="fr">word</div>
`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? (expr === "word" ? "mot" : expr) : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div>word</div><div>mot</div>");
expect(translateFn).toHaveBeenCalledWith("word", "");
expect(translateFn).toHaveBeenCalledWith("word", "fr");
});
test("translation of attributes in context", async () => {
class SomeComponent extends Component {
static template = xml`
<div t-translation-context="en" t-translation-context-title="fr" title="title" label="game"/>
`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? (expr === "title" ? "titre" : expr) : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe(`<div title="titre" label="game"></div>`);
expect(translateFn).toHaveBeenCalledWith("title", "fr");
expect(translateFn).toHaveBeenCalledWith("game", "en");
});
test("body of t-sets are translated in context", async () => {
class SomeComponent extends Component {
static template = xml`
<t t-set="label" t-translation-context="fr">untranslated</t>
<t t-esc="label"/>`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? "traduit" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("traduit");
expect(translateFn).toHaveBeenCalledWith("untranslated", "fr");
});
test("props with modifier .translate are translated in context", async () => {
class ChildComponent extends Component {
static props = ["text"];
static template = xml`<span t-esc="props.text"/>`;
}
class SomeComponent extends Component {
static components = { ChildComponent };
static template = xml`
<ChildComponent text.translate="game" t-translation-context-text.translate="fr" />`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? "jeu" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<span>jeu</span>");
expect(translateFn).toHaveBeenCalledWith("game", "fr");
});
test("slot attrs and text contents are translated in context", async () => {
class ChildComponent extends Component {
static template = xml`
<div t-translation-context="ja">
<t t-slot="a"/>
</div>`;
}
class SomeComponent extends Component {
static components = { ChildComponent };
static template = xml`
<ChildComponent t-translation-context="fr">
<t t-set-slot="a" title.translate="title" t-translation-context-title.translate="pt">game</t>
</ChildComponent>
`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? "jeu" : translationCtx === "pt" ? "título" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div>jeu</div>");
expect(translateFn).toHaveBeenCalledWith("game", "fr");
expect(translateFn).toHaveBeenCalledWith("title", "pt");
});
test("default slot params and content translated in context", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<t
t-slot="default"
t-translation-context="fr"
param.translate="param"
title.translate="title"
t-translation-context-title.translate="pt"
>
foo
</t>
</div>`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "pt" ? "título" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div> foo </div>");
expect(translateFn).toHaveBeenCalledWith("foo", "fr");
expect(translateFn).toHaveBeenCalledWith("param", "fr");
expect(translateFn).toHaveBeenCalledWith("title", "pt");
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers"; import { makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers";
import { Component, onError, xml, mount, OwlError, useState } from "../../src"; import { Component, onError, xml, mount, OwlError, useState } from "../../src";
import { App, DEV_MSG } from "../../src/runtime/app"; import { App } from "../../src/runtime/app";
import { validateProps } from "../../src/runtime/template_helpers"; import { validateProps } from "../../src/runtime/template_helpers";
import { Schema } from "../../src/runtime/validation"; import { Schema } from "../../src/runtime/validation";
@@ -13,7 +13,7 @@ let mockConsoleWarn: any;
beforeAll(() => { beforeAll(() => {
console.info = (message: any) => { console.info = (message: any) => {
if (message === DEV_MSG()) { if (message === `Owl is running in 'dev' mode.`) {
return; return;
} }
info(message); info(message);
+1 -2
View File
@@ -11,7 +11,6 @@ import {
useState, useState,
} from "../../src"; } from "../../src";
import { xml } from "../../src/"; import { xml } from "../../src/";
import { DEV_MSG } from "../../src/runtime/app";
import { elem, makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers"; import { elem, makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers";
let fixture: HTMLElement; let fixture: HTMLElement;
@@ -30,7 +29,7 @@ snapshotEverything();
beforeAll(() => { beforeAll(() => {
console.info = (message: any) => { console.info = (message: any) => {
if (message === DEV_MSG()) { if (message === `Owl is running in 'dev' mode.`) {
return; return;
} }
info(message); info(message);
@@ -27,6 +27,58 @@ exports[`shadow_dom can mount app 1`] = `
}" }"
`; `;
exports[`shadow_dom can mount app in closed shadow dom 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside a separate HTML document 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside a shadow child element 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside an element in a shadow root inside an iframe 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom useRef hook 1`] = ` exports[`shadow_dom useRef hook 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
+87
View File
@@ -29,6 +29,24 @@ describe("shadow_dom", () => {
expect(status(comp)).toBe("destroyed"); expect(status(comp)).toBe("destroyed");
}); });
test("can mount app in closed shadow dom", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const container = document.createElement("div");
fixture.appendChild(container);
const shadow = container.attachShadow({ mode: "closed" });
const app = new App(SomeComponent);
const comp = await app.mount(shadow);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can bind event handler", async () => { test("can bind event handler", async () => {
let a = 1; let a = 1;
class SomeComponent extends Component { class SomeComponent extends Component {
@@ -64,4 +82,73 @@ describe("shadow_dom", () => {
await mountedProm; await mountedProm;
expect(comp!.div.el).toBe(shadow.querySelector(".my-div")); expect(comp!.div.el).toBe(shadow.querySelector(".my-div"));
}); });
test("can mount app inside a shadow child element", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const shadow = fixture.attachShadow({ mode: "open" });
const shadowDiv = document.createElement("div");
shadow.append(shadowDiv);
const app = new App(SomeComponent);
const comp = await app.mount(shadowDiv);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can mount app inside a separate HTML document", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const separateDoc = document.implementation.createHTMLDocument();
const container = separateDoc.createElement("div");
separateDoc.body.appendChild(container);
const app = new App(SomeComponent);
let error: Error;
try {
await app.mount(container);
} catch (e) {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Cannot mount a component: the target document is not attached to a window (defaultView is missing)"
);
});
test("can mount app inside an element in a shadow root inside an iframe", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const iframe = document.createElement("iframe");
fixture.appendChild(iframe);
const iframeDoc = iframe.contentDocument!;
const container = iframeDoc.createElement("div");
iframeDoc.body.appendChild(container);
const shadow = container.attachShadow({ mode: "open" });
const shadowTarget = iframeDoc.createElement("div");
shadow.appendChild(shadowTarget);
const app = new App(SomeComponent);
const comp = await app.mount(shadowTarget);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
expect(iframeDoc.body.contains(container)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
}); });
+92 -1
View File
@@ -1,4 +1,4 @@
import { batched, EventBus } from "../src/runtime/utils"; import { batched, EventBus, htmlEscape, markup } from "../src/runtime/utils";
import { nextMicroTick } from "./helpers"; import { nextMicroTick } from "./helpers";
describe("event bus behaviour", () => { describe("event bus behaviour", () => {
@@ -71,3 +71,94 @@ describe("batched", () => {
expect(n).toBe(2); expect(n).toBe(2);
}); });
}); });
const Markup = markup("").constructor;
describe("markup", () => {
test("string is flagged as safe", () => {
const html = markup("<blink>Hello</blink>");
expect(html).toBeInstanceOf(Markup);
});
describe("htmlEscape", () => {
test("htmlEscape escapes text", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape keeps html markup", () => {
const res = htmlEscape(markup("<p>test</p>"));
expect(res.toString()).toBe("<p>test</p>");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces empty string on undefined", () => {
const res = htmlEscape(undefined);
expect(res.toString()).toBe("");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces string from number", () => {
const res = htmlEscape(10);
expect(res.toString()).toBe("10");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces string from boolean", () => {
const res = htmlEscape(false);
expect(res.toString()).toBe("false");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape correctly escapes various links", () => {
expect(htmlEscape("<a>this is a link</a>").toString()).toBe(
"&lt;a&gt;this is a link&lt;/a&gt;"
);
expect(htmlEscape(`<a href="https://www.odoo.com">odoo<a>`).toString()).toBe(
`&lt;a href=&quot;https://www.odoo.com&quot;&gt;odoo&lt;a&gt;`
);
expect(htmlEscape(`<a href='https://www.odoo.com'>odoo<a>`).toString()).toBe(
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;odoo&lt;a&gt;`
);
expect(htmlEscape("<a href='https://www.odoo.com'>Odoo`s website<a>").toString()).toBe(
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;Odoo&#x60;s website&lt;a&gt;`
);
});
test("htmlEscape doesn't escape already escaped content", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
const res2 = htmlEscape(res);
expect(res2.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res2).toBeInstanceOf(Markup);
expect(res2).toBe(res);
});
test("htmlEscape returns markup even for only-safe text", () => {
const res = htmlEscape("safe");
expect(res.toString()).toBe("safe");
expect(res).toBeInstanceOf(Markup);
});
});
describe("tag function", () => {
test("interpolated values are escaped", () => {
const maliciousInput = "<script>alert('💥💥')</script>";
const html = markup`<b>${maliciousInput}</b>`;
expect(html.toString()).toBe("<b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>");
expect(html).toBeInstanceOf(Markup);
});
test("interpolated markups aren't escaped", () => {
const shouldBeEscaped = "<script>alert('should be escaped')</script>";
const shouldnt = markup("<b>this is safe</b>");
const html = markup`<div>${shouldBeEscaped} ${shouldnt}</div>`;
expect(html.toString()).toBe(
"<div>&lt;script&gt;alert(&#x27;should be escaped&#x27;)&lt;/script&gt; <b>this is safe</b></div>"
);
expect(html).toBeInstanceOf(Markup);
});
test("quotes in interpolated values are escaped", () => {
const imgUrl = `lol" onerror="alert('xss')`;
const html = markup`<img src="${imgUrl}">`;
expect(html.toString()).toBe(`<img src="lol&quot; onerror=&quot;alert(&#x27;xss&#x27;)">`);
});
test("already escaped content is not escaped again", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
const html = markup`${res}`;
expect(html.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
});
});
});