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( `text

htmlnested

{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(`text

htmlnested

`)) + .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 +} + +