diff --git a/modules/@angular/compiler/src/i18n/extractor.ts b/modules/@angular/compiler/src/i18n/extractor.ts deleted file mode 100644 index e5b2774a5d..0000000000 --- a/modules/@angular/compiler/src/i18n/extractor.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * @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 html from '../ml_parser/ast'; -import {I18nError} from './parse_util'; - -const _I18N_ATTR = 'i18n'; -const _I18N_ATTR_PREFIX = 'i18n-'; -const _I18N_COMMENT_PREFIX_REGEXP = /^i18n:?/; - -/** - * Extract translatable messages from an html AST as a list of html AST nodes - */ -export function extractAstMessages( - sourceAst: html.Node[], implicitTags: string[], - implicitAttrs: {[k: string]: string[]}): ExtractionResult { - const visitor = new _ExtractVisitor(implicitTags, implicitAttrs); - return visitor.extract(sourceAst); -} - -export class ExtractionResult { - constructor(public messages: Message[], public errors: I18nError[]) {} -} - -class _ExtractVisitor implements html.Visitor { - // ... - private _inI18nNode = false; - private _depth: number = 0; - - // ... - private _blockMeaningAndDesc: string; - private _blockChildren: html.Node[]; - private _blockStartDepth: number; - private _inI18nBlock: boolean; - - // {} - private _inIcu = false; - - private _msgCountAtSectionStart: number; - private _errors: I18nError[]; - - constructor(private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} - - extract(nodes: html.Node[]): ExtractionResult { - this._init(); - const messages: Message[] = []; - - nodes.forEach(node => node.visit(this, messages)); - - if (this._inI18nBlock) { - this._reportError(nodes[nodes.length - 1], 'Unclosed block'); - } - - return new ExtractionResult(messages, this._errors); - } - - visitExpansionCase(icuCase: html.ExpansionCase, messages: Message[]): any { - html.visitAll(this, icuCase.expression, messages); - } - - visitExpansion(icu: html.Expansion, messages: Message[]): any { - this._mayBeAddBlockChildren(icu); - - const wasInIcu = this._inIcu; - - if (!this._inIcu) { - if (this._inI18nNode || this._inI18nBlock) { - this._addMessage(messages, [icu]); - } - this._inIcu = true; - } - - html.visitAll(this, icu.cases, messages); - - this._inIcu = wasInIcu; - } - - visitComment(comment: html.Comment, messages: Message[]): any { - const isOpening = _isOpeningComment(comment); - - if (isOpening && (this._inI18nBlock || this._inI18nNode)) { - this._reportError(comment, 'Could not start a block inside a translatable section'); - return; - } - - const isClosing = _isClosingComment(comment); - - if (isClosing && !this._inI18nBlock) { - this._reportError(comment, 'Trying to close an unopened block'); - return; - } - - if (!(this._inI18nNode || this._inIcu)) { - if (!this._inI18nBlock) { - if (isOpening) { - this._inI18nBlock = true; - this._blockStartDepth = this._depth; - this._blockChildren = []; - this._blockMeaningAndDesc = comment.value.replace(_I18N_COMMENT_PREFIX_REGEXP, '').trim(); - this._openTranslatableSection(comment, messages); - } - } else { - if (isClosing) { - if (this._depth == this._blockStartDepth) { - this._closeTranslatableSection(comment, messages, this._blockChildren); - this._inI18nBlock = false; - this._addMessage(messages, this._blockChildren, this._blockMeaningAndDesc); - } else { - this._reportError(comment, 'I18N blocks should not cross element boundaries'); - return; - } - } - } - } - } - - visitText(text: html.Text, messages: Message[]): any { this._mayBeAddBlockChildren(text); } - - visitElement(el: html.Element, messages: Message[]): any { - this._mayBeAddBlockChildren(el); - this._depth++; - const wasInI18nNode = this._inI18nNode; - - // Extract only top level nodes with the (implicit) "i18n" attribute if not in a block or an ICU - // message - const i18nAttr = _getI18nAttr(el); - const isImplicitI18n = this._implicitTags.some((tag: string): boolean => el.name === tag); - if (!(this._inI18nNode || this._inIcu || this._inI18nBlock)) { - if (i18nAttr) { - this._inI18nNode = true; - this._addMessage(messages, el.children, i18nAttr.value); - } else if (isImplicitI18n) { - this._inI18nNode = true; - this._addMessage(messages, el.children); - } - } else { - if (i18nAttr || isImplicitI18n) { - // TODO(vicb): we should probably allow nested implicit element (ie
) - this._reportError( - el, 'Could not mark an element as translatable inside a translatable section'); - } - } - - this._extractFromAttributes(el, messages); - - if (i18nAttr || isImplicitI18n) { - // Start a section when the content is translatable - this._openTranslatableSection(el, messages); - html.visitAll(this, el.children, messages); - this._closeTranslatableSection(el, messages, el.children); - } else { - html.visitAll(this, el.children, messages); - } - - this._depth--; - this._inI18nNode = wasInI18nNode; - } - - visitAttribute(attribute: html.Attribute, messages: Message[]): any { - throw new Error('unreachable code'); - } - - private _init(): void { - this._inI18nBlock = false; - this._inI18nNode = false; - this._depth = 0; - this._inIcu = false; - this._msgCountAtSectionStart = void 0; - this._errors = []; - } - - private _extractFromAttributes(el: html.Element, messages: Message[]): void { - const explicitAttrNameToValue: {[k: string]: string} = {}; - const implicitAttrNames: string[] = this._implicitAttrs[el.name] || []; - - el.attrs.filter(attr => attr.name.startsWith(_I18N_ATTR_PREFIX)) - .forEach( - attr => explicitAttrNameToValue[attr.name.slice(_I18N_ATTR_PREFIX.length)] = - attr.value); - - el.attrs.forEach(attr => { - if (attr.name in explicitAttrNameToValue) { - this._addMessage(messages, [attr], explicitAttrNameToValue[attr.name]); - } else if (implicitAttrNames.some(name => attr.name === name)) { - this._addMessage(messages, [attr]); - } - }); - } - - private _addMessage(messages: Message[], ast: html.Node[], meaningAndDesc?: string): void { - if (ast.length == 0 || - ast.length == 1 && ast[0] instanceof html.Attribute && !(ast[0]).value) { - // Do not create empty messages - return; - } - const [meaning, description] = _splitMeaningAndDesc(meaningAndDesc); - messages.push(new Message(ast, meaning, description)); - } - - /** - * Add the node as a child of the block when: - * - we are in a block, - * - we are not inside a ICU message (those are handled separately), - * - the node is a "direct child" of the block - */ - private _mayBeAddBlockChildren(node: html.Node): void { - if (this._inI18nBlock && !this._inIcu && this._depth == this._blockStartDepth) { - this._blockChildren.push(node); - } - } - - /** - * Marks the start of a section, see `_endSection` - */ - private _openTranslatableSection(node: html.Node, messages: Message[]): void { - if (this._msgCountAtSectionStart !== void 0) { - this._reportError(node, 'Unexpected section start'); - } else { - this._msgCountAtSectionStart = messages.length; - } - } - - /** - * Terminates a section. - * - * If a section has only one significant children (comments not significant) then we should not - * keep the message from this children: - * - * `

{ICU message}

` would produce two messages: - * - one for the

content with meaning and description, - * - another one for the ICU message. - * - * In this case the last message is discarded as it contains less information (the AST is - * otherwise identical). - * - * Note that we should still keep messages extracted from attributes inside the section (ie in the - * ICU message here) - */ - private _closeTranslatableSection( - node: html.Node, messages: Message[], directChildren: html.Node[]): void { - if (this._msgCountAtSectionStart === void 0) { - this._reportError(node, 'Unexpected section end'); - return; - } - - const startIndex = this._msgCountAtSectionStart; - const significantChildren: number = directChildren.reduce( - (count: number, node: html.Node): number => count + (node instanceof html.Comment ? 0 : 1), - 0); - - if (significantChildren == 1) { - for (let i = messages.length - 1; i >= startIndex; i--) { - const ast = messages[i].nodes; - if (!(ast.length == 1 && ast[0] instanceof html.Attribute)) { - messages.splice(i, 1); - break; - } - } - } - - this._msgCountAtSectionStart = void 0; - } - - private _reportError(node: html.Node, msg: string): void { - this._errors.push(new I18nError(node.sourceSpan, msg)); - } -} - -/** - * A Message contain a fragment (= a subtree) of the source html AST. - */ -export class Message { - constructor(public nodes: html.Node[], public meaning: string, public description: string) {} -} - -function _isOpeningComment(n: html.Node): boolean { - return n instanceof html.Comment && n.value && n.value.startsWith('i18n'); -} - -function _isClosingComment(n: html.Node): boolean { - return n instanceof html.Comment && n.value && n.value === '/i18n'; -} - -function _getI18nAttr(p: html.Element): html.Attribute { - return p.attrs.find(attr => attr.name === _I18N_ATTR) || null; -} - -function _splitMeaningAndDesc(i18n: string): [string, string] { - if (!i18n) return ['', '']; - const pipeIndex = i18n.indexOf('|'); - return pipeIndex == -1 ? ['', i18n] : [i18n.slice(0, pipeIndex), i18n.slice(pipeIndex + 1)]; -} diff --git a/modules/@angular/compiler/src/i18n/extractor_merger.ts b/modules/@angular/compiler/src/i18n/extractor_merger.ts new file mode 100644 index 0000000000..ab298ef77e --- /dev/null +++ b/modules/@angular/compiler/src/i18n/extractor_merger.ts @@ -0,0 +1,497 @@ +/** + * @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 html from '../ml_parser/ast'; +import {InterpolationConfig} from '../ml_parser/interpolation_config'; + +import {Message as I18nMessage} from './i18n_ast'; +import * as i18nParser from './i18n_parser'; +import * as msgBundle from './message_bundle'; +import {I18nError} from './parse_util'; +import {TranslationBundle} from './translation_bundle'; + +const _I18N_ATTR = 'i18n'; +const _I18N_ATTR_PREFIX = 'i18n-'; +const _I18N_COMMENT_PREFIX_REGEXP = /^i18n:?/; + +/** + * Extract translatable messages from an html AST as a list of html AST nodes + */ +export function extractAstMessages( + nodes: html.Node[], implicitTags: string[], + implicitAttrs: {[k: string]: string[]}): ExtractionResult { + const visitor = new _Visitor(implicitTags, implicitAttrs); + return visitor.extract(nodes); +} + +export function mergeTranslations( + nodes: html.Node[], translations: TranslationBundle, interpolationConfig: InterpolationConfig, + implicitTags: string[], implicitAttrs: {[k: string]: string[]}): html.Node[] { + const visitor = new _Visitor(implicitTags, implicitAttrs); + return visitor.merge(nodes, translations, interpolationConfig); +} + +export class ExtractionResult { + constructor(public messages: Message[], public errors: I18nError[]) {} +} + +enum _VisitorMode { + Extract, + Merge +} + +/** + * This Visitor is used: + * 1. to extract all the translatable strings from an html AST (see `extract()`), + * 2. to replace the translatable strings with the actual translations (see `merge()`) + * + * @internal + */ +class _Visitor implements html.Visitor { + // ... + private _inI18nNode = false; + private _depth: number = 0; + + // ... + private _blockMeaningAndDesc: string; + private _blockChildren: html.Node[]; + private _blockStartDepth: number; + private _inI18nBlock: boolean; + + // {} + private _inIcu = false; + + private _msgCountAtSectionStart: number; + private _errors: I18nError[]; + private _mode: _VisitorMode; + + // _VisitorMode.Extract only + private _messages: Message[]; + + // _VisitorMode.Merge only + private _translations: TranslationBundle; + private _convertHtmlToI18n: (html: Message) => I18nMessage; + + + constructor(private _implicitTags: string[], private _implicitAttrs: {[k: string]: string[]}) {} + + /** + * Extracts the messages from the tree + */ + extract(nodes: html.Node[]): ExtractionResult { + this._init(_VisitorMode.Extract); + + nodes.forEach(node => node.visit(this, null)); + + if (this._inI18nBlock) { + this._reportError(nodes[nodes.length - 1], 'Unclosed block'); + } + + return new ExtractionResult(this._messages, this._errors); + } + + /** + * Returns a tree where all translatable nodes are translated + */ + merge( + nodes: html.Node[], translations: TranslationBundle, + interpolationConfig: InterpolationConfig): html.Node[] { + this._init(_VisitorMode.Merge); + this._convertHtmlToI18n = i18nParser.getHtmlToI18nConverter(interpolationConfig); + this._translations = translations; + + // Construct a single fake root element + const wrapper = new html.Element('wrapper', [], nodes, null, null, null); + + const translatedNode = wrapper.visit(this, null); + + if (this._inI18nBlock) { + this._reportError(nodes[nodes.length - 1], 'Unclosed block'); + } + + return translatedNode.children; + } + + visitExpansionCase(icuCase: html.ExpansionCase, context: any): any { + // Parse cases for translatable html attributes + const expression = html.visitAll(this, icuCase.expression, context); + + if (this._mode === _VisitorMode.Merge) { + return new html.ExpansionCase( + icuCase.value, expression, icuCase.sourceSpan, icuCase.valueSourceSpan, + icuCase.expSourceSpan); + } + } + + visitExpansion(icu: html.Expansion, context: any): html.Expansion { + this._mayBeAddBlockChildren(icu); + + const wasInIcu = this._inIcu; + + if (!this._inIcu) { + // nested ICU messages should not be extracted but top-level translated as a whole + if (this._isInTranslatableSection) { + this._addMessage(context, [icu]); + } + this._inIcu = true; + } + + const cases = html.visitAll(this, icu.cases, context); + + if (this._mode === _VisitorMode.Merge) { + icu = new html.Expansion( + icu.switchValue, icu.type, cases, icu.sourceSpan, icu.switchValueSourceSpan); + } + + this._inIcu = wasInIcu; + + return icu; + } + + visitComment(comment: html.Comment, context: any): any { + const isOpening = _isOpeningComment(comment); + + if (isOpening && this._isInTranslatableSection) { + this._reportError(comment, 'Could not start a block inside a translatable section'); + return; + } + + const isClosing = _isClosingComment(comment); + + if (isClosing && !this._inI18nBlock) { + this._reportError(comment, 'Trying to close an unopened block'); + return; + } + + if (!this._inI18nNode && !this._inIcu) { + if (!this._inI18nBlock) { + if (isOpening) { + this._inI18nBlock = true; + this._blockStartDepth = this._depth; + this._blockChildren = []; + this._blockMeaningAndDesc = comment.value.replace(_I18N_COMMENT_PREFIX_REGEXP, '').trim(); + this._openTranslatableSection(comment); + } + } else { + if (isClosing) { + if (this._depth == this._blockStartDepth) { + this._closeTranslatableSection(comment, this._blockChildren); + this._inI18nBlock = false; + const message = + this._addMessage(context, this._blockChildren, this._blockMeaningAndDesc); + return this._translateMessage(comment, message); + } else { + this._reportError(comment, 'I18N blocks should not cross element boundaries'); + return; + } + } + } + } + } + + visitText(text: html.Text, context: any): html.Text { + if (this._isInTranslatableSection) { + this._mayBeAddBlockChildren(text); + } + return text; + } + + visitElement(el: html.Element, context: any): html.Element { + this._mayBeAddBlockChildren(el); + this._depth++; + const wasInI18nNode = this._inI18nNode; + let childNodes: html.Node[]; + + // Extract only top level nodes with the (implicit) "i18n" attribute if not in a block or an ICU + // message + const i18nAttr = _getI18nAttr(el); + const isImplicitI18n = this._implicitTags.some((tag: string): boolean => el.name === tag); + + if (!this._isInTranslatableSection && !this._inIcu) { + if (i18nAttr) { + // explicit translation + this._inI18nNode = true; + const message = this._addMessage(context, el.children, i18nAttr.value); + childNodes = this._translateMessage(el, message); + } else if (isImplicitI18n) { + // implicit translation + this._inI18nNode = true; + const message = this._addMessage(context, el.children); + childNodes = this._translateMessage(el, message); + } + + if (this._mode == _VisitorMode.Extract) { + const isTranslatable = i18nAttr || isImplicitI18n; + if (isTranslatable) { + this._openTranslatableSection(el); + } + html.visitAll(this, el.children); + if (isTranslatable) { + this._closeTranslatableSection(el, el.children); + } + } + + if (this._mode === _VisitorMode.Merge && !i18nAttr && !isImplicitI18n) { + childNodes = []; + el.children.forEach(child => { + const visited = child.visit(this, context); + if (visited && !this._isInTranslatableSection) { + // Do not add the children from translatable sections (= i18n blocks here) + // They will be added when the section is close (i.e. on ``) + childNodes = childNodes.concat(visited); + } + }); + } + } else { + if (i18nAttr || isImplicitI18n) { + // TODO(vicb): we should probably allow nested implicit element (ie

) + this._reportError( + el, 'Could not mark an element as translatable inside a translatable section'); + } + + if (this._mode == _VisitorMode.Extract) { + // Descend into child nodes for extraction + html.visitAll(this, el.children); + } + + if (this._mode == _VisitorMode.Merge) { + // Translate attributes in ICU messages + childNodes = []; + el.children.forEach(child => { + const visited = child.visit(this, context); + if (visited && !this._isInTranslatableSection) { + // Do not add the children from translatable sections (= i18n blocks here) + // They will be added when the section is close (i.e. on ``) + childNodes = childNodes.concat(visited); + } + }); + } + } + + this._visitAttributesOf(el, context); + + this._depth--; + this._inI18nNode = wasInI18nNode; + + if (this._mode === _VisitorMode.Merge) { + // There are no childNodes in translatable sections - those nodes will be replace anyway + const translatedAttrs = this._translateAttributes(el); + return new html.Element( + el.name, translatedAttrs, childNodes, el.sourceSpan, el.startSourceSpan, + el.endSourceSpan); + } + } + + visitAttribute(attribute: html.Attribute, context: any): any { + throw new Error('unreachable code'); + } + + private _init(mode: _VisitorMode): void { + this._mode = mode; + this._inI18nBlock = false; + this._inI18nNode = false; + this._depth = 0; + this._inIcu = false; + this._msgCountAtSectionStart = void 0; + this._errors = []; + this._messages = []; + } + + // looks for translatable attributes + private _visitAttributesOf(el: html.Element, context: any): void { + const explicitAttrNameToValue: {[k: string]: string} = {}; + const implicitAttrNames: string[] = this._implicitAttrs[el.name] || []; + + el.attrs.filter(attr => attr.name.startsWith(_I18N_ATTR_PREFIX)) + .forEach( + attr => explicitAttrNameToValue[attr.name.slice(_I18N_ATTR_PREFIX.length)] = + attr.value); + + el.attrs.forEach(attr => { + if (attr.name in explicitAttrNameToValue) { + this._addMessage(context, [attr], explicitAttrNameToValue[attr.name]); + } else if (implicitAttrNames.some(name => attr.name === name)) { + this._addMessage(context, [attr]); + } + }); + } + + // add a translatable message + private _addMessage(context: any, ast: html.Node[], meaningAndDesc?: string): Message { + if (ast.length == 0 || + ast.length == 1 && ast[0] instanceof html.Attribute && !(ast[0]).value) { + // Do not create empty messages + return; + } + + const [meaning, description] = _splitMeaningAndDesc(meaningAndDesc); + const message = new Message(ast, meaning, description); + this._messages.push(message); + return message; + } + + // translate the given message given the `TranslationBundle` + private _translateMessage(el: html.Node, message: Message): html.Node[] { + if (message && this._mode === _VisitorMode.Merge) { + const i18nMessage: I18nMessage = this._convertHtmlToI18n(message); + const id = msgBundle.digestMessage(i18nMessage.nodes, i18nMessage.meaning); + const nodes = this._translations.get(id); + + if (nodes) { + return nodes; + } + + this._reportError(el, `Translation unavailable for message id="${id}"`); + } + + return []; + } + + // translate the attributes of an element and remove i18n specific attributes + private _translateAttributes(el: html.Element): html.Attribute[] { + const attributes = el.attrs; + const i18nAttributeMeanings: {[name: string]: string} = {}; + + attributes.forEach(attr => { + if (attr.name.startsWith(_I18N_ATTR_PREFIX)) { + i18nAttributeMeanings[attr.name.slice(_I18N_ATTR_PREFIX.length)] = + _splitMeaningAndDesc(attr.value)[0]; + } + }); + + const translatedAttributes: html.Attribute[] = []; + + attributes.forEach((attr) => { + if (attr.name === _I18N_ATTR || attr.name.startsWith(_I18N_ATTR_PREFIX)) { + // strip i18n specific attributes + return; + } + + if (i18nAttributeMeanings.hasOwnProperty(attr.name)) { + const meaning = i18nAttributeMeanings[attr.name]; + const i18nMessage: I18nMessage = this._convertHtmlToI18n(new Message([attr], meaning, '')); + const id = msgBundle.digestMessage(i18nMessage.nodes, i18nMessage.meaning); + const nodes = this._translations.get(id); + if (!nodes) { + this._reportError( + el, `Translation unavailable for attribute "${attr.name}" (id="${id}")`); + } + if (nodes[0] instanceof html.Text) { + const value = (nodes[0] as html.Text).value; + translatedAttributes.push(new html.Attribute(attr.name, value, attr.sourceSpan)); + } + } else { + translatedAttributes.push(attr); + } + }); + + return translatedAttributes; + } + + + /** + * Add the node as a child of the block when: + * - we are in a block, + * - we are not inside a ICU message (those are handled separately), + * - the node is a "direct child" of the block + */ + private _mayBeAddBlockChildren(node: html.Node): void { + if (this._inI18nBlock && !this._inIcu && this._depth == this._blockStartDepth) { + this._blockChildren.push(node); + } + } + + /** + * Marks the start of a section, see `_endSection` + */ + private _openTranslatableSection(node: html.Node): void { + if (this._isInTranslatableSection) { + this._reportError(node, 'Unexpected section start'); + } else { + this._msgCountAtSectionStart = this._messages.length; + } + } + + /** + * A translatable section could be: + * - a translatable element, + * - nodes between `` and `` comments + */ + private get _isInTranslatableSection(): boolean { + return this._msgCountAtSectionStart !== void 0; + } + + /** + * Terminates a section. + * + * If a section has only one significant children (comments not significant) then we should not + * keep the message from this children: + * + * `

{ICU message}

` would produce two messages: + * - one for the

content with meaning and description, + * - another one for the ICU message. + * + * In this case the last message is discarded as it contains less information (the AST is + * otherwise identical). + * + * Note that we should still keep messages extracted from attributes inside the section (ie in the + * ICU message here) + */ + private _closeTranslatableSection(node: html.Node, directChildren: html.Node[]): void { + if (!this._isInTranslatableSection) { + this._reportError(node, 'Unexpected section end'); + return; + } + + const startIndex = this._msgCountAtSectionStart; + const significantChildren: number = directChildren.reduce( + (count: number, node: html.Node): number => count + (node instanceof html.Comment ? 0 : 1), + 0); + + if (significantChildren == 1) { + for (let i = this._messages.length - 1; i >= startIndex; i--) { + const ast = this._messages[i].nodes; + if (!(ast.length == 1 && ast[0] instanceof html.Attribute)) { + this._messages.splice(i, 1); + break; + } + } + } + + this._msgCountAtSectionStart = void 0; + } + + private _reportError(node: html.Node, msg: string): void { + this._errors.push(new I18nError(node.sourceSpan, msg)); + } +} + +/** + * A Message contain a fragment (= a subtree) of the source html AST. + */ +export class Message { + constructor(public nodes: html.Node[], public meaning: string, public description: string) {} +} + +function _isOpeningComment(n: html.Node): boolean { + return n instanceof html.Comment && n.value && n.value.startsWith('i18n'); +} + +function _isClosingComment(n: html.Node): boolean { + return n instanceof html.Comment && n.value && n.value === '/i18n'; +} + +function _getI18nAttr(p: html.Element): html.Attribute { + return p.attrs.find(attr => attr.name === _I18N_ATTR) || null; +} + +function _splitMeaningAndDesc(i18n: string): [string, string] { + if (!i18n) return ['', '']; + const pipeIndex = i18n.indexOf('|'); + return pipeIndex == -1 ? ['', i18n] : [i18n.slice(0, pipeIndex), i18n.slice(pipeIndex + 1)]; +} diff --git a/modules/@angular/compiler/src/i18n/i18n_parser.ts b/modules/@angular/compiler/src/i18n/i18n_parser.ts index 6774f54733..8eccc15623 100644 --- a/modules/@angular/compiler/src/i18n/i18n_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_parser.ts @@ -13,7 +13,7 @@ import {getHtmlTagDefinition} from '../ml_parser/html_tags'; import {InterpolationConfig} from '../ml_parser/interpolation_config'; import {ParseSourceSpan} from '../parse_util'; -import {Message as HtmlMessage, extractAstMessages} from './extractor'; +import * as extractor from './extractor_merger'; import * as i18n from './i18n_ast'; import {PlaceholderRegistry} from './serializers/placeholder'; @@ -25,7 +25,7 @@ const _expParser = new ExpressionParser(new ExpressionLexer()); export function extractI18nMessages( sourceAst: html.Node[], interpolationConfig: InterpolationConfig, implicitTags: string[], implicitAttrs: {[k: string]: string[]}): i18n.Message[] { - const extractionResult = extractAstMessages(sourceAst, implicitTags, implicitAttrs); + const extractionResult = extractor.extractAstMessages(sourceAst, implicitTags, implicitAttrs); if (extractionResult.errors.length) { return []; @@ -40,10 +40,10 @@ export function extractI18nMessages( * Returns a function converting html Messages to i18n Messages given an interpolationConfig */ export function getHtmlToI18nConverter(interpolationConfig: InterpolationConfig): - (msg: HtmlMessage) => i18n.Message { + (msg: extractor.Message) => i18n.Message { const visitor = new _I18nVisitor(_expParser, interpolationConfig); - return (msg: HtmlMessage) => visitor.toI18nMessage(msg.nodes, msg.meaning, msg.description); + return (msg: extractor.Message) => visitor.toI18nMessage(msg.nodes, msg.meaning, msg.description); } class _I18nVisitor implements html.Visitor { diff --git a/modules/@angular/compiler/src/i18n/message_bundle.ts b/modules/@angular/compiler/src/i18n/message_bundle.ts index 01c52ab0fa..bf5f7a673b 100644 --- a/modules/@angular/compiler/src/i18n/message_bundle.ts +++ b/modules/@angular/compiler/src/i18n/message_bundle.ts @@ -11,7 +11,7 @@ import {InterpolationConfig} from '../ml_parser/interpolation_config'; import {ParseError} from '../parse_util'; import * as i18n from './i18n_ast'; -import {extractI18nMessages} from './i18n_parser'; +import * as i18nParser from './i18n_parser'; import {Serializer} from './serializers/serializer'; @@ -33,18 +33,18 @@ export class MessageBundle { return htmlParserResult.errors; } - const messages = extractI18nMessages( + const messages = i18nParser.extractI18nMessages( htmlParserResult.rootNodes, interpolationConfig, this._implicitTags, this._implicitAttrs); messages.forEach((message) => { - this._messageMap[messageDigest(message.nodes, message.meaning)] = message; + this._messageMap[digestMessage(message.nodes, message.meaning)] = message; }); } write(serializer: Serializer): string { return serializer.write(this._messageMap); } } -export function messageDigest(nodes: i18n.Node[], meaning: string): string { +export function digestMessage(nodes: i18n.Node[], meaning: string): string { return strHash(serializeNodes(nodes).join('') + `[${meaning}]`); } diff --git a/modules/@angular/compiler/src/i18n/translation_bundle.ts b/modules/@angular/compiler/src/i18n/translation_bundle.ts index 4874ac9657..c8072abb10 100644 --- a/modules/@angular/compiler/src/i18n/translation_bundle.ts +++ b/modules/@angular/compiler/src/i18n/translation_bundle.ts @@ -19,7 +19,7 @@ export class TranslationBundle { static load( content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}, serializer: Serializer): TranslationBundle { - return new TranslationBundle(serializer.load(content, 'url', placeholders)); + return new TranslationBundle(serializer.load(content, url, placeholders)); } get(id: string): html.Node[] { return this._messageMap[id]; } diff --git a/modules/@angular/compiler/test/i18n/extractor_spec.ts b/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts similarity index 77% rename from modules/@angular/compiler/test/i18n/extractor_spec.ts rename to modules/@angular/compiler/test/i18n/extractor_merger_spec.ts index d99c80a043..eea2369625 100644 --- a/modules/@angular/compiler/test/i18n/extractor_spec.ts +++ b/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts @@ -6,14 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ -import {ExtractionResult, extractAstMessages} from '@angular/compiler/src/i18n/extractor'; import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; +import {ExtractionResult, Message as HtmlMessage, extractAstMessages, mergeTranslations} from '../../src/i18n/extractor_merger'; +import {getHtmlToI18nConverter} from '../../src/i18n/i18n_parser'; +import {digestMessage} from '../../src/i18n/message_bundle'; +import {TranslationBundle} from '../../src/i18n/translation_bundle'; +import * as html from '../../src/ml_parser/ast'; import {HtmlParser} from '../../src/ml_parser/html_parser'; -import {serializeNodes} from '../html_parser/ast_serializer_spec'; +import {DEFAULT_INTERPOLATION_CONFIG} from '../../src/ml_parser/interpolation_config'; +import {serializeNodes} from '../ml_parser/ast_serializer_spec'; export function main() { - describe('MessageExtractor', () => { + describe('Extractor', () => { describe('elements', () => { it('should extract from elements', () => { expect(extract('

textnested
')).toEqual([ @@ -143,20 +148,20 @@ export function main() { }); describe('attributes', () => { - it('should extract from attributes outside of translatable section', () => { + it('should extract from attributes outside of translatable sections', () => { expect(extract('
')).toEqual([ [['title="msg"'], 'm', 'd'], ]); }); - it('should extract from attributes in translatable element', () => { + it('should extract from attributes in translatable elements', () => { expect(extract('

')).toEqual([ [['

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

')) .toEqual([ [['title="msg"'], 'm', 'd'], @@ -164,7 +169,7 @@ export function main() { ]); }); - it('should extract from attributes in translatable ICU', () => { + it('should extract from attributes in translatable ICUs', () => { expect( extract( '{count, plural, =0 {

}}')) @@ -174,7 +179,7 @@ export function main() { ]); }); - it('should extract from attributes in non translatable ICU', () => { + it('should extract from attributes in non translatable ICUs', () => { expect(extract('{count, plural, =0 {

}}')) .toEqual([ [['title="msg"'], 'm', 'd'], @@ -207,24 +212,18 @@ export function main() { it('should report nested translatable elements', () => { expect(extractErrors(`

`)).toEqual([ ['Could not mark an element as translatable inside a translatable section', ''], - ['Unexpected section start', ''], - ['Unexpected section end', '

'], ]); }); it('should report translatable elements in implicit elements', () => { expect(extractErrors(`

`, ['p'])).toEqual([ ['Could not mark an element as translatable inside a translatable section', ''], - ['Unexpected section start', ''], - ['Unexpected section end', '

'], ]); }); it('should report translatable elements in translatable blocks', () => { expect(extractErrors(``)).toEqual([ ['Could not mark an element as translatable inside a translatable section', ''], - ['Unexpected section start', ''], - ['Unexpected section end', '`, ['b'])).toEqual([ ['Could not mark an element as translatable inside a translatable section', ''], - ['Unexpected section start', ''], - ['Unexpected section end', '

`; + expect(fakeTranslate(HTML)).toEqual('
before

-*foo*-

'); + }); + }); + + describe('blocks', () => { + it('should merge blocks', () => { + const HTML = `before

foo

barafter`; + expect(fakeTranslate(HTML)).toEqual('before-*

foo

bar*-after'); + }); + + it('should merge nested blocks', () => { + const HTML = + `
before

foo

barafter
`; + expect(fakeTranslate(HTML)) + .toEqual('
before-*

foo

bar*-after
'); + }); + }); + + describe('attributes', () => { + it('should merge attributes', () => { + const HTML = `

`; + expect(fakeTranslate(HTML)).toEqual('

'); + }); + + it('should merge attributes', () => { + const HTML = `
{count, plural, =0 {

}}
`; + expect(fakeTranslate(HTML)).toEqual('
{count, plural, =0 {

}}
'); + }); + }); + }); } -function getExtractionResult( - html: string, implicitTags: string[], - implicitAttrs: {[k: string]: string[]}): ExtractionResult { +function parseHtml(html: string): html.Node[] { 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')}`); } + return parseResult.rootNodes; +} - return extractAstMessages(parseResult.rootNodes, implicitTags, implicitAttrs); +function fakeTranslate( + content: string, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): string { + const htmlNodes: html.Node[] = parseHtml(content); + const htmlMsgs: HtmlMessage[] = + extractAstMessages(htmlNodes, implicitTags, implicitAttrs).messages; + + const i18nMsgMap: {[id: string]: html.Node[]} = {}; + const converter = getHtmlToI18nConverter(DEFAULT_INTERPOLATION_CONFIG); + + htmlMsgs.forEach(msg => { + const i18nMsg = converter(msg); + + i18nMsgMap[digestMessage(i18nMsg.nodes, i18nMsg.meaning)] = [ + new html.Text('-*', null), + ...msg.nodes, + new html.Text('*-', null), + ]; + }); + + const translations = new TranslationBundle(i18nMsgMap); + + const translateNodes = mergeTranslations( + htmlNodes, translations, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs); + + return serializeNodes(translateNodes).join(''); } function extract( html: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): [string[], string, string][] { - const messages = getExtractionResult(html, implicitTags, implicitAttrs).messages; + const messages = extractAstMessages(parseHtml(html), implicitTags, implicitAttrs).messages; // clang-format off // https://github.com/angular/clang-format/issues/35 @@ -326,7 +384,7 @@ function extract( function extractErrors( html: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): any[] { - const errors = getExtractionResult(html, implicitTags, implicitAttrs).errors; + const errors = extractAstMessages(parseHtml(html), implicitTags, implicitAttrs).errors; return errors.map((e): [string, string] => [e.msg, e.span.toString()]); }