Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix(localize): enable whitespace preservation marker in XLIFF files #38737

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
26 changes: 21 additions & 5 deletions packages/localize/src/tools/src/extract/main.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -109,6 +118,7 @@ if (require.main === module) {
useSourceMaps: options.useSourceMaps,
useLegacyIds: options.useLegacyIds,
duplicateMessageHandling,
formatOptions,
});
}

Expand Down Expand Up @@ -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({
Expand All @@ -164,6 +178,7 @@ export function extractTranslations({
useSourceMaps,
useLegacyIds,
duplicateMessageHandling,
formatOptions = {},
}: ExtractTranslationsOptions) {
const fs = getFileSystem();
const basePath = fs.resolve(rootPath);
Expand All @@ -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);
Expand All @@ -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':
Expand Down
@@ -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<string, string>;
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<ValidOption[0], ValidOption[1]>(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);
}
Expand Up @@ -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';
Expand All @@ -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<string>();
Expand All @@ -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) {
Expand Down
Expand Up @@ -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';
Expand All @@ -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<string>();
Expand All @@ -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 `<file>` 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)) {
Expand Down
1 change: 1 addition & 0 deletions packages/localize/src/tools/test/BUILD.bazel
Expand Up @@ -6,6 +6,7 @@ ts_library(
srcs = glob(
["**/*.ts"],
),
visibility = ["//packages/localize/src/tools/test:__subpackages__"],
deps = [
"//packages:types",
"//packages/compiler",
Expand Down
Expand Up @@ -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",
],
)

Expand Down