Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[4.1 only] feat(compiler): support ICU messages in XLIFF #15068

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 47 additions & 31 deletions packages/compiler/src/i18n/serializers/xliff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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[] {
Expand All @@ -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;
}
Expand Down Expand Up @@ -149,30 +143,29 @@ class _WriteVisitor implements i18n.Visitor {
}

serialize(nodes: i18n.Node[]): xml.Node[] {
this._isInIcu = false;
return [].concat(...nodes.map(node => node.visit(this)));
}
}

// 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);

this._errors = xml.errors;
ml.visitAll(this, xml.rootNodes, null);

return {
mlNodesByMsgId: this._mlNodesByMsgId,
msgIdToHtml: this._msgIdToHtml,
errors: this._errors,
locale: this._locale,
};
Expand All @@ -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`);
}
Expand All @@ -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:
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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) {}

Expand Down
158 changes: 158 additions & 0 deletions packages/compiler/test/i18n/integration_common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* @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 {Component, DebugElement} from '@angular/core';
import {ComponentFixture} from '@angular/core/testing';

import {By} from '@angular/platform-browser/src/dom/debug/by';
import {stringifyElement} from '@angular/platform-browser/testing/src/browser_util';
import {expect} from '@angular/platform-browser/testing/src/matchers';

@Component({
selector: 'i18n-cmp',
template: '',
})
export class I18nComponent {
count: number;
sex: string;
sexB: string;
response: any = {getItemsList: (): any[] => []};
}

export class FrLocalization extends NgLocalization {
getPluralCategory(value: number): string {
switch (value) {
case 0:
case 1:
return 'one';
default:
return 'other';
}
}
}

export function validateHtml(
tb: ComponentFixture<I18nComponent>, cmp: I18nComponent, el: DebugElement) {
expectHtml(el, 'h1').toBe('<h1>attributs i18n sur les balises</h1>');
expectHtml(el, '#i18n-1').toBe('<div id="i18n-1"><p>imbriqué</p></div>');
expectHtml(el, '#i18n-2').toBe('<div id="i18n-2"><p>imbriqué</p></div>');
expectHtml(el, '#i18n-3').toBe('<div id="i18n-3"><p><i>avec des espaces réservés</i></p></div>');
expectHtml(el, '#i18n-3b')
.toBe(
'<div id="i18n-3b"><p><i class="preserved-on-placeholders">avec des espaces réservés</i></p></div>');
expectHtml(el, '#i18n-4').toBe('<p id="i18n-4" title="sur des balises non traductibles"></p>');
expectHtml(el, '#i18n-5').toBe('<p id="i18n-5" title="sur des balises traductibles"></p>');
expectHtml(el, '#i18n-6').toBe('<p id="i18n-6" title=""></p>');

cmp.count = 0;
tb.detectChanges();
expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('zero');
expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('zero');
cmp.count = 1;
tb.detectChanges();
expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('un');
expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('un');
expect(el.query(By.css('#i18n-17')).nativeElement).toHaveText('un');
cmp.count = 2;
tb.detectChanges();
expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('deux');
expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('deux');
expect(el.query(By.css('#i18n-17')).nativeElement).toHaveText('deux');
cmp.count = 3;
tb.detectChanges();
expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('beaucoup');
expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('beaucoup');
expect(el.query(By.css('#i18n-17')).nativeElement).toHaveText('beaucoup');

cmp.sex = 'm';
cmp.sexB = 'f';
tb.detectChanges();
expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('homme');
expect(el.query(By.css('#i18n-8b')).nativeElement).toHaveText('femme');
cmp.sex = 'f';
tb.detectChanges();
expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('femme');

cmp.count = 123;
tb.detectChanges();
expectHtml(el, '#i18n-9').toEqual('<div id="i18n-9">count = 123</div>');

cmp.sex = 'f';
tb.detectChanges();
expectHtml(el, '#i18n-10').toEqual('<div id="i18n-10">sexe = f</div>');

expectHtml(el, '#i18n-11').toEqual('<div id="i18n-11">custom name</div>');
expectHtml(el, '#i18n-12').toEqual('<h1 id="i18n-12">Balises dans les commentaires html</h1>');
expectHtml(el, '#i18n-13').toBe('<div id="i18n-13" title="dans une section traductible"></div>');
expectHtml(el, '#i18n-15').toMatch(/ca <b>devrait<\/b> marcher/);
expectHtml(el, '#i18n-16').toMatch(/avec un ID explicite/);
expectHtml(el, '#i18n-18')
.toEqual('<div id="i18n-18">FOO<a title="dans une section traductible">BAR</a></div>');
}

function expectHtml(el: DebugElement, cssSelector: string): any {
return expect(stringifyElement(el.query(By.css(cssSelector)).nativeElement));
}

export const HTML = `
<div>
<h1 i18n>i18n attribute on tags</h1>

<div id="i18n-1"><p i18n>nested</p></div>

<div id="i18n-2"><p i18n="different meaning|">nested</p></div>

<div id="i18n-3"><p i18n><i>with placeholders</i></p></div>
<div id="i18n-3b"><p i18n><i class="preserved-on-placeholders">with placeholders</i></p></div>

<div>
<p id="i18n-4" i18n-title title="on not translatable node"></p>
<p id="i18n-5" i18n i18n-title title="on translatable node"></p>
<p id="i18n-6" i18n-title title></p>
</div>

<!-- no ph below because the ICU node is the only child of the div, i.e. no text nodes -->
<div i18n id="i18n-7">{count, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>}}</div>

<div i18n id="i18n-8">
{sex, select, m {male} f {female}}
</div>
<div i18n id="i18n-8b">
{sexB, select, m {male} f {female}}
</div>

<div i18n id="i18n-9">{{ "count = " + count }}</div>
<div i18n id="i18n-10">sex = {{ sex }}</div>
<div i18n id="i18n-11">{{ "custom name" //i18n(ph="CUSTOM_NAME") }}</div>
</div>

<!-- i18n -->
<h1 id="i18n-12" >Markers in html comments</h1>
<div id="i18n-13" i18n-title title="in a translatable section"></div>
<div id="i18n-14">{count, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>}}</div>
<!-- /i18n -->

<div id="i18n-15"><ng-container i18n>it <b>should</b> work</ng-container></div>

<div id="i18n-16" i18n="@@i18n16">with an explicit ID</div>
<div id="i18n-17" i18n="@@i18n17">{count, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>}}</div>

<!-- make sure that ICU messages are not treated as text nodes -->
<div i18n="desc">{
response.getItemsList().length,
plural,
=0 {Found no results}
=1 {Found one result}
other {Found {{response.getItemsList().length}} results}
}</div>

<div i18n id="i18n-18">foo<a i18n-title title="in a translatable section">bar</a></div>

<div i18n>{{ 'test' //i18n(ph="map name") }}</div>
`;