feat(ivy): ICU support for Ivy (#26794)

PR Close #26794
This commit is contained in:
Andrew Kushnir 2018-10-18 10:08:51 -07:00 committed by Misko Hevery
parent a4934a74b6
commit 92e80af875
28 changed files with 3106 additions and 933 deletions

View File

@ -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<string, o.Expression>();
private deferredTranslations = new Map<o.ReadVarExpr, number>();
private literals = new Map<string, FixupExpression>();
private literalFactories = new Map<string, o.Expression>();
private injectorDefinitions = new Map<any, FixupExpression>();
@ -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);
}
@ -350,20 +281,3 @@ function invalid<T>(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);
}

View File

@ -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;

View File

@ -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] = `</${el.name}>`;
}
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 {

View File

@ -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); }
}

View File

@ -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;

View File

@ -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],
})));
}

View File

@ -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<Result>(visitor: Visitor<Result>): 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<Result>(visitor: Visitor<Result>): 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<Result>(visitor: Visitor<Result>): 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<Result>(visitor: Visitor<Result>): 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<Result>(visitor: Visitor<Result>): 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<Result>(visitor: Visitor<Result>): Result { return visitor.visitContent(this); }
}
@ -95,6 +98,14 @@ export class Reference implements Node {
visit<Result>(visitor: Visitor<Result>): 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<Result>(visitor: Visitor<Result>): Result { return visitor.visitIcu(this); }
}
export interface Visitor<Result = any> {
// 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<Result = any> {
visitBoundEvent(attribute: BoundEvent): Result;
visitText(text: Text): Result;
visitBoundText(text: BoundText): Result;
visitIcu(icu: Icu): Result;
}
export class NullVisitor implements Visitor<void> {
@ -123,6 +135,7 @@ export class NullVisitor implements Visitor<void> {
visitBoundEvent(attribute: BoundEvent): void {}
visitText(text: Text): void {}
visitBoundText(text: BoundText): void {}
visitIcu(icu: Icu): void {}
}
export class RecursiveVisitor implements Visitor<void> {
@ -145,6 +158,7 @@ export class RecursiveVisitor implements Visitor<void> {
visitBoundEvent(attribute: BoundEvent): void {}
visitText(text: Text): void {}
visitBoundText(text: BoundText): void {}
visitIcu(icu: Icu): void {}
}
export class TransformVisitor implements Visitor<Node> {
@ -190,6 +204,7 @@ export class TransformVisitor implements Visitor<Node> {
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<Result>(visitor: Visitor<Result>, nodes: Node[]): Result[] {

View File

@ -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};

View File

@ -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) {
// `<ng-template>`
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); }

View File

@ -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 = '<27>';
// 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<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;
}
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<o.Expression>();
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);
}
}

View File

@ -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<string, any[]>()};
}
/**
* 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<o.Expression>();
public placeholders = new Map<string, any[]>();
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<string, any[]>();
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;
}
}

View File

@ -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);
}

View File

@ -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('');
}

View File

@ -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 = '<27>';
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<string, string[]>):
{[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<string, any[]>, 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<string, any[]> {
const startIdx = bindingStartIndex;
const placeholders = new Map<string, any>();
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;
}

View File

@ -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<DirectiveT extends DirectiveMeta> 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

View File

@ -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<void>, 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<void>, 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. <ng-template i18n>)
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<void>, 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<void>, 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`: [<5B>#1<>, <20>#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, selfClosing?: boolean): void {
if (!this.i18n) {
throw new Error('i18nEnd is executed with no i18n context present');
}
i18nEnd(span: ParseSourceSpan|null = null): void {
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.i18n = null; // reset local i18n context
}
@ -341,36 +434,31 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, 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<void>, 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<void>, 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<void>, 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<void>, 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<void>, 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<void>, 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<void>, 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<void>, LocalResolver
// be able to support bindings in nested templates to local refs that occur after the
// template definition. e.g. <div *ngIf="showing"> {{ foo }} </div> <div #foo></div>
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<void>, 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,13 +761,51 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, 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)]);
}
}
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));
}

View File

@ -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;
}
});

View File

@ -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<void> {
result: any[] = [];
@ -104,6 +77,8 @@ class R3AstHumanizer implements t.Visitor<void> {
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)); }
}

View File

@ -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('<div i18n>A {{ valueA }} <div> B </div><p *ngIf="visible"> C </p></div>');
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<6F>#1<>Bar<61>/#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 = `
<div i18n>
A {{ valueA }}
<div>A</div>
<b *ngIf="visible">
B {{ valueB }}
<div>B</div>
C {{ valueC }}
</b>
</div>
`;
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<61>#2:1<>Baz<61>/#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<6F>*1:1<>Bar<61>#2:1<>Baz<61>/#2:1<><31>/*1:1<>');
// verify placeholders
const expected = new Map([
['INTERPOLATION', '<27>0<EFBFBD>'], ['START_TAG_DIV', '<27>#0<>|<7C>#1:1<>'],
['START_BOLD_TEXT', '<27>*1:1<><31>#0:1<>'], ['CLOSE_BOLD_TEXT', '<27>/#0:1<><31>/*1:1<>'],
['CLOSE_TAG_DIV', '<27>/#0<>|<7C>/#1:1<>'], ['INTERPOLATION_1', '<27>0:1<>'],
['INTERPOLATION_2', '<27>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 <ng-template>', () => {
const template = `
<ng-template i18n>
Level A
<ng-template>
Level B
<ng-template>
Level C
</ng-template>
</ng-template>
</ng-template>
`;
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', '<27>*1:1<>|<7C>*1:2<>'], ['CLOSE_TAG_NG-TEMPLATE', '<27>/*1:2<>|<7C>/*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(`<div i18n>${input}</div>`);
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 <span>B<div>C</div></span> 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 {<b>ten</b>} other {<div class="A">other</div>}}',
'{VAR_PLURAL, plural, 10 {{$startBoldText}ten{$closeBoldText}} other {{$startTagDiv}other{$closeTagDiv}}}'
]
];
cases.forEach(([input, output]) => { expect(serialize(input)).toEqual(output); });
});
});

View File

@ -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);
}

View File

@ -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';

View File

@ -210,8 +210,9 @@ The goal is for the `@Component` (and friends) to be the compiler of template. S
| i18nAttributes | ✅ | ✅ | ✅ |
| i18nExp | ✅ | ✅ | ✅ |
| i18nApply | ✅ | ✅ | ✅ |
| ICU expressions | ✅ | ✅ | ❌ |
| closure support for g3 | ✅ | ✅ | ❌ |
| ICU expressions | ✅ | ✅ | ✅ |
| closure support for g3 | ✅ | ✅ | ✅ |
| `<ng-container>` support | ✅ | ✅ | ✅ |
| runtime service for external world | ❌ | ❌ | ❌ |
| migration tool | ❌ | ❌ | ❌ |

View File

@ -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', '<27>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: '<27>0:1<>',
START_BOLD_TEXT_1: '<27>*3:1<><31>#1<>',
END_BOLD_TEXT_1: '<27>/#1:1<><31>/*3:1<>',
});
const MSG_div = i18nPostprocess(MSG_div_raw, {VAR_PLURAL: '<27>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:
}<7D>/#1:1<><31>/*3:1<>.
```
### Notice `i18nIcuReplaceVar` function
### Placeholders with multiple values
The `i18nIcuReplaceVar(MSG_div_icu, 'VAR_PLURAL', '<27>0:1<>')` function is needed to replace `VAR_PLURAL` for `<60>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
<b>My text 1</b><b>My text 2</b>
```
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
<EFBFBD>#1<>My text 1<>/#1<><31>#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: '[<5B>#1<>|<7C>#2<>]',
CLOSE_TAG_BOLD: '[<5B>/#2<>|<7C>/#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 (`<60>I18N_EXP_ICU<43>) 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)

View File

@ -30,6 +30,11 @@ const PH_REGEXP = /<2F>(\/?[#*]\d+):?\d*<2A>/gi;
const BINDING_REGEXP = /<2F>(\d+):?\d*<2A>/gi;
const ICU_REGEXP = /({\s*<2A>\d+<2B>\s*,\s*\S{6}\s*,[\s\S]*})/gi;
// i18nPostproocess regexps
const PP_PLACEHOLDERS = /\[(<28>.+?<3F>?)\]/g;
const PP_ICU_VARS = /({\s*)(VAR_(PLURAL|SELECT)(_\d+)?)(\s*,)/g;
const PP_ICUS = /<2F>I18N_EXP_(ICU(_\d+)?)<29>/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 [<EFBFBD>*1:1<EFBFBD><EFBFBD>#2:1<EFBFBD>|<EFBFBD>#4:1<EFBFBD>|<EFBFBD>5<EFBFBD>])
* 2. Replace all ICU vars (like "VAR_PLURAL")
* 3. Replace all ICU references with corresponding values (like <EFBFBD>ICU_EXP_ICU_1<EFBFBD>)
* 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 [<5B>*1:1<><31>#2:1<>|<7C>#4:1<>|<7C>5<EFBFBD>])
//
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 <20>ICU_EXP_ICU_1<5F>)
// 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.
@ -1434,26 +1510,3 @@ function parseNodes(
}
}
}
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: "<22>0<EFBFBD>" });
* // --> MSG_APP_1 = "{<7B>0<EFBFBD>, 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;
}

View File

@ -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';

View File

@ -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,

View File

@ -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('{<7B>0<EFBFBD>, 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 = ['<27>*1:1<><31>#2:1<>', '<27>#4:2<>', '<27>6:4<>', '<27>/#2:1<><31>/*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: '<27>1:2<>'},
'My ICU: {<7B>1:2<>, select, =1 {one} other {other}}'
],
[
'My ICU: {\n\n\tVAR_SELECT_1 \n\n, select, =1 {one} other {other}}',
{VAR_SELECT_1: '<27>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: '<27>1:2<>'},
'My ICU: {<7B>1:2<>, plural, one {1} other {other}}'
],
[
'My ICU: {\n\n\tVAR_PLURAL_1 \n\n, select, =1 {one} other {other}}',
{VAR_PLURAL_1: '<27>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: '<27>1:2<>'},
'My ICU: {<7B>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: '<27>1:2<>', VAR_SELECT: '<27>5<EFBFBD>'},
'My ICU: {<7B>1:2<>, plural, one {1 - {<7B>5<EFBFBD>, 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: '<27>1:2<>', VAR_PLURAL_1: '<27>5<EFBFBD>'},
'My ICU: {<7B>1:2<>, plural, one {1 - {<7B>5<EFBFBD>, age, 50 {fifty} other {other}}} other {other}}'
],
// ICU replacement
[
'My ICU #1: <20>I18N_EXP_ICU<43>, My ICU #2: <20>I18N_EXP_ICU<43>',
{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: <EFBFBD>I18N_EXP_ICU<EFBFBD> and ${str}, ${str} and one more ICU: <EFBFBD>I18N_EXP_ICU<EFBFBD> and end.`,
{VAR_SELECT: '<27>1:2<>', ICU: ['ICU_VALUE_1', 'ICU_VALUE_2']},
`Start: ${arr[0]}, ${arr[1]}. ICU: {<7B>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 = ['<27>*1:1<><31>#2:1<>', '<27>#4:2<>', '<27>6:4<>', '<27>/#2:1<><31>/*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: <20>I18N_EXP_ICU<43>, My ICU #2: <20>I18N_EXP_ICU<43>', {ICU: ['ICU_VALUE_1']}]
];
cases.forEach(([input, replacements, output]) => {
expect(() => i18nPostprocess(input as string, replacements as any)).toThrowError();
});
});
});
});