From cc5cfe87c39df850b9a325755d2666cef625ed15 Mon Sep 17 00:00:00 2001
From: Victor Berchet
Date: Fri, 15 Jul 2016 09:42:33 -0700
Subject: [PATCH] feat(i18n): xmb serializer
---
modules/@angular/compiler/src/html_parser.ts | 4 +-
modules/@angular/compiler/src/html_tags.ts | 28 +--
modules/@angular/compiler/src/i18n/catalog.ts | 112 +++++++++
.../@angular/compiler/src/i18n/i18n_ast.ts | 53 ++--
.../@angular/compiler/src/i18n/i18n_parser.ts | 109 +++++----
.../src/i18n/serializers/serializer.ts | 15 ++
.../compiler/src/i18n/serializers/util.ts | 122 ++++++++++
.../compiler/src/i18n/serializers/xmb.ts | 101 ++++++++
.../src/i18n/serializers/xml_helper.ts | 93 +++++++
modules/@angular/compiler/src/i18n/shared.ts | 5 +-
.../compiler/src/i18n/xmb_serializer.ts | 2 +-
.../compiler/test/i18n/catalog_spec.ts | 91 +++++++
.../compiler/test/i18n/extractor_spec.ts | 12 +-
.../compiler/test/i18n/i18n_parser_spec.ts | 226 +++++++++++-------
.../test/i18n/serializers/util_spec.ts | 94 ++++++++
.../test/i18n/serializers/xmb_spec.ts | 68 ++++++
.../test/i18n/serializers/xml_helper_spec.ts | 49 ++++
17 files changed, 995 insertions(+), 189 deletions(-)
create mode 100644 modules/@angular/compiler/src/i18n/catalog.ts
create mode 100644 modules/@angular/compiler/src/i18n/serializers/util.ts
create mode 100644 modules/@angular/compiler/src/i18n/serializers/xml_helper.ts
create mode 100644 modules/@angular/compiler/test/i18n/catalog_spec.ts
create mode 100644 modules/@angular/compiler/test/i18n/serializers/util_spec.ts
create mode 100644 modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts
create mode 100644 modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts
diff --git a/modules/@angular/compiler/src/html_parser.ts b/modules/@angular/compiler/src/html_parser.ts
index 83705dd052..95ee1f7893 100644
--- a/modules/@angular/compiler/src/html_parser.ts
+++ b/modules/@angular/compiler/src/html_parser.ts
@@ -140,8 +140,8 @@ class TreeBuilder {
// read {
if (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_START) {
- this.errors.push(HtmlTreeError.create(
- null, this.peek.sourceSpan, `Invalid ICU message. Missing '{'.`));
+ this.errors.push(
+ HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid ICU message. Missing '{'.`));
return null;
}
diff --git a/modules/@angular/compiler/src/html_tags.ts b/modules/@angular/compiler/src/html_tags.ts
index 0cfc16c3e0..3129e6d680 100644
--- a/modules/@angular/compiler/src/html_tags.ts
+++ b/modules/@angular/compiler/src/html_tags.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {isPresent, isBlank, normalizeBool, RegExpWrapper,} from '../src/facade/lang';
+import {normalizeBool, RegExpWrapper,} from '../src/facade/lang';
// see http://www.w3.org/TR/html51/syntax.html#named-character-references
// see https://html.spec.whatwg.org/multipage/entities.json
@@ -294,31 +294,32 @@ export class HtmlTagDefinition {
isVoid?: boolean,
ignoreFirstLf?: boolean
} = {}) {
- if (isPresent(closedByChildren) && closedByChildren.length > 0) {
+ if (closedByChildren && closedByChildren.length > 0) {
closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true);
}
this.isVoid = normalizeBool(isVoid);
this.closedByParent = normalizeBool(closedByParent) || this.isVoid;
- if (isPresent(requiredParents) && requiredParents.length > 0) {
+ if (requiredParents && requiredParents.length > 0) {
this.requiredParents = {};
+ // The first parent is the list is automatically when none of the listed parents are present
this.parentToAdd = requiredParents[0];
requiredParents.forEach(tagName => this.requiredParents[tagName] = true);
}
this.implicitNamespacePrefix = implicitNamespacePrefix;
- this.contentType = isPresent(contentType) ? contentType : HtmlTagContentType.PARSABLE_DATA;
+ this.contentType = contentType || HtmlTagContentType.PARSABLE_DATA;
this.ignoreFirstLf = normalizeBool(ignoreFirstLf);
}
requireExtraParent(currentParent: string): boolean {
- if (isBlank(this.requiredParents)) {
+ if (!this.requiredParents) {
return false;
}
- if (isBlank(currentParent)) {
+ if (!currentParent) {
return true;
}
- let lcParent = currentParent.toLowerCase();
+ const lcParent = currentParent.toLowerCase();
return this.requiredParents[lcParent] != true && lcParent != 'template';
}
@@ -382,20 +383,19 @@ var TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = {
{contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}),
};
-var DEFAULT_TAG_DEFINITION = new HtmlTagDefinition();
+const _DEFAULT_TAG_DEFINITION = new HtmlTagDefinition();
export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition {
- var result = TAG_DEFINITIONS[tagName.toLowerCase()];
- return isPresent(result) ? result : DEFAULT_TAG_DEFINITION;
+ return TAG_DEFINITIONS[tagName.toLowerCase()] || _DEFAULT_TAG_DEFINITION;
}
-var NS_PREFIX_RE = /^:([^:]+):(.+)/g;
+const _NS_PREFIX_RE = /^:([^:]+):(.+)/g;
-export function splitNsName(elementName: string): string[] {
+export function splitNsName(elementName: string): [string, string] {
if (elementName[0] != ':') {
return [null, elementName];
}
- let match = RegExpWrapper.firstMatch(NS_PREFIX_RE, elementName);
+ const match = RegExpWrapper.firstMatch(_NS_PREFIX_RE, elementName);
return [match[1], match[2]];
}
@@ -404,5 +404,5 @@ export function getNsPrefix(elementName: string): string {
}
export function mergeNsAndName(prefix: string, localName: string): string {
- return isPresent(prefix) ? `:${prefix}:${localName}` : localName;
+ return prefix ? `:${prefix}:${localName}` : localName;
}
diff --git a/modules/@angular/compiler/src/i18n/catalog.ts b/modules/@angular/compiler/src/i18n/catalog.ts
new file mode 100644
index 0000000000..6cbf998284
--- /dev/null
+++ b/modules/@angular/compiler/src/i18n/catalog.ts
@@ -0,0 +1,112 @@
+/**
+ * @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 {HtmlParser} from '../html_parser';
+import {InterpolationConfig} from '../interpolation_config';
+
+import * as i18nAst from './i18n_ast';
+import {extractI18nMessages} from './i18n_parser';
+import {Serializer} from './serializers/serializer';
+
+export class Catalog {
+ private _messageMap: {[k: string]: i18nAst.Message} = {};
+
+ constructor(
+ private _htmlParser: HtmlParser, private _implicitTags: string[],
+ private _implicitAttrs: {[k: string]: string[]}) {}
+
+ public updateFromTemplate(html: string, url: string, interpolationConfig: InterpolationConfig):
+ void {
+ const htmlParserResult = this._htmlParser.parse(html, url, true, interpolationConfig);
+
+ if (htmlParserResult.errors.length) {
+ throw new Error();
+ }
+
+ const messages = extractI18nMessages(
+ htmlParserResult.rootNodes, interpolationConfig, this._implicitTags, this._implicitAttrs);
+
+ messages.forEach((message) => {
+ const id = strHash(serializeAst(message.nodes).join('') + `[${message.meaning}]`);
+ this._messageMap[id] = message;
+ });
+ }
+
+ public load(content: string, serializer: Serializer): void {
+ const nodeMap = serializer.load(content);
+ this._messageMap = {};
+
+ Object.getOwnPropertyNames(nodeMap).forEach(
+ (id) => { this._messageMap[id] = new i18nAst.Message(nodeMap[id], '', ''); });
+ }
+
+ public write(serializer: Serializer): string { return serializer.write(this._messageMap); }
+}
+
+
+/**
+ * String hash function similar to java.lang.String.hashCode().
+ * The hash code for a string is computed as
+ * s[0] * 31 ^ (n - 1) + s[1] * 31 ^ (n - 2) + ... + s[n - 1],
+ * where s[i] is the ith character of the string and n is the length of
+ * the string. We mod the result to make it between 0 (inclusive) and 2^32 (exclusive).
+ *
+ * Based on goog.string.hashCode from the Google Closure library
+ * https://github.com/google/closure-library/
+ *
+ * @internal
+ */
+// TODO(vicb): better algo (less collisions) ?
+export function strHash(str: string): string {
+ let result: number = 0;
+ for (var i = 0; i < str.length; ++i) {
+ // Normalize to 4 byte range, 0 ... 2^32.
+ result = (31 * result + str.charCodeAt(i)) >>> 0;
+ }
+ return result.toString(16);
+}
+
+/**
+ * Serialize the i18n ast to something xml-like in order to generate an UID.
+ *
+ * The visitor is also used in the i18n parser tests
+ *
+ * @internal
+ */
+class _SerializerVisitor implements i18nAst.Visitor {
+ visitText(text: i18nAst.Text, context: any): any { return text.value; }
+
+ visitContainer(container: i18nAst.Container, context: any): any {
+ return `[${container.children.map(child => child.visit(this)).join(', ')}]`;
+ }
+
+ visitIcu(icu: i18nAst.Icu, context: any): any {
+ let strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`);
+ return `{${icu.expression}, ${icu.type}, ${strCases.join(', ')}}`;
+ }
+
+ visitTagPlaceholder(ph: i18nAst.TagPlaceholder, context: any): any {
+ return ph.isVoid ?
+ `` :
+ `${ph.children.map(child => child.visit(this)).join(', ')}`;
+ }
+
+ visitPlaceholder(ph: i18nAst.Placeholder, context: any): any {
+ return `${ph.value}`;
+ }
+
+ visitIcuPlaceholder(ph: i18nAst.IcuPlaceholder, context?: any): any {
+ return `${ph.value.visit(this)}`;
+ }
+}
+
+const serializerVisitor = new _SerializerVisitor();
+
+export function serializeAst(ast: i18nAst.Node[]): string[] {
+ return ast.map(a => a.visit(serializerVisitor, null));
+}
diff --git a/modules/@angular/compiler/src/i18n/i18n_ast.ts b/modules/@angular/compiler/src/i18n/i18n_ast.ts
index 313388ac9b..19dcd6736f 100644
--- a/modules/@angular/compiler/src/i18n/i18n_ast.ts
+++ b/modules/@angular/compiler/src/i18n/i18n_ast.ts
@@ -6,58 +6,53 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {ParseSourceSpan} from "../parse_util";
+import {ParseSourceSpan} from '../parse_util';
-export interface I18nNode {
- visit(visitor: Visitor, context?: any): any;
+export class Message {
+ constructor(public nodes: Node[], public meaning: string, public description: string) {}
}
-export class Text implements I18nNode {
+export interface Node { visit(visitor: Visitor, context?: any): any; }
+
+export class Text implements Node {
constructor(public value: string, public sourceSpan: ParseSourceSpan) {}
- visit(visitor: Visitor, context?: any): any {
- return visitor.visitText(this, context);
- }
+ visit(visitor: Visitor, context?: any): any { return visitor.visitText(this, context); }
}
-export class Container implements I18nNode {
- constructor(public children: I18nNode[], public sourceSpan: ParseSourceSpan) {}
+export class Container implements Node {
+ constructor(public children: Node[], public sourceSpan: ParseSourceSpan) {}
- visit(visitor: Visitor, context?: any): any {
- return visitor.visitContainer(this, context);
- }
+ visit(visitor: Visitor, context?: any): any { return visitor.visitContainer(this, context); }
}
-export class Icu implements I18nNode {
- constructor(public expression: string, public type: string, public cases: {[k: string]: I18nNode}, public sourceSpan: ParseSourceSpan) {}
+export class Icu implements Node {
+ constructor(
+ public expression: string, public type: string, public cases: {[k: string]: Node},
+ public sourceSpan: ParseSourceSpan) {}
- visit(visitor: Visitor, context?: any): any {
- return visitor.visitIcu(this, context);
- }
+ visit(visitor: Visitor, context?: any): any { return visitor.visitIcu(this, context); }
}
export class TagPlaceholder {
- constructor(public name: string, public attrs: {[k: string]: string}, public children: I18nNode[], public sourceSpan: ParseSourceSpan) {}
+ constructor(
+ public tag: string, public attrs: {[k: string]: string}, public startName: string,
+ public closeName: string, public children: Node[], public isVoid: boolean,
+ public sourceSpan: ParseSourceSpan) {}
- visit(visitor: Visitor, context?: any): any {
- return visitor.visitTagPlaceholder(this, context);
- }
+ visit(visitor: Visitor, context?: any): any { return visitor.visitTagPlaceholder(this, context); }
}
export class Placeholder {
constructor(public value: string, public name: string = '', public sourceSpan: ParseSourceSpan) {}
- visit(visitor: Visitor, context?: any): any {
- return visitor.visitPlaceholder(this, context);
- }
+ visit(visitor: Visitor, context?: any): any { return visitor.visitPlaceholder(this, context); }
}
export class IcuPlaceholder {
constructor(public value: Icu, public name: string = '', public sourceSpan: ParseSourceSpan) {}
- visit(visitor: Visitor, context?: any): any {
- return visitor.visitIcuPlaceholder(this, context);
- }
+ visit(visitor: Visitor, context?: any): any { return visitor.visitIcuPlaceholder(this, context); }
}
export interface Visitor {
@@ -68,7 +63,3 @@ export interface Visitor {
visitPlaceholder(ph: Placeholder, context?: any): any;
visitIcuPlaceholder(ph: IcuPlaceholder, context?: any): any;
}
-
-
-
-
diff --git a/modules/@angular/compiler/src/i18n/i18n_parser.ts b/modules/@angular/compiler/src/i18n/i18n_parser.ts
index 5294165d56..9ec7947c53 100644
--- a/modules/@angular/compiler/src/i18n/i18n_parser.ts
+++ b/modules/@angular/compiler/src/i18n/i18n_parser.ts
@@ -6,71 +6,79 @@
* found in the LICENSE file at https://angular.io/license
*/
-import { extractAstMessages} from './extractor';
-import * as hAst from '../html_ast';
-import * as i18nAst from './i18n_ast';
-import {Parser as ExpressionParser} from '../expression_parser/parser';
import {Lexer as ExpressionLexer} from '../expression_parser/lexer';
-import {ParseSourceSpan} from "../parse_util";
-import {HtmlAst} from "../html_ast";
-import {extractPlaceholderName} from "@angular/compiler/src/i18n/shared";
+import {Parser as ExpressionParser} from '../expression_parser/parser';
+import * as hAst from '../html_ast';
+import {getHtmlTagDefinition} from '../html_tags';
+import {InterpolationConfig} from '../interpolation_config';
+import {ParseSourceSpan} from '../parse_util';
-export class Message {
- constructor(public nodes: i18nAst.I18nNode[], public meaning: string, public description: string) {}
-}
+import {extractAstMessages} from './extractor';
+import * as i18nAst from './i18n_ast';
+import {PlaceholderRegistry} from './serializers/util';
+import {extractPlaceholderName} from './shared';
-// TODO: should get the interpolation config
export function extractI18nMessages(
- sourceAst: HtmlAst[], implicitTags: string[],
- implicitAttrs: {[k: string]: string[]}): Message[] {
- const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs);
+ sourceAst: hAst.HtmlAst[], interpolationConfig: InterpolationConfig, implicitTags: string[],
+ implicitAttrs: {[k: string]: string[]}): i18nAst.Message[] {
+ const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs);
- if (extractionResult.errors.length) {
- return[];
- }
+ if (extractionResult.errors.length) {
+ return [];
+ }
- const visitor = new _I18nVisitor(new ExpressionParser(new ExpressionLexer()));
+ const visitor =
+ new _I18nVisitor(new ExpressionParser(new ExpressionLexer()), interpolationConfig);
- return extractionResult.messages.map((msg): Message => {
- return new Message(visitor.convertToI18nAst(msg.nodes), msg.meaning, msg.description);
- });
+ return extractionResult.messages.map((msg): i18nAst.Message => {
+ return new i18nAst.Message(visitor.convertToI18nAst(msg.nodes), msg.meaning, msg.description);
+ });
}
class _I18nVisitor implements hAst.HtmlAstVisitor {
private _isIcu: boolean;
private _icuDepth: number;
+ private _placeholderRegistry: PlaceholderRegistry;
- constructor(private _expressionParser: ExpressionParser) {}
+ constructor(
+ private _expressionParser: ExpressionParser,
+ private _interpolationConfig: InterpolationConfig) {}
- visitElement(el:hAst.HtmlElementAst, context:any):i18nAst.I18nNode {
+ visitElement(el: hAst.HtmlElementAst, context: any): i18nAst.Node {
const children = hAst.htmlVisitAll(this, el.children);
const attrs: {[k: string]: string} = {};
el.attrs.forEach(attr => {
// Do not visit the attributes, translatable ones are top-level ASTs
attrs[attr.name] = attr.value;
});
- return new i18nAst.TagPlaceholder(el.name, attrs, children, el.sourceSpan);
+
+ const isVoid: boolean = getHtmlTagDefinition(el.name).isVoid;
+ const startPhName =
+ this._placeholderRegistry.getStartTagPlaceholderName(el.name, attrs, isVoid);
+ const closePhName = isVoid ? '' : this._placeholderRegistry.getCloseTagPlaceholderName(el.name);
+
+ return new i18nAst.TagPlaceholder(
+ el.name, attrs, startPhName, closePhName, children, isVoid, el.sourceSpan);
}
- visitAttr(attr:hAst.HtmlAttrAst, context:any):i18nAst.I18nNode {
+ visitAttr(attr: hAst.HtmlAttrAst, context: any): i18nAst.Node {
return this._visitTextWithInterpolation(attr.value, attr.sourceSpan);
}
- visitText(text:hAst.HtmlTextAst, context:any):i18nAst.I18nNode {
+ visitText(text: hAst.HtmlTextAst, context: any): i18nAst.Node {
return this._visitTextWithInterpolation(text.value, text.sourceSpan);
}
- visitComment(comment:hAst.HtmlCommentAst, context:any):i18nAst.I18nNode {
- return null;
- }
+ visitComment(comment: hAst.HtmlCommentAst, context: any): i18nAst.Node { return null; }
- visitExpansion(icu:hAst.HtmlExpansionAst, context:any):i18nAst.I18nNode {
+ visitExpansion(icu: hAst.HtmlExpansionAst, context: any): i18nAst.Node {
this._icuDepth++;
- const i18nIcuCases: {[k: string]: i18nAst.I18nNode} = {};
+ const i18nIcuCases: {[k: string]: i18nAst.Node} = {};
const i18nIcu = new i18nAst.Icu(icu.switchValue, icu.type, i18nIcuCases, icu.sourceSpan);
icu.cases.forEach((caze): void => {
- i18nIcuCases[caze.value] = new i18nAst.Container(caze.expression.map((hAst) => hAst.visit(this, {})), caze.expSourceSpan);
- });
+ i18nIcuCases[caze.value] = new i18nAst.Container(
+ caze.expression.map((hAst) => hAst.visit(this, {})), caze.expSourceSpan);
+ });
this._icuDepth--;
if (this._isIcu || this._icuDepth > 0) {
@@ -79,21 +87,25 @@ class _I18nVisitor implements hAst.HtmlAstVisitor {
}
// else returns a placeholder
- return new i18nAst.IcuPlaceholder(i18nIcu, 'icu', icu.sourceSpan);
+ const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString());
+ return new i18nAst.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan);
}
- visitExpansionCase(icuCase:hAst.HtmlExpansionCaseAst, context:any):i18nAst.I18nNode {
+ visitExpansionCase(icuCase: hAst.HtmlExpansionCaseAst, context: any): i18nAst.Node {
throw new Error('Unreachable code');
}
- public convertToI18nAst(htmlAsts: hAst.HtmlAst[]): i18nAst.I18nNode[] {
+ public convertToI18nAst(htmlAsts: hAst.HtmlAst[]): i18nAst.Node[] {
this._isIcu = htmlAsts.length == 1 && htmlAsts[0] instanceof hAst.HtmlExpansionAst;
this._icuDepth = 0;
+ this._placeholderRegistry = new PlaceholderRegistry();
+
return hAst.htmlVisitAll(this, htmlAsts, {});
}
- private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18nAst.I18nNode {
- const splitInterpolation = this._expressionParser.splitInterpolation(text, sourceSpan.start.toString());
+ private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18nAst.Node {
+ const splitInterpolation = this._expressionParser.splitInterpolation(
+ text, sourceSpan.start.toString(), this._interpolationConfig);
if (!splitInterpolation) {
// No expression, return a single text
@@ -101,23 +113,26 @@ class _I18nVisitor implements hAst.HtmlAstVisitor {
}
// Return a group of text + expressions
- const nodes: i18nAst.I18nNode[] = [];
+ const nodes: i18nAst.Node[] = [];
const container = new i18nAst.Container(nodes, sourceSpan);
for (let i = 0; i < splitInterpolation.strings.length - 1; i++) {
const expression = splitInterpolation.expressions[i];
- const phName = extractPlaceholderName(expression);
- nodes.push(
- new i18nAst.Text(splitInterpolation.strings[i], sourceSpan),
- new i18nAst.Placeholder(expression, phName, sourceSpan)
- )
+ const baseName = extractPlaceholderName(expression) || 'INTERPOLATION';
+ const phName = this._placeholderRegistry.getPlaceholderName(baseName, expression);
+
+ if (splitInterpolation.strings[i].length) {
+ nodes.push(new i18nAst.Text(splitInterpolation.strings[i], sourceSpan));
+ }
+
+ nodes.push(new i18nAst.Placeholder(expression, phName, sourceSpan));
}
// The last index contains no expression
const lastStringIdx = splitInterpolation.strings.length - 1;
- nodes.push(new i18nAst.Text(splitInterpolation.strings[lastStringIdx], sourceSpan));
-
+ if (splitInterpolation.strings[lastStringIdx].length) {
+ nodes.push(new i18nAst.Text(splitInterpolation.strings[lastStringIdx], sourceSpan));
+ }
return container;
}
}
-
diff --git a/modules/@angular/compiler/src/i18n/serializers/serializer.ts b/modules/@angular/compiler/src/i18n/serializers/serializer.ts
index e69de29bb2..690bbcbb86 100644
--- a/modules/@angular/compiler/src/i18n/serializers/serializer.ts
+++ b/modules/@angular/compiler/src/i18n/serializers/serializer.ts
@@ -0,0 +1,15 @@
+/**
+ * @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 i18nAst from '../i18n_ast';
+
+export interface Serializer {
+ write(messageMap: {[k: string]: i18nAst.Message}): string;
+
+ load(content: string): {[k: string]: i18nAst.Node[]};
+}
\ No newline at end of file
diff --git a/modules/@angular/compiler/src/i18n/serializers/util.ts b/modules/@angular/compiler/src/i18n/serializers/util.ts
new file mode 100644
index 0000000000..fd403b990d
--- /dev/null
+++ b/modules/@angular/compiler/src/i18n/serializers/util.ts
@@ -0,0 +1,122 @@
+/**
+ * @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
+ */
+
+const TAG_TO_PLACEHOLDER_NAMES: {[k: string]: string} = {
+ 'A': 'LINK',
+ 'B': 'BOLD_TEXT',
+ 'BR': 'LINE_BREAK',
+ 'EM': 'EMPHASISED_TEXT',
+ 'H1': 'HEADING_LEVEL1',
+ 'H2': 'HEADING_LEVEL2',
+ 'H3': 'HEADING_LEVEL3',
+ 'H4': 'HEADING_LEVEL4',
+ 'H5': 'HEADING_LEVEL5',
+ 'H6': 'HEADING_LEVEL6',
+ 'HR': 'HORIZONTAL_RULE',
+ 'I': 'ITALIC_TEXT',
+ 'LI': 'LIST_ITEM',
+ 'LINK': 'MEDIA_LINK',
+ 'OL': 'ORDERED_LIST',
+ 'P': 'PARAGRAPH',
+ 'Q': 'QUOTATION',
+ 'S': 'STRIKETHROUGH_TEXT',
+ 'SMALL': 'SMALL_TEXT',
+ 'SUB': 'SUBSTRIPT',
+ 'SUP': 'SUPERSCRIPT',
+ 'TBODY': 'TABLE_BODY',
+ 'TD': 'TABLE_CELL',
+ 'TFOOT': 'TABLE_FOOTER',
+ 'TH': 'TABLE_HEADER_CELL',
+ 'THEAD': 'TABLE_HEADER',
+ 'TR': 'TABLE_ROW',
+ 'TT': 'MONOSPACED_TEXT',
+ 'U': 'UNDERLINED_TEXT',
+ 'UL': 'UNORDERED_LIST',
+};
+
+/**
+ * Creates unique names for placeholder with different content
+ *
+ * @internal
+ */
+export class PlaceholderRegistry {
+ private _placeHolderNameCounts: {[k: string]: number} = {};
+ private _signatureToName: {[k: string]: string} = {};
+
+ getStartTagPlaceholderName(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string {
+ const signature = this._hashTag(tag, attrs, isVoid);
+ if (this._signatureToName[signature]) {
+ return this._signatureToName[signature];
+ }
+
+ const upperTag = tag.toUpperCase();
+ const baseName = TAG_TO_PLACEHOLDER_NAMES[upperTag] || `TAG_${upperTag}`;
+ const name = this._generateUniqueName(isVoid ? baseName : `START_${baseName}`);
+
+ this._signatureToName[signature] = name;
+
+ return name;
+ }
+
+ getCloseTagPlaceholderName(tag: string): string {
+ const signature = this._hashClosingTag(tag);
+ if (this._signatureToName[signature]) {
+ return this._signatureToName[signature];
+ }
+
+ const upperTag = tag.toUpperCase();
+ const baseName = TAG_TO_PLACEHOLDER_NAMES[upperTag] || `TAG_${upperTag}`;
+ const name = this._generateUniqueName(`CLOSE_${baseName}`);
+
+ this._signatureToName[signature] = name;
+
+ return name;
+ }
+
+ getPlaceholderName(name: string, content: string): string {
+ const upperName = name.toUpperCase();
+ const signature = `PH: ${upperName}=${content}`;
+ if (this._signatureToName[signature]) {
+ return this._signatureToName[signature];
+ }
+
+ const uniqueName = this._generateUniqueName(upperName);
+ this._signatureToName[signature] = uniqueName;
+
+ return uniqueName;
+ }
+
+ private _hashTag(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string {
+ const start = `<${tag.toUpperCase()}`;
+ const strAttrs =
+ Object.getOwnPropertyNames(attrs).sort().map((name) => ` ${name}=${attrs[name]}`).join('');
+ const end = isVoid ? '/>' : `>${tag.toUpperCase()}>`;
+
+ return start + strAttrs + end;
+ }
+
+ private _hashClosingTag(tag: string): string {
+ return this._hashTag(`/${tag.toUpperCase()}`, {}, false);
+ }
+
+ private _generateUniqueName(base: string): string {
+ let name = base;
+ let next = this._placeHolderNameCounts[name];
+
+ if (!next) {
+ next = 1;
+ } else {
+ name += `_${next}`;
+ next++;
+ }
+
+ this._placeHolderNameCounts[base] = next;
+
+ return name;
+ }
+}
\ No newline at end of file
diff --git a/modules/@angular/compiler/src/i18n/serializers/xmb.ts b/modules/@angular/compiler/src/i18n/serializers/xmb.ts
index e69de29bb2..d3156801b9 100644
--- a/modules/@angular/compiler/src/i18n/serializers/xmb.ts
+++ b/modules/@angular/compiler/src/i18n/serializers/xmb.ts
@@ -0,0 +1,101 @@
+/**
+ * @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 {ListWrapper} from '../../facade/collection';
+import * as i18nAst from '../i18n_ast';
+
+import {Serializer} from './serializer';
+import * as xml from './xml_helper';
+
+const _MESSAGES_TAG = 'messagebundle';
+const _MESSAGE_TAG = 'msg';
+const _PLACEHOLDER_TAG = 'ph';
+const _EXEMPLE_TAG = 'ex';
+
+export class XmbSerializer implements Serializer {
+ // TODO(vicb): DOCTYPE
+ write(messageMap: {[k: string]: i18nAst.Message}): string {
+ const visitor = new _Visitor();
+ const declaration = new xml.Declaration({version: '1.0', encoding: 'UTF-8'});
+ let rootNode = new xml.Tag(_MESSAGES_TAG);
+ rootNode.children.push(new xml.Text('\n'));
+
+ Object.getOwnPropertyNames(messageMap).forEach((id) => {
+ const message = messageMap[id];
+ let attrs: {[k: string]: string} = {id};
+
+ if (message.description) {
+ attrs['desc'] = message.description;
+ }
+
+ if (message.meaning) {
+ attrs['meaning'] = message.meaning;
+ }
+
+ rootNode.children.push(
+ new xml.Text(' '), new xml.Tag(_MESSAGE_TAG, attrs, visitor.serialize(message.nodes)),
+ new xml.Text('\n'));
+ });
+
+ return xml.serialize([
+ declaration,
+ new xml.Text('\n'),
+ rootNode,
+ ]);
+ }
+
+ load(content: string): {[k: string]: i18nAst.Node[]} { throw new Error('Unsupported'); }
+}
+
+class _Visitor implements i18nAst.Visitor {
+ visitText(text: i18nAst.Text, context?: any): xml.Node[] { return [new xml.Text(text.value)]; }
+
+ visitContainer(container: i18nAst.Container, context?: any): xml.Node[] {
+ const nodes: xml.Node[] = [];
+ container.children.forEach((node: i18nAst.Node) => nodes.push(...node.visit(this)));
+ return nodes;
+ }
+
+ visitIcu(icu: i18nAst.Icu, context?: any): xml.Node[] {
+ const nodes = [new xml.Text(`{${icu.expression}, ${icu.type}, `)];
+
+ Object.getOwnPropertyNames(icu.cases).forEach((c: string) => {
+ nodes.push(new xml.Text(`${c} {`), ...icu.cases[c].visit(this), new xml.Text(`}`));
+ });
+
+ nodes.push(new xml.Text(`}`));
+
+ return nodes;
+ }
+
+ visitTagPlaceholder(ph: i18nAst.TagPlaceholder, context?: any): xml.Node[] {
+ const startEx = new xml.Tag(_EXEMPLE_TAG, {}, [new xml.Text(`<${ph.tag}>`)]);
+ const startTagPh = new xml.Tag(_PLACEHOLDER_TAG, {name: ph.startName}, [startEx]);
+ if (ph.isVoid) {
+ // void tags have no children nor closing tags
+ return [startTagPh];
+ }
+
+ const closeEx = new xml.Tag(_EXEMPLE_TAG, {}, [new xml.Text(`${ph.tag}>`)]);
+ const closeTagPh = new xml.Tag(_PLACEHOLDER_TAG, {name: ph.closeName}, [closeEx]);
+
+ return [startTagPh, ...this.serialize(ph.children), closeTagPh];
+ }
+
+ visitPlaceholder(ph: i18nAst.Placeholder, context?: any): xml.Node[] {
+ return [new xml.Tag(_PLACEHOLDER_TAG, {name: ph.name})];
+ }
+
+ visitIcuPlaceholder(ph: i18nAst.IcuPlaceholder, context?: any): xml.Node[] {
+ return [new xml.Tag(_PLACEHOLDER_TAG, {name: ph.name})];
+ }
+
+ serialize(nodes: i18nAst.Node[]): xml.Node[] {
+ return ListWrapper.flatten(nodes.map(node => node.visit(this)));
+ }
+}
diff --git a/modules/@angular/compiler/src/i18n/serializers/xml_helper.ts b/modules/@angular/compiler/src/i18n/serializers/xml_helper.ts
new file mode 100644
index 0000000000..e088fb5f8b
--- /dev/null
+++ b/modules/@angular/compiler/src/i18n/serializers/xml_helper.ts
@@ -0,0 +1,93 @@
+/**
+ * @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
+ */
+
+export interface IVisitor {
+ visitTag(tag: Tag): any;
+ visitText(text: Text): any;
+ visitDeclaration(decl: Declaration): any;
+}
+
+class _Visitor implements IVisitor {
+ visitTag(tag: Tag): string {
+ const strAttrs = this._serializeAttributes(tag.attrs);
+
+ if (tag.children.length == 0) {
+ return `<${tag.name}${strAttrs}/>`;
+ }
+
+ const strChildren = tag.children.map(node => node.visit(this));
+ return `<${tag.name}${strAttrs}>${strChildren.join('')}${tag.name}>`;
+ }
+
+ visitText(text: Text): string { return text.value; }
+
+ visitDeclaration(decl: Declaration): string {
+ return ` xml${this._serializeAttributes(decl.attrs)} ?>`;
+ }
+
+ private _serializeAttributes(attrs: {[k: string]: string}) {
+ const strAttrs = Object.getOwnPropertyNames(attrs)
+ .map((name: string) => `${name}="${attrs[name]}"`)
+ .join(' ');
+ return strAttrs.length > 0 ? ' ' + strAttrs : '';
+ }
+}
+
+const _visitor = new _Visitor();
+
+export function serialize(nodes: Node[]): string {
+ return nodes.map((node: Node): string => node.visit(_visitor)).join('');
+}
+
+export interface Node { visit(visitor: IVisitor): any; }
+
+export class Declaration implements Node {
+ public attrs: {[k: string]: string} = {};
+
+ constructor(unescapedAttrs: {[k: string]: string}) {
+ Object.getOwnPropertyNames(unescapedAttrs).forEach((k: string) => {
+ this.attrs[k] = _escapeXml(unescapedAttrs[k]);
+ });
+ }
+
+ visit(visitor: IVisitor): any { return visitor.visitDeclaration(this); }
+}
+
+export class Tag implements Node {
+ public attrs: {[k: string]: string} = {};
+
+ constructor(
+ public name: string, unescapedAttrs: {[k: string]: string} = {},
+ public children: Node[] = []) {
+ Object.getOwnPropertyNames(unescapedAttrs).forEach((k: string) => {
+ this.attrs[k] = _escapeXml(unescapedAttrs[k]);
+ });
+ }
+
+ visit(visitor: IVisitor): any { return visitor.visitTag(this); }
+}
+
+export class Text implements Node {
+ value: string;
+ constructor(unescapedValue: string) { this.value = _escapeXml(unescapedValue); };
+
+ visit(visitor: IVisitor): any { return visitor.visitText(this); }
+}
+
+const _ESCAPED_CHARS: [RegExp, string][] = [
+ [/&/g, '&'],
+ [/"/g, '"'],
+ [/'/g, '''],
+ [//g, '>'],
+];
+
+function _escapeXml(text: string): string {
+ return _ESCAPED_CHARS.reduce(
+ (text: string, entry: [RegExp, string]) => text.replace(entry[0], entry[1]), text);
+}
diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts
index 2bc53f7ac3..534a0e57eb 100644
--- a/modules/@angular/compiler/src/i18n/shared.ts
+++ b/modules/@angular/compiler/src/i18n/shared.ts
@@ -175,8 +175,7 @@ export function extractPhNameFromInterpolation(input: string, index: number): st
}
export function extractPlaceholderName(input: string): string {
- const matches = StringWrapper.split(input, _CUSTOM_PH_EXP);
- return matches[1] || `interpolation`;
+ return StringWrapper.split(input, _CUSTOM_PH_EXP)[1];
}
@@ -253,4 +252,4 @@ class _StringifyVisitor implements HtmlAstVisitor {
private _join(strs: string[], str: string): string {
return strs.filter(s => s.length > 0).join(str);
}
-}
+}
\ No newline at end of file
diff --git a/modules/@angular/compiler/src/i18n/xmb_serializer.ts b/modules/@angular/compiler/src/i18n/xmb_serializer.ts
index 6c0dc7ae96..d0fd9944c2 100644
--- a/modules/@angular/compiler/src/i18n/xmb_serializer.ts
+++ b/modules/@angular/compiler/src/i18n/xmb_serializer.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {RegExpWrapper, isBlank, isPresent} from '../facade/lang';
+import {RegExpWrapper, isPresent} from '../facade/lang';
import {HtmlAst, HtmlElementAst} from '../html_ast';
import {HtmlParser} from '../html_parser';
import {ParseError, ParseSourceSpan} from '../parse_util';
diff --git a/modules/@angular/compiler/test/i18n/catalog_spec.ts b/modules/@angular/compiler/test/i18n/catalog_spec.ts
new file mode 100644
index 0000000000..3d43d3db49
--- /dev/null
+++ b/modules/@angular/compiler/test/i18n/catalog_spec.ts
@@ -0,0 +1,91 @@
+/**
+ * @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 {HtmlParser} from '@angular/compiler/src/html_parser';
+import {Catalog, strHash} from '@angular/compiler/src/i18n/catalog';
+import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/interpolation_config';
+import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
+
+import Serializable = webdriver.Serializable;
+import {Serializer} from '@angular/compiler/src/i18n/serializers/serializer';
+import {serializeAst} from '@angular/compiler/src/i18n/catalog';
+import * as i18nAst from '@angular/compiler/src/i18n/i18n_ast';
+
+export function main(): void {
+ ddescribe('Catalog', () => {
+
+ describe('write', () => {
+ let catalog: Catalog;
+
+ beforeEach(() => { catalog = new Catalog(new HtmlParser, [], {}); });
+
+ it('should extract the message to the catalog', () => {
+ catalog.updateFromTemplate(
+ 'Translate Me
', 'url', DEFAULT_INTERPOLATION_CONFIG);
+ expect(humanizeCatalog(catalog)).toEqual([
+ 'a486901=Translate Me',
+ ]);
+ });
+
+ it('should extract the same message with different meaning in different entries', () => {
+ catalog.updateFromTemplate(
+ 'Translate Me
Translate Me
', 'url',
+ DEFAULT_INTERPOLATION_CONFIG);
+ expect(humanizeCatalog(catalog)).toEqual([
+ 'a486901=Translate Me',
+ '8475f2cc=Translate Me',
+ ]);
+ });
+ });
+
+ describe(
+ 'load', () => {
+ // TODO
+ });
+
+ describe('strHash', () => {
+ it('should return a hash value', () => {
+ // https://github.com/google/closure-library/blob/1fb19a857b96b74e6523f3e9d33080baf25be046/closure/goog/string/string_test.js#L1115
+ expectHash('', 0);
+ expectHash('foo', 101574);
+ expectHash('\uAAAAfoo', 1301670364);
+ expectHash('a', 92567585, 5);
+ expectHash('a', 2869595232, 6);
+ expectHash('a', 3058106369, 7);
+ expectHash('a', 312017024, 8);
+ expectHash('a', 2929737728, 1024);
+ });
+ });
+ });
+}
+
+class _TestSerializer implements Serializer {
+ write(messageMap: {[k: string]: i18nAst.Message}): string {
+ return Object.keys(messageMap)
+ .map(id => `${id}=${serializeAst(messageMap[id].nodes)}`)
+ .join('//');
+ }
+
+ load(content: string): {[k: string]: i18nAst.Node[]} { return null; }
+}
+
+function humanizeCatalog(catalog: Catalog): string[] {
+ return catalog.write(new _TestSerializer()).split('//');
+}
+
+function expectHash(text: string, decimal: number, repeat: number = 1) {
+ let acc = text;
+ for (let i = 1; i < repeat; i++) {
+ acc += text;
+ }
+
+ const hash = strHash(acc);
+ expect(typeof(hash)).toEqual('string');
+ expect(hash.length > 0).toBe(true);
+ expect(parseInt(hash, 16)).toEqual(decimal);
+}
\ No newline at end of file
diff --git a/modules/@angular/compiler/test/i18n/extractor_spec.ts b/modules/@angular/compiler/test/i18n/extractor_spec.ts
index 66c4b91d78..8196c2cf50 100644
--- a/modules/@angular/compiler/test/i18n/extractor_spec.ts
+++ b/modules/@angular/compiler/test/i18n/extractor_spec.ts
@@ -263,8 +263,8 @@ export function main() {
}
function getExtractionResult(
- html: string, implicitTags: string[],
- implicitAttrs: {[k: string]: string[]}): ExtractionResult {
+ html: string, implicitTags: string[], implicitAttrs:
+ {[k: string]: string[]}): ExtractionResult {
const htmlParser = new HtmlParser();
const parseResult = htmlParser.parse(html, 'extractor spec', true);
if (parseResult.errors.length > 1) {
@@ -275,8 +275,8 @@ function getExtractionResult(
}
function extract(
- html: string, implicitTags: string[] = [],
- implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] {
+ html: string, implicitTags: string[] = [], implicitAttrs:
+ {[k: string]: string[]} = {}): [string[], string, string][] {
const messages = getExtractionResult(html, implicitTags, implicitAttrs).messages;
// clang-format off
@@ -287,8 +287,8 @@ function extract(
}
function extractErrors(
- html: string, implicitTags: string[] = [],
- implicitAttrs: {[k: string]: string[]} = {}): any[] {
+ html: string, implicitTags: string[] = [], implicitAttrs:
+ {[k: string]: string[]} = {}): any[] {
const errors = getExtractionResult(html, implicitTags, implicitAttrs).errors;
return errors.map((e): [string, string] => [e.msg, e.span.toString()]);
diff --git a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts
index ff6ab9c3c0..7a5087d1d5 100644
--- a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts
+++ b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts
@@ -6,10 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {HtmlParser} from "@angular/compiler/src/html_parser";
-import * as i18nAst from "@angular/compiler/src/i18n/i18n_ast";
-import {ddescribe, describe, expect, it} from "@angular/core/testing/testing_internal";
-import {extractI18nMessages} from "@angular/compiler/src/i18n/i18n_parser";
+import {HtmlParser} from '@angular/compiler/src/html_parser';
+import {serializeAst} from '@angular/compiler/src/i18n/catalog';
+import {extractI18nMessages} from '@angular/compiler/src/i18n/i18n_parser';
+import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/interpolation_config';
+import {ddescribe, describe, expect, it} from '@angular/core/testing/testing_internal';
+
export function main() {
ddescribe('I18nParser', () => {
@@ -22,15 +24,32 @@ export function main() {
it('should extract from nested elements', () => {
expect(extract('textnested
')).toEqual([
- [['text', 'nested'], 'm', 'd'],
+ [
+ [
+ 'text',
+ 'nested'
+ ],
+ 'm', 'd'
+ ],
]);
});
it('should not create a message for empty elements',
- () => { expect(extract('')).toEqual([]); });
+ () => { expect(extract('')).toEqual([]); });
it('should not create a message for plain elements',
- () => { expect(extract('')).toEqual([]); });
+ () => { expect(extract('')).toEqual([]); });
+
+ it('should suppoprt void elements', () => {
+ expect(extract('')).toEqual([
+ [
+ [
+ ''
+ ],
+ 'm', 'd'
+ ],
+ ]);
+ });
});
describe('attributes', () => {
@@ -42,51 +61,65 @@ export function main() {
it('should extract from attributes in translatable element', () => {
expect(extract('')).toEqual([
- [[''], '', ''],
+ [
+ [
+ ''
+ ],
+ '', ''
+ ],
[['msg'], 'm', 'd'],
]);
});
it('should extract from attributes in translatable block', () => {
- expect(
- extract('
'))
- .toEqual([
- [['msg'], 'm', 'd'],
- [[''], '', ''],
- ]);
+ expect(extract('
'))
+ .toEqual([
+ [['msg'], 'm', 'd'],
+ [
+ [
+ ''
+ ],
+ '', ''
+ ],
+ ]);
});
it('should extract from attributes in translatable ICU', () => {
expect(
- extract(
- '{count, plural, =0 {
}}'))
- .toEqual([
- [['msg'], 'm', 'd'],
- [['{count, plural, =0 {[]}}'], '', ''],
- ]);
+ extract(
+ '{count, plural, =0 {
}}'))
+ .toEqual([
+ [['msg'], 'm', 'd'],
+ [
+ [
+ '{count, plural, =0 {[]}}'
+ ],
+ '', ''
+ ],
+ ]);
});
it('should extract from attributes in non translatable ICU', () => {
expect(extract('{count, plural, =0 {
}}'))
- .toEqual([
- [['msg'], 'm', 'd'],
- ]);
+ .toEqual([
+ [['msg'], 'm', 'd'],
+ ]);
});
it('should not create a message for empty attributes',
- () => { expect(extract('')).toEqual([]); });
+ () => { expect(extract('')).toEqual([]); });
});
describe('interpolation', () => {
it('should replace interpolation with placeholder', () => {
expect(extract('before{{ exp }}after
')).toEqual([
- [['[before, exp , after]'], 'm', 'd'],
+ [['[before, exp , after]'], 'm', 'd'],
]);
});
it('should support named interpolation', () => {
expect(extract('before{{ exp //i18n(ph="teSt") }}after
')).toEqual([
- [['[before, exp //i18n(ph="teSt") , after]'], 'm', 'd'],
+ [['[before, exp //i18n(ph="teSt") , after]'], 'm', 'd'],
]);
})
});
@@ -96,19 +129,23 @@ export function main() {
expect(extract(`message1
message2
message3`))
- .toEqual([
- [['message1'], 'meaning1', 'desc1'],
- [['message2'], 'meaning2', ''],
- [['message3'], '', ''],
- ]);
+ .toEqual([
+ [['message1'], 'meaning1', 'desc1'],
+ [['message2'], 'meaning2', ''],
+ [['message3'], '', ''],
+ ]);
});
it('should extract all siblings', () => {
- expect(
- extract(`texthtmlnested
`))
- .toEqual([
- [[ 'text', 'html, nested'], '', '' ],
- ]);
+ expect(extract(`texthtmlnested
`)).toEqual([
+ [
+ [
+ 'text',
+ 'html, nested'
+ ],
+ '', ''
+ ],
+ ]);
});
});
@@ -121,8 +158,8 @@ export function main() {
it('should extract as ICU + ph when not single child of an element', () => {
expect(extract('b{count, plural, =0 {zero}}a
')).toEqual([
- [[ 'b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'],
- [[ '{count, plural, =0 {[zero]}}' ], '', ''],
+ [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'],
+ [['{count, plural, =0 {[zero]}}'], '', ''],
]);
});
@@ -134,16 +171,23 @@ export function main() {
it('should extract as ICU + ph when not single child of a block', () => {
expect(extract('b{count, plural, =0 {zero}}a')).toEqual([
- [[ '{count, plural, =0 {[zero]}}' ], '', ''],
- [[ 'b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'],
+ [['{count, plural, =0 {[zero]}}'], '', ''],
+ [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'],
]);
});
it('should not extract nested ICU messages', () => {
- expect(extract('b{count, plural, =0 {{sex, gender, =m {m}}}}a
')).toEqual([
- [[ 'b', '{count, plural, =0 {[{sex, gender, =m {[m]}}]}}', 'a'], 'm', 'd'],
- [[ '{count, plural, =0 {[{sex, gender, =m {[m]}}]}}' ], '', ''],
- ]);
+ expect(extract('b{count, plural, =0 {{sex, gender, =m {m}}}}a
'))
+ .toEqual([
+ [
+ [
+ 'b', '{count, plural, =0 {[{sex, gender, =m {[m]}}]}}',
+ 'a'
+ ],
+ 'm', 'd'
+ ],
+ [['{count, plural, =0 {[{sex, gender, =m {[m]}}]}}'], '', ''],
+ ]);
});
});
@@ -158,57 +202,71 @@ export function main() {
describe('implicit attributes', () => {
it('should extract implicit attributes', () => {
expect(extract('bolditalic', [], {'b': ['title']}))
- .toEqual([
- [['bb'], '', ''],
- ]);
+ .toEqual([
+ [['bb'], '', ''],
+ ]);
});
});
+
+ describe('placeholders', () => {
+ it('should reuse the same placeholder name for tags', () => {
+ expect(extract('')).toEqual([
+ [
+ [
+ 'one',
+ 'two',
+ 'three',
+ ],
+ 'm', 'd'
+ ],
+ ]);
+ });
+
+ it('should reuse the same placeholder name for interpolations', () => {
+ expect(extract('{{ a }}{{ a }}{{ b }}
')).toEqual([
+ [
+ [
+ '[ a , a , b ]'
+ ],
+ 'm', 'd'
+ ],
+ ]);
+ });
+
+ it('should reuse the same placeholder name for icu messages', () => {
+ expect(
+ extract(
+ '{count, plural, =0 {0}}{count, plural, =0 {0}}{count, plural, =1 {1}}
'))
+ .toEqual([
+ [
+ [
+ '{count, plural, =0 {[0]}}',
+ '{count, plural, =0 {[0]}}',
+ '{count, plural, =1 {[1]}}',
+ ],
+ 'm', 'd'
+ ],
+ [['{count, plural, =0 {[0]}}'], '', ''],
+ [['{count, plural, =0 {[0]}}'], '', ''],
+ [['{count, plural, =1 {[1]}}'], '', ''],
+ ]);
+ });
+
+ });
});
}
-class _SerializerVisitor implements i18nAst.Visitor {
- visitText(text:i18nAst.Text, context:any):any {
- return text.value;
- }
-
- visitContainer(container:i18nAst.Container, context:any):any {
- return `[${container.children.map(child => child.visit(this)).join(', ')}]`
- }
-
- visitIcu(icu:i18nAst.Icu, context:any):any {
- let strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`);
- return `{${icu.expression}, ${icu.type}, ${strCases.join(', ')}}`
- }
-
- visitTagPlaceholder(ph:i18nAst.TagPlaceholder, context:any):any {
- return `${ph.children.map(child => child.visit(this)).join(', ')}`;
- }
-
- visitPlaceholder(ph:i18nAst.Placeholder, context:any):any {
- return `${ph.value}`;
- }
-
- visitIcuPlaceholder(ph:i18nAst.IcuPlaceholder, context?:any):any {
- return `${ph.value.visit(this)}`
- }
-}
-
-const serializerVisitor = new _SerializerVisitor();
-
-export function serializeAst(ast: i18nAst.I18nNode[]): string[] {
- return ast.map(a => a.visit(serializerVisitor, null));
-}
-
function extract(
- html: string, implicitTags: string[] = [],
- implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] {
+ html: string, implicitTags: string[] = [],
+ implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] {
const htmlParser = new HtmlParser();
const parseResult = htmlParser.parse(html, 'extractor spec', true);
if (parseResult.errors.length > 1) {
throw Error(`unexpected parse errors: ${parseResult.errors.join('\n')}`);
}
- const messages = extractI18nMessages(parseResult.rootNodes, implicitTags, implicitAttrs);
+ const messages = extractI18nMessages(
+ parseResult.rootNodes, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs);
// clang-format off
// https://github.com/angular/clang-format/issues/35
@@ -216,5 +274,3 @@ function extract(
message => [serializeAst(message.nodes), message.meaning, message.description, ]) as [string[], string, string][];
// clang-format on
}
-
-
diff --git a/modules/@angular/compiler/test/i18n/serializers/util_spec.ts b/modules/@angular/compiler/test/i18n/serializers/util_spec.ts
new file mode 100644
index 0000000000..b539671c40
--- /dev/null
+++ b/modules/@angular/compiler/test/i18n/serializers/util_spec.ts
@@ -0,0 +1,94 @@
+/**
+ * @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 {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
+
+import {PlaceholderRegistry} from '../../../src/i18n/serializers/util';
+
+export function main(): void {
+ ddescribe('PlaceholderRegistry', () => {
+ let reg: PlaceholderRegistry;
+
+ beforeEach(() => { reg = new PlaceholderRegistry(); });
+
+ describe('tag placeholder', () => {
+ it('should generate names for well known tags', () => {
+ expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH');
+ expect(reg.getCloseTagPlaceholderName('p')).toEqual('CLOSE_PARAGRAPH');
+ });
+
+ it('should generate names for custom tags', () => {
+ expect(reg.getStartTagPlaceholderName('my-cmp', {}, false)).toEqual('START_TAG_MY-CMP');
+ expect(reg.getCloseTagPlaceholderName('my-cmp')).toEqual('CLOSE_TAG_MY-CMP');
+ });
+
+ it('should generate the same name for the same tag', () => {
+ expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH');
+ });
+
+ it('should be case insensitive for tag name', () => {
+ expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('P', {}, false)).toEqual('START_PARAGRAPH');
+ expect(reg.getCloseTagPlaceholderName('p')).toEqual('CLOSE_PARAGRAPH');
+ expect(reg.getCloseTagPlaceholderName('P')).toEqual('CLOSE_PARAGRAPH');
+ });
+
+ it('should generate the same name for the same tag with the same attributes', () => {
+ expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false))
+ .toEqual('START_PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false))
+ .toEqual('START_PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {bar: 'b', foo: 'a'}, false))
+ .toEqual('START_PARAGRAPH');
+ });
+
+ it('should generate different names for the same tag with different attributes', () => {
+ expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false))
+ .toEqual('START_PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {foo: 'a'}, false)).toEqual('START_PARAGRAPH_1');
+ });
+
+ it('should be case sensitive for attributes', () => {
+ expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false))
+ .toEqual('START_PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {fOo: 'a', bar: 'b'}, false))
+ .toEqual('START_PARAGRAPH_1');
+ expect(reg.getStartTagPlaceholderName('p', {fOo: 'a', bAr: 'b'}, false))
+ .toEqual('START_PARAGRAPH_2');
+ });
+
+ it('should support void tags', () => {
+ expect(reg.getStartTagPlaceholderName('p', {}, true)).toEqual('PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {}, true)).toEqual('PARAGRAPH');
+ expect(reg.getStartTagPlaceholderName('p', {other: 'true'}, true)).toEqual('PARAGRAPH_1');
+ });
+ });
+
+ describe('arbitrary placeholders', () => {
+ it('should generate the same name given the same name and content', () => {
+ expect(reg.getPlaceholderName('name', 'content')).toEqual('NAME');
+ expect(reg.getPlaceholderName('name', 'content')).toEqual('NAME');
+ });
+
+ it('should generate a different name given different content', () => {
+ expect(reg.getPlaceholderName('name', 'content1')).toEqual('NAME');
+ expect(reg.getPlaceholderName('name', 'content2')).toEqual('NAME_1');
+ expect(reg.getPlaceholderName('name', 'content3')).toEqual('NAME_2');
+ });
+
+ it('should generate a different name given different names', () => {
+ expect(reg.getPlaceholderName('name1', 'content')).toEqual('NAME1');
+ expect(reg.getPlaceholderName('name2', 'content')).toEqual('NAME2');
+ });
+
+ });
+ });
+}
\ No newline at end of file
diff --git a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts
new file mode 100644
index 0000000000..c0c2a3d7e5
--- /dev/null
+++ b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 {HtmlParser} from '@angular/compiler/src/html_parser';
+import {Catalog} from '@angular/compiler/src/i18n/catalog';
+import {XmbSerializer} from '@angular/compiler/src/i18n/serializers/xmb';
+import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/interpolation_config';
+import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
+
+
+export function main(): void {
+ ddescribe('XMB serializer', () => {
+ const HTML = `
+not translatable
+translatable element with placeholders {{ interpolation}}
+{ count, plural, =0 {test
}}
+foo
+{ count, plural, =0 { { sex, gender, other {
deeply nested
}} }}
`;
+
+ const XMB = ` xml version="1.0" encoding="UTF-8" ?>
+
+ translatable element <b>with placeholders</b>
+ { count, plural, =0 {<p>test</p>}}
+ foo
+ { count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }}
+`;
+
+ it('should write a valid xmb file', () => { expect(toXmb(HTML)).toEqual(XMB); });
+
+ it('should throw when trying to load an xmb file', () => {
+ expect(() => {
+ const serializer = new XmbSerializer();
+ serializer.load(XMB);
+ }).toThrow();
+ });
+ });
+}
+
+function toXmb(html: string): string {
+ let catalog = new Catalog(new HtmlParser, [], {});
+ const serializer = new XmbSerializer();
+
+ catalog.updateFromTemplate(html, '', DEFAULT_INTERPOLATION_CONFIG);
+
+ return catalog.write(serializer);
+}
+
+// xml version="1.0" encoding="UTF-8" ?>translatable
+// element <b>with placeholders</b> { count, plural, =0 {<p>test</p>}}foo{ count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }}
+// xml version="1.0" encoding="UTF-8" ?>translatable
+// element <b>with placeholders</b> { count, plural, =0 {<p>test</p>}}{ count,
+// plural, =0 {{ sex, gender, other {<p>deeply
+// nested</p>}} }}foo
\ No newline at end of file
diff --git a/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts
new file mode 100644
index 0000000000..1edc8d3f44
--- /dev/null
+++ b/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts
@@ -0,0 +1,49 @@
+/**
+ * @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 {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
+
+import * as xml from '../../../src/i18n/serializers/xml_helper';
+
+export function main(): void {
+ ddescribe('XML helper', () => {
+ it('should serialize XML declaration', () => {
+ expect(xml.serialize([new xml.Declaration({version: '1.0'})]))
+ .toEqual(' xml version="1.0" ?>');
+ });
+
+ it('should serialize text node',
+ () => { expect(xml.serialize([new xml.Text('foo bar')])).toEqual('foo bar'); });
+
+ it('should escape text nodes',
+ () => { expect(xml.serialize([new xml.Text('<>')])).toEqual('<>'); });
+
+ it('should serialize xml nodes without children', () => {
+ expect(xml.serialize([new xml.Tag('el', {foo: 'bar'}, [])])).toEqual('');
+ });
+
+ it('should serialize xml nodes with children', () => {
+ expect(xml.serialize([
+ new xml.Tag('parent', {}, [new xml.Tag('child', {}, [new xml.Text('content')])])
+ ])).toEqual('content');
+ });
+
+ it('should serialize node lists', () => {
+ expect(xml.serialize([
+ new xml.Tag('el', {order: '0'}, []),
+ new xml.Tag('el', {order: '1'}, []),
+ ])).toEqual('');
+ });
+
+ it('should escape attribute values', () => {
+ expect(xml.serialize([new xml.Tag('el', {foo: '<">'}, [])]))
+ .toEqual('');
+ });
+
+ });
+}
\ No newline at end of file