From 92e80af8751993879a1d0b4a5a029ededb973e4d Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Thu, 18 Oct 2018 10:08:51 -0700 Subject: [PATCH] feat(ivy): ICU support for Ivy (#26794) PR Close #26794 --- .../compliance/r3_view_compiler_i18n_spec.ts | 2075 +++++++++++++---- packages/compiler/src/constant_pool.ts | 88 +- packages/compiler/src/i18n/i18n_ast.ts | 2 + packages/compiler/src/i18n/i18n_parser.ts | 39 +- packages/compiler/src/ml_parser/ast.ts | 10 +- .../src/ml_parser/html_whitespaces.ts | 6 +- packages/compiler/src/output/map_util.ts | 5 +- packages/compiler/src/render3/r3_ast.ts | 31 +- .../compiler/src/render3/r3_identifiers.ts | 2 + .../src/render3/r3_template_transform.ts | 76 +- packages/compiler/src/render3/view/i18n.ts | 140 -- .../compiler/src/render3/view/i18n/context.ts | 202 ++ .../compiler/src/render3/view/i18n/meta.ts | 123 + .../src/render3/view/i18n/serializer.ts | 47 + .../compiler/src/render3/view/i18n/util.ts | 257 ++ .../compiler/src/render3/view/t2_binder.ts | 5 +- .../compiler/src/render3/view/template.ts | 330 ++- packages/compiler/src/render3/view/util.ts | 4 +- .../render3/r3_template_transform_spec.ts | 31 +- .../compiler/test/render3/view/i18n_spec.ts | 258 +- packages/compiler/test/render3/view/util.ts | 37 + .../core/src/core_render3_private_export.ts | 3 +- packages/core/src/render3/STATUS.md | 7 +- packages/core/src/render3/i18n.md | 42 +- packages/core/src/render3/i18n.ts | 101 +- packages/core/src/render3/index.ts | 3 +- packages/core/src/render3/jit/environment.ts | 2 + packages/core/test/render3/i18n_spec.ts | 113 +- 28 files changed, 3106 insertions(+), 933 deletions(-) delete mode 100644 packages/compiler/src/render3/view/i18n.ts create mode 100644 packages/compiler/src/render3/view/i18n/context.ts create mode 100644 packages/compiler/src/render3/view/i18n/meta.ts create mode 100644 packages/compiler/src/render3/view/i18n/serializer.ts create mode 100644 packages/compiler/src/render3/view/i18n/util.ts diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts index e15a714944..1713ebd4a3 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts @@ -7,42 +7,141 @@ */ import {setup} from '@angular/compiler/test/aot/test_util'; + +import {DEFAULT_INTERPOLATION_CONFIG} from '../../../compiler/src/compiler'; +import {decimalDigest} from '../../../compiler/src/i18n/digest'; +import {extractMessages} from '../../../compiler/src/i18n/extractor_merger'; +import {HtmlParser} from '../../../compiler/src/ml_parser/html_parser'; + import {compile, expectEmit} from './mock_compile'; -describe('i18n support in the view compiler', () => { - const angularFiles = setup({ - compileAngular: false, - compileFakeCore: true, - compileAnimations: false, +const angularFiles = setup({ + compileAngular: false, + compileFakeCore: true, + compileAnimations: false, +}); + +const htmlParser = new HtmlParser(); + +const diff = (a: Set, b: Set): Set => + new Set([...Array.from(a)].filter(x => !b.has(x))); + +// verify that we extracted all the necessary translations +// and their ids match the ones extracted via 'ng xi18n' +const verifyTranslationIds = (source: string, output: string, exceptions = {}) => { + const parseResult = htmlParser.parse(source, 'path:://to/template', true); + const extractedIdToMsg = new Map(); + const extractedIds = new Set(); + const generatedIds = new Set(); + const msgs = extractMessages(parseResult.rootNodes, DEFAULT_INTERPOLATION_CONFIG, [], {}); + msgs.messages.forEach(msg => { + const id = msg.id || decimalDigest(msg); + extractedIds.add(id); + extractedIdToMsg.set(id, msg); }); + const matched = output.match(/\[BACKUP_MESSAGE_ID:(.+?)\]/g) || []; + matched.forEach(match => { + const [key, id] = match.split(':'); + generatedIds.add(id.slice(0, -1)); + }); + const delta = diff(extractedIds, generatedIds); + if (delta.size) { + // check if we have ids in exception list + const outstanding = diff(delta, new Set(Object.keys(exceptions))); + if (outstanding.size) { + throw new Error(` + Extracted and generated IDs don't match, delta: + ${JSON.stringify(Array.from(delta))} + `); + } + } + return true; +}; + +// verify that placeholders in translation string match +// placeholders object defined as goog.getMsg function argument +const verifyPlaceholdersIntegrity = (output: string) => { + const extract = (from: string, regex: any, transformFn: (match: any[]) => any) => { + const result = new Set(); + let item; + while ((item = regex.exec(from)) !== null) { + result.add(transformFn(item)); + } + return result; + }; + const extactTranslations = (from: string) => { + const regex = /const\s*(.*?)\s*=\s*goog\.getMsg\("(.*?)",?\s*(.*?)\)/g; + return extract(from, regex, v => [v[2], v[3]]); + }; + const extractPlaceholdersFromBody = (body: string) => { + const regex = /{\$(.*?)}/g; + return extract(body, regex, v => v[1]); + }; + const extractPlaceholdersFromArgs = (args: string) => { + const regex = /\s+"(.+?)":\s*".*?"/g; + return extract(args, regex, v => v[1]); + }; + const translations = extactTranslations(output); + translations.forEach((translation) => { + const bodyPhs = extractPlaceholdersFromBody(translation[0]); + const argsPhs = extractPlaceholdersFromArgs(translation[1]); + if (bodyPhs.size !== argsPhs.size || diff(bodyPhs, argsPhs).size) { + return false; + } + }); + return true; +}; + +const getAppFilesWithTemplate = (template: string, args: any = {}) => ({ + app: { + 'spec.ts': ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'my-component', + ${args.preserveWhitespaces ? 'preserveWhitespaces: true,' : ''} + template: \`${template}\` + }) + export class MyComponent {} + + @NgModule({declarations: [MyComponent]}) + export class MyModule {} + ` + } +}); + +const verify = (input: string, output: string, extra: any = {}) => { + const files = getAppFilesWithTemplate(input, extra.inputArgs); + const result = compile(files, angularFiles); + if (extra.verbose) { + // tslint:disable-next-line + console.log(` +========== Generated output: ========== +${result.source} +======================================= + `); + } + expect(verifyTranslationIds(input, result.source, extra.exceptions)).toBe(true); + expect(verifyPlaceholdersIntegrity(result.source)).toBe(true); + expectEmit(result.source, output, 'Incorrect template'); + return result.source; +}; + +describe('i18n support in the view compiler', () => { describe('element attributes', () => { it('should add the meaning and description as JsDoc comments', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
Content A
+
Content B
+
Content C
+
Content D
+
Content E
+
Content F
+
Content G
+ `; - @Component({ - selector: 'my-component', - template: \` -
Content A
-
Content B
-
Content C
-
Content D
-
Content E
-
Content F
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = ` + const output = ` /** * @desc [BACKUP_MESSAGE_ID:idA] descA * @meaning meaningA @@ -53,34 +152,38 @@ describe('i18n support in the view compiler', () => { * @meaning meaningB */ const $MSG_APP_SPEC_TS_1$ = goog.getMsg("Title B"); - const $_c2$ = ["title", $MSG_APP_SPEC_TS_1$, 0]; + const $_c2$ = ["title", $MSG_APP_SPEC_TS_1$]; /** - * @desc meaningC + * @desc [BACKUP_MESSAGE_ID:4978592519614169666] meaningC */ const $MSG_APP_SPEC_TS_3$ = goog.getMsg("Title C"); - const $_c4$ = ["title", $MSG_APP_SPEC_TS_3$, 0]; + const $_c4$ = ["title", $MSG_APP_SPEC_TS_3$]; /** - * @desc descD + * @desc [BACKUP_MESSAGE_ID:5200291527729162531] descD * @meaning meaningD */ const $MSG_APP_SPEC_TS_5$ = goog.getMsg("Title D"); - const $_c6$ = ["title", $MSG_APP_SPEC_TS_5$, 0]; + const $_c6$ = ["title", $MSG_APP_SPEC_TS_5$]; /** * @desc [BACKUP_MESSAGE_ID:idE] meaningE */ const $MSG_APP_SPEC_TS_7$ = goog.getMsg("Title E"); - const $_c8$ = ["title", $MSG_APP_SPEC_TS_7$, 0]; + const $_c8$ = ["title", $MSG_APP_SPEC_TS_7$]; /** * @desc [BACKUP_MESSAGE_ID:idF] */ const $MSG_APP_SPEC_TS_9$ = goog.getMsg("Title F"); - const $_c10$ = ["title", $MSG_APP_SPEC_TS_9$, 0]; + const $_c10$ = ["title", $MSG_APP_SPEC_TS_9$]; + /** + * @desc [BACKUP_MESSAGE_ID:idG]desc + */ + const $MSG_APP_SPEC_TS_11$ = goog.getMsg("Title G"); + const $_c12$ = ["title", $MSG_APP_SPEC_TS_11$]; … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵelementStart(0, "div"); - $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); - $r3$.ɵi18nEnd(); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); $r3$.ɵelementEnd(); $r3$.ɵelementStart(2, "div"); $r3$.ɵi18nAttributes(3, $_c2$); @@ -102,42 +205,48 @@ describe('i18n support in the view compiler', () => { $r3$.ɵi18nAttributes(15, $_c10$); $r3$.ɵtext(16, "Content F"); $r3$.ɵelementEnd(); + $r3$.ɵelementStart(17, "div"); + $r3$.ɵi18nAttributes(18, $_c12$); + $r3$.ɵtext(19, "Content G"); + $r3$.ɵelementEnd(); } } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); + }); + + it('should not create translations for empty attributes', () => { + const input = ` +
+ `; + + const output = ` + const $_c0$ = ["id", "static", "title", ""]; + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelement(0, "div", $_c0$); + } + } + `; + + verify(input, output); }); it('should translate static attributes', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+ `; - @Component({ - selector: 'my-component', - template: \` -
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = ` + const output = ` const $_c0$ = ["id", "static"]; /** - * @desc d + * @desc [BACKUP_MESSAGE_ID:8809028065680254561] d * @meaning m */ const $MSG_APP_SPEC_TS_1$ = goog.getMsg("introduction"); - const $_c2$ = ["title", MSG_APP_SPEC_TS_1, 0]; + const $_c2$ = ["title", $MSG_APP_SPEC_TS_1$]; … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -148,60 +257,54 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should support interpolation', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+
+ `; - @Component({ - selector: 'my-component', - template: \` -
-
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` const $_c0$ = ["id", "dynamic-1"]; /** - * @desc d + * @desc [BACKUP_MESSAGE_ID:5526535577705876535] + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("static text"); + /** + * @desc [BACKUP_MESSAGE_ID:8977039798304050198] d * @meaning m */ - const $MSG_APP_SPEC_TS_1$ = goog.getMsg("intro \uFFFD0\uFFFD"); + const $MSG_APP_SPEC_TS_2$ = goog.getMsg("intro {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); /** - * @desc d1 + * @desc [BACKUP_MESSAGE_ID:7432761130955693041] d1 * @meaning m1 */ - const $MSG_APP_SPEC_TS_2$ = goog.getMsg("\uFFFD0\uFFFD"); - const $MSG_APP_SPEC_TS_3$ = goog.getMsg("static text"); - const $_c4$ = ["title", $MSG_APP_SPEC_TS_1$, 1, "aria-label", $MSG_APP_SPEC_TS_2$, 1, "aria-roledescription", $MSG_APP_SPEC_TS_3$, 0]; + const $MSG_APP_SPEC_TS_3$ = goog.getMsg("{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c4$ = ["aria-roledescription", $MSG_APP_SPEC_TS_1$, "title", $MSG_APP_SPEC_TS_2$, "aria-label", $MSG_APP_SPEC_TS_3$]; const $_c5$ = ["id", "dynamic-2"]; /** - * @desc d2 + * @desc [BACKUP_MESSAGE_ID:7566208596013750546] d2 * @meaning m2 */ - const $MSG_APP_SPEC_TS_6$ = goog.getMsg("\uFFFD0\uFFFD and \uFFFD1\uFFFD and again \uFFFD2\uFFFD"); - const $MSG_APP_SPEC_TS_7$ = goog.getMsg("\uFFFD0\uFFFD"); - const $_c8$ = ["title", $MSG_APP_SPEC_TS_6$, 3, "aria-roledescription", $MSG_APP_SPEC_TS_7$, 1]; + const $MSG_APP_SPEC_TS_6$ = goog.getMsg("{$interpolation} and {$interpolation_1} and again {$interpolation_2}", { + "interpolation": "\uFFFD0\uFFFD", + "interpolation_1": "\uFFFD1\uFFFD", + "interpolation_2": "\uFFFD2\uFFFD" + }); + /** + * @desc [BACKUP_MESSAGE_ID:6639222533406278123] + */ + const $MSG_APP_SPEC_TS_7$ = goog.getMsg("{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const _c8 = ["title", $MSG_APP_SPEC_TS_6$, "aria-roledescription", $MSG_APP_SPEC_TS_7$]; … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -226,40 +329,24 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should correctly bind to context in nested template', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+
+
+ `; - @Component({ - selector: 'my-component', - template: \` -
-
-
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` const $_c0$ = ["ngFor", "", 1, "ngForOf"]; /** - * @desc d + * @desc [BACKUP_MESSAGE_ID:8538466649243975456] d * @meaning m */ - const $MSG_APP_SPEC_TS__1$ = goog.getMsg("different scope \uFFFD0\uFFFD"); - const $_c2$ = ["title", $MSG_APP_SPEC_TS__1$, 1]; + const $MSG_APP_SPEC_TS__1$ = goog.getMsg("different scope {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c2$ = ["title", $MSG_APP_SPEC_TS__1$]; function MyComponent_div_Template_0(rf, ctx) { if (rf & 1) { $r3$.ɵelementStart(0, "div"); @@ -286,60 +373,54 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should support interpolation', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+
+ `; - @Component({ - selector: 'my-component', - template: \` -
-
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` const $_c0$ = ["id", "dynamic-1"]; /** - * @desc d + * @desc [BACKUP_MESSAGE_ID:5526535577705876535] + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("static text"); + /** + * @desc [BACKUP_MESSAGE_ID:8977039798304050198] d * @meaning m */ - const $MSG_APP_SPEC_TS_1$ = goog.getMsg("intro \uFFFD0\uFFFD"); + const $MSG_APP_SPEC_TS_2$ = goog.getMsg("intro {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); /** - * @desc d1 + * @desc [BACKUP_MESSAGE_ID:7432761130955693041] d1 * @meaning m1 */ - const $MSG_APP_SPEC_TS_2$ = goog.getMsg("\uFFFD0\uFFFD"); - const $MSG_APP_SPEC_TS_3$ = goog.getMsg("static text"); - const $_c4$ = ["title", $MSG_APP_SPEC_TS_1$, 1, "aria-label", $MSG_APP_SPEC_TS_2$, 1, "aria-roledescription", $MSG_APP_SPEC_TS_3$, 0]; + const $MSG_APP_SPEC_TS_3$ = goog.getMsg("{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c4$ = ["aria-roledescription", $MSG_APP_SPEC_TS_1$, "title", $MSG_APP_SPEC_TS_2$, "aria-label", $MSG_APP_SPEC_TS_3$]; const $_c5$ = ["id", "dynamic-2"]; /** - * @desc d2 + * @desc [BACKUP_MESSAGE_ID:7566208596013750546] d2 * @meaning m2 */ - const $MSG_APP_SPEC_TS_6$ = goog.getMsg("\uFFFD0\uFFFD and \uFFFD1\uFFFD and again \uFFFD2\uFFFD"); - const $MSG_APP_SPEC_TS_7$ = goog.getMsg("\uFFFD0\uFFFD"); - const $_c8$ = ["title", $MSG_APP_SPEC_TS_6$, 3, "aria-roledescription", $MSG_APP_SPEC_TS_7$, 1]; + const $MSG_APP_SPEC_TS_6$ = goog.getMsg("{$interpolation} and {$interpolation_1} and again {$interpolation_2}", { + "interpolation": "\uFFFD0\uFFFD", + "interpolation_1": "\uFFFD1\uFFFD", + "interpolation_2": "\uFFFD2\uFFFD" + }); + /** + * @desc [BACKUP_MESSAGE_ID:6639222533406278123] + */ + const $MSG_APP_SPEC_TS_7$ = goog.getMsg("{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c8$ = ["title", $MSG_APP_SPEC_TS_6$, "aria-roledescription", $MSG_APP_SPEC_TS_7$]; … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -364,40 +445,24 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should correctly bind to context in nested template', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+
+
+ `; - @Component({ - selector: 'my-component', - template: \` -
-
-
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` const $_c0$ = ["ngFor", "", 1, "ngForOf"]; /** - * @desc d + * @desc [BACKUP_MESSAGE_ID:8538466649243975456] d * @meaning m */ - const $MSG_APP_SPEC_TS__1$ = goog.getMsg("different scope \uFFFD0\uFFFD"); - const $_c2$ = ["title", $MSG_APP_SPEC_TS__1$, 1]; + const $MSG_APP_SPEC_TS__1$ = goog.getMsg("different scope {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c2$ = ["title", $MSG_APP_SPEC_TS__1$]; function MyComponent_div_Template_0(rf, ctx) { if (rf & 1) { $r3$.ɵelementStart(0, "div"); @@ -424,145 +489,188 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); + }); + + it('should work correctly when placed on i18n root node', () => { + const input = ` +
Some content
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7727043314656808423] d + * @meaning m + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("Element title"); + const $_c2$ = ["title", $MSG_APP_SPEC_TS_1$]; + /** + * @desc [BACKUP_MESSAGE_ID:4969674997806975147] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("Some content"); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵi18nAttributes(2, $_c2$); + $r3$.ɵelementEnd(); + } + } + `; + + verify(input, output); }); }); describe('nested nodes', () => { it('should not produce instructions for empty content', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+
+
- @Component({ - selector: 'my-component', - template: \` -
-
- \` - }) - export class MyComponent {} +
+ `; - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵelement(0, "div"); $r3$.ɵelement(1, "div"); + $r3$.ɵelement(2, "div"); } } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + const exceptions = { + '6524085439495453930': 'No translation is produced for empty content (whitespaces)', + '814405839137385666': 'No translation is produced for empty content (line breaks)' + }; + verify(input, output, {exceptions}); }); + it('should properly escape quotes in content', () => { + const input = ` +
Some text 'with single quotes', "with double quotes" and without quotes.
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:4924931801512133405] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("Some text 'with single quotes', \"with double quotes\" and without quotes."); + `; + + verify(input, output); + }); it('should handle i18n attributes with plain-text content', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
My i18n block #1
+
My non-i18n block #1
+
My i18n block #2
+
My non-i18n block #2
+
My i18n block #3
+ `; - @Component({ - selector: 'my-component', - template: \` -
My i18n block #1
-
My non-i18n block #1
-
My i18n block #2
-
My non-i18n block #2
-
My i18n block #3
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:4890179241114413722] + */ const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #1"); + /** + * @desc [BACKUP_MESSAGE_ID:2413150872298537152] + */ const $MSG_APP_SPEC_TS_1$ = goog.getMsg("My i18n block #2"); + /** + * @desc [BACKUP_MESSAGE_ID:5023003143537152794] + */ const $MSG_APP_SPEC_TS_2$ = goog.getMsg("My i18n block #3"); … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵelementStart(0, "div"); - $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); - $r3$.ɵi18nEnd(); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); $r3$.ɵelementEnd(); $r3$.ɵelementStart(2, "div"); $r3$.ɵtext(3, "My non-i18n block #1"); $r3$.ɵelementEnd(); $r3$.ɵelementStart(4, "div"); - $r3$.ɵi18nStart(5, $MSG_APP_SPEC_TS_1$); - $r3$.ɵi18nEnd(); + $r3$.ɵi18n(5, $MSG_APP_SPEC_TS_1$); $r3$.ɵelementEnd(); $r3$.ɵelementStart(6, "div"); $r3$.ɵtext(7, "My non-i18n block #2"); $r3$.ɵelementEnd(); $r3$.ɵelementStart(8, "div"); - $r3$.ɵi18nStart(9, $MSG_APP_SPEC_TS_2$); - $r3$.ɵi18nEnd(); + $r3$.ɵi18n(9, $MSG_APP_SPEC_TS_2$); $r3$.ɵelementEnd(); } } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); - it('should handle i18n attributes with bindings in content', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + it('should support named interpolations', () => { + const input = ` +
Some value: {{ valueA // i18n(ph="PH_A") }}
+ `; - @Component({ - selector: 'my-component', - template: \` -
My i18n block #{{ one }}
-
My i18n block #{{ two | uppercase }}
-
My i18n block #{{ three + four + five }}
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` - const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #\uFFFD0\uFFFD"); - const $MSG_APP_SPEC_TS_1$ = goog.getMsg("My i18n block #\uFFFD0\uFFFD"); - const $MSG_APP_SPEC_TS_2$ = goog.getMsg("My i18n block #\uFFFD0\uFFFD"); + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:2817319788724342848] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("Some value: {$phA}", { "phA": "\uFFFD0\uFFFD" }); … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵelementStart(0, "div"); - $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); - $r3$.ɵi18nEnd(); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.valueA)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle i18n attributes with bindings in content', () => { + const input = ` +
My i18n block #{{ one }}
+
My i18n block #{{ two | uppercase }}
+
My i18n block #{{ three + four + five }}
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:572579892698764378] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:609623417156596326] + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("My i18n block #{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:3998119318957372120] + */ + const $MSG_APP_SPEC_TS_2$ = goog.getMsg("My i18n block #{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); $r3$.ɵelementEnd(); $r3$.ɵelementStart(2, "div"); - $r3$.ɵi18nStart(3, $MSG_APP_SPEC_TS_1$); + $r3$.ɵi18n(3, $MSG_APP_SPEC_TS_1$); $r3$.ɵpipe(4, "uppercase"); - $r3$.ɵi18nEnd(); $r3$.ɵelementEnd(); $r3$.ɵelementStart(5, "div"); - $r3$.ɵi18nStart(6, $MSG_APP_SPEC_TS_2$); - $r3$.ɵi18nEnd(); + $r3$.ɵi18n(6, $MSG_APP_SPEC_TS_2$); $r3$.ɵelementEnd(); } if (rf & 2) { @@ -576,46 +684,48 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should handle i18n attributes with bindings and nested elements in content', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+ My i18n block #{{ one }} + Plain text in nested element +
+
+ My i18n block #{{ two | uppercase }} +
+
+ + More bindings in more nested element: {{ nestedInBlockTwo }} + +
+
+
+ `; - @Component({ - selector: 'my-component', - template: \` -
- My i18n block #{{ one }} - Plain text in nested element -
-
- My i18n block #{{ two | uppercase }} -
-
- - More bindings in more nested element: {{ nestedInBlockTwo }} - -
-
-
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` - const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #\uFFFD0\uFFFD\uFFFD#2\uFFFDPlain text in nested element\uFFFD/#2\uFFFD"); - const $MSG_APP_SPEC_TS_1$ = goog.getMsg("My i18n block #\uFFFD0\uFFFD\uFFFD#6\uFFFD\uFFFD#7\uFFFD\uFFFD#8\uFFFDMore bindings in more nested element: \uFFFD1\uFFFD\uFFFD/#8\uFFFD\uFFFD/#7\uFFFD\uFFFD/#6\uFFFD"); + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7905233330103651696] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg(" My i18n block #{$interpolation} {$startTagSpan}Plain text in nested element{$closeTagSpan}", { + "interpolation": "\uFFFD0\uFFFD", + "startTagSpan": "\uFFFD#2\uFFFD", + "closeTagSpan": "\uFFFD/#2\uFFFD" + }); + /** + * @desc [BACKUP_MESSAGE_ID:5788821996131681377] + */ + const $MSG_APP_SPEC_TS_1_RAW$ = goog.getMsg(" My i18n block #{$interpolation} {$startTagDiv}{$startTagDiv}{$startTagSpan} More bindings in more nested element: {$interpolation_1} {$closeTagSpan}{$closeTagDiv}{$closeTagDiv}", { + "interpolation": "\uFFFD0\uFFFD", + "startTagDiv": "[\uFFFD#6\uFFFD|\uFFFD#7\uFFFD]", + "startTagSpan": "\uFFFD#8\uFFFD", + "interpolation_1": "\uFFFD1\uFFFD", + "closeTagSpan": "\uFFFD/#8\uFFFD", + "closeTagDiv": "[\uFFFD/#7\uFFFD|\uFFFD/#6\uFFFD]" + }); + const $MSG_APP_SPEC_TS_1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_1_RAW$); … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -645,48 +755,55 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should handle i18n attributes with bindings in content and element attributes', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+ My i18n block #1 with value: {{ valueA }} + + Plain text in nested element (block #1) + +
+
+ My i18n block #2 with value {{ valueD | uppercase }} + + Plain text in nested element (block #2) + +
+ `; - @Component({ - selector: 'my-component', - template: \` -
- My i18n block #1 with value: {{ valueA }} - - Plain text in nested element (block #1) - -
-
- My i18n block #2 with value {{ valueD | uppercase }} - - Plain text in nested element (block #2) - -
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` - const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #1 with value: \uFFFD0\uFFFD\uFFFD#2\uFFFDPlain text in nested element (block #1)\uFFFD/#2\uFFFD"); - const $MSG_APP_SPEC_TS_1$ = goog.getMsg("Span title \uFFFD0\uFFFD and \uFFFD1\uFFFD"); - const $_c2$ = ["title", $MSG_APP_SPEC_TS_1$, 2]; - const $MSG_APP_SPEC_TS_3$ = goog.getMsg("My i18n block #2 with value \uFFFD0\uFFFD\uFFFD#7\uFFFDPlain text in nested element (block #2)\uFFFD/#7\uFFFD"); - const $MSG_APP_SPEC_TS_4$ = goog.getMsg("Span title \uFFFD0\uFFFD"); - const $_c5$ = ["title", $MSG_APP_SPEC_TS_4$, 1]; + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:4782264005467235841] + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("Span title {$interpolation} and {$interpolation_1}", { + "interpolation": "\uFFFD0\uFFFD", + "interpolation_1": "\uFFFD1\uFFFD" + }); + const $_c2$ = ["title", $MSG_APP_SPEC_TS_1$]; + /** + * @desc [BACKUP_MESSAGE_ID:4446430594603971069] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg(" My i18n block #1 with value: {$interpolation} {$startTagSpan} Plain text in nested element (block #1) {$closeTagSpan}", { + "interpolation": "\uFFFD0\uFFFD", + "startTagSpan": "\uFFFD#2\uFFFD", + "closeTagSpan": "\uFFFD/#2\uFFFD" + }); + /** + * @desc [BACKUP_MESSAGE_ID:2719594642740200058] + */ + const $MSG_APP_SPEC_TS_4$ = goog.getMsg("Span title {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c5$ = ["title", $MSG_APP_SPEC_TS_4$]; + /** + * @desc [BACKUP_MESSAGE_ID:2778714953278357902] + */ + const $MSG_APP_SPEC_TS_3$ = goog.getMsg(" My i18n block #2 with value {$interpolation} {$startTagSpan} Plain text in nested element (block #2) {$closeTagSpan}", { + "interpolation": "\uFFFD0\uFFFD", + "startTagSpan": "\uFFFD#7\uFFFD", + "closeTagSpan": "\uFFFD/#7\uFFFD" + }); … template: function MyComponent_Template(rf, ctx) { if (rf & 1) { @@ -720,43 +837,35 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); }); it('should handle i18n attributes in nested templates', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; + const input = ` +
+ Some content +
+
+ Some other content {{ valueA }} +
+ More nested levels with bindings {{ valueB | uppercase }} +
+
+
+
+ `; - @Component({ - selector: 'my-component', - template: \` -
- Some content -
-
- Some other content {{ valueA }} -
- More nested levels with bindings {{ valueB | uppercase }} -
-
-
-
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` + const output = String.raw ` const $_c0$ = [1, "ngIf"]; - const $MSG_APP_SPEC_TS__1$ = goog.getMsg("Some other content \uFFFD0\uFFFD\uFFFD#3\uFFFDMore nested levels with bindings \uFFFD1\uFFFD\uFFFD/#3\uFFFD"); + /** + * @desc [BACKUP_MESSAGE_ID:7679414751795588050] + */ + const $MSG_APP_SPEC_TS__1$ = goog.getMsg(" Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$closeTagDiv}", { + "interpolation": "\uFFFD0\uFFFD", + "startTagDiv": "\uFFFD#3\uFFFD", + "interpolation_1": "\uFFFD1\uFFFD", + "closeTagDiv": "\uFFFD/#3\uFFFD" + }); … function MyComponent_div_Template_2(rf, ctx) { if (rf & 1) { @@ -770,9 +879,9 @@ describe('i18n support in the view compiler', () => { $r3$.ɵelementEnd(); } if (rf & 2) { - const $$ctx_r0$$ = $r3$.ɵnextContext(); - $r3$.ɵi18nExp($r3$.ɵbind($$ctx_r0$$.valueA)); - $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(4, 0, $$ctx_r0$$.valueB))); + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.valueA)); + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(4, 0, $ctx_r0$.valueB))); $r3$.ɵi18nApply(2); } } @@ -790,54 +899,83 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); + }); + + it('should ignore i18n attributes on self-closing tags', () => { + const input = ` + + + + `; + + const output = String.raw ` + const $_c0$ = ["src", "logo.png"]; + const $_c1$ = [1, "ngIf"]; + function MyComponent_img_Template_1(rf, ctx) { if (rf & 1) { + $r3$.ɵelement(0, "img", $_c0$); + } } + /** + * @desc [BACKUP_MESSAGE_ID:2367729185105559721] + */ + const $MSG_APP_SPEC_TS__2$ = goog.getMsg("App logo #{$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + const $_c3$ = ["title", $MSG_APP_SPEC_TS__2$]; + function MyComponent_img_Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "img", $_c0$); + $r3$.ɵi18nAttributes(1, $_c3$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + const $ctx_r1$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r1$.id)); + $r3$.ɵi18nApply(1); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelement(0, "img", $_c0$); + $r3$.ɵtemplate(1, MyComponent_img_Template_1, 1, 0, null, $_c1$); + $r3$.ɵtemplate(2, MyComponent_img_Template_2, 2, 0, null, $_c1$); + } + if (rf & 2) { + $r3$.ɵelementProperty(1, "ngIf", $r3$.ɵbind(ctx.visible)); + $r3$.ɵelementProperty(2, "ngIf", $r3$.ɵbind(ctx.visible)); + } + } + `; + + verify(input, output); }); it('should handle i18n context in nested templates', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; - - @Component({ - selector: 'my-component', - template: \` -
- Some content -
- Some other content {{ valueA }} -
- More nested levels with bindings {{ valueB | uppercase }} -
- Content inside sub-template {{ valueC }} -
- Bottom level element {{ valueD }} -
-
-
-
-
- Some other content {{ valueE + valueF }} -
- More nested levels with bindings {{ valueG | uppercase }} -
-
+ const input = ` +
+ Some content +
+ Some other content {{ valueA }} +
+ More nested levels with bindings {{ valueB | uppercase }} +
+ Content inside sub-template {{ valueC }} +
+ Bottom level element {{ valueD }}
- \` - }) - export class MyComponent {} +
+
+
+
+ Some other content {{ valueE + valueF }} +
+ More nested levels with bindings {{ valueG | uppercase }} +
+
+
+ `; - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - - const template = String.raw ` - const $MSG_APP_SPEC_TS_0$ = goog.getMsg("Some content\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFDSome other content \uFFFD0:1\uFFFD\uFFFD#2:1\uFFFDMore nested levels with bindings \uFFFD1:1\uFFFD\uFFFD*4:2\uFFFD\uFFFD#1:2\uFFFDContent inside sub-template \uFFFD0:2\uFFFD\uFFFD#2:2\uFFFDBottom level element \uFFFD1:2\uFFFD\uFFFD/#2:2\uFFFD\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD\uFFFD/#2:1\uFFFD\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD\uFFFD*3:3\uFFFD\uFFFD#1:3\uFFFDSome other content \uFFFD0:3\uFFFD\uFFFD#2:3\uFFFDMore nested levels with bindings \uFFFD1:3\uFFFD\uFFFD/#2:3\uFFFD\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD"); + const output = String.raw ` const $_c1$ = [1, "ngIf"]; - … function MyComponent_div_div_Template_4(rf, ctx) { if (rf & 1) { $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 2); @@ -872,6 +1010,23 @@ describe('i18n support in the view compiler', () => { $r3$.ɵi18nApply(0); } } + /** + * @desc [BACKUP_MESSAGE_ID:1221890473527419724] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg(" Some content {$startTagDiv_2} Some other content {$interpolation} {$startTagDiv} More nested levels with bindings {$interpolation_1} {$startTagDiv_1} Content inside sub-template {$interpolation_2} {$startTagDiv} Bottom level element {$interpolation_3} {$closeTagDiv}{$closeTagDiv}{$closeTagDiv}{$closeTagDiv}{$startTagDiv_3} Some other content {$interpolation_4} {$startTagDiv} More nested levels with bindings {$interpolation_5} {$closeTagDiv}{$closeTagDiv}", { + "startTagDiv_2": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD", + "closeTagDiv": "[\uFFFD/#2:2\uFFFD|\uFFFD/#1:2\uFFFD\uFFFD/*4:2\uFFFD|\uFFFD/#2:1\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD|\uFFFD/#2:3\uFFFD|\uFFFD/#1:3\uFFFD\uFFFD/*3:3\uFFFD]", + "startTagDiv_3": "\uFFFD*3:3\uFFFD\uFFFD#1:3\uFFFD", + "interpolation": "\uFFFD0:1\uFFFD", + "startTagDiv": "[\uFFFD#2:1\uFFFD|\uFFFD#2:2\uFFFD|\uFFFD#2:3\uFFFD]", + "interpolation_1": "\uFFFD1:1\uFFFD", + "startTagDiv_1": "\uFFFD*4:2\uFFFD\uFFFD#1:2\uFFFD", + "interpolation_2": "\uFFFD0:2\uFFFD", + "interpolation_3": "\uFFFD1:2\uFFFD", + "interpolation_4": "\uFFFD0:3\uFFFD", + "interpolation_5": "\uFFFD1:3\uFFFD" + }); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$); function MyComponent_div_Template_3(rf, ctx) { if (rf & 1) { $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 3); @@ -905,32 +1060,1056 @@ describe('i18n support in the view compiler', () => { } `; - const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template'); + verify(input, output); + }); + + it('should handle i18n attribute with directives', () => { + const input = ` +
Some other content {{ valueA }}
+ `; + + const output = String.raw ` + const $_c0$ = [1, "ngIf"]; + /** + * @desc [BACKUP_MESSAGE_ID:119975189388320493] + */ + const $MSG_APP_SPEC_TS__1$ = goog.getMsg("Some other content {$startTagSpan}{$interpolation}{$closeTagSpan}", { + "startTagSpan": "\uFFFD#2\uFFFD", + "interpolation": "\uFFFD0\uFFFD", + "closeTagSpan": "\uFFFD/#2\uFFFD" + }); + … + function MyComponent_div_Template_0(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS__1$); + $r3$.ɵelement(2, "span"); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.valueA)); + $r3$.ɵi18nApply(1); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵtemplate(0, MyComponent_div_Template_0, 3, 0, null, $_c0$); + } + if (rf & 2) { + $r3$.ɵelementProperty(0, "ngIf", $r3$.ɵbind(ctx.visible)); + } + } + `; + + verify(input, output); + }); + }); + + describe('self-closing i18n instructions', () => { + it('should be generated with text-only content', () => { + const input = ` +
My i18n block #1
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:4890179241114413722] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #1"); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + } + `; + + verify(input, output); + }); + + it('should be generated for ICU-only i18n blocks', () => { + const input = ` +
{age, select, 10 {ten} 20 {twenty} other {other}}
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:8806993169187953163] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.age)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should be generated within and blocks', () => { + const input = ` + My i18n block #1 + My i18n block #2 + `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:2413150872298537152] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("My i18n block #2"); + /** + * @desc [BACKUP_MESSAGE_ID:4890179241114413722] + */ + const $MSG_APP_SPEC_TS__1$ = goog.getMsg("My i18n block #1"); + function Template_0(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $MSG_APP_SPEC_TS__1$); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵtemplate(0, Template_0, 1, 0); + $r3$.ɵelementContainerStart(1); + $r3$.ɵi18n(2, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementContainerEnd(); + } + } + `; + + verify(input, output); + }); + }); + + describe('ng-container and ng-template', () => { + it('should handle single translation message using ', () => { + const input = ` + Some content: {{ valueA | uppercase }} + `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:355394464191978948] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("Some content: {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementContainerStart(0); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵpipe(2, "uppercase"); + $r3$.ɵelementContainerEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(2, 0, ctx.valueA))); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle single translation message using ', () => { + const input = ` + Some content: {{ valueA | uppercase }} + `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:355394464191978948] + */ + const $MSG_APP_SPEC_TS__0$ = goog.getMsg("Some content: {$interpolation}", { "interpolation": "\uFFFD0\uFFFD" }); + function Template_0(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $MSG_APP_SPEC_TS__0$); + $r3$.ɵpipe(1, "uppercase"); + } if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(1, 0, $ctx_r0$.valueA))); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵtemplate(0, Template_0, 2, 2); + } + } + `; + + verify(input, output); + }); + + it('should be able to be child elements inside i18n block', () => { + const input = ` +
+ Template content: {{ valueA | uppercase }} + Container content: {{ valueB | uppercase }} +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:702706566400598764] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("{$startTagNgTemplate}Template content: {$interpolation}{$closeTagNgTemplate}{$startTagNgContainer}Container content: {$interpolation_1}{$closeTagNgContainer}", { + "startTagNgTemplate": "\uFFFD*2:1\uFFFD", + "closeTagNgTemplate": "\uFFFD/*2:1\uFFFD", + "startTagNgContainer": "\uFFFD#3\uFFFD", + "interpolation_1": "\uFFFD0\uFFFD", + "closeTagNgContainer": "\uFFFD/#3\uFFFD", + "interpolation": "\uFFFD0:1\uFFFD" + }); + function Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $MSG_APP_SPEC_TS_0$, 1); + $r3$.ɵpipe(1, "uppercase"); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(1, 0, $ctx_r0$.valueA))); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵtemplate(2, Template_2, 2, 2); + $r3$.ɵelementContainerStart(3); + $r3$.ɵpipe(4, "uppercase"); + $r3$.ɵelementContainerEnd(); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(4, 0, ctx.valueB))); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle ICUs outside of translatable sections', () => { + const input = ` + {gender, select, male {male} female {female} other {other}} + {age, select, 10 {ten} 20 {twenty} other {other}} + `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:8806993169187953163] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS__1_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS__1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + function Template_0(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $MSG_APP_SPEC_TS__1$); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.gender)); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵtemplate(0, Template_0, 1, 0); + $r3$.ɵelementContainerStart(1); + $r3$.ɵi18n(2, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementContainerEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.age)); + $r3$.ɵi18nApply(2); + } + } + `; + + verify(input, output); + }); + + it('should correctly propagate i18n context through nested templates', () => { + const input = ` +
+ + Template A: {{ valueA | uppercase }} + + Template B: {{ valueB }} + + Template C: {{ valueC }} + + + +
+ `; + + const output = String.raw ` + function Template_1(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $MSG_APP_SPEC_TS_0$, 3); + } + if (rf & 2) { + const $ctx_r2$ = $r3$.ɵnextContext(3); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r2$.valueC)); + $r3$.ɵi18nApply(0); + } + } + function Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 2); + $r3$.ɵtemplate(1, Template_1, 1, 0); + $r3$.ɵi18nEnd(); + } + if (rf & 2) { + const $ctx_r1$ = $r3$.ɵnextContext(2); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r1$.valueB)); + $r3$.ɵi18nApply(0); + } + } + /** + * @desc [BACKUP_MESSAGE_ID:2051477021417799640] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{$startTagNgTemplate} Template A: {$interpolation} {$startTagNgTemplate} Template B: {$interpolation_1} {$startTagNgTemplate} Template C: {$interpolation_2} {$closeTagNgTemplate}{$closeTagNgTemplate}{$closeTagNgTemplate}", { + "startTagNgTemplate": "[\uFFFD*2:1\uFFFD|\uFFFD*2:2\uFFFD|\uFFFD*1:3\uFFFD]", + "closeTagNgTemplate": "[\uFFFD/*1:3\uFFFD|\uFFFD/*2:2\uFFFD|\uFFFD/*2:1\uFFFD]", + "interpolation": "\uFFFD0:1\uFFFD", + "interpolation_1": "\uFFFD0:2\uFFFD", + "interpolation_2": "\uFFFD0:3\uFFFD" + }); + const MSG_APP_SPEC_TS_0 = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$); + function Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 1); + $r3$.ɵpipe(1, "uppercase"); + $r3$.ɵtemplate(2, Template_2, 2, 0); + $r3$.ɵi18nEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(1, 0, $ctx_r0$.valueA))); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵtemplate(2, Template_2, 3, 2); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + } + `; + + verify(input, output); + }); + + it('should work with ICUs', () => { + const input = ` + {gender, select, male {male} female {female} other {other}} + {age, select, 10 {ten} 20 {twenty} other {other}} + `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:8806993169187953163] + */ + const $MSG_APP_SPEC_TS__1_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); + const $MSG_APP_SPEC_TS__1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + function Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $MSG_APP_SPEC_TS__1$); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.age)); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementContainerStart(0); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementContainerEnd(); + $r3$.ɵtemplate(2, Template_2, 1, 0); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle self-closing tags as content', () => { + const input = ` + + is my logo + + + is my logo + + `; + + const output = String.raw ` + const $_c1$ = ["src", "logo.png", "title", "Logo"]; + /** + * @desc [BACKUP_MESSAGE_ID:394166286969183735] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("{$tagImg} is my logo ", { "tagImg": "\uFFFD#2\uFFFD\uFFFD/#2\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:394166286969183735] + */ + const $MSG_APP_SPEC_TS__2$ = goog.getMsg("{$tagImg} is my logo ", { "tagImg": "\uFFFD#1\uFFFD\uFFFD/#1\uFFFD" }); + function Template_3(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS__2$); + $r3$.ɵelement(1, "img", $_c1$); + $r3$.ɵi18nEnd(); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementContainerStart(0); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelement(2, "img", $_c1$); + $r3$.ɵi18nEnd(); + $r3$.ɵelementContainerEnd(); + $r3$.ɵtemplate(3, Template_3, 2, 0); + } + } + `; + + verify(input, output); + }); + }); + + describe('whitespace preserving mode', () => { + it('should keep inner content of i18n block as is', () => { + const input = ` +
+ Some text + Text inside span +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:963542717423364282] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("\n Some text\n {$startTagSpan}Text inside span{$closeTagSpan}\n ", { + "startTagSpan": "\uFFFD#3\uFFFD", + "closeTagSpan": "\uFFFD/#3\uFFFD" + }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵtext(0, "\n "); + $r3$.ɵelementStart(1, "div"); + $r3$.ɵi18nStart(2, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelement(3, "span"); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + $r3$.ɵtext(4, "\n "); + } + } + `; + + verify(input, output, {inputArgs: {preserveWhitespaces: true}}); + }); + }); + + describe('icu logic', () => { + it('should handle single icus', () => { + const input = ` +
{gender, select, male {male} female {female} other {other}}
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should properly escape quotes in content', () => { + const input = ` +
{gender, select, single {'single quotes'} double {"double quotes"} other {other}}
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:4166854826696768832] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, single {'single quotes'} double {\"double quotes\"} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + `; + + verify(input, output); + }); + + it('should generate i18n instructions for icus generated outside of i18n blocks', () => { + const input = ` +
{gender, select, male {male} female {female} other {other}}
+
+ {age, select, 10 {ten} 20 {twenty} other {other}} +
+
+ You have {count, select, 0 {no emails} 1 {one email} other {{{count}} emails}}. +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + const $_c1$ = [1, "ngIf"]; + const $_c2$ = ["title", "icu only"]; + /** + * @desc [BACKUP_MESSAGE_ID:8806993169187953163] + */ + const $MSG_APP_SPEC_TS__3_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); + const $MSG_APP_SPEC_TS__3$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__3_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + function MyComponent_div_Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div", $_c2$); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS__3$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.age)); + $r3$.ɵi18nApply(1); + } + } + const $_c4$ = ["title", "icu and text"]; + /** + * @desc [BACKUP_MESSAGE_ID:1922743304863699161] + */ + const MSG_APP_SPEC_TS__5_RAW = goog.getMsg("{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{$interpolation} emails}}", { + "interpolation": "\uFFFD1\uFFFD" + }); + const MSG_APP_SPEC_TS__5 = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__5_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + function MyComponent_div_Template_3(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div", $_c4$); + $r3$.ɵtext(1, " You have "); + $r3$.ɵi18n(2, $MSG_APP_SPEC_TS__5$); + $r3$.ɵtext(3, ". "); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + const $ctx_r1$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r1$.count)); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r1$.count)); + $r3$.ɵi18nApply(2); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + $r3$.ɵtemplate(2, MyComponent_div_Template_2, 2, 0, null, $_c1$); + $r3$.ɵtemplate(3, MyComponent_div_Template_3, 4, 0, null, $_c1$); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + $r3$.ɵelementProperty(2, "ngIf", $r3$.ɵbind(ctx.visible)); + $r3$.ɵelementProperty(3, "ngIf", $r3$.ɵbind(ctx.available)); + } + } + `; + + verify(input, output); + }); + + it('should handle icus with html', () => { + const input = ` +
+ {gender, select, male {male - male} female {female female} other {
other
}} + Other content +
Another content
+
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:2417296354340576868] + */ + const $MSG_APP_SPEC_TS_1_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male - {$startBoldText}male{$closeBoldText}} female {female {$startBoldText}female{$closeBoldText}} other {{$startTagDiv}{$startItalicText}other{$closeItalicText}{$closeTagDiv}}}", { + "startBoldText": "", + "closeBoldText": "", + "startItalicText": "", + "closeItalicText": "", + "startTagDiv": "
", + "closeTagDiv": "
" + }); + const $MSG_APP_SPEC_TS_1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + const $_c2$ = ["other", 1, "other", true]; + /** + * @desc [BACKUP_MESSAGE_ID:9102821288363830807] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("{$icu}{$startBoldText}Other content{$closeBoldText}{$startTagDiv}{$startItalicText}Another content{$closeItalicText}{$closeTagDiv}", { + "startBoldText": "\uFFFD#2\uFFFD", + "closeBoldText": "\uFFFD/#2\uFFFD", + "startTagDiv": "\uFFFD#3\uFFFD", + "startItalicText": "\uFFFD#4\uFFFD", + "closeItalicText": "\uFFFD/#4\uFFFD", + "closeTagDiv": "\uFFFD/#3\uFFFD", + "icu": $MSG_APP_SPEC_TS_1$ + }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelement(2, "b"); + $r3$.ɵelementStart(3, "div"); + $r3$.ɵelementStyling($_c2$); + $r3$.ɵelement(4, "i"); + $r3$.ɵelementEnd(); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle icus with expressions', () => { + const input = ` +
{gender, select, male {male of age: {{ ageA + ageB + ageC }}} female {female} other {other}}
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:6879461626778511059] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male of age: {$interpolation}} female {female} other {other}}", { + "interpolation": "\uFFFD1\uFFFD" + }); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nExp($r3$.ɵbind(((ctx.ageA + ctx.ageB) + ctx.ageC))); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle multiple icus in one block', () => { + const input = ` +
+ {gender, select, male {male} female {female} other {other}} + {age, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}} +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_1_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:7068143081688428291] + */ + const $MSG_APP_SPEC_TS_2_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}"); + const $MSG_APP_SPEC_TS_2$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_2_RAW$, { "VAR_SELECT": "\uFFFD1\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:2967249209167308918] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("{$icu}{$icu_1}", { + "icu": $MSG_APP_SPEC_TS_1$, + "icu_1": $MSG_APP_SPEC_TS_2$ + }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.age)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle multiple icus that share same placeholder', () => { + const input = ` +
+ {gender, select, male {male} female {female} other {other}} +
+ {gender, select, male {male} female {female} other {other}} +
+
+ {gender, select, male {male} female {female} other {other}} +
+
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_1_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_2_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_2$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_2_RAW$, { "VAR_SELECT": "\uFFFD1\uFFFD" }); + const $_c3$ = [1, "ngIf"]; + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS__4_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS__4$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__4_RAW$, { "VAR_SELECT": "\uFFFD0:1\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:7986645988117050801] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{$icu}{$startTagDiv}{$icu}{$closeTagDiv}{$startTagDiv_1}{$icu}{$closeTagDiv}", { + "startTagDiv": "\uFFFD#2\uFFFD", + "closeTagDiv": "[\uFFFD/#2\uFFFD|\uFFFD/#1:1\uFFFD\uFFFD/*3:1\uFFFD]", + "startTagDiv_1": "\uFFFD*3:1\uFFFD\uFFFD#1:1\uFFFD", + "icu": "\uFFFDI18N_EXP_ICU\uFFFD" + }); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { + "ICU": [$MSG_APP_SPEC_TS_1$, $MSG_APP_SPEC_TS_2$, $MSG_APP_SPEC_TS__4$] + }); + function MyComponent_div_Template_3(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 1); + $r3$.ɵelement(1, "div"); + $r3$.ɵi18nEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.gender)); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelement(2, "div"); + $r3$.ɵtemplate(3, MyComponent_div_Template_3, 2, 0, null, $_c3$); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵelementProperty(3, "ngIf", $r3$.ɵbind(ctx.visible)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle nested icus', () => { + const input = ` +
+ {gender, select, + male {male of age: {age, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}} + female {female} + other {other} + } +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:343563413083115114] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT_1, select, male {male of age: {VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { + "VAR_SELECT": "\uFFFD0\uFFFD", + "VAR_SELECT_1": "\uFFFD1\uFFFD" + }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.age)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + } + } + `; + + const exceptions = { + '3052001905251380936': 'Wrapper message generated by "ng xi18n" around ICU: " {$ICU} "' + }; + verify(input, output, {exceptions}); + }); + + it('should handle icus in different contexts', () => { + const input = ` +
+ {gender, select, male {male} female {female} other {other}} + + {age, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}} + +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7842238767399919809] + */ + const $MSG_APP_SPEC_TS_1_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male} female {female} other {other}}"); + const $MSG_APP_SPEC_TS_1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + const $_c2$ = [1, "ngIf"]; + /** + * @desc [BACKUP_MESSAGE_ID:7068143081688428291] + */ + const $MSG_APP_SPEC_TS__3_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other}}"); + const $MSG_APP_SPEC_TS__3$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__3_RAW$, { "VAR_SELECT": "\uFFFD0:1\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:1194472282609532229] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("{$icu}{$startTagSpan}{$icu_1}{$closeTagSpan}", { + "startTagSpan": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD", + "closeTagSpan": "\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD", + "icu": MSG_APP_SPEC_TS_1, + "icu_1": MSG_APP_SPEC_TS__3 + }); + function MyComponent_span_Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 1); + $r3$.ɵelement(1, "span"); + $r3$.ɵi18nEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.age)); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵtemplate(2, MyComponent_span_Template_2, 2, 0, null, $_c2$); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵelementProperty(2, "ngIf", $r3$.ɵbind(ctx.ageVisible)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle icus with interpolations', () => { + const input = ` +
+ {gender, select, male {male {{ weight }}} female {female {{ height }}} other {other}} + + {age, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {{ otherAge }}}} + +
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:7825031864601787094] + */ + const $MSG_APP_SPEC_TS_1_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male {$interpolation}} female {female {$interpolation_1}} other {other}}", { + "interpolation": "\uFFFD1\uFFFD", + "interpolation_1": "\uFFFD2\uFFFD" + }); + const $MSG_APP_SPEC_TS_1$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_1_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + const $_c2$ = [1, "ngIf"]; + /** + * @desc [BACKUP_MESSAGE_ID:2310343208266678305] + */ + const $MSG_APP_SPEC_TS__3_RAW$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {$interpolation}}}", { + "interpolation": "\uFFFD1:1\uFFFD" + }); + const $MSG_APP_SPEC_TS__3$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS__3_RAW$, { "VAR_SELECT": "\uFFFD0:1\uFFFD" }); + /** + * @desc [BACKUP_MESSAGE_ID:7186042105600518133] + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("{$icu}{$startTagSpan}{$icu_1}{$closeTagSpan}", { + "startTagSpan": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD", + "closeTagSpan": "\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD", + "icu": $MSG_APP_SPEC_TS_1$, + "icu_1": $MSG_APP_SPEC_TS__3$ + }); + function MyComponent_span_Template_2(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18nStart(0, $MSG_APP_SPEC_TS_0$, 1); + $r3$.ɵelement(1, "span"); + $r3$.ɵi18nEnd(); + } + if (rf & 2) { + const $ctx_r0$ = $r3$.ɵnextContext(); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.age)); + $r3$.ɵi18nExp($r3$.ɵbind($ctx_r0$.otherAge)); + $r3$.ɵi18nApply(0); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nStart(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵtemplate(2, MyComponent_span_Template_2, 2, 0, null, $_c2$); + $r3$.ɵi18nEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵelementProperty(2, "ngIf", $r3$.ɵbind(ctx.ageVisible)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.weight)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.height)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); + }); + + it('should handle icus with named interpolations', () => { + const input = ` +
{ + gender, + select, + male {male {{ weight // i18n(ph="PH_A") }}} + female {female {{ height // i18n(ph="PH_B") }}} + other {other {{ age // i18n(ph="PH_C") }}} + }
+ `; + + const output = String.raw ` + /** + * @desc [BACKUP_MESSAGE_ID:4853189513362404940] + */ + const $MSG_APP_SPEC_TS_0_RAW$ = goog.getMsg("{VAR_SELECT, select, male {male {$phA}} female {female {$phB}} other {other {$phC}}}", { + "phA": "\uFFFD1\uFFFD", + "phB": "\uFFFD2\uFFFD", + "phC": "\uFFFD3\uFFFD" + }); + const $MSG_APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_APP_SPEC_TS_0_RAW$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18n(1, $MSG_APP_SPEC_TS_0$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.gender)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.weight)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.height)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.age)); + $r3$.ɵi18nApply(1); + } + } + `; + + verify(input, output); }); }); describe('errors', () => { it('should throw on nested i18n sections', () => { - const files = { - app: { - 'spec.ts': ` - import {Component, NgModule} from '@angular/core'; - - @Component({ - selector: 'my-component', - template: \` -
- \` - }) - export class MyComponent {} - - @NgModule({declarations: [MyComponent]}) - export class MyModule {} - ` - } - }; - + const files = getAppFilesWithTemplate(` +
Some content
+ `); expect(() => compile(files, angularFiles)) .toThrowError( 'Could not mark an element as translatable inside of a translatable section'); diff --git a/packages/compiler/src/constant_pool.ts b/packages/compiler/src/constant_pool.ts index 2f3856ebd7..e0124efa9f 100644 --- a/packages/compiler/src/constant_pool.ts +++ b/packages/compiler/src/constant_pool.ts @@ -7,21 +7,12 @@ */ import * as o from './output/output_ast'; -import {I18nMeta, parseI18nMeta} from './render3/view/i18n'; import {OutputContext, error} from './util'; const CONSTANT_PREFIX = '_c'; -// Closure variables holding messages must be named `MSG_[A-Z0-9]+` -const TRANSLATION_PREFIX = 'MSG_'; - export const enum DefinitionKind {Injector, Directive, Component, Pipe} -/** - * Closure uses `goog.getMsg(message)` to lookup translations - */ -const GOOG_GET_MSG = 'goog.getMsg'; - /** * Context to use when producing a key. * @@ -78,8 +69,6 @@ class FixupExpression extends o.Expression { */ export class ConstantPool { statements: o.Statement[] = []; - private translations = new Map(); - private deferredTranslations = new Map(); private literals = new Map(); private literalFactories = new Map(); private injectorDefinitions = new Map(); @@ -115,60 +104,6 @@ export class ConstantPool { return fixup; } - getDeferredTranslationConst(suffix: string): o.ReadVarExpr { - const index = this.statements.push(new o.ExpressionStatement(o.NULL_EXPR)) - 1; - const variable = o.variable(this.freshTranslationName(suffix)); - this.deferredTranslations.set(variable, index); - return variable; - } - - setDeferredTranslationConst(variable: o.ReadVarExpr, message: string): void { - const index = this.deferredTranslations.get(variable) !; - this.statements[index] = this.getTranslationDeclStmt(variable, message); - } - - getTranslationDeclStmt(variable: o.ReadVarExpr, message: string): o.DeclareVarStmt { - const fnCall = o.variable(GOOG_GET_MSG).callFn([o.literal(message)]); - return variable.set(fnCall).toDeclStmt(o.INFERRED_TYPE, [o.StmtModifier.Final]); - } - - appendTranslationMeta(meta: string|I18nMeta) { - const parsedMeta = typeof meta === 'string' ? parseI18nMeta(meta) : meta; - const docStmt = i18nMetaToDocStmt(parsedMeta); - if (docStmt) { - this.statements.push(docStmt); - } - } - - // Generates closure specific code for translation. - // - // ``` - // /** - // * @desc description? - // * @meaning meaning? - // */ - // const MSG_XYZ = goog.getMsg('message'); - // ``` - getTranslation(message: string, meta: string, suffix: string): o.Expression { - const parsedMeta = parseI18nMeta(meta); - - // The identity of an i18n message depends on the message and its meaning - const key = parsedMeta.meaning ? `${message}\u0000\u0000${parsedMeta.meaning}` : message; - - const exp = this.translations.get(key); - - if (exp) { - return exp; - } - - const variable = o.variable(this.freshTranslationName(suffix)); - this.appendTranslationMeta(parsedMeta); - this.statements.push(this.getTranslationDeclStmt(variable, message)); - - this.translations.set(key, variable); - return variable; - } - getDefinition(type: any, kind: DefinitionKind, ctx: OutputContext, forceShared: boolean = false): o.Expression { const definitions = this.definitionsOf(kind); @@ -279,10 +214,6 @@ export class ConstantPool { private freshName(): string { return this.uniqueName(CONSTANT_PREFIX); } - private freshTranslationName(suffix: string): string { - return this.uniqueName(TRANSLATION_PREFIX + suffix).toUpperCase(); - } - private keyOf(expression: o.Expression) { return expression.visitExpression(new KeyVisitor(), KEY_CONTEXT); } @@ -349,21 +280,4 @@ function invalid(arg: o.Expression | o.Statement): never { function isVariable(e: o.Expression): e is o.ReadVarExpr { return e instanceof o.ReadVarExpr; -} - -// Converts i18n meta informations for a message (id, description, meaning) -// to a JsDoc statement formatted as expected by the Closure compiler. -function i18nMetaToDocStmt(meta: I18nMeta): o.JSDocCommentStmt|null { - const tags: o.JSDocTag[] = []; - - if (meta.id || meta.description) { - const text = meta.id ? `[BACKUP_MESSAGE_ID:${meta.id}] ${meta.description}` : meta.description; - tags.push({tagName: o.JSDocTagName.Desc, text: text !.trim()}); - } - - if (meta.meaning) { - tags.push({tagName: o.JSDocTagName.Meaning, text: meta.meaning}); - } - - return tags.length == 0 ? null : new o.JSDocCommentStmt(tags); -} +} \ No newline at end of file diff --git a/packages/compiler/src/i18n/i18n_ast.ts b/packages/compiler/src/i18n/i18n_ast.ts index fd2fd93034..02ddf74733 100644 --- a/packages/compiler/src/i18n/i18n_ast.ts +++ b/packages/compiler/src/i18n/i18n_ast.ts @@ -95,6 +95,8 @@ export class IcuPlaceholder implements Node { visit(visitor: Visitor, context?: any): any { return visitor.visitIcuPlaceholder(this, context); } } +export type AST = Message | Node; + export interface Visitor { visitText(text: Text, context?: any): any; visitContainer(container: Container, context?: any): any; diff --git a/packages/compiler/src/i18n/i18n_parser.ts b/packages/compiler/src/i18n/i18n_parser.ts index 815d58c2ff..3913dae2c1 100644 --- a/packages/compiler/src/i18n/i18n_parser.ts +++ b/packages/compiler/src/i18n/i18n_parser.ts @@ -18,15 +18,19 @@ import {PlaceholderRegistry} from './serializers/placeholder'; const _expParser = new ExpressionParser(new ExpressionLexer()); +type VisitNodeFn = (html: html.Node, i18n: i18n.Node) => void; + /** * Returns a function converting html nodes to an i18n Message given an interpolationConfig */ export function createI18nMessageFactory(interpolationConfig: InterpolationConfig): ( - nodes: html.Node[], meaning: string, description: string, id: string) => i18n.Message { + nodes: html.Node[], meaning: string, description: string, id: string, + visitNodeFn?: VisitNodeFn) => i18n.Message { const visitor = new _I18nVisitor(_expParser, interpolationConfig); - return (nodes: html.Node[], meaning: string, description: string, id: string) => - visitor.toI18nMessage(nodes, meaning, description, id); + return (nodes: html.Node[], meaning: string, description: string, id: string, + visitNodeFn?: VisitNodeFn) => + visitor.toI18nMessage(nodes, meaning, description, id, visitNodeFn); } class _I18nVisitor implements html.Visitor { @@ -40,18 +44,21 @@ class _I18nVisitor implements html.Visitor { private _placeholderToContent !: {[phName: string]: string}; // TODO(issue/24571): remove '!'. private _placeholderToMessage !: {[phName: string]: i18n.Message}; + private _visitNodeFn: VisitNodeFn|undefined; constructor( private _expressionParser: ExpressionParser, private _interpolationConfig: InterpolationConfig) {} - public toI18nMessage(nodes: html.Node[], meaning: string, description: string, id: string): - i18n.Message { + public toI18nMessage( + nodes: html.Node[], meaning: string, description: string, id: string, + visitNodeFn?: VisitNodeFn): i18n.Message { this._isIcu = nodes.length == 1 && nodes[0] instanceof html.Expansion; this._icuDepth = 0; this._placeholderRegistry = new PlaceholderRegistry(); this._placeholderToContent = {}; this._placeholderToMessage = {}; + this._visitNodeFn = visitNodeFn; const i18nodes: i18n.Node[] = html.visitAll(this, nodes, {}); @@ -59,6 +66,13 @@ class _I18nVisitor implements html.Visitor { i18nodes, this._placeholderToContent, this._placeholderToMessage, meaning, description, id); } + private _visitNode(html: html.Node, i18n: i18n.Node): i18n.Node { + if (this._visitNodeFn) { + this._visitNodeFn(html, i18n); + } + return i18n; + } + visitElement(el: html.Element, context: any): i18n.Node { const children = html.visitAll(this, el.children); const attrs: {[k: string]: string} = {}; @@ -79,16 +93,19 @@ class _I18nVisitor implements html.Visitor { this._placeholderToContent[closePhName] = ``; } - return new i18n.TagPlaceholder( + const node = new i18n.TagPlaceholder( el.name, attrs, startPhName, closePhName, children, isVoid, el.sourceSpan !); + return this._visitNode(el, node); } visitAttribute(attribute: html.Attribute, context: any): i18n.Node { - return this._visitTextWithInterpolation(attribute.value, attribute.sourceSpan); + const node = this._visitTextWithInterpolation(attribute.value, attribute.sourceSpan); + return this._visitNode(attribute, node); } visitText(text: html.Text, context: any): i18n.Node { - return this._visitTextWithInterpolation(text.value, text.sourceSpan !); + const node = this._visitTextWithInterpolation(text.value, text.sourceSpan !); + return this._visitNode(text, node); } visitComment(comment: html.Comment, context: any): i18n.Node|null { return null; } @@ -110,8 +127,7 @@ class _I18nVisitor implements html.Visitor { const expPh = this._placeholderRegistry.getUniquePlaceholder(`VAR_${icu.type}`); i18nIcu.expressionPlaceholder = expPh; this._placeholderToContent[expPh] = icu.switchValue; - - return i18nIcu; + return this._visitNode(icu, i18nIcu); } // Else returns a placeholder @@ -122,7 +138,8 @@ class _I18nVisitor implements html.Visitor { const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString()); const visitor = new _I18nVisitor(this._expressionParser, this._interpolationConfig); this._placeholderToMessage[phName] = visitor.toI18nMessage([icu], '', '', ''); - return new i18n.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan); + const node = new i18n.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan); + return this._visitNode(icu, node); } visitExpansionCase(icuCase: html.ExpansionCase, context: any): i18n.Node { diff --git a/packages/compiler/src/ml_parser/ast.ts b/packages/compiler/src/ml_parser/ast.ts index 3bd8742fed..a94d7c02b1 100644 --- a/packages/compiler/src/ml_parser/ast.ts +++ b/packages/compiler/src/ml_parser/ast.ts @@ -7,6 +7,7 @@ */ import {AstPath} from '../ast_path'; +import {AST as I18nAST} from '../i18n/i18n_ast'; import {ParseSourceSpan} from '../parse_util'; export interface Node { @@ -15,14 +16,15 @@ export interface Node { } export class Text implements Node { - constructor(public value: string, public sourceSpan: ParseSourceSpan) {} + constructor(public value: string, public sourceSpan: ParseSourceSpan, public i18n?: I18nAST) {} visit(visitor: Visitor, context: any): any { return visitor.visitText(this, context); } } export class Expansion implements Node { constructor( public switchValue: string, public type: string, public cases: ExpansionCase[], - public sourceSpan: ParseSourceSpan, public switchValueSourceSpan: ParseSourceSpan) {} + public sourceSpan: ParseSourceSpan, public switchValueSourceSpan: ParseSourceSpan, + public i18n?: I18nAST) {} visit(visitor: Visitor, context: any): any { return visitor.visitExpansion(this, context); } } @@ -37,7 +39,7 @@ export class ExpansionCase implements Node { export class Attribute implements Node { constructor( public name: string, public value: string, public sourceSpan: ParseSourceSpan, - public valueSpan?: ParseSourceSpan) {} + public valueSpan?: ParseSourceSpan, public i18n?: I18nAST) {} visit(visitor: Visitor, context: any): any { return visitor.visitAttribute(this, context); } } @@ -45,7 +47,7 @@ export class Element implements Node { constructor( public name: string, public attrs: Attribute[], public children: Node[], public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan|null = null, - public endSourceSpan: ParseSourceSpan|null = null) {} + public endSourceSpan: ParseSourceSpan|null = null, public i18n?: I18nAST) {} visit(visitor: Visitor, context: any): any { return visitor.visitElement(this, context); } } diff --git a/packages/compiler/src/ml_parser/html_whitespaces.ts b/packages/compiler/src/ml_parser/html_whitespaces.ts index f948a8481d..2eefa7a472 100644 --- a/packages/compiler/src/ml_parser/html_whitespaces.ts +++ b/packages/compiler/src/ml_parser/html_whitespaces.ts @@ -56,12 +56,12 @@ export class WhitespaceVisitor implements html.Visitor { // but still visit all attributes to eliminate one used as a market to preserve WS return new html.Element( element.name, html.visitAll(this, element.attrs), element.children, element.sourceSpan, - element.startSourceSpan, element.endSourceSpan); + element.startSourceSpan, element.endSourceSpan, element.i18n); } return new html.Element( element.name, element.attrs, html.visitAll(this, element.children), element.sourceSpan, - element.startSourceSpan, element.endSourceSpan); + element.startSourceSpan, element.endSourceSpan, element.i18n); } visitAttribute(attribute: html.Attribute, context: any): any { @@ -73,7 +73,7 @@ export class WhitespaceVisitor implements html.Visitor { if (isNotBlank) { return new html.Text( - replaceNgsp(text.value).replace(WS_REPLACE_REGEXP, ' '), text.sourceSpan); + replaceNgsp(text.value).replace(WS_REPLACE_REGEXP, ' '), text.sourceSpan, text.i18n); } return null; diff --git a/packages/compiler/src/output/map_util.ts b/packages/compiler/src/output/map_util.ts index 359702bfc6..aece040431 100644 --- a/packages/compiler/src/output/map_util.ts +++ b/packages/compiler/src/output/map_util.ts @@ -20,10 +20,11 @@ export function mapEntry(key: string, value: o.Expression): MapEntry { return {key, value, quoted: false}; } -export function mapLiteral(obj: {[key: string]: o.Expression}): o.Expression { +export function mapLiteral( + obj: {[key: string]: o.Expression}, quoted: boolean = false): o.Expression { return o.literalMap(Object.keys(obj).map(key => ({ key, - quoted: false, + quoted, value: obj[key], }))); } diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 792f5a31e9..9501af0048 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -8,6 +8,7 @@ import {SecurityContext} from '../core'; import {AST, BindingType, BoundElementProperty, ParsedEvent, ParsedEventType} from '../expression_parser/ast'; +import {AST as I18nAST} from '../i18n/i18n_ast'; import {ParseSourceSpan} from '../parse_util'; export interface Node { @@ -21,25 +22,26 @@ export class Text implements Node { } export class BoundText implements Node { - constructor(public value: AST, public sourceSpan: ParseSourceSpan) {} + constructor(public value: AST, public sourceSpan: ParseSourceSpan, public i18n?: I18nAST) {} visit(visitor: Visitor): Result { return visitor.visitBoundText(this); } } export class TextAttribute implements Node { constructor( public name: string, public value: string, public sourceSpan: ParseSourceSpan, - public valueSpan?: ParseSourceSpan) {} + public valueSpan?: ParseSourceSpan, public i18n?: I18nAST) {} visit(visitor: Visitor): Result { return visitor.visitTextAttribute(this); } } export class BoundAttribute implements Node { constructor( public name: string, public type: BindingType, public securityContext: SecurityContext, - public value: AST, public unit: string|null, public sourceSpan: ParseSourceSpan) {} + public value: AST, public unit: string|null, public sourceSpan: ParseSourceSpan, + public i18n?: I18nAST) {} - static fromBoundElementProperty(prop: BoundElementProperty) { + static fromBoundElementProperty(prop: BoundElementProperty, i18n?: I18nAST) { return new BoundAttribute( - prop.name, prop.type, prop.securityContext, prop.value, prop.unit, prop.sourceSpan); + prop.name, prop.type, prop.securityContext, prop.value, prop.unit, prop.sourceSpan, i18n); } visit(visitor: Visitor): Result { return visitor.visitBoundAttribute(this); } @@ -65,7 +67,7 @@ export class Element implements Node { public name: string, public attributes: TextAttribute[], public inputs: BoundAttribute[], public outputs: BoundEvent[], public children: Node[], public references: Reference[], public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan|null, - public endSourceSpan: ParseSourceSpan|null) {} + public endSourceSpan: ParseSourceSpan|null, public i18n?: I18nAST) {} visit(visitor: Visitor): Result { return visitor.visitElement(this); } } @@ -74,14 +76,15 @@ export class Template implements Node { public attributes: TextAttribute[], public inputs: BoundAttribute[], public outputs: BoundEvent[], public children: Node[], public references: Reference[], public variables: Variable[], public sourceSpan: ParseSourceSpan, - public startSourceSpan: ParseSourceSpan|null, public endSourceSpan: ParseSourceSpan|null) {} + public startSourceSpan: ParseSourceSpan|null, public endSourceSpan: ParseSourceSpan|null, + public i18n?: I18nAST) {} visit(visitor: Visitor): Result { return visitor.visitTemplate(this); } } export class Content implements Node { constructor( public selectorIndex: number, public attributes: TextAttribute[], - public sourceSpan: ParseSourceSpan) {} + public sourceSpan: ParseSourceSpan, public i18n?: I18nAST) {} visit(visitor: Visitor): Result { return visitor.visitContent(this); } } @@ -95,6 +98,14 @@ export class Reference implements Node { visit(visitor: Visitor): Result { return visitor.visitReference(this); } } +export class Icu implements Node { + constructor( + public vars: {[name: string]: BoundText}, + public placeholders: {[name: string]: Text | BoundText}, public sourceSpan: ParseSourceSpan, + public i18n?: I18nAST) {} + visit(visitor: Visitor): Result { return visitor.visitIcu(this); } +} + export interface Visitor { // Returning a truthy value from `visit()` will prevent `visitAll()` from the call to the typed // method and result returned will become the result included in `visitAll()`s result array. @@ -110,6 +121,7 @@ export interface Visitor { visitBoundEvent(attribute: BoundEvent): Result; visitText(text: Text): Result; visitBoundText(text: BoundText): Result; + visitIcu(icu: Icu): Result; } export class NullVisitor implements Visitor { @@ -123,6 +135,7 @@ export class NullVisitor implements Visitor { visitBoundEvent(attribute: BoundEvent): void {} visitText(text: Text): void {} visitBoundText(text: BoundText): void {} + visitIcu(icu: Icu): void {} } export class RecursiveVisitor implements Visitor { @@ -145,6 +158,7 @@ export class RecursiveVisitor implements Visitor { visitBoundEvent(attribute: BoundEvent): void {} visitText(text: Text): void {} visitBoundText(text: BoundText): void {} + visitIcu(icu: Icu): void {} } export class TransformVisitor implements Visitor { @@ -190,6 +204,7 @@ export class TransformVisitor implements Visitor { visitBoundEvent(attribute: BoundEvent): Node { return attribute; } visitText(text: Text): Node { return text; } visitBoundText(text: BoundText): Node { return text; } + visitIcu(icu: Icu): Node { return icu; } } export function visitAll(visitor: Visitor, nodes: Node[]): Result[] { diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index dc33e750a6..7283d9e01b 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -97,11 +97,13 @@ export class Identifiers { static pipeBind4: o.ExternalReference = {name: 'ɵpipeBind4', moduleName: CORE}; static pipeBindV: o.ExternalReference = {name: 'ɵpipeBindV', moduleName: CORE}; + static i18n: o.ExternalReference = {name: 'ɵi18n', moduleName: CORE}; static i18nAttributes: o.ExternalReference = {name: 'ɵi18nAttributes', moduleName: CORE}; static i18nExp: o.ExternalReference = {name: 'ɵi18nExp', moduleName: CORE}; static i18nStart: o.ExternalReference = {name: 'ɵi18nStart', moduleName: CORE}; static i18nEnd: o.ExternalReference = {name: 'ɵi18nEnd', moduleName: CORE}; static i18nApply: o.ExternalReference = {name: 'ɵi18nApply', moduleName: CORE}; + static i18nPostprocess: o.ExternalReference = {name: 'ɵi18nPostprocess', moduleName: CORE}; static load: o.ExternalReference = {name: 'ɵload', moduleName: CORE}; static loadQueryList: o.ExternalReference = {name: 'ɵloadQueryList', moduleName: CORE}; diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts index cabf6ae64a..1fab7c15e8 100644 --- a/packages/compiler/src/render3/r3_template_transform.ts +++ b/packages/compiler/src/render3/r3_template_transform.ts @@ -7,6 +7,7 @@ */ import {ParsedEvent, ParsedProperty, ParsedVariable} from '../expression_parser/ast'; +import * as i18n from '../i18n/i18n_ast'; import * as html from '../ml_parser/ast'; import {replaceNgsp} from '../ml_parser/html_whitespaces'; import {isNgTemplate} from '../ml_parser/tags'; @@ -17,7 +18,7 @@ import {PreparsedElementType, preparseElement} from '../template_parser/template import {syntaxError} from '../util'; import * as t from './r3_ast'; - +import {I18N_ICU_VAR_PREFIX} from './view/i18n/util'; const BIND_NAME_REGEXP = /^(?:(?:(?:(bind-)|(let-)|(ref-|#)|(on-)|(bindon-)|(@))(.+))|\[\(([^\)]+)\)\]|\[([^\]]+)\]|\(([^\)]+)\))$/; @@ -112,6 +113,7 @@ class HtmlAstToIvyAst implements html.Visitor { const variables: t.Variable[] = []; const references: t.Reference[] = []; const attributes: t.TextAttribute[] = []; + const i18nAttrsMeta: {[key: string]: i18n.AST} = {}; const templateParsedProperties: ParsedProperty[] = []; const templateVariables: t.Variable[] = []; @@ -126,6 +128,10 @@ class HtmlAstToIvyAst implements html.Visitor { // `*attr` defines template bindings let isTemplateBinding = false; + if (attribute.i18n) { + i18nAttrsMeta[attribute.name] = attribute.i18n; + } + if (normalizedName.startsWith(TEMPLATE_ATTR_PREFIX)) { // *-attributes if (elementHasInlineTemplate) { @@ -175,61 +181,83 @@ class HtmlAstToIvyAst implements html.Visitor { const selectorIndex = selector === DEFAULT_CONTENT_SELECTOR ? 0 : this.ngContentSelectors.push(selector); - parsedElement = new t.Content(selectorIndex, attributes, element.sourceSpan); + parsedElement = new t.Content(selectorIndex, attributes, element.sourceSpan, element.i18n); } else if (isTemplateElement) { // `` - const attrs = this.extractAttributes(element.name, parsedProperties); + const attrs = this.extractAttributes(element.name, parsedProperties, i18nAttrsMeta); parsedElement = new t.Template( attributes, attrs.bound, boundEvents, children, references, variables, element.sourceSpan, - element.startSourceSpan, element.endSourceSpan); + element.startSourceSpan, element.endSourceSpan, element.i18n); } else { - const attrs = this.extractAttributes(element.name, parsedProperties); - + const attrs = this.extractAttributes(element.name, parsedProperties, i18nAttrsMeta); parsedElement = new t.Element( element.name, attributes, attrs.bound, boundEvents, children, references, - element.sourceSpan, element.startSourceSpan, element.endSourceSpan); + element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n); } if (elementHasInlineTemplate) { - const attrs = this.extractAttributes('ng-template', templateParsedProperties); + const attrs = this.extractAttributes('ng-template', templateParsedProperties, i18nAttrsMeta); // TODO(pk): test for this case parsedElement = new t.Template( attrs.literal, attrs.bound, [], [parsedElement], [], templateVariables, - element.sourceSpan, element.startSourceSpan, element.endSourceSpan); + element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n); } return parsedElement; } visitAttribute(attribute: html.Attribute): t.TextAttribute { return new t.TextAttribute( - attribute.name, attribute.value, attribute.sourceSpan, attribute.valueSpan); + attribute.name, attribute.value, attribute.sourceSpan, attribute.valueSpan, attribute.i18n); } visitText(text: html.Text): t.Node { - const valueNoNgsp = replaceNgsp(text.value); - const expr = this.bindingParser.parseInterpolation(valueNoNgsp, text.sourceSpan); - return expr ? new t.BoundText(expr, text.sourceSpan) : new t.Text(valueNoNgsp, text.sourceSpan); + return this._visitTextWithInterpolation(text.value, text.sourceSpan, text.i18n); } - visitComment(comment: html.Comment): null { return null; } - - visitExpansion(expansion: html.Expansion): null { return null; } + visitExpansion(expansion: html.Expansion): t.Icu|null { + const meta = expansion.i18n as i18n.Message; + // do not generate Icu in case it was created + // outside of i18n block in a template + if (!meta) { + return null; + } + const vars: {[name: string]: t.BoundText} = {}; + const placeholders: {[name: string]: t.Text | t.BoundText} = {}; + // extract VARs from ICUs - we process them separately while + // assembling resulting message via goog.getMsg function, since + // we need to pass them to top-level goog.getMsg call + Object.keys(meta.placeholders).forEach(key => { + const value = meta.placeholders[key]; + if (key.startsWith(I18N_ICU_VAR_PREFIX)) { + vars[key] = + this._visitTextWithInterpolation(`{{${value}}}`, expansion.sourceSpan) as t.BoundText; + } else { + placeholders[key] = this._visitTextWithInterpolation(value, expansion.sourceSpan); + } + }); + return new t.Icu(vars, placeholders, expansion.sourceSpan, meta); + } visitExpansionCase(expansionCase: html.ExpansionCase): null { return null; } + visitComment(comment: html.Comment): null { return null; } + // convert view engine `ParsedProperty` to a format suitable for IVY - private extractAttributes(elementName: string, properties: ParsedProperty[]): + private extractAttributes( + elementName: string, properties: ParsedProperty[], i18nPropsMeta: {[key: string]: i18n.AST}): {bound: t.BoundAttribute[], literal: t.TextAttribute[]} { const bound: t.BoundAttribute[] = []; const literal: t.TextAttribute[] = []; properties.forEach(prop => { + const i18n = i18nPropsMeta[prop.name]; if (prop.isLiteral) { - literal.push(new t.TextAttribute(prop.name, prop.expression.source || '', prop.sourceSpan)); + literal.push(new t.TextAttribute( + prop.name, prop.expression.source || '', prop.sourceSpan, undefined, i18n)); } else { const bep = this.bindingParser.createBoundElementProperty(elementName, prop); - bound.push(t.BoundAttribute.fromBoundElementProperty(bep)); + bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n)); } }); @@ -305,6 +333,13 @@ class HtmlAstToIvyAst implements html.Visitor { return hasBinding; } + private _visitTextWithInterpolation(value: string, sourceSpan: ParseSourceSpan, i18n?: i18n.AST): + t.Text|t.BoundText { + const valueNoNgsp = replaceNgsp(value); + const expr = this.bindingParser.parseInterpolation(valueNoNgsp, sourceSpan); + return expr ? new t.BoundText(expr, sourceSpan, i18n) : new t.Text(valueNoNgsp, sourceSpan); + } + private parseVariable( identifier: string, value: string, sourceSpan: ParseSourceSpan, variables: t.Variable[]) { if (identifier.indexOf('-') > -1) { @@ -360,7 +395,8 @@ class NonBindableVisitor implements html.Visitor { visitComment(comment: html.Comment): any { return null; } visitAttribute(attribute: html.Attribute): t.TextAttribute { - return new t.TextAttribute(attribute.name, attribute.value, attribute.sourceSpan); + return new t.TextAttribute( + attribute.name, attribute.value, attribute.sourceSpan, undefined, attribute.i18n); } visitText(text: html.Text): t.Text { return new t.Text(text.value, text.sourceSpan); } diff --git a/packages/compiler/src/render3/view/i18n.ts b/packages/compiler/src/render3/view/i18n.ts deleted file mode 100644 index be03f589f8..0000000000 --- a/packages/compiler/src/render3/view/i18n.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import * as o from '../../output/output_ast'; - -/** I18n separators for metadata **/ -const I18N_MEANING_SEPARATOR = '|'; -const I18N_ID_SEPARATOR = '@@'; - -/** Name of the i18n attributes **/ -export const I18N_ATTR = 'i18n'; -export const I18N_ATTR_PREFIX = 'i18n-'; - -/** Placeholder wrapper for i18n expressions **/ -export const I18N_PLACEHOLDER_SYMBOL = '�'; - -// Parse i18n metas like: -// - "@@id", -// - "description[@@id]", -// - "meaning|description[@@id]" -export function parseI18nMeta(meta?: string): I18nMeta { - let id: string|undefined; - let meaning: string|undefined; - let description: string|undefined; - - if (meta) { - const idIndex = meta.indexOf(I18N_ID_SEPARATOR); - const descIndex = meta.indexOf(I18N_MEANING_SEPARATOR); - let meaningAndDesc: string; - [meaningAndDesc, id] = - (idIndex > -1) ? [meta.slice(0, idIndex), meta.slice(idIndex + 2)] : [meta, '']; - [meaning, description] = (descIndex > -1) ? - [meaningAndDesc.slice(0, descIndex), meaningAndDesc.slice(descIndex + 1)] : - ['', meaningAndDesc]; - } - - return {id, meaning, description}; -} - -export function isI18NAttribute(name: string): boolean { - return name === I18N_ATTR || name.startsWith(I18N_ATTR_PREFIX); -} - -export function wrapI18nPlaceholder(content: string | number, contextId: number = 0): string { - const blockId = contextId > 0 ? `:${contextId}` : ''; - return `${I18N_PLACEHOLDER_SYMBOL}${content}${blockId}${I18N_PLACEHOLDER_SYMBOL}`; -} - -export function assembleI18nBoundString( - strings: Array, bindingStartIndex: number = 0, contextId: number = 0): string { - if (!strings.length) return ''; - let acc = ''; - const lastIdx = strings.length - 1; - for (let i = 0; i < lastIdx; i++) { - acc += `${strings[i]}${wrapI18nPlaceholder(bindingStartIndex + i, contextId)}`; - } - acc += strings[lastIdx]; - return acc; -} - -function getSeqNumberGenerator(startsAt: number = 0): () => number { - let current = startsAt; - return () => current++; -} - -export type I18nMeta = { - id?: string, - description?: string, - meaning?: string -}; - -/** - * I18nContext is a helper class which keeps track of all i18n-related aspects - * (accumulates content, bindings, etc) between i18nStart and i18nEnd instructions. - * - * When we enter a nested template, the top-level context is being passed down - * to the nested component, which uses this context to generate a child instance - * of I18nContext class (to handle nested template) and at the end, reconciles it back - * with the parent context. - */ -export class I18nContext { - private id: number; - private content: string = ''; - private bindings = new Set(); - - constructor( - private index: number, private templateIndex: number|null, private ref: any, - private level: number = 0, private uniqueIdGen?: () => number) { - this.uniqueIdGen = uniqueIdGen || getSeqNumberGenerator(); - this.id = this.uniqueIdGen(); - } - - private wrap(symbol: string, elementIndex: number, contextId: number, closed?: boolean) { - const state = closed ? '/' : ''; - return wrapI18nPlaceholder(`${state}${symbol}${elementIndex}`, contextId); - } - private append(content: string) { this.content += content; } - private genTemplatePattern(contextId: number|string, templateId: number|string): string { - return wrapI18nPlaceholder(`tmpl:${contextId}:${templateId}`); - } - - getId() { return this.id; } - getRef() { return this.ref; } - getIndex() { return this.index; } - getContent() { return this.content; } - getTemplateIndex() { return this.templateIndex; } - - getBindings() { return this.bindings; } - appendBinding(binding: o.Expression) { this.bindings.add(binding); } - - isRoot() { return this.level === 0; } - isResolved() { - const regex = new RegExp(this.genTemplatePattern('\\d+', '\\d+')); - return !regex.test(this.content); - } - - appendText(content: string) { this.append(content.trim()); } - appendTemplate(index: number) { this.append(this.genTemplatePattern(this.id, index)); } - appendElement(elementIndex: number, closed?: boolean) { - this.append(this.wrap('#', elementIndex, this.id, closed)); - } - - forkChildContext(index: number, templateIndex: number) { - return new I18nContext(index, templateIndex, this.ref, this.level + 1, this.uniqueIdGen); - } - reconcileChildContext(context: I18nContext) { - const id = context.getId(); - const content = context.getContent(); - const templateIndex = context.getTemplateIndex() !; - const pattern = new RegExp(this.genTemplatePattern(this.id, templateIndex)); - const replacement = - `${this.wrap('*', templateIndex, id)}${content}${this.wrap('*', templateIndex, id, true)}`; - this.content = this.content.replace(pattern, replacement); - } -} \ No newline at end of file diff --git a/packages/compiler/src/render3/view/i18n/context.ts b/packages/compiler/src/render3/view/i18n/context.ts new file mode 100644 index 0000000000..87c50ac6f8 --- /dev/null +++ b/packages/compiler/src/render3/view/i18n/context.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as i18n from '../../../i18n/i18n_ast'; +import * as o from '../../../output/output_ast'; + +import {assembleBoundTextPlaceholders, findIndex, getSeqNumberGenerator, updatePlaceholderMap, wrapI18nPlaceholder} from './util'; + +enum TagType { + ELEMENT, + TEMPLATE +} + +/** + * Generates an object that is used as a shared state between parent and all child contexts. + */ +function setupRegistry() { + return {getUniqueId: getSeqNumberGenerator(), icus: new Map()}; +} + +/** + * I18nContext is a helper class which keeps track of all i18n-related aspects + * (accumulates placeholders, bindings, etc) between i18nStart and i18nEnd instructions. + * + * When we enter a nested template, the top-level context is being passed down + * to the nested component, which uses this context to generate a child instance + * of I18nContext class (to handle nested template) and at the end, reconciles it back + * with the parent context. + * + * @param index Instruction index of i18nStart, which initiates this context + * @param ref Reference to a translation const that represents the content if thus context + * @param level Nestng level defined for child contexts + * @param templateIndex Instruction index of a template which this context belongs to + * @param meta Meta information (id, meaning, description, etc) associated with this context + */ +export class I18nContext { + public readonly id: number; + public bindings = new Set(); + public placeholders = new Map(); + + private _registry !: any; + private _unresolvedCtxCount: number = 0; + + constructor( + readonly index: number, readonly ref: o.ReadVarExpr, readonly level: number = 0, + readonly templateIndex: number|null = null, readonly meta: i18n.AST, private registry?: any) { + this._registry = registry || setupRegistry(); + this.id = this._registry.getUniqueId(); + } + + private appendTag(type: TagType, node: i18n.TagPlaceholder, index: number, closed?: boolean) { + if (node.isVoid && closed) { + return; // ignore "close" for void tags + } + const ph = node.isVoid || !closed ? node.startName : node.closeName; + const content = {type, index, ctx: this.id, isVoid: node.isVoid, closed}; + updatePlaceholderMap(this.placeholders, ph, content); + } + + get icus() { return this._registry.icus; } + get isRoot() { return this.level === 0; } + get isResolved() { return this._unresolvedCtxCount === 0; } + + getSerializedPlaceholders() { + const result = new Map(); + this.placeholders.forEach( + (values, key) => result.set(key, values.map(serializePlaceholderValue))); + return result; + } + + // public API to accumulate i18n-related content + appendBinding(binding: o.Expression) { this.bindings.add(binding); } + appendIcu(name: string, ref: o.Expression) { + updatePlaceholderMap(this._registry.icus, name, ref); + } + appendBoundText(node: i18n.AST) { + const phs = assembleBoundTextPlaceholders(node, this.bindings.size, this.id); + phs.forEach((values, key) => updatePlaceholderMap(this.placeholders, key, ...values)); + } + appendTemplate(node: i18n.AST, index: number) { + // add open and close tags at the same time, + // since we process nested templates separately + this.appendTag(TagType.TEMPLATE, node as i18n.TagPlaceholder, index, false); + this.appendTag(TagType.TEMPLATE, node as i18n.TagPlaceholder, index, true); + this._unresolvedCtxCount++; + } + appendElement(node: i18n.AST, index: number, closed?: boolean) { + this.appendTag(TagType.ELEMENT, node as i18n.TagPlaceholder, index, closed); + } + + /** + * Generates an instance of a child context based on the root one, + * when we enter a nested template within I18n section. + * + * @param index Instruction index of corresponding i18nStart, which initiates this context + * @param templateIndex Instruction index of a template which this context belongs to + * @param meta Meta information (id, meaning, description, etc) associated with this context + * + * @returns I18nContext instance + */ + forkChildContext(index: number, templateIndex: number, meta: i18n.AST) { + return new I18nContext(index, this.ref, this.level + 1, templateIndex, meta, this._registry); + } + + /** + * Reconciles child context into parent one once the end of the i18n block is reached (i18nEnd). + * + * @param context Child I18nContext instance to be reconciled with parent context. + */ + reconcileChildContext(context: I18nContext) { + // set the right context id for open and close + // template tags, so we can use it as sub-block ids + ['start', 'close'].forEach((op: string) => { + const key = (context.meta as any)[`${op}Name`]; + const phs = this.placeholders.get(key) || []; + const tag = phs.find(findTemplateFn(this.id, context.templateIndex)); + if (tag) { + tag.ctx = context.id; + } + }); + + // reconcile placeholders + const childPhs = context.placeholders; + childPhs.forEach((values: any[], key: string) => { + const phs = this.placeholders.get(key); + if (!phs) { + this.placeholders.set(key, values); + return; + } + // try to find matching template... + const tmplIdx = findIndex(phs, findTemplateFn(context.id, context.templateIndex)); + if (tmplIdx >= 0) { + // ... if found - replace it with nested template content + const isCloseTag = key.startsWith('CLOSE'); + const isTemplateTag = key.endsWith('NG-TEMPLATE'); + if (isTemplateTag) { + // current template's content is placed before or after + // parent template tag, depending on the open/close atrribute + phs.splice(tmplIdx + (isCloseTag ? 0 : 1), 0, ...values); + } else { + const idx = isCloseTag ? values.length - 1 : 0; + values[idx].tmpl = phs[tmplIdx]; + phs.splice(tmplIdx, 1, ...values); + } + } else { + // ... otherwise just append content to placeholder value + phs.push(...values); + } + this.placeholders.set(key, phs); + }); + this._unresolvedCtxCount--; + } +} + +// +// Helper methods +// + +function wrap(symbol: string, index: number, contextId: number, closed?: boolean): string { + const state = closed ? '/' : ''; + return wrapI18nPlaceholder(`${state}${symbol}${index}`, contextId); +} + +function wrapTag(symbol: string, {index, ctx, isVoid}: any, closed?: boolean): string { + return isVoid ? wrap(symbol, index, ctx) + wrap(symbol, index, ctx, true) : + wrap(symbol, index, ctx, closed); +} + +function findTemplateFn(ctx: number, templateIndex: number | null) { + return (token: any) => typeof token === 'object' && token.type === TagType.TEMPLATE && + token.index === templateIndex && token.ctx === ctx; +} + +function serializePlaceholderValue(value: any): string { + const element = (data: any, closed?: boolean) => wrapTag('#', data, closed); + const template = (data: any, closed?: boolean) => wrapTag('*', data, closed); + + switch (value.type) { + case TagType.ELEMENT: + // close element tag + if (value.closed) { + return element(value, true) + (value.tmpl ? template(value.tmpl, true) : ''); + } + // open element tag that also initiates a template + if (value.tmpl) { + return template(value.tmpl) + element(value) + + (value.isVoid ? template(value.tmpl, true) : ''); + } + return element(value); + + case TagType.TEMPLATE: + return template(value, value.closed); + + default: + return value; + } +} diff --git a/packages/compiler/src/render3/view/i18n/meta.ts b/packages/compiler/src/render3/view/i18n/meta.ts new file mode 100644 index 0000000000..c277252737 --- /dev/null +++ b/packages/compiler/src/render3/view/i18n/meta.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {decimalDigest} from '../../../i18n/digest'; +import * as i18n from '../../../i18n/i18n_ast'; +import {createI18nMessageFactory} from '../../../i18n/i18n_parser'; +import * as html from '../../../ml_parser/ast'; +import {DEFAULT_INTERPOLATION_CONFIG} from '../../../ml_parser/interpolation_config'; +import {ParseTreeResult} from '../../../ml_parser/parser'; + +import {I18N_ATTR, I18N_ATTR_PREFIX, I18nMeta, hasI18nAttrs, icuFromI18nMessage, metaFromI18nMessage, parseI18nMeta} from './util'; + +function setI18nRefs(html: html.Node & {i18n: i18n.AST}, i18n: i18n.Node) { + html.i18n = i18n; +} + +/** + * This visitor walks over HTML parse tree and converts information stored in + * i18n-related attributes ("i18n" and "i18n-*") into i18n meta object that is + * stored with other element's and attribute's information. + */ +export class I18nMetaVisitor implements html.Visitor { + // i18n message generation factory + private _createI18nMessage = createI18nMessageFactory(DEFAULT_INTERPOLATION_CONFIG); + + constructor(private config: {keepI18nAttrs: boolean}) {} + + private _generateI18nMessage( + nodes: html.Node[], meta: string|i18n.AST = '', + visitNodeFn?: (html: html.Node, i18n: i18n.Node) => void): i18n.Message { + const parsed: I18nMeta = + typeof meta === 'string' ? parseI18nMeta(meta) : metaFromI18nMessage(meta as i18n.Message); + const message = this._createI18nMessage( + nodes, parsed.meaning || '', parsed.description || '', parsed.id || '', visitNodeFn); + if (!message.id) { + // generate (or restore) message id if not specified in template + message.id = typeof meta !== 'string' && (meta as i18n.Message).id || decimalDigest(message); + } + return message; + } + + visitElement(element: html.Element, context: any): any { + if (hasI18nAttrs(element)) { + const attrs: html.Attribute[] = []; + const attrsMeta: {[key: string]: string} = {}; + + for (const attr of element.attrs) { + if (attr.name === I18N_ATTR) { + // root 'i18n' node attribute + const i18n = element.i18n || attr.value; + const message = this._generateI18nMessage(element.children, i18n, setI18nRefs); + // do not assign empty i18n meta + if (message.nodes.length) { + element.i18n = message; + } + + } else if (attr.name.startsWith(I18N_ATTR_PREFIX)) { + // 'i18n-*' attributes + const key = attr.name.slice(I18N_ATTR_PREFIX.length); + attrsMeta[key] = attr.value; + + } else { + // non-i18n attributes + attrs.push(attr); + } + } + + // set i18n meta for attributes + if (Object.keys(attrsMeta).length) { + for (const attr of attrs) { + const meta = attrsMeta[attr.name]; + // do not create translation for empty attributes + if (meta !== undefined && attr.value) { + attr.i18n = this._generateI18nMessage([attr], attr.i18n || meta); + } + } + } + + if (!this.config.keepI18nAttrs) { + // update element's attributes, + // keeping only non-i18n related ones + element.attrs = attrs; + } + } + html.visitAll(this, element.children); + return element; + } + + visitExpansion(expansion: html.Expansion, context: any): any { + let message; + const meta = expansion.i18n; + if (meta instanceof i18n.IcuPlaceholder) { + // set ICU placeholder name (e.g. "ICU_1"), + // generated while processing root element contents, + // so we can reference it when we output translation + const name = meta.name; + message = this._generateI18nMessage([expansion], meta); + const icu = icuFromI18nMessage(message); + icu.name = name; + } else { + // when ICU is a root level translation + message = this._generateI18nMessage([expansion], meta); + } + expansion.i18n = message; + return expansion; + } + + visitText(text: html.Text, context: any): any { return text; } + visitAttribute(attribute: html.Attribute, context: any): any { return attribute; } + visitComment(comment: html.Comment, context: any): any { return comment; } + visitExpansionCase(expansionCase: html.ExpansionCase, context: any): any { return expansionCase; } +} + +export function processI18nMeta(htmlAstWithErrors: ParseTreeResult): ParseTreeResult { + return new ParseTreeResult( + html.visitAll(new I18nMetaVisitor({keepI18nAttrs: false}), htmlAstWithErrors.rootNodes), + htmlAstWithErrors.errors); +} \ No newline at end of file diff --git a/packages/compiler/src/render3/view/i18n/serializer.ts b/packages/compiler/src/render3/view/i18n/serializer.ts new file mode 100644 index 0000000000..cd92cdb034 --- /dev/null +++ b/packages/compiler/src/render3/view/i18n/serializer.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as i18n from '../../../i18n/i18n_ast'; + +import {formatI18nPlaceholderName} from './util'; + +const formatPh = (value: string): string => `{$${formatI18nPlaceholderName(value)}}`; + +/** + * This visitor walks over i18n tree and generates its string representation, + * including ICUs and placeholders in {$PLACEHOLDER} format. + */ +class SerializerVisitor implements i18n.Visitor { + visitText(text: i18n.Text, context: any): any { return text.value; } + + visitContainer(container: i18n.Container, context: any): any { + return container.children.map(child => child.visit(this)).join(''); + } + + visitIcu(icu: i18n.Icu, context: any): any { + const strCases = + Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`); + return `{${icu.expressionPlaceholder}, ${icu.type}, ${strCases.join(' ')}}`; + } + + visitTagPlaceholder(ph: i18n.TagPlaceholder, context: any): any { + return ph.isVoid ? + formatPh(ph.startName) : + `${formatPh(ph.startName)}${ph.children.map(child => child.visit(this)).join('')}${formatPh(ph.closeName)}`; + } + + visitPlaceholder(ph: i18n.Placeholder, context: any): any { return formatPh(ph.name); } + + visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any { return formatPh(ph.name); } +} + +const serializerVisitor = new SerializerVisitor(); + +export function getSerializedI18nContent(message: i18n.Message): string { + return message.nodes.map(node => node.visit(serializerVisitor, null)).join(''); +} \ No newline at end of file diff --git a/packages/compiler/src/render3/view/i18n/util.ts b/packages/compiler/src/render3/view/i18n/util.ts new file mode 100644 index 0000000000..1f49ca5eed --- /dev/null +++ b/packages/compiler/src/render3/view/i18n/util.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as i18n from '../../../i18n/i18n_ast'; +import {toPublicName} from '../../../i18n/serializers/xmb'; +import * as html from '../../../ml_parser/ast'; +import {mapLiteral} from '../../../output/map_util'; +import * as o from '../../../output/output_ast'; + + +/* Closure variables holding messages must be named `MSG_[A-Z0-9]+` */ +const TRANSLATION_PREFIX = 'MSG_'; + +/** Closure uses `goog.getMsg(message)` to lookup translations */ +const GOOG_GET_MSG = 'goog.getMsg'; + +/** String key that is used to provide backup id of translatable message in Closure */ +const BACKUP_MESSAGE_ID = 'BACKUP_MESSAGE_ID'; + +/** Regexp to identify whether backup id already provided in description */ +const BACKUP_MESSAGE_ID_REGEXP = new RegExp(BACKUP_MESSAGE_ID); + +/** I18n separators for metadata **/ +const I18N_MEANING_SEPARATOR = '|'; +const I18N_ID_SEPARATOR = '@@'; + +/** Name of the i18n attributes **/ +export const I18N_ATTR = 'i18n'; +export const I18N_ATTR_PREFIX = 'i18n-'; + +/** Prefix of var expressions used in ICUs */ +export const I18N_ICU_VAR_PREFIX = 'VAR_'; + +/** Prefix of ICU expressions for post processing */ +export const I18N_ICU_MAPPING_PREFIX = 'I18N_EXP_'; + +/** Placeholder wrapper for i18n expressions **/ +export const I18N_PLACEHOLDER_SYMBOL = '�'; + +export type I18nMeta = { + id?: string, + description?: string, + meaning?: string +}; + +function i18nTranslationToDeclStmt( + variable: o.ReadVarExpr, message: string, + params?: {[name: string]: o.Expression}): o.DeclareVarStmt { + const args = [o.literal(message) as o.Expression]; + if (params && Object.keys(params).length) { + args.push(mapLiteral(params, true)); + } + const fnCall = o.variable(GOOG_GET_MSG).callFn(args); + return variable.set(fnCall).toDeclStmt(o.INFERRED_TYPE, [o.StmtModifier.Final]); +} + +// Converts i18n meta informations for a message (id, description, meaning) +// to a JsDoc statement formatted as expected by the Closure compiler. +function i18nMetaToDocStmt(meta: I18nMeta): o.JSDocCommentStmt|null { + const tags: o.JSDocTag[] = []; + const {id, description, meaning} = meta; + if (id || description) { + const hasBackupId = !!description && BACKUP_MESSAGE_ID_REGEXP.test(description); + const text = + id && !hasBackupId ? `[${BACKUP_MESSAGE_ID}:${id}] ${description || ''}` : description; + tags.push({tagName: o.JSDocTagName.Desc, text: text !.trim()}); + } + if (meaning) { + tags.push({tagName: o.JSDocTagName.Meaning, text: meaning}); + } + return tags.length == 0 ? null : new o.JSDocCommentStmt(tags); +} + +export function isI18nAttribute(name: string): boolean { + return name === I18N_ATTR || name.startsWith(I18N_ATTR_PREFIX); +} + +export function isI18nRootNode(meta?: i18n.AST): meta is i18n.Message { + return meta instanceof i18n.Message; +} + +export function isSingleI18nIcu(meta?: i18n.AST): boolean { + return isI18nRootNode(meta) && meta.nodes.length === 1 && meta.nodes[0] instanceof i18n.Icu; +} + +export function hasI18nAttrs(element: html.Element): boolean { + return element.attrs.some((attr: html.Attribute) => isI18nAttribute(attr.name)); +} + +export function metaFromI18nMessage(message: i18n.Message): I18nMeta { + return { + id: message.id || '', + meaning: message.meaning || '', + description: message.description || '' + }; +} + +export function icuFromI18nMessage(message: i18n.Message) { + return message.nodes[0] as i18n.IcuPlaceholder; +} + +export function wrapI18nPlaceholder(content: string | number, contextId: number = 0): string { + const blockId = contextId > 0 ? `:${contextId}` : ''; + return `${I18N_PLACEHOLDER_SYMBOL}${content}${blockId}${I18N_PLACEHOLDER_SYMBOL}`; +} + +export function assembleI18nBoundString( + strings: string[], bindingStartIndex: number = 0, contextId: number = 0): string { + if (!strings.length) return ''; + let acc = ''; + const lastIdx = strings.length - 1; + for (let i = 0; i < lastIdx; i++) { + acc += `${strings[i]}${wrapI18nPlaceholder(bindingStartIndex + i, contextId)}`; + } + acc += strings[lastIdx]; + return acc; +} + +export function getSeqNumberGenerator(startsAt: number = 0): () => number { + let current = startsAt; + return () => current++; +} + +export function placeholdersToParams(placeholders: Map): + {[name: string]: o.Expression} { + const params: {[name: string]: o.Expression} = {}; + placeholders.forEach((values: string[], key: string) => { + params[key] = o.literal(values.length > 1 ? `[${values.join('|')}]` : values[0]); + }); + return params; +} + +export function updatePlaceholderMap(map: Map, name: string, ...values: any[]) { + const current = map.get(name) || []; + current.push(...values); + map.set(name, current); +} + +export function assembleBoundTextPlaceholders( + meta: i18n.AST, bindingStartIndex: number = 0, contextId: number = 0): Map { + const startIdx = bindingStartIndex; + const placeholders = new Map(); + const node = + meta instanceof i18n.Message ? meta.nodes.find(node => node instanceof i18n.Container) : meta; + if (node) { + (node as i18n.Container) + .children.filter((child: i18n.Node) => child instanceof i18n.Placeholder) + .forEach((child: i18n.Placeholder, idx: number) => { + const content = wrapI18nPlaceholder(startIdx + idx, contextId); + updatePlaceholderMap(placeholders, child.name, content); + }); + } + return placeholders; +} + +export function findIndex(items: any[], callback: (item: any) => boolean): number { + for (let i = 0; i < items.length; i++) { + if (callback(items[i])) { + return i; + } + } + return -1; +} + +/** + * Parses i18n metas like: + * - "@@id", + * - "description[@@id]", + * - "meaning|description[@@id]" + * and returns an object with parsed output. + * + * @param meta String that represents i18n meta + * @returns Object with id, meaning and description fields + */ +export function parseI18nMeta(meta?: string): I18nMeta { + let id: string|undefined; + let meaning: string|undefined; + let description: string|undefined; + + if (meta) { + const idIndex = meta.indexOf(I18N_ID_SEPARATOR); + const descIndex = meta.indexOf(I18N_MEANING_SEPARATOR); + let meaningAndDesc: string; + [meaningAndDesc, id] = + (idIndex > -1) ? [meta.slice(0, idIndex), meta.slice(idIndex + 2)] : [meta, '']; + [meaning, description] = (descIndex > -1) ? + [meaningAndDesc.slice(0, descIndex), meaningAndDesc.slice(descIndex + 1)] : + ['', meaningAndDesc]; + } + + return {id, meaning, description}; +} + +/** + * Converts internal placeholder names to public-facing format + * (for example to use in goog.getMsg call). + * Example: `START_TAG_DIV_1` is converted to `startTagDiv_1`. + * + * @param name The placeholder name that should be formatted + * @returns Formatted placeholder name + */ +export function formatI18nPlaceholderName(name: string): string { + const chunks = toPublicName(name).split('_'); + if (chunks.length === 1) { + // if no "_" found - just lowercase the value + return name.toLowerCase(); + } + let postfix; + // eject last element if it's a number + if (/^\d+$/.test(chunks[chunks.length - 1])) { + postfix = chunks.pop(); + } + let raw = chunks.shift() !.toLowerCase(); + if (chunks.length) { + raw += chunks.map(c => c.charAt(0).toUpperCase() + c.slice(1).toLowerCase()).join(''); + } + return postfix ? `${raw}_${postfix}` : raw; +} + +export function getTranslationConstPrefix(fileBasedSuffix: string): string { + return `${TRANSLATION_PREFIX}${fileBasedSuffix}`.toUpperCase(); +} + +/** + * Generates translation declaration statements. + * + * @param variable Translation value reference + * @param message Text message to be translated + * @param meta Object that contains meta information (id, meaning and description) + * @param params Object with placeholders key-value pairs + * @param transformFn Optional transformation (post processing) function reference + * @returns Array of Statements that represent a given translation + */ +export function getTranslationDeclStmts( + variable: o.ReadVarExpr, message: string, meta: I18nMeta, + params: {[name: string]: o.Expression} = {}, + transformFn?: (raw: o.ReadVarExpr) => o.Expression): o.Statement[] { + const statements: o.Statement[] = []; + const docStatements = i18nMetaToDocStmt(meta); + if (docStatements) { + statements.push(docStatements); + } + if (transformFn) { + const raw = o.variable(`${variable.name}_RAW`); + statements.push(i18nTranslationToDeclStmt(raw, message, params)); + statements.push( + variable.set(transformFn(raw)).toDeclStmt(o.INFERRED_TYPE, [o.StmtModifier.Final])); + } else { + statements.push(i18nTranslationToDeclStmt(variable, message, params)); + } + return statements; +} \ No newline at end of file diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index a9e50a9436..6d73a35c8a 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -8,7 +8,7 @@ import {AST, ImplicitReceiver, MethodCall, PropertyRead, PropertyWrite, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead} from '../../expression_parser/ast'; import {CssSelector, SelectorMatcher} from '../../selector'; -import {BoundAttribute, BoundEvent, BoundText, Content, Element, Node, Reference, Template, Text, TextAttribute, Variable, Visitor} from '../r3_ast'; +import {BoundAttribute, BoundEvent, BoundText, Content, Element, Icu, Node, Reference, Template, Text, TextAttribute, Variable, Visitor} from '../r3_ast'; import {BoundTarget, DirectiveMeta, Target, TargetBinder} from './t2_api'; import {getAttrsForDirectiveMatching} from './util'; @@ -132,6 +132,7 @@ class Scope implements Visitor { visitBoundText(text: BoundText) {} visitText(text: Text) {} visitTextAttribute(attr: TextAttribute) {} + visitIcu(icu: Icu) {} private maybeDeclare(thing: Reference|Variable) { // Declare something with a name, as long as that name isn't taken. @@ -312,6 +313,7 @@ class DirectiveBinder implements Visitor { visitBoundAttributeOrEvent(node: BoundAttribute|BoundEvent) {} visitText(text: Text): void {} visitBoundText(text: BoundText): void {} + visitIcu(icu: Icu): void {} } /** @@ -423,6 +425,7 @@ class TemplateBinder extends RecursiveAstVisitor implements Visitor { visitText(text: Text) {} visitContent(content: Content) {} visitTextAttribute(attribute: TextAttribute) {} + visitIcu(icu: Icu): void {} // The remaining visitors are concerned with processing AST expressions within template bindings diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 1fd2a8584d..da83a9fbf6 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -13,11 +13,13 @@ import * as core from '../../core'; import {AST, AstMemoryEfficientTransformer, BindingPipe, BindingType, FunctionCall, ImplicitReceiver, Interpolation, LiteralArray, LiteralMap, LiteralPrimitive, PropertyRead} from '../../expression_parser/ast'; import {Lexer} from '../../expression_parser/lexer'; import {Parser} from '../../expression_parser/parser'; +import * as i18n from '../../i18n/i18n_ast'; import * as html from '../../ml_parser/ast'; import {HtmlParser} from '../../ml_parser/html_parser'; import {WhitespaceVisitor} from '../../ml_parser/html_whitespaces'; import {DEFAULT_INTERPOLATION_CONFIG} from '../../ml_parser/interpolation_config'; import {isNgContainer as checkIsNgContainer, splitNsName} from '../../ml_parser/tags'; +import {mapLiteral} from '../../output/map_util'; import * as o from '../../output/output_ast'; import {ParseError, ParseSourceSpan} from '../../parse_util'; import {DomElementSchemaRegistry} from '../../schema/dom_element_schema_registry'; @@ -29,7 +31,10 @@ import {Identifiers as R3} from '../r3_identifiers'; import {htmlAstToRender3Ast} from '../r3_template_transform'; import {R3QueryMetadata} from './api'; -import {I18N_ATTR, I18N_ATTR_PREFIX, I18nContext, assembleI18nBoundString} from './i18n'; +import {I18nContext} from './i18n/context'; +import {I18nMetaVisitor} from './i18n/meta'; +import {getSerializedI18nContent} from './i18n/serializer'; +import {I18N_ICU_MAPPING_PREFIX, assembleBoundTextPlaceholders, assembleI18nBoundString, formatI18nPlaceholderName, getTranslationConstPrefix, getTranslationDeclStmts, icuFromI18nMessage, isI18nRootNode, isSingleI18nIcu, metaFromI18nMessage, placeholdersToParams, wrapI18nPlaceholder} from './i18n/util'; import {StylingBuilder, StylingInstruction} from './styling'; import {CONTEXT_NAME, IMPLICIT_REFERENCE, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, asLiteral, getAttrsForDirectiveMatching, invalid, trimTrailingNulls, unsupported} from './util'; @@ -151,7 +156,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver buildTemplateFunction( nodes: t.Node[], variables: t.Variable[], hasNgContent: boolean = false, - ngContentSelectors: string[] = []): o.FunctionExpr { + ngContentSelectors: string[] = [], i18n?: i18n.AST): o.FunctionExpr { if (this._namespace !== R3.namespaceHTML) { this.creationInstruction(null, this._namespace); } @@ -175,8 +180,15 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver this.creationInstruction(null, R3.projectionDef, parameters); } - if (this.i18nContext) { - this.i18nStart(); + // Initiate i18n context in case: + // - this template has parent i18n context + // - or the template has i18n meta associated with it, + // but it's not initiated by the Element (e.g. ) + const initI18nContext = this.i18nContext || + (isI18nRootNode(i18n) && !(isSingleElementTemplate(nodes) && nodes[0].i18n === i18n)); + const selfClosingI18nInstruction = hasTextChildrenOnly(nodes); + if (initI18nContext) { + this.i18nStart(null, i18n !, selfClosingI18nInstruction); } // This is the initial pass through the nodes of this template. In this pass, we @@ -198,8 +210,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // instructions can be generated with the correct internal const count. this._nestedTemplateFns.forEach(buildTemplateFn => buildTemplateFn()); - if (this.i18nContext) { - this.i18nEnd(); + if (initI18nContext) { + this.i18nEnd(null, selfClosingI18nInstruction); } // Generate all the creation mode instructions (e.g. resolve bindings in listeners) @@ -240,59 +252,140 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // LocalResolver getLocal(name: string): o.Expression|null { return this._bindingScope.get(name); } - i18nTranslate(label: string, meta: string = ''): o.Expression { - return this.constantPool.getTranslation(label, meta, this.fileBasedI18nSuffix); + i18nTranslate( + message: i18n.Message, params: {[name: string]: o.Expression} = {}, ref?: o.ReadVarExpr, + transformFn?: (raw: o.ReadVarExpr) => o.Expression): o.Expression { + const _ref = ref || this.i18nAllocateRef(); + const _params: {[key: string]: any} = {}; + if (params && Object.keys(params).length) { + Object.keys(params).forEach(key => _params[formatI18nPlaceholderName(key)] = params[key]); + } + const meta = metaFromI18nMessage(message); + const content = getSerializedI18nContent(message); + const statements = getTranslationDeclStmts(_ref, content, meta, _params, transformFn); + this.constantPool.statements.push(...statements); + return _ref; } - i18nAppendTranslationMeta(meta: string = '') { this.constantPool.appendTranslationMeta(meta); } + i18nAppendBindings(expressions: AST[]) { + if (!this.i18n || !expressions.length) return; + const implicit = o.variable(CONTEXT_NAME); + expressions.forEach(expression => { + const binding = this.convertExpressionBinding(implicit, expression); + this.i18n !.appendBinding(binding); + }); + } - i18nAllocateRef(): o.ReadVarExpr { - return this.constantPool.getDeferredTranslationConst(this.fileBasedI18nSuffix); + i18nBindProps(props: {[key: string]: t.Text | t.BoundText}): {[key: string]: o.Expression} { + const bound: {[key: string]: o.Expression} = {}; + Object.keys(props).forEach(key => { + const prop = props[key]; + if (prop instanceof t.Text) { + bound[key] = o.literal(prop.value); + } else { + const value = prop.value.visit(this._valueConverter); + if (value instanceof Interpolation) { + const {strings, expressions} = value; + const {id, bindings} = this.i18n !; + const label = assembleI18nBoundString(strings, bindings.size, id); + this.i18nAppendBindings(expressions); + bound[key] = o.literal(label); + } + } + }); + return bound; + } + + i18nAllocateRef() { + const prefix = getTranslationConstPrefix(this.fileBasedI18nSuffix); + return o.variable(this.constantPool.uniqueName(prefix)); } i18nUpdateRef(context: I18nContext): void { - if (context.isRoot() && context.isResolved()) { - this.constantPool.setDeferredTranslationConst(context.getRef(), context.getContent()); + const {icus, meta, isRoot, isResolved} = context; + if (isRoot && isResolved && !isSingleI18nIcu(meta)) { + const placeholders = context.getSerializedPlaceholders(); + let icuMapping: {[name: string]: o.Expression} = {}; + let params: {[name: string]: o.Expression} = + placeholders.size ? placeholdersToParams(placeholders) : {}; + if (icus.size) { + icus.forEach((refs: o.Expression[], key: string) => { + if (refs.length === 1) { + // if we have one ICU defined for a given + // placeholder - just output its reference + params[key] = refs[0]; + } else { + // ... otherwise we need to activate post-processing + // to replace ICU placeholders with proper values + const placeholder: string = wrapI18nPlaceholder(`${I18N_ICU_MAPPING_PREFIX}${key}`); + params[key] = o.literal(placeholder); + icuMapping[key] = o.literalArr(refs); + } + }); + } + + // translation requires post processing in 2 cases: + // - if we have placeholders with multiple values (ex. `START_DIV`: [�#1�, �#2�, ...]) + // - if we have multiple ICUs that refer to the same placeholder name + const needsPostprocessing = + Array.from(placeholders.values()).some((value: string[]) => value.length > 1) || + Object.keys(icuMapping).length; + + let transformFn; + if (needsPostprocessing) { + transformFn = (raw: o.ReadVarExpr) => { + const args: o.Expression[] = [raw]; + if (Object.keys(icuMapping).length) { + args.push(mapLiteral(icuMapping, true)); + } + return instruction(null, R3.i18nPostprocess, args); + }; + } + this.i18nTranslate(meta as i18n.Message, params, context.ref, transformFn); } } - i18nStart(span: ParseSourceSpan|null = null, meta?: string): void { + i18nStart(span: ParseSourceSpan|null = null, meta: i18n.AST, selfClosing?: boolean): void { const index = this.allocateDataSlot(); if (this.i18nContext) { - this.i18n = this.i18nContext.forkChildContext(index, this.templateIndex !); + this.i18n = this.i18nContext.forkChildContext(index, this.templateIndex !, meta); } else { - this.i18nAppendTranslationMeta(meta); const ref = this.i18nAllocateRef(); - this.i18n = new I18nContext(index, this.templateIndex, ref); + this.i18n = new I18nContext(index, ref, 0, this.templateIndex, meta); } // generate i18nStart instruction - const params: o.Expression[] = [o.literal(index), this.i18n.getRef()]; - if (this.i18n.getId() > 0) { + const {id, ref} = this.i18n; + const params: o.Expression[] = [o.literal(index), ref]; + if (id > 0) { // do not push 3rd argument (sub-block id) // into i18nStart call for top level i18n context - params.push(o.literal(this.i18n.getId())); + params.push(o.literal(id)); } - this.creationInstruction(span, R3.i18nStart, params); + this.creationInstruction(span, selfClosing ? R3.i18n : R3.i18nStart, params); } - i18nEnd(span: ParseSourceSpan|null = null): void { + i18nEnd(span: ParseSourceSpan|null = null, selfClosing?: boolean): void { + if (!this.i18n) { + throw new Error('i18nEnd is executed with no i18n context present'); + } + if (this.i18nContext) { - this.i18nContext.reconcileChildContext(this.i18n !); + this.i18nContext.reconcileChildContext(this.i18n); this.i18nUpdateRef(this.i18nContext); } else { - this.i18nUpdateRef(this.i18n !); + this.i18nUpdateRef(this.i18n); } // setup accumulated bindings - const bindings = this.i18n !.getBindings(); + const {index, bindings} = this.i18n; if (bindings.size) { - bindings.forEach(binding => { this.updateInstruction(span, R3.i18nExp, [binding]); }); - const index: o.Expression = o.literal(this.i18n !.getIndex()); - this.updateInstruction(span, R3.i18nApply, [index]); + bindings.forEach(binding => this.updateInstruction(span, R3.i18nExp, [binding])); + this.updateInstruction(span, R3.i18nApply, [o.literal(index)]); + } + if (!selfClosing) { + this.creationInstruction(span, R3.i18nEnd); } - - this.creationInstruction(span, R3.i18nEnd); this.i18n = null; // reset local i18n context } @@ -341,36 +434,31 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const stylingBuilder = new StylingBuilder(elementIndex); let isNonBindableMode: boolean = false; - let isI18nRootElement: boolean = false; + const isI18nRootElement: boolean = isI18nRootNode(element.i18n); - const outputAttrs: {[name: string]: string} = {}; - const attrI18nMetas: {[name: string]: string} = {}; - let i18nMeta: string = ''; + if (isI18nRootElement && this.i18n) { + throw new Error(`Could not mark an element as translatable inside of a translatable section`); + } + + const i18nAttrs: (t.TextAttribute | t.BoundAttribute)[] = []; + const outputAttrs: t.TextAttribute[] = []; const [namespaceKey, elementName] = splitNsName(element.name); const isNgContainer = checkIsNgContainer(element.name); - // Handle i18n and ngNonBindable attributes + // Handle styling, i18n, ngNonBindable attributes for (const attr of element.attributes) { - const name = attr.name; - const value = attr.value; + const {name, value} = attr; if (name === NON_BINDABLE_ATTR) { isNonBindableMode = true; - } else if (name === I18N_ATTR) { - if (this.i18n) { - throw new Error( - `Could not mark an element as translatable inside of a translatable section`); - } - isI18nRootElement = true; - i18nMeta = value; - } else if (name.startsWith(I18N_ATTR_PREFIX)) { - attrI18nMetas[name.slice(I18N_ATTR_PREFIX.length)] = value; } else if (name == 'style') { stylingBuilder.registerStyleAttr(value); } else if (name == 'class') { stylingBuilder.registerClassAttr(value); + } else if (attr.i18n) { + i18nAttrs.push(attr); } else { - outputAttrs[name] = value; + outputAttrs.push(attr); } } @@ -387,12 +475,11 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const attributes: o.Expression[] = []; const allOtherInputs: t.BoundAttribute[] = []; - const i18nAttrs: Array<{name: string, value: string | AST}> = []; element.inputs.forEach((input: t.BoundAttribute) => { if (!stylingBuilder.registerInput(input)) { if (input.type == BindingType.Property) { - if (attrI18nMetas.hasOwnProperty(input.name)) { - i18nAttrs.push({name: input.name, value: input.value}); + if (input.i18n) { + i18nAttrs.push(input); } else { allOtherInputs.push(input); } @@ -402,14 +489,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } }); - Object.getOwnPropertyNames(outputAttrs).forEach(name => { - const value = outputAttrs[name]; - if (attrI18nMetas.hasOwnProperty(name)) { - i18nAttrs.push({name, value}); - } else { - attributes.push(o.literal(name), o.literal(value)); - } - }); + outputAttrs.forEach(attr => attributes.push(o.literal(attr.name), o.literal(attr.value))); // this will build the instructions so that they fall into the following syntax // add attributes for directive matching purposes @@ -431,15 +511,14 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const implicit = o.variable(CONTEXT_NAME); if (this.i18n) { - this.i18n.appendElement(elementIndex); + this.i18n.appendElement(element.i18n !, elementIndex); } const hasChildren = () => { if (!isI18nRootElement && this.i18n) { - // we do not append text node instructions inside i18n section, so we - // exclude them while calculating whether current element has children - return element.children.find( - child => !(child instanceof t.Text || child instanceof t.BoundText)); + // we do not append text node instructions and ICUs inside i18n section, + // so we exclude them while calculating whether current element has children + return !hasTextChildrenOnly(element.children); } return element.children.length > 0; }; @@ -447,6 +526,9 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const createSelfClosingInstruction = !stylingBuilder.hasBindingsOrInitialValues && !isNgContainer && element.outputs.length === 0 && i18nAttrs.length === 0 && !hasChildren(); + const createSelfClosingI18nInstruction = + !createSelfClosingInstruction && hasTextChildrenOnly(element.children); + if (createSelfClosingInstruction) { this.creationInstruction(element.sourceSpan, R3.element, trimTrailingNulls(parameters)); } else { @@ -459,27 +541,24 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } if (isI18nRootElement) { - this.i18nStart(element.sourceSpan, i18nMeta); + this.i18nStart(element.sourceSpan, element.i18n !, createSelfClosingI18nInstruction); } // process i18n element attributes if (i18nAttrs.length) { let hasBindings: boolean = false; const i18nAttrArgs: o.Expression[] = []; - i18nAttrs.forEach(({name, value}) => { - const meta = attrI18nMetas[name]; - if (typeof value === 'string') { - // in case of static string value, 3rd argument is 0 declares - // that there are no expressions defined in this translation - i18nAttrArgs.push(o.literal(name), this.i18nTranslate(value, meta), o.literal(0)); + i18nAttrs.forEach(attr => { + const message = attr.i18n !as i18n.Message; + if (attr instanceof t.TextAttribute) { + i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message)); } else { - const converted = value.visit(this._valueConverter); + const converted = attr.value.visit(this._valueConverter); if (converted instanceof Interpolation) { - const {strings, expressions} = converted; - const label = assembleI18nBoundString(strings); - i18nAttrArgs.push( - o.literal(name), this.i18nTranslate(label, meta), o.literal(expressions.length)); - expressions.forEach(expression => { + const placeholders = assembleBoundTextPlaceholders(message); + const params = placeholdersToParams(placeholders); + i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message, params)); + converted.expressions.forEach(expression => { hasBindings = true; const binding = this.convertExpressionBinding(implicit, expression); this.updateInstruction(element.sourceSpan, R3.i18nExp, [binding]); @@ -552,14 +631,14 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver t.visitAll(this, element.children); if (!isI18nRootElement && this.i18n) { - this.i18n.appendElement(elementIndex, true); + this.i18n.appendElement(element.i18n !, elementIndex, true); } if (!createSelfClosingInstruction) { // Finish element construction mode. const span = element.endSourceSpan || element.sourceSpan; if (isI18nRootElement) { - this.i18nEnd(span); + this.i18nEnd(span, createSelfClosingI18nInstruction); } if (isNonBindableMode) { this.creationInstruction(span, R3.enableBindings); @@ -572,13 +651,13 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const templateIndex = this.allocateDataSlot(); if (this.i18n) { - this.i18n.appendTemplate(templateIndex); + this.i18n.appendTemplate(template.i18n !, templateIndex); } let elName = ''; - if (template.children.length === 1 && template.children[0] instanceof t.Element) { + if (isSingleElementTemplate(template.children)) { // When the template as a single child, derive the context name from the tag - elName = sanitizeIdentifier((template.children[0] as t.Element).name); + elName = sanitizeIdentifier(template.children[0].name); } const contextName = elName ? `${this.contextName}_${elName}` : ''; @@ -632,8 +711,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // be able to support bindings in nested templates to local refs that occur after the // template definition. e.g.
{{ foo }}
this._nestedTemplateFns.push(() => { - const templateFunctionExpr = - templateVisitor.buildTemplateFunction(template.children, template.variables); + const templateFunctionExpr = templateVisitor.buildTemplateFunction( + template.children, template.variables, false, [], template.i18n); this.constantPool.statements.push(templateFunctionExpr.toDeclStmt(templateName, null)); }); @@ -664,15 +743,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver if (this.i18n) { const value = text.value.visit(this._valueConverter); if (value instanceof Interpolation) { - const {strings, expressions} = value; - const label = - assembleI18nBoundString(strings, this.i18n.getBindings().size, this.i18n.getId()); - const implicit = o.variable(CONTEXT_NAME); - expressions.forEach(expression => { - const binding = this.convertExpressionBinding(implicit, expression); - this.i18n !.appendBinding(binding); - }); - this.i18n.appendText(label); + this.i18n.appendBoundText(text.i18n !); + this.i18nAppendBindings(value.expressions); } return; } @@ -689,12 +761,50 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } visitText(text: t.Text) { - if (this.i18n) { - this.i18n.appendText(text.value); - return; + // when a text element is located within a translatable + // block, we exclude this text element from instructions set, + // since it will be captured in i18n content and processed at runtime + if (!this.i18n) { + this.creationInstruction( + text.sourceSpan, R3.text, [o.literal(this.allocateDataSlot()), o.literal(text.value)]); } - this.creationInstruction( - text.sourceSpan, R3.text, [o.literal(this.allocateDataSlot()), o.literal(text.value)]); + } + + visitIcu(icu: t.Icu) { + let initWasInvoked = false; + + // if an ICU was created outside of i18n block, we still treat + // it as a translatable entity and invoke i18nStart and i18nEnd + // to generate i18n context and the necessary instructions + if (!this.i18n) { + initWasInvoked = true; + this.i18nStart(null, icu.i18n !, true); + } + + const i18n = this.i18n !; + const vars = this.i18nBindProps(icu.vars); + const placeholders = this.i18nBindProps(icu.placeholders); + + // output ICU directly and keep ICU reference in context + const message = icu.i18n !as i18n.Message; + const transformFn = (raw: o.ReadVarExpr) => + instruction(null, R3.i18nPostprocess, [raw, mapLiteral(vars, true)]); + + // in case the whole i18n message is a single ICU - we do not need to + // create a separate top-level translation, we can use the root ref instead + // and make this ICU a top-level translation + if (isSingleI18nIcu(i18n.meta)) { + this.i18nTranslate(message, placeholders, i18n.ref, transformFn); + } else { + // output ICU directly and keep ICU reference in context + const ref = this.i18nTranslate(message, placeholders, undefined, transformFn); + i18n.appendIcu(icuFromI18nMessage(message).name, ref); + } + + if (initWasInvoked) { + this.i18nEnd(null, true); + } + return null; } private allocateDataSlot() { return this._dataIndex++; } @@ -1283,7 +1393,7 @@ export function parseTemplate( } { const bindingParser = makeBindingParser(); const htmlParser = new HtmlParser(); - const parseResult = htmlParser.parse(template, templateUrl); + const parseResult = htmlParser.parse(template, templateUrl, true); if (parseResult.errors && parseResult.errors.length > 0) { return { @@ -1295,8 +1405,22 @@ export function parseTemplate( } let rootNodes: html.Node[] = parseResult.rootNodes; + + // process i18n meta information (scan attributes, generate ids) + // before we run whitespace removal process, because existing i18n + // extraction process (ng xi18n) relies on a raw content to generate + // message ids + const i18nConfig = {keepI18nAttrs: !options.preserveWhitespaces}; + rootNodes = html.visitAll(new I18nMetaVisitor(i18nConfig), rootNodes); + if (!options.preserveWhitespaces) { rootNodes = html.visitAll(new WhitespaceVisitor(), rootNodes); + + // run i18n meta visitor again in case we remove whitespaces, because + // that might affect generated i18n message content. During this pass + // i18n IDs generated at the first pass will be preserved, so we can mimic + // existing extraction process (ng xi18n) + rootNodes = html.visitAll(new I18nMetaVisitor({keepI18nAttrs: false}), rootNodes); } const {nodes, hasNgContent, ngContentSelectors, errors} = @@ -1345,3 +1469,13 @@ function resolveSanitizationFn(input: t.BoundAttribute, context: core.SecurityCo function prepareSyntheticAttributeName(name: string) { return '@' + name; } + +function isSingleElementTemplate(children: t.Node[]): children is[t.Element] { + return children.length === 1 && children[0] instanceof t.Element; +} + +function hasTextChildrenOnly(children: t.Node[]): boolean { + return !children.find( + child => + !(child instanceof t.Text || child instanceof t.BoundText || child instanceof t.Icu)); +} \ No newline at end of file diff --git a/packages/compiler/src/render3/view/util.ts b/packages/compiler/src/render3/view/util.ts index d5da1b807c..1fe4a48618 100644 --- a/packages/compiler/src/render3/view/util.ts +++ b/packages/compiler/src/render3/view/util.ts @@ -11,7 +11,7 @@ import * as o from '../../output/output_ast'; import * as t from '../r3_ast'; import {R3QueryMetadata} from './api'; -import {isI18NAttribute} from './i18n'; +import {isI18nAttribute} from './i18n/util'; /** Name of the temporary to use during data binding */ export const TEMPORARY_NAME = '_t'; @@ -135,7 +135,7 @@ export function getAttrsForDirectiveMatching(elOrTpl: t.Element | t.Template): const attributesMap: {[name: string]: string} = {}; elOrTpl.attributes.forEach(a => { - if (!isI18NAttribute(a.name)) { + if (!isI18nAttribute(a.name)) { attributesMap[a.name] = a.value; } }); diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index ee9fa5bc63..477fac1be0 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -7,38 +7,11 @@ */ import {BindingType} from '../../src/expression_parser/ast'; -import {Lexer} from '../../src/expression_parser/lexer'; -import {Parser} from '../../src/expression_parser/parser'; -import {HtmlParser} from '../../src/ml_parser/html_parser'; -import {DEFAULT_INTERPOLATION_CONFIG} from '../../src/ml_parser/interpolation_config'; import * as t from '../../src/render3/r3_ast'; -import {Render3ParseResult, htmlAstToRender3Ast} from '../../src/render3/r3_template_transform'; -import {BindingParser} from '../../src/template_parser/binding_parser'; -import {MockSchemaRegistry} from '../../testing'; import {unparse} from '../expression_parser/utils/unparser'; +import {parseR3 as parse} from './view/util'; -// Parse an html string to IVY specific info -function parse(html: string): Render3ParseResult { - const htmlParser = new HtmlParser(); - - const parseResult = htmlParser.parse(html, 'path:://to/template', true); - - if (parseResult.errors.length > 0) { - const msg = parseResult.errors.map(e => e.toString()).join('\n'); - throw new Error(msg); - } - - const htmlNodes = parseResult.rootNodes; - const expressionParser = new Parser(new Lexer()); - const schemaRegistry = new MockSchemaRegistry( - {'invalidProp': false}, {'mappedAttr': 'mappedProp'}, {'unknown': false, 'un-known': false}, - ['onEvent'], ['onEvent']); - const bindingParser = - new BindingParser(expressionParser, DEFAULT_INTERPOLATION_CONFIG, schemaRegistry, null, []); - return htmlAstToRender3Ast(htmlNodes, bindingParser); -} - // Transform an IVY AST to a flat list of nodes to ease testing class R3AstHumanizer implements t.Visitor { result: any[] = []; @@ -104,6 +77,8 @@ class R3AstHumanizer implements t.Visitor { visitBoundText(text: t.BoundText) { this.result.push(['BoundText', unparse(text.value)]); } + visitIcu(icu: t.Icu) { return null; } + private visitAll(nodes: t.Node[][]) { nodes.forEach(node => t.visitAll(this, node)); } } diff --git a/packages/compiler/test/render3/view/i18n_spec.ts b/packages/compiler/test/render3/view/i18n_spec.ts index f7343dd0f2..62588c98da 100644 --- a/packages/compiler/test/render3/view/i18n_spec.ts +++ b/packages/compiler/test/render3/view/i18n_spec.ts @@ -6,65 +6,249 @@ * found in the LICENSE file at https://angular.io/license */ +import * as i18n from '../../../src/i18n/i18n_ast'; import * as o from '../../../src/output/output_ast'; -import {I18nContext} from '../../../src/render3/view/i18n'; +import * as t from '../../../src/render3/r3_ast'; +import {I18nContext} from '../../../src/render3/view/i18n/context'; +import {getSerializedI18nContent} from '../../../src/render3/view/i18n/serializer'; +import {I18nMeta, formatI18nPlaceholderName, parseI18nMeta} from '../../../src/render3/view/i18n/util'; + +import {parseR3 as parse} from './util'; + +const i18nOf = (element: t.Node & {i18n?: i18n.AST}) => element.i18n !; describe('I18nContext', () => { it('should support i18n content collection', () => { - const ctx = new I18nContext(5, null, 'myRef'); + const ref = o.variable('ref'); + const ast = new i18n.Message([], {}, {}, '', '', ''); + const ctx = new I18nContext(5, ref, 0, null, ast); // basic checks - expect(ctx.isRoot()).toBe(true); - expect(ctx.isResolved()).toBe(true); - expect(ctx.getId()).toBe(0); - expect(ctx.getIndex()).toBe(5); - expect(ctx.getTemplateIndex()).toBeNull(); - expect(ctx.getRef()).toBe('myRef'); + expect(ctx.isRoot).toBe(true); + expect(ctx.isResolved).toBe(true); + expect(ctx.id).toBe(0); + expect(ctx.ref).toBe(ref); + expect(ctx.index).toBe(5); + expect(ctx.templateIndex).toBe(null); + + const tree = parse('
A {{ valueA }}
B

C

'); + const [boundText, element, template] = (tree.nodes[0] as t.Element).children; // data collection checks - expect(ctx.getContent()).toBe(''); - ctx.appendText('Foo'); - ctx.appendElement(1); - ctx.appendText('Bar'); - ctx.appendElement(1, true); - expect(ctx.getContent()).toBe('Foo�#1�Bar�/#1�'); + expect(ctx.placeholders.size).toBe(0); + ctx.appendBoundText(i18nOf(boundText)); // interpolation + ctx.appendElement(i18nOf(element), 1); // open tag + ctx.appendElement(i18nOf(element), 1, true); // close tag + ctx.appendTemplate(i18nOf(template), 2); // open + close tags + expect(ctx.placeholders.size).toBe(5); // binding collection checks - expect(ctx.getBindings().size).toBe(0); + expect(ctx.bindings.size).toBe(0); ctx.appendBinding(o.literal(1)); ctx.appendBinding(o.literal(2)); - expect(ctx.getBindings().size).toBe(2); + expect(ctx.bindings.size).toBe(2); }); it('should support nested contexts', () => { - const ctx = new I18nContext(5, null, 'myRef'); - const templateIndex = 1; + const template = ` +
+ A {{ valueA }} +
A
+ + B {{ valueB }} +
B
+ C {{ valueC }} +
+
+ `; + const tree = parse(template); + const root = tree.nodes[0] as t.Element; + const [boundTextA, elementA, templateA] = root.children; + const elementB = (templateA as t.Template).children[0] as t.Element; + const [boundTextB, elementC, boundTextC] = (elementB as t.Element).children; - // set some data for root ctx - ctx.appendText('Foo'); - ctx.appendBinding(o.literal(1)); - ctx.appendTemplate(templateIndex); - expect(ctx.isResolved()).toBe(false); + // simulate I18nContext for a given template + const ctx = new I18nContext(1, o.variable('ctx'), 0, null, root.i18n !); + + // set data for root ctx + ctx.appendBoundText(i18nOf(boundTextA)); + ctx.appendBinding(o.literal('valueA')); + ctx.appendElement(i18nOf(elementA), 0); + ctx.appendTemplate(i18nOf(templateA), 1); + ctx.appendElement(i18nOf(elementA), 0, true); + expect(ctx.bindings.size).toBe(1); + expect(ctx.placeholders.size).toBe(5); + expect(ctx.isResolved).toBe(false); // create child context - const childCtx = ctx.forkChildContext(6, templateIndex); - expect(childCtx.getContent()).toBe(''); - expect(childCtx.getBindings().size).toBe(0); - expect(childCtx.getRef()).toBe(ctx.getRef()); // ref should be passed into child ctx - expect(childCtx.isRoot()).toBe(false); + const childCtx = ctx.forkChildContext(2, 1, (templateA as t.Template).i18n !); + expect(childCtx.bindings.size).toBe(0); + expect(childCtx.isRoot).toBe(false); - childCtx.appendText('Bar'); - childCtx.appendElement(2); - childCtx.appendText('Baz'); - childCtx.appendElement(2, true); - childCtx.appendBinding(o.literal(2)); - childCtx.appendBinding(o.literal(3)); + // set data for child context + childCtx.appendElement(i18nOf(elementB), 0); + childCtx.appendBoundText(i18nOf(boundTextB)); + childCtx.appendBinding(o.literal('valueB')); + childCtx.appendElement(i18nOf(elementC), 1); + childCtx.appendElement(i18nOf(elementC), 1, true); + childCtx.appendBoundText(i18nOf(boundTextC)); + childCtx.appendBinding(o.literal('valueC')); + childCtx.appendElement(i18nOf(elementB), 0, true); - expect(childCtx.getContent()).toBe('Bar�#2:1�Baz�/#2:1�'); - expect(childCtx.getBindings().size).toBe(2); + expect(childCtx.bindings.size).toBe(2); + expect(childCtx.placeholders.size).toBe(6); + + // ctx bindings and placeholders are not shared, + // so root bindings and placeholders do not change + expect(ctx.bindings.size).toBe(1); + expect(ctx.placeholders.size).toBe(5); // reconcile ctx.reconcileChildContext(childCtx); - expect(ctx.getContent()).toBe('Foo�*1:1�Bar�#2:1�Baz�/#2:1��/*1:1�'); + + // verify placeholders + const expected = new Map([ + ['INTERPOLATION', '�0�'], ['START_TAG_DIV', '�#0�|�#1:1�'], + ['START_BOLD_TEXT', '�*1:1��#0:1�'], ['CLOSE_BOLD_TEXT', '�/#0:1��/*1:1�'], + ['CLOSE_TAG_DIV', '�/#0�|�/#1:1�'], ['INTERPOLATION_1', '�0:1�'], + ['INTERPOLATION_2', '�1:1�'] + ]); + const phs = ctx.getSerializedPlaceholders(); + expected.forEach((value, key) => { expect(phs.get(key) !.join('|')).toEqual(value); }); + + // placeholders are added into the root ctx + expect(phs.size).toBe(expected.size); + + // root context is considered resolved now + expect(ctx.isResolved).toBe(true); + + // bindings are not merged into root ctx + expect(ctx.bindings.size).toBe(1); + }); + + it('should support templates based on ', () => { + const template = ` + + Level A + + Level B + + Level C + + + + `; + const tree = parse(template); + const root = tree.nodes[0] as t.Template; + + const [textA, templateA] = root.children; + const [textB, templateB] = (templateA as t.Template).children; + const [textC] = (templateB as t.Template).children; + + // simulate I18nContext for a given template + const ctxLevelA = new I18nContext(0, o.variable('ctx'), 0, null, root.i18n !); + + // create Level A context + ctxLevelA.appendTemplate(i18nOf(templateA), 1); + expect(ctxLevelA.placeholders.size).toBe(2); + expect(ctxLevelA.isResolved).toBe(false); + + // create Level B context + const ctxLevelB = ctxLevelA.forkChildContext(0, 1, (templateA as t.Template).i18n !); + ctxLevelB.appendTemplate(i18nOf(templateB), 1); + expect(ctxLevelB.isRoot).toBe(false); + + // create Level 2 context + const ctxLevelC = ctxLevelB.forkChildContext(0, 1, (templateB as t.Template).i18n !); + expect(ctxLevelC.isRoot).toBe(false); + + // reconcile + ctxLevelB.reconcileChildContext(ctxLevelC); + ctxLevelA.reconcileChildContext(ctxLevelB); + + // verify placeholders + const expected = new Map( + [['START_TAG_NG-TEMPLATE', '�*1:1�|�*1:2�'], ['CLOSE_TAG_NG-TEMPLATE', '�/*1:2�|�/*1:1�']]); + const phs = ctxLevelA.getSerializedPlaceholders(); + expected.forEach((value, key) => { expect(phs.get(key) !.join('|')).toEqual(value); }); + + // placeholders are added into the root ctx + expect(phs.size).toBe(expected.size); + + // root context is considered resolved now + expect(ctxLevelA.isResolved).toBe(true); + }); +}); + +describe('Utils', () => { + it('formatI18nPlaceholderName', () => { + const cases = [ + // input, output + ['', ''], ['ICU', 'icu'], ['ICU_1', 'icu_1'], ['ICU_1000', 'icu_1000'], + ['START_TAG_NG-CONTAINER', 'startTagNgContainer'], + ['START_TAG_NG-CONTAINER_1', 'startTagNgContainer_1'], ['CLOSE_TAG_ITALIC', 'closeTagItalic'], + ['CLOSE_TAG_BOLD_1', 'closeTagBold_1'] + ]; + cases.forEach( + ([input, output]) => { expect(formatI18nPlaceholderName(input)).toEqual(output); }); + }); + + it('parseI18nMeta', () => { + const meta = (id?: string, meaning?: string, description?: string) => + ({id, meaning, description}); + const cases = [ + ['', meta()], + ['desc', meta('', '', 'desc')], + ['desc@@id', meta('id', '', 'desc')], + ['meaning|desc', meta('', 'meaning', 'desc')], + ['meaning|desc@@id', meta('id', 'meaning', 'desc')], + ['@@id', meta('id', '', '')], + ]; + cases.forEach(([input, output]) => { + expect(parseI18nMeta(input as string)).toEqual(output as I18nMeta, input); + }); + }); +}); + +describe('Serializer', () => { + const serialize = (input: string): string => { + const tree = parse(`
${input}
`); + const root = tree.nodes[0] as t.Element; + return getSerializedI18nContent(root.i18n as i18n.Message); + }; + it('should produce output for i18n content', () => { + const cases = [ + // plain text + ['Some text', 'Some text'], + + // text with interpolation + [ + 'Some text {{ valueA }} and {{ valueB + valueC }}', + 'Some text {$interpolation} and {$interpolation_1}' + ], + + // content with HTML tags + [ + 'A B
C
D', + 'A {$startTagSpan}B{$startTagDiv}C{$closeTagDiv}{$closeTagSpan} D' + ], + + // simple ICU + ['{age, plural, 10 {ten} other {other}}', '{VAR_PLURAL, plural, 10 {ten} other {other}}'], + + // nested ICUs + [ + '{age, plural, 10 {ten {size, select, 1 {one} 2 {two} other {2+}}} other {other}}', + '{VAR_PLURAL, plural, 10 {ten {VAR_SELECT, select, 1 {one} 2 {two} other {2+}}} other {other}}' + ], + + // ICU with nested HTML + [ + '{age, plural, 10 {ten} other {
other
}}', + '{VAR_PLURAL, plural, 10 {{$startBoldText}ten{$closeBoldText}} other {{$startTagDiv}other{$closeTagDiv}}}' + ] + ]; + + cases.forEach(([input, output]) => { expect(serialize(input)).toEqual(output); }); }); }); \ No newline at end of file diff --git a/packages/compiler/test/render3/view/util.ts b/packages/compiler/test/render3/view/util.ts index 85bf558163..26f584ea23 100644 --- a/packages/compiler/test/render3/view/util.ts +++ b/packages/compiler/test/render3/view/util.ts @@ -7,7 +7,17 @@ */ import * as e from '../../../src/expression_parser/ast'; +import {Lexer} from '../../../src/expression_parser/lexer'; +import {Parser} from '../../../src/expression_parser/parser'; +import * as html from '../../../src/ml_parser/ast'; +import {HtmlParser} from '../../../src/ml_parser/html_parser'; +import {WhitespaceVisitor} from '../../../src/ml_parser/html_whitespaces'; +import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config'; import * as a from '../../../src/render3/r3_ast'; +import {Render3ParseResult, htmlAstToRender3Ast} from '../../../src/render3/r3_template_transform'; +import {processI18nMeta} from '../../../src/render3/view/i18n/meta'; +import {BindingParser} from '../../../src/template_parser/binding_parser'; +import {MockSchemaRegistry} from '../../../testing'; export function findExpression(tmpl: a.Node[], expr: string): e.AST|null { const res = tmpl.reduce((found, node) => { @@ -65,3 +75,30 @@ export function toStringExpression(expr: e.AST): string { throw new Error(`Unsupported type: ${(expr as any).constructor.name}`); } } + +// Parse an html string to IVY specific info +export function parseR3( + input: string, options: {preserveWhitespaces?: boolean} = {}): Render3ParseResult { + const htmlParser = new HtmlParser(); + + const parseResult = htmlParser.parse(input, 'path:://to/template', true); + + if (parseResult.errors.length > 0) { + const msg = parseResult.errors.map(e => e.toString()).join('\n'); + throw new Error(msg); + } + + let htmlNodes = processI18nMeta(parseResult).rootNodes; + + if (!options.preserveWhitespaces) { + htmlNodes = html.visitAll(new WhitespaceVisitor(), htmlNodes); + } + + const expressionParser = new Parser(new Lexer()); + const schemaRegistry = new MockSchemaRegistry( + {'invalidProp': false}, {'mappedAttr': 'mappedProp'}, {'unknown': false, 'un-known': false}, + ['onEvent'], ['onEvent']); + const bindingParser = + new BindingParser(expressionParser, DEFAULT_INTERPOLATION_CONFIG, schemaRegistry, null, []); + return htmlAstToRender3Ast(htmlNodes, bindingParser); +} \ No newline at end of file diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 228e36c58c..1343f86cc4 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -110,12 +110,13 @@ export { PipeDef as ɵPipeDef, PipeDefWithMeta as ɵPipeDefWithMeta, whenRendered as ɵwhenRendered, + i18n as ɵi18n, i18nAttributes as ɵi18nAttributes, i18nExp as ɵi18nExp, i18nStart as ɵi18nStart, i18nEnd as ɵi18nEnd, i18nApply as ɵi18nApply, - i18nIcuReplaceVars as ɵi18nIcuReplaceVars, + i18nPostprocess as ɵi18nPostprocess, WRAP_RENDERER_FACTORY2 as ɵWRAP_RENDERER_FACTORY2, setClassMetadata as ɵsetClassMetadata, } from './render3/index'; diff --git a/packages/core/src/render3/STATUS.md b/packages/core/src/render3/STATUS.md index c44c7e572b..ac8df837b0 100644 --- a/packages/core/src/render3/STATUS.md +++ b/packages/core/src/render3/STATUS.md @@ -208,10 +208,11 @@ The goal is for the `@Component` (and friends) to be the compiler of template. S | i18nStart | ✅ | ✅ | ✅ | | i18nEnd | ✅ | ✅ | ✅ | | i18nAttributes | ✅ | ✅ | ✅ | -| i18nExp | ✅ | ✅ | ✅ | +| i18nExp | ✅ | ✅ | ✅ | | i18nApply | ✅ | ✅ | ✅ | -| ICU expressions | ✅ | ✅ | ❌ | -| closure support for g3 | ✅ | ✅ | ❌ | +| ICU expressions | ✅ | ✅ | ✅ | +| closure support for g3 | ✅ | ✅ | ✅ | +| `` support | ✅ | ✅ | ✅ | | runtime service for external world | ❌ | ❌ | ❌ | | migration tool | ❌ | ❌ | ❌ | diff --git a/packages/core/src/render3/i18n.md b/packages/core/src/render3/i18n.md index c7fd23e14d..b1f7225e92 100644 --- a/packages/core/src/render3/i18n.md +++ b/packages/core/src/render3/i18n.md @@ -1175,12 +1175,13 @@ const MSG_div_icu = goog.getMsg(`{VAR_PLURAL, plural, /** * @desc [BACKUP_MESSAGE_ID:2919330615509803611] Some description. */ -const MSG_div = goog.getMsg('{$COUNT_1} is rendered as: {$START_BOLD_TEXT_1}{$ICU}{$END_BOLD_TEXT_1}', { - ICU: i18nIcuReplaceVar(MSG_div_icu, 'VAR_PLURAL', '�0:1�'), +const MSG_div_raw = goog.getMsg('{$COUNT_1} is rendered as: {$START_BOLD_TEXT_1}{$ICU}{$END_BOLD_TEXT_1}', { + ICU: MSG_div_icu, COUNT: '�0:1�', START_BOLD_TEXT_1: '�*3:1��#1�', END_BOLD_TEXT_1: '�/#1:1��/*3:1�', }); +const MSG_div = i18nPostprocess(MSG_div_raw, {VAR_PLURAL: '�0:1�'}); ``` NOTE: - The compiler generates `[BACKUP_MESSAGE_ID:2919330615509803611]` which forces the `goog.getMsg` to use a specific message ID. @@ -1196,9 +1197,38 @@ Resulting in same string which Angular can process: }�/#1:1��/*3:1�. ``` -### Notice `i18nIcuReplaceVar` function +### Placeholders with multiple values -The `i18nIcuReplaceVar(MSG_div_icu, 'VAR_PLURAL', '�0:1�')` function is needed to replace `VAR_PLURAL` for `�0:1�`. -This is required because the ICU format does not allow placeholders in the ICU header location, a variable such as `VAR_PLURAL` must be used. -The point of `i18nIcuReplaceVar` is to format the ICU message to something that `i18nStart` can understand. +While extracting messages via `ng xi18n`, the tool performs an optimization and reuses the same placeholders for elements/interpolations in case placeholder content is identical. +For example the following template: +```html +My text 1My text 2 +``` +is transformed into: +```html +{$START_TAG_BOLD}My text 1{$CLOSE_TAG_BOLD}{$START_TAG_BOLD}My text 2{$CLOSE_TAG_BOLD} +``` +In IVY we need to have specific element instruction indices for open and close tags, so the result string (that can be consumed by `i18nStart`) produced, should look like this: +```html +�#1�My text 1�/#1��#2�My text 1�/#2� +``` +In order to resolve this, we need to supply all values that a given placeholder represents and invoke post processing function to transform intermediate string into its final version. +In this case the `goog.getMsg` invocation will look like this: +```typescript +/** + * @desc [BACKUP_MESSAGE_ID:2919330615509803611] Some description. + */ +const MSG_div_raw = goog.getMsg('{$START_TAG_BOLD}My text 1{$CLOSE_TAG_BOLD}{$START_TAG_BOLD}My text 2{$CLOSE_TAG_BOLD}', { + START_TAG_BOLD: '[�#1�|�#2�]', + CLOSE_TAG_BOLD: '[�/#2�|�/#1�]' +}); +const MSG_div = i18nPostprocess(MSG_div_raw); +``` + +### `i18nPostprocess` function + +Due to backwards-compatibility requirements and some limitations of `goog.getMsg`, in some cases we need to run post process to convert intermediate string into its final version that can be consumed by Ivy runtime code (something that `i18nStart` can understand), specifically: +- we replace all `VAR_PLURAL` and `VAR_SELECT` with respective values. This is required because the ICU format does not allow placeholders in the ICU header location, a variable such as `VAR_PLURAL` must be used. +- in some cases, ICUs may share the same placeholder name (like `ICU_1`). For this scenario we inject a special markers (`�I18N_EXP_ICU�) into a string and resolve this within the post processing function +- this function also resolves the case when one placeholder is used to represent multiple elements (see example above) diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index 7e4ec1e31d..b730262609 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -30,6 +30,11 @@ const PH_REGEXP = /�(\/?[#*]\d+):?\d*�/gi; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+�\s*,\s*\S{6}\s*,[\s\S]*})/gi; +// i18nPostproocess regexps +const PP_PLACEHOLDERS = /\[(�.+?�?)\]/g; +const PP_ICU_VARS = /({\s*)(VAR_(PLURAL|SELECT)(_\d+)?)(\s*,)/g; +const PP_ICUS = /�I18N_EXP_(ICU(_\d+)?)�/g; + interface IcuExpression { type: IcuType; mainBinding: number; @@ -482,6 +487,77 @@ function appendI18nNode(tNode: TNode, parentTNode: TNode, previousTNode: TNode | return tNode; } +/** + * Handles message string post-processing for internationalization. + * + * Handles message string post-processing by transforming it from intermediate + * format (that might contain some markers that we need to replace) to the final + * form, consumable by i18nStart instruction. Post processing steps include: + * + * 1. Resolve all multi-value cases (like [�*1:1��#2:1�|�#4:1�|�5�]) + * 2. Replace all ICU vars (like "VAR_PLURAL") + * 3. Replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) + * in case multiple ICUs have the same placeholder name + * + * @param message Raw translation string for post processing + * @param replacements Set of replacements that should be applied + * + * @returns Transformed string that can be consumed by i18nStart instruction + * + * @publicAPI + */ +export function i18nPostprocess( + message: string, replacements: {[key: string]: (string | string[])}): string { + // + // Step 1: resolve all multi-value cases (like [�*1:1��#2:1�|�#4:1�|�5�]) + // + const matches: {[key: string]: string[]} = {}; + let result = message.replace(PP_PLACEHOLDERS, (_match, content: string): string => { + if (!matches[content]) { + matches[content] = content.split('|'); + } + if (!matches[content].length) { + throw new Error(`i18n postprocess: unmatched placeholder - ${content}`); + } + return matches[content].shift() !; + }); + + // verify that we injected all values + const hasUnmatchedValues = Object.keys(matches).some(key => !!matches[key].length); + if (hasUnmatchedValues) { + throw new Error(`i18n postprocess: unmatched values - ${JSON.stringify(matches)}`); + } + + // return current result if no replacements specified + if (!Object.keys(replacements).length) { + return result; + } + + // + // Step 2: replace all ICU vars (like "VAR_PLURAL") + // + result = result.replace(PP_ICU_VARS, (match, start, key, _type, _idx, end): string => { + return replacements.hasOwnProperty(key) ? `${start}${replacements[key]}${end}` : match; + }); + + // + // Step 3: replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) + // in case multiple ICUs have the same placeholder name + // + result = result.replace(PP_ICUS, (match, key): string => { + if (replacements.hasOwnProperty(key)) { + const list = replacements[key] as string[]; + if (!list.length) { + throw new Error(`i18n postprocess: unmatched ICU - ${match} with key: ${key}`); + } + return list.shift() !; + } + return match; + }); + + return result; +} + /** * Translates a translation block marked by `i18nStart` and `i18nEnd`. It inserts the text/ICU nodes * into the render tree, moves the placeholder nodes and removes the deleted nodes. @@ -1433,27 +1509,4 @@ function parseNodes( nestedIcuNodeIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Remove); } } -} - -const RAW_ICU_REGEXP = /{\s*(\S*)\s*,\s*\S{6}\s*,[\s\S]*}/gi; - -/** - * Replaces the variable parameter (main binding) of an ICU by a given value. - * - * Example: - * ``` - * const MSG_APP_1_RAW = "{VAR_SELECT, select, male {male} female {female} other {other}}"; - * const MSG_APP_1 = i18nIcuReplaceVars(MSG_APP_1_RAW, { VAR_SELECT: "�0�" }); - * // --> MSG_APP_1 = "{�0�, select, male {male} female {female} other {other}}" - * ``` - */ -export function i18nIcuReplaceVars(message: string, replacements: {[key: string]: string}): string { - const keys = Object.keys(replacements); - function replaceFn(replacement: string) { - return (str: string, varMatch: string) => { return str.replace(varMatch, replacement); }; - } - for (let i = 0; i < keys.length; i++) { - message = message.replace(RAW_ICU_REGEXP, replaceFn(replacements[keys[i]])); - } - return message; -} +} \ No newline at end of file diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index db46134731..2f347f8ab2 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -87,12 +87,13 @@ export { } from './state'; export { + i18n, i18nAttributes, i18nExp, i18nStart, i18nEnd, i18nApply, - i18nIcuReplaceVars, + i18nPostprocess } from './i18n'; export {NgModuleFactory, NgModuleRef, NgModuleType} from './ng_module_ref'; diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index e42e711305..700e51362a 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -97,11 +97,13 @@ export const angularCoreEnv: {[name: string]: Function} = { 'ɵtextBinding': r3.textBinding, 'ɵembeddedViewStart': r3.embeddedViewStart, 'ɵembeddedViewEnd': r3.embeddedViewEnd, + 'ɵi18n': r3.i18n, 'ɵi18nAttributes': r3.i18nAttributes, 'ɵi18nExp': r3.i18nExp, 'ɵi18nStart': r3.i18nStart, 'ɵi18nEnd': r3.i18nEnd, 'ɵi18nApply': r3.i18nApply, + 'ɵi18nPostprocess': r3.i18nPostprocess, 'ɵsanitizeHtml': sanitization.sanitizeHtml, 'ɵsanitizeStyle': sanitization.sanitizeStyle, diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts index cc2bcb9f83..172b55c42f 100644 --- a/packages/core/test/render3/i18n_spec.ts +++ b/packages/core/test/render3/i18n_spec.ts @@ -9,10 +9,12 @@ import {noop} from '../../../compiler/src/render3/view/util'; import {Component as _Component} from '../../src/core'; import {defineComponent} from '../../src/render3/definition'; -import {getTranslationForTemplate, i18n, i18nApply, i18nAttributes, i18nEnd, i18nExp, i18nIcuReplaceVars, i18nStart} from '../../src/render3/i18n'; +import {getTranslationForTemplate, i18n, i18nApply, i18nAttributes, i18nEnd, i18nExp, i18nPostprocess, i18nStart} from '../../src/render3/i18n'; import {RenderFlags} from '../../src/render3/interfaces/definition'; import {getNativeByIndex} from '../../src/render3/util'; + import {NgIf} from './common_with_def'; + import {element, elementEnd, elementStart, template, text, bind, elementProperty, projectionDef, projection} from '../../src/render3/instructions'; import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nUpdateOpCode, I18nUpdateOpCodes, TI18n} from '../../src/render3/interfaces/i18n'; import {HEADER_OFFSET, LViewData, TVIEW} from '../../src/render3/interfaces/view'; @@ -58,14 +60,6 @@ describe('Runtime i18n', () => { }); }); - describe('i18nIcuReplaceVars', () => { - it('should replace var names', () => { - const MSG_APP_1_RAW = '{VAR_SELECT, select, male {male} female {female} other {other}}'; - const MSG_APP_1 = i18nIcuReplaceVars(MSG_APP_1_RAW, {VAR_SELECT: '\uFFFD0\uFFFD'}); - expect(MSG_APP_1).toEqual('{�0�, select, male {male} female {female} other {other}}'); - }); - }); - function prepareFixture( createTemplate: () => void, updateTemplate: (() => void)|null, nbConsts = 0, nbVars = 0): TemplateFixture { @@ -1506,4 +1500,105 @@ describe('Runtime i18n', () => { }); }); }); + + describe('i18nPostprocess', () => { + it('should handle valid cases', () => { + const arr = ['�*1:1��#2:1�', '�#4:2�', '�6:4�', '�/#2:1��/*1:1�']; + const str = `[${arr.join('|')}]`; + + const cases = [ + // empty string + ['', {}, ''], + + // string without any special cases + ['Foo [1,2,3] Bar - no ICU here', {}, 'Foo [1,2,3] Bar - no ICU here'], + + // multi-value cases + [ + `Start: ${str}, ${str} and ${str}, ${str} end.`, {}, + `Start: ${arr[0]}, ${arr[1]} and ${arr[2]}, ${arr[3]} end.` + ], + + // replace VAR_SELECT + [ + 'My ICU: {VAR_SELECT, select, =1 {one} other {other}}', {VAR_SELECT: '�1:2�'}, + 'My ICU: {�1:2�, select, =1 {one} other {other}}' + ], + + [ + 'My ICU: {\n\n\tVAR_SELECT_1 \n\n, select, =1 {one} other {other}}', + {VAR_SELECT_1: '�1:2�'}, 'My ICU: {\n\n\t�1:2� \n\n, select, =1 {one} other {other}}' + ], + + // replace VAR_PLURAL + [ + 'My ICU: {VAR_PLURAL, plural, one {1} other {other}}', {VAR_PLURAL: '�1:2�'}, + 'My ICU: {�1:2�, plural, one {1} other {other}}' + ], + + [ + 'My ICU: {\n\n\tVAR_PLURAL_1 \n\n, select, =1 {one} other {other}}', + {VAR_PLURAL_1: '�1:2�'}, 'My ICU: {\n\n\t�1:2� \n\n, select, =1 {one} other {other}}' + ], + + // do not replace VAR_* anywhere else in a string (only in ICU) + [ + 'My ICU: {VAR_PLURAL, plural, one {1} other {other}} VAR_PLURAL and VAR_SELECT', + {VAR_PLURAL: '�1:2�'}, + 'My ICU: {�1:2�, plural, one {1} other {other}} VAR_PLURAL and VAR_SELECT' + ], + + // replace VAR_*'s in nested ICUs + [ + 'My ICU: {VAR_PLURAL, plural, one {1 - {VAR_SELECT, age, 50 {fifty} other {other}}} other {other}}', + {VAR_PLURAL: '�1:2�', VAR_SELECT: '�5�'}, + 'My ICU: {�1:2�, plural, one {1 - {�5�, age, 50 {fifty} other {other}}} other {other}}' + ], + + [ + 'My ICU: {VAR_PLURAL, plural, one {1 - {VAR_PLURAL_1, age, 50 {fifty} other {other}}} other {other}}', + {VAR_PLURAL: '�1:2�', VAR_PLURAL_1: '�5�'}, + 'My ICU: {�1:2�, plural, one {1 - {�5�, age, 50 {fifty} other {other}}} other {other}}' + ], + + // ICU replacement + [ + 'My ICU #1: �I18N_EXP_ICU�, My ICU #2: �I18N_EXP_ICU�', + {ICU: ['ICU_VALUE_1', 'ICU_VALUE_2']}, 'My ICU #1: ICU_VALUE_1, My ICU #2: ICU_VALUE_2' + ], + + // mixed case + [ + `Start: ${str}, ${str}. ICU: {VAR_SELECT, count, 10 {ten} other {other}}. + Another ICU: �I18N_EXP_ICU� and ${str}, ${str} and one more ICU: �I18N_EXP_ICU� and end.`, + {VAR_SELECT: '�1:2�', ICU: ['ICU_VALUE_1', 'ICU_VALUE_2']}, + `Start: ${arr[0]}, ${arr[1]}. ICU: {�1:2�, count, 10 {ten} other {other}}. + Another ICU: ICU_VALUE_1 and ${arr[2]}, ${arr[3]} and one more ICU: ICU_VALUE_2 and end.`, + ], + ]; + cases.forEach(([input, replacements, output]) => { + expect(i18nPostprocess(input as string, replacements as any)).toEqual(output as string); + }); + }); + + it('should throw in case we have invalid string', () => { + const arr = ['�*1:1��#2:1�', '�#4:2�', '�6:4�', '�/#2:1��/*1:1�']; + const str = `[${arr.join('|')}]`; + + const cases = [ + // less placeholders than we have + [`Start: ${str}, ${str} and ${str} end.`, {}], + + // more placeholders than we have + [`Start: ${str}, ${str} and ${str}, ${str} ${str} end.`, {}], + + // not enough ICU replacements + ['My ICU #1: �I18N_EXP_ICU�, My ICU #2: �I18N_EXP_ICU�', {ICU: ['ICU_VALUE_1']}] + ]; + cases.forEach(([input, replacements, output]) => { + expect(() => i18nPostprocess(input as string, replacements as any)).toThrowError(); + }); + }); + }); + });