diff --git a/packages/compiler/src/i18n/serializers/xliff.ts b/packages/compiler/src/i18n/serializers/xliff.ts index dd9549409c..a8c9b7646a 100644 --- a/packages/compiler/src/i18n/serializers/xliff.ts +++ b/packages/compiler/src/i18n/serializers/xliff.ts @@ -77,13 +77,14 @@ export class Xliff extends Serializer { {locale: string, i18nNodesByMsgId: {[msgId: string]: i18n.Node[]}} { // xliff to xml nodes const xliffParser = new XliffParser(); - const {locale, mlNodesByMsgId, errors} = xliffParser.parse(content, url); + const {locale, msgIdToHtml, errors} = xliffParser.parse(content, url); // xml nodes to i18n nodes const i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {}; const converter = new XmlToI18n(); - Object.keys(mlNodesByMsgId).forEach(msgId => { - const {i18nNodes, errors: e} = converter.convert(mlNodesByMsgId[msgId]); + + Object.keys(msgIdToHtml).forEach(msgId => { + const {i18nNodes, errors: e} = converter.convert(msgIdToHtml[msgId], url); errors.push(...e); i18nNodesByMsgId[msgId] = i18nNodes; }); @@ -99,8 +100,6 @@ export class Xliff extends Serializer { } class _WriteVisitor implements i18n.Visitor { - private _isInIcu: boolean; - visitText(text: i18n.Text, context?: any): xml.Node[] { return [new xml.Text(text.value)]; } visitContainer(container: i18n.Container, context?: any): xml.Node[] { @@ -110,18 +109,13 @@ class _WriteVisitor implements i18n.Visitor { } visitIcu(icu: i18n.Icu, context?: any): xml.Node[] { - if (this._isInIcu) { - // nested ICU is not supported - throw new Error('xliff does not support nested ICU messages'); - } - this._isInIcu = true; + const nodes = [new xml.Text(`{${icu.expressionPlaceholder}, ${icu.type}, `)]; - // TODO(vicb): support ICU messages - // https://lists.oasis-open.org/archives/xliff/201201/msg00028.html - // http://docs.oasis-open.org/xliff/v1.2/xliff-profile-po/xliff-profile-po-1.2-cd02.html - const nodes: xml.Node[] = []; + Object.keys(icu.cases).forEach((c: string) => { + nodes.push(new xml.Text(`${c} {`), ...icu.cases[c].visit(this), new xml.Text(`} `)); + }); - this._isInIcu = false; + nodes.push(new xml.Text(`}`)); return nodes; } @@ -149,7 +143,6 @@ class _WriteVisitor implements i18n.Visitor { } serialize(nodes: i18n.Node[]): xml.Node[] { - this._isInIcu = false; return [].concat(...nodes.map(node => node.visit(this))); } } @@ -157,14 +150,14 @@ class _WriteVisitor implements i18n.Visitor { // TODO(vicb): add error management (structure) // Extract messages as xml nodes from the xliff file class XliffParser implements ml.Visitor { - private _unitMlNodes: ml.Node[]; + private _unitMlString: string; private _errors: I18nError[]; - private _mlNodesByMsgId: {[msgId: string]: ml.Node[]}; + private _msgIdToHtml: {[msgId: string]: string}; private _locale: string|null = null; parse(xliff: string, url: string) { - this._unitMlNodes = []; - this._mlNodesByMsgId = {}; + this._unitMlString = null; + this._msgIdToHtml = {}; const xml = new XmlParser().parse(xliff, url, false); @@ -172,7 +165,7 @@ class XliffParser implements ml.Visitor { ml.visitAll(this, xml.rootNodes, null); return { - mlNodesByMsgId: this._mlNodesByMsgId, + msgIdToHtml: this._msgIdToHtml, errors: this._errors, locale: this._locale, }; @@ -181,18 +174,18 @@ class XliffParser implements ml.Visitor { visitElement(element: ml.Element, context: any): any { switch (element.name) { case _UNIT_TAG: - this._unitMlNodes = null; + this._unitMlString = null; const idAttr = element.attrs.find((attr) => attr.name === 'id'); if (!idAttr) { this._addError(element, `<${_UNIT_TAG}> misses the "id" attribute`); } else { const id = idAttr.value; - if (this._mlNodesByMsgId.hasOwnProperty(id)) { + if (this._msgIdToHtml.hasOwnProperty(id)) { this._addError(element, `Duplicated translations for msg ${id}`); } else { ml.visitAll(this, element.children, null); - if (this._unitMlNodes) { - this._mlNodesByMsgId[id] = this._unitMlNodes; + if (typeof this._unitMlString === 'string') { + this._msgIdToHtml[id] = this._unitMlString; } else { this._addError(element, `Message ${id} misses a translation`); } @@ -205,7 +198,11 @@ class XliffParser implements ml.Visitor { break; case _TARGET_TAG: - this._unitMlNodes = element.children; + const innerTextStart = element.startSourceSpan.end.offset; + const innerTextEnd = element.endSourceSpan.start.offset; + const content = element.startSourceSpan.start.file.content; + const innerText = content.slice(innerTextStart, innerTextEnd); + this._unitMlString = innerText; break; case _FILE_TAG: @@ -242,10 +239,16 @@ class XliffParser implements ml.Visitor { class XmlToI18n implements ml.Visitor { private _errors: I18nError[]; - convert(nodes: ml.Node[]) { - this._errors = []; + convert(message: string, url: string) { + const xmlIcu = new XmlParser().parse(message, url, true); + this._errors = xmlIcu.errors; + + const i18nNodes = this._errors.length > 0 || xmlIcu.rootNodes.length == 0 ? + [] : + ml.visitAll(this, xmlIcu.rootNodes); + return { - i18nNodes: ml.visitAll(this, nodes), + i18nNodes: i18nNodes, errors: this._errors, }; } @@ -265,9 +268,22 @@ class XmlToI18n implements ml.Visitor { } } - visitExpansion(icu: ml.Expansion, context: any) {} + visitExpansion(icu: ml.Expansion, context: any) { + const caseMap: {[value: string]: i18n.Node} = {}; - visitExpansionCase(icuCase: ml.ExpansionCase, context: any): any {} + ml.visitAll(this, icu.cases).forEach((c: any) => { + caseMap[c.value] = new i18n.Container(c.nodes, icu.sourceSpan); + }); + + return new i18n.Icu(icu.switchValue, icu.type, caseMap, icu.sourceSpan); + } + + visitExpansionCase(icuCase: ml.ExpansionCase, context: any): any { + return { + value: icuCase.value, + nodes: ml.visitAll(this, icuCase.expression), + }; + } visitComment(comment: ml.Comment, context: any) {} diff --git a/packages/compiler/test/i18n/integration_xliff_spec.ts b/packages/compiler/test/i18n/integration_xliff_spec.ts new file mode 100644 index 0000000000..d082c283dd --- /dev/null +++ b/packages/compiler/test/i18n/integration_xliff_spec.ts @@ -0,0 +1,260 @@ +/** + * @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 {NgLocalization} from '@angular/common'; +import {ResourceLoader} from '@angular/compiler'; +import {MessageBundle} from '@angular/compiler/src/i18n/message_bundle'; +import {Xliff} from '@angular/compiler/src/i18n/serializers/xliff'; +import {HtmlParser} from '@angular/compiler/src/ml_parser/html_parser'; +import {DEFAULT_INTERPOLATION_CONFIG} from '@angular/compiler/src/ml_parser/interpolation_config'; +import {DebugElement, TRANSLATIONS, TRANSLATIONS_FORMAT} from '@angular/core'; +import {ComponentFixture, TestBed, async} from '@angular/core/testing'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; + +import {SpyResourceLoader} from '../spies'; + +import {FrLocalization, HTML, I18nComponent, validateHtml} from './integration_common'; + +export function main() { + describe('i18n XLIFF integration spec', () => { + + beforeEach(async(() => { + TestBed.configureCompiler({ + providers: [ + {provide: ResourceLoader, useClass: SpyResourceLoader}, + {provide: NgLocalization, useClass: FrLocalization}, + {provide: TRANSLATIONS, useValue: XLIFF_TOMERGE}, + {provide: TRANSLATIONS_FORMAT, useValue: 'xliff'}, + ] + }); + + TestBed.configureTestingModule({declarations: [I18nComponent]}); + })); + + it('should extract from templates', () => { + const catalog = new MessageBundle(new HtmlParser, [], {}); + const serializer = new Xliff(); + catalog.updateFromTemplate(HTML, '', DEFAULT_INTERPOLATION_CONFIG); + + expect(catalog.write(serializer)).toContain(XLIFF_EXTRACTED); + }); + + it('should translate templates', () => { + const tb: ComponentFixture = + TestBed.overrideTemplate(I18nComponent, HTML).createComponent(I18nComponent); + const cmp: I18nComponent = tb.componentInstance; + const el: DebugElement = tb.debugElement; + + validateHtml(tb, cmp, el); + }); + }); +} + +const XLIFF_TOMERGE = ` + + i18n attribute on tags + attributs i18n sur les balises + + + nested + imbriqué + + + nested + imbriqué + different meaning + + + with placeholders + avec des espaces réservés + + + on not translatable node + sur des balises non traductibles + + + on translatable node + sur des balises traductibles + + + {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {many} } + {VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup} } + + + + + + + + + {VAR_SELECT, select, m {male} f {female} } + {VAR_SELECT, select, m {homme} f {femme} } + + + + + + + + + {VAR_SELECT, select, m {male} f {female} } + {VAR_SELECT, select, m {homme} f {femme} } + + + + + + + sex = + sexe = + + + + + + + in a translatable section + dans une section traductible + + + + Markers in html comments + + + + + Balises dans les commentaires html + + + + + + it should work + ca devrait marcher + + + with an explicit ID + avec un ID explicite + + + {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {many} } + {VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup} } + + + {VAR_PLURAL, plural, =0 {Found no results} =1 {Found one result} other {Found results} } + {VAR_PLURAL, plural, =0 {Pas de réponse} =1 {une réponse} other {Found réponse} } + desc + + + foobar + FOOBAR + + + + + `; + +const XLIFF_EXTRACTED = ` + + i18n attribute on tags + + + + nested + + + + nested + + different meaning + + + with placeholders + + + + on not translatable node + + + + on translatable node + + + + {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {many} } + + + + + + + + + + {VAR_SELECT, select, m {male} f {female} } + + + + + + + + + + {VAR_SELECT, select, m {male} f {female} } + + + + + + + + sex = + + + + + + + + in a translatable section + + + + + Markers in html comments + + + + + + + it should work + + + + with an explicit ID + + + + {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {many} } + + + + {VAR_PLURAL, plural, =0 {Found no results} =1 {Found one result} other {Found results} } + + desc + + + foobar + + + + + + `; diff --git a/packages/compiler/test/i18n/serializers/xliff_spec.ts b/packages/compiler/test/i18n/serializers/xliff_spec.ts index 18603a07fb..f16dbc57ad 100644 --- a/packages/compiler/test/i18n/serializers/xliff_spec.ts +++ b/packages/compiler/test/i18n/serializers/xliff_spec.ts @@ -17,10 +17,13 @@ import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation const HTML = `

not translatable

translatable element with placeholders {{ interpolation}}

+{ count, plural, =0 {

test

}}

foo

foo

foo


+

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

deeply nested

}} }}

+

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

deeply nested

}} }}

`; const WRITE_XLIFF = ` @@ -35,6 +38,10 @@ const WRITE_XLIFF = ` translatable element with placeholders + + {VAR_PLURAL, plural, =0 {test} } + + foo @@ -56,6 +63,14 @@ const WRITE_XLIFF = ` ph names + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested} } } } + + + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested} } } } + + @@ -73,6 +88,10 @@ const LOAD_XLIFF = ` translatable element with placeholders footnemele elbatalsnart sredlohecalp htiw + + {VAR_PLURAL, plural, =0 {test} } + {VAR_PLURAL, plural, =0 {TEST} } + foo oof @@ -99,6 +118,14 @@ const LOAD_XLIFF = ` ph names + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested} } } } + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {profondément imbriqué} } } } + + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested} } } } + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {profondément imbriqué} } } } + @@ -136,12 +163,18 @@ export function main(): void { '983775b9a51ce14b036be72d4cfd65d68d64e231': 'etubirtta elbatalsnart', 'ec1d033f2436133c14ab038286c4f5df4697484a': ' footnemele elbatalsnart sredlohecalp htiw', + 'e2ccf3d131b15f54aa1fcf1314b1ca77c14bfcc2': + '{VAR_PLURAL, plural, =0 {[, TEST, ]}}', 'db3e0a6a5a96481f60aec61d98c3eecddef5ac23': 'oof', 'i': 'toto', 'bar': 'tata', 'd7fa2d59aaedcaa5309f13028c59af8c85b8c49d': '', 'empty target': '', + 'baz': + '{VAR_PLURAL, plural, =0 {[{VAR_SELECT, select, other {[, profondément imbriqué, ]}}, ]}}', + '0e16a673a5a7a135c9f7b957ec2c5c6f6ee6e2c4': + '{VAR_PLURAL, plural, =0 {[{VAR_SELECT, select, other {[, profondément imbriqué, ]}}, ]}}' }); });