fix(localize): ensure extracted messages are serialized in a consistent order (#40192)

The CLI integration can provide code files in a non-deterministic
order, which led to the extracted translation files having
messages in a non-consistent order between extractions.

This commit fixes this by ensuring that serialized messages
are ordered by their location.

Fixes #39262

PR Close #40192
This commit is contained in:
Pete Bacon Darwin 2020-12-18 16:55:13 +00:00 committed by Joey Perrott
parent 805b4f936b
commit 212245f197
12 changed files with 395 additions and 96 deletions

View File

@ -48,12 +48,13 @@ export class ArbTranslationSerializer implements TranslationSerializer {
private sourceLocale: string, private basePath: AbsoluteFsPath, private fs: FileSystem) {} private sourceLocale: string, private basePath: AbsoluteFsPath, private fs: FileSystem) {}
serialize(messages: ɵParsedMessage[]): string { serialize(messages: ɵParsedMessage[]): string {
const messageMap = consolidateMessages(messages, message => message.customId || message.id); const messageGroups = consolidateMessages(messages, message => getMessageId(message));
let output = `{\n "@@locale": ${JSON.stringify(this.sourceLocale)}`; let output = `{\n "@@locale": ${JSON.stringify(this.sourceLocale)}`;
for (const [id, duplicateMessages] of messageMap.entries()) { for (const duplicateMessages of messageGroups) {
const message = duplicateMessages[0]; const message = duplicateMessages[0];
const id = getMessageId(message);
output += this.serializeMessage(id, message); output += this.serializeMessage(id, message);
output += this.serializeMeta( output += this.serializeMeta(
id, message.description, duplicateMessages.filter(hasLocation).map(m => m.location)); id, message.description, duplicateMessages.filter(hasLocation).map(m => m.location));
@ -98,3 +99,7 @@ export class ArbTranslationSerializer implements TranslationSerializer {
].join('\n'); ].join('\n');
} }
} }
function getMessageId(message: ɵParsedMessage): string {
return message.customId || message.id;
}

View File

@ -7,6 +7,7 @@
*/ */
import {ɵMessageId, ɵParsedMessage, ɵSourceMessage} from '@angular/localize'; import {ɵMessageId, ɵParsedMessage, ɵSourceMessage} from '@angular/localize';
import {TranslationSerializer} from './translation_serializer'; import {TranslationSerializer} from './translation_serializer';
import {consolidateMessages} from './utils';
interface SimpleJsonTranslationFile { interface SimpleJsonTranslationFile {
@ -24,7 +25,7 @@ export class SimpleJsonTranslationSerializer implements TranslationSerializer {
constructor(private sourceLocale: string) {} constructor(private sourceLocale: string) {}
serialize(messages: ɵParsedMessage[]): string { serialize(messages: ɵParsedMessage[]): string {
const fileObj: SimpleJsonTranslationFile = {locale: this.sourceLocale, translations: {}}; const fileObj: SimpleJsonTranslationFile = {locale: this.sourceLocale, translations: {}};
for (const message of messages) { for (const [message] of consolidateMessages(messages, message => message.id)) {
fileObj.translations[message.id] = message.text; fileObj.translations[message.id] = message.text;
} }
return JSON.stringify(fileObj, null, 2); return JSON.stringify(fileObj, null, 2);

View File

@ -8,24 +8,40 @@
import {ɵMessageId, ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; import {ɵMessageId, ɵParsedMessage, ɵSourceLocation} from '@angular/localize';
/** /**
* Consolidate an array of messages into a map from message id to an array of messages with that id. * Consolidate messages into groups that have the same id.
*
* Messages with the same id are grouped together so that we can quickly deduplicate messages when
* rendering into translation files.
*
* To ensure that messages are rendered in a deterministic order:
* - the messages within a group are sorted by location (file path, then start position)
* - the groups are sorted by the location of the first message in the group
* *
* @param messages the messages to consolidate. * @param messages the messages to consolidate.
* @param getMessageId a function that will compute the message id of a message. * @param getMessageId a function that will compute the message id of a message.
* @returns an array of message groups, where each group is an array of messages that have the same
* id.
*/ */
export function consolidateMessages( export function consolidateMessages(
messages: ɵParsedMessage[], messages: ɵParsedMessage[],
getMessageId: (message: ɵParsedMessage) => string): Map<ɵMessageId, ɵParsedMessage[]> { getMessageId: (message: ɵParsedMessage) => string): ɵParsedMessage[][] {
const consolidateMessages = new Map<ɵMessageId, ɵParsedMessage[]>(); const messageGroups = new Map<ɵMessageId, ɵParsedMessage[]>();
for (const message of messages) { for (const message of messages) {
const id = getMessageId(message); const id = getMessageId(message);
if (!consolidateMessages.has(id)) { if (!messageGroups.has(id)) {
consolidateMessages.set(id, [message]); messageGroups.set(id, [message]);
} else { } else {
consolidateMessages.get(id)!.push(message); messageGroups.get(id)!.push(message);
} }
} }
return consolidateMessages;
// Here we sort the messages within a group into location order.
// Note that `Array.sort()` will mutate the array in-place.
for (const messages of messageGroups.values()) {
messages.sort(compareLocations);
}
// Now we sort the groups by location of the first message in the group.
return Array.from(messageGroups.values()).sort((a1, a2) => compareLocations(a1[0], a2[0]));
} }
/** /**
@ -35,3 +51,26 @@ export function hasLocation(message: ɵParsedMessage): message is ɵParsedMessag
{location: ɵSourceLocation} { {location: ɵSourceLocation} {
return message.location !== undefined; return message.location !== undefined;
} }
export function compareLocations(
{location: location1}: ɵParsedMessage, {location: location2}: ɵParsedMessage): number {
if (location1 === location2) {
return 0;
}
if (location1 === undefined) {
return -1;
}
if (location2 === undefined) {
return 1;
}
if (location1.file !== location2.file) {
return location1.file < location2.file ? -1 : 1;
}
if (location1.start.line !== location2.start.line) {
return location1.start.line < location2.start.line ? -1 : 1;
}
if (location1.start.column !== location2.start.column) {
return location1.start.column < location2.start.column ? -1 : 1;
}
return 0;
}

View File

@ -34,7 +34,7 @@ export class Xliff1TranslationSerializer implements TranslationSerializer {
} }
serialize(messages: ɵParsedMessage[]): string { serialize(messages: ɵParsedMessage[]): string {
const messageMap = consolidateMessages(messages, message => this.getMessageId(message)); const messageGroups = consolidateMessages(messages, message => this.getMessageId(message));
const xml = new XmlFile(); const xml = new XmlFile();
xml.startTag('xliff', {'version': '1.2', 'xmlns': 'urn:oasis:names:tc:xliff:document:1.2'}); xml.startTag('xliff', {'version': '1.2', 'xmlns': 'urn:oasis:names:tc:xliff:document:1.2'});
// NOTE: the `original` property is set to the legacy `ng2.template` value for backward // NOTE: the `original` property is set to the legacy `ng2.template` value for backward
@ -51,8 +51,9 @@ export class Xliff1TranslationSerializer implements TranslationSerializer {
...this.formatOptions, ...this.formatOptions,
}); });
xml.startTag('body'); xml.startTag('body');
for (const [id, duplicateMessages] of messageMap.entries()) { for (const duplicateMessages of messageGroups) {
const message = duplicateMessages[0]; const message = duplicateMessages[0];
const id = this.getMessageId(message);
xml.startTag('trans-unit', {id, datatype: 'html'}); xml.startTag('trans-unit', {id, datatype: 'html'});
xml.startTag('source', {}, {preserveWhitespace: true}); xml.startTag('source', {}, {preserveWhitespace: true});

View File

@ -34,7 +34,7 @@ export class Xliff2TranslationSerializer implements TranslationSerializer {
} }
serialize(messages: ɵParsedMessage[]): string { serialize(messages: ɵParsedMessage[]): string {
const messageMap = consolidateMessages(messages, message => this.getMessageId(message)); const messageGroups = consolidateMessages(messages, message => this.getMessageId(message));
const xml = new XmlFile(); const xml = new XmlFile();
xml.startTag('xliff', { xml.startTag('xliff', {
'version': '2.0', 'version': '2.0',
@ -49,8 +49,9 @@ export class Xliff2TranslationSerializer implements TranslationSerializer {
// messages that come from a particular original file, and the translation file parsers may // messages that come from a particular original file, and the translation file parsers may
// not // not
xml.startTag('file', {'id': 'ngi18n', 'original': 'ng.template', ...this.formatOptions}); xml.startTag('file', {'id': 'ngi18n', 'original': 'ng.template', ...this.formatOptions});
for (const [id, duplicateMessages] of messageMap.entries()) { for (const duplicateMessages of messageGroups) {
const message = duplicateMessages[0]; const message = duplicateMessages[0];
const id = this.getMessageId(message);
xml.startTag('unit', {id}); xml.startTag('unit', {id});
const messagesWithLocations = duplicateMessages.filter(hasLocation); const messagesWithLocations = duplicateMessages.filter(hasLocation);

View File

@ -10,6 +10,7 @@ import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize';
import {extractIcuPlaceholders} from './icu_parsing'; import {extractIcuPlaceholders} from './icu_parsing';
import {TranslationSerializer} from './translation_serializer'; import {TranslationSerializer} from './translation_serializer';
import {consolidateMessages} from './utils';
import {XmlFile} from './xml_file'; import {XmlFile} from './xml_file';
/** /**
@ -26,7 +27,7 @@ export class XmbTranslationSerializer implements TranslationSerializer {
private fs: FileSystem = getFileSystem()) {} private fs: FileSystem = getFileSystem()) {}
serialize(messages: ɵParsedMessage[]): string { serialize(messages: ɵParsedMessage[]): string {
const ids = new Set<string>(); const messageGroups = consolidateMessages(messages, message => this.getMessageId(message));
const xml = new XmlFile(); const xml = new XmlFile();
xml.rawText( xml.rawText(
`<!DOCTYPE messagebundle [\n` + `<!DOCTYPE messagebundle [\n` +
@ -51,13 +52,9 @@ export class XmbTranslationSerializer implements TranslationSerializer {
`<!ELEMENT ex (#PCDATA)>\n` + `<!ELEMENT ex (#PCDATA)>\n` +
`]>\n`); `]>\n`);
xml.startTag('messagebundle'); xml.startTag('messagebundle');
for (const message of messages) { for (const duplicateMessages of messageGroups) {
const message = duplicateMessages[0];
const id = this.getMessageId(message); const id = this.getMessageId(message);
if (ids.has(id)) {
// Do not render the same message more than once
continue;
}
ids.add(id);
xml.startTag( xml.startTag(
'msg', {id, desc: message.description, meaning: message.meaning}, 'msg', {id, desc: message.description, meaning: message.meaning},
{preserveWhitespace: true}); {preserveWhitespace: true});

View File

@ -464,7 +464,7 @@ runInEachFileSystem(() => {
` "locale": "en-GB",`, ` "locale": "en-GB",`,
` "translations": {`, ` "translations": {`,
` "message-1": "message {$PH} contents",`, ` "message-1": "message {$PH} contents",`,
` "message-2": "different message contents"`, ` "message-2": "message contents"`,
` }`, ` }`,
`}`, `}`,
].join('\n')); ].join('\n'));
@ -489,7 +489,7 @@ runInEachFileSystem(() => {
` "locale": "en-GB",`, ` "locale": "en-GB",`,
` "translations": {`, ` "translations": {`,
` "message-1": "message {$PH} contents",`, ` "message-1": "message {$PH} contents",`,
` "message-2": "different message contents"`, ` "message-2": "message contents"`,
` }`, ` }`,
`}`, `}`,
].join('\n')); ].join('\n'));

View File

@ -7,11 +7,11 @@
*/ */
import {absoluteFrom, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; import {absoluteFrom, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; import {ɵParsedMessage} from '@angular/localize';
import {ArbTranslationSerializer} from '../../../src/extract/translation_files/arb_translation_serializer'; import {ArbTranslationSerializer} from '../../../src/extract/translation_files/arb_translation_serializer';
import {mockMessage} from './mock_message'; import {location, mockMessage} from './mock_message';
runInEachFileSystem(() => { runInEachFileSystem(() => {
let fs: FileSystem; let fs: FileSystem;
@ -25,30 +25,18 @@ runInEachFileSystem(() => {
const messages: ɵParsedMessage[] = [ const messages: ɵParsedMessage[] = [
mockMessage('12345', ['a', 'b', 'c'], ['PH', 'PH_1'], { mockMessage('12345', ['a', 'b', 'c'], ['PH', 'PH_1'], {
meaning: 'some meaning', meaning: 'some meaning',
location: { location: location('/project/file.ts', 5, 10, 5, 12),
file: absoluteFrom('/project/file.ts'),
start: {line: 5, column: 10},
end: {line: 5, column: 12}
},
}), }),
mockMessage('54321', ['a', 'b', 'c'], ['PH', 'PH_1'], { mockMessage('54321', ['a', 'b', 'c'], ['PH', 'PH_1'], {
customId: 'someId', customId: 'someId',
}), }),
mockMessage('67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { mockMessage('67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], {
description: 'some description', description: 'some description',
location: { location: location('/project/file.ts', 5, 10, 5, 12)
file: absoluteFrom('/project/file.ts'),
start: {line: 5, column: 10},
end: {line: 5, column: 12}
},
}), }),
mockMessage('67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { mockMessage('67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], {
description: 'some description', description: 'some description',
location: { location: location('/project/other.ts', 2, 10, 3, 12)
file: absoluteFrom('/project/other.ts'),
start: {line: 2, column: 10},
end: {line: 3, column: 12}
},
}), }),
mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}),
mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}),
@ -72,6 +60,16 @@ runInEachFileSystem(() => {
expect(output.split('\n')).toEqual([ expect(output.split('\n')).toEqual([
'{', '{',
' "@@locale": "xx",', ' "@@locale": "xx",',
' "someId": "a{$PH}b{$PH_1}c",',
' "13579": "{$START_BOLD_TEXT}b{$CLOSE_BOLD_TEXT}",',
' "24680": "a",',
' "@24680": {',
' "description": "and description"',
' },',
' "80808": "multi\\nlines",',
' "90000": "<escape{$double-quotes-\\"}me>",',
' "100000": "pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU",',
' "100001": "{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}",',
' "12345": "a{$PH}b{$PH_1}c",', ' "12345": "a{$PH}b{$PH_1}c",',
' "@12345": {', ' "@12345": {',
' "x-locations": [', ' "x-locations": [',
@ -82,7 +80,6 @@ runInEachFileSystem(() => {
' }', ' }',
' ]', ' ]',
' },', ' },',
' "someId": "a{$PH}b{$PH_1}c",',
' "67890": "a{$START_TAG_SPAN}{$CLOSE_TAG_SPAN}c",', ' "67890": "a{$START_TAG_SPAN}{$CLOSE_TAG_SPAN}c",',
' "@67890": {', ' "@67890": {',
' "description": "some description",', ' "description": "some description",',
@ -98,16 +95,89 @@ runInEachFileSystem(() => {
' "end": { "line": "3", "column": "12" }', ' "end": { "line": "3", "column": "12" }',
' }', ' }',
' ]', ' ]',
' }',
'}',
]);
});
it('should consistently order serialized messages by location', () => {
const messages: ɵParsedMessage[] = [
mockMessage('1', ['message-1'], [], {location: location('/root/c-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/c-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 8, 0, 10, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 8, 0, 10, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/a-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/a-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 20, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 20, 5, 12)}),
];
const serializer = new ArbTranslationSerializer('xx', fs.resolve('/root'), fs);
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'{',
' "@@locale": "xx",',
' "1": "message-1",',
' "@1": {',
' "x-locations": [',
' {',
' "file": "a-1.ts",',
' "start": { "line": "5", "column": "10" },',
' "end": { "line": "5", "column": "12" }',
' },',
' {',
' "file": "b-1.ts",',
' "start": { "line": "5", "column": "10" },',
' "end": { "line": "5", "column": "12" }',
' },',
' {',
' "file": "b-1.ts",',
' "start": { "line": "5", "column": "20" },',
' "end": { "line": "5", "column": "12" }',
' },',
' {',
' "file": "b-1.ts",',
' "start": { "line": "8", "column": "0" },',
' "end": { "line": "10", "column": "12" }',
' },',
' {',
' "file": "c-1.ts",',
' "start": { "line": "5", "column": "10" },',
' "end": { "line": "5", "column": "12" }',
' }',
' ]',
' },', ' },',
' "13579": "{$START_BOLD_TEXT}b{$CLOSE_BOLD_TEXT}",', ' "2": "message-1",',
' "24680": "a",', ' "@2": {',
' "@24680": {', ' "x-locations": [',
' "description": "and description"', ' {',
' },', ' "file": "a-2.ts",',
' "80808": "multi\\nlines",', ' "start": { "line": "5", "column": "10" },',
' "90000": "<escape{$double-quotes-\\"}me>",', ' "end": { "line": "5", "column": "12" }',
' "100000": "pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU",', ' },',
' "100001": "{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}"', ' {',
' "file": "b-2.ts",',
' "start": { "line": "5", "column": "10" },',
' "end": { "line": "5", "column": "12" }',
' },',
' {',
' "file": "b-2.ts",',
' "start": { "line": "5", "column": "20" },',
' "end": { "line": "5", "column": "12" }',
' },',
' {',
' "file": "b-2.ts",',
' "start": { "line": "8", "column": "0" },',
' "end": { "line": "10", "column": "12" }',
' },',
' {',
' "file": "c-2.ts",',
' "start": { "line": "5", "column": "10" },',
' "end": { "line": "5", "column": "12" }',
' }',
' ]',
' }',
'}', '}',
]); ]);
}); });

View File

@ -5,6 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {ɵParsedMessage} from '@angular/localize'; import {ɵParsedMessage} from '@angular/localize';
import {MessageId, SourceLocation} from '@angular/localize/src/utils'; import {MessageId, SourceLocation} from '@angular/localize/src/utils';
@ -38,3 +39,13 @@ export function mockMessage(
placeholderNames, placeholderNames,
}; };
} }
export function location(
file: string, startLine: number, startCol: number, endLine: number,
endCol: number): SourceLocation {
return {
file: absoluteFrom(file),
start: {line: startLine, column: startCol},
end: {line: endLine, column: endCol}
};
}

View File

@ -12,7 +12,7 @@ import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize';
import {FormatOptions} from '../../../src/extract/translation_files/format_options'; import {FormatOptions} from '../../../src/extract/translation_files/format_options';
import {Xliff1TranslationSerializer} from '../../../src/extract/translation_files/xliff1_translation_serializer'; import {Xliff1TranslationSerializer} from '../../../src/extract/translation_files/xliff1_translation_serializer';
import {mockMessage} from './mock_message'; import {location, mockMessage} from './mock_message';
import {toAttributes} from './utils'; import {toAttributes} from './utils';
runInEachFileSystem(() => { runInEachFileSystem(() => {
@ -85,16 +85,6 @@ runInEachFileSystem(() => {
` <file source-language="xx" datatype="plaintext" original="ng2.template"${ ` <file source-language="xx" datatype="plaintext" original="ng2.template"${
toAttributes(options)}>`, toAttributes(options)}>`,
` <body>`, ` <body>`,
` <trans-unit id="${
useLegacyIds ? '1234567890ABCDEF1234567890ABCDEF12345678' :
'12345'}" datatype="html">`,
` <source>a<x id="PH"/>b<x id="PH_1"/>c</source>`,
` <context-group purpose="location">`,
` <context context-type="sourcefile">file.ts</context>`,
` <context context-type="linenumber">6</context>`,
` </context-group>`,
` <note priority="1" from="meaning">some meaning</note>`,
` </trans-unit>`,
` <trans-unit id="someId" datatype="html">`, ` <trans-unit id="someId" datatype="html">`,
` <source>a<x id="PH" equiv-text="placeholder + 1"/>b<x id="PH_1"/>c</source>`, ` <source>a<x id="PH" equiv-text="placeholder + 1"/>b<x id="PH_1"/>c</source>`,
` </trans-unit>`, ` </trans-unit>`,
@ -102,13 +92,6 @@ runInEachFileSystem(() => {
` <source>a<x id="START_TAG_SPAN" ctype="x-span"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/>c</source>`, ` <source>a<x id="START_TAG_SPAN" ctype="x-span"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/>c</source>`,
` <note priority="1" from="description">some description</note>`, ` <note priority="1" from="description">some description</note>`,
` </trans-unit>`, ` </trans-unit>`,
` <trans-unit id="38705" datatype="html">`,
` <source>a<x id="START_TAG_SPAN" ctype="x-span"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/>c</source>`,
` <context-group purpose="location">`,
` <context context-type="sourcefile">file.ts</context>`,
` <context context-type="linenumber">3,4</context>`,
` </context-group>`,
` </trans-unit>`,
` <trans-unit id="13579" datatype="html">`, ` <trans-unit id="13579" datatype="html">`,
` <source><x id="START_BOLD_TEXT" ctype="x-b"/>b<x id="CLOSE_BOLD_TEXT" ctype="x-b"/></source>`, ` <source><x id="START_BOLD_TEXT" ctype="x-b"/>b<x id="CLOSE_BOLD_TEXT" ctype="x-b"/></source>`,
` </trans-unit>`, ` </trans-unit>`,
@ -130,6 +113,23 @@ runInEachFileSystem(() => {
` <trans-unit id="100001" datatype="html">`, ` <trans-unit id="100001" datatype="html">`,
` <source>{VAR_PLURAL, plural, one {<x id="START_BOLD_TEXT" ctype="x-b"/>something bold<x id="CLOSE_BOLD_TEXT" ctype="x-b"/>} other {pre <x id="START_TAG_SPAN" ctype="x-span"/>middle<x id="CLOSE_TAG_SPAN" ctype="x-span"/> post}}</source>`, ` <source>{VAR_PLURAL, plural, one {<x id="START_BOLD_TEXT" ctype="x-b"/>something bold<x id="CLOSE_BOLD_TEXT" ctype="x-b"/>} other {pre <x id="START_TAG_SPAN" ctype="x-span"/>middle<x id="CLOSE_TAG_SPAN" ctype="x-span"/> post}}</source>`,
` </trans-unit>`, ` </trans-unit>`,
` <trans-unit id="38705" datatype="html">`,
` <source>a<x id="START_TAG_SPAN" ctype="x-span"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/>c</source>`,
` <context-group purpose="location">`,
` <context context-type="sourcefile">file.ts</context>`,
` <context context-type="linenumber">3,4</context>`,
` </context-group>`,
` </trans-unit>`,
` <trans-unit id="${
useLegacyIds ? '1234567890ABCDEF1234567890ABCDEF12345678' :
'12345'}" datatype="html">`,
` <source>a<x id="PH"/>b<x id="PH_1"/>c</source>`,
` <context-group purpose="location">`,
` <context context-type="sourcefile">file.ts</context>`,
` <context context-type="linenumber">6</context>`,
` </context-group>`,
` <note priority="1" from="meaning">some meaning</note>`,
` </trans-unit>`,
` </body>`, ` </body>`,
` </file>`, ` </file>`,
`</xliff>\n`, `</xliff>\n`,
@ -291,5 +291,80 @@ runInEachFileSystem(() => {
}); });
}); });
}); });
describe('renderFile()', () => {
it('should consistently order serialized messages by location', () => {
const messages: ɵParsedMessage[] = [
mockMessage('1', ['message-1'], [], {location: location('/root/c-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/c-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 8, 0, 10, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 8, 0, 10, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/a-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/a-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 20, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 20, 5, 12)}),
];
const serializer = new Xliff1TranslationSerializer('xx', absoluteFrom('/root'), false, {});
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'<?xml version="1.0" encoding="UTF-8" ?>',
'<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">',
' <file source-language="xx" datatype="plaintext" original="ng2.template">',
' <body>',
' <trans-unit id="1" datatype="html">',
' <source>message-1</source>',
' <context-group purpose="location">',
' <context context-type="sourcefile">a-1.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">b-1.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">b-1.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">b-1.ts</context>',
' <context context-type="linenumber">9,11</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">c-1.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' </trans-unit>',
' <trans-unit id="2" datatype="html">',
' <source>message-1</source>',
' <context-group purpose="location">',
' <context context-type="sourcefile">a-2.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">b-2.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">b-2.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">b-2.ts</context>',
' <context context-type="linenumber">9,11</context>',
' </context-group>',
' <context-group purpose="location">',
' <context context-type="sourcefile">c-2.ts</context>',
' <context context-type="linenumber">6</context>',
' </context-group>',
' </trans-unit>',
' </body>',
' </file>',
'</xliff>',
'',
]);
});
});
}); });
}); });

View File

@ -12,7 +12,7 @@ import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize';
import {FormatOptions} from '../../../src/extract/translation_files/format_options'; import {FormatOptions} from '../../../src/extract/translation_files/format_options';
import {Xliff2TranslationSerializer} from '../../../src/extract/translation_files/xliff2_translation_serializer'; import {Xliff2TranslationSerializer} from '../../../src/extract/translation_files/xliff2_translation_serializer';
import {mockMessage} from './mock_message'; import {location, mockMessage} from './mock_message';
import {toAttributes} from './utils'; import {toAttributes} from './utils';
runInEachFileSystem(() => { runInEachFileSystem(() => {
@ -88,37 +88,11 @@ runInEachFileSystem(() => {
`<?xml version="1.0" encoding="UTF-8" ?>`, `<?xml version="1.0" encoding="UTF-8" ?>`,
`<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="xx">`, `<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="xx">`,
` <file id="ngi18n" original="ng.template"${toAttributes(options)}>`, ` <file id="ngi18n" original="ng.template"${toAttributes(options)}>`,
` <unit id="${useLegacyIds ? '615790887472569365' : '12345'}">`,
` <notes>`,
` <note category="location">file.ts:6</note>`,
` <note category="meaning">some meaning</note>`,
` </notes>`,
` <segment>`,
` <source>a<ph id="0" equiv="PH"/>b<ph id="1" equiv="PH_1"/>c</source>`,
` </segment>`,
` </unit>`,
` <unit id="someId">`, ` <unit id="someId">`,
` <segment>`, ` <segment>`,
` <source>a<ph id="0" equiv="PH" disp="placeholder + 1"/>b<ph id="1" equiv="PH_1"/>c</source>`, ` <source>a<ph id="0" equiv="PH" disp="placeholder + 1"/>b<ph id="1" equiv="PH_1"/>c</source>`,
` </segment>`, ` </segment>`,
` </unit>`, ` </unit>`,
` <unit id="67890">`,
` <notes>`,
` <note category="location">file.ts:3,4</note>`,
` <note category="description">some description</note>`,
` </notes>`,
` <segment>`,
` <source>a<pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other"></pc>c</source>`,
` </segment>`,
` </unit>`,
` <unit id="location-only">`,
` <notes>`,
` <note category="location">file.ts:3,4</note>`,
` </notes>`,
` <segment>`,
` <source>a<pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other"></pc>c</source>`,
` </segment>`,
` </unit>`,
` <unit id="13579">`, ` <unit id="13579">`,
` <segment>`, ` <segment>`,
` <source><pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt">b</pc></source>`, ` <source><pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt">b</pc></source>`,
@ -154,6 +128,32 @@ runInEachFileSystem(() => {
` <source>{VAR_PLURAL, plural, one {<pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt">something bold</pc>} other {pre <pc id="1" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other">middle</pc> post}}</source>`, ` <source>{VAR_PLURAL, plural, one {<pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt">something bold</pc>} other {pre <pc id="1" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other">middle</pc> post}}</source>`,
` </segment>`, ` </segment>`,
` </unit>`, ` </unit>`,
` <unit id="67890">`,
` <notes>`,
` <note category="location">file.ts:3,4</note>`,
` <note category="description">some description</note>`,
` </notes>`,
` <segment>`,
` <source>a<pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other"></pc>c</source>`,
` </segment>`,
` </unit>`,
` <unit id="location-only">`,
` <notes>`,
` <note category="location">file.ts:3,4</note>`,
` </notes>`,
` <segment>`,
` <source>a<pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other"></pc>c</source>`,
` </segment>`,
` </unit>`,
` <unit id="${useLegacyIds ? '615790887472569365' : '12345'}">`,
` <notes>`,
` <note category="location">file.ts:6</note>`,
` <note category="meaning">some meaning</note>`,
` </notes>`,
` <segment>`,
` <source>a<ph id="0" equiv="PH"/>b<ph id="1" equiv="PH_1"/>c</source>`,
` </segment>`,
` </unit>`,
` </file>`, ` </file>`,
`</xliff>\n`, `</xliff>\n`,
].join('\n')); ].join('\n'));
@ -304,5 +304,56 @@ runInEachFileSystem(() => {
}); });
}); });
}); });
describe('renderFile()', () => {
it('should consistently order serialized messages by location', () => {
const messages: ɵParsedMessage[] = [
mockMessage('1', ['message-1'], [], {location: location('/root/c-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/c-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 8, 0, 10, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 8, 0, 10, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/a-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/a-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 20, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 20, 5, 12)}),
];
const serializer = new Xliff2TranslationSerializer('xx', absoluteFrom('/root'), false, {});
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'<?xml version="1.0" encoding="UTF-8" ?>',
'<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="xx">',
' <file id="ngi18n" original="ng.template">',
' <unit id="1">',
' <notes>',
' <note category="location">a-1.ts:6</note>',
' <note category="location">b-1.ts:6</note>',
' <note category="location">b-1.ts:6</note>',
' <note category="location">b-1.ts:9,11</note>',
' <note category="location">c-1.ts:6</note>',
' </notes>',
' <segment>',
' <source>message-1</source>',
' </segment>',
' </unit>',
' <unit id="2">',
' <notes>',
' <note category="location">a-2.ts:6</note>',
' <note category="location">b-2.ts:6</note>',
' <note category="location">b-2.ts:6</note>',
' <note category="location">b-2.ts:9,11</note>',
' <note category="location">c-2.ts:6</note>',
' </notes>',
' <segment>',
' <source>message-1</source>',
' </segment>',
' </unit>',
' </file>',
'</xliff>',
'',
]);
});
});
}); });
}); });

View File

@ -11,7 +11,7 @@ import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize';
import {XmbTranslationSerializer} from '../../../src/extract/translation_files/xmb_translation_serializer'; import {XmbTranslationSerializer} from '../../../src/extract/translation_files/xmb_translation_serializer';
import {mockMessage} from './mock_message'; import {location, mockMessage} from './mock_message';
runInEachFileSystem(() => { runInEachFileSystem(() => {
let fs: FileSystem; let fs: FileSystem;
@ -86,4 +86,52 @@ runInEachFileSystem(() => {
}); });
}); });
}); });
describe('renderFile()', () => {
it('should consistently order serialized messages by location', () => {
const messages: ɵParsedMessage[] = [
mockMessage('1', ['message-1'], [], {location: location('/root/c-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/c-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 8, 0, 10, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 8, 0, 10, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/a-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/a-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 10, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 10, 5, 12)}),
mockMessage('1', ['message-1'], [], {location: location('/root/b-1.ts', 5, 20, 5, 12)}),
mockMessage('2', ['message-1'], [], {location: location('/root/b-2.ts', 5, 20, 5, 12)}),
];
const serializer = new XmbTranslationSerializer(absoluteFrom('/root'), false);
const output = serializer.serialize(messages);
expect(output.split('\n')).toEqual([
'<?xml version="1.0" encoding="UTF-8" ?>',
'<!DOCTYPE messagebundle [',
'<!ELEMENT messagebundle (msg)*>',
'<!ATTLIST messagebundle class CDATA #IMPLIED>',
'',
'<!ELEMENT msg (#PCDATA|ph|source)*>',
'<!ATTLIST msg id CDATA #IMPLIED>',
'<!ATTLIST msg seq CDATA #IMPLIED>',
'<!ATTLIST msg name CDATA #IMPLIED>',
'<!ATTLIST msg desc CDATA #IMPLIED>',
'<!ATTLIST msg meaning CDATA #IMPLIED>',
'<!ATTLIST msg obsolete (obsolete) #IMPLIED>',
'<!ATTLIST msg xml:space (default|preserve) "default">',
'<!ATTLIST msg is_hidden CDATA #IMPLIED>',
'',
'<!ELEMENT source (#PCDATA)>',
'',
'<!ELEMENT ph (#PCDATA|ex)*>',
'<!ATTLIST ph name CDATA #REQUIRED>',
'',
'<!ELEMENT ex (#PCDATA)>',
']>',
'<messagebundle>',
' <msg id="1"><source>a-1.ts:5</source>message-1</msg>',
' <msg id="2"><source>a-2.ts:5</source>message-1</msg>',
'</messagebundle>',
'',
]);
});
});
}); });