diff --git a/doc/reference/props.md b/doc/reference/props.md
index b9f864e36..508afad30 100644
--- a/doc/reference/props.md
+++ b/doc/reference/props.md
@@ -140,6 +140,28 @@ class SomeComponent extends Component {
The `.bind` suffix also implies `.alike`, so these props will not cause additional
renderings.
+## Translatable props
+
+When you need to pass a user-facing string to a subcomponent, you likely want it
+to be translated. Unfortunately, because props are arbitrary expressions, it wouldn't
+be practical for Owl to find out which parts of the expression are strings and translate
+them, and it also makes it difficult for tooling to extract these strings to generate
+terms to translate. While you can work around this issue by doing the translation in
+JavaScript, or by using `t-set` with a body (the body of `t-set` is translated),
+and passing the variable as a prop, this is a sufficiently common use case that Owl
+provides a suffix for this purpose: `.translate`.
+
+```xml
+
+
+
+```
+
+Note that the content of this attribute is _NOT_ treated as a JavaScript expression:
+it is treated as a string, as if it was an attribute on an HTML element, and translated
+before being passed to the component. If you need to interpolate some data into the
+string, you will still have to do this in JavaScript.
+
## Dynamic Props
The `t-props` directive can be used to specify totally dynamic props:
diff --git a/doc/reference/slots.md b/doc/reference/slots.md
index d565a7314..01c75e370 100644
--- a/doc/reference/slots.md
+++ b/doc/reference/slots.md
@@ -201,16 +201,17 @@ use this `Notebook` component:
```xml
-
+
this is in the page 1
-
+
this is in the page 2
```
-Slot params works like normal props, so one can use the `.bind` suffix to
+Slot params works like normal props, so one can use suffixes like `.translate`
+when a the prop is a user facing string and should be translated, or `.bind` to
bind a function if needed.
## Slot scopes
diff --git a/src/compiler/code_generator.ts b/src/compiler/code_generator.ts
index ed1d97bf8..63e40f02a 100644
--- a/src/compiler/code_generator.ts
+++ b/src/compiler/code_generator.ts
@@ -1136,7 +1136,11 @@ export class CodeGenerator {
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/
formatProp(name: string, value: string): string {
- value = this.captureExpression(value);
+ if (name.endsWith(".translate")) {
+ value = toStringExpression(this.translateFn(value));
+ } else {
+ value = this.captureExpression(value);
+ }
if (name.includes(".")) {
let [_name, suffix] = name.split(".");
name = _name;
@@ -1145,6 +1149,7 @@ export class CodeGenerator {
value = `(${value}).bind(this)`;
break;
case "alike":
+ case "translate":
break;
default:
throw new OwlError("Invalid prop suffix");
diff --git a/tests/components/__snapshots__/props.test.ts.snap b/tests/components/__snapshots__/props.test.ts.snap
index 7a0f8b1f3..6504ed856 100644
--- a/tests/components/__snapshots__/props.test.ts.snap
+++ b/tests/components/__snapshots__/props.test.ts.snap
@@ -66,6 +66,29 @@ exports[`.alike suffix in a simple case 2`] = `
}"
`;
+exports[`.translate props are translated 1`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+ const comp1 = app.createComponent(\`Child\`, true, false, false, []);
+
+ return function template(ctx, node, key = \\"\\") {
+ return comp1({message: \`translated message\`}, key + \`__1\`, node, this, null);
+ }
+}"
+`;
+
+exports[`.translate props are translated 2`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+
+ return function template(ctx, node, key = \\"\\") {
+ return text(ctx['props'].message);
+ }
+}"
+`;
+
exports[`basics accept ES6-like syntax for props (with getters) 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -412,6 +435,29 @@ exports[`can bind function prop with bind suffix 2`] = `
}"
`;
+exports[`can use .translate suffix 1`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+ const comp1 = app.createComponent(\`Child\`, true, false, false, []);
+
+ return function template(ctx, node, key = \\"\\") {
+ return comp1({message: \`some message\`}, key + \`__1\`, node, this, null);
+ }
+}"
+`;
+
+exports[`can use .translate suffix 2`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+
+ return function template(ctx, node, key = \\"\\") {
+ return text(ctx['props'].message);
+ }
+}"
+`;
+
exports[`do not crash when binding anonymous function prop with bind suffix 1`] = `
"function anonymous(app, bdom, helpers
) {
diff --git a/tests/components/__snapshots__/slots.test.ts.snap b/tests/components/__snapshots__/slots.test.ts.snap
index 068d76fd4..ced3282b3 100644
--- a/tests/components/__snapshots__/slots.test.ts.snap
+++ b/tests/components/__snapshots__/slots.test.ts.snap
@@ -1,5 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`slots .translate slot props are translated 1`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+ let { capture, markRaw } = helpers;
+ const comp1 = app.createComponent(\`Child\`, true, true, false, []);
+
+ return function template(ctx, node, key = \\"\\") {
+ const ctx1 = capture(ctx);
+ return comp1({slots: markRaw({'default': {message: \`translated message\`}})}, key + \`__1\`, node, this, null);
+ }
+}"
+`;
+
+exports[`slots .translate slot props are translated 2`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+
+ return function template(ctx, node, key = \\"\\") {
+ return text(ctx['props'].slots.default.message);
+ }
+}"
+`;
+
exports[`slots can define a default content 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -201,6 +226,31 @@ exports[`slots can render only empty slot 1`] = `
}"
`;
+exports[`slots can use .translate suffix on slot props 1`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+ let { capture, markRaw } = helpers;
+ const comp1 = app.createComponent(\`Child\`, true, true, false, []);
+
+ return function template(ctx, node, key = \\"\\") {
+ const ctx1 = capture(ctx);
+ return comp1({slots: markRaw({'default': {message: \`some message\`}})}, key + \`__1\`, node, this, null);
+ }
+}"
+`;
+
+exports[`slots can use .translate suffix on slot props 2`] = `
+"function anonymous(app, bdom, helpers
+) {
+ let { text, createBlock, list, multi, html, toggler, comment } = bdom;
+
+ return function template(ctx, node, key = \\"\\") {
+ return text(ctx['props'].slots.default.message);
+ }
+}"
+`;
+
exports[`slots can use component in default-content of t-slot 1`] = `
"function anonymous(app, bdom, helpers
) {
diff --git a/tests/components/props.test.ts b/tests/components/props.test.ts
index e1a11a9ab..540304120 100644
--- a/tests/components/props.test.ts
+++ b/tests/components/props.test.ts
@@ -299,6 +299,34 @@ test("bound functions are considered 'alike'", async () => {
expect(fixture.innerHTML).toBe("3child");
});
+test("can use .translate suffix", async () => {
+ class Child extends Component {
+ static template = xml``;
+ }
+
+ class Parent extends Component {
+ static template = xml``;
+ static components = { Child };
+ }
+
+ await mount(Parent, fixture);
+ expect(fixture.innerHTML).toBe("some message");
+});
+
+test(".translate props are translated", async () => {
+ class Child extends Component {
+ static template = xml``;
+ }
+
+ class Parent extends Component {
+ static template = xml``;
+ static components = { Child };
+ }
+
+ await mount(Parent, fixture, { translateFn: () => "translated message" });
+ expect(fixture.innerHTML).toBe("translated message");
+});
+
test("throw if prop uses an unknown suffix", async () => {
class Child extends Component {
static template = xml``;
diff --git a/tests/components/slots.test.ts b/tests/components/slots.test.ts
index 167358579..a0a75b465 100644
--- a/tests/components/slots.test.ts
+++ b/tests/components/slots.test.ts
@@ -179,6 +179,34 @@ describe("slots", () => {
expect(fixture.innerHTML).toBe("default empty");
});
+ test("can use .translate suffix on slot props", async () => {
+ class Child extends Component {
+ static template = xml``;
+ }
+
+ class Parent extends Component {
+ static template = xml``;
+ static components = { Child };
+ }
+
+ await mount(Parent, fixture);
+ expect(fixture.innerHTML).toBe("some message");
+ });
+
+ test(".translate slot props are translated", async () => {
+ class Child extends Component {
+ static template = xml``;
+ }
+
+ class Parent extends Component {
+ static template = xml``;
+ static components = { Child };
+ }
+
+ await mount(Parent, fixture, { translateFn: () => "translated message" });
+ expect(fixture.innerHTML).toBe("translated message");
+ });
+
test("default slot with slot scope: shorthand syntax", async () => {
let child: any;
class Child extends Component {