From 190dca0fdcc8cef676f893889df26c9403c4ed17 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Mon, 7 Sep 2020 21:16:55 +0100 Subject: [PATCH] fix(localize): enable whitespace preservation marker in XLIFF files (#38737) Whitespace can be relevant in extracted XLIFF translation files. Some i18n tools - e.g. CAT tool (OmegaT) - will reformat the file to collapse whitespace if there is no indication to tell it not to. This commit adds the ability to specify "format options" that are passed to the translation file serializer. The XLIFF 1.2 and 2.0 seralizers have been updated to accept `{"xml:space":"preserve"}` format option which will by added to the `` element in the serialized translation file during extraction. Fixes #38679 PR Close #38737 --- .../localize/src/tools/src/extract/main.ts | 26 +- .../translation_files/format_options.ts | 44 +++ .../xliff1_translation_serializer.ts | 8 +- .../xliff2_translation_serializer.ts | 12 +- packages/localize/src/tools/test/BUILD.bazel | 1 + .../test/extract/integration/BUILD.bazel | 1 + .../test/extract/integration/main_spec.ts | 283 +++++++++--------- .../translation_files/format_options_spec.ts | 49 +++ .../test/extract/translation_files/utils.ts | 17 ++ .../xliff1_translation_serializer_spec.ts | 235 ++++++++------- .../xliff2_translation_serializer_spec.ts | 282 ++++++++--------- 11 files changed, 556 insertions(+), 402 deletions(-) create mode 100644 packages/localize/src/tools/src/extract/translation_files/format_options.ts create mode 100644 packages/localize/src/tools/test/extract/translation_files/format_options_spec.ts create mode 100644 packages/localize/src/tools/test/extract/translation_files/utils.ts 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')); + }); }); }); });