From dee16a4355aac40df0fee6b4dde897beed99d074 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Mon, 8 Jul 2019 17:37:26 -0700 Subject: [PATCH] fix(ivy): update ICU placeholders format to match Closure compiler (#31459) Since `goog.getMsg` does not process ICUs (post-processing is required via goog.i18n.MessageFormat, https://google.github.io/closure-library/api/goog.i18n.MessageFormat.html) and placeholder format used for ICUs and regular messages inside `goog.getMsg` are different, the current implementation (that assumed the same placeholder format) needs to be updated. This commit updates placeholder format used inside ICUs from `{$placeholder}` to `{PLACEHOLDER}` to better align with Closure. ICU placeholders (that were left as is prior to this commit) are now replaced with actual values in post-processing step (inside `i18nPostprocess`). PR Close #31459 --- .../compliance/r3_view_compiler_i18n_spec.ts | 126 +++++++----------- .../src/render3/view/i18n/serializer.ts | 36 +++-- .../compiler/src/render3/view/i18n/util.ts | 8 +- .../compiler/src/render3/view/template.ts | 36 +++-- .../compiler/test/render3/view/i18n_spec.ts | 2 +- packages/core/src/render3/i18n.ts | 13 +- packages/core/test/acceptance/i18n_spec.ts | 70 ++++++++-- 7 files changed, 185 insertions(+), 106 deletions(-) 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 12a2b94a57..10d9a22852 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 @@ -967,25 +967,30 @@ describe('i18n support in the view compiler', () => { it('should support named interpolations', () => { const input = ` -
Some value: {{ valueA // i18n(ph="PH_A") }}
+
+ Named interpolation: {{ valueA // i18n(ph="PH_A") }} + Named interpolation with spaces: {{ valueB // i18n(ph="PH B") }} +
`; const output = String.raw ` var $I18N_0$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_2817319788724342848$$APP_SPEC_TS_0$ = goog.getMsg("Some value: {$phA}", { - "phA": "\uFFFD0\uFFFD" + const $MSG_EXTERNAL_7597881511811528589$$APP_SPEC_TS_0$ = goog.getMsg(" Named interpolation: {$phA} Named interpolation with spaces: {$phB} ", { + "phA": "\uFFFD0\uFFFD", + "phB": "\uFFFD1\uFFFD" }); - $I18N_0$ = $MSG_EXTERNAL_2817319788724342848$$APP_SPEC_TS_0$; + $I18N_0$ = $MSG_EXTERNAL_7597881511811528589$$APP_SPEC_TS_0$; } else { - $I18N_0$ = $r3$.ɵɵi18nLocalize("Some value: {$phA}", { - "phA": "\uFFFD0\uFFFD" + $I18N_0$ = $r3$.ɵɵi18nLocalize(" Named interpolation: {$phA} Named interpolation with spaces: {$phB} ", { + "phA": "\uFFFD0\uFFFD", + "phB": "\uFFFD1\uFFFD" }); } … consts: 2, - vars: 1, + vars: 2, template: function MyComponent_Template(rf, ctx) { if (rf & 1) { $r3$.ɵɵelementStart(0, "div"); @@ -994,7 +999,7 @@ describe('i18n support in the view compiler', () => { } if (rf & 2) { $r3$.ɵɵselect(1); - $r3$.ɵɵi18nExp(ctx.valueA); + $r3$.ɵɵi18nExp(ctx.valueA)(ctx.valueB); $r3$.ɵɵi18nApply(1); } } @@ -2609,18 +2614,15 @@ describe('i18n support in the view compiler', () => { const $_c3$ = ["title", "icu and text"]; var $I18N_5$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_1922743304863699161$$APP_SPEC_TS__5$ = goog.getMsg("{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{$interpolation} emails}}", { - "interpolation": "\uFFFD1\uFFFD" - }); + const $MSG_EXTERNAL_1922743304863699161$$APP_SPEC_TS__5$ = goog.getMsg("{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{INTERPOLATION} emails}}"); $I18N_5$ = $MSG_EXTERNAL_1922743304863699161$$APP_SPEC_TS__5$; } else { - $I18N_5$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{$interpolation} emails}}", { - "interpolation": "\uFFFD1\uFFFD" - }); + $I18N_5$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, 0 {no emails} 1 {one email} other {{INTERPOLATION} emails}}"); } $I18N_5$ = $r3$.ɵɵi18nPostprocess($I18N_5$, { - "VAR_SELECT": "\uFFFD0\uFFFD" + "VAR_SELECT": "\uFFFD0\uFFFD", + "INTERPOLATION": "\uFFFD1\uFFFD" }); function MyComponent_div_3_Template(rf, ctx) { if (rf & 1) { @@ -2671,18 +2673,15 @@ describe('i18n support in the view compiler', () => { const output = String.raw ` var $I18N_0$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_2949673783721159566$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{$interpolation}}}", { - "interpolation": "\uFFFD1\uFFFD" - }); + const $MSG_EXTERNAL_2949673783721159566$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{INTERPOLATION}}}"); $I18N_0$ = $MSG_EXTERNAL_2949673783721159566$$APP_SPEC_TS_0$; } else { - $I18N_0$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{$interpolation}}}", { - "interpolation": "\uFFFD1\uFFFD" - }); + $I18N_0$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {{INTERPOLATION}}}"); } $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" + "VAR_SELECT": "\uFFFD0\uFFFD", + "INTERPOLATION": "\uFFFD1\uFFFD" }); … template: function MyComponent_Template(rf, ctx) { @@ -2714,28 +2713,20 @@ describe('i18n support in the view compiler', () => { const output = String.raw ` var $I18N_1$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_2417296354340576868$$APP_SPEC_TS_1$ = 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_EXTERNAL_2417296354340576868$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male - {START_BOLD_TEXT}male{CLOSE_BOLD_TEXT}} female {female {START_BOLD_TEXT}female{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}{START_ITALIC_TEXT}other{CLOSE_ITALIC_TEXT}{CLOSE_TAG_DIV}}}"); $I18N_1$ = $MSG_EXTERNAL_2417296354340576868$$APP_SPEC_TS_1$; } else { - $I18N_1$ = $r3$.ɵɵi18nLocalize("{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": "
" - }); + $I18N_1$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male - {START_BOLD_TEXT}male{CLOSE_BOLD_TEXT}} female {female {START_BOLD_TEXT}female{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}{START_ITALIC_TEXT}other{CLOSE_ITALIC_TEXT}{CLOSE_TAG_DIV}}}"); } $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD" + "VAR_SELECT": "\uFFFD0\uFFFD", + "START_BOLD_TEXT": "", + "CLOSE_BOLD_TEXT": "", + "START_ITALIC_TEXT": "", + "CLOSE_ITALIC_TEXT": "", + "START_TAG_DIV": "
", + "CLOSE_TAG_DIV": "
" }); const $_c2$ = [1, "other"]; var $I18N_0$; @@ -2795,18 +2786,15 @@ describe('i18n support in the view compiler', () => { const output = String.raw ` var $I18N_0$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_6879461626778511059$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male of age: {$interpolation}} female {female} other {other}}", { - "interpolation": "\uFFFD1\uFFFD" - }); + const $MSG_EXTERNAL_6879461626778511059$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male of age: {INTERPOLATION}} female {female} other {other}}"); $I18N_0$ = $MSG_EXTERNAL_6879461626778511059$$APP_SPEC_TS_0$; } else { - $I18N_0$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male of age: {$interpolation}} female {female} other {other}}", { - "interpolation": "\uFFFD1\uFFFD" - }); + $I18N_0$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male of age: {INTERPOLATION}} female {female} other {other}}"); } $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" + "VAR_SELECT": "\uFFFD0\uFFFD", + "INTERPOLATION": "\uFFFD1\uFFFD" }); … consts: 2, @@ -3148,36 +3136,29 @@ describe('i18n support in the view compiler', () => { const output = String.raw ` var $I18N_1$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_7825031864601787094$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male {$interpolation}} female {female {$interpolation_1}} other {other}}", { - "interpolation": "\uFFFD1\uFFFD", - "interpolation_1": "\uFFFD2\uFFFD" - }); + const $MSG_EXTERNAL_7825031864601787094$$APP_SPEC_TS_1$ = goog.getMsg("{VAR_SELECT, select, male {male {INTERPOLATION}} female {female {INTERPOLATION_1}} other {other}}"); $I18N_1$ = $MSG_EXTERNAL_7825031864601787094$$APP_SPEC_TS_1$; } else { - $I18N_1$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male {$interpolation}} female {female {$interpolation_1}} other {other}}", { - "interpolation": "\uFFFD1\uFFFD", - "interpolation_1": "\uFFFD2\uFFFD" - }); + $I18N_1$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male {INTERPOLATION}} female {female {INTERPOLATION_1}} other {other}}"); } $I18N_1$ = $r3$.ɵɵi18nPostprocess($I18N_1$, { - "VAR_SELECT": "\uFFFD0\uFFFD" + "VAR_SELECT": "\uFFFD0\uFFFD", + "INTERPOLATION": "\uFFFD1\uFFFD", + "INTERPOLATION_1": "\uFFFD2\uFFFD" }); const $_c0$ = [${AttributeMarker.Template}, "ngIf"]; var $I18N_3$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_2310343208266678305$$APP_SPEC_TS__3$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {$interpolation}}}", { - "interpolation": "\uFFFD1:1\uFFFD" - }); + const $MSG_EXTERNAL_2310343208266678305$$APP_SPEC_TS__3$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {INTERPOLATION}}}"); $I18N_3$ = $MSG_EXTERNAL_2310343208266678305$$APP_SPEC_TS__3$; } else { - $I18N_3$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {$interpolation}}}", { - "interpolation": "\uFFFD1:1\uFFFD" - }); + $I18N_3$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, 10 {ten} 20 {twenty} 30 {thirty} other {other: {INTERPOLATION}}}"); } $I18N_3$ = $r3$.ɵɵi18nPostprocess($I18N_3$, { - "VAR_SELECT": "\uFFFD0:1\uFFFD" + "VAR_SELECT": "\uFFFD0:1\uFFFD", + "INTERPOLATION": "\uFFFD1:1\uFFFD" }); var $I18N_0$; if (ngI18nClosureMode) { @@ -3239,29 +3220,24 @@ describe('i18n support in the view compiler', () => { select, male {male {{ weight // i18n(ph="PH_A") }}} female {female {{ height // i18n(ph="PH_B") }}} - other {other {{ age // i18n(ph="PH_C") }}} + other {other {{ age // i18n(ph="PH WITH SPACES") }}} } `; const output = String.raw ` var $I18N_0$; if (ngI18nClosureMode) { - const $MSG_EXTERNAL_4853189513362404940$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male {$phA}} female {female {$phB}} other {other {$phC}}}", { - "phA": "\uFFFD1\uFFFD", - "phB": "\uFFFD2\uFFFD", - "phC": "\uFFFD3\uFFFD" - }); - $I18N_0$ = $MSG_EXTERNAL_4853189513362404940$$APP_SPEC_TS_0$; + const $MSG_EXTERNAL_6318060397235942326$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, male {male {PH_A}} female {female {PH_B}} other {other {PH_WITH_SPACES}}}"); + $I18N_0$ = $MSG_EXTERNAL_6318060397235942326$$APP_SPEC_TS_0$; } else { - $I18N_0$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male {$phA}} female {female {$phB}} other {other {$phC}}}", { - "phA": "\uFFFD1\uFFFD", - "phB": "\uFFFD2\uFFFD", - "phC": "\uFFFD3\uFFFD" - }); + $I18N_0$ = $r3$.ɵɵi18nLocalize("{VAR_SELECT, select, male {male {PH_A}} female {female {PH_B}} other {other {PH_WITH_SPACES}}}"); } $I18N_0$ = $r3$.ɵɵi18nPostprocess($I18N_0$, { - "VAR_SELECT": "\uFFFD0\uFFFD" + "VAR_SELECT": "\uFFFD0\uFFFD", + "PH_A": "\uFFFD1\uFFFD", + "PH_B": "\uFFFD2\uFFFD", + "PH_WITH_SPACES": "\uFFFD3\uFFFD" }); … consts: 2, diff --git a/packages/compiler/src/render3/view/i18n/serializer.ts b/packages/compiler/src/render3/view/i18n/serializer.ts index cd92cdb034..1ef7d2492b 100644 --- a/packages/compiler/src/render3/view/i18n/serializer.ts +++ b/packages/compiler/src/render3/view/i18n/serializer.ts @@ -10,13 +10,26 @@ 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. + * This visitor walks over i18n tree and generates its string representation, including ICUs and + * placeholders in `{$placeholder}` (for plain messages) or `{PLACEHOLDER}` (inside ICUs) format. */ class SerializerVisitor implements i18n.Visitor { + /** + * Flag that indicates that we are processing elements of an ICU. + * + * This flag is needed due to the fact that placeholders in ICUs and in other messages are + * represented differently in Closure: + * - {$placeholder} in non-ICU case + * - {PLACEHOLDER} inside ICU + */ + private insideIcu = false; + + private formatPh(value: string): string { + const formatted = formatI18nPlaceholderName(value, /* useCamelCase */ !this.insideIcu); + return this.insideIcu ? `{${formatted}}` : `{$${formatted}}`; + } + visitText(text: i18n.Text, context: any): any { return text.value; } visitContainer(container: i18n.Container, context: any): any { @@ -24,20 +37,25 @@ class SerializerVisitor implements i18n.Visitor { } visitIcu(icu: i18n.Icu, context: any): any { + this.insideIcu = true; const strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`); - return `{${icu.expressionPlaceholder}, ${icu.type}, ${strCases.join(' ')}}`; + const result = `{${icu.expressionPlaceholder}, ${icu.type}, ${strCases.join(' ')}}`; + this.insideIcu = false; + return result; } 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)}`; + this.formatPh(ph.startName) : + `${this.formatPh(ph.startName)}${ph.children.map(child => child.visit(this)).join('')}${this.formatPh(ph.closeName)}`; } - visitPlaceholder(ph: i18n.Placeholder, context: any): any { return formatPh(ph.name); } + visitPlaceholder(ph: i18n.Placeholder, context: any): any { return this.formatPh(ph.name); } - visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any { return formatPh(ph.name); } + visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any { + return this.formatPh(ph.name); + } } const serializerVisitor = new SerializerVisitor(); diff --git a/packages/compiler/src/render3/view/i18n/util.ts b/packages/compiler/src/render3/view/i18n/util.ts index 01db12534b..c700c1beb8 100644 --- a/packages/compiler/src/render3/view/i18n/util.ts +++ b/packages/compiler/src/render3/view/i18n/util.ts @@ -220,8 +220,12 @@ export function parseI18nMeta(meta?: string): I18nMeta { * @param name The placeholder name that should be formatted * @returns Formatted placeholder name */ -export function formatI18nPlaceholderName(name: string): string { - const chunks = toPublicName(name).split('_'); +export function formatI18nPlaceholderName(name: string, useCamelCase: boolean = true): string { + const publicName = toPublicName(name); + if (!useCamelCase) { + return publicName; + } + const chunks = publicName.split('_'); if (chunks.length === 1) { // if no "_" found - just lowercase the value return name.toLowerCase(); diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index abe513896e..a384a22622 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -324,18 +324,24 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // Closure Compiler requires const names to start with `MSG_` but disallows any other const to // start with `MSG_`. We define a variable starting with `MSG_` just for the `goog.getMsg` call const closureVar = this.i18nGenerateClosureVar(message.id); - const _params: {[key: string]: any} = {}; - if (params && Object.keys(params).length) { - Object.keys(params).forEach(key => _params[formatI18nPlaceholderName(key)] = params[key]); - } + const formattedParams = this.i18nFormatPlaceholderNames(params, /* useCamelCase */ true); const meta = metaFromI18nMessage(message); const content = getSerializedI18nContent(message); const statements = - getTranslationDeclStmts(_ref, closureVar, content, meta, _params, transformFn); + getTranslationDeclStmts(_ref, closureVar, content, meta, formattedParams, transformFn); this.constantPool.statements.push(...statements); return _ref; } + i18nFormatPlaceholderNames(params: {[name: string]: o.Expression} = {}, useCamelCase: boolean) { + const _params: {[key: string]: o.Expression} = {}; + if (params && Object.keys(params).length) { + Object.keys(params).forEach( + key => _params[formatI18nPlaceholderName(key, useCamelCase)] = params[key]); + } + return _params; + } + i18nAppendBindings(expressions: AST[]) { if (expressions.length > 0) { expressions.forEach(expression => this.i18n !.appendBinding(expression)); @@ -994,17 +1000,29 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // 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)]); + + // we always need post-processing function for ICUs, to make sure that: + // - all placeholders in a form of {PLACEHOLDER} are replaced with actual values (note: + // `goog.getMsg` does not process ICUs and uses the `{PLACEHOLDER}` format for placeholders + // inside ICUs) + // - all ICU vars (such as `VAR_SELECT` or `VAR_PLURAL`) are replaced with correct values + const transformFn = (raw: o.ReadVarExpr) => { + const params = {...vars, ...placeholders}; + const formatted = this.i18nFormatPlaceholderNames(params, /* useCamelCase */ false); + return instruction(null, R3.i18nPostprocess, [raw, mapLiteral(formatted, 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 + // note: ICU placeholders are replaced with actual values in `i18nPostprocess` function + // separately, so we do not pass placeholders into `i18nTranslate` function. if (isSingleI18nIcu(i18n.meta)) { - this.i18nTranslate(message, placeholders, i18n.ref, transformFn); + 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); + const ref = + this.i18nTranslate(message, /* placeholders */ {}, /* ref */ undefined, transformFn); i18n.appendIcu(icuFromI18nMessage(message).name, ref); } diff --git a/packages/compiler/test/render3/view/i18n_spec.ts b/packages/compiler/test/render3/view/i18n_spec.ts index 8acdd6fc41..fa1a1a5214 100644 --- a/packages/compiler/test/render3/view/i18n_spec.ts +++ b/packages/compiler/test/render3/view/i18n_spec.ts @@ -249,7 +249,7 @@ describe('Serializer', () => { // ICU with nested HTML [ '{age, plural, 10 {ten} other {
other
}}', - '{VAR_PLURAL, plural, 10 {{$startBoldText}ten{$closeBoldText}} other {{$startTagDiv}other{$closeTagDiv}}}' + '{VAR_PLURAL, plural, 10 {{START_BOLD_TEXT}ten{CLOSE_BOLD_TEXT}} other {{START_TAG_DIV}other{CLOSE_TAG_DIV}}}' ] ]; diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index 9e55eb569c..2a328885b3 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -51,6 +51,7 @@ const ROOT_TEMPLATE_ID = 0; const PP_MULTI_VALUE_PLACEHOLDERS_REGEXP = /\[(�.+?�?)\]/; const PP_PLACEHOLDERS_REGEXP = /\[(�.+?�?)\]|(�\/?\*\d+:\d+�)/g; const PP_ICU_VARS_REGEXP = /({\s*)(VAR_(PLURAL|SELECT)(_\d+)?)(\s*,)/g; +const PP_ICU_PLACEHOLDERS_REGEXP = /{([A-Z0-9_]+)}/g; const PP_ICUS_REGEXP = /�I18N_EXP_(ICU(_\d+)?)�/g; const PP_CLOSE_TEMPLATE_REGEXP = /\/\*/; const PP_TEMPLATE_ID_REGEXP = /\d+\:(\d+)/; @@ -549,7 +550,8 @@ function appendI18nNode( * * 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�) + * 3. Replace all placeholders used inside ICUs in a form of {PLACEHOLDER} + * 4. 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 @@ -627,7 +629,14 @@ export function ɵɵi18nPostprocess( }); /** - * Step 3: replace all ICU references with corresponding values (like �ICU_EXP_ICU_1�) in case + * Step 3: replace all placeholders used inside ICUs in a form of {PLACEHOLDER} + */ + result = result.replace(PP_ICU_PLACEHOLDERS_REGEXP, (match, key): string => { + return replacements.hasOwnProperty(key) ? replacements[key] as string : match; + }); + + /** + * Step 4: 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_REGEXP, (match, key): string => { diff --git a/packages/core/test/acceptance/i18n_spec.ts b/packages/core/test/acceptance/i18n_spec.ts index e1b0c722ee..eccc3b883a 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -39,6 +39,26 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { expect(fixture.nativeElement.innerHTML).toEqual(`
Bonjour John!
`); }); + it('should support named interpolations', () => { + ɵi18nConfigureLocalize({ + translations: { + ' Hello {$userName}! Emails: {$amountOfEmailsReceived} ': + ' Bonjour {$userName}! Emails: {$amountOfEmailsReceived} ' + } + }); + const fixture = initWithTemplate(AppComp, ` +
+ Hello {{ name // i18n(ph="user_name") }}! + Emails: {{ count // i18n(ph="amount of emails received") }} +
+ `); + expect(fixture.nativeElement.innerHTML).toEqual(`
Bonjour Angular! Emails: 0
`); + fixture.componentRef.instance.name = `John`; + fixture.componentRef.instance.count = 5; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toEqual(`
Bonjour John! Emails: 5
`); + }); + it('should support interpolations with custom interpolation config', () => { ɵi18nConfigureLocalize({translations: {'Hello {$interpolation}': 'Bonjour {$interpolation}'}}); const interpolation = ['{%', '%}'] as[string, string]; @@ -470,8 +490,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { it('multiple', () => { ɵi18nConfigureLocalize({ translations: { - '{VAR_PLURAL, plural, =0 {no {$startBoldText}emails{$closeBoldText}!} =1 {one {$startItalicText}email{$closeItalicText}} other {{$interpolation} {$startTagSpan}emails{$closeTagSpan}}}': - '{VAR_PLURAL, plural, =0 {aucun {$startBoldText}email{$closeBoldText}!} =1 {un {$startItalicText}email{$closeItalicText}} other {{$interpolation} {$startTagSpan}emails{$closeTagSpan}}}', + '{VAR_PLURAL, plural, =0 {no {START_BOLD_TEXT}emails{CLOSE_BOLD_TEXT}!} =1 {one {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} {START_TAG_SPAN}emails{CLOSE_TAG_SPAN}}}': + '{VAR_PLURAL, plural, =0 {aucun {START_BOLD_TEXT}email{CLOSE_BOLD_TEXT}!} =1 {un {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} {START_TAG_SPAN}emails{CLOSE_TAG_SPAN}}}', '{VAR_SELECT, select, other {(name)}}': '{VAR_SELECT, select, other {({$interpolation})}}' } }); @@ -516,8 +536,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { it('inside HTML elements', () => { ɵi18nConfigureLocalize({ translations: { - '{VAR_PLURAL, plural, =0 {no {$startBoldText}emails{$closeBoldText}!} =1 {one {$startItalicText}email{$closeItalicText}} other {{$interpolation} {$startTagSpan}emails{$closeTagSpan}}}': - '{VAR_PLURAL, plural, =0 {aucun {$startBoldText}email{$closeBoldText}!} =1 {un {$startItalicText}email{$closeItalicText}} other {{$interpolation} {$startTagSpan}emails{$closeTagSpan}}}', + '{VAR_PLURAL, plural, =0 {no {START_BOLD_TEXT}emails{CLOSE_BOLD_TEXT}!} =1 {one {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} {START_TAG_SPAN}emails{CLOSE_TAG_SPAN}}}': + '{VAR_PLURAL, plural, =0 {aucun {START_BOLD_TEXT}email{CLOSE_BOLD_TEXT}!} =1 {un {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} {START_TAG_SPAN}emails{CLOSE_TAG_SPAN}}}', '{VAR_SELECT, select, other {(name)}}': '{VAR_SELECT, select, other {({$interpolation})}}' } }); @@ -599,8 +619,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { it('nested', () => { ɵi18nConfigureLocalize({ translations: { - '{VAR_PLURAL, plural, =0 {zero} other {{$interpolation} {VAR_SELECT, select, cat {cats} dog {dogs} other {animals}}!}}': - '{VAR_PLURAL, plural, =0 {zero} other {{$interpolation} {VAR_SELECT, select, cat {chats} dog {chients} other {animaux}}!}}' + '{VAR_PLURAL, plural, =0 {zero} other {{INTERPOLATION} {VAR_SELECT, select, cat {cats} dog {dogs} other {animals}}!}}': + '{VAR_PLURAL, plural, =0 {zero} other {{INTERPOLATION} {VAR_SELECT, select, cat {chats} dog {chients} other {animaux}}!}}' } }); const fixture = initWithTemplate(AppComp, `
{count, plural, @@ -837,6 +857,40 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { expect(fixture.debugElement.nativeElement.innerHTML).not.toContain('A'); expect(fixture.debugElement.nativeElement.innerHTML).toContain('C1'); }); + + it('with named interpolations', () => { + @Component({ + selector: 'comp', + template: ` + { + type, + select, + A {A - {{ typeA // i18n(ph="PH_A") }}} + B {B - {{ typeB // i18n(ph="PH_B") }}} + other {other - {{ typeC // i18n(ph="PH WITH SPACES") }}} + } + `, + }) + class Comp { + type = 'A'; + typeA = 'Type A'; + typeB = 'Type B'; + typeC = 'Type C'; + } + + TestBed.configureTestingModule({declarations: [Comp]}); + + const fixture = TestBed.createComponent(Comp); + fixture.detectChanges(); + + expect(fixture.debugElement.nativeElement.innerHTML).toContain('A - Type A'); + + fixture.componentInstance.type = 'C'; // trigger "other" case + fixture.detectChanges(); + + expect(fixture.debugElement.nativeElement.innerHTML).not.toContain('A - Type A'); + expect(fixture.debugElement.nativeElement.innerHTML).toContain('other - Type C'); + }); }); describe('should support attributes', () => { @@ -994,8 +1048,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { translations: { 'start {$interpolation} middle {$interpolation_1} end': 'début {$interpolation_1} milieu {$interpolation} fin', - '{VAR_PLURAL, plural, =0 {no {$startBoldText}emails{$closeBoldText}!} =1 {one {$startItalicText}email{$closeItalicText}} other {{$interpolation} emails}}': - '{VAR_PLURAL, plural, =0 {aucun {$startBoldText}email{$closeBoldText}!} =1 {un {$startItalicText}email{$closeItalicText}} other {{$interpolation} emails}}', + '{VAR_PLURAL, plural, =0 {no {START_BOLD_TEXT}emails{CLOSE_BOLD_TEXT}!} =1 {one {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} emails}}': + '{VAR_PLURAL, plural, =0 {aucun {START_BOLD_TEXT}email{CLOSE_BOLD_TEXT}!} =1 {un {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} emails}}', ' trad: {$icu}': ' traduction: {$icu}' } });