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);
}
@ -349,21 +280,4 @@ 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): void {
i18nEnd(span: ParseSourceSpan|null = null, selfClosing?: boolean): void {
if (!this.i18n) {
throw new Error('i18nEnd is executed with no i18n context present');
}
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.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,12 +761,50 @@ 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)]);
}
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);
}