From 28e8b2faabfff910909b857e048f45c4c8424c46 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 30 Jun 2016 18:37:15 -0700 Subject: [PATCH] feat(ICU): extract ICU messages --- .../compiler/src/i18n/i18n_html_parser.ts | 5 +- .../compiler/src/i18n/message_extractor.ts | 2 +- modules/@angular/compiler/src/i18n/shared.ts | 47 ++-- .../test/i18n/i18n_html_parser_spec.ts | 229 ++++++++--------- .../test/i18n/message_extractor_spec.ts | 230 ++++++++++-------- 5 files changed, 287 insertions(+), 226 deletions(-) diff --git a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts index 83d787e09f..c27e3eddd3 100644 --- a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts @@ -117,7 +117,10 @@ export class I18nHtmlParser implements HtmlParser { // Look for the translated message and merge it back to the tree private _mergeI18Part(part: Part): HtmlAst[] { - let message = part.createMessage(this._expressionParser, this._interpolationConfig); + let messages = part.createMessages(this._expressionParser, this._interpolationConfig); + // TODO - dirty smoke fix + let message = messages[0]; + let messageId = id(message); if (!StringMapWrapper.contains(this._messages, messageId)) { diff --git a/modules/@angular/compiler/src/i18n/message_extractor.ts b/modules/@angular/compiler/src/i18n/message_extractor.ts index 6509199597..79cb204673 100644 --- a/modules/@angular/compiler/src/i18n/message_extractor.ts +++ b/modules/@angular/compiler/src/i18n/message_extractor.ts @@ -123,7 +123,7 @@ export class MessageExtractor { private _extractMessagesFromPart(part: Part, interpolationConfig: InterpolationConfig): void { if (part.hasI18n) { - this._messages.push(part.createMessage(this._expressionParser, interpolationConfig)); + this._messages.push(...part.createMessages(this._expressionParser, interpolationConfig)); this._recurseToExtractMessagesFromAttributes(part.children, interpolationConfig); } else { this._recurse(part.children, interpolationConfig); diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts index b75e7334c0..89c80791df 100644 --- a/modules/@angular/compiler/src/i18n/shared.ts +++ b/modules/@angular/compiler/src/i18n/shared.ts @@ -74,10 +74,12 @@ export class Part { this.children[0].sourceSpan.start, this.children[this.children.length - 1].sourceSpan.end); } - createMessage(parser: ExpressionParser, interpolationConfig: InterpolationConfig): Message { - return new Message( - stringifyNodes(this.children, parser, interpolationConfig), meaning(this.i18n), - description(this.i18n)); + createMessages(parser: ExpressionParser, interpolationConfig: InterpolationConfig): Message[] { + let {message, icuMessages} = stringifyNodes(this.children, parser, interpolationConfig); + return [ + new Message(message, meaning(this.i18n), description(this.i18n)), + ...icuMessages.map(icu => new Message(icu, null)) + ]; } } @@ -197,28 +199,33 @@ export function dedupePhName(usedNames: Map, name: string): stri */ export function stringifyNodes( nodes: HtmlAst[], expressionParser: ExpressionParser, - interpolationConfig: InterpolationConfig): string { + interpolationConfig: InterpolationConfig): {message: string, icuMessages: string[]} { const visitor = new _StringifyVisitor(expressionParser, interpolationConfig); - return htmlVisitAll(visitor, nodes).join(''); + const icuMessages: string[] = []; + const message = htmlVisitAll(visitor, nodes, icuMessages).join(''); + return {message, icuMessages}; } class _StringifyVisitor implements HtmlAstVisitor { private _index: number = 0; + private _nestedExpansion = 0; + constructor( - private _parser: ExpressionParser, private _interpolationConfig: InterpolationConfig) {} + private _expressionParser: ExpressionParser, + private _interpolationConfig: InterpolationConfig) {} visitElement(ast: HtmlElementAst, context: any): any { - let name = this._index++; - let children = this._join(htmlVisitAll(this, ast.children), ''); - return `${children}`; + const index = this._index++; + const children = this._join(htmlVisitAll(this, ast.children), ''); + return `${children}`; } visitAttr(ast: HtmlAttrAst, context: any): any { return null; } visitText(ast: HtmlTextAst, context: any): any { - let index = this._index++; - let noInterpolation = - removeInterpolation(ast.value, ast.sourceSpan, this._parser, this._interpolationConfig); + const index = this._index++; + const noInterpolation = removeInterpolation( + ast.value, ast.sourceSpan, this._expressionParser, this._interpolationConfig); if (noInterpolation != ast.value) { return `${noInterpolation}`; } @@ -227,9 +234,19 @@ class _StringifyVisitor implements HtmlAstVisitor { visitComment(ast: HtmlCommentAst, context: any): any { return ''; } - visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; } + visitExpansion(ast: HtmlExpansionAst, context: any): any { + const index = this._index++; + this._nestedExpansion++; + const content = `{${ast.switchValue}, ${ast.type}${htmlVisitAll(this, ast.cases).join('')}}`; + this._nestedExpansion--; - visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; } + return this._nestedExpansion == 0 ? `${content}` : content; + } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { + const expStr = htmlVisitAll(this, ast.expression).join(''); + return ` ${ast.value} {${expStr}}`; + } private _join(strs: string[], str: string): string { return strs.filter(s => s.length > 0).join(str); diff --git a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts index 98fd9da0bd..1649295c29 100644 --- a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts +++ b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts @@ -46,131 +46,134 @@ export function main() { ]); }); - it('should replace attributes', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('some message', 'meaning', null))] = 'another message'; + describe('interpolation', () => { + it('should handle interpolation', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message( + ' and ', null, null))] = + ' or '; - expect( - humanizeDom(parse( - '
', translations))) - .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'another message']]); + expect(humanizeDom(parse('
', translations))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{{b}} or {{a}}']]); + }); + + it('should handle interpolation with config', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message( + ' and ', null, null))] = + ' or '; + + expect(humanizeDom(parse( + '
', translations, [], {}, + InterpolationConfig.fromArray(['{%', '%}'])))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlAttrAst, 'value', '{%b%} or {%a%}'], + ]); + }); + + it('should handle interpolation with custom placeholder names', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message(' and ', null, null))] = + ' or '; + + expect( + humanizeDom(parse( + `
`, + translations))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlAttrAst, 'value', '{{b //i18n(ph="SECOND")}} or {{a //i18n(ph="FIRST")}}'] + ]); + }); + + it('should handle interpolation with duplicate placeholder names', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message(' and ', null, null))] = + ' or '; + + expect( + humanizeDom(parse( + `
`, + translations))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlAttrAst, 'value', '{{b //i18n(ph="FIRST")}} or {{a //i18n(ph="FIRST")}}'] + ]); + }); + + it('should support interpolation', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message( + 'ab', + null, null))] = + 'BA'; + expect(humanizeDom(parse('
ab{{i}}
', translations))).toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'b', 1], + [HtmlTextAst, '{{i}}B', 2], + [HtmlElementAst, 'a', 1], + [HtmlTextAst, 'A', 2], + ]); + }); }); - it('should replace elements with the i18n attr', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message', 'meaning', null))] = 'another message'; + describe('html', () => { + it('should handle nested html', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('ab', null, null))] = + 'BA'; - expect(humanizeDom(parse('
message
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlTextAst, 'another message', 1] - ]); - }); + expect(humanizeDom(parse('
ab
', translations))).toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'b', 1], + [HtmlTextAst, 'B', 2], + [HtmlElementAst, 'a', 1], + [HtmlTextAst, 'A', 2], + ]); + }); - it('should handle interpolation', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message( - ' and ', null, null))] = - ' or '; + it('should i18n attributes of placeholder elements', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('a', null, null))] = 'A'; + translations[id(new Message('b', null, null))] = 'B'; - expect(humanizeDom(parse('
', translations))) - .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{{b}} or {{a}}']]); - }); + expect(humanizeDom(parse('', translations))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'a', 1], + [HtmlAttrAst, 'value', 'B'], + [HtmlTextAst, 'A', 2], + ]); + }); - it('should handle interpolation with config', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message( - ' and ', null, null))] = - ' or '; + it('should preserve non-i18n attributes', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('message', null, null))] = 'another message'; - expect(humanizeDom(parse( - '
', translations, [], {}, - InterpolationConfig.fromArray(['{%', '%}'])))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'value', '{%b%} or {%a%}'], - ]); - }); + expect(humanizeDom(parse('
message
', translations))).toEqual([ + [HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'b'], + [HtmlTextAst, 'another message', 1] + ]); + }); - it('should handle interpolation with custom placeholder names', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message(' and ', null, null))] = - ' or '; + it('should replace attributes', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('some message', 'meaning', null))] = 'another message'; - expect( - humanizeDom(parse( - `
`, - translations))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'value', '{{b //i18n(ph="SECOND")}} or {{a //i18n(ph="FIRST")}}'] - ]); - }); + expect( + humanizeDom(parse( + '
', translations))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'another message']]); + }); - it('should handle interpolation with duplicate placeholder names', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message(' and ', null, null))] = - ' or '; + it('should replace elements with the i18n attr', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('message', 'meaning', null))] = 'another message'; - expect( - humanizeDom(parse( - `
`, - translations))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlAttrAst, 'value', '{{b //i18n(ph="FIRST")}} or {{a //i18n(ph="FIRST")}}'] - ]); - }); - - it('should handle nested html', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('ab', null, null))] = - 'BA'; - - expect(humanizeDom(parse('
ab
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'b', 1], - [HtmlTextAst, 'B', 2], - [HtmlElementAst, 'a', 1], - [HtmlTextAst, 'A', 2], - ]); - }); - - it('should support interpolation', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message( - 'ab', - null, null))] = - 'BA'; - expect(humanizeDom(parse('
ab{{i}}
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'b', 1], - [HtmlTextAst, '{{i}}B', 2], - [HtmlElementAst, 'a', 1], - [HtmlTextAst, 'A', 2], - ]); - }); - - it('should i18n attributes of placeholder elements', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('a', null, null))] = 'A'; - translations[id(new Message('b', null, null))] = 'B'; - - expect(humanizeDom(parse('', translations))) - .toEqual([ - [HtmlElementAst, 'div', 0], - [HtmlElementAst, 'a', 1], - [HtmlAttrAst, 'value', 'B'], - [HtmlTextAst, 'A', 2], - ]); - }); - - it('should preserve non-i18n attributes', () => { - let translations: {[key: string]: string} = {}; - translations[id(new Message('message', null, null))] = 'another message'; - - expect(humanizeDom(parse('
message
', translations))).toEqual([ - [HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'b'], - [HtmlTextAst, 'another message', 1] - ]); + expect(humanizeDom(parse('
message
', translations))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlTextAst, 'another message', 1]]); + }); }); it('should extract from partitions', () => { diff --git a/modules/@angular/compiler/test/i18n/message_extractor_spec.ts b/modules/@angular/compiler/test/i18n/message_extractor_spec.ts index cb1a8048d9..5ea37684d9 100644 --- a/modules/@angular/compiler/test/i18n/message_extractor_spec.ts +++ b/modules/@angular/compiler/test/i18n/message_extractor_spec.ts @@ -21,40 +21,9 @@ export function main() { beforeEach(() => { const expParser = new ExpressionParser(new ExpressionLexer()); const htmlParser = new HtmlParser(); - // TODO: pass expression parser extractor = new MessageExtractor(htmlParser, expParser, ['i18n-tag'], {'i18n-el': ['trans']}); }); - it('should extract from elements with the i18n attr', () => { - let res = extractor.extract('
message
', 'someurl'); - expect(res.messages).toEqual([new Message('message', 'meaning', 'desc')]); - }); - - it('should extract from elements with the i18n attr without a desc', () => { - let res = extractor.extract('
message
', 'someurl'); - expect(res.messages).toEqual([new Message('message', 'meaning', null)]); - }); - - it('should extract from elements with the i18n attr without a meaning', () => { - let res = extractor.extract('
message
', 'someurl'); - expect(res.messages).toEqual([new Message('message', null, null)]); - }); - - it('should extract from attributes', () => { - let res = extractor.extract( - ` -
-
- `, - 'someurl'); - - expect(res.messages).toEqual([ - new Message('message1', 'meaning1', 'desc1'), new Message('message2', 'meaning2', 'desc2') - ]); - }); - it('should extract from partitions', () => { let res = extractor.extract( ` @@ -79,90 +48,159 @@ export function main() { expect(res.messages).toEqual([new Message('message1', 'meaning1', 'desc1')]); }); - it('should replace interpolation with placeholders (text nodes)', () => { - let res = extractor.extract('
Hi {{one}} and {{two}}
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', - null, null)]); + describe('ICU messages', () => { + it('should replace icu messages with placeholders', () => { + let res = extractor.extract('
{count, plural, =0 {text} }
', 'someurl'); + expect(res.messages).toEqual([new Message( + '{count, plural =0 {text}}', null, null)]); + }); + + it('should replace HTML with placeholders in ICU cases', () => { + let res = + extractor.extract('
{count, plural, =0 {

html

} }
', 'someurl'); + expect(res.messages).toEqual([new Message( + '{count, plural =0 {html}}', null, null)]); + }); + + it('should replace interpolation with placeholders in ICU cases', () => { + let res = + extractor.extract('
{count, plural, =0 {{{interpolation}}}}
', 'someurl'); + expect(res.messages).toEqual([new Message( + '{count, plural =0 {}}', + null, null)]); + }); + + it('should not replace nested interpolation with placeholders in ICU cases', () => { + let res = extractor.extract( + '
{count, plural, =0 {{sex, gender, =m {{{he}}} =f {she}}}}
', + 'someurl'); + expect(res.messages).toEqual([new Message( + '{count, plural =0 {{sex, gender =m {} =f {she}}}}', + null, null)]); + }); }); - it('should replace interpolation with placeholders (attributes)', () => { - let res = - extractor.extract('
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); + describe('interpolation', () => { + it('should replace interpolation with placeholders (text nodes)', () => { + let res = extractor.extract('
Hi {{one}} and {{two}}
', 'someurl'); + expect(res.messages).toEqual([new Message( + 'Hi and ', + null, null)]); + }); - it('should replace interpolation with named placeholders if provided (text nodes)', () => { - let res = extractor.extract( - ` + it('should replace interpolation with placeholders (attributes)', () => { + let res = + extractor.extract('
', 'someurl'); + expect(res.messages).toEqual([new Message( + 'Hi and ', null, null)]); + }); + + it('should replace interpolation with named placeholders if provided (text nodes)', () => { + let res = extractor.extract( + `
Hi {{one //i18n(ph="FIRST")}} and {{two //i18n(ph="SECOND")}}
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); + 'someurl'); + expect(res.messages).toEqual([new Message( + 'Hi and ', null, null)]); + }); - it('should replace interpolation with named placeholders if provided (attributes)', () => { - let res = extractor.extract( - ` + it('should replace interpolation with named placeholders if provided (attributes)', () => { + let res = extractor.extract( + `
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); + 'someurl'); + expect(res.messages).toEqual([new Message( + 'Hi and ', null, null)]); + }); }); - it('should match named placeholders with extra spacing', () => { - let res = extractor.extract( - ` + describe('placehoders', () => { + it('should match named placeholders with extra spacing', () => { + let res = extractor.extract( + `
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and ', null, null)]); - }); + 'someurl'); + expect(res.messages).toEqual([new Message( + 'Hi and ', null, null)]); + }); - it('should suffix duplicate placeholder names with numbers', () => { - let res = extractor.extract( - ` + it('should suffix duplicate placeholder names with numbers', () => { + let res = extractor.extract( + `
`, - 'someurl'); - expect(res.messages).toEqual([new Message( - 'Hi and and ', null, null)]); + 'someurl'); + expect(res.messages).toEqual([new Message( + 'Hi and and ', null, + null)]); + }); }); - it('should handle html content', () => { - let res = extractor.extract( - '
zero
one
two
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'zeroonetwo', null, null)]); - }); + describe('html', () => { + it('should extract from elements with the i18n attr', () => { + let res = extractor.extract('
message
', 'someurl'); + expect(res.messages).toEqual([new Message('message', 'meaning', 'desc')]); + }); - it('should handle html content with interpolation', () => { - let res = - extractor.extract('
zero{{a}}
{{b}}
', 'someurl'); - expect(res.messages).toEqual([new Message( - 'zero', - null, null)]); - }); + it('should extract from elements with the i18n attr without a desc', () => { + let res = extractor.extract('
message
', 'someurl'); + expect(res.messages).toEqual([new Message('message', 'meaning', null)]); + }); - it('should extract from nested elements', () => { - let res = extractor.extract( - '
message2
', - 'someurl'); - expect(res.messages).toEqual([ - new Message('message2', 'meaning2', 'desc2'), new Message('message1', 'meaning1', 'desc1') - ]); - }); + it('should extract from elements with the i18n attr without a meaning', () => { + let res = extractor.extract('
message
', 'someurl'); + expect(res.messages).toEqual([new Message('message', null, null)]); + }); - it('should extract messages from attributes in i18n blocks', () => { - let res = extractor.extract( - '
message
', 'someurl'); - expect(res.messages).toEqual([ - new Message('message', null, null), - new Message('value', 'meaning', 'desc') - ]); + it('should extract from attributes', () => { + let res = extractor.extract( + ` +
+
+ `, + 'someurl'); + + expect(res.messages).toEqual([ + new Message('message1', 'meaning1', 'desc1'), new Message('message2', 'meaning2', 'desc2') + ]); + }); + + it('should handle html content', () => { + let res = extractor.extract( + '
zero
one
two
', 'someurl'); + expect(res.messages).toEqual([new Message( + 'zeroonetwo', null, null)]); + }); + + it('should handle html content with interpolation', () => { + let res = + extractor.extract('
zero{{a}}
{{b}}
', 'someurl'); + expect(res.messages).toEqual([new Message( + 'zero', + null, null)]); + }); + + it('should extract from nested elements', () => { + let res = extractor.extract( + '
message2
', + 'someurl'); + expect(res.messages).toEqual([ + new Message('message2', 'meaning2', 'desc2'), new Message('message1', 'meaning1', 'desc1') + ]); + }); + + it('should extract messages from attributes in i18n blocks', () => { + let res = extractor.extract( + '
message
', 'someurl'); + expect(res.messages).toEqual([ + new Message('message', null, null), + new Message('value', 'meaning', 'desc') + ]); + }); }); it('should remove duplicate messages', () => {