diff --git a/packages/localize/src/tools/src/extract/main.ts b/packages/localize/src/tools/src/extract/main.ts index c764abcb0bfae..27d50f6212ae8 100644 --- a/packages/localize/src/tools/src/extract/main.ts +++ b/packages/localize/src/tools/src/extract/main.ts @@ -21,6 +21,7 @@ import {SimpleJsonTranslationSerializer} from './translation_files/json_translat import {Xliff1TranslationSerializer} from './translation_files/xliff1_translation_serializer'; import {Xliff2TranslationSerializer} from './translation_files/xliff2_translation_serializer'; import {XmbTranslationSerializer} from './translation_files/xmb_translation_serializer'; +import {FormatOptions, parseFormatOptions} from './translation_files/format_options'; if (require.main === module) { const args = process.argv.slice(2); @@ -54,6 +55,13 @@ if (require.main === module) { describe: 'The format of the translation file.', type: 'string', }) + .option('formatOptions', { + describe: + 'Additional options to pass to the translation file serializer, in the form of JSON formatted key-value string pairs:\n' + + 'For example: `--formatOptions {"xml:space":"preserve"}.\n' + + 'The meaning of the options is specific to the format being serialized.', + type: 'string' + }) .option('o', { alias: 'outputPath', required: true, @@ -97,6 +105,7 @@ if (require.main === module) { const logLevel = options.loglevel as (keyof typeof LogLevel) | undefined; const logger = new ConsoleLogger(logLevel ? LogLevel[logLevel] : LogLevel.warn); const duplicateMessageHandling = options.d as DiagnosticHandlingStrategy; + const formatOptions = parseFormatOptions(options.formatOptions); extractTranslations({ @@ -109,6 +118,7 @@ if (require.main === module) { useSourceMaps: options.useSourceMaps, useLegacyIds: options.useLegacyIds, duplicateMessageHandling, + formatOptions, }); } @@ -152,6 +162,10 @@ export interface ExtractTranslationsOptions { * How to handle messages with the same id but not the same text. */ duplicateMessageHandling: DiagnosticHandlingStrategy; + /** + * A collection of formatting options to pass to the translation file serializer. + */ + formatOptions?: FormatOptions; } export function extractTranslations({ @@ -164,6 +178,7 @@ export function extractTranslations({ useSourceMaps, useLegacyIds, duplicateMessageHandling, + formatOptions = {}, }: ExtractTranslationsOptions) { const fs = getFileSystem(); const basePath = fs.resolve(rootPath); @@ -180,7 +195,8 @@ export function extractTranslations({ } const outputPath = fs.resolve(rootPath, output); - const serializer = getSerializer(format, sourceLocale, fs.dirname(outputPath), useLegacyIds); + const serializer = + getSerializer(format, sourceLocale, fs.dirname(outputPath), useLegacyIds, formatOptions); const translationFile = serializer.serialize(messages); fs.ensureDir(fs.dirname(outputPath)); fs.writeFile(outputPath, translationFile); @@ -191,17 +207,17 @@ export function extractTranslations({ } export function getSerializer( - format: string, sourceLocale: string, rootPath: AbsoluteFsPath, - useLegacyIds: boolean): TranslationSerializer { + format: string, sourceLocale: string, rootPath: AbsoluteFsPath, useLegacyIds: boolean, + formatOptions: FormatOptions): TranslationSerializer { switch (format) { case 'xlf': case 'xlif': case 'xliff': - return new Xliff1TranslationSerializer(sourceLocale, rootPath, useLegacyIds); + return new Xliff1TranslationSerializer(sourceLocale, rootPath, useLegacyIds, formatOptions); case 'xlf2': case 'xlif2': case 'xliff2': - return new Xliff2TranslationSerializer(sourceLocale, rootPath, useLegacyIds); + return new Xliff2TranslationSerializer(sourceLocale, rootPath, useLegacyIds, formatOptions); case 'xmb': return new XmbTranslationSerializer(rootPath, useLegacyIds); case 'json': diff --git a/packages/localize/src/tools/src/extract/translation_files/format_options.ts b/packages/localize/src/tools/src/extract/translation_files/format_options.ts new file mode 100644 index 0000000000000..43113678bb2da --- /dev/null +++ b/packages/localize/src/tools/src/extract/translation_files/format_options.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +export type FormatOptions = Record; +export type ValidOption = [key: string, values: string[]]; +export type ValidOptions = ValidOption[]; + +/** + * Check that the given `options` are allowed based on the given `validOptions`. + * @param name The name of the serializer that is receiving the options. + * @param validOptions An array of valid options and their allowed values. + * @param options The options to be validated. + */ +export function validateOptions(name: string, validOptions: ValidOptions, options: FormatOptions) { + const validOptionsMap = new Map(validOptions); + for (const option in options) { + if (!validOptionsMap.has(option)) { + throw new Error( + `Invalid format option for ${name}: "${option}".\n` + + `Allowed options are ${JSON.stringify(Array.from(validOptionsMap.keys()))}.`); + } + const validOptionValues = validOptionsMap.get(option)!; + const optionValue = options[option]; + if (!validOptionValues.includes(optionValue)) { + throw new Error( + `Invalid format option value for ${name}: "${option}".\n` + + `Allowed option values are ${JSON.stringify(validOptionValues)} but received "${ + optionValue}".`); + } + } +} + +/** + * Parse the given `optionString` into a collection of `FormatOptions`. + * @param optionString The string to parse. + */ +export function parseFormatOptions(optionString: string = '{}'): FormatOptions { + return JSON.parse(optionString); +} \ No newline at end of file diff --git a/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts b/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts index c44f8f9d21315..f56a4addf214e 100644 --- a/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts +++ b/packages/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer.ts @@ -8,6 +8,7 @@ import {AbsoluteFsPath, relative} from '@angular/compiler-cli/src/ngtsc/file_system'; import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; +import {FormatOptions, validateOptions} from './format_options'; import {extractIcuPlaceholders} from './icu_parsing'; import {TranslationSerializer} from './translation_serializer'; import {XmlFile} from './xml_file'; @@ -25,8 +26,10 @@ const LEGACY_XLIFF_MESSAGE_LENGTH = 40; */ export class Xliff1TranslationSerializer implements TranslationSerializer { constructor( - private sourceLocale: string, private basePath: AbsoluteFsPath, - private useLegacyIds: boolean) {} + private sourceLocale: string, private basePath: AbsoluteFsPath, private useLegacyIds: boolean, + private formatOptions: FormatOptions) { + validateOptions('Xliff1TranslationSerializer', [['xml:space', ['preserve']]], formatOptions); + } serialize(messages: ɵParsedMessage[]): string { const ids = new Set(); @@ -43,6 +46,7 @@ export class Xliff1TranslationSerializer implements TranslationSerializer { 'source-language': this.sourceLocale, 'datatype': 'plaintext', 'original': 'ng2.template', + ...this.formatOptions, }); xml.startTag('body'); for (const message of messages) { diff --git a/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts b/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts index 6dafa6a5d5dc6..395e4c1bbea2e 100644 --- a/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts +++ b/packages/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer.ts @@ -8,6 +8,7 @@ import {AbsoluteFsPath, relative} from '@angular/compiler-cli/src/ngtsc/file_system'; import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; +import {FormatOptions, validateOptions} from './format_options'; import {extractIcuPlaceholders} from './icu_parsing'; import {TranslationSerializer} from './translation_serializer'; import {XmlFile} from './xml_file'; @@ -25,8 +26,10 @@ const MAX_LEGACY_XLIFF_2_MESSAGE_LENGTH = 20; export class Xliff2TranslationSerializer implements TranslationSerializer { private currentPlaceholderId = 0; constructor( - private sourceLocale: string, private basePath: AbsoluteFsPath, - private useLegacyIds: boolean) {} + private sourceLocale: string, private basePath: AbsoluteFsPath, private useLegacyIds: boolean, + private formatOptions: FormatOptions) { + validateOptions('Xliff1TranslationSerializer', [['xml:space', ['preserve']]], formatOptions); + } serialize(messages: ɵParsedMessage[]): string { const ids = new Set(); @@ -41,8 +44,9 @@ export class Xliff2TranslationSerializer implements TranslationSerializer { // We could compute the file from the `message.location` property, but there could // be multiple values for this in the collection of `messages`. In that case we would probably // need to change the serializer to output a new `` element for each collection of - // messages that come from a particular original file, and the translation file parsers may not - xml.startTag('file', {'id': 'ngi18n', 'original': 'ng.template'}); + // messages that come from a particular original file, and the translation file parsers may + // not + xml.startTag('file', {'id': 'ngi18n', 'original': 'ng.template', ...this.formatOptions}); for (const message of messages) { const id = this.getMessageId(message); if (ids.has(id)) { diff --git a/packages/localize/src/tools/test/BUILD.bazel b/packages/localize/src/tools/test/BUILD.bazel index c441be3b2ecb9..705826ea14bc8 100644 --- a/packages/localize/src/tools/test/BUILD.bazel +++ b/packages/localize/src/tools/test/BUILD.bazel @@ -6,6 +6,7 @@ ts_library( srcs = glob( ["**/*.ts"], ), + visibility = ["//packages/localize/src/tools/test:__subpackages__"], deps = [ "//packages:types", "//packages/compiler", diff --git a/packages/localize/src/tools/test/extract/integration/BUILD.bazel b/packages/localize/src/tools/test/extract/integration/BUILD.bazel index bad294d09dacd..37d9fdc252412 100644 --- a/packages/localize/src/tools/test/extract/integration/BUILD.bazel +++ b/packages/localize/src/tools/test/extract/integration/BUILD.bazel @@ -14,6 +14,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/logging/testing", "//packages/compiler-cli/test/helpers", "//packages/localize/src/tools", + "//packages/localize/src/tools/test:test_lib", ], ) diff --git a/packages/localize/src/tools/test/extract/integration/main_spec.ts b/packages/localize/src/tools/test/extract/integration/main_spec.ts index 07f89aa5c4540..d93bb690208f4 100644 --- a/packages/localize/src/tools/test/extract/integration/main_spec.ts +++ b/packages/localize/src/tools/test/extract/integration/main_spec.ts @@ -11,6 +11,8 @@ import {MockLogger} from '@angular/compiler-cli/src/ngtsc/logging/testing'; import {loadTestDirectory} from '@angular/compiler-cli/test/helpers'; import {extractTranslations} from '../../../src/extract/main'; +import {FormatOptions} from '../../../src/extract/translation_files/format_options'; +import {toAttributes} from '../translation_files/utils'; runInEachFileSystem(() => { let fs: FileSystem; @@ -134,145 +136,152 @@ runInEachFileSystem(() => { ].join('\n')); }); - it('should extract translations from source code, and write as XLIFF 1.2 format', () => { - extractTranslations({ - rootPath, - sourceLocale: 'en-CA', - sourceFilePaths: [sourceFilePath], - format: 'xliff', - outputPath, - logger, - useSourceMaps: false, - useLegacyIds, - duplicateMessageHandling: 'ignore', - }); - expect(fs.readFile(outputPath)).toEqual([ - ``, - ``, - ` `, - ` `, - ` `, - ` Hello, !`, - ` `, - ` test_files/test.js`, - ` 2`, - ` `, - ` `, - ` `, - ` tryme`, - ` `, - ` test_files/test.js`, - ` 3`, - ` `, - ` `, - ` `, - ` Custom id message`, - ` `, - ` test_files/test.js`, - ` 4`, - ` `, - ` `, - ` `, - ` Legacy id message`, - ` `, - ` test_files/test.js`, - ` 6`, - ` `, - ` `, - ` `, - ` Custom and legacy message`, - ` `, - ` test_files/test.js`, - ` 8`, - ` `, - ` `, - ` `, - ` pre` + - `inner-prebold` + - `inner-postpost`, - ` `, - ` test_files/test.js`, - ` 9,10`, - ` `, - ` `, - ` `, - ` `, - `\n`, - ].join('\n')); - }); + for (const formatOptions of [{}, {'xml:space': 'preserve'}] as FormatOptions[]) { + it(`should extract translations from source code, and write as XLIFF 1.2 format${ + formatOptions['xml:space'] ? '[with xml:space attribute]' : ''}`, + () => { + extractTranslations({ + rootPath, + sourceLocale: 'en-CA', + sourceFilePaths: [sourceFilePath], + format: 'xliff', + outputPath, + logger, + useSourceMaps: false, + useLegacyIds, + duplicateMessageHandling: 'ignore', + formatOptions, + }); + expect(fs.readFile(outputPath)).toEqual([ + ``, + ``, + ` `, + ` `, + ` `, + ` Hello, !`, + ` `, + ` test_files/test.js`, + ` 2`, + ` `, + ` `, + ` `, + ` tryme`, + ` `, + ` test_files/test.js`, + ` 3`, + ` `, + ` `, + ` `, + ` Custom id message`, + ` `, + ` test_files/test.js`, + ` 4`, + ` `, + ` `, + ` `, + ` Legacy id message`, + ` `, + ` test_files/test.js`, + ` 6`, + ` `, + ` `, + ` `, + ` Custom and legacy message`, + ` `, + ` test_files/test.js`, + ` 8`, + ` `, + ` `, + ` `, + ` pre` + + `inner-prebold` + + `inner-postpost`, + ` `, + ` test_files/test.js`, + ` 9,10`, + ` `, + ` `, + ` `, + ` `, + `\n`, + ].join('\n')); + }); - it('should extract translations from source code, and write as XLIFF 2 format', () => { - extractTranslations({ - rootPath, - sourceLocale: 'en-AU', - sourceFilePaths: [sourceFilePath], - format: 'xliff2', - outputPath, - logger, - useSourceMaps: false, - useLegacyIds, - duplicateMessageHandling: 'ignore', + it('should extract translations from source code, and write as XLIFF 2 format', () => { + extractTranslations({ + rootPath, + sourceLocale: 'en-AU', + sourceFilePaths: [sourceFilePath], + format: 'xliff2', + outputPath, + logger, + useSourceMaps: false, + useLegacyIds, + duplicateMessageHandling: 'ignore', + formatOptions, + }); + expect(fs.readFile(outputPath)).toEqual([ + ``, + ``, + ` `, + ` `, + ` `, + ` test_files/test.js:2`, + ` `, + ` `, + ` Hello, !`, + ` `, + ` `, + ` `, + ` `, + ` test_files/test.js:3`, + ` `, + ` `, + ` tryme`, + ` `, + ` `, + ` `, + ` `, + ` test_files/test.js:4`, + ` `, + ` `, + ` Custom id message`, + ` `, + ` `, + ` `, + ` `, + ` test_files/test.js:6`, + ` `, + ` `, + ` Legacy id message`, + ` `, + ` `, + ` `, + ` `, + ` test_files/test.js:8`, + ` `, + ` `, + ` Custom and legacy message`, + ` `, + ` `, + ` `, + ` `, + ` test_files/test.js:9,10`, + ` `, + ` `, + ` pre` + + `inner-prebold` + + `inner-postpost`, + ` `, + ` `, + ` `, + `\n`, + ].join('\n')); }); - expect(fs.readFile(outputPath)).toEqual([ - ``, - ``, - ` `, - ` `, - ` `, - ` test_files/test.js:2`, - ` `, - ` `, - ` Hello, !`, - ` `, - ` `, - ` `, - ` `, - ` test_files/test.js:3`, - ` `, - ` `, - ` tryme`, - ` `, - ` `, - ` `, - ` `, - ` test_files/test.js:4`, - ` `, - ` `, - ` Custom id message`, - ` `, - ` `, - ` `, - ` `, - ` test_files/test.js:6`, - ` `, - ` `, - ` Legacy id message`, - ` `, - ` `, - ` `, - ` `, - ` test_files/test.js:8`, - ` `, - ` `, - ` Custom and legacy message`, - ` `, - ` `, - ` `, - ` `, - ` test_files/test.js:9,10`, - ` `, - ` `, - ` pre` + - `inner-prebold` + - `inner-postpost`, - ` `, - ` `, - ` `, - `\n`, - ].join('\n')); - }); + } }); } diff --git a/packages/localize/src/tools/test/extract/translation_files/format_options_spec.ts b/packages/localize/src/tools/test/extract/translation_files/format_options_spec.ts new file mode 100644 index 0000000000000..452e0d6250d8f --- /dev/null +++ b/packages/localize/src/tools/test/extract/translation_files/format_options_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC 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 {parseFormatOptions, validateOptions} from '../../../src/extract/translation_files/format_options'; + +describe('format_options', () => { + describe('validateOptions()', () => { + it('should do nothing if there are no options', () => { + expect(() => validateOptions('TestSerializer', [['key', ['value1', 'value2']]], {})) + .not.toThrow(); + }); + + it('should do nothing if the options are valid', () => { + expect( + () => validateOptions('TestSerializer', [['key', ['value1', 'value2']]], {key: 'value1'})) + .not.toThrow(); + }); + + it('should error if there is an unexpected option', () => { + expect( + () => validateOptions('TestSerializer', [['key', ['value1', 'value2']]], {wrong: 'xxx'})) + .toThrowError( + 'Invalid format option for TestSerializer: "wrong".\n' + + 'Allowed options are ["key"].'); + }); + + it('should error if there is an unexpected option value', () => { + expect( + () => validateOptions('TestSerializer', [['key', ['value1', 'value2']]], {key: 'other'})) + .toThrowError( + 'Invalid format option value for TestSerializer: "key".\n' + + 'Allowed option values are ["value1","value2"] but received "other".'); + }); + }); + + describe('parseFormatOptions()', () => { + it('should parse the string as JSON', () => { + expect(parseFormatOptions('{"a": "1", "b": "2"}')).toEqual({a: '1', b: '2'}); + }); + + it('should parse undefined into an empty object', () => { + expect(parseFormatOptions(undefined)).toEqual({}); + }); + }); +}); diff --git a/packages/localize/src/tools/test/extract/translation_files/utils.ts b/packages/localize/src/tools/test/extract/translation_files/utils.ts new file mode 100644 index 0000000000000..2fe55a6eb3580 --- /dev/null +++ b/packages/localize/src/tools/test/extract/translation_files/utils.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC 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 {FormatOptions} from '../../../src/extract/translation_files/format_options'; + +export function toAttributes(options: FormatOptions) { + let result = ''; + for (const option in options) { + result += ` ${option}="${options[option]}"`; + } + return result; +} diff --git a/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts b/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts index 4a9e8baa89594..0ed91d10b33d6 100644 --- a/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts +++ b/packages/localize/src/tools/test/extract/translation_files/xliff1_translation_serializer_spec.ts @@ -9,127 +9,132 @@ import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; +import {FormatOptions} from '../../../src/extract/translation_files/format_options'; import {Xliff1TranslationSerializer} from '../../../src/extract/translation_files/xliff1_translation_serializer'; import {mockMessage} from './mock_message'; +import {toAttributes} from './utils'; runInEachFileSystem(() => { describe('Xliff1TranslationSerializer', () => { - [false, true].forEach(useLegacyIds => { - describe(`renderFile() [using ${useLegacyIds ? 'legacy' : 'canonical'} ids]`, () => { - it('should convert a set of parsed messages into an XML string', () => { - const phLocation: ɵSourceLocation = { - start: {line: 0, column: 10}, - end: {line: 1, column: 15}, - file: absoluteFrom('/project/file.ts'), - text: 'placeholder + 1' - }; - const messagePartLocation: ɵSourceLocation = { - start: {line: 0, column: 5}, - end: {line: 0, column: 10}, - file: absoluteFrom('/project/file.ts'), - text: 'message part' - }; - const messages: ɵParsedMessage[] = [ - mockMessage('12345', ['a', 'b', 'c'], ['PH', 'PH_1'], { - meaning: 'some meaning', - location: { - file: absoluteFrom('/project/file.ts'), - start: {line: 5, column: 10}, - end: {line: 5, column: 12} - }, - legacyIds: ['1234567890ABCDEF1234567890ABCDEF12345678', '615790887472569365'], - }), - mockMessage('54321', ['a', 'b', 'c'], ['PH', 'PH_1'], { - customId: 'someId', - legacyIds: ['87654321FEDCBA0987654321FEDCBA0987654321', '563965274788097516'], - messagePartLocations: [undefined, messagePartLocation, undefined], - substitutionLocations: {'PH': phLocation, 'PH_1': undefined}, - }), - mockMessage( - '67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], - {description: 'some description'}), - mockMessage('38705', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { - location: { - file: absoluteFrom('/project/file.ts'), - start: {line: 2, column: 7}, - end: {line: 3, column: 2} - } - }), - mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), - mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), - mockMessage('80808', ['multi\nlines'], [], {}), - mockMessage('90000', [''], ['double-quotes-"'], {}), - mockMessage( - '100000', - [ - 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' - ], - [], {}), - mockMessage( - '100001', - [ - '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' - ], - [], {}), - ]; - const serializer = - new Xliff1TranslationSerializer('xx', absoluteFrom('/project'), useLegacyIds); - const output = serializer.serialize(messages); - expect(output).toEqual([ - ``, - ``, - ` `, - ` `, - ` `, - ` abc`, - ` `, - ` file.ts`, - ` 6`, - ` `, - ` some meaning`, - ` `, - ` `, - ` abc`, - ` `, - ` `, - ` ac`, - ` some description`, - ` `, - ` `, - ` ac`, - ` `, - ` file.ts`, - ` 3,4`, - ` `, - ` `, - ` `, - ` b`, - ` `, - ` `, - ` a`, - ` and description`, - ` meaning`, - ` `, - ` `, - ` multi`, - `lines`, - ` `, - ` `, - ` <escapeme>`, - ` `, - ` `, - ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, - ` `, - ` `, - ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, - ` `, - ` `, - ` `, - `\n`, - ].join('\n')); + ([{}, {'xml:space': 'preserve'}] as FormatOptions[]).forEach(options => { + [false, true].forEach(useLegacyIds => { + describe(`renderFile() [using ${useLegacyIds ? 'legacy' : 'canonical'} ids]`, () => { + it('should convert a set of parsed messages into an XML string', () => { + const phLocation: ɵSourceLocation = { + start: {line: 0, column: 10}, + end: {line: 1, column: 15}, + file: absoluteFrom('/project/file.ts'), + text: 'placeholder + 1' + }; + const messagePartLocation: ɵSourceLocation = { + start: {line: 0, column: 5}, + end: {line: 0, column: 10}, + file: absoluteFrom('/project/file.ts'), + text: 'message part' + }; + const messages: ɵParsedMessage[] = [ + mockMessage('12345', ['a', 'b', 'c'], ['PH', 'PH_1'], { + meaning: 'some meaning', + location: { + file: absoluteFrom('/project/file.ts'), + start: {line: 5, column: 10}, + end: {line: 5, column: 12} + }, + legacyIds: ['1234567890ABCDEF1234567890ABCDEF12345678', '615790887472569365'], + }), + mockMessage('54321', ['a', 'b', 'c'], ['PH', 'PH_1'], { + customId: 'someId', + legacyIds: ['87654321FEDCBA0987654321FEDCBA0987654321', '563965274788097516'], + messagePartLocations: [undefined, messagePartLocation, undefined], + substitutionLocations: {'PH': phLocation, 'PH_1': undefined}, + }), + mockMessage( + '67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], + {description: 'some description'}), + mockMessage('38705', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { + location: { + file: absoluteFrom('/project/file.ts'), + start: {line: 2, column: 7}, + end: {line: 3, column: 2} + } + }), + mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), + mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), + mockMessage('80808', ['multi\nlines'], [], {}), + mockMessage('90000', [''], ['double-quotes-"'], {}), + mockMessage( + '100000', + [ + 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' + ], + [], {}), + mockMessage( + '100001', + [ + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' + ], + [], {}), + ]; + const serializer = new Xliff1TranslationSerializer( + 'xx', absoluteFrom('/project'), useLegacyIds, options); + const output = serializer.serialize(messages); + expect(output).toEqual([ + ``, + ``, + ` `, + ` `, + ` `, + ` abc`, + ` `, + ` file.ts`, + ` 6`, + ` `, + ` some meaning`, + ` `, + ` `, + ` abc`, + ` `, + ` `, + ` ac`, + ` some description`, + ` `, + ` `, + ` ac`, + ` `, + ` file.ts`, + ` 3,4`, + ` `, + ` `, + ` `, + ` b`, + ` `, + ` `, + ` a`, + ` and description`, + ` meaning`, + ` `, + ` `, + ` multi`, + `lines`, + ` `, + ` `, + ` <escapeme>`, + ` `, + ` `, + ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, + ` `, + ` `, + ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, + ` `, + ` `, + ` `, + `\n`, + ].join('\n')); + }); }); }); }); diff --git a/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts b/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts index d01b6d0ca3f40..5d424ef4a7edd 100644 --- a/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts +++ b/packages/localize/src/tools/test/extract/translation_files/xliff2_translation_serializer_spec.ts @@ -9,151 +9,155 @@ import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system'; import {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; import {ɵParsedMessage, ɵSourceLocation} from '@angular/localize'; +import {FormatOptions} from '../../../src/extract/translation_files/format_options'; import {Xliff2TranslationSerializer} from '../../../src/extract/translation_files/xliff2_translation_serializer'; import {mockMessage} from './mock_message'; +import {toAttributes} from './utils'; runInEachFileSystem(() => { describe('Xliff2TranslationSerializer', () => { - [false, true].forEach(useLegacyIds => { - describe(`renderFile() [using ${useLegacyIds ? 'legacy' : 'canonical'} ids]`, () => { - it('should convert a set of parsed messages into an XML string', () => { - const phLocation: ɵSourceLocation = { - start: {line: 0, column: 10}, - end: {line: 1, column: 15}, - file: absoluteFrom('/project/file.ts'), - text: 'placeholder + 1' - }; - const messagePartLocation: ɵSourceLocation = { - start: {line: 0, column: 5}, - end: {line: 0, column: 10}, - file: absoluteFrom('/project/file.ts'), - text: 'message part' - }; - const messages: ɵParsedMessage[] = [ - mockMessage('12345', ['a', 'b', 'c'], ['PH', 'PH_1'], { - meaning: 'some meaning', - location: { - file: absoluteFrom('/project/file.ts'), - start: {line: 5, column: 0}, - end: {line: 5, column: 3} - }, - legacyIds: ['1234567890ABCDEF1234567890ABCDEF12345678', '615790887472569365'], - }), - mockMessage('54321', ['a', 'b', 'c'], ['PH', 'PH_1'], { - customId: 'someId', - legacyIds: ['87654321FEDCBA0987654321FEDCBA0987654321', '563965274788097516'], - messagePartLocations: [undefined, messagePartLocation, undefined], - substitutionLocations: {'PH': phLocation, 'PH_1': undefined}, - }), - mockMessage('67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { - description: 'some description', - location: { - file: absoluteFrom('/project/file.ts'), - start: {line: 2, column: 7}, - end: {line: 3, column: 2} - } - }), - mockMessage('location-only', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { - location: { - file: absoluteFrom('/project/file.ts'), - start: {line: 2, column: 7}, - end: {line: 3, column: 2} - } - }), - mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), - mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), - mockMessage('80808', ['multi\nlines'], [], {}), - mockMessage('90000', [''], ['double-quotes-"'], {}), - mockMessage( - '100000', - [ - 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' - ], - [], {}), - mockMessage( - '100001', - [ - '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' - ], - [], {}), - ]; - const serializer = - new Xliff2TranslationSerializer('xx', absoluteFrom('/project'), useLegacyIds); - const output = serializer.serialize(messages); - expect(output).toEqual([ - ``, - ``, - ` `, - ` `, - ` `, - ` file.ts:6`, - ` some meaning`, - ` `, - ` `, - ` abc`, - ` `, - ` `, - ` `, - ` `, - ` abc`, - ` `, - ` `, - ` `, - ` `, - ` file.ts:3,4`, - ` some description`, - ` `, - ` `, - ` ac`, - ` `, - ` `, - ` `, - ` `, - ` file.ts:3,4`, - ` `, - ` `, - ` ac`, - ` `, - ` `, - ` `, - ` `, - ` b`, - ` `, - ` `, - ` `, - ` `, - ` and description`, - ` meaning`, - ` `, - ` `, - ` a`, - ` `, - ` `, - ` `, - ` `, - ` multi`, - `lines`, - ` `, - ` `, - ` `, - ` `, - ` <escapeme>`, - ` `, - ` `, - ` `, - ` `, - ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, - ` `, - ` `, - ` `, - ` `, - ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, - ` `, - ` `, - ` `, - `\n`, - ].join('\n')); + ([{}, {'xml:space': 'preserve'}] as FormatOptions[]).forEach(options => { + [false, true].forEach(useLegacyIds => { + describe(`renderFile() [using ${useLegacyIds ? 'legacy' : 'canonical'} ids]`, () => { + it('should convert a set of parsed messages into an XML string', () => { + const phLocation: ɵSourceLocation = { + start: {line: 0, column: 10}, + end: {line: 1, column: 15}, + file: absoluteFrom('/project/file.ts'), + text: 'placeholder + 1' + }; + const messagePartLocation: ɵSourceLocation = { + start: {line: 0, column: 5}, + end: {line: 0, column: 10}, + file: absoluteFrom('/project/file.ts'), + text: 'message part' + }; + const messages: ɵParsedMessage[] = [ + mockMessage('12345', ['a', 'b', 'c'], ['PH', 'PH_1'], { + meaning: 'some meaning', + location: { + file: absoluteFrom('/project/file.ts'), + start: {line: 5, column: 0}, + end: {line: 5, column: 3} + }, + legacyIds: ['1234567890ABCDEF1234567890ABCDEF12345678', '615790887472569365'], + }), + mockMessage('54321', ['a', 'b', 'c'], ['PH', 'PH_1'], { + customId: 'someId', + legacyIds: ['87654321FEDCBA0987654321FEDCBA0987654321', '563965274788097516'], + messagePartLocations: [undefined, messagePartLocation, undefined], + substitutionLocations: {'PH': phLocation, 'PH_1': undefined}, + }), + mockMessage('67890', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { + description: 'some description', + location: { + file: absoluteFrom('/project/file.ts'), + start: {line: 2, column: 7}, + end: {line: 3, column: 2} + } + }), + mockMessage('location-only', ['a', '', 'c'], ['START_TAG_SPAN', 'CLOSE_TAG_SPAN'], { + location: { + file: absoluteFrom('/project/file.ts'), + start: {line: 2, column: 7}, + end: {line: 3, column: 2} + } + }), + mockMessage('13579', ['', 'b', ''], ['START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'], {}), + mockMessage('24680', ['a'], [], {meaning: 'meaning', description: 'and description'}), + mockMessage('80808', ['multi\nlines'], [], {}), + mockMessage('90000', [''], ['double-quotes-"'], {}), + mockMessage( + '100000', + [ + 'pre-ICU {VAR_SELECT, select, a {a} b {{INTERPOLATION}} c {pre {INTERPOLATION_1} post}} post-ICU' + ], + [], {}), + mockMessage( + '100001', + [ + '{VAR_PLURAL, plural, one {{START_BOLD_TEXT}something bold{CLOSE_BOLD_TEXT}} other {pre {START_TAG_SPAN}middle{CLOSE_TAG_SPAN} post}}' + ], + [], {}), + ]; + const serializer = new Xliff2TranslationSerializer( + 'xx', absoluteFrom('/project'), useLegacyIds, options); + const output = serializer.serialize(messages); + expect(output).toEqual([ + ``, + ``, + ` `, + ` `, + ` `, + ` file.ts:6`, + ` some meaning`, + ` `, + ` `, + ` abc`, + ` `, + ` `, + ` `, + ` `, + ` abc`, + ` `, + ` `, + ` `, + ` `, + ` file.ts:3,4`, + ` some description`, + ` `, + ` `, + ` ac`, + ` `, + ` `, + ` `, + ` `, + ` file.ts:3,4`, + ` `, + ` `, + ` ac`, + ` `, + ` `, + ` `, + ` `, + ` b`, + ` `, + ` `, + ` `, + ` `, + ` and description`, + ` meaning`, + ` `, + ` `, + ` a`, + ` `, + ` `, + ` `, + ` `, + ` multi`, + `lines`, + ` `, + ` `, + ` `, + ` `, + ` <escapeme>`, + ` `, + ` `, + ` `, + ` `, + ` pre-ICU {VAR_SELECT, select, a {a} b {} c {pre post}} post-ICU`, + ` `, + ` `, + ` `, + ` `, + ` {VAR_PLURAL, plural, one {something bold} other {pre middle post}}`, + ` `, + ` `, + ` `, + `\n`, + ].join('\n')); + }); }); }); });