feat(i18n): add support for nested expansion forms

Closes #7977
This commit is contained in:
vsavkin 2016-04-13 16:01:25 -07:00 committed by Victor Savkin
parent 22ae2d0976
commit c6244d1470
9 changed files with 246 additions and 94 deletions

View File

@ -125,8 +125,7 @@ class _HtmlTokenizer {
private currentTokenStart: ParseLocation;
private currentTokenType: HtmlTokenType;
private inExpansionCase: boolean = false;
private inExpansionForm: boolean = false;
private expansionCaseStack = [];
tokens: HtmlToken[] = [];
errors: HtmlTokenError[] = [];
@ -169,10 +168,12 @@ class _HtmlTokenizer {
} else if (this.peek === $EQ && this.tokenizeExpansionForms) {
this._consumeExpansionCaseStart();
} else if (this.peek === $RBRACE && this.inExpansionCase && this.tokenizeExpansionForms) {
} else if (this.peek === $RBRACE && this.isInExpansionCase() &&
this.tokenizeExpansionForms) {
this._consumeExpansionCaseEnd();
} else if (this.peek === $RBRACE && !this.inExpansionCase && this.tokenizeExpansionForms) {
} else if (this.peek === $RBRACE && this.isInExpansionForm() &&
this.tokenizeExpansionForms) {
this._consumeExpansionFormEnd();
} else {
@ -551,7 +552,7 @@ class _HtmlTokenizer {
this._requireCharCode($COMMA);
this._attemptCharCodeUntilFn(isNotWhitespace);
this.inExpansionForm = true;
this.expansionCaseStack.push(HtmlTokenType.EXPANSION_FORM_START);
}
private _consumeExpansionCaseStart() {
@ -567,7 +568,7 @@ class _HtmlTokenizer {
this._endToken([], this._getLocation());
this._attemptCharCodeUntilFn(isNotWhitespace);
this.inExpansionCase = true;
this.expansionCaseStack.push(HtmlTokenType.EXPANSION_CASE_EXP_START);
}
private _consumeExpansionCaseEnd() {
@ -576,7 +577,7 @@ class _HtmlTokenizer {
this._endToken([], this._getLocation());
this._attemptCharCodeUntilFn(isNotWhitespace);
this.inExpansionCase = false;
this.expansionCaseStack.pop();
}
private _consumeExpansionFormEnd() {
@ -584,7 +585,7 @@ class _HtmlTokenizer {
this._requireCharCode($RBRACE);
this._endToken([]);
this.inExpansionForm = false;
this.expansionCaseStack.pop();
}
private _consumeText() {
@ -622,7 +623,9 @@ class _HtmlTokenizer {
if (this.peek === $LT || this.peek === $EOF) return true;
if (this.tokenizeExpansionForms) {
if (isSpecialFormStart(this.peek, this.nextPeek)) return true;
if (this.peek === $RBRACE && !interpolation && this.inExpansionForm) return true;
if (this.peek === $RBRACE && !interpolation &&
(this.isInExpansionCase() || this.isInExpansionForm()))
return true;
}
return false;
}
@ -648,6 +651,18 @@ class _HtmlTokenizer {
this.tokens = ListWrapper.slice(this.tokens, 0, nbTokens);
}
}
private isInExpansionCase(): boolean {
return this.expansionCaseStack.length > 0 &&
this.expansionCaseStack[this.expansionCaseStack.length - 1] ===
HtmlTokenType.EXPANSION_CASE_EXP_START;
}
private isInExpansionForm(): boolean {
return this.expansionCaseStack.length > 0 &&
this.expansionCaseStack[this.expansionCaseStack.length - 1] ===
HtmlTokenType.EXPANSION_FORM_START;
}
}
function isNotWhitespace(code: number): boolean {

View File

@ -124,40 +124,9 @@ class TreeBuilder {
// read =
while (this.peek.type === HtmlTokenType.EXPANSION_CASE_VALUE) {
let value = this._advance();
// read {
let exp = [];
if (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_START) {
this.errors.push(HtmlTreeError.create(null, this.peek.sourceSpan,
`Invalid expansion form. Missing '{'.,`));
return;
}
// read until }
let start = this._advance();
while (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_END) {
exp.push(this._advance());
if (this.peek.type === HtmlTokenType.EOF) {
this.errors.push(
HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
return;
}
}
let end = this._advance();
exp.push(new HtmlToken(HtmlTokenType.EOF, [], end.sourceSpan));
// parse everything in between { and }
let parsedExp = new TreeBuilder(exp).build();
if (parsedExp.errors.length > 0) {
this.errors = this.errors.concat(<HtmlTreeError[]>parsedExp.errors);
return;
}
let sourceSpan = new ParseSourceSpan(value.sourceSpan.start, end.sourceSpan.end);
let expSourceSpan = new ParseSourceSpan(start.sourceSpan.start, end.sourceSpan.end);
cases.push(new HtmlExpansionCaseAst(value.parts[0], parsedExp.rootNodes, sourceSpan,
value.sourceSpan, expSourceSpan));
let expCase = this._parseExpansionCase();
if (isBlank(expCase)) return; // error
cases.push(expCase);
}
// read the final }
@ -173,6 +142,80 @@ class TreeBuilder {
mainSourceSpan, switchValue.sourceSpan));
}
private _parseExpansionCase(): HtmlExpansionCaseAst {
let value = this._advance();
// read {
if (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_START) {
this.errors.push(HtmlTreeError.create(null, this.peek.sourceSpan,
`Invalid expansion form. Missing '{'.,`));
return null;
}
// read until }
let start = this._advance();
let exp = this._collectExpansionExpTokens(start);
if (isBlank(exp)) return null;
let end = this._advance();
exp.push(new HtmlToken(HtmlTokenType.EOF, [], end.sourceSpan));
// parse everything in between { and }
let parsedExp = new TreeBuilder(exp).build();
if (parsedExp.errors.length > 0) {
this.errors = this.errors.concat(<HtmlTreeError[]>parsedExp.errors);
return null;
}
let sourceSpan = new ParseSourceSpan(value.sourceSpan.start, end.sourceSpan.end);
let expSourceSpan = new ParseSourceSpan(start.sourceSpan.start, end.sourceSpan.end);
return new HtmlExpansionCaseAst(value.parts[0], parsedExp.rootNodes, sourceSpan,
value.sourceSpan, expSourceSpan);
}
private _collectExpansionExpTokens(start: HtmlToken): HtmlToken[] {
let exp = [];
let expansionFormStack = [HtmlTokenType.EXPANSION_CASE_EXP_START];
while (true) {
if (this.peek.type === HtmlTokenType.EXPANSION_FORM_START ||
this.peek.type === HtmlTokenType.EXPANSION_CASE_EXP_START) {
expansionFormStack.push(this.peek.type);
}
if (this.peek.type === HtmlTokenType.EXPANSION_CASE_EXP_END) {
if (lastOnStack(expansionFormStack, HtmlTokenType.EXPANSION_CASE_EXP_START)) {
expansionFormStack.pop();
if (expansionFormStack.length == 0) return exp;
} else {
this.errors.push(
HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
return null;
}
}
if (this.peek.type === HtmlTokenType.EXPANSION_FORM_END) {
if (lastOnStack(expansionFormStack, HtmlTokenType.EXPANSION_FORM_START)) {
expansionFormStack.pop();
} else {
this.errors.push(
HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
return null;
}
}
if (this.peek.type === HtmlTokenType.EOF) {
this.errors.push(
HtmlTreeError.create(null, start.sourceSpan, `Invalid expansion form. Missing '}'.`));
return null;
}
exp.push(this._advance());
}
}
private _consumeText(token: HtmlToken) {
let text = token.parts[0];
if (text.length > 0 && text[0] == '\n') {
@ -321,3 +364,7 @@ function getElementFullName(prefix: string, localName: string,
return mergeNsAndName(prefix, localName);
}
function lastOnStack(stack: any[], element: any): boolean {
return stack.length > 0 && stack[stack.length - 1] === element;
}

View File

@ -254,6 +254,10 @@ class TemplateParseVisitor implements HtmlAstVisitor {
}
}
visitExpansion(ast: HtmlExpansionAst, context: any): any { return null; }
visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return null; }
visitText(ast: HtmlTextAst, parent: ElementContext): any {
var ngContentIndex = parent.findNgContentIndex(TEXT_CSS_SELECTOR);
var expr = this._parseInterpolation(ast.value, ast.sourceSpan);
@ -270,7 +274,7 @@ class TemplateParseVisitor implements HtmlAstVisitor {
visitComment(ast: HtmlCommentAst, context: any): any { return null; }
visitElement(element: HtmlElementAst, component: ElementContext): any {
visitElement(element: HtmlElementAst, parent: ElementContext): any {
var nodeName = element.name;
var preparsedElement = preparseElement(element);
if (preparsedElement.type === PreparsedElementType.SCRIPT ||
@ -773,7 +777,6 @@ class NonBindableVisitor implements HtmlAstVisitor {
return new TextAst(ast.value, ngContentIndex, ast.sourceSpan);
}
visitExpansion(ast: HtmlExpansionAst, context: any): any { return ast; }
visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return ast; }
}

View File

@ -12,6 +12,7 @@ import {
import {BaseException} from 'angular2/src/facade/exceptions';
/**
* Expands special forms into elements.
*
@ -35,7 +36,18 @@ import {BaseException} from 'angular2/src/facade/exceptions';
* </ul>
* ```
*/
export class Expander implements HtmlAstVisitor {
export function expandNodes(nodes: HtmlAst[]): ExpansionResult {
let e = new _Expander();
let n = htmlVisitAll(e, nodes);
return new ExpansionResult(n, e.expanded);
}
export class ExpansionResult {
constructor(public nodes: HtmlAst[], public expanded: boolean) {}
}
class _Expander implements HtmlAstVisitor {
expanded: boolean = false;
constructor() {}
visitElement(ast: HtmlElementAst, context: any): any {
@ -50,6 +62,7 @@ export class Expander implements HtmlAstVisitor {
visitComment(ast: HtmlCommentAst, context: any): any { return ast; }
visitExpansion(ast: HtmlExpansionAst, context: any): any {
this.expanded = true;
return ast.type == "plural" ? _expandPluralForm(ast) : _expandDefaultForm(ast);
}
@ -59,36 +72,44 @@ export class Expander implements HtmlAstVisitor {
}
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 children = ast.cases.map(c => {
let expansionResult = expandNodes(c.expression);
let i18nAttrs = expansionResult.expanded ?
[] :
[new HtmlAttrAst("i18n", `${ast.type}_${c.value}`, c.valueSourceSpan)];
return new HtmlElementAst(`template`,
[
new HtmlAttrAst("ngPluralCase", c.value, c.valueSourceSpan),
],
[
new HtmlElementAst(`li`, i18nAttrs, expansionResult.nodes,
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 children = ast.cases.map(c => {
let expansionResult = expandNodes(c.expression);
let i18nAttrs = expansionResult.expanded ?
[] :
[new HtmlAttrAst("i18n", `${ast.type}_${c.value}`, c.valueSourceSpan)];
return new HtmlElementAst(`template`,
[
new HtmlAttrAst("ngSwitchWhen", c.value, c.valueSourceSpan),
],
[
new HtmlElementAst(`li`, i18nAttrs, expansionResult.nodes,
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

@ -16,7 +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 {expandNodes} from './expander';
import {
messageFromAttribute,
I18nError,
@ -126,21 +126,16 @@ export class I18nHtmlParser implements HtmlParser {
parseExpansionForms: boolean = false): HtmlParseTreeResult {
this.errors = [];
let res = this._htmlParser.parse(sourceContent, sourceUrl, parseExpansionForms);
let res = this._htmlParser.parse(sourceContent, sourceUrl, true);
if (res.errors.length > 0) {
return res;
} else {
let nodes = this._recurse(this._expandNodes(res.rootNodes));
let nodes = this._recurse(expandNodes(res.rootNodes).nodes);
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);
@ -155,9 +150,11 @@ export class I18nHtmlParser implements HtmlParser {
}
private _mergeI18Part(p: Part): HtmlAst[] {
let messageId = id(p.createMessage(this._parser));
let message = p.createMessage(this._parser);
let messageId = id(message);
if (!StringMapWrapper.contains(this._messages, messageId)) {
throw new I18nError(p.sourceSpan, `Cannot find message for id '${messageId}'`);
throw new I18nError(
p.sourceSpan, `Cannot find message for id '${messageId}', content '${message.content}'.`);
}
let parsedMessage = this._messages[messageId];
@ -294,14 +291,17 @@ export class I18nHtmlParser implements HtmlParser {
}
let i18n = i18ns[0];
let messageId = id(messageFromAttribute(this._parser, el, i18n));
let message = messageFromAttribute(this._parser, el, i18n);
let messageId = id(message);
if (StringMapWrapper.contains(this._messages, messageId)) {
let updatedMessage = this._replaceInterpolationInAttr(attr, this._messages[messageId]);
res.push(new HtmlAttrAst(attr.name, updatedMessage, attr.sourceSpan));
} else {
throw new I18nError(attr.sourceSpan, `Cannot find message for id '${messageId}'`);
throw new I18nError(
attr.sourceSpan,
`Cannot find message for id '${messageId}', content '${message.content}'.`);
}
});
return res;

View File

@ -13,7 +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 {expandNodes} from './expander';
import {
I18nError,
Part,
@ -126,16 +126,11 @@ export class MessageExtractor {
if (res.errors.length > 0) {
return new ExtractionResult([], res.errors);
} else {
this._recurse(this._expandNodes(res.rootNodes));
this._recurse(expandNodes(res.rootNodes).nodes);
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));

View File

@ -646,6 +646,31 @@ export function main() {
[HtmlTokenType.EOF]
]);
});
it("should parse nested expansion forms", () => {
expect(tokenizeAndHumanizeParts(`{one.two, three, =4 { {xx, yy, =x {one}} }}`, true))
.toEqual([
[HtmlTokenType.EXPANSION_FORM_START],
[HtmlTokenType.RAW_TEXT, 'one.two'],
[HtmlTokenType.RAW_TEXT, 'three'],
[HtmlTokenType.EXPANSION_CASE_VALUE, '4'],
[HtmlTokenType.EXPANSION_CASE_EXP_START],
[HtmlTokenType.EXPANSION_FORM_START],
[HtmlTokenType.RAW_TEXT, 'xx'],
[HtmlTokenType.RAW_TEXT, 'yy'],
[HtmlTokenType.EXPANSION_CASE_VALUE, 'x'],
[HtmlTokenType.EXPANSION_CASE_EXP_START],
[HtmlTokenType.TEXT, 'one'],
[HtmlTokenType.EXPANSION_CASE_EXP_END],
[HtmlTokenType.EXPANSION_FORM_END],
[HtmlTokenType.TEXT, ' '],
[HtmlTokenType.EXPANSION_CASE_EXP_END],
[HtmlTokenType.EXPANSION_FORM_END],
[HtmlTokenType.EOF]
]);
});
});
describe('errors', () => {

View File

@ -271,6 +271,27 @@ export function main() {
.toEqual([[HtmlTextAst, 'One {{message}}', 0]]);
});
it("should parse out nested expansion forms", () => {
let parsed = parser.parse(`{messages.length, plural, =0 { {p.gender, gender, =m {m}} }}`,
'TestComp', true);
expect(humanizeDom(parsed))
.toEqual([
[HtmlExpansionAst, 'messages.length', 'plural'],
[HtmlExpansionCaseAst, '0'],
]);
let firstCase = (<any>parsed.rootNodes[0]).cases[0];
expect(humanizeDom(new HtmlParseTreeResult(firstCase.expression, [])))
.toEqual([
[HtmlExpansionAst, 'p.gender', 'gender'],
[HtmlExpansionCaseAst, 'm'],
[HtmlTextAst, ' ', 0],
]);
});
it("should error when expansion form is not closed", () => {
let p = parser.parse(`{messages.length, plural, =0 {one}`, 'TestComp', true);
expect(humanizeErrors(p.errors))

View File

@ -188,7 +188,7 @@ export function main() {
expect(res[1].sourceSpan.start.offset).toEqual(10);
});
it("should handle the plural special form", () => {
it("should handle the plural expansion 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>';
@ -200,7 +200,7 @@ export function main() {
[HtmlElementAst, 'ul', 0],
[HtmlAttrAst, '[ngPlural]', 'messages.length'],
[HtmlElementAst, 'template', 1],
[HtmlAttrAst, '[ngPluralCase]', '0'],
[HtmlAttrAst, 'ngPluralCase', '0'],
[HtmlElementAst, 'li', 2],
[HtmlTextAst, 'ZERO', 3],
[HtmlElementAst, 'b', 3],
@ -208,6 +208,31 @@ export function main() {
]);
});
it("should handle nested expansion forms", () => {
let translations: {[key: string]: string} = {};
translations[id(new Message('m', "gender_m", null))] = 'M';
let res = parse(`{messages.length, plural, =0 { {p.gender, gender, =m {m}} }}`, translations);
expect(humanizeDom(res))
.toEqual([
[HtmlElementAst, 'ul', 0],
[HtmlAttrAst, '[ngPlural]', 'messages.length'],
[HtmlElementAst, 'template', 1],
[HtmlAttrAst, 'ngPluralCase', '0'],
[HtmlElementAst, 'li', 2],
[HtmlElementAst, 'ul', 3],
[HtmlAttrAst, '[ngSwitch]', 'p.gender'],
[HtmlElementAst, 'template', 4],
[HtmlAttrAst, 'ngSwitchWhen', 'm'],
[HtmlElementAst, 'li', 5],
[HtmlTextAst, 'M', 6],
[HtmlTextAst, ' ', 3]
]);
});
it("should correctly set source code positions", () => {
let translations: {[key: string]: string} = {};
translations[id(new Message('<ph name="e0">bold</ph>', "plural_0", null))] =
@ -258,7 +283,7 @@ export function main() {
[HtmlElementAst, 'ul', 0],
[HtmlAttrAst, '[ngSwitch]', 'person.gender'],
[HtmlElementAst, 'template', 1],
[HtmlAttrAst, '[ngSwitchWhen]', 'male'],
[HtmlAttrAst, 'ngSwitchWhen', 'male'],
[HtmlElementAst, 'li', 2],
[HtmlTextAst, 'M', 3],
]);
@ -273,13 +298,13 @@ export function main() {
it("should error when no matching message (attr)", () => {
let mid = id(new Message("some message", null, null));
expect(humanizeErrors(parse("<div value='some message' i18n-value></div>", {}).errors))
.toEqual([`Cannot find message for id '${mid}'`]);
.toEqual([`Cannot find message for id '${mid}', content 'some message'.`]);
});
it("should error when no matching message (text)", () => {
let mid = id(new Message("some message", null, null));
expect(humanizeErrors(parse("<div i18n>some message</div>", {}).errors))
.toEqual([`Cannot find message for id '${mid}'`]);
.toEqual([`Cannot find message for id '${mid}', content 'some message'.`]);
});
it("should error when a non-placeholder element appears in translation", () => {