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 cae168aedb..1bfce76af2 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 @@ -1682,6 +1682,31 @@ describe('i18n support in the view compiler', () => { verify(input, output); }); + it('should support ICU-only templates', () => { + const input = ` + {age, select, 10 {ten} 20 {twenty} other {other}} + `; + + const output = String.raw ` + const $MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$ = goog.getMsg("{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}"); + const $I18N_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$ = $r3$.ɵi18nPostprocess($MSG_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$, { "VAR_SELECT": "\uFFFD0\uFFFD" }); + … + consts: 1, + vars: 1, + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵi18n(0, $I18N_EXTERNAL_8806993169187953163$$APP_SPEC_TS_0$); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind(ctx.age)); + $r3$.ɵi18nApply(0); + } + } + `; + + 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}}
diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index 3812841552..33061675b7 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -25,16 +25,24 @@ import {NO_CHANGE} from './tokens'; import {addAllToArray, getNativeByIndex, getNativeByTNode, getTNode, isLContainer, renderStringify} from './util'; const MARKER = `�`; -const ICU_BLOCK_REGEX = /^\s*(�\d+:?\d*�)\s*,\s*(select|plural)\s*,/; +const ICU_BLOCK_REGEXP = /^\s*(�\d+:?\d*�)\s*,\s*(select|plural)\s*,/; const SUBTEMPLATE_REGEXP = /�\/?\*(\d+:\d+)�/gi; const PH_REGEXP = /�(\/?[#*]\d+):?\d*�/gi; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\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; +// i18nPostprocess consts +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_ICUS_REGEXP = /�I18N_EXP_(ICU(_\d+)?)�/g; +const PP_CLOSE_TEMPLATE_REGEXP = /\/\*/; +const PP_TEMPLATE_ID_REGEXP = /\d+\:(\d+)/; + +// Parsed placeholder structure used in postprocessing (within `i18nPostprocess` function) +// Contains the following fields: [templateId, isCloseTemplateTag, placeholder] +type PostprocessPlaceholder = [number, boolean, string]; interface IcuExpression { type: IcuType; @@ -104,7 +112,7 @@ function extractParts(pattern: string): (string | IcuExpression)[] { if (braceStack.length == 0) { // End of the block. const block = pattern.substring(prevPos, pos); - if (ICU_BLOCK_REGEX.test(block)) { + if (ICU_BLOCK_REGEXP.test(block)) { results.push(parseICUBlock(block)); } else if (block) { // Don't push empty strings results.push(block); @@ -142,7 +150,7 @@ function parseICUBlock(pattern: string): IcuExpression { const values: (string | IcuExpression)[][] = []; let icuType = IcuType.plural; let mainBinding = 0; - pattern = pattern.replace(ICU_BLOCK_REGEX, function(str: string, binding: string, type: string) { + pattern = pattern.replace(ICU_BLOCK_REGEXP, function(str: string, binding: string, type: string) { if (type === 'select') { icuType = IcuType.select; } else { @@ -505,24 +513,62 @@ function appendI18nNode(tNode: TNode, parentTNode: TNode, previousTNode: TNode | */ 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() !; - }); + /** + * Step 1: resolve all multi-value placeholders like [�#5�|�*1:1��#2:1�|�#4:1�] + * + * Note: due to the way we process nested templates (BFS), multi-value placeholders are typically + * grouped by templates, for example: [�#5�|�#6�|�#1:1�|�#3:2�] where �#5� and �#6� belong to root + * template, �#1:1� belong to nested template with index 1 and �#1:2� - nested template with index + * 3. However in real templates the order might be different: i.e. �#1:1� and/or �#3:2� may go in + * front of �#6�. The post processing step restores the right order by keeping track of the + * template id stack and looks for placeholders that belong to the currently active template. + */ + let result: string = message; + if (PP_MULTI_VALUE_PLACEHOLDERS_REGEXP.test(message)) { + const matches: {[key: string]: PostprocessPlaceholder[]} = {}; + const templateIdsStack: number[] = [ROOT_TEMPLATE_ID]; + result = result.replace(PP_PLACEHOLDERS_REGEXP, (m: any, phs: string, tmpl: string): string => { + const content = phs || tmpl; + if (!matches[content]) { + const placeholders: PostprocessPlaceholder[] = []; + content.split('|').forEach((placeholder: string) => { + const match = placeholder.match(PP_TEMPLATE_ID_REGEXP); + const templateId = match ? parseInt(match[1], 10) : ROOT_TEMPLATE_ID; + const isCloseTemplateTag = PP_CLOSE_TEMPLATE_REGEXP.test(placeholder); + placeholders.push([templateId, isCloseTemplateTag, placeholder]); + }); + matches[content] = placeholders; + } + if (!matches[content].length) { + throw new Error(`i18n postprocess: unmatched placeholder - ${content}`); + } + const currentTemplateId = templateIdsStack[templateIdsStack.length - 1]; + const placeholders = matches[content]; + let idx = 0; + // find placeholder index that matches current template id + for (let i = 0; i < placeholders.length; i++) { + if (placeholders[i][0] === currentTemplateId) { + idx = i; + break; + } + } + // update template id stack based on the current tag extracted + const [templateId, isCloseTemplateTag, placeholder] = placeholders[idx]; + if (isCloseTemplateTag) { + templateIdsStack.pop(); + } else if (currentTemplateId !== templateId) { + templateIdsStack.push(templateId); + } + // remove processed tag from the list + placeholders.splice(idx, 1); + return placeholder; + }); - // 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)}`); + // 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 @@ -530,18 +576,18 @@ export function i18nPostprocess( return result; } - // - // Step 2: replace all ICU vars (like "VAR_PLURAL") - // - result = result.replace(PP_ICU_VARS, (match, start, key, _type, _idx, end): string => { + /** + * Step 2: replace all ICU vars (like "VAR_PLURAL") + */ + result = result.replace(PP_ICU_VARS_REGEXP, (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 => { + /** + * 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_REGEXP, (match, key): string => { if (replacements.hasOwnProperty(key)) { const list = replacements[key] as string[]; if (!list.length) { diff --git a/packages/core/test/i18n_integration_spec.ts b/packages/core/test/i18n_integration_spec.ts index 6463305be2..0e01f39edf 100644 --- a/packages/core/test/i18n_integration_spec.ts +++ b/packages/core/test/i18n_integration_spec.ts @@ -46,8 +46,12 @@ const TRANSLATIONS: any = { '{$startTagSpan}Mon logo{$tagImg}{$closeTagSpan}', '{$startTagNgTemplate} Hello {$closeTagNgTemplate}{$startTagNgContainer} Bye {$closeTagNgContainer}': '{$startTagNgTemplate} Bonjour {$closeTagNgTemplate}{$startTagNgContainer} Au revoir {$closeTagNgContainer}', + '{$startTagNgTemplate}{$startTagSpan}Hello{$closeTagSpan}{$closeTagNgTemplate}{$startTagNgContainer}{$startTagSpan}Hello{$closeTagSpan}{$closeTagNgContainer}': + '{$startTagNgTemplate}{$startTagSpan}Bonjour{$closeTagSpan}{$closeTagNgTemplate}{$startTagNgContainer}{$startTagSpan}Bonjour{$closeTagSpan}{$closeTagNgContainer}', '{$startTagNgTemplate}{$startTagSpan}Hello{$closeTagSpan}{$closeTagNgTemplate}{$startTagNgContainer}{$startTagSpan_1}Hello{$closeTagSpan}{$closeTagNgContainer}': '{$startTagNgTemplate}{$startTagSpan}Bonjour{$closeTagSpan}{$closeTagNgTemplate}{$startTagNgContainer}{$startTagSpan_1}Bonjour{$closeTagSpan}{$closeTagNgContainer}', + '{$startTagSpan} Hello - 1 {$closeTagSpan}{$startTagSpan_1} Hello - 2 {$startTagSpan_1} Hello - 3 {$startTagSpan_1} Hello - 4 {$closeTagSpan}{$closeTagSpan}{$closeTagSpan}{$startTagSpan} Hello - 5 {$closeTagSpan}': + '{$startTagSpan} Bonjour - 1 {$closeTagSpan}{$startTagSpan_1} Bonjour - 2 {$startTagSpan_1} Bonjour - 3 {$startTagSpan_1} Bonjour - 4 {$closeTagSpan}{$closeTagSpan}{$closeTagSpan}{$startTagSpan} Bonjour - 5 {$closeTagSpan}', '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}': '{VAR_SELECT, select, 10 {dix} 20 {vingt} other {autres}}', '{VAR_SELECT, select, 1 {one} 2 {two} other {more than two}}': @@ -283,29 +287,56 @@ onlyInIvy('Ivy i18n logic').describe('i18n', function() { expect(element.textContent.replace(/\s+/g, ' ').trim()).toBe('Bonjour Au revoir'); }); - fixmeIvy( - 'FW-910: Invalid placeholder structure generated when using with content that contains tags') - .it('should be able to act as child elements inside i18n block (text + tags)', () => { - const content = 'Hello'; - const template = ` -
- - ${content} - - - ${content} - -
- `; - const fixture = getFixtureWithOverrides({template}); + it('should be able to act as child elements inside i18n block (text + tags)', () => { + const content = 'Hello'; + const template = ` +
+ + ${content} + + + ${content} + +
+ `; + const fixture = getFixtureWithOverrides({template}); - const element = fixture.nativeElement; - const spans = element.getElementsByTagName('span'); - for (let i = 0; i < spans.length; i++) { - const child = spans[i]; - expect((child as any).innerHTML).toBe('Bonjour'); - } - }); + const element = fixture.nativeElement; + const spans = element.getElementsByTagName('span'); + for (let i = 0; i < spans.length; i++) { + expect(spans[i]).toHaveText('Bonjour'); + } + }); + + it('should be able to handle deep nested levels with templates', () => { + const content = 'Hello'; + const template = ` +
+ + ${content} - 1 + + + ${content} - 2 + + ${content} - 3 + + ${content} - 4 + + + + + ${content} - 5 + +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + const spans = element.getElementsByTagName('span'); + for (let i = 0; i < spans.length; i++) { + expect(spans[i].innerHTML).toContain(`Bonjour - ${i + 1}`); + } + }); it('should handle self-closing tags as content', () => { const label = 'My logo'; @@ -340,6 +371,16 @@ onlyInIvy('Ivy i18n logic').describe('i18n', function() { expect(element).toHaveText('vingt'); }); + it('should support ICU-only templates', () => { + const template = ` + {age, select, 10 {ten} 20 {twenty} other {other}} + `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element).toHaveText('vingt'); + }); + it('should support ICUs generated outside of i18n blocks', () => { const template = `
{age, select, 10 {ten} 20 {twenty} other {other}}
diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts index f4ad2fe136..04be71d7b9 100644 --- a/packages/core/test/render3/i18n_spec.ts +++ b/packages/core/test/render3/i18n_spec.ts @@ -1777,7 +1777,7 @@ 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 arr = ['�*1:1��#2:1�', '�#4:1�', '�6:1�', '�/#2:1��/*1:1�']; const str = `[${arr.join('|')}]`; const cases = [ @@ -1855,6 +1855,57 @@ describe('Runtime i18n', () => { }); }); + it('should handle nested template represented by multi-value placeholders', () => { + /** + *
+ * + * Hello - 1 + * + * + * Hello - 2 + * + * Hello - 3 + * + * Hello - 4 + * + * + * + * + * Hello - 5 + * + *
+ */ + const generated = ` + [�#2�|�#4�] Bonjour - 1 [�/#2�|�/#1:3��/*2:3�|�/#1:2��/*2:2�|�/#1:1��/*3:1�|�/#4�] + [�*3:1��#1:1�|�*2:2��#1:2�|�*2:3��#1:3�] + Bonjour - 2 + [�*3:1��#1:1�|�*2:2��#1:2�|�*2:3��#1:3�] + Bonjour - 3 + [�*3:1��#1:1�|�*2:2��#1:2�|�*2:3��#1:3�] Bonjour - 4 [�/#2�|�/#1:3��/*2:3�|�/#1:2��/*2:2�|�/#1:1��/*3:1�|�/#4�] + [�/#2�|�/#1:3��/*2:3�|�/#1:2��/*2:2�|�/#1:1��/*3:1�|�/#4�] + [�/#2�|�/#1:3��/*2:3�|�/#1:2��/*2:2�|�/#1:1��/*3:1�|�/#4�] + [�#2�|�#4�] Bonjour - 5 [�/#2�|�/#1:3��/*2:3�|�/#1:2��/*2:2�|�/#1:1��/*3:1�|�/#4�] + `; + const final = ` + �#2� Bonjour - 1 �/#2� + �*3:1� + �#1:1� + Bonjour - 2 + �*2:2� + �#1:2� + Bonjour - 3 + �*2:3� + �#1:3� Bonjour - 4 �/#1:3� + �/*2:3� + �/#1:2� + �/*2:2� + �/#1:1� + �/*3:1� + �#4� Bonjour - 5 �/#4� + `; + expect(i18nPostprocess(generated.replace(/\s+/g, ''))).toEqual(final.replace(/\s+/g, '')); + }); + 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('|')}]`;