From ff1cd4267c49187c9742297641070e3cadd8e9ca Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 12 Apr 2016 11:46:28 -0700 Subject: [PATCH 1/5] feat(html_lexer): support special forms used by i18n { exp, plural, =0 {} } --- modules/angular2/src/compiler/html_lexer.ts | 131 +++++++++++++++++- .../angular2/test/compiler/html_lexer_spec.ts | 82 ++++++++++- 2 files changed, 202 insertions(+), 11 deletions(-) diff --git a/modules/angular2/src/compiler/html_lexer.ts b/modules/angular2/src/compiler/html_lexer.ts index d3a90d74aae4..76f48683d9ac 100644 --- a/modules/angular2/src/compiler/html_lexer.ts +++ b/modules/angular2/src/compiler/html_lexer.ts @@ -25,6 +25,11 @@ export enum HtmlTokenType { ATTR_NAME, ATTR_VALUE, DOC_TYPE, + EXPANSION_FORM_START, + EXPANSION_CASE_VALUE, + EXPANSION_CASE_EXP_START, + EXPANSION_CASE_EXP_END, + EXPANSION_FORM_END, EOF } @@ -43,8 +48,10 @@ export class HtmlTokenizeResult { constructor(public tokens: HtmlToken[], public errors: HtmlTokenError[]) {} } -export function tokenizeHtml(sourceContent: string, sourceUrl: string): HtmlTokenizeResult { - return new _HtmlTokenizer(new ParseSourceFile(sourceContent, sourceUrl)).tokenize(); +export function tokenizeHtml(sourceContent: string, sourceUrl: string, + tokenizeExpansionForms: boolean = false): HtmlTokenizeResult { + return new _HtmlTokenizer(new ParseSourceFile(sourceContent, sourceUrl), tokenizeExpansionForms) + .tokenize(); } const $EOF = 0; @@ -75,6 +82,9 @@ const $GT = 62; const $QUESTION = 63; const $LBRACKET = 91; const $RBRACKET = 93; +const $LBRACE = 123; +const $RBRACE = 125; +const $COMMA = 44; const $A = 65; const $F = 70; const $X = 88; @@ -108,16 +118,20 @@ class _HtmlTokenizer { private length: number; // Note: this is always lowercase! private peek: number = -1; + private nextPeek: number = -1; private index: number = -1; private line: number = 0; private column: number = -1; private currentTokenStart: ParseLocation; private currentTokenType: HtmlTokenType; + private inExpansionCase: boolean = false; + private inExpansionForm: boolean = false; + tokens: HtmlToken[] = []; errors: HtmlTokenError[] = []; - constructor(private file: ParseSourceFile) { + constructor(private file: ParseSourceFile, private tokenizeExpansionForms: boolean) { this.input = file.content; this.length = file.content.length; this._advance(); @@ -149,6 +163,18 @@ class _HtmlTokenizer { } else { this._consumeTagOpen(start); } + } else if (isSpecialFormStart(this.peek, this.nextPeek) && this.tokenizeExpansionForms) { + this._consumeExpansionFormStart(); + + } else if (this.peek === $EQ && this.tokenizeExpansionForms) { + this._consumeExpansionCaseStart(); + + } else if (this.peek === $RBRACE && this.inExpansionCase && this.tokenizeExpansionForms) { + this._consumeExpansionCaseEnd(); + + } else if (this.peek === $RBRACE && !this.inExpansionCase && this.tokenizeExpansionForms) { + this._consumeExpansionFormEnd(); + } else { this._consumeText(); } @@ -218,6 +244,8 @@ class _HtmlTokenizer { } this.index++; this.peek = this.index >= this.length ? $EOF : StringWrapper.charCodeAt(this.input, this.index); + this.nextPeek = + this.index + 1 >= this.length ? $EOF : StringWrapper.charCodeAt(this.input, this.index + 1); } private _attemptCharCode(charCode: number): boolean { @@ -506,20 +534,109 @@ class _HtmlTokenizer { this._endToken(prefixAndName); } + private _consumeExpansionFormStart() { + this._beginToken(HtmlTokenType.EXPANSION_FORM_START, this._getLocation()); + this._requireCharCode($LBRACE); + this._endToken([]); + + this._beginToken(HtmlTokenType.RAW_TEXT, this._getLocation()); + let condition = this._readUntil($COMMA); + this._endToken([condition], this._getLocation()); + this._requireCharCode($COMMA); + this._attemptCharCodeUntilFn(isNotWhitespace); + + this._beginToken(HtmlTokenType.RAW_TEXT, this._getLocation()); + let type = this._readUntil($COMMA); + this._endToken([type], this._getLocation()); + this._requireCharCode($COMMA); + this._attemptCharCodeUntilFn(isNotWhitespace); + + this.inExpansionForm = true; + } + + private _consumeExpansionCaseStart() { + this._requireCharCode($EQ); + + this._beginToken(HtmlTokenType.EXPANSION_CASE_VALUE, this._getLocation()); + let value = this._readUntil($LBRACE).trim(); + this._endToken([value], this._getLocation()); + this._attemptCharCodeUntilFn(isNotWhitespace); + + this._beginToken(HtmlTokenType.EXPANSION_CASE_EXP_START, this._getLocation()); + this._requireCharCode($LBRACE); + this._endToken([], this._getLocation()); + this._attemptCharCodeUntilFn(isNotWhitespace); + + this.inExpansionCase = true; + } + + private _consumeExpansionCaseEnd() { + this._beginToken(HtmlTokenType.EXPANSION_CASE_EXP_END, this._getLocation()); + this._requireCharCode($RBRACE); + this._endToken([], this._getLocation()); + this._attemptCharCodeUntilFn(isNotWhitespace); + + this.inExpansionCase = false; + } + + private _consumeExpansionFormEnd() { + this._beginToken(HtmlTokenType.EXPANSION_FORM_END, this._getLocation()); + this._requireCharCode($RBRACE); + this._endToken([]); + + this.inExpansionForm = false; + } + private _consumeText() { var start = this._getLocation(); this._beginToken(HtmlTokenType.TEXT, start); - var parts = [this._readChar(true)]; - while (!isTextEnd(this.peek)) { + + var parts = []; + let interpolation = false; + + if (this.peek === $LBRACE && this.nextPeek === $LBRACE) { + parts.push(this._readChar(true)); + parts.push(this._readChar(true)); + interpolation = true; + } else { parts.push(this._readChar(true)); } + + while (!this.isTextEnd(interpolation)) { + if (this.peek === $LBRACE && this.nextPeek === $LBRACE) { + parts.push(this._readChar(true)); + parts.push(this._readChar(true)); + interpolation = true; + } else if (this.peek === $RBRACE && this.nextPeek === $RBRACE && interpolation) { + parts.push(this._readChar(true)); + parts.push(this._readChar(true)); + interpolation = false; + } else { + parts.push(this._readChar(true)); + } + } this._endToken([this._processCarriageReturns(parts.join(''))]); } + private isTextEnd(interpolation: boolean): boolean { + 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; + } + return false; + } + private _savePosition(): number[] { return [this.peek, this.index, this.column, this.line, this.tokens.length]; } + private _readUntil(char: number): string { + let start = this.index; + this._attemptUntilChar(char); + return this.input.substring(start, this.index); + } + private _restorePosition(position: number[]): void { this.peek = position[0]; this.index = position[1]; @@ -558,8 +675,8 @@ function isNamedEntityEnd(code: number): boolean { return code == $SEMICOLON || code == $EOF || !isAsciiLetter(code); } -function isTextEnd(code: number): boolean { - return code === $LT || code === $EOF; +function isSpecialFormStart(peek: number, nextPeek: number): boolean { + return peek === $LBRACE && nextPeek != $LBRACE; } function isAsciiLetter(code: number): boolean { diff --git a/modules/angular2/test/compiler/html_lexer_spec.ts b/modules/angular2/test/compiler/html_lexer_spec.ts index a4c32142a871..aef9b096587f 100644 --- a/modules/angular2/test/compiler/html_lexer_spec.ts +++ b/modules/angular2/test/compiler/html_lexer_spec.ts @@ -576,6 +576,78 @@ export function main() { }); + describe("expansion forms", () => { + it("should parse an expansion form", () => { + expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four} =5 {five} }', 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.TEXT, 'four'], + [HtmlTokenType.EXPANSION_CASE_EXP_END], + [HtmlTokenType.EXPANSION_CASE_VALUE, '5'], + [HtmlTokenType.EXPANSION_CASE_EXP_START], + [HtmlTokenType.TEXT, 'five'], + [HtmlTokenType.EXPANSION_CASE_EXP_END], + [HtmlTokenType.EXPANSION_FORM_END], + [HtmlTokenType.EOF] + ]); + }); + + it("should parse an expansion form with text elements surrounding it", () => { + expect(tokenizeAndHumanizeParts('before{one.two, three, =4 {four}}after', true)) + .toEqual([ + [HtmlTokenType.TEXT, "before"], + [HtmlTokenType.EXPANSION_FORM_START], + [HtmlTokenType.RAW_TEXT, 'one.two'], + [HtmlTokenType.RAW_TEXT, 'three'], + [HtmlTokenType.EXPANSION_CASE_VALUE, '4'], + [HtmlTokenType.EXPANSION_CASE_EXP_START], + [HtmlTokenType.TEXT, 'four'], + [HtmlTokenType.EXPANSION_CASE_EXP_END], + [HtmlTokenType.EXPANSION_FORM_END], + [HtmlTokenType.TEXT, "after"], + [HtmlTokenType.EOF] + ]); + }); + + it("should parse an expansion forms with elements in it", () => { + expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four a}}', 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.TEXT, 'four '], + [HtmlTokenType.TAG_OPEN_START, null, 'b'], + [HtmlTokenType.TAG_OPEN_END], + [HtmlTokenType.TEXT, 'a'], + [HtmlTokenType.TAG_CLOSE, null, 'b'], + [HtmlTokenType.EXPANSION_CASE_EXP_END], + [HtmlTokenType.EXPANSION_FORM_END], + [HtmlTokenType.EOF] + ]); + }); + + it("should parse an expansion forms with interpolation in it", () => { + expect(tokenizeAndHumanizeParts('{one.two, three, =4 {four {{a}}}}', 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.TEXT, 'four {{a}}'], + [HtmlTokenType.EXPANSION_CASE_EXP_END], + [HtmlTokenType.EXPANSION_FORM_END], + [HtmlTokenType.EOF] + ]); + }); + }); + describe('errors', () => { it('should include 2 lines of context in message', () => { let src = "111\n222\n333\nE\n444\n555\n666\n"; @@ -604,8 +676,9 @@ export function main() { }); } -function tokenizeWithoutErrors(input: string): HtmlToken[] { - var tokenizeResult = tokenizeHtml(input, 'someUrl'); +function tokenizeWithoutErrors(input: string, + tokenizeExpansionForms: boolean = false): HtmlToken[] { + var tokenizeResult = tokenizeHtml(input, 'someUrl', tokenizeExpansionForms); if (tokenizeResult.errors.length > 0) { var errorString = tokenizeResult.errors.join('\n'); throw new BaseException(`Unexpected parse errors:\n${errorString}`); @@ -613,8 +686,9 @@ function tokenizeWithoutErrors(input: string): HtmlToken[] { return tokenizeResult.tokens; } -function tokenizeAndHumanizeParts(input: string): any[] { - return tokenizeWithoutErrors(input).map(token => [token.type].concat(token.parts)); +function tokenizeAndHumanizeParts(input: string, tokenizeExpansionForms: boolean = false): any[] { + return tokenizeWithoutErrors(input, tokenizeExpansionForms) + .map(token => [token.type].concat(token.parts)); } function tokenizeAndHumanizeSourceSpans(input: string): any[] { From 2db53cb09152d0755024eac22f9c686f0b3888fc Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 12 Apr 2016 11:46:39 -0700 Subject: [PATCH 2/5] feat(html_parser): support special forms used by i18n { exp, plural, =0 {} } --- modules/angular2/src/compiler/html_parser.ts | 74 ++++++++++++++++++- .../test/compiler/html_parser_spec.ts | 54 +++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/modules/angular2/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index 40385eb1055a..56b522f559b9 100644 --- a/modules/angular2/src/compiler/html_parser.ts +++ b/modules/angular2/src/compiler/html_parser.ts @@ -11,7 +11,15 @@ import { import {ListWrapper} from 'angular2/src/facade/collection'; -import {HtmlAst, HtmlAttrAst, HtmlTextAst, HtmlCommentAst, HtmlElementAst} from './html_ast'; +import { + HtmlAst, + HtmlAttrAst, + HtmlTextAst, + HtmlCommentAst, + HtmlElementAst, + HtmlExpansionAst, + HtmlExpansionCaseAst +} from './html_ast'; import {Injectable} from 'angular2/src/core/di'; import {HtmlToken, HtmlTokenType, tokenizeHtml} from './html_lexer'; @@ -32,8 +40,9 @@ export class HtmlParseTreeResult { @Injectable() export class HtmlParser { - parse(sourceContent: string, sourceUrl: string): HtmlParseTreeResult { - var tokensAndErrors = tokenizeHtml(sourceContent, sourceUrl); + parse(sourceContent: string, sourceUrl: string, + parseExpansionForms: boolean = false): HtmlParseTreeResult { + var tokensAndErrors = tokenizeHtml(sourceContent, sourceUrl, parseExpansionForms); var treeAndErrors = new TreeBuilder(tokensAndErrors.tokens).build(); return new HtmlParseTreeResult(treeAndErrors.rootNodes, (tokensAndErrors.errors) .concat(treeAndErrors.errors)); @@ -68,6 +77,8 @@ class TreeBuilder { this.peek.type === HtmlTokenType.ESCAPABLE_RAW_TEXT) { this._closeVoidElement(); this._consumeText(this._advance()); + } else if (this.peek.type === HtmlTokenType.EXPANSION_FORM_START) { + this._consumeExpansion(this._advance()); } else { // Skip all other tokens... this._advance(); @@ -105,6 +116,63 @@ class TreeBuilder { this._addToParent(new HtmlCommentAst(value, token.sourceSpan)); } + private _consumeExpansion(token: HtmlToken) { + let switchValue = this._advance(); + + let type = this._advance(); + let cases = []; + + // 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(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)); + } + + // read the final } + if (this.peek.type !== HtmlTokenType.EXPANSION_FORM_END) { + this.errors.push( + HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid expansion form. Missing '}'.`)); + return; + } + this._advance(); + + let mainSourceSpan = new ParseSourceSpan(token.sourceSpan.start, this.peek.sourceSpan.end); + this._addToParent(new HtmlExpansionAst(switchValue.parts[0], type.parts[0], cases, + mainSourceSpan, switchValue.sourceSpan)); + } + private _consumeText(token: HtmlToken) { let text = token.parts[0]; if (text.length > 0 && text[0] == '\n') { diff --git a/modules/angular2/test/compiler/html_parser_spec.ts b/modules/angular2/test/compiler/html_parser_spec.ts index 3af628f315eb..403deb3d4f2d 100644 --- a/modules/angular2/test/compiler/html_parser_spec.ts +++ b/modules/angular2/test/compiler/html_parser_spec.ts @@ -18,7 +18,9 @@ import { HtmlAttrAst, HtmlTextAst, HtmlCommentAst, - htmlVisitAll + htmlVisitAll, + HtmlExpansionAst, + HtmlExpansionCaseAst } from 'angular2/src/compiler/html_ast'; import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util'; import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn} from './html_ast_spec_utils'; @@ -227,7 +229,7 @@ export function main() { .toEqual([[HtmlElementAst, 'template', 0], [HtmlAttrAst, 'k', 'v']]); }); - it('should support mamespace', () => { + it('should support namespace', () => { expect(humanizeDom(parser.parse('', 'TestComp'))) .toEqual([[HtmlElementAst, '@svg:use', 0], [HtmlAttrAst, '@xlink:href', 'Port']]); }); @@ -240,6 +242,54 @@ export function main() { }); }); + describe("expansion forms", () => { + it("should parse out expansion forms", () => { + let parsed = parser.parse(`
before{messages.length, plural, =0 {You have no messages} =1 {One {{message}}}}after
`, + 'TestComp', true); + + expect(humanizeDom(parsed)) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlTextAst, 'before', 1], + [HtmlExpansionAst, 'messages.length', 'plural'], + [HtmlExpansionCaseAst, '0'], + [HtmlExpansionCaseAst, '1'], + [HtmlTextAst, 'after', 1] + ]); + + let cases = (parsed.rootNodes[0]).children[1].cases; + + expect(humanizeDom(new HtmlParseTreeResult(cases[0].expression, []))) + .toEqual([ + [HtmlTextAst, 'You have ', 0], + [HtmlElementAst, 'b', 0], + [HtmlTextAst, 'no', 1], + [HtmlTextAst, ' messages', 0], + ]); + + expect(humanizeDom(new HtmlParseTreeResult(cases[1].expression, []))) + .toEqual([[HtmlTextAst, 'One {{message}}', 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)) + .toEqual([[null, "Invalid expansion form. Missing '}'.", '0:34']]); + }); + + it("should error when expansion case is not closed", () => { + let p = parser.parse(`{messages.length, plural, =0 {one`, 'TestComp', true); + expect(humanizeErrors(p.errors)) + .toEqual([[null, "Invalid expansion form. Missing '}'.", '0:29']]); + }); + + it("should error when invalid html in the case", () => { + let p = parser.parse(`{messages.length, plural, =0 {}`, 'TestComp', true); + expect(humanizeErrors(p.errors)) + .toEqual([['b', 'Only void and foreign elements can be self closed "b"', '0:30']]); + }); + }); + describe('source spans', () => { it('should store the location', () => { expect(humanizeDomSourceSpans(parser.parse( From be1ced5b1065a891c1898dd5d49a379f6162dac3 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 12 Apr 2016 11:46:49 -0700 Subject: [PATCH 3/5] 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 e6d440c9ebe6..5ffabda9ace6 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 d1f18b47dfad..6da9687043de 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 6adbf78a4a46..d26b9e8ac171 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 1c08e1589404..1c332773f32a 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 000000000000..6a27dd637340 --- /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 a009192df8d5..23b09735a050 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 a923aae4ac6b..7da4ee0f79e3 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 d1ebd8a7a2ff..d46798f75db3 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 a59476917fa5..af58c224f66d 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 68c505fc57b1..65ebe42450fb 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 f7f8541ad597..430b62416183 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 From be4afc10382d7749308c438e5906cdd706b8bcbc Mon Sep 17 00:00:00 2001 From: vsavkin Date: Tue, 12 Apr 2016 12:03:38 -0700 Subject: [PATCH 4/5] cleanup(html_parser): cleanup to fix analyzer warnings --- modules/angular2/src/compiler/html_parser.ts | 2 +- modules/angular2/src/compiler/legacy_template.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/angular2/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index 56b522f559b9..0bd124cbe4ab 100644 --- a/modules/angular2/src/compiler/html_parser.ts +++ b/modules/angular2/src/compiler/html_parser.ts @@ -150,7 +150,7 @@ class TreeBuilder { // parse everything in between { and } let parsedExp = new TreeBuilder(exp).build(); if (parsedExp.errors.length > 0) { - this.errors = this.errors.concat(parsedExp.errors); + this.errors = this.errors.concat(parsedExp.errors); return; } diff --git a/modules/angular2/src/compiler/legacy_template.ts b/modules/angular2/src/compiler/legacy_template.ts index d26b9e8ac171..3a58f7c7a1bd 100644 --- a/modules/angular2/src/compiler/legacy_template.ts +++ b/modules/angular2/src/compiler/legacy_template.ts @@ -88,7 +88,8 @@ export class LegacyHtmlAstTransformer implements HtmlAstVisitor { 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); + return new HtmlExpansionAst(ast.switchValue, ast.type, cases, ast.sourceSpan, + ast.switchValueSourceSpan); } visitExpansionCase(ast: HtmlExpansionCaseAst, context: any): any { return ast; } From 7c5b59084ab261807c85400188aa8eaef605947c Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 13 Apr 2016 16:01:25 -0700 Subject: [PATCH 5/5] feat(i18n): add support for nested expansion forms --- modules/angular2/src/compiler/html_lexer.ts | 33 +++-- modules/angular2/src/compiler/html_parser.ts | 115 ++++++++++++------ .../angular2/src/compiler/template_parser.ts | 7 +- modules/angular2/src/i18n/expander.ts | 71 +++++++---- modules/angular2/src/i18n/i18n_html_parser.ts | 24 ++-- .../angular2/src/i18n/message_extractor.ts | 9 +- .../angular2/test/compiler/html_lexer_spec.ts | 25 ++++ .../test/compiler/html_parser_spec.ts | 21 ++++ .../test/i18n/i18n_html_parser_spec.ts | 35 +++++- 9 files changed, 246 insertions(+), 94 deletions(-) diff --git a/modules/angular2/src/compiler/html_lexer.ts b/modules/angular2/src/compiler/html_lexer.ts index 76f48683d9ac..bedab75ca837 100644 --- a/modules/angular2/src/compiler/html_lexer.ts +++ b/modules/angular2/src/compiler/html_lexer.ts @@ -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 { diff --git a/modules/angular2/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index 0bd124cbe4ab..14437d0d9ad3 100644 --- a/modules/angular2/src/compiler/html_parser.ts +++ b/modules/angular2/src/compiler/html_parser.ts @@ -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(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(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; +} \ No newline at end of file diff --git a/modules/angular2/src/compiler/template_parser.ts b/modules/angular2/src/compiler/template_parser.ts index 1c332773f32a..0595beedd065 100644 --- a/modules/angular2/src/compiler/template_parser.ts +++ b/modules/angular2/src/compiler/template_parser.ts @@ -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; } } diff --git a/modules/angular2/src/i18n/expander.ts b/modules/angular2/src/i18n/expander.ts index 6a27dd637340..6fc8127a9f18 100644 --- a/modules/angular2/src/i18n/expander.ts +++ b/modules/angular2/src/i18n/expander.ts @@ -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'; * * ``` */ -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); diff --git a/modules/angular2/src/i18n/i18n_html_parser.ts b/modules/angular2/src/i18n/i18n_html_parser.ts index 23b09735a050..bcf22061a416 100644 --- a/modules/angular2/src/i18n/i18n_html_parser.ts +++ b/modules/angular2/src/i18n/i18n_html_parser.ts @@ -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; diff --git a/modules/angular2/src/i18n/message_extractor.ts b/modules/angular2/src/i18n/message_extractor.ts index 7da4ee0f79e3..5630d44af73e 100644 --- a/modules/angular2/src/i18n/message_extractor.ts +++ b/modules/angular2/src/i18n/message_extractor.ts @@ -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)); diff --git a/modules/angular2/test/compiler/html_lexer_spec.ts b/modules/angular2/test/compiler/html_lexer_spec.ts index aef9b096587f..63f57d231d52 100644 --- a/modules/angular2/test/compiler/html_lexer_spec.ts +++ b/modules/angular2/test/compiler/html_lexer_spec.ts @@ -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', () => { diff --git a/modules/angular2/test/compiler/html_parser_spec.ts b/modules/angular2/test/compiler/html_parser_spec.ts index 403deb3d4f2d..945ab983aee9 100644 --- a/modules/angular2/test/compiler/html_parser_spec.ts +++ b/modules/angular2/test/compiler/html_parser_spec.ts @@ -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 = (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)) diff --git a/modules/angular2/test/i18n/i18n_html_parser_spec.ts b/modules/angular2/test/i18n/i18n_html_parser_spec.ts index 65ebe42450fb..0b4f2e93c70e 100644 --- a/modules/angular2/test/i18n/i18n_html_parser_spec.ts +++ b/modules/angular2/test/i18n/i18n_html_parser_spec.ts @@ -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('zerobold', "plural_0", null))] = 'ZEROBOLD'; @@ -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('bold', "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("
", {}).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("
some message
", {}).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", () => {