From 39f42bad1cc11e2dd9297fd8a9aaaeefd2e9ff13 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Fri, 5 Oct 2018 14:12:13 -0700 Subject: [PATCH] feat(ivy): i18n compiler support for element attributes (#26280) PR Close #26280 --- .../compliance/r3_view_compiler_i18n_spec.ts | 253 ++++++++++++++---- .../compiler/src/render3/r3_identifiers.ts | 6 + .../compiler/src/render3/view/template.ts | 66 ++++- packages/compiler/src/render3/view/util.ts | 18 ++ .../core/src/core_render3_private_export.ts | 4 + packages/core/src/render3/i18n.ts | 16 ++ packages/core/src/render3/index.ts | 4 + packages/core/src/render3/jit/environment.ts | 5 + 8 files changed, 305 insertions(+), 67 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 2cd4bf2e55..313d8b0749 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 @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {MockDirectory, setup} from '@angular/compiler/test/aot/test_util'; +import {setup} from '@angular/compiler/test/aot/test_util'; import {compile, expectEmit} from './mock_compile'; const TRANSLATION_NAME_REGEXP = /^MSG_[A-Z0-9]+/; @@ -38,28 +38,28 @@ describe('i18n support in the view compiler', () => { @NgModule({declarations: [MyComponent]}) export class MyModule {} - ` + ` } }; const template = ` - const $msg_1$ = goog.getMsg("Hello world"); - const $msg_2$ = goog.getMsg("farewell"); - … - template: function MyComponent_Template(rf, ctx) { - if (rf & 1) { - … - $r3$.ɵtext(1, $msg_1$); - … - $r3$.ɵtext(3,"&"); - … - $r3$.ɵtext(5, $msg_2$); - … - $r3$.ɵtext(7, $msg_2$); - … + const $msg_1$ = goog.getMsg("Hello world"); + const $msg_2$ = goog.getMsg("farewell"); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + … + $r3$.ɵtext(1, $msg_1$); + … + $r3$.ɵtext(3,"&"); + … + $r3$.ɵtext(5, $msg_2$); + … + $r3$.ɵtext(7, $msg_2$); + … + } } - } - `; + `; const result = compile(files, angularFiles); expectEmit(result.source, template, 'Incorrect template', { @@ -84,40 +84,40 @@ describe('i18n support in the view compiler', () => { @NgModule({declarations: [MyComponent]}) export class MyModule {} - ` + ` } }; const template = ` - /** - * @desc desc - */ - const $msg_1$ = goog.getMsg("introduction"); - const $c1$ = ["title", $msg_1$]; - … - /** - * @desc desc - * @meaning meaning - */ - const $msg_2$ = goog.getMsg("Hello world"); - … - template: function MyComponent_Template(rf, ctx) { - if (rf & 1) { - $r3$.ɵelementStart(0, "div", $c1$); - $r3$.ɵtext(1, $msg_2$); - $r3$.ɵelementEnd(); + /** + * @desc desc + */ + const $MSG_APP_SPEC_TS_0$ = goog.getMsg("introduction"); + const $_c1$ = ["title", $MSG_APP_SPEC_TS_0$, 0]; + … + /** + * @desc desc + * @meaning meaning + */ + const $MSG_APP_SPEC_TS_2$ = goog.getMsg("Hello world"); + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵi18nAttribute(1, $_c1$); + $r3$.ɵtext(2, $MSG_APP_SPEC_TS_2$); + $r3$.ɵelementEnd(); + } } - } `; const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template', { - '$msg_1$': TRANSLATION_NAME_REGEXP, - }); + expectEmit(result.source, template, 'Incorrect template'); }); }); - describe('static attributes', () => { + describe('element attributes', () => { + it('should translate static attributes', () => { const files = { app: { @@ -134,29 +134,168 @@ describe('i18n support in the view compiler', () => { @NgModule({declarations: [MyComponent]}) export class MyModule {} - ` + ` } }; const template = ` - /** - * @desc d - * @meaning m - */ - const $msg_1$ = goog.getMsg("introduction"); - const $c1$ = ["id", "static", "title", $msg_1$]; - … - template: function MyComponent_Template(rf, ctx) { - if (rf & 1) { - $r3$.ɵelement(0, "div", $c1$); + const $_c0$ = ["id", "static"]; + /** + * @desc d + * @meaning m + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("introduction"); + const $_c2$ = ["title", MSG_APP_SPEC_TS_1, 0]; + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div", $_c0$); + $r3$.ɵi18nAttribute(1, $_c2$); + $r3$.ɵelementEnd(); + } } - } - `; + `; const result = compile(files, angularFiles); - expectEmit(result.source, template, 'Incorrect template', { - '$msg_1$': TRANSLATION_NAME_REGEXP, - }); + expectEmit(result.source, template, 'Incorrect template'); + }); + + it('should support interpolation', () => { + 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 template = String.raw ` + const $_c0$ = ["id", "dynamic-1"]; + /** + * @desc d + * @meaning m + */ + const $MSG_APP_SPEC_TS_1$ = goog.getMsg("intro \uFFFD0\uFFFD"); + /** + * @desc 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 $_c5$ = ["id", "dynamic-2"]; + /** + * @desc 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]; + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div", $_c0$); + $r3$.ɵpipe(1, "uppercase"); + $r3$.ɵi18nAttribute(2, $_c4$); + $r3$.ɵelementEnd(); + $r3$.ɵelementStart(3, "div", $_c5$); + $r3$.ɵi18nAttribute(4, $_c8$); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(1, 0, ctx.valueA))); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.valueB)); + $r3$.ɵi18nApply(2); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.valueA)); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.valueB)); + $r3$.ɵi18nExp($r3$.ɵbind((ctx.valueA + ctx.valueB))); + $r3$.ɵi18nExp($r3$.ɵbind(ctx.valueC)); + $r3$.ɵi18nApply(4); + } + } + `; + + const result = compile(files, angularFiles); + expectEmit(result.source, template, 'Incorrect template'); + }); + + it('should correctly bind to context in nested template', () => { + 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 template = String.raw ` + const $_c0$ = ["ngFor", "", 1, "ngForOf"]; + /** + * @desc d + * @meaning m + */ + const $MSG_APP_SPEC_TS__1$ = goog.getMsg("different scope \uFFFD0\uFFFD"); + const $_c2$ = ["title", $MSG_APP_SPEC_TS__1$, 1]; + function MyComponent_div_Template_0(rf, ctx) { + if (rf & 1) { + $r3$.ɵelementStart(0, "div"); + $r3$.ɵelementStart(1, "div"); + $r3$.ɵpipe(2, "uppercase"); + $r3$.ɵi18nAttribute(3, $_c2$); + $r3$.ɵelementEnd(); + $r3$.ɵelementEnd(); + } + if (rf & 2) { + const $outer_r1$ = ctx.$implicit; + $r3$.ɵi18nExp($r3$.ɵbind($r3$.ɵpipeBind1(2, 0, $outer_r1$))); + $r3$.ɵi18nApply(3); + } + } + … + template: function MyComponent_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵtemplate(0, MyComponent_div_Template_0, 4, 2, null, $_c0$); + } + if (rf & 2) { + $r3$.ɵelementProperty(0, "ngForOf", $r3$.ɵbind(ctx.items)); + } + } + `; + + const result = compile(files, angularFiles); + expectEmit(result.source, template, 'Incorrect template'); }); }); diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 9d7539f996..a11be1c20d 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -95,6 +95,12 @@ export class Identifiers { static pipeBind4: o.ExternalReference = {name: 'ɵpipeBind4', moduleName: CORE}; static pipeBindV: o.ExternalReference = {name: 'ɵpipeBindV', moduleName: CORE}; + static i18nAttribute: o.ExternalReference = {name: 'ɵi18nAttribute', 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 load: o.ExternalReference = {name: 'ɵload', moduleName: CORE}; static loadQueryList: o.ExternalReference = {name: 'ɵloadQueryList', moduleName: CORE}; diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index f213c5097c..306a8c7d7f 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -30,7 +30,7 @@ import {htmlAstToRender3Ast} from '../r3_template_transform'; import {R3QueryMetadata} from './api'; import {parseStyle} from './styling'; -import {CONTEXT_NAME, I18N_ATTR, I18N_ATTR_PREFIX, ID_SEPARATOR, IMPLICIT_REFERENCE, MEANING_SEPARATOR, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, asLiteral, getAttrsForDirectiveMatching, invalid, isI18NAttribute, mapToExpression, trimTrailingNulls, unsupported} from './util'; +import {CONTEXT_NAME, I18N_ATTR, I18N_ATTR_PREFIX, ID_SEPARATOR, IMPLICIT_REFERENCE, MEANING_SEPARATOR, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, asLiteral, assembleI18nTemplate, getAttrsForDirectiveMatching, invalid, isI18NAttribute, mapToExpression, trimTrailingNulls, unsupported} from './util'; function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefined { switch (type) { @@ -243,6 +243,10 @@ 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, parseI18nMeta(meta), this.fileBasedI18nSuffix); + } + visitContent(ngContent: t.Content) { const slot = this.allocateDataSlot(); const selectorIndex = ngContent.selectorIndex; @@ -306,7 +310,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver let isNonBindableMode: boolean = false; - // Handle i18n attributes + // Handle i18n and ngNonBindable attributes for (const attr of element.attributes) { const name = attr.name; const value = attr.value; @@ -346,6 +350,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const classInputs: t.BoundAttribute[] = []; const allOtherInputs: t.BoundAttribute[] = []; + const i18nAttrs: Array<{name: string, value: string | AST}> = []; + element.inputs.forEach((input: t.BoundAttribute) => { switch (input.type) { // [attr.style] or [attr.class] should not be treated as styling-based @@ -360,6 +366,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } else if (isClassBinding(input)) { // this should always go first in the compilation (for [class]) classInputs.splice(0, 0, input); + } else if (attrI18nMetas.hasOwnProperty(input.name)) { + i18nAttrs.push({name: input.name, value: input.value}); } else { allOtherInputs.push(input); } @@ -394,13 +402,10 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver staticClassesMap ![className] = true; }); } else { - attributes.push(o.literal(name)); if (attrI18nMetas.hasOwnProperty(name)) { - const meta = parseI18nMeta(attrI18nMetas[name]); - const variable = this.constantPool.getTranslation(value, meta, this.fileBasedI18nSuffix); - attributes.push(variable); + i18nAttrs.push({name, value}); } else { - attributes.push(o.literal(value)); + attributes.push(o.literal(name), o.literal(value)); } } }); @@ -482,7 +487,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const implicit = o.variable(CONTEXT_NAME); const createSelfClosingInstruction = !hasStylingInstructions && !isNgContainer && - element.children.length === 0 && element.outputs.length === 0; + element.children.length === 0 && element.outputs.length === 0 && i18nAttrs.length === 0; if (createSelfClosingInstruction) { this.creationInstruction(element.sourceSpan, R3.element, trimTrailingNulls(parameters)); @@ -495,6 +500,41 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver this.creationInstruction(element.sourceSpan, R3.disableBindings); } + // 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)); + } else { + const converted = value.visit(this._valueConverter); + if (converted instanceof Interpolation) { + const {strings, expressions} = converted; + const label = assembleI18nTemplate(strings); + i18nAttrArgs.push( + o.literal(name), this.i18nTranslate(label, meta), o.literal(expressions.length)); + expressions.forEach(expression => { + hasBindings = true; + const binding = this.convertExpressionBinding(implicit, expression); + this.updateInstruction(element.sourceSpan, R3.i18nExp, [binding]); + }); + } + } + }); + if (i18nAttrArgs.length) { + const index: o.Expression = o.literal(this.allocateDataSlot()); + const args = this.constantPool.getConstLiteral(o.literalArr(i18nAttrArgs), true); + this.creationInstruction(element.sourceSpan, R3.i18nAttribute, [index, args]); + if (hasBindings) { + this.updateInstruction(element.sourceSpan, R3.i18nApply, [index]); + } + } + } + // initial styling for static style="..." attributes if (hasStylingInstructions) { const paramsList: (o.Expression)[] = []; @@ -791,8 +831,7 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver // i0.ɵtext(1, MSG_XYZ); // ``` visitSingleI18nTextChild(text: t.Text, i18nMeta: string) { - const meta = parseI18nMeta(i18nMeta); - const variable = this.constantPool.getTranslation(text.value, meta, this.fileBasedI18nSuffix); + const variable = this.i18nTranslate(text.value, i18nMeta); this.creationInstruction( text.sourceSpan, R3.text, [o.literal(this.allocateDataSlot()), variable]); } @@ -840,6 +879,13 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver this._bindingSlots += value instanceof Interpolation ? value.expressions.length : 1; } + private convertExpressionBinding(implicit: o.Expression, value: AST): o.Expression { + const convertedPropertyBinding = + convertPropertyBinding(this, implicit, value, this.bindingContext(), BindingForm.TrySimple); + const valExpr = convertedPropertyBinding.currValExpr; + return o.importExpr(R3.bind).callFn([valExpr]); + } + private convertPropertyBinding(implicit: o.Expression, value: AST, skipBindFn?: boolean): o.Expression { const interpolationFn = diff --git a/packages/compiler/src/render3/view/util.ts b/packages/compiler/src/render3/view/util.ts index 55c6174840..1dbe8da9ac 100644 --- a/packages/compiler/src/render3/view/util.ts +++ b/packages/compiler/src/render3/view/util.ts @@ -35,6 +35,9 @@ export const I18N_ATTR_PREFIX = 'i18n-'; export const MEANING_SEPARATOR = '|'; export const ID_SEPARATOR = '@@'; +/** Placeholder wrapper for i18n expressions **/ +export const I18N_PLACEHOLDER_SYMBOL = '�'; + /** Non bindable attribute name **/ export const NON_BINDABLE_ATTR = 'ngNonBindable'; @@ -71,6 +74,21 @@ export function isI18NAttribute(name: string): boolean { return name === I18N_ATTR || name.startsWith(I18N_ATTR_PREFIX); } +export function wrapI18nPlaceholder(content: string | number): string { + return `${I18N_PLACEHOLDER_SYMBOL}${content}${I18N_PLACEHOLDER_SYMBOL}`; +} + +export function assembleI18nTemplate(strings: Array): string { + if (!strings.length) return ''; + let acc = ''; + const lastIdx = strings.length - 1; + for (let i = 0; i < lastIdx; i++) { + acc += `${strings[i]}${wrapI18nPlaceholder(i)}`; + } + acc += strings[lastIdx]; + return acc; +} + export function asLiteral(value: any): o.Expression { if (Array.isArray(value)) { return o.literalArr(value.map(asLiteral)); diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index c8912a0959..a22d71e56f 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -105,6 +105,10 @@ export { PipeDef as ɵPipeDef, PipeDefWithMeta as ɵPipeDefWithMeta, whenRendered as ɵwhenRendered, + i18nAttribute as ɵi18nAttribute, + i18nExp as ɵi18nExp, + i18nStart as ɵi18nStart, + i18nEnd as ɵi18nEnd, i18nApply as ɵi18nApply, i18nExpMapping as ɵi18nExpMapping, i18nInterpolation1 as ɵi18nInterpolation1, diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index e5a0e8b126..f9450906d0 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -283,6 +283,22 @@ function appendI18nNode( return tNode; } +export function i18nAttribute(index: number, attrs: any[]): void { + // placeholder for i18nAttribute function +} + +export function i18nExp(expression: any): void { + // placeholder for i18nExp function +} + +export function i18nStart(index: number, message: string, subTemplateIndex: number = 0): void { + // placeholder for i18nExp function +} + +export function i18nEnd(): void { + // placeholder for i18nEnd function +} + /** * Takes a list of instructions generated by `i18nMapping()` to transform the template accordingly. * diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 453409ca54..f6dcc7dded 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -86,6 +86,10 @@ export { } from './instructions'; export { + i18nAttribute, + i18nExp, + i18nStart, + i18nEnd, i18nApply, i18nMapping, i18nInterpolation1, diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 3c1b88d01f..39bb2ff4c1 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -98,6 +98,11 @@ export const angularCoreEnv: {[name: string]: Function} = { 'ɵtextBinding': r3.textBinding, 'ɵembeddedViewStart': r3.embeddedViewStart, 'ɵembeddedViewEnd': r3.embeddedViewEnd, + 'ɵi18nAttribute': r3.i18nAttribute, + 'ɵi18nExp': r3.i18nExp, + 'ɵi18nStart': r3.i18nStart, + 'ɵi18nEnd': r3.i18nEnd, + 'ɵi18nApply': r3.i18nApply, 'ɵsanitizeHtml': sanitization.sanitizeHtml, 'ɵsanitizeStyle': sanitization.sanitizeStyle,