feat(i18n): support plural and gender special forms

This commit is contained in:
vsavkin 2016-04-12 11:46:49 -07:00 committed by Victor Savkin
parent 7c9717bba8
commit 88b0a239c4
11 changed files with 278 additions and 9 deletions

View File

@ -23,6 +23,8 @@ import {
HtmlAttrAst, HtmlAttrAst,
HtmlAst, HtmlAst,
HtmlCommentAst, HtmlCommentAst,
HtmlExpansionAst,
HtmlExpansionCaseAst,
htmlVisitAll htmlVisitAll
} from './html_ast'; } from './html_ast';
import {HtmlParser} from './html_parser'; import {HtmlParser} from './html_parser';
@ -158,4 +160,7 @@ class TemplatePreparseVisitor implements HtmlAstVisitor {
visitComment(ast: HtmlCommentAst, context: any): any { return null; } visitComment(ast: HtmlCommentAst, context: any): any { return null; }
visitAttr(ast: HtmlAttrAst, context: any): any { return null; } visitAttr(ast: HtmlAttrAst, context: any): any { return null; }
visitText(ast: HtmlTextAst, 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; }
} }

View File

@ -12,6 +12,24 @@ export class HtmlTextAst implements HtmlAst {
visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitText(this, context); } 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 { export class HtmlAttrAst implements HtmlAst {
constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {} constructor(public name: string, public value: string, public sourceSpan: ParseSourceSpan) {}
visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitAttr(this, context); } visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitAttr(this, context); }
@ -34,6 +52,8 @@ export interface HtmlAstVisitor {
visitAttr(ast: HtmlAttrAst, context: any): any; visitAttr(ast: HtmlAttrAst, context: any): any;
visitText(ast: HtmlTextAst, context: any): any; visitText(ast: HtmlTextAst, context: any): any;
visitComment(ast: HtmlCommentAst, 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[] { export function htmlVisitAll(visitor: HtmlAstVisitor, asts: HtmlAst[], context: any = null): any[] {

View File

@ -14,6 +14,8 @@ import {
HtmlElementAst, HtmlElementAst,
HtmlTextAst, HtmlTextAst,
HtmlCommentAst, HtmlCommentAst,
HtmlExpansionAst,
HtmlExpansionCaseAst,
HtmlAst HtmlAst
} from './html_ast'; } from './html_ast';
import {HtmlParser, HtmlParseTreeResult} from './html_parser'; import {HtmlParser, HtmlParseTreeResult} from './html_parser';
@ -84,6 +86,13 @@ export class LegacyHtmlAstTransformer implements HtmlAstVisitor {
visitText(ast: HtmlTextAst, context: any): HtmlTextAst { return ast; } 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 { private _rewriteLongSyntax(ast: HtmlAttrAst): HtmlAttrAst {
let m = RegExpWrapper.firstMatch(LONG_SYNTAX_REGEXP, ast.name); let m = RegExpWrapper.firstMatch(LONG_SYNTAX_REGEXP, ast.name);
let attrName = ast.name; let attrName = ast.name;
@ -211,9 +220,10 @@ export class LegacyHtmlAstTransformer implements HtmlAstVisitor {
@Injectable() @Injectable()
export class LegacyHtmlParser extends HtmlParser { 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 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)); let rootNodes = htmlParseTreeResult.rootNodes.map(node => node.visit(transformer, null));

View File

@ -64,6 +64,8 @@ import {
HtmlAttrAst, HtmlAttrAst,
HtmlTextAst, HtmlTextAst,
HtmlCommentAst, HtmlCommentAst,
HtmlExpansionAst,
HtmlExpansionCaseAst,
htmlVisitAll htmlVisitAll
} from './html_ast'; } from './html_ast';
@ -268,7 +270,7 @@ class TemplateParseVisitor implements HtmlAstVisitor {
visitComment(ast: HtmlCommentAst, context: any): any { return null; } 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 nodeName = element.name;
var preparsedElement = preparseElement(element); var preparsedElement = preparseElement(element);
if (preparsedElement.type === PreparsedElementType.SCRIPT || if (preparsedElement.type === PreparsedElementType.SCRIPT ||
@ -770,6 +772,9 @@ class NonBindableVisitor implements HtmlAstVisitor {
var ngContentIndex = parent.findNgContentIndex(TEXT_CSS_SELECTOR); var ngContentIndex = parent.findNgContentIndex(TEXT_CSS_SELECTOR);
return new TextAst(ast.value, ngContentIndex, ast.sourceSpan); 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 { class BoundElementOrDirectiveProperty {

View File

@ -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
*
* ```
* <ul [ngPlural]="messages.length">
* <template [ngPluralCase]="0"><li i18n="plural_0">zero</li></template>
* <template [ngPluralCase]="1"><li i18n="plural_1">one</li></template>
* <template [ngPluralCase]="other"><li i18n="plural_other">more than one</li></template>
* </ul>
* ```
*/
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);
}

View File

@ -7,6 +7,8 @@ import {
HtmlAttrAst, HtmlAttrAst,
HtmlTextAst, HtmlTextAst,
HtmlCommentAst, HtmlCommentAst,
HtmlExpansionAst,
HtmlExpansionCaseAst,
htmlVisitAll htmlVisitAll
} from 'angular2/src/compiler/html_ast'; } from 'angular2/src/compiler/html_ast';
import {ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; 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 {BaseException} from 'angular2/src/facade/exceptions';
import {Parser} from 'angular2/src/compiler/expression_parser/parser'; import {Parser} from 'angular2/src/compiler/expression_parser/parser';
import {Message, id} from './message'; import {Message, id} from './message';
import {Expander} from './expander';
import { import {
messageFromAttribute, messageFromAttribute,
I18nError, I18nError,
@ -119,19 +122,25 @@ export class I18nHtmlParser implements HtmlParser {
constructor(private _htmlParser: HtmlParser, private _parser: Parser, constructor(private _htmlParser: HtmlParser, private _parser: Parser,
private _messagesContent: string, private _messages: {[key: string]: HtmlAst[]}) {} 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 = []; this.errors = [];
let res = this._htmlParser.parse(sourceContent, sourceUrl); let res = this._htmlParser.parse(sourceContent, sourceUrl, parseExpansionForms);
if (res.errors.length > 0) { if (res.errors.length > 0) {
return res; return res;
} else { } else {
let nodes = this._recurse(res.rootNodes); let nodes = this._recurse(this._expandNodes(res.rootNodes));
return this.errors.length > 0 ? new HtmlParseTreeResult([], this.errors) : return this.errors.length > 0 ? new HtmlParseTreeResult([], this.errors) :
new HtmlParseTreeResult(nodes, []); new HtmlParseTreeResult(nodes, []);
} }
} }
private _expandNodes(nodes: HtmlAst[]): HtmlAst[] {
let e = new Expander();
return htmlVisitAll(e, nodes);
}
private _processI18nPart(p: Part): HtmlAst[] { private _processI18nPart(p: Part): HtmlAst[] {
try { try {
return p.hasI18n ? this._mergeI18Part(p) : this._recurseIntoI18nPart(p); return p.hasI18n ? this._mergeI18Part(p) : this._recurseIntoI18nPart(p);
@ -360,5 +369,9 @@ class _CreateNodeMapping implements HtmlAstVisitor {
return null; 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 ""; } visitComment(ast: HtmlCommentAst, context: any): any { return ""; }
} }

View File

@ -13,6 +13,7 @@ import {isPresent, isBlank} from 'angular2/src/facade/lang';
import {StringMapWrapper} from 'angular2/src/facade/collection'; import {StringMapWrapper} from 'angular2/src/facade/collection';
import {Parser} from 'angular2/src/compiler/expression_parser/parser'; import {Parser} from 'angular2/src/compiler/expression_parser/parser';
import {Message, id} from './message'; import {Message, id} from './message';
import {Expander} from './expander';
import { import {
I18nError, I18nError,
Part, Part,
@ -121,15 +122,20 @@ export class MessageExtractor {
this.messages = []; this.messages = [];
this.errors = []; this.errors = [];
let res = this._htmlParser.parse(template, sourceUrl); let res = this._htmlParser.parse(template, sourceUrl, true);
if (res.errors.length > 0) { if (res.errors.length > 0) {
return new ExtractionResult([], res.errors); return new ExtractionResult([], res.errors);
} else { } else {
this._recurse(res.rootNodes); this._recurse(this._expandNodes(res.rootNodes));
return new ExtractionResult(this.messages, this.errors); 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 { private _extractMessagesFromPart(p: Part): void {
if (p.hasI18n) { if (p.hasI18n) {
this.messages.push(p.createMessage(this._parser)); this.messages.push(p.createMessage(this._parser));

View File

@ -6,6 +6,8 @@ import {
HtmlAttrAst, HtmlAttrAst,
HtmlTextAst, HtmlTextAst,
HtmlCommentAst, HtmlCommentAst,
HtmlExpansionAst,
HtmlExpansionCaseAst,
htmlVisitAll htmlVisitAll
} from 'angular2/src/compiler/html_ast'; } from 'angular2/src/compiler/html_ast';
import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang'; import {isPresent, isBlank, StringWrapper} from 'angular2/src/facade/lang';
@ -179,6 +181,10 @@ class _StringifyVisitor implements HtmlAstVisitor {
visitComment(ast: HtmlCommentAst, context: any): any { return ""; } 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 { private _join(strs: string[], str: string): string {
return strs.filter(s => s.length > 0).join(str); return strs.filter(s => s.length > 0).join(str);
} }

View File

@ -6,6 +6,8 @@ import {
HtmlAttrAst, HtmlAttrAst,
HtmlTextAst, HtmlTextAst,
HtmlCommentAst, HtmlCommentAst,
HtmlExpansionAst,
HtmlExpansionCaseAst,
htmlVisitAll htmlVisitAll
} from 'angular2/src/compiler/html_ast'; } from 'angular2/src/compiler/html_ast';
import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util'; import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util';
@ -70,6 +72,19 @@ class _Humanizer implements HtmlAstVisitor {
return null; 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[] { private _appendContext(ast: HtmlAst, input: any[]): any[] {
if (!this.includeSourceSpan) return input; if (!this.includeSourceSpan) return input;
input.push(ast.sourceSpan.toString()); input.push(ast.sourceSpan.toString());

View File

@ -42,7 +42,7 @@ export function main() {
let res = deserializeXmb(`<message-bundle>${msgs}</message-bundle>`, 'someUrl'); let res = deserializeXmb(`<message-bundle>${msgs}</message-bundle>`, 'someUrl');
return new I18nHtmlParser(htmlParser, parser, res.content, res.messages) 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", () => { 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); expect(res[1].sourceSpan.start.offset).toEqual(10);
}); });
it("should handle the plural special form", () => {
let translations: {[key: string]: string} = {};
translations[id(new Message('zero<ph name="e1">bold</ph>', "plural_0", null))] =
'ZERO<ph name="e1">BOLD</ph>';
let res = parse(`{messages.length, plural,=0 {zero<b>bold</b>}}`, 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('<ph name="e0">bold</ph>', "plural_0", null))] =
'<ph name="e0">BOLD</ph>';
let nodes = parse(`{messages.length, plural,=0 {<b>bold</b>}}`, translations).rootNodes;
let ul: HtmlElementAst = <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 = <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 = <HtmlElementAst>template.children[0];
expect(li.sourceSpan.start.col).toEqual(26);
expect(li.sourceSpan.end.col).toEqual(41);
let b: HtmlElementAst = <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", () => { describe("errors", () => {
it("should error when giving an invalid template", () => { it("should error when giving an invalid template", () => {
expect(humanizeErrors(parse("<a>a</b>", {}).errors)) expect(humanizeErrors(parse("<a>a</b>", {}).errors))

View File

@ -176,6 +176,24 @@ export function main() {
]); ]);
}); });
it("should extract messages from special forms", () => {
let res = extractor.extract(`
<div>
{messages.length, plural,
=0 {You have <b>no</b> messages}
=1 {You have one message}
}
</div>
`,
"someurl");
expect(res.messages)
.toEqual([
new Message('You have <ph name="e1">no</ph> messages', "plural_0", null),
new Message('You have one message', "plural_1", null)
]);
});
it("should remove duplicate messages", () => { it("should remove duplicate messages", () => {
let res = extractor.extract(` let res = extractor.extract(`
<!-- i18n: meaning|desc1 -->message<!-- /i18n --> <!-- i18n: meaning|desc1 -->message<!-- /i18n -->