feat(ivy): i18n compiler support for element attributes (#26280)

PR Close #26280
This commit is contained in:
Andrew Kushnir 2018-10-05 14:12:13 -07:00 committed by Miško Hevery
parent 3f8ac238f1
commit 39f42bad1c
8 changed files with 305 additions and 67 deletions

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * 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'; import {compile, expectEmit} from './mock_compile';
const TRANSLATION_NAME_REGEXP = /^MSG_[A-Z0-9]+/; const TRANSLATION_NAME_REGEXP = /^MSG_[A-Z0-9]+/;
@ -38,28 +38,28 @@ describe('i18n support in the view compiler', () => {
@NgModule({declarations: [MyComponent]}) @NgModule({declarations: [MyComponent]})
export class MyModule {} export class MyModule {}
` `
} }
}; };
const template = ` const template = `
const $msg_1$ = goog.getMsg("Hello world"); const $msg_1$ = goog.getMsg("Hello world");
const $msg_2$ = goog.getMsg("farewell"); const $msg_2$ = goog.getMsg("farewell");
template: function MyComponent_Template(rf, ctx) { template: function MyComponent_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵtext(1, $msg_1$); $r3$.ɵtext(1, $msg_1$);
$r3$.ɵtext(3,"&"); $r3$.ɵtext(3,"&");
$r3$.ɵtext(5, $msg_2$); $r3$.ɵtext(5, $msg_2$);
$r3$.ɵtext(7, $msg_2$); $r3$.ɵtext(7, $msg_2$);
}
} }
} `;
`;
const result = compile(files, angularFiles); const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template', { expectEmit(result.source, template, 'Incorrect template', {
@ -84,40 +84,40 @@ describe('i18n support in the view compiler', () => {
@NgModule({declarations: [MyComponent]}) @NgModule({declarations: [MyComponent]})
export class MyModule {} export class MyModule {}
` `
} }
}; };
const template = ` const template = `
/** /**
* @desc desc * @desc desc
*/ */
const $msg_1$ = goog.getMsg("introduction"); const $MSG_APP_SPEC_TS_0$ = goog.getMsg("introduction");
const $c1$ = ["title", $msg_1$]; const $_c1$ = ["title", $MSG_APP_SPEC_TS_0$, 0];
/** /**
* @desc desc * @desc desc
* @meaning meaning * @meaning meaning
*/ */
const $msg_2$ = goog.getMsg("Hello world"); const $MSG_APP_SPEC_TS_2$ = goog.getMsg("Hello world");
template: function MyComponent_Template(rf, ctx) { template: function MyComponent_Template(rf, ctx) {
if (rf & 1) { if (rf & 1) {
$r3$.ɵelementStart(0, "div", $c1$); $r3$.ɵelementStart(0, "div");
$r3$.ɵtext(1, $msg_2$); $r3$.ɵi18nAttribute(1, $_c1$);
$r3$.ɵelementEnd(); $r3$.ɵtext(2, $MSG_APP_SPEC_TS_2$);
$r3$.ɵelementEnd();
}
} }
}
`; `;
const result = compile(files, angularFiles); const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template', { expectEmit(result.source, template, 'Incorrect template');
'$msg_1$': TRANSLATION_NAME_REGEXP,
});
}); });
}); });
describe('static attributes', () => { describe('element attributes', () => {
it('should translate static attributes', () => { it('should translate static attributes', () => {
const files = { const files = {
app: { app: {
@ -134,29 +134,168 @@ describe('i18n support in the view compiler', () => {
@NgModule({declarations: [MyComponent]}) @NgModule({declarations: [MyComponent]})
export class MyModule {} export class MyModule {}
` `
} }
}; };
const template = ` const template = `
/** const $_c0$ = ["id", "static"];
* @desc d /**
* @meaning m * @desc d
*/ * @meaning m
const $msg_1$ = goog.getMsg("introduction"); */
const $c1$ = ["id", "static", "title", $msg_1$]; 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) { template: function MyComponent_Template(rf, ctx) {
$r3$.ɵelement(0, "div", $c1$); if (rf & 1) {
$r3$.ɵelementStart(0, "div", $_c0$);
$r3$.ɵi18nAttribute(1, $_c2$);
$r3$.ɵelementEnd();
}
} }
} `;
`;
const result = compile(files, angularFiles); const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template', { expectEmit(result.source, template, 'Incorrect template');
'$msg_1$': TRANSLATION_NAME_REGEXP, });
});
it('should support interpolation', () => {
const files = {
app: {
'spec.ts': `
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'my-component',
template: \`
<div i18n id="dynamic-1"
i18n-title="m|d" title="intro {{ valueA | uppercase }}"
i18n-aria-label="m1|d1" aria-label="{{ valueB }}"
i18n-aria-roledescription aria-roledescription="static text"
></div>
<div i18n id="dynamic-2"
i18n-title="m2|d2" title="{{ valueA }} and {{ valueB }} and again {{ valueA + valueB }}"
i18n-aria-roledescription aria-roledescription="{{ valueC }}"
></div>
\`
})
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: \`
<div *ngFor="let outer of items">
<div i18n-title="m|d" title="different scope {{ outer | uppercase }}">
</div>
\`
})
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');
}); });
}); });

View File

@ -95,6 +95,12 @@ export class Identifiers {
static pipeBind4: o.ExternalReference = {name: 'ɵpipeBind4', moduleName: CORE}; static pipeBind4: o.ExternalReference = {name: 'ɵpipeBind4', moduleName: CORE};
static pipeBindV: o.ExternalReference = {name: 'ɵpipeBindV', 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 load: o.ExternalReference = {name: 'ɵload', moduleName: CORE};
static loadQueryList: o.ExternalReference = {name: 'ɵloadQueryList', moduleName: CORE}; static loadQueryList: o.ExternalReference = {name: 'ɵloadQueryList', moduleName: CORE};

View File

@ -30,7 +30,7 @@ import {htmlAstToRender3Ast} from '../r3_template_transform';
import {R3QueryMetadata} from './api'; import {R3QueryMetadata} from './api';
import {parseStyle} from './styling'; 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 { function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefined {
switch (type) { switch (type) {
@ -243,6 +243,10 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// LocalResolver // LocalResolver
getLocal(name: string): o.Expression|null { return this._bindingScope.get(name); } 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) { visitContent(ngContent: t.Content) {
const slot = this.allocateDataSlot(); const slot = this.allocateDataSlot();
const selectorIndex = ngContent.selectorIndex; const selectorIndex = ngContent.selectorIndex;
@ -306,7 +310,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
let isNonBindableMode: boolean = false; let isNonBindableMode: boolean = false;
// Handle i18n attributes // Handle i18n and ngNonBindable attributes
for (const attr of element.attributes) { for (const attr of element.attributes) {
const name = attr.name; const name = attr.name;
const value = attr.value; const value = attr.value;
@ -346,6 +350,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const classInputs: t.BoundAttribute[] = []; const classInputs: t.BoundAttribute[] = [];
const allOtherInputs: t.BoundAttribute[] = []; const allOtherInputs: t.BoundAttribute[] = [];
const i18nAttrs: Array<{name: string, value: string | AST}> = [];
element.inputs.forEach((input: t.BoundAttribute) => { element.inputs.forEach((input: t.BoundAttribute) => {
switch (input.type) { switch (input.type) {
// [attr.style] or [attr.class] should not be treated as styling-based // [attr.style] or [attr.class] should not be treated as styling-based
@ -360,6 +366,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
} else if (isClassBinding(input)) { } else if (isClassBinding(input)) {
// this should always go first in the compilation (for [class]) // this should always go first in the compilation (for [class])
classInputs.splice(0, 0, input); classInputs.splice(0, 0, input);
} else if (attrI18nMetas.hasOwnProperty(input.name)) {
i18nAttrs.push({name: input.name, value: input.value});
} else { } else {
allOtherInputs.push(input); allOtherInputs.push(input);
} }
@ -394,13 +402,10 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
staticClassesMap ![className] = true; staticClassesMap ![className] = true;
}); });
} else { } else {
attributes.push(o.literal(name));
if (attrI18nMetas.hasOwnProperty(name)) { if (attrI18nMetas.hasOwnProperty(name)) {
const meta = parseI18nMeta(attrI18nMetas[name]); i18nAttrs.push({name, value});
const variable = this.constantPool.getTranslation(value, meta, this.fileBasedI18nSuffix);
attributes.push(variable);
} else { } else {
attributes.push(o.literal(value)); attributes.push(o.literal(name), o.literal(value));
} }
} }
}); });
@ -482,7 +487,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const implicit = o.variable(CONTEXT_NAME); const implicit = o.variable(CONTEXT_NAME);
const createSelfClosingInstruction = !hasStylingInstructions && !isNgContainer && 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) { if (createSelfClosingInstruction) {
this.creationInstruction(element.sourceSpan, R3.element, trimTrailingNulls(parameters)); this.creationInstruction(element.sourceSpan, R3.element, trimTrailingNulls(parameters));
@ -495,6 +500,41 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.creationInstruction(element.sourceSpan, R3.disableBindings); 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 // initial styling for static style="..." attributes
if (hasStylingInstructions) { if (hasStylingInstructions) {
const paramsList: (o.Expression)[] = []; const paramsList: (o.Expression)[] = [];
@ -791,8 +831,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// i0.ɵtext(1, MSG_XYZ); // i0.ɵtext(1, MSG_XYZ);
// ``` // ```
visitSingleI18nTextChild(text: t.Text, i18nMeta: string) { visitSingleI18nTextChild(text: t.Text, i18nMeta: string) {
const meta = parseI18nMeta(i18nMeta); const variable = this.i18nTranslate(text.value, i18nMeta);
const variable = this.constantPool.getTranslation(text.value, meta, this.fileBasedI18nSuffix);
this.creationInstruction( this.creationInstruction(
text.sourceSpan, R3.text, [o.literal(this.allocateDataSlot()), variable]); text.sourceSpan, R3.text, [o.literal(this.allocateDataSlot()), variable]);
} }
@ -840,6 +879,13 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this._bindingSlots += value instanceof Interpolation ? value.expressions.length : 1; 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): private convertPropertyBinding(implicit: o.Expression, value: AST, skipBindFn?: boolean):
o.Expression { o.Expression {
const interpolationFn = const interpolationFn =

View File

@ -35,6 +35,9 @@ export const I18N_ATTR_PREFIX = 'i18n-';
export const MEANING_SEPARATOR = '|'; export const MEANING_SEPARATOR = '|';
export const ID_SEPARATOR = '@@'; export const ID_SEPARATOR = '@@';
/** Placeholder wrapper for i18n expressions **/
export const I18N_PLACEHOLDER_SYMBOL = '<27>';
/** Non bindable attribute name **/ /** Non bindable attribute name **/
export const NON_BINDABLE_ATTR = 'ngNonBindable'; 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); 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>): 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 { export function asLiteral(value: any): o.Expression {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return o.literalArr(value.map(asLiteral)); return o.literalArr(value.map(asLiteral));

View File

@ -105,6 +105,10 @@ export {
PipeDef as ɵPipeDef, PipeDef as ɵPipeDef,
PipeDefWithMeta as ɵPipeDefWithMeta, PipeDefWithMeta as ɵPipeDefWithMeta,
whenRendered as ɵwhenRendered, whenRendered as ɵwhenRendered,
i18nAttribute as ɵi18nAttribute,
i18nExp as ɵi18nExp,
i18nStart as ɵi18nStart,
i18nEnd as ɵi18nEnd,
i18nApply as ɵi18nApply, i18nApply as ɵi18nApply,
i18nExpMapping as ɵi18nExpMapping, i18nExpMapping as ɵi18nExpMapping,
i18nInterpolation1 as ɵi18nInterpolation1, i18nInterpolation1 as ɵi18nInterpolation1,

View File

@ -283,6 +283,22 @@ function appendI18nNode(
return tNode; 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. * Takes a list of instructions generated by `i18nMapping()` to transform the template accordingly.
* *

View File

@ -86,6 +86,10 @@ export {
} from './instructions'; } from './instructions';
export { export {
i18nAttribute,
i18nExp,
i18nStart,
i18nEnd,
i18nApply, i18nApply,
i18nMapping, i18nMapping,
i18nInterpolation1, i18nInterpolation1,

View File

@ -98,6 +98,11 @@ export const angularCoreEnv: {[name: string]: Function} = {
'ɵtextBinding': r3.textBinding, 'ɵtextBinding': r3.textBinding,
'ɵembeddedViewStart': r3.embeddedViewStart, 'ɵembeddedViewStart': r3.embeddedViewStart,
'ɵembeddedViewEnd': r3.embeddedViewEnd, 'ɵembeddedViewEnd': r3.embeddedViewEnd,
'ɵi18nAttribute': r3.i18nAttribute,
'ɵi18nExp': r3.i18nExp,
'ɵi18nStart': r3.i18nStart,
'ɵi18nEnd': r3.i18nEnd,
'ɵi18nApply': r3.i18nApply,
'ɵsanitizeHtml': sanitization.sanitizeHtml, 'ɵsanitizeHtml': sanitization.sanitizeHtml,
'ɵsanitizeStyle': sanitization.sanitizeStyle, 'ɵsanitizeStyle': sanitization.sanitizeStyle,