From 88b0a239c439d0a006fff0a4003879263d1b3d1b Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 12 Apr 2016 11:46:49 -0700 Subject: [PATCH] feat(i18n): support plural and gender special forms --- .../src/compiler/directive_normalizer.ts | 5 + modules/angular2/src/compiler/html_ast.ts | 20 ++++ .../angular2/src/compiler/legacy_template.ts | 14 ++- .../angular2/src/compiler/template_parser.ts | 7 +- modules/angular2/src/i18n/expander.ts | 95 +++++++++++++++++++ modules/angular2/src/i18n/i18n_html_parser.ts | 19 +++- .../angular2/src/i18n/message_extractor.ts | 10 +- modules/angular2/src/i18n/shared.ts | 6 ++ .../test/compiler/html_ast_spec_utils.ts | 15 +++ .../test/i18n/i18n_html_parser_spec.ts | 78 ++++++++++++++- .../test/i18n/message_extractor_spec.ts | 18 ++++ 11 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 modules/angular2/src/i18n/expander.ts diff --git a/modules/angular2/src/compiler/directive_normalizer.ts b/modules/angular2/src/compiler/directive_normalizer.ts index e6d440c9eb..5ffabda9ac 100644 --- a/modules/angular2/src/compiler/directive_normalizer.ts +++ b/modules/angular2/src/compiler/directive_normalizer.ts @@ -23,6 +23,8 @@ import { HtmlAttrAst, HtmlAst, HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, htmlVisitAll } from './html_ast'; import {HtmlParser} from './html_parser'; @@ -158,4 +160,7 @@ class TemplatePreparseVisitor implements HtmlAstVisitor { visitComment(ast: HtmlCommentAst, context: any): any { return null; } visitAttr(ast: HtmlAttrAst, context: any): any { return null; } visitText(ast: HtmlTextAst, context: any): any { return null; } + visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; } } diff --git a/modules/angular2/src/compiler/html_ast.ts b/modules/angular2/src/compiler/html_ast.ts index d1f18b47df..6da9687043 100644 --- a/modules/angular2/src/compiler/html_ast.ts +++ b/modules/angular2/src/compiler/html_ast.ts @@ -12,6 +12,24 @@ export class HtmlTextAst implements HtmlAst { visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitText(this, context); } } +export class HtmlExpansionAst implements HtmlAst { + constructor(public switchValue: string, public type: string, public cases: HtmlExpansionCaseAst[], + public sourceSpan: ParseSourceSpan, public switchValueSourceSpan: ParseSourceSpan) {} + visit(visitor: HtmlAstVisitor, context: any): any { + return visitor.visitExpansion(this, context); + } +} + +export class HtmlExpansionCaseAst implements HtmlAst { + constructor(public value: string, public expression: HtmlAst[], + public sourceSpan: ParseSourceSpan, public valueSourceSpan: ParseSourceSpan, + public expSourceSpan: ParseSourceSpan) {} + + visit(visitor: HtmlAstVisitor, context: any): any { + return visitor.visitExpansionCase(this, context); + } +} + export class HtmlAttrAst implements HtmlAst { constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitAttr(this, context); } @@ -34,6 +52,8 @@ export interface HtmlAstVisitor { visitAttr(ast: HtmlAttrAst, context: any): any; visitText(ast: HtmlTextAst, context: any): any; visitComment(ast: HtmlCommentAst, context: any): any; + visitExpansion(ast: HtmlExpansionAst, context: any): any; + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any; } export function htmlVisitAll(visitor: HtmlAstVisitor, asts: HtmlAst[], context: any = null): any[] { diff --git a/modules/angular2/src/compiler/legacy_template.ts b/modules/angular2/src/compiler/legacy_template.ts index 6adbf78a4a..d26b9e8ac1 100644 --- a/modules/angular2/src/compiler/legacy_template.ts +++ b/modules/angular2/src/compiler/legacy_template.ts @@ -14,6 +14,8 @@ import { HtmlElementAst, HtmlTextAst, HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, HtmlAst } from './html_ast'; import {HtmlParser, HtmlParseTreeResult} from './html_parser'; @@ -84,6 +86,13 @@ export class LegacyHtmlAstTransformer implements HtmlAstVisitor { visitText(ast: HtmlTextAst, context: any): HtmlTextAst { return ast; } + visitExpansion(ast: HtmlExpansionAst, context: any): any { + let cases = ast.cases.map(c => c.visit(this, null)); + return new HtmlExpansionAst(ast.switchValue, ast.type, cases, ast.sourceSpan); + } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return ast; } + private _rewriteLongSyntax(ast: HtmlAttrAst): HtmlAttrAst { let m = RegExpWrapper.firstMatch(LONG_SYNTAX_REGEXP, ast.name); let attrName = ast.name; @@ -211,9 +220,10 @@ export class LegacyHtmlAstTransformer implements HtmlAstVisitor { @Injectable() export class LegacyHtmlParser extends HtmlParser { - parse(sourceContent: string, sourceUrl: string): HtmlParseTreeResult { + parse(sourceContent: string, sourceUrl: string, + parseExpansionForms: boolean = false): HtmlParseTreeResult { let transformer = new LegacyHtmlAstTransformer(); - let htmlParseTreeResult = super.parse(sourceContent, sourceUrl); + let htmlParseTreeResult = super.parse(sourceContent, sourceUrl, parseExpansionForms); let rootNodes = htmlParseTreeResult.rootNodes.map(node => node.visit(transformer, null)); diff --git a/modules/angular2/src/compiler/template_parser.ts b/modules/angular2/src/compiler/template_parser.ts index 1c08e15894..1c332773f3 100644 --- a/modules/angular2/src/compiler/template_parser.ts +++ b/modules/angular2/src/compiler/template_parser.ts @@ -64,6 +64,8 @@ import { HtmlAttrAst, HtmlTextAst, HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, htmlVisitAll } from './html_ast'; @@ -268,7 +270,7 @@ class TemplateParseVisitor implements HtmlAstVisitor { visitComment(ast: HtmlCommentAst, context: any): any { return null; } - visitElement(element: HtmlElementAst, parent: ElementContext): any { + visitElement(element: HtmlElementAst, component: ElementContext): any { var nodeName = element.name; var preparsedElement = preparseElement(element); if (preparsedElement.type === PreparsedElementType.SCRIPT || @@ -770,6 +772,9 @@ class NonBindableVisitor implements HtmlAstVisitor { var ngContentIndex = parent.findNgContentIndex(TEXT_CSS_SELECTOR); return new TextAst(ast.value, ngContentIndex, ast.sourceSpan); } + visitExpansion(ast: HtmlExpansionAst, context: any): any { return ast; } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return ast; } } class BoundElementOrDirectiveProperty { diff --git a/modules/angular2/src/i18n/expander.ts b/modules/angular2/src/i18n/expander.ts new file mode 100644 index 0000000000..6a27dd6373 --- /dev/null +++ b/modules/angular2/src/i18n/expander.ts @@ -0,0 +1,95 @@ +import { + HtmlAst, + HtmlAstVisitor, + HtmlElementAst, + HtmlAttrAst, + HtmlTextAst, + HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, + htmlVisitAll +} from 'angular2/src/compiler/html_ast'; + +import {BaseException} from 'angular2/src/facade/exceptions'; + +/** + * Expands special forms into elements. + * + * For example, + * + * ``` + * { messages.length, plural, + * =0 {zero} + * =1 {one} + * =other {more than one} + * } + * ``` + * + * will be expanded into + * + * ``` + * + * ``` + */ +export class Expander implements HtmlAstVisitor { + constructor() {} + + visitElement(ast: HtmlElementAst, context: any): any { + return new HtmlElementAst(ast.name, ast.attrs, htmlVisitAll(this, ast.children), ast.sourceSpan, + ast.startSourceSpan, ast.endSourceSpan); + } + + visitAttr(ast: HtmlAttrAst, context: any): any { return ast; } + + visitText(ast: HtmlTextAst, context: any): any { return ast; } + + visitComment(ast: HtmlCommentAst, context: any): any { return ast; } + + visitExpansion(ast: HtmlExpansionAst, context: any): any { + return ast.type == "plural" ? _expandPluralForm(ast) : _expandDefaultForm(ast); + } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { + throw new BaseException("Should not be reached"); + } +} + +function _expandPluralForm(ast: HtmlExpansionAst): HtmlElementAst { + let children = ast.cases.map( + c => new HtmlElementAst( + `template`, + [ + new HtmlAttrAst("[ngPluralCase]", c.value, c.valueSourceSpan), + ], + [ + new HtmlElementAst( + `li`, [new HtmlAttrAst("i18n", `${ast.type}_${c.value}`, c.valueSourceSpan)], + c.expression, c.sourceSpan, c.sourceSpan, c.sourceSpan) + ], + c.sourceSpan, c.sourceSpan, c.sourceSpan)); + let switchAttr = new HtmlAttrAst("[ngPlural]", ast.switchValue, ast.switchValueSourceSpan); + return new HtmlElementAst("ul", [switchAttr], children, ast.sourceSpan, ast.sourceSpan, + ast.sourceSpan); +} + +function _expandDefaultForm(ast: HtmlExpansionAst): HtmlElementAst { + let children = ast.cases.map( + c => new HtmlElementAst( + `template`, + [ + new HtmlAttrAst("[ngSwitchWhen]", c.value, c.valueSourceSpan), + ], + [ + new HtmlElementAst( + `li`, [new HtmlAttrAst("i18n", `${ast.type}_${c.value}`, c.valueSourceSpan)], + c.expression, c.sourceSpan, c.sourceSpan, c.sourceSpan) + ], + c.sourceSpan, c.sourceSpan, c.sourceSpan)); + let switchAttr = new HtmlAttrAst("[ngSwitch]", ast.switchValue, ast.switchValueSourceSpan); + return new HtmlElementAst("ul", [switchAttr], children, ast.sourceSpan, ast.sourceSpan, + ast.sourceSpan); +} \ No newline at end of file diff --git a/modules/angular2/src/i18n/i18n_html_parser.ts b/modules/angular2/src/i18n/i18n_html_parser.ts index a009192df8..23b09735a0 100644 --- a/modules/angular2/src/i18n/i18n_html_parser.ts +++ b/modules/angular2/src/i18n/i18n_html_parser.ts @@ -7,6 +7,8 @@ import { HtmlAttrAst, HtmlTextAst, HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, htmlVisitAll } from 'angular2/src/compiler/html_ast'; import {ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; @@ -14,6 +16,7 @@ import {RegExpWrapper, NumberWrapper, isPresent} from 'angular2/src/facade/lang' import {BaseException} from 'angular2/src/facade/exceptions'; import {Parser} from 'angular2/src/compiler/expression_parser/parser'; import {Message, id} from './message'; +import {Expander} from './expander'; import { messageFromAttribute, I18nError, @@ -119,19 +122,25 @@ export class I18nHtmlParser implements HtmlParser { constructor(private _htmlParser: HtmlParser, private _parser: Parser, private _messagesContent: string, private _messages: {[key: string]: HtmlAst[]}) {} - parse(sourceContent: string, sourceUrl: string): HtmlParseTreeResult { + parse(sourceContent: string, sourceUrl: string, + parseExpansionForms: boolean = false): HtmlParseTreeResult { this.errors = []; - let res = this._htmlParser.parse(sourceContent, sourceUrl); + let res = this._htmlParser.parse(sourceContent, sourceUrl, parseExpansionForms); if (res.errors.length > 0) { return res; } else { - let nodes = this._recurse(res.rootNodes); + let nodes = this._recurse(this._expandNodes(res.rootNodes)); return this.errors.length > 0 ? new HtmlParseTreeResult([], this.errors) : new HtmlParseTreeResult(nodes, []); } } + private _expandNodes(nodes: HtmlAst[]): HtmlAst[] { + let e = new Expander(); + return htmlVisitAll(e, nodes); + } + private _processI18nPart(p: Part): HtmlAst[] { try { return p.hasI18n ? this._mergeI18Part(p) : this._recurseIntoI18nPart(p); @@ -360,5 +369,9 @@ class _CreateNodeMapping implements HtmlAstVisitor { return null; } + visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; } + visitComment(ast: HtmlCommentAst, context: any): any { return ""; } } diff --git a/modules/angular2/src/i18n/message_extractor.ts b/modules/angular2/src/i18n/message_extractor.ts index a923aae4ac..7da4ee0f79 100644 --- a/modules/angular2/src/i18n/message_extractor.ts +++ b/modules/angular2/src/i18n/message_extractor.ts @@ -13,6 +13,7 @@ import {isPresent, isBlank} from 'angular2/src/facade/lang'; import {StringMapWrapper} from 'angular2/src/facade/collection'; import {Parser} from 'angular2/src/compiler/expression_parser/parser'; import {Message, id} from './message'; +import {Expander} from './expander'; import { I18nError, Part, @@ -121,15 +122,20 @@ export class MessageExtractor { this.messages = []; this.errors = []; - let res = this._htmlParser.parse(template, sourceUrl); + let res = this._htmlParser.parse(template, sourceUrl, true); if (res.errors.length > 0) { return new ExtractionResult([], res.errors); } else { - this._recurse(res.rootNodes); + this._recurse(this._expandNodes(res.rootNodes)); return new ExtractionResult(this.messages, this.errors); } } + private _expandNodes(nodes: HtmlAst[]): HtmlAst[] { + let e = new Expander(); + return htmlVisitAll(e, nodes); + } + private _extractMessagesFromPart(p: Part): void { if (p.hasI18n) { this.messages.push(p.createMessage(this._parser)); diff --git a/modules/angular2/src/i18n/shared.ts b/modules/angular2/src/i18n/shared.ts index d1ebd8a7a2..d46798f75d 100644 --- a/modules/angular2/src/i18n/shared.ts +++ b/modules/angular2/src/i18n/shared.ts @@ -6,6 +6,8 @@ import { HtmlAttrAst, HtmlTextAst, HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, htmlVisitAll } from 'angular2/src/compiler/html_ast'; import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang'; @@ -179,6 +181,10 @@ class _StringifyVisitor implements HtmlAstVisitor { visitComment(ast: HtmlCommentAst, context: any): any { return ""; } + visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; } + private _join(strs: string[], str: string): string { return strs.filter(s => s.length > 0).join(str); } diff --git a/modules/angular2/test/compiler/html_ast_spec_utils.ts b/modules/angular2/test/compiler/html_ast_spec_utils.ts index a59476917f..af58c224f6 100644 --- a/modules/angular2/test/compiler/html_ast_spec_utils.ts +++ b/modules/angular2/test/compiler/html_ast_spec_utils.ts @@ -6,6 +6,8 @@ import { HtmlAttrAst, HtmlTextAst, HtmlCommentAst, + HtmlExpansionAst, + HtmlExpansionCaseAst, htmlVisitAll } from 'angular2/src/compiler/html_ast'; import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util'; @@ -70,6 +72,19 @@ class _Humanizer implements HtmlAstVisitor { return null; } + visitExpansion(ast: HtmlExpansionAst, context: any): any { + var res = this._appendContext(ast, [HtmlExpansionAst, ast.switchValue, ast.type]); + this.result.push(res); + htmlVisitAll(this, ast.cases); + return null; + } + + visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { + var res = this._appendContext(ast, [HtmlExpansionCaseAst, ast.value]); + this.result.push(res); + return null; + } + private _appendContext(ast: HtmlAst, input: any[]): any[] { if (!this.includeSourceSpan) return input; input.push(ast.sourceSpan.toString()); diff --git a/modules/angular2/test/i18n/i18n_html_parser_spec.ts b/modules/angular2/test/i18n/i18n_html_parser_spec.ts index 68c505fc57..65ebe42450 100644 --- a/modules/angular2/test/i18n/i18n_html_parser_spec.ts +++ b/modules/angular2/test/i18n/i18n_html_parser_spec.ts @@ -42,7 +42,7 @@ export function main() { let res = deserializeXmb(`${msgs}`, 'someUrl'); return new I18nHtmlParser(htmlParser, parser, res.content, res.messages) - .parse(template, "someurl"); + .parse(template, "someurl", true); } it("should delegate to the provided parser when no i18n", () => { @@ -188,6 +188,82 @@ export function main() { expect(res[1].sourceSpan.start.offset).toEqual(10); }); + it("should handle the plural special form", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('zerobold', "plural_0", null))] = + 'ZEROBOLD'; + + let res = parse(`{messages.length, plural,=0 {zerobold}}`, translations); + + expect(humanizeDom(res)) + .toEqual([ + [HtmlElementAst, 'ul', 0], + [HtmlAttrAst, '[ngPlural]', 'messages.length'], + [HtmlElementAst, 'template', 1], + [HtmlAttrAst, '[ngPluralCase]', '0'], + [HtmlElementAst, 'li', 2], + [HtmlTextAst, 'ZERO', 3], + [HtmlElementAst, 'b', 3], + [HtmlTextAst, 'BOLD', 4] + ]); + }); + + it("should correctly set source code positions", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('bold', "plural_0", null))] = + 'BOLD'; + + let nodes = parse(`{messages.length, plural,=0 {bold}}`, translations).rootNodes; + + let ul: HtmlElementAst = nodes[0]; + + expect(ul.sourceSpan.start.col).toEqual(0); + expect(ul.sourceSpan.end.col).toEqual(42); + + expect(ul.startSourceSpan.start.col).toEqual(0); + expect(ul.startSourceSpan.end.col).toEqual(42); + + expect(ul.endSourceSpan.start.col).toEqual(0); + expect(ul.endSourceSpan.end.col).toEqual(42); + + let switchExp = ul.attrs[0]; + expect(switchExp.sourceSpan.start.col).toEqual(1); + expect(switchExp.sourceSpan.end.col).toEqual(16); + + let template: HtmlElementAst = ul.children[0]; + expect(template.sourceSpan.start.col).toEqual(26); + expect(template.sourceSpan.end.col).toEqual(41); + + let switchCheck = template.attrs[0]; + expect(switchCheck.sourceSpan.start.col).toEqual(26); + expect(switchCheck.sourceSpan.end.col).toEqual(28); + + let li: HtmlElementAst = template.children[0]; + expect(li.sourceSpan.start.col).toEqual(26); + expect(li.sourceSpan.end.col).toEqual(41); + + let b: HtmlElementAst = li.children[0]; + expect(b.sourceSpan.start.col).toEqual(29); + expect(b.sourceSpan.end.col).toEqual(32); + }); + + it("should handle other special forms", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('m', "gender_male", null))] = 'M'; + + let res = parse(`{person.gender, gender,=male {m}}`, translations); + + expect(humanizeDom(res)) + .toEqual([ + [HtmlElementAst, 'ul', 0], + [HtmlAttrAst, '[ngSwitch]', 'person.gender'], + [HtmlElementAst, 'template', 1], + [HtmlAttrAst, '[ngSwitchWhen]', 'male'], + [HtmlElementAst, 'li', 2], + [HtmlTextAst, 'M', 3], + ]); + }); + describe("errors", () => { it("should error when giving an invalid template", () => { expect(humanizeErrors(parse("a", {}).errors)) diff --git a/modules/angular2/test/i18n/message_extractor_spec.ts b/modules/angular2/test/i18n/message_extractor_spec.ts index f7f8541ad5..430b624161 100644 --- a/modules/angular2/test/i18n/message_extractor_spec.ts +++ b/modules/angular2/test/i18n/message_extractor_spec.ts @@ -176,6 +176,24 @@ export function main() { ]); }); + it("should extract messages from special forms", () => { + let res = extractor.extract(` +
+ {messages.length, plural, + =0 {You have no messages} + =1 {You have one message} + } +
+ `, + "someurl"); + + expect(res.messages) + .toEqual([ + new Message('You have no messages', "plural_0", null), + new Message('You have one message', "plural_1", null) + ]); + }); + it("should remove duplicate messages", () => { let res = extractor.extract(` message