From 3e5716ec16c96ef930247009978c71ead7ac2898 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 20 May 2016 15:44:58 -0700 Subject: [PATCH] feat(i18n): support implicit tags/attributes --- modules/@angular/compiler/src/html_lexer.ts | 4 +- .../compiler/src/i18n/i18n_html_parser.ts | 56 ++++++------ .../compiler/src/i18n/message_extractor.ts | 47 +++++----- modules/@angular/compiler/src/i18n/shared.ts | 30 +++++-- .../test/i18n/i18n_html_parser_spec.ts | 86 ++++++++++++------- .../test/i18n/message_extractor_spec.ts | 26 +++++- 6 files changed, 157 insertions(+), 92 deletions(-) diff --git a/modules/@angular/compiler/src/html_lexer.ts b/modules/@angular/compiler/src/html_lexer.ts index 0e25d640f4..bfd1cd7777 100644 --- a/modules/@angular/compiler/src/html_lexer.ts +++ b/modules/@angular/compiler/src/html_lexer.ts @@ -1,5 +1,5 @@ -import {StringWrapper, NumberWrapper, isPresent, isBlank, serializeEnum} from '../src/facade/lang'; -import {ListWrapper} from '../src/facade/collection'; +import {StringWrapper, NumberWrapper, isPresent, isBlank} from './facade/lang'; +import {ListWrapper} from './facade/collection'; import {ParseLocation, ParseError, ParseSourceFile, ParseSourceSpan} from './parse_util'; import {getHtmlTagDefinition, HtmlTagContentType, NAMED_ENTITIES} from './html_tags'; diff --git a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts index 8081c3f399..b167ad0dde 100644 --- a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts @@ -1,5 +1,5 @@ -import {HtmlParser, HtmlParseTreeResult} from '../html_parser'; -import {ParseSourceSpan, ParseError} from '../parse_util'; +import {HtmlParser, HtmlParseTreeResult} from "../html_parser"; +import {ParseSourceSpan, ParseError} from "../parse_util"; import { HtmlAst, HtmlAstVisitor, @@ -10,31 +10,28 @@ import { HtmlExpansionAst, HtmlExpansionCaseAst, htmlVisitAll -} from '../html_ast'; -import {ListWrapper, StringMapWrapper} from '../../src/facade/collection'; -import {RegExpWrapper, NumberWrapper, isPresent} from '../../src/facade/lang'; -import {BaseException} from '../../src/facade/exceptions'; -import {Parser} from '../expression_parser/parser'; -import {id} from './message'; -import {expandNodes} from './expander'; +} from "../html_ast"; +import {ListWrapper, StringMapWrapper} from "../facade/collection"; +import {RegExpWrapper, NumberWrapper, isPresent} from "../facade/lang"; +import {BaseException} from "../facade/exceptions"; +import {Parser} from "../expression_parser/parser"; +import {id} from "./message"; +import {expandNodes} from "./expander"; import { - messageFromAttribute, + messageFromI18nAttribute, I18nError, I18N_ATTR_PREFIX, I18N_ATTR, partition, Part, - stringifyNodes, - meaning, getPhNameFromBinding, - dedupePhName -} from './shared'; + dedupePhName, + messageFromAttribute +} from "./shared"; -const _I18N_ATTR = "i18n"; const _PLACEHOLDER_ELEMENT = "ph"; const _NAME_ATTR = "name"; -const _I18N_ATTR_PREFIX = "i18n-"; -let _PLACEHOLDER_EXPANDED_REGEXP = RegExpWrapper.create(`\\\\<\\/ph\\>`); +let _PLACEHOLDER_EXPANDED_REGEXP = /<\/ph>/gi; /** * Creates an i18n-ed version of the parsed template. @@ -120,13 +117,15 @@ export class I18nHtmlParser implements HtmlParser { errors: ParseError[]; constructor(private _htmlParser: HtmlParser, private _parser: Parser, - private _messagesContent: string, private _messages: {[key: string]: HtmlAst[]}) {} + private _messagesContent: string, private _messages: {[key: string]: HtmlAst[]}, + private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} parse(sourceContent: string, sourceUrl: string, parseExpansionForms: boolean = false): HtmlParseTreeResult { this.errors = []; let res = this._htmlParser.parse(sourceContent, sourceUrl, true); + if (res.errors.length > 0) { return res; } else { @@ -184,7 +183,7 @@ export class I18nHtmlParser implements HtmlParser { } private _recurse(nodes: HtmlAst[]): HtmlAst[] { - let ps = partition(nodes, this.errors); + let ps = partition(nodes, this.errors, this._implicitTags); return ListWrapper.flatten(ps.map(p => this._processI18nPart(p))); } @@ -281,17 +280,26 @@ export class I18nHtmlParser implements HtmlParser { private _i18nAttributes(el: HtmlElementAst): HtmlAttrAst[] { let res = []; + let implicitAttrs: string[] = + isPresent(this._implicitAttrs[el.name]) ? this._implicitAttrs[el.name] : []; + el.attrs.forEach(attr => { if (attr.name.startsWith(I18N_ATTR_PREFIX) || attr.name == I18N_ATTR) return; - let i18ns = el.attrs.filter(a => a.name == `i18n-${attr.name}`); + let message; + + let i18ns = el.attrs.filter(a => a.name == `${I18N_ATTR_PREFIX}${attr.name}`); + if (i18ns.length == 0) { - res.push(attr); - return; + if (implicitAttrs.indexOf(attr.name) == -1) { + res.push(attr); + return; + } + message = messageFromAttribute(this._parser, attr); + } else { + message = messageFromI18nAttribute(this._parser, el, i18ns[0]); } - let i18n = i18ns[0]; - let message = messageFromAttribute(this._parser, el, i18n); let messageId = id(message); if (StringMapWrapper.contains(this._messages, messageId)) { diff --git a/modules/@angular/compiler/src/i18n/message_extractor.ts b/modules/@angular/compiler/src/i18n/message_extractor.ts index a1c65cc1db..4b67314ef7 100644 --- a/modules/@angular/compiler/src/i18n/message_extractor.ts +++ b/modules/@angular/compiler/src/i18n/message_extractor.ts @@ -1,29 +1,19 @@ -import {HtmlParser} from '../html_parser'; -import {ParseSourceSpan, ParseError} from '../parse_util'; -import { - HtmlAst, - HtmlAstVisitor, - HtmlElementAst, - HtmlAttrAst, - HtmlTextAst, - HtmlCommentAst, - htmlVisitAll -} from '../html_ast'; -import {isPresent} from '../../src/facade/lang'; -import {StringMapWrapper} from '../../src/facade/collection'; -import {Parser} from '../expression_parser/parser'; -import {Message, id} from './message'; -import {expandNodes} from './expander'; +import {HtmlParser} from "../html_parser"; +import {ParseError} from "../parse_util"; +import {HtmlAst, HtmlElementAst} from "../html_ast"; +import {isPresent} from "../facade/lang"; +import {StringMapWrapper} from "../facade/collection"; +import {Parser} from "../expression_parser/parser"; +import {Message, id} from "./message"; +import {expandNodes} from "./expander"; import { I18nError, Part, I18N_ATTR_PREFIX, partition, - meaning, - description, - stringifyNodes, + messageFromI18nAttribute, messageFromAttribute -} from './shared'; +} from "./shared"; /** * All messages extracted from a template. @@ -116,7 +106,8 @@ export class MessageExtractor { messages: Message[]; errors: ParseError[]; - constructor(private _htmlParser: HtmlParser, private _parser: Parser) {} + constructor(private _htmlParser: HtmlParser, private _parser: Parser, + private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} extract(template: string, sourceUrl: string): ExtractionResult { this.messages = []; @@ -146,7 +137,7 @@ export class MessageExtractor { private _recurse(nodes: HtmlAst[]): void { if (isPresent(nodes)) { - let ps = partition(nodes, this.errors); + let ps = partition(nodes, this.errors, this._implicitTags); ps.forEach(p => this._extractMessagesFromPart(p)); } } @@ -161,10 +152,15 @@ export class MessageExtractor { } private _extractMessagesFromAttributes(p: HtmlElementAst): void { + let transAttrs: string[] = + isPresent(this._implicitAttrs[p.name]) ? this._implicitAttrs[p.name] : []; + let explicitAttrs: string[] = []; + p.attrs.forEach(attr => { if (attr.name.startsWith(I18N_ATTR_PREFIX)) { try { - this.messages.push(messageFromAttribute(this._parser, p, attr)); + explicitAttrs.push(attr.name.substring(I18N_ATTR_PREFIX.length)); + this.messages.push(messageFromI18nAttribute(this._parser, p, attr)); } catch (e) { if (e instanceof I18nError) { this.errors.push(e); @@ -174,5 +170,10 @@ export class MessageExtractor { } } }); + + p.attrs.filter(attr => !attr.name.startsWith(I18N_ATTR_PREFIX)) + .filter(attr => explicitAttrs.indexOf(attr.name) == -1) + .filter(attr => transAttrs.indexOf(attr.name) > -1) + .forEach(attr => this.messages.push(messageFromAttribute(this._parser, attr))); } } diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts index 6b2414dd67..c0ae76acf4 100644 --- a/modules/@angular/compiler/src/i18n/shared.ts +++ b/modules/@angular/compiler/src/i18n/shared.ts @@ -27,7 +27,7 @@ export class I18nError extends ParseError { // Man, this is so ugly! -export function partition(nodes: HtmlAst[], errors: ParseError[]): Part[] { +export function partition(nodes: HtmlAst[], errors: ParseError[], implicitTags: string[]): Part[] { let res = []; for (let i = 0; i < nodes.length; ++i) { @@ -47,7 +47,8 @@ export function partition(nodes: HtmlAst[], errors: ParseError[]): Part[] { } else if (n instanceof HtmlElementAst) { let i18n = _findI18nAttr(n); - res.push(new Part(n, null, n.children, isPresent(i18n) ? i18n.value : null, isPresent(i18n))); + let hasI18n: boolean = isPresent(i18n) || implicitTags.indexOf(n.name) > -1; + res.push(new Part(n, null, n.children, isPresent(i18n) ? i18n.value : null, hasI18n)); } else if (n instanceof HtmlTextAst) { res.push(new Part(null, n, null, null, false)); } @@ -99,17 +100,28 @@ export function description(i18n: string): string { return parts.length > 1 ? parts[1] : null; } -export function messageFromAttribute(parser: Parser, p: HtmlElementAst, - attr: HtmlAttrAst): Message { - let expectedName = attr.name.substring(5); +/** + * Extract a translation string given an `i18n-` prefixed attribute. + * + * @internal + */ +export function messageFromI18nAttribute(parser: Parser, p: HtmlElementAst, + i18nAttr: HtmlAttrAst): Message { + let expectedName = i18nAttr.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}'.`); + return messageFromAttribute(parser, matching[0], meaning(i18nAttr.value), + description(i18nAttr.value)); } + + throw new I18nError(p.sourceSpan, `Missing attribute '${expectedName}'.`); +} + +export function messageFromAttribute(parser: Parser, attr: HtmlAttrAst, meaning: string = null, + description: string = null): Message { + let value = removeInterpolation(attr.value, attr.sourceSpan, parser); + return new Message(value, meaning, description); } export function removeInterpolation(value: string, source: ParseSourceSpan, diff --git a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts index 2985501805..6e4d59703b 100644 --- a/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts +++ b/modules/@angular/compiler/test/i18n/i18n_html_parser_spec.ts @@ -1,38 +1,19 @@ -import { - beforeEach, - ddescribe, - describe, - expect, - iit, - inject, - it, - xdescribe, - xit -} from '@angular/core/testing/testing_internal'; - -import {I18nHtmlParser} from '@angular/compiler/src/i18n/i18n_html_parser'; -import {Message, id} from '@angular/compiler/src/i18n/message'; -import {Parser} from '@angular/compiler/src/expression_parser/parser'; -import {Lexer} from '@angular/compiler/src/expression_parser/lexer'; - -import {StringMapWrapper} from '../../src/facade/collection'; -import {HtmlParser, HtmlParseTreeResult} from '@angular/compiler/src/html_parser'; -import { - HtmlAst, - HtmlAstVisitor, - HtmlElementAst, - HtmlAttrAst, - HtmlTextAst, - HtmlCommentAst, - htmlVisitAll -} from '@angular/compiler/src/html_ast'; -import {serializeXmb, deserializeXmb} from '@angular/compiler/src/i18n/xmb_serializer'; -import {ParseError, ParseLocation} from '@angular/compiler/src/parse_util'; -import {humanizeDom} from '@angular/compiler/test/html_ast_spec_utils'; +import {describe, expect, it, iit, ddescribe} from "@angular/core/testing/testing_internal"; +import {I18nHtmlParser} from "@angular/compiler/src/i18n/i18n_html_parser"; +import {Message, id} from "@angular/compiler/src/i18n/message"; +import {Parser} from "@angular/compiler/src/expression_parser/parser"; +import {Lexer} from "@angular/compiler/src/expression_parser/lexer"; +import {StringMapWrapper} from "../../src/facade/collection"; +import {HtmlParser, HtmlParseTreeResult} from "@angular/compiler/src/html_parser"; +import {HtmlElementAst, HtmlAttrAst, HtmlTextAst} from "@angular/compiler/src/html_ast"; +import {deserializeXmb} from "@angular/compiler/src/i18n/xmb_serializer"; +import {ParseError} from "@angular/compiler/src/parse_util"; +import {humanizeDom} from "@angular/compiler/test/html_ast_spec_utils"; export function main() { describe('I18nHtmlParser', () => { - function parse(template: string, messages: {[key: string]: string}): HtmlParseTreeResult { + function parse(template: string, messages: {[key: string]: string}, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): HtmlParseTreeResult { var parser = new Parser(new Lexer()); let htmlParser = new HtmlParser(); @@ -40,7 +21,8 @@ export function main() { StringMapWrapper.forEach(messages, (v, k) => msgs += `${v}`); let res = deserializeXmb(`${msgs}`, 'someUrl'); - return new I18nHtmlParser(htmlParser, parser, res.content, res.messages) + return new I18nHtmlParser(htmlParser, parser, res.content, res.messages, implicitTags, + implicitAttrs) .parse(template, "someurl", true); } @@ -331,6 +313,44 @@ export function main() { .toEqual(["Invalid interpolation name '99'"]); }); + describe('implicit translation', () => { + it("should support attributes", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("some message", null, null))] = "another message"; + + expect(humanizeDom(parse("", translations, [], + {'i18n-el': ['value']}))) + .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlAttrAst, 'value', 'another message']]); + }); + + it("should support attributes with meaning and description", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("some message", "meaning", "description"))] = + "another message"; + + expect(humanizeDom(parse( + "", + translations, [], {'i18n-el': ['value']}))) + .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlAttrAst, 'value', 'another message']]); + }); + + it("should support elements", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("message", null, null))] = "another message"; + + expect(humanizeDom(parse("message", translations, ['i18n-el']))) + .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlTextAst, 'another message', 1]]); + }); + + it("should support elements with meaning and description", () => { + let translations: {[key: string]: string} = {}; + translations[id(new Message("message", "meaning", "description"))] = "another message"; + + expect(humanizeDom(parse("message", + translations, ['i18n-el']))) + .toEqual([[HtmlElementAst, 'i18n-el', 0], [HtmlTextAst, 'another message', 1]]); + }); + }); }); }); } diff --git a/modules/@angular/compiler/test/i18n/message_extractor_spec.ts b/modules/@angular/compiler/test/i18n/message_extractor_spec.ts index eb4b9ce5ce..9438888e0d 100644 --- a/modules/@angular/compiler/test/i18n/message_extractor_spec.ts +++ b/modules/@angular/compiler/test/i18n/message_extractor_spec.ts @@ -23,7 +23,7 @@ export function main() { beforeEach(() => { let htmlParser = new HtmlParser(); var parser = new Parser(new Lexer()); - extractor = new MessageExtractor(htmlParser, parser); + extractor = new MessageExtractor(htmlParser, parser, ['i18n-tag'], {'i18n-el': ['trans']}); }); it('should extract from elements with the i18n attr', () => { @@ -205,6 +205,30 @@ export function main() { ]); }); + describe('implicit translation', () => { + it('should extract from elements', () => { + let res = extractor.extract("message", "someurl"); + expect(res.messages).toEqual([new Message("message", null, null)]); + }); + + it('should extract meaning and description from elements when present', () => { + let res = + extractor.extract("message", "someurl"); + expect(res.messages).toEqual([new Message("message", "meaning", "description")]); + }); + + it('should extract from attributes', () => { + let res = extractor.extract(``, "someurl"); + expect(res.messages).toEqual([new Message("message", null, null)]); + }); + + it('should extract meaning and description from attributes when present', () => { + let res = extractor.extract(``, + "someurl"); + expect(res.messages).toEqual([new Message("message", "meaning", "desc")]); + }); + }); + describe("errors", () => { it('should error on i18n attributes without matching "real" attributes', () => { let res = extractor.extract(`