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/html_lexer.ts b/modules/angular2/src/compiler/html_lexer.ts index d3a90d74aae4..bedab75ca837 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,19 @@ 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 expansionCaseStack = []; + 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 +162,20 @@ 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.isInExpansionCase() && + this.tokenizeExpansionForms) { + this._consumeExpansionCaseEnd(); + + } else if (this.peek === $RBRACE && this.isInExpansionForm() && + this.tokenizeExpansionForms) { + this._consumeExpansionFormEnd(); + } else { this._consumeText(); } @@ -218,6 +245,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 +535,111 @@ 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.expansionCaseStack.push(HtmlTokenType.EXPANSION_FORM_START); + } + + 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.expansionCaseStack.push(HtmlTokenType.EXPANSION_CASE_EXP_START); + } + + private _consumeExpansionCaseEnd() { + this._beginToken(HtmlTokenType.EXPANSION_CASE_EXP_END, this._getLocation()); + this._requireCharCode($RBRACE); + this._endToken([], this._getLocation()); + this._attemptCharCodeUntilFn(isNotWhitespace); + + this.expansionCaseStack.pop(); + } + + private _consumeExpansionFormEnd() { + this._beginToken(HtmlTokenType.EXPANSION_FORM_END, this._getLocation()); + this._requireCharCode($RBRACE); + this._endToken([]); + + this.expansionCaseStack.pop(); + } + 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.isInExpansionCase() || this.isInExpansionForm())) + 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]; @@ -531,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 { @@ -558,8 +690,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/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index 40385eb1055a..14437d0d9ad3 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,106 @@ 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 expCase = this._parseExpansionCase(); + if (isBlank(expCase)) return; // error + cases.push(expCase); + } + + // 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 _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') { @@ -253,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/legacy_template.ts b/modules/angular2/src/compiler/legacy_template.ts index 6adbf78a4a46..3a58f7c7a1bd 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,14 @@ 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, + ast.switchValueSourceSpan); + } + + 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 +221,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..0595beedd065 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'; @@ -252,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); @@ -770,6 +776,8 @@ 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..6fc8127a9f18 --- /dev/null +++ b/modules/angular2/src/i18n/expander.ts @@ -0,0 +1,116 @@ +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 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 { + 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 { + this.expanded = true; + 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 => { + 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 => { + 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); +} \ 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..bcf22061a416 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 {expandNodes} from './expander'; import { messageFromAttribute, I18nError, @@ -119,14 +122,15 @@ 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, true); if (res.errors.length > 0) { return res; } else { - let nodes = this._recurse(res.rootNodes); + let nodes = this._recurse(expandNodes(res.rootNodes).nodes); return this.errors.length > 0 ? new HtmlParseTreeResult([], this.errors) : new HtmlParseTreeResult(nodes, []); } @@ -146,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]; @@ -285,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; @@ -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..5630d44af73e 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 {expandNodes} from './expander'; import { I18nError, Part, @@ -121,11 +122,11 @@ 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(expandNodes(res.rootNodes).nodes); return new ExtractionResult(this.messages, this.errors); } } 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/compiler/html_lexer_spec.ts b/modules/angular2/test/compiler/html_lexer_spec.ts index a4c32142a871..63f57d231d52 100644 --- a/modules/angular2/test/compiler/html_lexer_spec.ts +++ b/modules/angular2/test/compiler/html_lexer_spec.ts @@ -576,6 +576,103 @@ 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] + ]); + }); + + 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', () => { it('should include 2 lines of context in message', () => { let src = "111\n222\n333\nE\n444\n555\n666\n"; @@ -604,8 +701,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 +711,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[] { diff --git a/modules/angular2/test/compiler/html_parser_spec.ts b/modules/angular2/test/compiler/html_parser_spec.ts index 3af628f315eb..945ab983aee9 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,75 @@ 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 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)) + .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( diff --git a/modules/angular2/test/i18n/i18n_html_parser_spec.ts b/modules/angular2/test/i18n/i18n_html_parser_spec.ts index 68c505fc57b1..0b4f2e93c70e 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,107 @@ export function main() { expect(res[1].sourceSpan.start.offset).toEqual(10); }); + it("should handle the plural expansion 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 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))] = + '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)) @@ -197,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", () => { 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