From b6fa3cd659461126dbab406ed9c6f1b61e74725f Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 23 Mar 2016 13:41:18 -0700 Subject: [PATCH 1/5] feat(facade): add RegExpWrapper.replaceAll to replace all matches using the provided function --- modules/angular2/src/facade/lang.dart | 14 ++++++++++++++ modules/angular2/src/facade/lang.ts | 15 +++++++++++++++ modules/angular2/test/facade/lang_spec.ts | 6 ++++++ 3 files changed, 35 insertions(+) diff --git a/modules/angular2/src/facade/lang.dart b/modules/angular2/src/facade/lang.dart index 41f7a8dd588f..ebc009888947 100644 --- a/modules/angular2/src/facade/lang.dart +++ b/modules/angular2/src/facade/lang.dart @@ -215,6 +215,20 @@ class RegExpWrapper { static Iterator matcher(RegExp regExp, String input) { return regExp.allMatches(input).iterator; } + + static String replaceAll(RegExp regExp, String input, Function replace) { + final m = RegExpWrapper.matcher(regExp, input); + var res = ""; + var prev = 0; + while(m.moveNext()) { + var c = m.current; + res += input.substring(prev, c.start); + res += replace(c); + prev = c.start + c[0].length; + } + res += input.substring(prev); + return res; + } } class RegExpMatcherWrapper { diff --git a/modules/angular2/src/facade/lang.ts b/modules/angular2/src/facade/lang.ts index edfb50dc5bab..86b21f6cf628 100644 --- a/modules/angular2/src/facade/lang.ts +++ b/modules/angular2/src/facade/lang.ts @@ -344,6 +344,21 @@ export class RegExpWrapper { regExp.lastIndex = 0; return {re: regExp, input: input}; } + static replaceAll(regExp: RegExp, input: string, replace: Function): string { + let c = regExp.exec(input); + let res = ''; + regExp.lastIndex = 0; + let prev = 0; + while (c) { + res += input.substring(prev, c.index); + res += replace(c); + prev = c.index + c[0].length; + regExp.lastIndex = prev; + c = regExp.exec(input); + } + res += input.substring(prev); + return res; + } } export class RegExpMatcherWrapper { diff --git a/modules/angular2/test/facade/lang_spec.ts b/modules/angular2/test/facade/lang_spec.ts index 67c010487a23..4caef714445e 100644 --- a/modules/angular2/test/facade/lang_spec.ts +++ b/modules/angular2/test/facade/lang_spec.ts @@ -42,6 +42,12 @@ export function main() { // If not reset, the second attempt to test results in false expect(RegExpWrapper.test(re, str)).toEqual(true); }); + + it("should implement replace all", () => { + let re = /(\d)+/g; + let m = RegExpWrapper.replaceAll(re, 'a1b2c', (match) => `!${match[1]}!`); + expect(m).toEqual('a!1!b!2!c'); + }); }); describe('const', () => { From 9d0f848d3a5574c99c781635f2017199f8d36e23 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 23 Mar 2016 13:42:19 -0700 Subject: [PATCH 2/5] feat(facade): add ListWrapper.flatten --- modules/angular2/src/facade/collection.dart | 8 ++++++++ modules/angular2/src/facade/collection.ts | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/modules/angular2/src/facade/collection.dart b/modules/angular2/src/facade/collection.dart index b903e584f2d0..5768c09af1aa 100644 --- a/modules/angular2/src/facade/collection.dart +++ b/modules/angular2/src/facade/collection.dart @@ -232,6 +232,14 @@ class ListWrapper { static bool isImmutable(List l) { return l is UnmodifiableListView; } + + static List flatten(List l) { + final res = []; + l.forEach((item) { + res.addAll(item); + }); + return res; + } } bool isListLikeIterable(obj) => obj is Iterable; diff --git a/modules/angular2/src/facade/collection.ts b/modules/angular2/src/facade/collection.ts index ef6587492c47..636bd4657dc7 100644 --- a/modules/angular2/src/facade/collection.ts +++ b/modules/angular2/src/facade/collection.ts @@ -278,6 +278,11 @@ export class ListWrapper { } static isImmutable(list: any[]): boolean { return Object.isSealed(list); } + static flatten(array: T[][]): T[] { + let res = []; + array.forEach((a) => res = res.concat(a)); + return res; + } } export function isListLikeIterable(obj: any): boolean { From 0920f84bf5d3a312b693a2b32b1aa2722e4afd47 Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 23 Mar 2016 13:43:28 -0700 Subject: [PATCH 3/5] feat(html_parser): change HtmlElementAst to store both the start and the end positions --- modules/angular2/src/compiler/html_ast.ts | 3 +- modules/angular2/src/compiler/html_parser.ts | 10 ++- .../angular2/src/compiler/legacy_template.ts | 3 +- .../test/compiler/html_ast_spec_utils.ts | 78 +++++++++++++++++ .../test/compiler/html_parser_spec.ts | 86 ++++--------------- 5 files changed, 104 insertions(+), 76 deletions(-) create mode 100644 modules/angular2/test/compiler/html_ast_spec_utils.ts diff --git a/modules/angular2/src/compiler/html_ast.ts b/modules/angular2/src/compiler/html_ast.ts index c18be31942dd..d1f18b47dfad 100644 --- a/modules/angular2/src/compiler/html_ast.ts +++ b/modules/angular2/src/compiler/html_ast.ts @@ -19,7 +19,8 @@ export class HtmlAttrAst implements HtmlAst { export class HtmlElementAst implements HtmlAst { constructor(public name: string, public attrs: HtmlAttrAst[], public children: HtmlAst[], - public sourceSpan: ParseSourceSpan) {} + public sourceSpan: ParseSourceSpan, public startSourceSpan: ParseSourceSpan, + public endSourceSpan: ParseSourceSpan) {} visit(visitor: HtmlAstVisitor, context: any): any { return visitor.visitElement(this, context); } } diff --git a/modules/angular2/src/compiler/html_parser.ts b/modules/angular2/src/compiler/html_parser.ts index d1eb270c8454..40385eb1055a 100644 --- a/modules/angular2/src/compiler/html_parser.ts +++ b/modules/angular2/src/compiler/html_parser.ts @@ -154,11 +154,12 @@ class TreeBuilder { selfClosing = false; } var end = this.peek.sourceSpan.start; - var el = new HtmlElementAst(fullName, attrs, [], - new ParseSourceSpan(startTagToken.sourceSpan.start, end)); + let span = new ParseSourceSpan(startTagToken.sourceSpan.start, end); + var el = new HtmlElementAst(fullName, attrs, [], span, span, null); this._pushElement(el); if (selfClosing) { this._popElement(fullName); + el.endSourceSpan = span; } } @@ -173,7 +174,8 @@ class TreeBuilder { var tagDef = getHtmlTagDefinition(el.name); var parentEl = this._getParentElement(); if (tagDef.requireExtraParent(isPresent(parentEl) ? parentEl.name : null)) { - var newParent = new HtmlElementAst(tagDef.parentToAdd, [], [el], el.sourceSpan); + var newParent = new HtmlElementAst(tagDef.parentToAdd, [], [el], el.sourceSpan, + el.startSourceSpan, el.endSourceSpan); this._addToParent(newParent); this.elementStack.push(newParent); this.elementStack.push(el); @@ -187,6 +189,8 @@ class TreeBuilder { var fullName = getElementFullName(endTagToken.parts[0], endTagToken.parts[1], this._getParentElement()); + this._getParentElement().endSourceSpan = endTagToken.sourceSpan; + if (getHtmlTagDefinition(fullName).isVoid) { this.errors.push( HtmlTreeError.create(fullName, endTagToken.sourceSpan, diff --git a/modules/angular2/src/compiler/legacy_template.ts b/modules/angular2/src/compiler/legacy_template.ts index 009810c64566..6adbf78a4a46 100644 --- a/modules/angular2/src/compiler/legacy_template.ts +++ b/modules/angular2/src/compiler/legacy_template.ts @@ -50,7 +50,8 @@ export class LegacyHtmlAstTransformer implements HtmlAstVisitor { this.visitingTemplateEl = ast.name.toLowerCase() == 'template'; let attrs = ast.attrs.map(attr => attr.visit(this, null)); let children = ast.children.map(child => child.visit(this, null)); - return new HtmlElementAst(ast.name, attrs, children, ast.sourceSpan); + return new HtmlElementAst(ast.name, attrs, children, ast.sourceSpan, ast.startSourceSpan, + ast.endSourceSpan); } visitAttr(originalAst: HtmlAttrAst, context: any): HtmlAttrAst { diff --git a/modules/angular2/test/compiler/html_ast_spec_utils.ts b/modules/angular2/test/compiler/html_ast_spec_utils.ts new file mode 100644 index 000000000000..a59476917fa5 --- /dev/null +++ b/modules/angular2/test/compiler/html_ast_spec_utils.ts @@ -0,0 +1,78 @@ +import {HtmlParser, HtmlParseTreeResult, HtmlTreeError} from 'angular2/src/compiler/html_parser'; +import { + HtmlAst, + HtmlAstVisitor, + HtmlElementAst, + HtmlAttrAst, + HtmlTextAst, + HtmlCommentAst, + htmlVisitAll +} from 'angular2/src/compiler/html_ast'; +import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util'; +import {BaseException} from 'angular2/src/facade/exceptions'; + +export function humanizeDom(parseResult: HtmlParseTreeResult): any[] { + if (parseResult.errors.length > 0) { + var errorString = parseResult.errors.join('\n'); + throw new BaseException(`Unexpected parse errors:\n${errorString}`); + } + + var humanizer = new _Humanizer(false); + htmlVisitAll(humanizer, parseResult.rootNodes); + return humanizer.result; +} + +export function humanizeDomSourceSpans(parseResult: HtmlParseTreeResult): any[] { + if (parseResult.errors.length > 0) { + var errorString = parseResult.errors.join('\n'); + throw new BaseException(`Unexpected parse errors:\n${errorString}`); + } + + var humanizer = new _Humanizer(true); + htmlVisitAll(humanizer, parseResult.rootNodes); + return humanizer.result; +} + +export function humanizeLineColumn(location: ParseLocation): string { + return `${location.line}:${location.col}`; +} + +class _Humanizer implements HtmlAstVisitor { + result: any[] = []; + elDepth: number = 0; + + constructor(private includeSourceSpan: boolean){}; + + visitElement(ast: HtmlElementAst, context: any): any { + var res = this._appendContext(ast, [HtmlElementAst, ast.name, this.elDepth++]); + this.result.push(res); + htmlVisitAll(this, ast.attrs); + htmlVisitAll(this, ast.children); + this.elDepth--; + return null; + } + + visitAttr(ast: HtmlAttrAst, context: any): any { + var res = this._appendContext(ast, [HtmlAttrAst, ast.name, ast.value]); + this.result.push(res); + return null; + } + + visitText(ast: HtmlTextAst, context: any): any { + var res = this._appendContext(ast, [HtmlTextAst, ast.value, this.elDepth]); + this.result.push(res); + return null; + } + + visitComment(ast: HtmlCommentAst, context: any): any { + var res = this._appendContext(ast, [HtmlCommentAst, ast.value, this.elDepth]); + this.result.push(res); + return null; + } + + private _appendContext(ast: HtmlAst, input: any[]): any[] { + if (!this.includeSourceSpan) return input; + input.push(ast.sourceSpan.toString()); + return input; + } +} diff --git a/modules/angular2/test/compiler/html_parser_spec.ts b/modules/angular2/test/compiler/html_parser_spec.ts index b87337c2bae7..3af628f315eb 100644 --- a/modules/angular2/test/compiler/html_parser_spec.ts +++ b/modules/angular2/test/compiler/html_parser_spec.ts @@ -20,9 +20,8 @@ import { HtmlCommentAst, htmlVisitAll } from 'angular2/src/compiler/html_ast'; -import {ParseError, ParseLocation, ParseSourceSpan} from 'angular2/src/compiler/parse_util'; - -import {BaseException} from 'angular2/src/facade/exceptions'; +import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util'; +import {humanizeDom, humanizeDomSourceSpans, humanizeLineColumn} from './html_ast_spec_utils'; export function main() { describe('HtmlParser', () => { @@ -51,6 +50,7 @@ export function main() { }); }); + describe('elements', () => { it('should parse root level elements', () => { expect(humanizeDom(parser.parse('
', 'TestComp'))) @@ -253,6 +253,16 @@ export function main() { [HtmlTextAst, '\na\n', 1, '\na\n'], ]); }); + + it('should set the start and end source spans', () => { + let node = parser.parse('
a
', 'TestComp').rootNodes[0]; + + expect(node.startSourceSpan.start.offset).toEqual(0); + expect(node.startSourceSpan.end.offset).toEqual(5); + + expect(node.endSourceSpan.start.offset).toEqual(6); + expect(node.endSourceSpan.end.offset).toEqual(12); + }); }); describe('errors', () => { @@ -299,33 +309,7 @@ export function main() { }); } -function humanizeDom(parseResult: HtmlParseTreeResult): any[] { - if (parseResult.errors.length > 0) { - var errorString = parseResult.errors.join('\n'); - throw new BaseException(`Unexpected parse errors:\n${errorString}`); - } - - var humanizer = new Humanizer(false); - htmlVisitAll(humanizer, parseResult.rootNodes); - return humanizer.result; -} - -function humanizeDomSourceSpans(parseResult: HtmlParseTreeResult): any[] { - if (parseResult.errors.length > 0) { - var errorString = parseResult.errors.join('\n'); - throw new BaseException(`Unexpected parse errors:\n${errorString}`); - } - - var humanizer = new Humanizer(true); - htmlVisitAll(humanizer, parseResult.rootNodes); - return humanizer.result; -} - -function humanizeLineColumn(location: ParseLocation): string { - return `${location.line}:${location.col}`; -} - -function humanizeErrors(errors: ParseError[]): any[] { +export function humanizeErrors(errors: ParseError[]): any[] { return errors.map(error => { if (error instanceof HtmlTreeError) { // Parser errors @@ -334,44 +318,4 @@ function humanizeErrors(errors: ParseError[]): any[] { // Tokenizer errors return [(error).tokenType, error.msg, humanizeLineColumn(error.span.start)]; }); -} - -class Humanizer implements HtmlAstVisitor { - result: any[] = []; - elDepth: number = 0; - - constructor(private includeSourceSpan: boolean){}; - - visitElement(ast: HtmlElementAst, context: any): any { - var res = this._appendContext(ast, [HtmlElementAst, ast.name, this.elDepth++]); - this.result.push(res); - htmlVisitAll(this, ast.attrs); - htmlVisitAll(this, ast.children); - this.elDepth--; - return null; - } - - visitAttr(ast: HtmlAttrAst, context: any): any { - var res = this._appendContext(ast, [HtmlAttrAst, ast.name, ast.value]); - this.result.push(res); - return null; - } - - visitText(ast: HtmlTextAst, context: any): any { - var res = this._appendContext(ast, [HtmlTextAst, ast.value, this.elDepth]); - this.result.push(res); - return null; - } - - visitComment(ast: HtmlCommentAst, context: any): any { - var res = this._appendContext(ast, [HtmlCommentAst, ast.value, this.elDepth]); - this.result.push(res); - return null; - } - - private _appendContext(ast: HtmlAst, input: any[]): any[] { - if (!this.includeSourceSpan) return input; - input.push(ast.sourceSpan.toString()); - return input; - } -} +} \ No newline at end of file From 2a7d044c0e941381321a91aca24cb5e7d42be1df Mon Sep 17 00:00:00 2001 From: vsavkin Date: Wed, 23 Mar 2016 13:44:45 -0700 Subject: [PATCH 4/5] refactor(i18n): remove utility functions into a separate file --- modules/angular2/src/i18n/message.ts | 2 +- .../angular2/src/i18n/message_extractor.ts | 227 ++++++------------ modules/angular2/src/i18n/shared.ts | 169 +++++++++++++ .../test/i18n/message_extractor_spec.ts | 90 ++++--- 4 files changed, 300 insertions(+), 188 deletions(-) create mode 100644 modules/angular2/src/i18n/shared.ts diff --git a/modules/angular2/src/i18n/message.ts b/modules/angular2/src/i18n/message.ts index 2ad922d2ffde..2a1798c58d1e 100644 --- a/modules/angular2/src/i18n/message.ts +++ b/modules/angular2/src/i18n/message.ts @@ -8,7 +8,7 @@ import {isPresent, escape} from 'angular2/src/facade/lang'; * `description` is additional information provided to the translator. */ export class Message { - constructor(public content: string, public meaning: string, public description: string) {} + constructor(public content: string, public meaning: string, public description: string = null) {} } /** diff --git a/modules/angular2/src/i18n/message_extractor.ts b/modules/angular2/src/i18n/message_extractor.ts index 21fabe9592e3..bae8d5f3450e 100644 --- a/modules/angular2/src/i18n/message_extractor.ts +++ b/modules/angular2/src/i18n/message_extractor.ts @@ -12,11 +12,17 @@ import { import {isPresent, isBlank} from 'angular2/src/facade/lang'; import {StringMapWrapper} from 'angular2/src/facade/collection'; import {Parser} from 'angular2/src/core/change_detection/parser/parser'; -import {Interpolation} from 'angular2/src/core/change_detection/parser/ast'; import {Message, id} from './message'; - -const I18N_ATTR = "i18n"; -const I18N_ATTR_PREFIX = "i18n-"; +import { + I18nError, + Part, + partition, + meaning, + description, + isI18nAttr, + stringifyNodes, + messageFromAttribute +} from './shared'; /** * All messages extracted from a template. @@ -25,13 +31,6 @@ export class ExtractionResult { constructor(public messages: Message[], public errors: ParseError[]) {} } -/** - * An extraction error. - */ -export class I18nExtractionError extends ParseError { - constructor(span: ParseSourceSpan, msg: string) { super(span, msg); } -} - /** * Removes duplicate messages. * @@ -56,20 +55,61 @@ export function removeDuplicates(messages: Message[]): Message[] { /** * Extracts all messages from a template. * - * It works like this. First, the extractor uses the provided html parser to get - * the html AST of the template. Then it partitions the root nodes into parts. - * Everything between two i18n comments becomes a single part. Every other nodes becomes - * a part too. + * Algorithm: + * + * To understand the algorithm, you need to know how partitioning works. + * Partitioning is required as we can use two i18n comments to group node siblings together. + * That is why we cannot just use nodes. + * + * Partitioning transforms an array of HtmlAst into an array of Part. + * A part can optionally contain a root element or a root text node. And it can also contain + * children. + * A part can contain i18n property, in which case it needs to be extracted. + * + * Example: * - * We process every part as follows. Say we have a part A. + * The following array of nodes will be split into four parts: * - * If the part has the i18n attribute, it gets converted into a message. - * And we do not recurse into that part, except to extract messages from the attributes. + * ``` + * A + * B + * + * C + * D + * + * E + * ``` + * + * Part 1 containing the a tag. It should not be translated. + * Part 2 containing the b tag. It should be translated. + * Part 3 containing the c tag and the D text node. It should be translated. + * Part 4 containing the E text node. It should not be translated.. + * + * It is also important to understand how we stringify nodes to create a message. + * + * We walk the tree and replace every element node with a placeholder. We also replace + * all expressions in interpolation with placeholders. We also insert a placeholder element + * to wrap a text node containing interpolation. * - * If the part doesn't have the i18n attribute, we recurse into that part and - * partition its children. + * Example: + * + * The following tree: + * + * ``` + * A{{I}}B + * ``` * - * While walking the AST we also remove i18n attributes from messages. + * will be stringified into: + * ``` + * AB + * ``` + * + * This is what the algorithm does: + * + * 1. Use the provided html parser to get the html AST of the template. + * 2. Partition the root nodes, and process each part separately. + * 3. If a part does not have the i18n attribute, recurse to process children and attributes. + * 4. If a part has the i18n attribute, stringify the nodes to create a Message. */ export class MessageExtractor { messages: Message[]; @@ -85,16 +125,14 @@ export class MessageExtractor { if (res.errors.length > 0) { return new ExtractionResult([], res.errors); } else { - let ps = this._partition(res.rootNodes); - ps.forEach(p => this._extractMessagesFromPart(p)); + this._recurse(res.rootNodes); return new ExtractionResult(this.messages, this.errors); } } - private _extractMessagesFromPart(p: _Part): void { + private _extractMessagesFromPart(p: Part): void { if (p.hasI18n) { - this.messages.push(new Message(_stringifyNodes(p.children, this._parser), _meaning(p.i18n), - _description(p.i18n))); + this.messages.push(p.createMessage(this._parser)); this._recurseToExtractMessagesFromAttributes(p.children); } else { this._recurse(p.children); @@ -106,8 +144,10 @@ export class MessageExtractor { } private _recurse(nodes: HtmlAst[]): void { - let ps = this._partition(nodes); - ps.forEach(p => this._extractMessagesFromPart(p)); + if (isPresent(nodes)) { + let ps = partition(nodes, this.errors); + ps.forEach(p => this._extractMessagesFromPart(p)); + } } private _recurseToExtractMessagesFromAttributes(nodes: HtmlAst[]): void { @@ -121,130 +161,17 @@ export class MessageExtractor { private _extractMessagesFromAttributes(p: HtmlElementAst): void { p.attrs.forEach(attr => { - if (attr.name.startsWith(I18N_ATTR_PREFIX)) { - let expectedName = attr.name.substring(5); - let matching = p.attrs.filter(a => a.name == expectedName); - - if (matching.length > 0) { - let value = _removeInterpolation(matching[0].value, p.sourceSpan, this._parser); - this.messages.push(new Message(value, _meaning(attr.value), _description(attr.value))); - } else { - this.errors.push( - new I18nExtractionError(p.sourceSpan, `Missing attribute '${expectedName}'.`)); - } - } - }); - } - - // Man, this is so ugly! - private _partition(nodes: HtmlAst[]): _Part[] { - let res = []; - - for (let i = 0; i < nodes.length; ++i) { - let n = nodes[i]; - let temp = []; - if (_isOpeningComment(n)) { - let i18n = (n).value.substring(5).trim(); - i++; - while (!_isClosingComment(nodes[i])) { - temp.push(nodes[i++]); - if (i === nodes.length) { - this.errors.push( - new I18nExtractionError(n.sourceSpan, "Missing closing 'i18n' comment.")); - break; + if (isI18nAttr(attr.name)) { + try { + this.messages.push(messageFromAttribute(this._parser, p, attr)); + } catch (e) { + if (e instanceof I18nError) { + this.errors.push(e); + } else { + throw e; } } - res.push(new _Part(null, temp, i18n, true)); - - } else if (n instanceof HtmlElementAst) { - let i18n = _findI18nAttr(n); - res.push(new _Part(n, n.children, isPresent(i18n) ? i18n.value : null, isPresent(i18n))); - } - } - - return res; - } -} - -class _Part { - constructor(public rootElement: HtmlElementAst, public children: HtmlAst[], public i18n: string, - public hasI18n: boolean) {} -} - -function _isOpeningComment(n: HtmlAst): boolean { - return n instanceof HtmlCommentAst && isPresent(n.value) && n.value.startsWith("i18n:"); -} - -function _isClosingComment(n: HtmlAst): boolean { - return n instanceof HtmlCommentAst && isPresent(n.value) && n.value == "/i18n"; -} - -function _stringifyNodes(nodes: HtmlAst[], parser: Parser) { - let visitor = new _StringifyVisitor(parser); - return htmlVisitAll(visitor, nodes).join(""); -} - -class _StringifyVisitor implements HtmlAstVisitor { - constructor(private _parser: Parser) {} - - visitElement(ast: HtmlElementAst, context: any): any { - let attrs = this._join(htmlVisitAll(this, ast.attrs), " "); - let children = this._join(htmlVisitAll(this, ast.children), ""); - return `<${ast.name} ${attrs}>${children}`; - } - - visitAttr(ast: HtmlAttrAst, context: any): any { - if (ast.name.startsWith(I18N_ATTR_PREFIX)) { - return ""; - } else { - return `${ast.name}="${ast.value}"`; - } - } - - visitText(ast: HtmlTextAst, context: any): any { - return _removeInterpolation(ast.value, ast.sourceSpan, this._parser); - } - - visitComment(ast: HtmlCommentAst, context: any): any { return ""; } - - private _join(strs: string[], str: string): string { - return strs.filter(s => s.length > 0).join(str); - } -} - -function _removeInterpolation(value: string, source: ParseSourceSpan, parser: Parser): string { - try { - let parsed = parser.parseInterpolation(value, source.toString()); - if (isPresent(parsed)) { - let ast: Interpolation = parsed.ast; - let res = ""; - for (let i = 0; i < ast.strings.length; ++i) { - res += ast.strings[i]; - if (i != ast.strings.length - 1) { - res += `{{I${i}}}`; - } } - return res; - } else { - return value; - } - } catch (e) { - return value; + }); } -} - -function _findI18nAttr(p: HtmlElementAst): HtmlAttrAst { - let i18n = p.attrs.filter(a => a.name == I18N_ATTR); - return i18n.length == 0 ? null : i18n[0]; -} - -function _meaning(i18n: string): string { - if (isBlank(i18n) || i18n == "") return null; - return i18n.split("|")[0]; -} - -function _description(i18n: string): string { - if (isBlank(i18n) || i18n == "") return null; - let parts = i18n.split("|"); - return parts.length > 1 ? parts[1] : null; } \ No newline at end of file diff --git a/modules/angular2/src/i18n/shared.ts b/modules/angular2/src/i18n/shared.ts new file mode 100644 index 000000000000..9f249f28803b --- /dev/null +++ b/modules/angular2/src/i18n/shared.ts @@ -0,0 +1,169 @@ +import {ParseSourceSpan, ParseError} from 'angular2/src/compiler/parse_util'; +import { + HtmlAst, + HtmlAstVisitor, + HtmlElementAst, + HtmlAttrAst, + HtmlTextAst, + HtmlCommentAst, + htmlVisitAll +} from 'angular2/src/compiler/html_ast'; +import {isPresent, isBlank} from 'angular2/src/facade/lang'; +import {Message} from './message'; +import {Parser} from 'angular2/src/core/change_detection/parser/parser'; + +const I18N_ATTR = "i18n"; +const I18N_ATTR_PREFIX = "i18n-"; + +/** + * An i18n error. + */ +export class I18nError extends ParseError { + constructor(span: ParseSourceSpan, msg: string) { super(span, msg); } +} + + +// Man, this is so ugly! +export function partition(nodes: HtmlAst[], errors: ParseError[]): Part[] { + let res = []; + + for (let i = 0; i < nodes.length; ++i) { + let n = nodes[i]; + let temp = []; + if (_isOpeningComment(n)) { + let i18n = (n).value.substring(5).trim(); + i++; + while (!_isClosingComment(nodes[i])) { + temp.push(nodes[i++]); + if (i === nodes.length) { + errors.push(new I18nError(n.sourceSpan, "Missing closing 'i18n' comment.")); + break; + } + } + res.push(new Part(null, null, temp, i18n, true)); + + } else if (n instanceof HtmlElementAst) { + let i18n = _findI18nAttr(n); + res.push(new Part(n, null, n.children, isPresent(i18n) ? i18n.value : null, isPresent(i18n))); + } else if (n instanceof HtmlTextAst) { + res.push(new Part(null, n, null, null, false)); + } + } + + return res; +} + +export class Part { + constructor(public rootElement: HtmlElementAst, public rootTextNode: HtmlTextAst, + public children: HtmlAst[], public i18n: string, public hasI18n: boolean) {} + + get sourceSpan(): ParseSourceSpan { + if (isPresent(this.rootElement)) + return this.rootElement.sourceSpan; + else if (isPresent(this.rootTextNode)) + return this.rootTextNode.sourceSpan; + else + return this.children[0].sourceSpan; + } + + createMessage(parser: Parser): Message { + return new Message(stringifyNodes(this.children, parser), meaning(this.i18n), + description(this.i18n)); + } +} + +function _isOpeningComment(n: HtmlAst): boolean { + return n instanceof HtmlCommentAst && isPresent(n.value) && n.value.startsWith("i18n:"); +} + +function _isClosingComment(n: HtmlAst): boolean { + return n instanceof HtmlCommentAst && isPresent(n.value) && n.value == "/i18n"; +} + +export function isI18nAttr(n: string): boolean { + return n.startsWith(I18N_ATTR_PREFIX); +} + +function _findI18nAttr(p: HtmlElementAst): HtmlAttrAst { + let i18n = p.attrs.filter(a => a.name == I18N_ATTR); + return i18n.length == 0 ? null : i18n[0]; +} + +export function meaning(i18n: string): string { + if (isBlank(i18n) || i18n == "") return null; + return i18n.split("|")[0]; +} + +export function description(i18n: string): string { + if (isBlank(i18n) || i18n == "") return null; + let parts = i18n.split("|"); + return parts.length > 1 ? parts[1] : null; +} + +export function messageFromAttribute(parser: Parser, p: HtmlElementAst, + attr: HtmlAttrAst): Message { + let expectedName = attr.name.substring(5); + let matching = p.attrs.filter(a => a.name == expectedName); + + if (matching.length > 0) { + let value = removeInterpolation(matching[0].value, matching[0].sourceSpan, parser); + return new Message(value, meaning(attr.value), description(attr.value)); + } else { + throw new I18nError(p.sourceSpan, `Missing attribute '${expectedName}'.`); + } +} + +export function removeInterpolation(value: string, source: ParseSourceSpan, + parser: Parser): string { + try { + let parsed = parser.splitInterpolation(value, source.toString()); + if (isPresent(parsed)) { + let res = ""; + for (let i = 0; i < parsed.strings.length; ++i) { + res += parsed.strings[i]; + if (i != parsed.strings.length - 1) { + res += ``; + } + } + return res; + } else { + return value; + } + } catch (e) { + return value; + } +} + +export function stringifyNodes(nodes: HtmlAst[], parser: Parser) { + let visitor = new _StringifyVisitor(parser); + return htmlVisitAll(visitor, nodes).join(""); +} + +class _StringifyVisitor implements HtmlAstVisitor { + private _index: number = 0; + constructor(private _parser: Parser) {} + + visitElement(ast: HtmlElementAst, context: any): any { + let name = this._index++; + let children = this._join(htmlVisitAll(this, ast.children), ""); + return `${children}`; + } + + visitAttr(ast: HtmlAttrAst, context: any): any { return null; } + + visitText(ast: HtmlTextAst, context: any): any { + let index = this._index++; + let noInterpolation = removeInterpolation(ast.value, ast.sourceSpan, this._parser); + if (noInterpolation != ast.value) { + return `${noInterpolation}`; + } else { + return ast.value; + } + } + + visitComment(ast: HtmlCommentAst, context: any): any { return ""; } + + private _join(strs: string[], str: string): string { + return strs.filter(s => s.length > 0).join(str); + } +} \ No newline at end of file diff --git a/modules/angular2/test/i18n/message_extractor_spec.ts b/modules/angular2/test/i18n/message_extractor_spec.ts index 7b1d1a8b3bd1..e624ad327eff 100644 --- a/modules/angular2/test/i18n/message_extractor_spec.ts +++ b/modules/angular2/test/i18n/message_extractor_spec.ts @@ -1,8 +1,8 @@ import { AsyncTestCompleter, beforeEach, - ddescribe, describe, + ddescribe, expect, iit, inject, @@ -58,18 +58,6 @@ export function main() { ]); }); - it('should error on i18n attributes without matching "real" attributes', () => { - let res = extractor.extract(` -
-
- `, - "someurl"); - - expect(res.errors.length).toEqual(1); - expect(res.errors[0].msg).toEqual("Missing attribute 'title2'."); - }); - it('should extract from partitions', () => { let res = extractor.extract(` message1 @@ -91,40 +79,39 @@ export function main() { expect(res.messages).toEqual([new Message("message1", "meaning1", "desc1")]); }); - it('should error when cannot find a matching desc', () => { - let res = extractor.extract(` - message1`, - "someUrl"); - - expect(res.errors.length).toEqual(1); - expect(res.errors[0].msg).toEqual("Missing closing 'i18n' comment."); - }); - it('should replace interpolation with placeholders (text nodes)', () => { let res = extractor.extract("
Hi {{one}} and {{two}}
", "someurl"); - expect(res.messages).toEqual([new Message("Hi {{I0}} and {{I1}}", null, null)]); + expect(res.messages) + .toEqual( + [new Message('Hi and ', null, null)]); }); it('should replace interpolation with placeholders (attributes)', () => { let res = extractor.extract("
", "someurl"); - expect(res.messages).toEqual([new Message("Hi {{I0}} and {{I1}}", null, null)]); - }); - - it('should ignore errors in interpolation', () => { - let res = extractor.extract("
Hi {{on???.s}}
", "someurl"); - expect(res.messages).toEqual([new Message("Hi {{on???.s}}", null, null)]); + expect(res.messages) + .toEqual([new Message('Hi and ', null, null)]); }); - it("should return parse errors when the template is invalid", () => { - let res = extractor.extract(" { + let res = extractor.extract( + '
zero
one
two
', "someurl"); + expect(res.messages) + .toEqual([ + new Message('zeroonetwo', null, + null) + ]); }); - it("should handle html content", () => { - let res = extractor.extract('
message
', "someurl"); - expect(res.messages).toEqual([new Message('
message
', null, null)]); + it("should handle html content with interpolation", () => { + let res = + extractor.extract('
zero{{a}}
{{b}}
', "someurl"); + expect(res.messages) + .toEqual([ + new Message( + 'zero', + null, null) + ]); }); it("should extract from nested elements", () => { @@ -143,7 +130,7 @@ export function main() { '
message
', "someurl"); expect(res.messages) .toEqual([ - new Message('
message
', null, null), + new Message('message', null, null), new Message('value', "meaning", "desc") ]); }); @@ -159,5 +146,34 @@ export function main() { new Message("message", "meaning", "desc1"), ]); }); + + describe("errors", () => { + it('should error on i18n attributes without matching "real" attributes', () => { + let res = extractor.extract(` +
+
+ `, + "someurl"); + + expect(res.errors.length).toEqual(1); + expect(res.errors[0].msg).toEqual("Missing attribute 'title2'."); + }); + + it('should error when cannot find a matching desc', () => { + let res = extractor.extract(` + message1`, + "someUrl"); + + expect(res.errors.length).toEqual(1); + expect(res.errors[0].msg).toEqual("Missing closing 'i18n' comment."); + }); + + it("should return parse errors when the template is invalid", () => { + let res = extractor.extract(" Date: Wed, 23 Mar 2016 13:45:04 -0700 Subject: [PATCH 5/5] feat(i18n): implement an i18n-aware html parser --- .../core/change_detection/parser/parser.ts | 25 +- modules/angular2/src/i18n/i18n_html_parser.ts | 353 ++++++++++++++++++ .../test/i18n/i18n_html_parser_spec.ts | 203 ++++++++++ 3 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 modules/angular2/src/i18n/i18n_html_parser.ts create mode 100644 modules/angular2/test/i18n/i18n_html_parser_spec.ts diff --git a/modules/angular2/src/core/change_detection/parser/parser.ts b/modules/angular2/src/core/change_detection/parser/parser.ts index 541890177ace..0992552bcd34 100644 --- a/modules/angular2/src/core/change_detection/parser/parser.ts +++ b/modules/angular2/src/core/change_detection/parser/parser.ts @@ -57,6 +57,10 @@ class ParseException extends BaseException { } } +export class SplitInterpolation { + constructor(public strings: string[], public expressions: string[]) {} +} + @Injectable() export class Parser { /** @internal */ @@ -118,6 +122,21 @@ export class Parser { } parseInterpolation(input: string, location: any): ASTWithSource { + let split = this.splitInterpolation(input, location); + if (split == null) return null; + + let expressions = []; + + for (let i = 0; i < split.expressions.length; ++i) { + var tokens = this._lexer.tokenize(split.expressions[i]); + var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseChain(); + expressions.push(ast); + } + + return new ASTWithSource(new Interpolation(split.strings, expressions), input, location); + } + + splitInterpolation(input: string, location: string): SplitInterpolation { var parts = StringWrapper.split(input, INTERPOLATION_REGEXP); if (parts.length <= 1) { return null; @@ -131,16 +150,14 @@ export class Parser { // fixed string strings.push(part); } else if (part.trim().length > 0) { - var tokens = this._lexer.tokenize(part); - var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseChain(); - expressions.push(ast); + expressions.push(part); } else { throw new ParseException('Blank expressions are not allowed in interpolated strings', input, `at column ${this._findInterpolationErrorColumn(parts, i)} in`, location); } } - return new ASTWithSource(new Interpolation(strings, expressions), input, location); + return new SplitInterpolation(strings, expressions); } wrapLiteralPrimitive(input: string, location: any): ASTWithSource { diff --git a/modules/angular2/src/i18n/i18n_html_parser.ts b/modules/angular2/src/i18n/i18n_html_parser.ts new file mode 100644 index 000000000000..0fb38f750b07 --- /dev/null +++ b/modules/angular2/src/i18n/i18n_html_parser.ts @@ -0,0 +1,353 @@ +import {HtmlParser, HtmlParseTreeResult} from 'angular2/src/compiler/html_parser'; +import {ParseSourceSpan, ParseError} from 'angular2/src/compiler/parse_util'; +import { + HtmlAst, + HtmlAstVisitor, + HtmlElementAst, + HtmlAttrAst, + HtmlTextAst, + HtmlCommentAst, + htmlVisitAll +} from 'angular2/src/compiler/html_ast'; +import {ListWrapper, StringMapWrapper} from 'angular2/src/facade/collection'; +import {RegExpWrapper, NumberWrapper, isPresent} from 'angular2/src/facade/lang'; +import {BaseException} from 'angular2/src/facade/exceptions'; +import {Parser} from 'angular2/src/core/change_detection/parser/parser'; +import {Message, id} from './message'; +import { + messageFromAttribute, + I18nError, + isI18nAttr, + partition, + Part, + stringifyNodes, + meaning +} from './shared'; + +const I18N_ATTR = "i18n"; +const PLACEHOLDER_ELEMENT = "ph"; +const NAME_ATTR = "name"; +const I18N_ATTR_PREFIX = "i18n-"; +let PLACEHOLDER_REGEXP = RegExpWrapper.create(`\\`); +let PLACEHOLDER_EXPANDED_REGEXP = RegExpWrapper.create(`\\\\<\\/ph\\>`); + +/** + * Creates an i18n-ed version of the parsed template. + * + * Algorithm: + * + * To understand the algorithm, you need to know how partitioning works. + * Partitioning is required as we can use two i18n comments to group node siblings together. + * That is why we cannot just use nodes. + * + * Partitioning transforms an array of HtmlAst into an array of Part. + * A part can optionally contain a root element or a root text node. And it can also contain + * children. + * A part can contain i18n property, in which case it needs to be transalted. + * + * Example: + * + * The following array of nodes will be split into four parts: + * + * ``` + * A + * B + * + * C + * D + * + * E + * ``` + * + * Part 1 containing the a tag. It should not be translated. + * Part 2 containing the b tag. It should be translated. + * Part 3 containing the c tag and the D text node. It should be translated. + * Part 4 containing the E text node. It should not be translated. + * + * + * It is also important to understand how we stringify nodes to create a message. + * + * We walk the tree and replace every element node with a placeholder. We also replace + * all expressions in interpolation with placeholders. We also insert a placeholder element + * to wrap a text node containing interpolation. + * + * Example: + * + * The following tree: + * + * ``` + * A{{I}}B + * ``` + * + * will be stringified into: + * ``` + * AB + * ``` + * + * This is what the algorithm does: + * + * 1. Use the provided html parser to get the html AST of the template. + * 2. Partition the root nodes, and process each part separately. + * 3. If a part does not have the i18n attribute, recurse to process children and attributes. + * 4. If a part has the i18n attribute, merge the translated i18n part with the original tree. + * + * This is how the merging works: + * + * 1. Use the stringify function to get the message id. Look up the message in the map. + * 2. Parse the translated message. At this point we have two trees: the original tree + * and the translated tree, where all the elements are replaced with placeholders. + * 3. Use the original tree to create a mapping Index:number -> HtmlAst. + * 4. Walk the translated tree. + * 5. If we encounter a placeholder element, get is name property. + * 6. Get the type and the index of the node using the name property. + * 7. If the type is 'e', which means element, then: + * - translate the attributes of the original element + * - recurse to merge the children + * - create a new element using the original element name, original position, + * and translated children and attributes + * 8. If the type if 't', which means text, then: + * - get the list of expressions from the original node. + * - get the string version of the interpolation subtree + * - find all the placeholders in the translated message, and replace them with the + * corresponding original expressions + */ +export class I18nHtmlParser implements HtmlParser { + errors: ParseError[]; + + constructor(private _htmlParser: HtmlParser, private _parser: Parser, + private _messages: {[key: string]: string}) {} + + parse(sourceContent: string, sourceUrl: string): HtmlParseTreeResult { + this.errors = []; + + let res = this._htmlParser.parse(sourceContent, sourceUrl); + if (res.errors.length > 0) { + return res; + } else { + let nodes = this._recurse(res.rootNodes); + return this.errors.length > 0 ? new HtmlParseTreeResult([], this.errors) : + new HtmlParseTreeResult(nodes, []); + } + } + + private _processI18nPart(p: Part): HtmlAst[] { + try { + return p.hasI18n ? this._mergeI18Part(p) : this._recurseIntoI18nPart(p); + } catch (e) { + if (e instanceof I18nError) { + this.errors.push(e); + return []; + } else { + throw e; + } + } + } + + private _mergeI18Part(p: Part): HtmlAst[] { + let messageId = id(p.createMessage(this._parser)); + if (!StringMapWrapper.contains(this._messages, messageId)) { + throw new I18nError(p.sourceSpan, `Cannot find message for id '${messageId}'`); + } + + // get the message and expand a placeholder so becomes + // we need to do it cause we use HtmlParser to parse the message + let message = _expandPlaceholder(this._messages[messageId]); + let parsedMessage = this._htmlParser.parse(message, "source"); + + if (parsedMessage.errors.length > 0) { + this.errors = this.errors.concat(parsedMessage.errors); + return []; + } else { + return this._mergeTrees(p, message, parsedMessage.rootNodes, p.children); + } + } + + private _recurseIntoI18nPart(p: Part): HtmlAst[] { + // we found an element without an i18n attribute + // we need to recurse in cause its children may have i18n set + // we also need to translate its attributes + if (isPresent(p.rootElement)) { + let root = p.rootElement; + let children = this._recurse(p.children); + let attrs = this._i18nAttributes(root); + return [ + new HtmlElementAst(root.name, attrs, children, root.sourceSpan, root.startSourceSpan, + root.endSourceSpan) + ]; + + // a text node without i18n or interpolation, nothing to do + } else if (isPresent(p.rootTextNode)) { + return [p.rootTextNode]; + + } else { + return this._recurse(p.children); + } + } + + private _recurse(nodes: HtmlAst[]): HtmlAst[] { + let ps = partition(nodes, this.errors); + return ListWrapper.flatten(ps.map(p => this._processI18nPart(p))); + } + + private _mergeTrees(p: Part, translatedSource: string, translated: HtmlAst[], + original: HtmlAst[]): HtmlAst[] { + let l = new _CreateNodeMapping(); + htmlVisitAll(l, original); + + // merge the translated tree with the original tree. + // we do it by preserving the source code position of the original tree + let merged = this._mergeTreesHelper(translatedSource, translated, l.mapping); + + // if the root element is present, we need to create a new root element with its attributes + // translated + if (isPresent(p.rootElement)) { + let root = p.rootElement; + let attrs = this._i18nAttributes(root); + return [ + new HtmlElementAst(root.name, attrs, merged, root.sourceSpan, root.startSourceSpan, + root.endSourceSpan) + ]; + + // this should never happen with a part. Parts that have root text node should not be merged. + } else if (isPresent(p.rootTextNode)) { + throw new BaseException("should not be reached"); + + } else { + return merged; + } + } + + private _mergeTreesHelper(translatedSource: string, translated: HtmlAst[], + mapping: HtmlAst[]): HtmlAst[] { + return translated.map(t => { + if (t instanceof HtmlElementAst) { + return this._mergeElementOrInterpolation(t, translatedSource, translated, mapping); + + } else if (t instanceof HtmlTextAst) { + return t; + + } else { + throw new BaseException("should not be reached"); + } + }); + } + + private _mergeElementOrInterpolation(t: HtmlElementAst, translatedSource: string, + translated: HtmlAst[], mapping: HtmlAst[]): HtmlAst { + let name = this._getName(t); + let type = name[0]; + let index = NumberWrapper.parseInt(name.substring(1), 10); + let originalNode = mapping[index]; + + if (type == "t") { + return this._mergeTextInterpolation(t, originalNode, translatedSource); + } else if (type == "e") { + return this._mergeElement(t, originalNode, mapping, translatedSource); + } else { + throw new BaseException("should not be reached"); + } + } + + private _getName(t: HtmlElementAst): string { + if (t.name != PLACEHOLDER_ELEMENT) { + throw new I18nError( + t.sourceSpan, + `Unexpected tag "${t.name}". Only "${PLACEHOLDER_ELEMENT}" tags are allowed.`); + } + let names = t.attrs.filter(a => a.name == NAME_ATTR); + if (names.length == 0) { + throw new I18nError(t.sourceSpan, `Missing "${NAME_ATTR}" attribute.`); + } + return names[0].value; + } + + private _mergeTextInterpolation(t: HtmlElementAst, originalNode: HtmlTextAst, + translatedSource: string): HtmlTextAst { + let split = + this._parser.splitInterpolation(originalNode.value, originalNode.sourceSpan.toString()); + let exps = isPresent(split) ? split.expressions : []; + + let messageSubstring = + translatedSource.substring(t.startSourceSpan.end.offset, t.endSourceSpan.start.offset); + let translated = + this._replacePlaceholdersWithExpressions(messageSubstring, exps, originalNode.sourceSpan); + + return new HtmlTextAst(translated, originalNode.sourceSpan); + } + + private _mergeElement(t: HtmlElementAst, originalNode: HtmlElementAst, mapping: HtmlAst[], + translatedSource: string): HtmlElementAst { + let children = this._mergeTreesHelper(translatedSource, t.children, mapping); + return new HtmlElementAst(originalNode.name, this._i18nAttributes(originalNode), children, + originalNode.sourceSpan, originalNode.startSourceSpan, + originalNode.endSourceSpan); + } + + private _i18nAttributes(el: HtmlElementAst): HtmlAttrAst[] { + let res = []; + el.attrs.forEach(attr => { + if (isI18nAttr(attr.name)) { + let messageId = id(messageFromAttribute(this._parser, el, attr)); + let expectedName = attr.name.substring(5); + let m = el.attrs.filter(a => a.name == expectedName)[0]; + + if (StringMapWrapper.contains(this._messages, messageId)) { + let split = this._parser.splitInterpolation(m.value, m.sourceSpan.toString()); + let exps = isPresent(split) ? split.expressions : []; + let message = this._replacePlaceholdersWithExpressions( + _expandPlaceholder(this._messages[messageId]), exps, m.sourceSpan); + res.push(new HtmlAttrAst(m.name, message, m.sourceSpan)); + + } else { + throw new I18nError(m.sourceSpan, `Cannot find message for id '${messageId}'`); + } + } + + }); + return res; + } + + private _replacePlaceholdersWithExpressions(message: string, exps: string[], + sourceSpan: ParseSourceSpan): string { + return RegExpWrapper.replaceAll(PLACEHOLDER_EXPANDED_REGEXP, message, (match) => { + let nameWithQuotes = match[2]; + let name = nameWithQuotes.substring(1, nameWithQuotes.length - 1); + let index = NumberWrapper.parseInt(name, 10); + return this._convertIntoExpression(index, exps, sourceSpan); + }); + } + + private _convertIntoExpression(index: number, exps: string[], sourceSpan: ParseSourceSpan) { + if (index >= 0 && index < exps.length) { + return `{{${exps[index]}}}`; + } else { + throw new I18nError(sourceSpan, `Invalid interpolation index '${index}'`); + } + } +} + +class _CreateNodeMapping implements HtmlAstVisitor { + mapping: HtmlAst[] = []; + + visitElement(ast: HtmlElementAst, context: any): any { + this.mapping.push(ast); + htmlVisitAll(this, ast.children); + return null; + } + + visitAttr(ast: HtmlAttrAst, context: any): any { return null; } + + visitText(ast: HtmlTextAst, context: any): any { + this.mapping.push(ast); + return null; + } + + visitComment(ast: HtmlCommentAst, context: any): any { return ""; } +} + +function _expandPlaceholder(input: string): string { + return RegExpWrapper.replaceAll(PLACEHOLDER_REGEXP, input, (match) => { + let nameWithQuotes = match[2]; + return ``; + }); +} \ No newline at end of file diff --git a/modules/angular2/test/i18n/i18n_html_parser_spec.ts b/modules/angular2/test/i18n/i18n_html_parser_spec.ts new file mode 100644 index 000000000000..f3fbf766fc12 --- /dev/null +++ b/modules/angular2/test/i18n/i18n_html_parser_spec.ts @@ -0,0 +1,203 @@ +import { + AsyncTestCompleter, + beforeEach, + ddescribe, + describe, + expect, + iit, + inject, + it, + xdescribe, + xit +} from 'angular2/testing_internal'; + +import {I18nHtmlParser} from 'angular2/src/i18n/i18n_html_parser'; +import {Message, id} from 'angular2/src/i18n/message'; +import {Parser} from 'angular2/src/core/change_detection/parser/parser'; +import {Lexer} from 'angular2/src/core/change_detection/parser/lexer'; + +import {HtmlParser, HtmlParseTreeResult} from 'angular2/src/compiler/html_parser'; +import { + HtmlAst, + HtmlAstVisitor, + HtmlElementAst, + HtmlAttrAst, + HtmlTextAst, + HtmlCommentAst, + htmlVisitAll +} from 'angular2/src/compiler/html_ast'; +import {ParseError, ParseLocation} from 'angular2/src/compiler/parse_util'; +import {humanizeDom} from '../../test/compiler/html_ast_spec_utils'; + +export function main() { + describe('I18nHtmlParser', () => { + function parse(template: string, messages: {[key: string]: string}): HtmlParseTreeResult { + var parser = new Parser(new Lexer()); + let htmlParser = new HtmlParser(); + return new I18nHtmlParser(htmlParser, parser, messages).parse(template, "someurl"); + } + + it("should delegate to the provided parser when no i18n", () => { + expect(humanizeDom(parse('
a
', {}))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlTextAst, 'a', 1]]); + }); + + it("should replace attributes", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("some message", "meaning", null))] = "another message"; + + expect(humanizeDom(parse("
", + translations))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', 'another message']]); + }); + + it("should replace elements with the i18n attr", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("message", "meaning", null))] = "another message"; + + expect(humanizeDom(parse("
message
", translations))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlTextAst, 'another message', 1]]); + }); + + it("should handle interpolation", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message(' and ', null, null))] = + ' or '; + + expect(humanizeDom(parse("
", translations))) + .toEqual([[HtmlElementAst, 'div', 0], [HtmlAttrAst, 'value', '{{b}} or {{a}}']]); + }); + + it("should handle nested html", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('ab', null, null))] = + 'BA'; + + expect(humanizeDom(parse('
ab
', translations))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'b', 1], + [HtmlTextAst, 'B', 2], + [HtmlElementAst, 'a', 1], + [HtmlTextAst, 'A', 2], + ]); + }); + + it("should support interpolation", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message( + 'ab', null, + null))] = 'BA'; + expect(humanizeDom(parse('
ab{{i}}
', translations))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'b', 1], + [HtmlTextAst, '{{i}}B', 2], + [HtmlElementAst, 'a', 1], + [HtmlTextAst, 'A', 2], + ]); + }); + + it("should i18n attributes of placeholder elements", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('a', null, null))] = 'A'; + translations[id(new Message('b', null, null))] = 'B'; + + expect(humanizeDom(parse('', translations))) + .toEqual([ + [HtmlElementAst, 'div', 0], + [HtmlElementAst, 'a', 1], + [HtmlAttrAst, 'value', "B"], + [HtmlTextAst, 'A', 2], + ]); + }); + + it('should extract from partitions', () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('message1', 'meaning1', null))] = 'another message1'; + translations[id(new Message('message2', 'meaning2', null))] = 'another message2'; + + let res = parse(`message1message2`, translations); + + expect(humanizeDom(res)) + .toEqual([ + [HtmlTextAst, 'another message1', 0], + [HtmlTextAst, 'another message2', 0], + ]); + }); + + it("should preserve original positions", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('ab', null, null))] = + 'BA'; + + let res = + (parse('
ab
', translations).rootNodes[0]).children; + + expect(res[0].sourceSpan.start.offset).toEqual(18); + expect(res[1].sourceSpan.start.offset).toEqual(10); + }); + + describe("errors", () => { + it("should error when giving an invalid template", () => { + expect(humanizeErrors(parse("a", {}).errors)) + .toEqual(['Unexpected closing tag "b"']); + }); + + 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}'`]); + }); + + 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}'`]); + }); + + it("should error when message cannot be parsed", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("some message", null, null))] = "
a"; + + expect(humanizeErrors(parse("
some message
", translations).errors)) + .toEqual([`Unexpected closing tag "b"`]); + }); + + it("should error when a non-placeholder element appears in translation", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("some message", null, null))] = "
a"; + + expect(humanizeErrors(parse("
some message
", translations).errors)) + .toEqual([`Unexpected tag "a". Only "ph" tags are allowed.`]); + }); + + it("should error when a placeholder element does not have the name attribute", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("some message", null, null))] = "a"; + + expect(humanizeErrors(parse("
some message
", translations).errors)) + .toEqual([`Missing "name" attribute.`]); + }); + + it("should error when no matching attribute", () => { + expect(humanizeErrors(parse("
", {}).errors)) + .toEqual([`Missing attribute 'value'.`]); + }); + + it("should error when the translation refers to an invalid expression", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message('hi ', null, null))] = 'hi '; + + expect( + humanizeErrors(parse("
", translations).errors)) + .toEqual(["Invalid interpolation index '99'"]); + }); + + }); + }); +} + +function humanizeErrors(errors: ParseError[]): string[] { + return errors.map(error => error.msg); +} \ No newline at end of file