From 48f230a95119d24d583988247ba14e5eae855561 Mon Sep 17 00:00:00 2001
From: Victor Berchet
Date: Fri, 8 Jul 2016 16:46:49 -0700
Subject: [PATCH] feat(I18nAst): introduce an intermediate AST
---
.../compiler/src/expression_parser/parser.ts | 6 +-
modules/@angular/compiler/src/html_parser.ts | 10 +-
.../@angular/compiler/src/i18n/extractor.ts | 4 +-
.../@angular/compiler/src/i18n/i18n_ast.ts | 74 ++++++
.../@angular/compiler/src/i18n/i18n_parser.ts | 123 ++++++++++
.../src/i18n/serializers/serializer.ts | 0
.../compiler/src/i18n/serializers/xmb.ts | 0
modules/@angular/compiler/src/i18n/shared.ts | 6 +
.../test/expression_parser/parser_spec.ts | 2 +-
.../compiler/test/html_ast_serializer_spec.ts | 14 +-
.../compiler/test/i18n/extractor_spec.ts | 72 +++---
.../compiler/test/i18n/i18n_parser_spec.ts | 220 ++++++++++++++++++
12 files changed, 477 insertions(+), 54 deletions(-)
create mode 100644 modules/@angular/compiler/src/i18n/i18n_ast.ts
create mode 100644 modules/@angular/compiler/src/i18n/i18n_parser.ts
create mode 100644 modules/@angular/compiler/src/i18n/serializers/serializer.ts
create mode 100644 modules/@angular/compiler/src/i18n/serializers/xmb.ts
create mode 100644 modules/@angular/compiler/test/i18n/i18n_parser_spec.ts
diff --git a/modules/@angular/compiler/src/expression_parser/parser.ts b/modules/@angular/compiler/src/expression_parser/parser.ts
index afdafac462..930330e4a7 100644
--- a/modules/@angular/compiler/src/expression_parser/parser.ts
+++ b/modules/@angular/compiler/src/expression_parser/parser.ts
@@ -128,10 +128,10 @@ export class Parser {
if (parts.length <= 1) {
return null;
}
- var strings: string[] = [];
- var expressions: string[] = [];
+ const strings: string[] = [];
+ const expressions: string[] = [];
- for (var i = 0; i < parts.length; i++) {
+ for (let i = 0; i < parts.length; i++) {
var part: string = parts[i];
if (i % 2 === 0) {
// fixed string
diff --git a/modules/@angular/compiler/src/html_parser.ts b/modules/@angular/compiler/src/html_parser.ts
index 7fc4a0ff92..83705dd052 100644
--- a/modules/@angular/compiler/src/html_parser.ts
+++ b/modules/@angular/compiler/src/html_parser.ts
@@ -125,7 +125,7 @@ class TreeBuilder {
// read the final }
if (this.peek.type !== HtmlTokenType.EXPANSION_FORM_END) {
this.errors.push(
- HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid expansion form. Missing '}'.`));
+ HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid ICU message. Missing '}'.`));
return;
}
this._advance();
@@ -141,7 +141,7 @@ class TreeBuilder {
// read {
if (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_START) {
this.errors.push(HtmlTreeError.create(
- null, this.peek.sourceSpan, `Invalid expansion form. Missing '{'.,`));
+ null, this.peek.sourceSpan, `Invalid ICU message. Missing '{'.`));
return null;
}
@@ -184,7 +184,7 @@ class TreeBuilder {
} else {
this.errors.push(
- HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
+ HtmlTreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`));
return null;
}
}
@@ -194,14 +194,14 @@ class TreeBuilder {
expansionFormStack.pop();
} else {
this.errors.push(
- HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
+ HtmlTreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`));
return null;
}
}
if (this.peek.type === HtmlTokenType.EOF) {
this.errors.push(
- HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
+ HtmlTreeError.create(null, start.sourceSpan, `Invalid ICU message. Missing '}'.`));
return null;
}
diff --git a/modules/@angular/compiler/src/i18n/extractor.ts b/modules/@angular/compiler/src/i18n/extractor.ts
index 4f2908826a..3d43250be7 100644
--- a/modules/@angular/compiler/src/i18n/extractor.ts
+++ b/modules/@angular/compiler/src/i18n/extractor.ts
@@ -243,7 +243,7 @@ class _ExtractVisitor implements HtmlAstVisitor {
if (significantChildren == 1) {
for (let i = startIndex; i < messages.length; i++) {
- let ast = messages[i].ast;
+ let ast = messages[i].nodes;
if (!(ast.length == 1 && ast[0] instanceof HtmlAttrAst)) {
messages.splice(i, 1);
break;
@@ -260,5 +260,5 @@ class _ExtractVisitor implements HtmlAstVisitor {
}
export class AstMessage {
- constructor(public ast: HtmlAst[], public meaning: string, public description: string) {}
+ constructor(public nodes: HtmlAst[], public meaning: string, public description: string) {}
}
diff --git a/modules/@angular/compiler/src/i18n/i18n_ast.ts b/modules/@angular/compiler/src/i18n/i18n_ast.ts
new file mode 100644
index 0000000000..313388ac9b
--- /dev/null
+++ b/modules/@angular/compiler/src/i18n/i18n_ast.ts
@@ -0,0 +1,74 @@
+/**
+ * @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 {ParseSourceSpan} from "../parse_util";
+
+export interface I18nNode {
+ visit(visitor: Visitor, context?: any): any;
+}
+
+export class Text implements I18nNode {
+ constructor(public value: string, public sourceSpan: ParseSourceSpan) {}
+
+ visit(visitor: Visitor, context?: any): any {
+ return visitor.visitText(this, context);
+ }
+}
+
+export class Container implements I18nNode {
+ constructor(public children: I18nNode[], public sourceSpan: ParseSourceSpan) {}
+
+ 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) {}
+
+ 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) {}
+
+ 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);
+ }
+}
+
+export class IcuPlaceholder {
+ constructor(public value: Icu, public name: string = '', public sourceSpan: ParseSourceSpan) {}
+
+ visit(visitor: Visitor, context?: any): any {
+ return visitor.visitIcuPlaceholder(this, context);
+ }
+}
+
+export interface Visitor {
+ visitText(text: Text, context?: any): any;
+ visitContainer(container: Container, context?: any): any;
+ visitIcu(icu: Icu, context?: any): any;
+ visitTagPlaceholder(ph: TagPlaceholder, context?: any): any;
+ 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
new file mode 100644
index 0000000000..5294165d56
--- /dev/null
+++ b/modules/@angular/compiler/src/i18n/i18n_parser.ts
@@ -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 { 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";
+
+export class Message {
+ constructor(public nodes: i18nAst.I18nNode[], public meaning: string, public description: string) {}
+}
+
+// TODO: should get the interpolation config
+export function extractI18nMessages(
+ sourceAst: HtmlAst[], implicitTags: string[],
+ implicitAttrs: {[k: string]: string[]}): Message[] {
+ const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs);
+
+ if (extractionResult.errors.length) {
+ return[];
+ }
+
+ const visitor = new _I18nVisitor(new ExpressionParser(new ExpressionLexer()));
+
+ return extractionResult.messages.map((msg): Message => {
+ return new Message(visitor.convertToI18nAst(msg.nodes), msg.meaning, msg.description);
+ });
+}
+
+class _I18nVisitor implements hAst.HtmlAstVisitor {
+ private _isIcu: boolean;
+ private _icuDepth: number;
+
+ constructor(private _expressionParser: ExpressionParser) {}
+
+ visitElement(el:hAst.HtmlElementAst, context:any):i18nAst.I18nNode {
+ 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);
+ }
+
+ visitAttr(attr:hAst.HtmlAttrAst, context:any):i18nAst.I18nNode {
+ return this._visitTextWithInterpolation(attr.value, attr.sourceSpan);
+ }
+
+ visitText(text:hAst.HtmlTextAst, context:any):i18nAst.I18nNode {
+ return this._visitTextWithInterpolation(text.value, text.sourceSpan);
+ }
+
+ visitComment(comment:hAst.HtmlCommentAst, context:any):i18nAst.I18nNode {
+ return null;
+ }
+
+ visitExpansion(icu:hAst.HtmlExpansionAst, context:any):i18nAst.I18nNode {
+ this._icuDepth++;
+ const i18nIcuCases: {[k: string]: i18nAst.I18nNode} = {};
+ 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);
+ });
+ this._icuDepth--;
+
+ if (this._isIcu || this._icuDepth > 0) {
+ // If the message (vs a part of the message) is an ICU message return its
+ return i18nIcu;
+ }
+
+ // else returns a placeholder
+ return new i18nAst.IcuPlaceholder(i18nIcu, 'icu', icu.sourceSpan);
+ }
+
+ visitExpansionCase(icuCase:hAst.HtmlExpansionCaseAst, context:any):i18nAst.I18nNode {
+ throw new Error('Unreachable code');
+ }
+
+ public convertToI18nAst(htmlAsts: hAst.HtmlAst[]): i18nAst.I18nNode[] {
+ this._isIcu = htmlAsts.length == 1 && htmlAsts[0] instanceof hAst.HtmlExpansionAst;
+ this._icuDepth = 0;
+ return hAst.htmlVisitAll(this, htmlAsts, {});
+ }
+
+ private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18nAst.I18nNode {
+ const splitInterpolation = this._expressionParser.splitInterpolation(text, sourceSpan.start.toString());
+
+ if (!splitInterpolation) {
+ // No expression, return a single text
+ return new i18nAst.Text(text, sourceSpan);
+ }
+
+ // Return a group of text + expressions
+ const nodes: i18nAst.I18nNode[] = [];
+ 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)
+ )
+ }
+
+ // The last index contains no expression
+ const lastStringIdx = splitInterpolation.strings.length - 1;
+ 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
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/modules/@angular/compiler/src/i18n/serializers/xmb.ts b/modules/@angular/compiler/src/i18n/serializers/xmb.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts
index d4036f8002..2bc53f7ac3 100644
--- a/modules/@angular/compiler/src/i18n/shared.ts
+++ b/modules/@angular/compiler/src/i18n/shared.ts
@@ -174,6 +174,12 @@ export function extractPhNameFromInterpolation(input: string, index: number): st
return customPhMatch.length > 1 ? customPhMatch[1] : `INTERPOLATION_${index}`;
}
+export function extractPlaceholderName(input: string): string {
+ const matches = StringWrapper.split(input, _CUSTOM_PH_EXP);
+ return matches[1] || `interpolation`;
+}
+
+
/**
* Return a unique placeholder name based on the given name
*/
diff --git a/modules/@angular/compiler/test/expression_parser/parser_spec.ts b/modules/@angular/compiler/test/expression_parser/parser_spec.ts
index 2582dbc434..b88e88ee04 100644
--- a/modules/@angular/compiler/test/expression_parser/parser_spec.ts
+++ b/modules/@angular/compiler/test/expression_parser/parser_spec.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {AST, ASTWithSource, BindingPipe, Interpolation, LiteralPrimitive, ParserError, TemplateBinding} from '@angular/compiler/src/expression_parser/ast';
+import {ASTWithSource, BindingPipe, Interpolation, ParserError, TemplateBinding} from '@angular/compiler/src/expression_parser/ast';
import {Lexer} from '@angular/compiler/src/expression_parser/lexer';
import {Parser, TemplateBindingParseResult} from '@angular/compiler/src/expression_parser/parser';
import {expect} from '@angular/platform-browser/testing/matchers';
diff --git a/modules/@angular/compiler/test/html_ast_serializer_spec.ts b/modules/@angular/compiler/test/html_ast_serializer_spec.ts
index 4bb7a91083..d3fee4b0d1 100644
--- a/modules/@angular/compiler/test/html_ast_serializer_spec.ts
+++ b/modules/@angular/compiler/test/html_ast_serializer_spec.ts
@@ -11,31 +11,31 @@ export function main() {
it('should support element', () => {
const html = '';
const ast = parser.parse(html, 'url');
- expect(serializeHtmlAst(ast.rootNodes)).toEqual([html]);
+ expect(serializeAst(ast.rootNodes)).toEqual([html]);
});
it('should support attributes', () => {
const html = '';
const ast = parser.parse(html, 'url');
- expect(serializeHtmlAst(ast.rootNodes)).toEqual([html]);
+ expect(serializeAst(ast.rootNodes)).toEqual([html]);
});
it('should support text', () => {
const html = 'some text';
const ast = parser.parse(html, 'url');
- expect(serializeHtmlAst(ast.rootNodes)).toEqual([html]);
+ expect(serializeAst(ast.rootNodes)).toEqual([html]);
});
it('should support expansion', () => {
const html = '{number, plural, =0 {none} =1 {one} other {many}}';
const ast = parser.parse(html, 'url', true);
- expect(serializeHtmlAst(ast.rootNodes)).toEqual([html]);
+ expect(serializeAst(ast.rootNodes)).toEqual([html]);
});
it('should support comment', () => {
const html = '';
const ast = parser.parse(html, 'url', true);
- expect(serializeHtmlAst(ast.rootNodes)).toEqual([html]);
+ expect(serializeAst(ast.rootNodes)).toEqual([html]);
});
it('should support nesting', () => {
@@ -47,7 +47,7 @@ export function main() {
`;
const ast = parser.parse(html, 'url', true);
- expect(serializeHtmlAst(ast.rootNodes)).toEqual([html]);
+ expect(serializeAst(ast.rootNodes)).toEqual([html]);
});
});
}
@@ -81,6 +81,6 @@ class _SerializerVisitor implements HtmlAstVisitor {
const serializerVisitor = new _SerializerVisitor();
-export function serializeHtmlAst(ast: HtmlAst[]): string[] {
+export function serializeAst(ast: HtmlAst[]): string[] {
return ast.map(a => a.visit(serializerVisitor, null));
}
diff --git a/modules/@angular/compiler/test/i18n/extractor_spec.ts b/modules/@angular/compiler/test/i18n/extractor_spec.ts
index 55b196bce9..66c4b91d78 100644
--- a/modules/@angular/compiler/test/i18n/extractor_spec.ts
+++ b/modules/@angular/compiler/test/i18n/extractor_spec.ts
@@ -10,44 +10,12 @@ import {HtmlParser} from '@angular/compiler/src/html_parser';
import {ExtractionResult, extractAstMessages} from '@angular/compiler/src/i18n/extractor';
import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
-import {serializeHtmlAst} from '../html_ast_serializer_spec'
+import {serializeAst} from '../html_ast_serializer_spec'
export function main() {
ddescribe(
'MessageExtractor',
() => {
- function getExtractionResult(
- 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) {
- throw Error(`unexpected parse errors: ${parseResult.errors.join('\n')}`);
- }
-
- return extractAstMessages(parseResult.rootNodes, implicitTags, implicitAttrs);
- }
-
- function extract(
- html: string, implicitTags: string[] = [],
- implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] {
- const messages = getExtractionResult(html, implicitTags, implicitAttrs).messages;
-
- // clang-format off
- // https://github.com/angular/clang-format/issues/35
- return messages.map(
- message => [serializeHtmlAst(message.ast), message.meaning, message.description, ]) as [string[], string, string][];
- // clang-format on
- }
-
- function extractErrors(
- 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()]);
- }
-
describe('elements', () => {
it('should extract from elements', () => {
expect(extract('textnested
')).toEqual([
@@ -71,7 +39,7 @@ export function main() {
]);
});
- it('should extract all siblings', () => {
+ it('should extract siblings', () => {
expect(
extract(
`texthtmlnested
{count, plural, =0 {html}}{{interp}}`))
@@ -181,8 +149,8 @@ export function main() {
() => { expect(extract('')).toEqual([]); });
});
- describe('implicit tags', () => {
- it('should extract from implicit tags', () => {
+ describe('implicit elements', () => {
+ it('should extract from implicit elements', () => {
expect(extract('bolditalic', ['b'])).toEqual([
[['bold'], '', ''],
]);
@@ -293,3 +261,35 @@ export function main() {
});
});
}
+
+function getExtractionResult(
+ 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) {
+ throw Error(`unexpected parse errors: ${parseResult.errors.join('\n')}`);
+ }
+
+ return extractAstMessages(parseResult.rootNodes, implicitTags, implicitAttrs);
+}
+
+function extract(
+ html: string, implicitTags: string[] = [],
+ implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] {
+ const messages = getExtractionResult(html, implicitTags, implicitAttrs).messages;
+
+ // clang-format off
+ // https://github.com/angular/clang-format/issues/35
+ return messages.map(
+ message => [serializeAst(message.nodes), message.meaning, message.description, ]) as [string[], string, string][];
+ // clang-format on
+}
+
+function extractErrors(
+ 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
new file mode 100644
index 0000000000..ff6ab9c3c0
--- /dev/null
+++ b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts
@@ -0,0 +1,220 @@
+/**
+ * @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 * 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";
+export function main() {
+ ddescribe('I18nParser', () => {
+
+ describe('elements', () => {
+ it('should extract from elements', () => {
+ expect(extract('text
')).toEqual([
+ [['text'], 'm', 'd'],
+ ]);
+ });
+
+ it('should extract from nested elements', () => {
+ expect(extract('textnested
')).toEqual([
+ [['text', 'nested'], 'm', 'd'],
+ ]);
+ });
+
+ it('should not create a message for empty elements',
+ () => { expect(extract('')).toEqual([]); });
+
+ it('should not create a message for plain elements',
+ () => { expect(extract('')).toEqual([]); });
+ });
+
+ describe('attributes', () => {
+ it('should extract from attributes outside of translatable section', () => {
+ expect(extract('')).toEqual([
+ [['msg'], 'm', 'd'],
+ ]);
+ });
+
+ 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'],
+ [[''], '', ''],
+ ]);
+ });
+
+ it('should extract from attributes in translatable ICU', () => {
+ expect(
+ 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'],
+ ]);
+ });
+
+ it('should not create a message for empty attributes',
+ () => { expect(extract('')).toEqual([]); });
+ });
+
+ describe('interpolation', () => {
+ it('should replace interpolation with placeholder', () => {
+ expect(extract('before{{ exp }}after
')).toEqual([
+ [['[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'],
+ ]);
+ })
+ });
+
+ describe('blocks', () => {
+ it('should extract from blocks', () => {
+ expect(extract(`message1
+ message2
+ message3`))
+ .toEqual([
+ [['message1'], 'meaning1', 'desc1'],
+ [['message2'], 'meaning2', ''],
+ [['message3'], '', ''],
+ ]);
+ });
+
+ it('should extract all siblings', () => {
+ expect(
+ extract(`texthtmlnested
`))
+ .toEqual([
+ [[ 'text', 'html, nested'], '', '' ],
+ ]);
+ });
+ });
+
+ describe('ICU messages', () => {
+ it('should extract as ICU when single child of an element', () => {
+ expect(extract('{count, plural, =0 {zero}}
')).toEqual([
+ [['{count, plural, =0 {[zero]}}'], 'm', 'd'],
+ ]);
+ });
+
+ 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]}}' ], '', ''],
+ ]);
+ });
+
+ it('should extract as ICU when single child of a block', () => {
+ expect(extract('{count, plural, =0 {zero}}')).toEqual([
+ [['{count, plural, =0 {[zero]}}'], 'm', 'd'],
+ ]);
+ });
+
+ 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'],
+ ]);
+ });
+
+ 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]}}]}}' ], '', ''],
+ ]);
+ });
+ });
+
+ describe('implicit elements', () => {
+ it('should extract from implicit elements', () => {
+ expect(extract('bolditalic', ['b'])).toEqual([
+ [['bold'], '', ''],
+ ]);
+ });
+ });
+
+ describe('implicit attributes', () => {
+ it('should extract implicit attributes', () => {
+ expect(extract('bolditalic', [], {'b': ['title']}))
+ .toEqual([
+ [['bb'], '', ''],
+ ]);
+ });
+ });
+ });
+}
+
+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][] {
+ 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);
+
+ // clang-format off
+ // https://github.com/angular/clang-format/issues/35
+ return messages.map(
+ message => [serializeAst(message.nodes), message.meaning, message.description, ]) as [string[], string, string][];
+ // clang-format on
+}
+
+