From cc5cfe87c39df850b9a325755d2666cef625ed15 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 15 Jul 2016 09:42:33 -0700 Subject: [PATCH] feat(i18n): xmb serializer --- modules/@angular/compiler/src/html_parser.ts | 4 +- modules/@angular/compiler/src/html_tags.ts | 28 +-- modules/@angular/compiler/src/i18n/catalog.ts | 112 +++++++++ .../@angular/compiler/src/i18n/i18n_ast.ts | 53 ++-- .../@angular/compiler/src/i18n/i18n_parser.ts | 109 +++++---- .../src/i18n/serializers/serializer.ts | 15 ++ .../compiler/src/i18n/serializers/util.ts | 122 ++++++++++ .../compiler/src/i18n/serializers/xmb.ts | 101 ++++++++ .../src/i18n/serializers/xml_helper.ts | 93 +++++++ modules/@angular/compiler/src/i18n/shared.ts | 5 +- .../compiler/src/i18n/xmb_serializer.ts | 2 +- .../compiler/test/i18n/catalog_spec.ts | 91 +++++++ .../compiler/test/i18n/extractor_spec.ts | 12 +- .../compiler/test/i18n/i18n_parser_spec.ts | 226 +++++++++++------- .../test/i18n/serializers/util_spec.ts | 94 ++++++++ .../test/i18n/serializers/xmb_spec.ts | 68 ++++++ .../test/i18n/serializers/xml_helper_spec.ts | 49 ++++ 17 files changed, 995 insertions(+), 189 deletions(-) create mode 100644 modules/@angular/compiler/src/i18n/catalog.ts create mode 100644 modules/@angular/compiler/src/i18n/serializers/util.ts create mode 100644 modules/@angular/compiler/src/i18n/serializers/xml_helper.ts create mode 100644 modules/@angular/compiler/test/i18n/catalog_spec.ts create mode 100644 modules/@angular/compiler/test/i18n/serializers/util_spec.ts create mode 100644 modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts create mode 100644 modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts diff --git a/modules/@angular/compiler/src/html_parser.ts b/modules/@angular/compiler/src/html_parser.ts index 83705dd052..95ee1f7893 100644 --- a/modules/@angular/compiler/src/html_parser.ts +++ b/modules/@angular/compiler/src/html_parser.ts @@ -140,8 +140,8 @@ class TreeBuilder { // read { if (this.peek.type !== HtmlTokenType.EXPANSION_CASE_EXP_START) { - this.errors.push(HtmlTreeError.create( - null, this.peek.sourceSpan, `Invalid ICU message. Missing '{'.`)); + this.errors.push( + HtmlTreeError.create(null, this.peek.sourceSpan, `Invalid ICU message. Missing '{'.`)); return null; } diff --git a/modules/@angular/compiler/src/html_tags.ts b/modules/@angular/compiler/src/html_tags.ts index 0cfc16c3e0..3129e6d680 100644 --- a/modules/@angular/compiler/src/html_tags.ts +++ b/modules/@angular/compiler/src/html_tags.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {isPresent, isBlank, normalizeBool, RegExpWrapper,} from '../src/facade/lang'; +import {normalizeBool, RegExpWrapper,} from '../src/facade/lang'; // see http://www.w3.org/TR/html51/syntax.html#named-character-references // see https://html.spec.whatwg.org/multipage/entities.json @@ -294,31 +294,32 @@ export class HtmlTagDefinition { isVoid?: boolean, ignoreFirstLf?: boolean } = {}) { - if (isPresent(closedByChildren) && closedByChildren.length > 0) { + if (closedByChildren && closedByChildren.length > 0) { closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true); } this.isVoid = normalizeBool(isVoid); this.closedByParent = normalizeBool(closedByParent) || this.isVoid; - if (isPresent(requiredParents) && requiredParents.length > 0) { + if (requiredParents && requiredParents.length > 0) { this.requiredParents = {}; + // The first parent is the list is automatically when none of the listed parents are present this.parentToAdd = requiredParents[0]; requiredParents.forEach(tagName => this.requiredParents[tagName] = true); } this.implicitNamespacePrefix = implicitNamespacePrefix; - this.contentType = isPresent(contentType) ? contentType : HtmlTagContentType.PARSABLE_DATA; + this.contentType = contentType || HtmlTagContentType.PARSABLE_DATA; this.ignoreFirstLf = normalizeBool(ignoreFirstLf); } requireExtraParent(currentParent: string): boolean { - if (isBlank(this.requiredParents)) { + if (!this.requiredParents) { return false; } - if (isBlank(currentParent)) { + if (!currentParent) { return true; } - let lcParent = currentParent.toLowerCase(); + const lcParent = currentParent.toLowerCase(); return this.requiredParents[lcParent] != true && lcParent != 'template'; } @@ -382,20 +383,19 @@ var TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { {contentType: HtmlTagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}), }; -var DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); +const _DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition { - var result = TAG_DEFINITIONS[tagName.toLowerCase()]; - return isPresent(result) ? result : DEFAULT_TAG_DEFINITION; + return TAG_DEFINITIONS[tagName.toLowerCase()] || _DEFAULT_TAG_DEFINITION; } -var NS_PREFIX_RE = /^:([^:]+):(.+)/g; +const _NS_PREFIX_RE = /^:([^:]+):(.+)/g; -export function splitNsName(elementName: string): string[] { +export function splitNsName(elementName: string): [string, string] { if (elementName[0] != ':') { return [null, elementName]; } - let match = RegExpWrapper.firstMatch(NS_PREFIX_RE, elementName); + const match = RegExpWrapper.firstMatch(_NS_PREFIX_RE, elementName); return [match[1], match[2]]; } @@ -404,5 +404,5 @@ export function getNsPrefix(elementName: string): string { } export function mergeNsAndName(prefix: string, localName: string): string { - return isPresent(prefix) ? `:${prefix}:${localName}` : localName; + return prefix ? `:${prefix}:${localName}` : localName; } diff --git a/modules/@angular/compiler/src/i18n/catalog.ts b/modules/@angular/compiler/src/i18n/catalog.ts new file mode 100644 index 0000000000..6cbf998284 --- /dev/null +++ b/modules/@angular/compiler/src/i18n/catalog.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {HtmlParser} from '../html_parser'; +import {InterpolationConfig} from '../interpolation_config'; + +import * as i18nAst from './i18n_ast'; +import {extractI18nMessages} from './i18n_parser'; +import {Serializer} from './serializers/serializer'; + +export class Catalog { + private _messageMap: {[k: string]: i18nAst.Message} = {}; + + constructor( + private _htmlParser: HtmlParser, private _implicitTags: string[], + private _implicitAttrs: {[k: string]: string[]}) {} + + public updateFromTemplate(html: string, url: string, interpolationConfig: InterpolationConfig): + void { + const htmlParserResult = this._htmlParser.parse(html, url, true, interpolationConfig); + + if (htmlParserResult.errors.length) { + throw new Error(); + } + + const messages = extractI18nMessages( + htmlParserResult.rootNodes, interpolationConfig, this._implicitTags, this._implicitAttrs); + + messages.forEach((message) => { + const id = strHash(serializeAst(message.nodes).join('') + `[${message.meaning}]`); + this._messageMap[id] = message; + }); + } + + public load(content: string, serializer: Serializer): void { + const nodeMap = serializer.load(content); + this._messageMap = {}; + + Object.getOwnPropertyNames(nodeMap).forEach( + (id) => { this._messageMap[id] = new i18nAst.Message(nodeMap[id], '', ''); }); + } + + public write(serializer: Serializer): string { return serializer.write(this._messageMap); } +} + + +/** + * String hash function similar to java.lang.String.hashCode(). + * The hash code for a string is computed as + * s[0] * 31 ^ (n - 1) + s[1] * 31 ^ (n - 2) + ... + s[n - 1], + * where s[i] is the ith character of the string and n is the length of + * the string. We mod the result to make it between 0 (inclusive) and 2^32 (exclusive). + * + * Based on goog.string.hashCode from the Google Closure library + * https://github.com/google/closure-library/ + * + * @internal + */ +// TODO(vicb): better algo (less collisions) ? +export function strHash(str: string): string { + let result: number = 0; + for (var i = 0; i < str.length; ++i) { + // Normalize to 4 byte range, 0 ... 2^32. + result = (31 * result + str.charCodeAt(i)) >>> 0; + } + return result.toString(16); +} + +/** + * Serialize the i18n ast to something xml-like in order to generate an UID. + * + * The visitor is also used in the i18n parser tests + * + * @internal + */ +class _SerializerVisitor implements i18nAst.Visitor { + visitText(text: i18nAst.Text, context: any): any { return text.value; } + + visitContainer(container: i18nAst.Container, context: any): any { + return `[${container.children.map(child => child.visit(this)).join(', ')}]`; + } + + visitIcu(icu: i18nAst.Icu, context: any): any { + let strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`); + return `{${icu.expression}, ${icu.type}, ${strCases.join(', ')}}`; + } + + visitTagPlaceholder(ph: i18nAst.TagPlaceholder, context: any): any { + return ph.isVoid ? + `` : + `${ph.children.map(child => child.visit(this)).join(', ')}`; + } + + visitPlaceholder(ph: i18nAst.Placeholder, context: any): any { + return `${ph.value}`; + } + + visitIcuPlaceholder(ph: i18nAst.IcuPlaceholder, context?: any): any { + return `${ph.value.visit(this)}`; + } +} + +const serializerVisitor = new _SerializerVisitor(); + +export function serializeAst(ast: i18nAst.Node[]): string[] { + return ast.map(a => a.visit(serializerVisitor, null)); +} diff --git a/modules/@angular/compiler/src/i18n/i18n_ast.ts b/modules/@angular/compiler/src/i18n/i18n_ast.ts index 313388ac9b..19dcd6736f 100644 --- a/modules/@angular/compiler/src/i18n/i18n_ast.ts +++ b/modules/@angular/compiler/src/i18n/i18n_ast.ts @@ -6,58 +6,53 @@ * found in the LICENSE file at https://angular.io/license */ -import {ParseSourceSpan} from "../parse_util"; +import {ParseSourceSpan} from '../parse_util'; -export interface I18nNode { - visit(visitor: Visitor, context?: any): any; +export class Message { + constructor(public nodes: Node[], public meaning: string, public description: string) {} } -export class Text implements I18nNode { +export interface Node { visit(visitor: Visitor, context?: any): any; } + +export class Text implements Node { constructor(public value: string, public sourceSpan: ParseSourceSpan) {} - visit(visitor: Visitor, context?: any): any { - return visitor.visitText(this, context); - } + visit(visitor: Visitor, context?: any): any { return visitor.visitText(this, context); } } -export class Container implements I18nNode { - constructor(public children: I18nNode[], public sourceSpan: ParseSourceSpan) {} +export class Container implements Node { + constructor(public children: Node[], public sourceSpan: ParseSourceSpan) {} - visit(visitor: Visitor, context?: any): any { - return visitor.visitContainer(this, context); - } + visit(visitor: Visitor, context?: any): any { return visitor.visitContainer(this, context); } } -export class Icu implements I18nNode { - constructor(public expression: string, public type: string, public cases: {[k: string]: I18nNode}, public sourceSpan: ParseSourceSpan) {} +export class Icu implements Node { + constructor( + public expression: string, public type: string, public cases: {[k: string]: Node}, + public sourceSpan: ParseSourceSpan) {} - visit(visitor: Visitor, context?: any): any { - return visitor.visitIcu(this, context); - } + visit(visitor: Visitor, context?: any): any { return visitor.visitIcu(this, context); } } export class TagPlaceholder { - constructor(public name: string, public attrs: {[k: string]: string}, public children: I18nNode[], public sourceSpan: ParseSourceSpan) {} + constructor( + public tag: string, public attrs: {[k: string]: string}, public startName: string, + public closeName: string, public children: Node[], public isVoid: boolean, + public sourceSpan: ParseSourceSpan) {} - visit(visitor: Visitor, context?: any): any { - return visitor.visitTagPlaceholder(this, context); - } + visit(visitor: Visitor, context?: any): any { return visitor.visitTagPlaceholder(this, context); } } export class Placeholder { constructor(public value: string, public name: string = '', public sourceSpan: ParseSourceSpan) {} - visit(visitor: Visitor, context?: any): any { - return visitor.visitPlaceholder(this, context); - } + visit(visitor: Visitor, context?: any): any { return visitor.visitPlaceholder(this, context); } } export class IcuPlaceholder { constructor(public value: Icu, public name: string = '', public sourceSpan: ParseSourceSpan) {} - visit(visitor: Visitor, context?: any): any { - return visitor.visitIcuPlaceholder(this, context); - } + visit(visitor: Visitor, context?: any): any { return visitor.visitIcuPlaceholder(this, context); } } export interface Visitor { @@ -68,7 +63,3 @@ export interface Visitor { visitPlaceholder(ph: Placeholder, context?: any): any; visitIcuPlaceholder(ph: IcuPlaceholder, context?: any): any; } - - - - diff --git a/modules/@angular/compiler/src/i18n/i18n_parser.ts b/modules/@angular/compiler/src/i18n/i18n_parser.ts index 5294165d56..9ec7947c53 100644 --- a/modules/@angular/compiler/src/i18n/i18n_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_parser.ts @@ -6,71 +6,79 @@ * found in the LICENSE file at https://angular.io/license */ -import { extractAstMessages} from './extractor'; -import * as hAst from '../html_ast'; -import * as i18nAst from './i18n_ast'; -import {Parser as ExpressionParser} from '../expression_parser/parser'; import {Lexer as ExpressionLexer} from '../expression_parser/lexer'; -import {ParseSourceSpan} from "../parse_util"; -import {HtmlAst} from "../html_ast"; -import {extractPlaceholderName} from "@angular/compiler/src/i18n/shared"; +import {Parser as ExpressionParser} from '../expression_parser/parser'; +import * as hAst from '../html_ast'; +import {getHtmlTagDefinition} from '../html_tags'; +import {InterpolationConfig} from '../interpolation_config'; +import {ParseSourceSpan} from '../parse_util'; -export class Message { - constructor(public nodes: i18nAst.I18nNode[], public meaning: string, public description: string) {} -} +import {extractAstMessages} from './extractor'; +import * as i18nAst from './i18n_ast'; +import {PlaceholderRegistry} from './serializers/util'; +import {extractPlaceholderName} from './shared'; -// TODO: should get the interpolation config export function extractI18nMessages( - sourceAst: HtmlAst[], implicitTags: string[], - implicitAttrs: {[k: string]: string[]}): Message[] { - const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs); + sourceAst: hAst.HtmlAst[], interpolationConfig: InterpolationConfig, implicitTags: string[], + implicitAttrs: {[k: string]: string[]}): i18nAst.Message[] { + const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs); - if (extractionResult.errors.length) { - return[]; - } + if (extractionResult.errors.length) { + return []; + } - const visitor = new _I18nVisitor(new ExpressionParser(new ExpressionLexer())); + const visitor = + new _I18nVisitor(new ExpressionParser(new ExpressionLexer()), interpolationConfig); - return extractionResult.messages.map((msg): Message => { - return new Message(visitor.convertToI18nAst(msg.nodes), msg.meaning, msg.description); - }); + return extractionResult.messages.map((msg): i18nAst.Message => { + return new i18nAst.Message(visitor.convertToI18nAst(msg.nodes), msg.meaning, msg.description); + }); } class _I18nVisitor implements hAst.HtmlAstVisitor { private _isIcu: boolean; private _icuDepth: number; + private _placeholderRegistry: PlaceholderRegistry; - constructor(private _expressionParser: ExpressionParser) {} + constructor( + private _expressionParser: ExpressionParser, + private _interpolationConfig: InterpolationConfig) {} - visitElement(el:hAst.HtmlElementAst, context:any):i18nAst.I18nNode { + visitElement(el: hAst.HtmlElementAst, context: any): i18nAst.Node { const children = hAst.htmlVisitAll(this, el.children); const attrs: {[k: string]: string} = {}; el.attrs.forEach(attr => { // Do not visit the attributes, translatable ones are top-level ASTs attrs[attr.name] = attr.value; }); - return new i18nAst.TagPlaceholder(el.name, attrs, children, el.sourceSpan); + + const isVoid: boolean = getHtmlTagDefinition(el.name).isVoid; + const startPhName = + this._placeholderRegistry.getStartTagPlaceholderName(el.name, attrs, isVoid); + const closePhName = isVoid ? '' : this._placeholderRegistry.getCloseTagPlaceholderName(el.name); + + return new i18nAst.TagPlaceholder( + el.name, attrs, startPhName, closePhName, children, isVoid, el.sourceSpan); } - visitAttr(attr:hAst.HtmlAttrAst, context:any):i18nAst.I18nNode { + visitAttr(attr: hAst.HtmlAttrAst, context: any): i18nAst.Node { return this._visitTextWithInterpolation(attr.value, attr.sourceSpan); } - visitText(text:hAst.HtmlTextAst, context:any):i18nAst.I18nNode { + visitText(text: hAst.HtmlTextAst, context: any): i18nAst.Node { return this._visitTextWithInterpolation(text.value, text.sourceSpan); } - visitComment(comment:hAst.HtmlCommentAst, context:any):i18nAst.I18nNode { - return null; - } + visitComment(comment: hAst.HtmlCommentAst, context: any): i18nAst.Node { return null; } - visitExpansion(icu:hAst.HtmlExpansionAst, context:any):i18nAst.I18nNode { + visitExpansion(icu: hAst.HtmlExpansionAst, context: any): i18nAst.Node { this._icuDepth++; - const i18nIcuCases: {[k: string]: i18nAst.I18nNode} = {}; + const i18nIcuCases: {[k: string]: i18nAst.Node} = {}; const i18nIcu = new i18nAst.Icu(icu.switchValue, icu.type, i18nIcuCases, icu.sourceSpan); icu.cases.forEach((caze): void => { - i18nIcuCases[caze.value] = new i18nAst.Container(caze.expression.map((hAst) => hAst.visit(this, {})), caze.expSourceSpan); - }); + i18nIcuCases[caze.value] = new i18nAst.Container( + caze.expression.map((hAst) => hAst.visit(this, {})), caze.expSourceSpan); + }); this._icuDepth--; if (this._isIcu || this._icuDepth > 0) { @@ -79,21 +87,25 @@ class _I18nVisitor implements hAst.HtmlAstVisitor { } // else returns a placeholder - return new i18nAst.IcuPlaceholder(i18nIcu, 'icu', icu.sourceSpan); + const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString()); + return new i18nAst.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan); } - visitExpansionCase(icuCase:hAst.HtmlExpansionCaseAst, context:any):i18nAst.I18nNode { + visitExpansionCase(icuCase: hAst.HtmlExpansionCaseAst, context: any): i18nAst.Node { throw new Error('Unreachable code'); } - public convertToI18nAst(htmlAsts: hAst.HtmlAst[]): i18nAst.I18nNode[] { + public convertToI18nAst(htmlAsts: hAst.HtmlAst[]): i18nAst.Node[] { this._isIcu = htmlAsts.length == 1 && htmlAsts[0] instanceof hAst.HtmlExpansionAst; this._icuDepth = 0; + this._placeholderRegistry = new PlaceholderRegistry(); + return hAst.htmlVisitAll(this, htmlAsts, {}); } - private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18nAst.I18nNode { - const splitInterpolation = this._expressionParser.splitInterpolation(text, sourceSpan.start.toString()); + private _visitTextWithInterpolation(text: string, sourceSpan: ParseSourceSpan): i18nAst.Node { + const splitInterpolation = this._expressionParser.splitInterpolation( + text, sourceSpan.start.toString(), this._interpolationConfig); if (!splitInterpolation) { // No expression, return a single text @@ -101,23 +113,26 @@ class _I18nVisitor implements hAst.HtmlAstVisitor { } // Return a group of text + expressions - const nodes: i18nAst.I18nNode[] = []; + const nodes: i18nAst.Node[] = []; const container = new i18nAst.Container(nodes, sourceSpan); for (let i = 0; i < splitInterpolation.strings.length - 1; i++) { const expression = splitInterpolation.expressions[i]; - const phName = extractPlaceholderName(expression); - nodes.push( - new i18nAst.Text(splitInterpolation.strings[i], sourceSpan), - new i18nAst.Placeholder(expression, phName, sourceSpan) - ) + const baseName = extractPlaceholderName(expression) || 'INTERPOLATION'; + const phName = this._placeholderRegistry.getPlaceholderName(baseName, expression); + + if (splitInterpolation.strings[i].length) { + nodes.push(new i18nAst.Text(splitInterpolation.strings[i], sourceSpan)); + } + + nodes.push(new i18nAst.Placeholder(expression, phName, sourceSpan)); } // The last index contains no expression const lastStringIdx = splitInterpolation.strings.length - 1; - nodes.push(new i18nAst.Text(splitInterpolation.strings[lastStringIdx], sourceSpan)); - + if (splitInterpolation.strings[lastStringIdx].length) { + nodes.push(new i18nAst.Text(splitInterpolation.strings[lastStringIdx], sourceSpan)); + } return container; } } - diff --git a/modules/@angular/compiler/src/i18n/serializers/serializer.ts b/modules/@angular/compiler/src/i18n/serializers/serializer.ts index e69de29bb2..690bbcbb86 100644 --- a/modules/@angular/compiler/src/i18n/serializers/serializer.ts +++ b/modules/@angular/compiler/src/i18n/serializers/serializer.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as i18nAst from '../i18n_ast'; + +export interface Serializer { + write(messageMap: {[k: string]: i18nAst.Message}): string; + + load(content: string): {[k: string]: i18nAst.Node[]}; +} \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/serializers/util.ts b/modules/@angular/compiler/src/i18n/serializers/util.ts new file mode 100644 index 0000000000..fd403b990d --- /dev/null +++ b/modules/@angular/compiler/src/i18n/serializers/util.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +const TAG_TO_PLACEHOLDER_NAMES: {[k: string]: string} = { + 'A': 'LINK', + 'B': 'BOLD_TEXT', + 'BR': 'LINE_BREAK', + 'EM': 'EMPHASISED_TEXT', + 'H1': 'HEADING_LEVEL1', + 'H2': 'HEADING_LEVEL2', + 'H3': 'HEADING_LEVEL3', + 'H4': 'HEADING_LEVEL4', + 'H5': 'HEADING_LEVEL5', + 'H6': 'HEADING_LEVEL6', + 'HR': 'HORIZONTAL_RULE', + 'I': 'ITALIC_TEXT', + 'LI': 'LIST_ITEM', + 'LINK': 'MEDIA_LINK', + 'OL': 'ORDERED_LIST', + 'P': 'PARAGRAPH', + 'Q': 'QUOTATION', + 'S': 'STRIKETHROUGH_TEXT', + 'SMALL': 'SMALL_TEXT', + 'SUB': 'SUBSTRIPT', + 'SUP': 'SUPERSCRIPT', + 'TBODY': 'TABLE_BODY', + 'TD': 'TABLE_CELL', + 'TFOOT': 'TABLE_FOOTER', + 'TH': 'TABLE_HEADER_CELL', + 'THEAD': 'TABLE_HEADER', + 'TR': 'TABLE_ROW', + 'TT': 'MONOSPACED_TEXT', + 'U': 'UNDERLINED_TEXT', + 'UL': 'UNORDERED_LIST', +}; + +/** + * Creates unique names for placeholder with different content + * + * @internal + */ +export class PlaceholderRegistry { + private _placeHolderNameCounts: {[k: string]: number} = {}; + private _signatureToName: {[k: string]: string} = {}; + + getStartTagPlaceholderName(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string { + const signature = this._hashTag(tag, attrs, isVoid); + if (this._signatureToName[signature]) { + return this._signatureToName[signature]; + } + + const upperTag = tag.toUpperCase(); + const baseName = TAG_TO_PLACEHOLDER_NAMES[upperTag] || `TAG_${upperTag}`; + const name = this._generateUniqueName(isVoid ? baseName : `START_${baseName}`); + + this._signatureToName[signature] = name; + + return name; + } + + getCloseTagPlaceholderName(tag: string): string { + const signature = this._hashClosingTag(tag); + if (this._signatureToName[signature]) { + return this._signatureToName[signature]; + } + + const upperTag = tag.toUpperCase(); + const baseName = TAG_TO_PLACEHOLDER_NAMES[upperTag] || `TAG_${upperTag}`; + const name = this._generateUniqueName(`CLOSE_${baseName}`); + + this._signatureToName[signature] = name; + + return name; + } + + getPlaceholderName(name: string, content: string): string { + const upperName = name.toUpperCase(); + const signature = `PH: ${upperName}=${content}`; + if (this._signatureToName[signature]) { + return this._signatureToName[signature]; + } + + const uniqueName = this._generateUniqueName(upperName); + this._signatureToName[signature] = uniqueName; + + return uniqueName; + } + + private _hashTag(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string { + const start = `<${tag.toUpperCase()}`; + const strAttrs = + Object.getOwnPropertyNames(attrs).sort().map((name) => ` ${name}=${attrs[name]}`).join(''); + const end = isVoid ? '/>' : `>`; + + return start + strAttrs + end; + } + + private _hashClosingTag(tag: string): string { + return this._hashTag(`/${tag.toUpperCase()}`, {}, false); + } + + private _generateUniqueName(base: string): string { + let name = base; + let next = this._placeHolderNameCounts[name]; + + if (!next) { + next = 1; + } else { + name += `_${next}`; + next++; + } + + this._placeHolderNameCounts[base] = next; + + return name; + } +} \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/serializers/xmb.ts b/modules/@angular/compiler/src/i18n/serializers/xmb.ts index e69de29bb2..d3156801b9 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xmb.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xmb.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ListWrapper} from '../../facade/collection'; +import * as i18nAst from '../i18n_ast'; + +import {Serializer} from './serializer'; +import * as xml from './xml_helper'; + +const _MESSAGES_TAG = 'messagebundle'; +const _MESSAGE_TAG = 'msg'; +const _PLACEHOLDER_TAG = 'ph'; +const _EXEMPLE_TAG = 'ex'; + +export class XmbSerializer implements Serializer { + // TODO(vicb): DOCTYPE + write(messageMap: {[k: string]: i18nAst.Message}): string { + const visitor = new _Visitor(); + const declaration = new xml.Declaration({version: '1.0', encoding: 'UTF-8'}); + let rootNode = new xml.Tag(_MESSAGES_TAG); + rootNode.children.push(new xml.Text('\n')); + + Object.getOwnPropertyNames(messageMap).forEach((id) => { + const message = messageMap[id]; + let attrs: {[k: string]: string} = {id}; + + if (message.description) { + attrs['desc'] = message.description; + } + + if (message.meaning) { + attrs['meaning'] = message.meaning; + } + + rootNode.children.push( + new xml.Text(' '), new xml.Tag(_MESSAGE_TAG, attrs, visitor.serialize(message.nodes)), + new xml.Text('\n')); + }); + + return xml.serialize([ + declaration, + new xml.Text('\n'), + rootNode, + ]); + } + + load(content: string): {[k: string]: i18nAst.Node[]} { throw new Error('Unsupported'); } +} + +class _Visitor implements i18nAst.Visitor { + visitText(text: i18nAst.Text, context?: any): xml.Node[] { return [new xml.Text(text.value)]; } + + visitContainer(container: i18nAst.Container, context?: any): xml.Node[] { + const nodes: xml.Node[] = []; + container.children.forEach((node: i18nAst.Node) => nodes.push(...node.visit(this))); + return nodes; + } + + visitIcu(icu: i18nAst.Icu, context?: any): xml.Node[] { + const nodes = [new xml.Text(`{${icu.expression}, ${icu.type}, `)]; + + Object.getOwnPropertyNames(icu.cases).forEach((c: string) => { + nodes.push(new xml.Text(`${c} {`), ...icu.cases[c].visit(this), new xml.Text(`}`)); + }); + + nodes.push(new xml.Text(`}`)); + + return nodes; + } + + visitTagPlaceholder(ph: i18nAst.TagPlaceholder, context?: any): xml.Node[] { + const startEx = new xml.Tag(_EXEMPLE_TAG, {}, [new xml.Text(`<${ph.tag}>`)]); + const startTagPh = new xml.Tag(_PLACEHOLDER_TAG, {name: ph.startName}, [startEx]); + if (ph.isVoid) { + // void tags have no children nor closing tags + return [startTagPh]; + } + + const closeEx = new xml.Tag(_EXEMPLE_TAG, {}, [new xml.Text(``)]); + const closeTagPh = new xml.Tag(_PLACEHOLDER_TAG, {name: ph.closeName}, [closeEx]); + + return [startTagPh, ...this.serialize(ph.children), closeTagPh]; + } + + visitPlaceholder(ph: i18nAst.Placeholder, context?: any): xml.Node[] { + return [new xml.Tag(_PLACEHOLDER_TAG, {name: ph.name})]; + } + + visitIcuPlaceholder(ph: i18nAst.IcuPlaceholder, context?: any): xml.Node[] { + return [new xml.Tag(_PLACEHOLDER_TAG, {name: ph.name})]; + } + + serialize(nodes: i18nAst.Node[]): xml.Node[] { + return ListWrapper.flatten(nodes.map(node => node.visit(this))); + } +} diff --git a/modules/@angular/compiler/src/i18n/serializers/xml_helper.ts b/modules/@angular/compiler/src/i18n/serializers/xml_helper.ts new file mode 100644 index 0000000000..e088fb5f8b --- /dev/null +++ b/modules/@angular/compiler/src/i18n/serializers/xml_helper.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export interface IVisitor { + visitTag(tag: Tag): any; + visitText(text: Text): any; + visitDeclaration(decl: Declaration): any; +} + +class _Visitor implements IVisitor { + visitTag(tag: Tag): string { + const strAttrs = this._serializeAttributes(tag.attrs); + + if (tag.children.length == 0) { + return `<${tag.name}${strAttrs}/>`; + } + + const strChildren = tag.children.map(node => node.visit(this)); + return `<${tag.name}${strAttrs}>${strChildren.join('')}`; + } + + visitText(text: Text): string { return text.value; } + + visitDeclaration(decl: Declaration): string { + return ``; + } + + private _serializeAttributes(attrs: {[k: string]: string}) { + const strAttrs = Object.getOwnPropertyNames(attrs) + .map((name: string) => `${name}="${attrs[name]}"`) + .join(' '); + return strAttrs.length > 0 ? ' ' + strAttrs : ''; + } +} + +const _visitor = new _Visitor(); + +export function serialize(nodes: Node[]): string { + return nodes.map((node: Node): string => node.visit(_visitor)).join(''); +} + +export interface Node { visit(visitor: IVisitor): any; } + +export class Declaration implements Node { + public attrs: {[k: string]: string} = {}; + + constructor(unescapedAttrs: {[k: string]: string}) { + Object.getOwnPropertyNames(unescapedAttrs).forEach((k: string) => { + this.attrs[k] = _escapeXml(unescapedAttrs[k]); + }); + } + + visit(visitor: IVisitor): any { return visitor.visitDeclaration(this); } +} + +export class Tag implements Node { + public attrs: {[k: string]: string} = {}; + + constructor( + public name: string, unescapedAttrs: {[k: string]: string} = {}, + public children: Node[] = []) { + Object.getOwnPropertyNames(unescapedAttrs).forEach((k: string) => { + this.attrs[k] = _escapeXml(unescapedAttrs[k]); + }); + } + + visit(visitor: IVisitor): any { return visitor.visitTag(this); } +} + +export class Text implements Node { + value: string; + constructor(unescapedValue: string) { this.value = _escapeXml(unescapedValue); }; + + visit(visitor: IVisitor): any { return visitor.visitText(this); } +} + +const _ESCAPED_CHARS: [RegExp, string][] = [ + [/&/g, '&'], + [/"/g, '"'], + [/'/g, '''], + [//g, '>'], +]; + +function _escapeXml(text: string): string { + return _ESCAPED_CHARS.reduce( + (text: string, entry: [RegExp, string]) => text.replace(entry[0], entry[1]), text); +} diff --git a/modules/@angular/compiler/src/i18n/shared.ts b/modules/@angular/compiler/src/i18n/shared.ts index 2bc53f7ac3..534a0e57eb 100644 --- a/modules/@angular/compiler/src/i18n/shared.ts +++ b/modules/@angular/compiler/src/i18n/shared.ts @@ -175,8 +175,7 @@ export function extractPhNameFromInterpolation(input: string, index: number): st } export function extractPlaceholderName(input: string): string { - const matches = StringWrapper.split(input, _CUSTOM_PH_EXP); - return matches[1] || `interpolation`; + return StringWrapper.split(input, _CUSTOM_PH_EXP)[1]; } @@ -253,4 +252,4 @@ class _StringifyVisitor implements HtmlAstVisitor { 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/@angular/compiler/src/i18n/xmb_serializer.ts b/modules/@angular/compiler/src/i18n/xmb_serializer.ts index 6c0dc7ae96..d0fd9944c2 100644 --- a/modules/@angular/compiler/src/i18n/xmb_serializer.ts +++ b/modules/@angular/compiler/src/i18n/xmb_serializer.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {RegExpWrapper, isBlank, isPresent} from '../facade/lang'; +import {RegExpWrapper, isPresent} from '../facade/lang'; import {HtmlAst, HtmlElementAst} from '../html_ast'; import {HtmlParser} from '../html_parser'; import {ParseError, ParseSourceSpan} from '../parse_util'; diff --git a/modules/@angular/compiler/test/i18n/catalog_spec.ts b/modules/@angular/compiler/test/i18n/catalog_spec.ts new file mode 100644 index 0000000000..3d43d3db49 --- /dev/null +++ b/modules/@angular/compiler/test/i18n/catalog_spec.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {HtmlParser} from '@angular/compiler/src/html_parser'; +import {Catalog, strHash} from '@angular/compiler/src/i18n/catalog'; +import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/interpolation_config'; +import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; + +import Serializable = webdriver.Serializable; +import {Serializer} from '@angular/compiler/src/i18n/serializers/serializer'; +import {serializeAst} from '@angular/compiler/src/i18n/catalog'; +import * as i18nAst from '@angular/compiler/src/i18n/i18n_ast'; + +export function main(): void { + ddescribe('Catalog', () => { + + describe('write', () => { + let catalog: Catalog; + + beforeEach(() => { catalog = new Catalog(new HtmlParser, [], {}); }); + + it('should extract the message to the catalog', () => { + catalog.updateFromTemplate( + '

Translate Me

', 'url', DEFAULT_INTERPOLATION_CONFIG); + expect(humanizeCatalog(catalog)).toEqual([ + 'a486901=Translate Me', + ]); + }); + + it('should extract the same message with different meaning in different entries', () => { + catalog.updateFromTemplate( + '

Translate Me

Translate Me

', 'url', + DEFAULT_INTERPOLATION_CONFIG); + expect(humanizeCatalog(catalog)).toEqual([ + 'a486901=Translate Me', + '8475f2cc=Translate Me', + ]); + }); + }); + + describe( + 'load', () => { + // TODO + }); + + describe('strHash', () => { + it('should return a hash value', () => { + // https://github.com/google/closure-library/blob/1fb19a857b96b74e6523f3e9d33080baf25be046/closure/goog/string/string_test.js#L1115 + expectHash('', 0); + expectHash('foo', 101574); + expectHash('\uAAAAfoo', 1301670364); + expectHash('a', 92567585, 5); + expectHash('a', 2869595232, 6); + expectHash('a', 3058106369, 7); + expectHash('a', 312017024, 8); + expectHash('a', 2929737728, 1024); + }); + }); + }); +} + +class _TestSerializer implements Serializer { + write(messageMap: {[k: string]: i18nAst.Message}): string { + return Object.keys(messageMap) + .map(id => `${id}=${serializeAst(messageMap[id].nodes)}`) + .join('//'); + } + + load(content: string): {[k: string]: i18nAst.Node[]} { return null; } +} + +function humanizeCatalog(catalog: Catalog): string[] { + return catalog.write(new _TestSerializer()).split('//'); +} + +function expectHash(text: string, decimal: number, repeat: number = 1) { + let acc = text; + for (let i = 1; i < repeat; i++) { + acc += text; + } + + const hash = strHash(acc); + expect(typeof(hash)).toEqual('string'); + expect(hash.length > 0).toBe(true); + expect(parseInt(hash, 16)).toEqual(decimal); +} \ No newline at end of file diff --git a/modules/@angular/compiler/test/i18n/extractor_spec.ts b/modules/@angular/compiler/test/i18n/extractor_spec.ts index 66c4b91d78..8196c2cf50 100644 --- a/modules/@angular/compiler/test/i18n/extractor_spec.ts +++ b/modules/@angular/compiler/test/i18n/extractor_spec.ts @@ -263,8 +263,8 @@ export function main() { } function getExtractionResult( - html: string, implicitTags: string[], - implicitAttrs: {[k: string]: string[]}): ExtractionResult { + html: string, implicitTags: string[], implicitAttrs: + {[k: string]: string[]}): ExtractionResult { const htmlParser = new HtmlParser(); const parseResult = htmlParser.parse(html, 'extractor spec', true); if (parseResult.errors.length > 1) { @@ -275,8 +275,8 @@ function getExtractionResult( } function extract( - html: string, implicitTags: string[] = [], - implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] { + html: string, implicitTags: string[] = [], implicitAttrs: + {[k: string]: string[]} = {}): [string[], string, string][] { const messages = getExtractionResult(html, implicitTags, implicitAttrs).messages; // clang-format off @@ -287,8 +287,8 @@ function extract( } function extractErrors( - html: string, implicitTags: string[] = [], - implicitAttrs: {[k: string]: string[]} = {}): any[] { + html: string, implicitTags: string[] = [], implicitAttrs: + {[k: string]: string[]} = {}): any[] { const errors = getExtractionResult(html, implicitTags, implicitAttrs).errors; return errors.map((e): [string, string] => [e.msg, e.span.toString()]); diff --git a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts index ff6ab9c3c0..7a5087d1d5 100644 --- a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts +++ b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {HtmlParser} from "@angular/compiler/src/html_parser"; -import * as i18nAst from "@angular/compiler/src/i18n/i18n_ast"; -import {ddescribe, describe, expect, it} from "@angular/core/testing/testing_internal"; -import {extractI18nMessages} from "@angular/compiler/src/i18n/i18n_parser"; +import {HtmlParser} from '@angular/compiler/src/html_parser'; +import {serializeAst} from '@angular/compiler/src/i18n/catalog'; +import {extractI18nMessages} from '@angular/compiler/src/i18n/i18n_parser'; +import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/interpolation_config'; +import {ddescribe, describe, expect, it} from '@angular/core/testing/testing_internal'; + export function main() { ddescribe('I18nParser', () => { @@ -22,15 +24,32 @@ export function main() { it('should extract from nested elements', () => { expect(extract('
textnested
')).toEqual([ - [['text', 'nested'], 'm', 'd'], + [ + [ + 'text', + 'nested' + ], + 'm', 'd' + ], ]); }); it('should not create a message for empty elements', - () => { expect(extract('
')).toEqual([]); }); + () => { expect(extract('
')).toEqual([]); }); it('should not create a message for plain elements', - () => { expect(extract('
')).toEqual([]); }); + () => { expect(extract('
')).toEqual([]); }); + + it('should suppoprt void elements', () => { + expect(extract('


')).toEqual([ + [ + [ + '' + ], + 'm', 'd' + ], + ]); + }); }); describe('attributes', () => { @@ -42,51 +61,65 @@ export function main() { it('should extract from attributes in translatable element', () => { expect(extract('

')).toEqual([ - [[''], '', ''], + [ + [ + '' + ], + '', '' + ], [['msg'], 'm', 'd'], ]); }); it('should extract from attributes in translatable block', () => { - expect( - extract('

')) - .toEqual([ - [['msg'], 'm', 'd'], - [[''], '', ''], - ]); + expect(extract('

')) + .toEqual([ + [['msg'], 'm', 'd'], + [ + [ + '' + ], + '', '' + ], + ]); }); it('should extract from attributes in translatable ICU', () => { expect( - extract( - '{count, plural, =0 {

}}')) - .toEqual([ - [['msg'], 'm', 'd'], - [['{count, plural, =0 {[]}}'], '', ''], - ]); + extract( + '{count, plural, =0 {

}}')) + .toEqual([ + [['msg'], 'm', 'd'], + [ + [ + '{count, plural, =0 {[]}}' + ], + '', '' + ], + ]); }); it('should extract from attributes in non translatable ICU', () => { expect(extract('{count, plural, =0 {

}}')) - .toEqual([ - [['msg'], 'm', 'd'], - ]); + .toEqual([ + [['msg'], 'm', 'd'], + ]); }); it('should not create a message for empty attributes', - () => { expect(extract('
')).toEqual([]); }); + () => { expect(extract('
')).toEqual([]); }); }); describe('interpolation', () => { it('should replace interpolation with placeholder', () => { expect(extract('
before{{ exp }}after
')).toEqual([ - [['[before, exp , after]'], 'm', 'd'], + [['[before, exp , after]'], 'm', 'd'], ]); }); it('should support named interpolation', () => { expect(extract('
before{{ exp //i18n(ph="teSt") }}after
')).toEqual([ - [['[before, exp //i18n(ph="teSt") , after]'], 'm', 'd'], + [['[before, exp //i18n(ph="teSt") , after]'], 'm', 'd'], ]); }) }); @@ -96,19 +129,23 @@ export function main() { expect(extract(`message1 message2 message3`)) - .toEqual([ - [['message1'], 'meaning1', 'desc1'], - [['message2'], 'meaning2', ''], - [['message3'], '', ''], - ]); + .toEqual([ + [['message1'], 'meaning1', 'desc1'], + [['message2'], 'meaning2', ''], + [['message3'], '', ''], + ]); }); it('should extract all siblings', () => { - expect( - extract(`text

htmlnested

`)) - .toEqual([ - [[ 'text', 'html, nested'], '', '' ], - ]); + expect(extract(`text

htmlnested

`)).toEqual([ + [ + [ + 'text', + 'html, nested' + ], + '', '' + ], + ]); }); }); @@ -121,8 +158,8 @@ export function main() { it('should extract as ICU + ph when not single child of an element', () => { expect(extract('
b{count, plural, =0 {zero}}a
')).toEqual([ - [[ 'b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], - [[ '{count, plural, =0 {[zero]}}' ], '', ''], + [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], + [['{count, plural, =0 {[zero]}}'], '', ''], ]); }); @@ -134,16 +171,23 @@ export function main() { it('should extract as ICU + ph when not single child of a block', () => { expect(extract('b{count, plural, =0 {zero}}a')).toEqual([ - [[ '{count, plural, =0 {[zero]}}' ], '', ''], - [[ 'b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], + [['{count, plural, =0 {[zero]}}'], '', ''], + [['b', '{count, plural, =0 {[zero]}}', 'a'], 'm', 'd'], ]); }); it('should not extract nested ICU messages', () => { - expect(extract('
b{count, plural, =0 {{sex, gender, =m {m}}}}a
')).toEqual([ - [[ 'b', '{count, plural, =0 {[{sex, gender, =m {[m]}}]}}', 'a'], 'm', 'd'], - [[ '{count, plural, =0 {[{sex, gender, =m {[m]}}]}}' ], '', ''], - ]); + expect(extract('
b{count, plural, =0 {{sex, gender, =m {m}}}}a
')) + .toEqual([ + [ + [ + 'b', '{count, plural, =0 {[{sex, gender, =m {[m]}}]}}', + 'a' + ], + 'm', 'd' + ], + [['{count, plural, =0 {[{sex, gender, =m {[m]}}]}}'], '', ''], + ]); }); }); @@ -158,57 +202,71 @@ export function main() { describe('implicit attributes', () => { it('should extract implicit attributes', () => { expect(extract('bolditalic', [], {'b': ['title']})) - .toEqual([ - [['bb'], '', ''], - ]); + .toEqual([ + [['bb'], '', ''], + ]); }); }); + + describe('placeholders', () => { + it('should reuse the same placeholder name for tags', () => { + expect(extract('

one

two

three

')).toEqual([ + [ + [ + 'one', + 'two', + 'three', + ], + 'm', 'd' + ], + ]); + }); + + it('should reuse the same placeholder name for interpolations', () => { + expect(extract('
{{ a }}{{ a }}{{ b }}
')).toEqual([ + [ + [ + '[ a , a , b ]' + ], + 'm', 'd' + ], + ]); + }); + + it('should reuse the same placeholder name for icu messages', () => { + expect( + extract( + '
{count, plural, =0 {0}}{count, plural, =0 {0}}{count, plural, =1 {1}}
')) + .toEqual([ + [ + [ + '{count, plural, =0 {[0]}}', + '{count, plural, =0 {[0]}}', + '{count, plural, =1 {[1]}}', + ], + 'm', 'd' + ], + [['{count, plural, =0 {[0]}}'], '', ''], + [['{count, plural, =0 {[0]}}'], '', ''], + [['{count, plural, =1 {[1]}}'], '', ''], + ]); + }); + + }); }); } -class _SerializerVisitor implements i18nAst.Visitor { - visitText(text:i18nAst.Text, context:any):any { - return text.value; - } - - visitContainer(container:i18nAst.Container, context:any):any { - return `[${container.children.map(child => child.visit(this)).join(', ')}]` - } - - visitIcu(icu:i18nAst.Icu, context:any):any { - let strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`); - return `{${icu.expression}, ${icu.type}, ${strCases.join(', ')}}` - } - - visitTagPlaceholder(ph:i18nAst.TagPlaceholder, context:any):any { - return `${ph.children.map(child => child.visit(this)).join(', ')}`; - } - - visitPlaceholder(ph:i18nAst.Placeholder, context:any):any { - return `${ph.value}`; - } - - visitIcuPlaceholder(ph:i18nAst.IcuPlaceholder, context?:any):any { - return `${ph.value.visit(this)}` - } -} - -const serializerVisitor = new _SerializerVisitor(); - -export function serializeAst(ast: i18nAst.I18nNode[]): string[] { - return ast.map(a => a.visit(serializerVisitor, null)); -} - function extract( - html: string, implicitTags: string[] = [], - implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] { + html: string, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] { const htmlParser = new HtmlParser(); const parseResult = htmlParser.parse(html, 'extractor spec', true); if (parseResult.errors.length > 1) { throw Error(`unexpected parse errors: ${parseResult.errors.join('\n')}`); } - const messages = extractI18nMessages(parseResult.rootNodes, implicitTags, implicitAttrs); + const messages = extractI18nMessages( + parseResult.rootNodes, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs); // clang-format off // https://github.com/angular/clang-format/issues/35 @@ -216,5 +274,3 @@ function extract( message => [serializeAst(message.nodes), message.meaning, message.description, ]) as [string[], string, string][]; // clang-format on } - - diff --git a/modules/@angular/compiler/test/i18n/serializers/util_spec.ts b/modules/@angular/compiler/test/i18n/serializers/util_spec.ts new file mode 100644 index 0000000000..b539671c40 --- /dev/null +++ b/modules/@angular/compiler/test/i18n/serializers/util_spec.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + + + +import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; + +import {PlaceholderRegistry} from '../../../src/i18n/serializers/util'; + +export function main(): void { + ddescribe('PlaceholderRegistry', () => { + let reg: PlaceholderRegistry; + + beforeEach(() => { reg = new PlaceholderRegistry(); }); + + describe('tag placeholder', () => { + it('should generate names for well known tags', () => { + expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH'); + expect(reg.getCloseTagPlaceholderName('p')).toEqual('CLOSE_PARAGRAPH'); + }); + + it('should generate names for custom tags', () => { + expect(reg.getStartTagPlaceholderName('my-cmp', {}, false)).toEqual('START_TAG_MY-CMP'); + expect(reg.getCloseTagPlaceholderName('my-cmp')).toEqual('CLOSE_TAG_MY-CMP'); + }); + + it('should generate the same name for the same tag', () => { + expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH'); + }); + + it('should be case insensitive for tag name', () => { + expect(reg.getStartTagPlaceholderName('p', {}, false)).toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('P', {}, false)).toEqual('START_PARAGRAPH'); + expect(reg.getCloseTagPlaceholderName('p')).toEqual('CLOSE_PARAGRAPH'); + expect(reg.getCloseTagPlaceholderName('P')).toEqual('CLOSE_PARAGRAPH'); + }); + + it('should generate the same name for the same tag with the same attributes', () => { + expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false)) + .toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false)) + .toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {bar: 'b', foo: 'a'}, false)) + .toEqual('START_PARAGRAPH'); + }); + + it('should generate different names for the same tag with different attributes', () => { + expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false)) + .toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {foo: 'a'}, false)).toEqual('START_PARAGRAPH_1'); + }); + + it('should be case sensitive for attributes', () => { + expect(reg.getStartTagPlaceholderName('p', {foo: 'a', bar: 'b'}, false)) + .toEqual('START_PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {fOo: 'a', bar: 'b'}, false)) + .toEqual('START_PARAGRAPH_1'); + expect(reg.getStartTagPlaceholderName('p', {fOo: 'a', bAr: 'b'}, false)) + .toEqual('START_PARAGRAPH_2'); + }); + + it('should support void tags', () => { + expect(reg.getStartTagPlaceholderName('p', {}, true)).toEqual('PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {}, true)).toEqual('PARAGRAPH'); + expect(reg.getStartTagPlaceholderName('p', {other: 'true'}, true)).toEqual('PARAGRAPH_1'); + }); + }); + + describe('arbitrary placeholders', () => { + it('should generate the same name given the same name and content', () => { + expect(reg.getPlaceholderName('name', 'content')).toEqual('NAME'); + expect(reg.getPlaceholderName('name', 'content')).toEqual('NAME'); + }); + + it('should generate a different name given different content', () => { + expect(reg.getPlaceholderName('name', 'content1')).toEqual('NAME'); + expect(reg.getPlaceholderName('name', 'content2')).toEqual('NAME_1'); + expect(reg.getPlaceholderName('name', 'content3')).toEqual('NAME_2'); + }); + + it('should generate a different name given different names', () => { + expect(reg.getPlaceholderName('name1', 'content')).toEqual('NAME1'); + expect(reg.getPlaceholderName('name2', 'content')).toEqual('NAME2'); + }); + + }); + }); +} \ No newline at end of file diff --git a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts new file mode 100644 index 0000000000..c0c2a3d7e5 --- /dev/null +++ b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {HtmlParser} from '@angular/compiler/src/html_parser'; +import {Catalog} from '@angular/compiler/src/i18n/catalog'; +import {XmbSerializer} from '@angular/compiler/src/i18n/serializers/xmb'; +import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/interpolation_config'; +import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; + + +export function main(): void { + ddescribe('XMB serializer', () => { + const HTML = ` +

not translatable

+

translatable element with placeholders {{ interpolation}}

+{ count, plural, =0 {

test

}} +

foo

+

{ count, plural, =0 { { sex, gender, other {

deeply nested

}} }}

`; + + const XMB = ` + + translatable element <b>with placeholders</b> + { count, plural, =0 {<p>test</p>}} + foo + { count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }} +`; + + it('should write a valid xmb file', () => { expect(toXmb(HTML)).toEqual(XMB); }); + + it('should throw when trying to load an xmb file', () => { + expect(() => { + const serializer = new XmbSerializer(); + serializer.load(XMB); + }).toThrow(); + }); + }); +} + +function toXmb(html: string): string { + let catalog = new Catalog(new HtmlParser, [], {}); + const serializer = new XmbSerializer(); + + catalog.updateFromTemplate(html, '', DEFAULT_INTERPOLATION_CONFIG); + + return catalog.write(serializer); +} + +// translatable +// element <b>with placeholders</b> { count, plural, =0 {<p>test</p>}}foo{ count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }} +// translatable +// element <b>with placeholders</b> { count, plural, =0 {<p>test</p>}}{ count, +// plural, =0 {{ sex, gender, other {<p>deeply +// nested</p>}} }}foo \ No newline at end of file diff --git a/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts new file mode 100644 index 0000000000..1edc8d3f44 --- /dev/null +++ b/modules/@angular/compiler/test/i18n/serializers/xml_helper_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; + +import * as xml from '../../../src/i18n/serializers/xml_helper'; + +export function main(): void { + ddescribe('XML helper', () => { + it('should serialize XML declaration', () => { + expect(xml.serialize([new xml.Declaration({version: '1.0'})])) + .toEqual(''); + }); + + it('should serialize text node', + () => { expect(xml.serialize([new xml.Text('foo bar')])).toEqual('foo bar'); }); + + it('should escape text nodes', + () => { expect(xml.serialize([new xml.Text('<>')])).toEqual('<>'); }); + + it('should serialize xml nodes without children', () => { + expect(xml.serialize([new xml.Tag('el', {foo: 'bar'}, [])])).toEqual(''); + }); + + it('should serialize xml nodes with children', () => { + expect(xml.serialize([ + new xml.Tag('parent', {}, [new xml.Tag('child', {}, [new xml.Text('content')])]) + ])).toEqual('content'); + }); + + it('should serialize node lists', () => { + expect(xml.serialize([ + new xml.Tag('el', {order: '0'}, []), + new xml.Tag('el', {order: '1'}, []), + ])).toEqual(''); + }); + + it('should escape attribute values', () => { + expect(xml.serialize([new xml.Tag('el', {foo: '<">'}, [])])) + .toEqual(''); + }); + + }); +} \ No newline at end of file