From b8d5f87f9647cc58de5d6f562bc7adead96a10e8 Mon Sep 17 00:00:00 2001
From: Marc Laval
Date: Mon, 13 Mar 2017 11:40:44 +0100
Subject: [PATCH] feat(compiler): support ICU messages in XLIFF
Fixes #12636
Closes #15068
---
.../compiler/src/i18n/serializers/xliff.ts | 78 +++---
.../test/i18n/integration_xliff_spec.ts | 260 ++++++++++++++++++
.../test/i18n/serializers/xliff_spec.ts | 33 +++
3 files changed, 340 insertions(+), 31 deletions(-)
create mode 100644 packages/compiler/test/i18n/integration_xliff_spec.ts
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} } } }
+
+