fix(core): fix placeholders handling in i18n.

Prior to this commit, translations were built in the serializers. This
could not work as a single translation can be used for different source
messages having different placeholder content.

Serializers do not try to replace the placeholders any more.
Placeholders are replaced by the translation bundle and the source
message is given as parameter so that the content of the placeholders is
taken into account.

Also XMB ids are now independent of the expression which is replaced by
a placeholder in the extracted file.
fixes #12512
This commit is contained in:
Victor Berchet
2016-11-02 17:40:15 -07:00
parent ed5e98d0df
commit 76e4911e8b
25 changed files with 624 additions and 440 deletions

View File

@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/
import {beforeEach, describe, expect, it} from '@angular/core/testing/testing_internal';
import {PlaceholderRegistry} from '../../../src/i18n/serializers/placeholder';
export function main(): void {

View File

@ -6,12 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Xliff} from '@angular/compiler/src/i18n/serializers/xliff';
import {beforeEach, describe, expect, it} from '@angular/core/testing/testing_internal';
import {escapeRegExp} from '@angular/core/src/facade/lang';
import {serializeNodes} from '../../../src/i18n/digest';
import {MessageBundle} from '../../../src/i18n/message_bundle';
import {Xliff} from '../../../src/i18n/serializers/xliff';
import {HtmlParser} from '../../../src/ml_parser/html_parser';
import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config';
import {serializeNodes} from '../../ml_parser/ast_serializer_spec';
const HTML = `
<p i18n-title title="translatable attribute">not translatable</p>
@ -77,8 +78,7 @@ const LOAD_XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
`;
export function main(): void {
let serializer: Xliff;
let htmlParser: HtmlParser;
const serializer = new Xliff();
function toXliff(html: string): string {
const catalog = new MessageBundle(new HtmlParser, [], {});
@ -86,37 +86,89 @@ export function main(): void {
return catalog.write(serializer);
}
function loadAsText(template: string, xliff: string): {[id: string]: string} {
const messageBundle = new MessageBundle(htmlParser, [], {});
messageBundle.updateFromTemplate(template, 'url', DEFAULT_INTERPOLATION_CONFIG);
function loadAsMap(xliff: string): {[id: string]: string} {
const i18nNodesByMsgId = serializer.load(xliff, 'url');
const msgMap: {[id: string]: string} = {};
Object.keys(i18nNodesByMsgId)
.forEach(id => msgMap[id] = serializeNodes(i18nNodesByMsgId[id]).join(''));
const asAst = serializer.load(xliff, 'url', messageBundle);
const asText: {[id: string]: string} = {};
Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); });
return asText;
return msgMap;
}
describe('XLIFF serializer', () => {
beforeEach(() => {
htmlParser = new HtmlParser();
serializer = new Xliff(htmlParser, DEFAULT_INTERPOLATION_CONFIG);
});
describe('write', () => {
it('should write a valid xliff file', () => { expect(toXliff(HTML)).toEqual(WRITE_XLIFF); });
});
describe('load', () => {
it('should load XLIFF files', () => {
expect(loadAsText(HTML, LOAD_XLIFF)).toEqual({
expect(loadAsMap(LOAD_XLIFF)).toEqual({
'983775b9a51ce14b036be72d4cfd65d68d64e231': 'etubirtta elbatalsnart',
'ec1d033f2436133c14ab038286c4f5df4697484a':
'{{ interpolation}} footnemele elbatalsnart <b>sredlohecalp htiw</b>',
'<ph name="INTERPOLATION"/> footnemele elbatalsnart <ph name="START_BOLD_TEXT"/>sredlohecalp htiw<ph name="CLOSE_BOLD_TEXT"/>',
'db3e0a6a5a96481f60aec61d98c3eecddef5ac23': 'oof',
'd7fa2d59aaedcaa5309f13028c59af8c85b8c49d': '<div></div><img/><br/>',
'd7fa2d59aaedcaa5309f13028c59af8c85b8c49d':
'<ph name="START_TAG_DIV"/><ph name="CLOSE_TAG_DIV"/><ph name="TAG_IMG"/><ph name="LINE_BREAK"/>',
});
});
describe('errors', () => {
it('should throw when a placeholder has no id attribute', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
<trans-unit datatype="html">
<source/>
<target/>
</trans-unit>
</body>
</file>
</xliff>`;
expect(() => {
loadAsMap(XLIFF);
}).toThrowError(/<trans-unit> misses the "id" attribute/);
});
it('should throw on unknown message tags', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="deadbeef" datatype="html">
<source/>
<target><b>msg should contain only ph tags</b></target>
</trans-unit>
</body>
</file>
</xliff>`;
expect(() => { loadAsMap(XLIFF); })
.toThrowError(
new RegExp(escapeRegExp(`[ERROR ->]<b>msg should contain only ph tags</b>`)));
});
it('should throw when a placeholder has no name attribute', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="deadbeef">
<source/>
<target/>
</trans-unit>
<trans-unit id="deadbeef">
<source/>
<target/>
</trans-unit>
</body>
</file>
</xliff>`;
expect(() => {
loadAsMap(XLIFF);
}).toThrowError(/Duplicated translations for msg deadbeef/);
});
});
});

View File

@ -44,9 +44,9 @@ export function main(): void {
]>
<messagebundle>
<msg id="2348600990161399314">translatable element <ph name="START_BOLD_TEXT"><ex>&lt;b&gt;</ex></ph>with placeholders<ph name="CLOSE_BOLD_TEXT"><ex>&lt;/b&gt;</ex></ph> <ph name="INTERPOLATION"/></msg>
<msg id="8332678508949127113">{ count, plural, =0 {<ph name="START_PARAGRAPH"><ex>&lt;p&gt;</ex></ph>test<ph name="CLOSE_PARAGRAPH"><ex>&lt;/p&gt;</ex></ph>} }</msg>
<msg id="5525949440406338075">{VAR_PLURAL, plural, =0 {<ph name="START_PARAGRAPH"><ex>&lt;p&gt;</ex></ph>test<ph name="CLOSE_PARAGRAPH"><ex>&lt;/p&gt;</ex></ph>} }</msg>
<msg id="130772889486467622" desc="d" meaning="m">foo</msg>
<msg id="5848862331224404557">{ count, plural, =0 {{ sex, gender, other {<ph name="START_PARAGRAPH"><ex>&lt;p&gt;</ex></ph>deeply nested<ph name="CLOSE_PARAGRAPH"><ex>&lt;/p&gt;</ex></ph>} } } }</msg>
<msg id="9095788995532341072">{VAR_PLURAL, plural, =0 {{VAR_GENDER, gender, other {<ph name="START_PARAGRAPH"><ex>&lt;p&gt;</ex></ph>deeply nested<ph name="CLOSE_PARAGRAPH"><ex>&lt;/p&gt;</ex></ph>} } } }</msg>
</messagebundle>
`;
@ -55,7 +55,7 @@ export function main(): void {
it('should throw when trying to load an xmb file', () => {
expect(() => {
const serializer = new Xmb();
serializer.load(XMB, 'url', null);
serializer.load(XMB, 'url');
}).toThrowError(/Unsupported/);
});
});

View File

@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/
import {describe, expect, it} from '@angular/core/testing/testing_internal';
import * as xml from '../../../src/i18n/serializers/xml_helper';
export function main(): void {

View File

@ -8,37 +8,24 @@
import {escapeRegExp} from '@angular/core/src/facade/lang';
import {MessageBundle} from '../../../src/i18n/message_bundle';
import {serializeNodes} from '../../../src/i18n/digest';
import {Xtb} from '../../../src/i18n/serializers/xtb';
import {HtmlParser} from '../../../src/ml_parser/html_parser';
import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config';
import {serializeNodes} from '../../ml_parser/ast_serializer_spec';
export function main(): void {
describe('XTB serializer', () => {
let serializer: Xtb;
let htmlParser: HtmlParser;
const serializer = new Xtb();
function loadAsText(template: string, xtb: string): {[id: string]: string} {
const messageBundle = new MessageBundle(htmlParser, [], {});
messageBundle.updateFromTemplate(template, 'url', DEFAULT_INTERPOLATION_CONFIG);
const asAst = serializer.load(xtb, 'url', messageBundle);
const asText: {[id: string]: string} = {};
Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); });
return asText;
function loadAsMap(xtb: string): {[id: string]: string} {
const i18nNodesByMsgId = serializer.load(xtb, 'url');
const msgMap: {[id: string]: string} = {};
Object.keys(i18nNodesByMsgId).forEach(id => {
msgMap[id] = serializeNodes(i18nNodesByMsgId[id]).join('');
});
return msgMap;
}
beforeEach(() => {
htmlParser = new HtmlParser();
serializer = new Xtb(htmlParser, DEFAULT_INTERPOLATION_CONFIG);
});
describe('load', () => {
it('should load XTB files with a doctype', () => {
const HTML = `<div i18n>bar</div>`;
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE translationbundle [<!ELEMENT translationbundle (translation)*>
<!ATTLIST translationbundle lang CDATA #REQUIRED>
@ -53,67 +40,61 @@ export function main(): void {
<translation id="8841459487341224498">rab</translation>
</translationbundle>`;
expect(loadAsText(HTML, XTB)).toEqual({'8841459487341224498': 'rab'});
expect(loadAsMap(XTB)).toEqual({'8841459487341224498': 'rab'});
});
it('should load XTB files without placeholders', () => {
const HTML = `<div i18n>bar</div>`;
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle>
<translation id="8841459487341224498">rab</translation>
</translationbundle>`;
expect(loadAsText(HTML, XTB)).toEqual({'8841459487341224498': 'rab'});
expect(loadAsMap(XTB)).toEqual({'8841459487341224498': 'rab'});
});
it('should load XTB files with placeholders', () => {
const HTML = `<div i18n><p>bar</p></div>`;
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle>
<translation id="8877975308926375834"><ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/></translation>
</translationbundle>`;
expect(loadAsText(HTML, XTB)).toEqual({'8877975308926375834': '<p>rab</p>'});
expect(loadAsMap(XTB)).toEqual({
'8877975308926375834': '<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>'
});
});
it('should replace ICU placeholders with their translations', () => {
const HTML = `<div i18n>-{ count, plural, =0 {<p>bar</p>}}-</div>`;
const XTB = `<?xml version="1.0" encoding="UTF-8" ?>
<translationbundle>
<translation id="1430521728694081603">*<ph name="ICU"/>*</translation>
<translation id="4004755025589356097">{ count, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
<translation id="7717087045075616176">*<ph name="ICU"/>*</translation>
<translation id="5115002811911870583">{VAR_PLURAL, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
</translationbundle>`;
expect(loadAsText(HTML, XTB)).toEqual({
'1430521728694081603': `*{ count, plural, =1 {<p>rab</p>}}*`,
'4004755025589356097': `{ count, plural, =1 {<p>rab</p>}}`,
expect(loadAsMap(XTB)).toEqual({
'7717087045075616176': `*<ph name="ICU"/>*`,
'5115002811911870583':
`{VAR_PLURAL, plural, =1 {[<ph name="START_PARAGRAPH"/>, rab, <ph name="CLOSE_PARAGRAPH"/>]}}`,
});
});
it('should load complex XTB files', () => {
const HTML = `
<div i18n>foo <b>bar</b> {{ a + b }}</div>
<div i18n>{ count, plural, =0 {<p>bar</p>}}</div>
<div i18n="m|d">foo</div>
<div i18n>{ count, plural, =0 {{ sex, select, other {<p>bar</p>}} }}</div>`;
const XTB = `<?xml version="1.0" encoding="UTF-8" ?>
<translationbundle>
<translation id="8281795707202401639"><ph name="INTERPOLATION"/><ph name="START_BOLD_TEXT"/>rab<ph name="CLOSE_BOLD_TEXT"/> oof</translation>
<translation id="4004755025589356097">{ count, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
<translation id="5115002811911870583">{VAR_PLURAL, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
<translation id="130772889486467622">oof</translation>
<translation id="4244993204427636474">{ count, plural, =1 {{ sex, gender, male {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}} }}</translation>
<translation id="4739316421648347533">{VAR_PLURAL, plural, =1 {{VAR_GENDER, gender, male {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}} }}</translation>
</translationbundle>`;
expect(loadAsText(HTML, XTB)).toEqual({
'8281795707202401639': `{{ a + b }}<b>rab</b> oof`,
'4004755025589356097': `{ count, plural, =1 {<p>rab</p>}}`,
expect(loadAsMap(XTB)).toEqual({
'8281795707202401639':
`<ph name="INTERPOLATION"/><ph name="START_BOLD_TEXT"/>rab<ph name="CLOSE_BOLD_TEXT"/> oof`,
'5115002811911870583':
`{VAR_PLURAL, plural, =1 {[<ph name="START_PARAGRAPH"/>, rab, <ph name="CLOSE_PARAGRAPH"/>]}}`,
'130772889486467622': `oof`,
'4244993204427636474': `{ count, plural, =1 {{ sex, gender, male {<p>rab</p>}} }}`,
'4739316421648347533':
`{VAR_PLURAL, plural, =1 {[{VAR_GENDER, gender, male {[<ph name="START_PARAGRAPH"/>, rab, <ph name="CLOSE_PARAGRAPH"/>]}}, ]}}`,
});
});
});
@ -124,7 +105,7 @@ export function main(): void {
'<translationbundle><translationbundle></translationbundle></translationbundle>';
expect(() => {
loadAsText('', XTB);
loadAsMap(XTB);
}).toThrowError(/<translationbundle> elements can not be nested/);
});
@ -133,58 +114,49 @@ export function main(): void {
<translation></translation>
</translationbundle>`;
expect(() => {
loadAsText('', XTB);
}).toThrowError(/<translation> misses the "id" attribute/);
expect(() => { loadAsMap(XTB); }).toThrowError(/<translation> misses the "id" attribute/);
});
it('should throw when a placeholder has no name attribute', () => {
const HTML = '<div i18n>give me a message</div>';
const XTB = `<translationbundle>
<translation id="1186013544048295927"><ph /></translation>
</translationbundle>`;
expect(() => { loadAsText(HTML, XTB); }).toThrowError(/<ph> misses the "name" attribute/);
expect(() => { loadAsMap(XTB); }).toThrowError(/<ph> misses the "name" attribute/);
});
it('should throw when a placeholder is not present in the source message', () => {
const HTML = `<div i18n>bar</div>`;
it('should throw on unknown xtb tags', () => {
const XTB = `<what></what>`;
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle>
<translation id="8841459487341224498"><ph name="UNKNOWN"/></translation>
expect(() => {
loadAsMap(XTB);
}).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]<what></what>")`)));
});
it('should throw on unknown message tags', () => {
const XTB = `<translationbundle>
<translation id="1186013544048295927"><b>msg should contain only ph tags</b></translation>
</translationbundle>`;
expect(() => { loadAsMap(XTB); })
.toThrowError(
new RegExp(escapeRegExp(`[ERROR ->]<b>msg should contain only ph tags</b>`)));
});
it('should throw on duplicate message id', () => {
const XTB = `<translationbundle>
<translation id="1186013544048295927">msg1</translation>
<translation id="1186013544048295927">msg2</translation>
</translationbundle>`;
expect(() => {
loadAsText(HTML, XTB);
}).toThrowError(/The placeholder "UNKNOWN" does not exists in the source message/);
loadAsMap(XTB);
}).toThrowError(/Duplicated translations for msg 1186013544048295927/);
});
});
it('should throw when the translation results in invalid html', () => {
const HTML = `<div i18n><p>bar</p></div>`;
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle>
<translation id="8877975308926375834">rab<ph name="CLOSE_PARAGRAPH"/></translation>
</translationbundle>`;
expect(() => {
loadAsText(HTML, XTB);
}).toThrowError(/xtb parse errors:\nUnexpected closing tag "p"/);
it('should throw when trying to save an xtb file',
() => { expect(() => { serializer.write([]); }).toThrowError(/Unsupported/); });
});
it('should throw on unknown tags', () => {
const XTB = `<what></what>`;
expect(() => {
loadAsText('', XTB);
}).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]<what></what>")`)));
});
it('should throw when trying to save an xtb file',
() => { expect(() => { serializer.write([]); }).toThrowError(/Unsupported/); });
});
}