feat(localize): add scripts to migrate away from legacy message IDs (#41026)
Adds a new flag to `localize-extract` called `--migrateMapFile` which will generate a JSON file that can be used to map legacy message IDs to cannonical ones. Also includes a new script called `localize-migrate` that can take the mapping file which was generated by `localize-extract` and migrate all of the IDs in the files that were passed in. PR Close #41026
This commit is contained in:
parent
531f0bfb4a
commit
1735430476
|
@ -4,7 +4,8 @@
|
||||||
"description": "Angular - library for localizing messages",
|
"description": "Angular - library for localizing messages",
|
||||||
"bin": {
|
"bin": {
|
||||||
"localize-translate": "./src/tools/src/translate/main.js",
|
"localize-translate": "./src/tools/src/translate/main.js",
|
||||||
"localize-extract": "./src/tools/src/extract/main.js"
|
"localize-extract": "./src/tools/src/extract/main.js",
|
||||||
|
"localize-migrate": "./src/tools/src/migrate/main.js"
|
||||||
},
|
},
|
||||||
"author": "angular",
|
"author": "angular",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {ɵParsedMessage} from '@angular/localize';
|
||||||
import * as glob from 'glob';
|
import * as glob from 'glob';
|
||||||
import * as yargs from 'yargs';
|
import * as yargs from 'yargs';
|
||||||
|
|
||||||
import {DiagnosticHandlingStrategy} from '../diagnostics';
|
import {Diagnostics, DiagnosticHandlingStrategy} from '../diagnostics';
|
||||||
|
|
||||||
import {checkDuplicateMessages} from './duplicates';
|
import {checkDuplicateMessages} from './duplicates';
|
||||||
import {MessageExtractor} from './extraction';
|
import {MessageExtractor} from './extraction';
|
||||||
|
@ -23,6 +23,7 @@ import {Xliff1TranslationSerializer} from './translation_files/xliff1_translatio
|
||||||
import {Xliff2TranslationSerializer} from './translation_files/xliff2_translation_serializer';
|
import {Xliff2TranslationSerializer} from './translation_files/xliff2_translation_serializer';
|
||||||
import {XmbTranslationSerializer} from './translation_files/xmb_translation_serializer';
|
import {XmbTranslationSerializer} from './translation_files/xmb_translation_serializer';
|
||||||
import {FormatOptions, parseFormatOptions} from './translation_files/format_options';
|
import {FormatOptions, parseFormatOptions} from './translation_files/format_options';
|
||||||
|
import {LegacyMessageIdMigrationSerializer} from './translation_files/legacy_message_id_migration_serializer';
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
process.title = 'Angular Localization Message Extractor (localize-extract)';
|
process.title = 'Angular Localization Message Extractor (localize-extract)';
|
||||||
|
@ -53,7 +54,9 @@ if (require.main === module) {
|
||||||
.option('f', {
|
.option('f', {
|
||||||
alias: 'format',
|
alias: 'format',
|
||||||
required: true,
|
required: true,
|
||||||
choices: ['xmb', 'xlf', 'xlif', 'xliff', 'xlf2', 'xlif2', 'xliff2', 'json'],
|
choices: [
|
||||||
|
'xmb', 'xlf', 'xlif', 'xliff', 'xlf2', 'xlif2', 'xliff2', 'json', 'legacy-migrate'
|
||||||
|
],
|
||||||
describe: 'The format of the translation file.',
|
describe: 'The format of the translation file.',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
})
|
})
|
||||||
|
@ -108,17 +111,17 @@ if (require.main === module) {
|
||||||
const logger = new ConsoleLogger(logLevel ? LogLevel[logLevel] : LogLevel.warn);
|
const logger = new ConsoleLogger(logLevel ? LogLevel[logLevel] : LogLevel.warn);
|
||||||
const duplicateMessageHandling = options.d as DiagnosticHandlingStrategy;
|
const duplicateMessageHandling = options.d as DiagnosticHandlingStrategy;
|
||||||
const formatOptions = parseFormatOptions(options.formatOptions);
|
const formatOptions = parseFormatOptions(options.formatOptions);
|
||||||
|
const format = options.f;
|
||||||
|
|
||||||
extractTranslations({
|
extractTranslations({
|
||||||
rootPath,
|
rootPath,
|
||||||
sourceFilePaths,
|
sourceFilePaths,
|
||||||
sourceLocale: options.l,
|
sourceLocale: options.l,
|
||||||
format: options.f,
|
format,
|
||||||
outputPath: options.o,
|
outputPath: options.o,
|
||||||
logger,
|
logger,
|
||||||
useSourceMaps: options.useSourceMaps,
|
useSourceMaps: options.useSourceMaps,
|
||||||
useLegacyIds: options.useLegacyIds,
|
useLegacyIds: format === 'legacy-migrate' || options.useLegacyIds,
|
||||||
duplicateMessageHandling,
|
duplicateMessageHandling,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
fileSystem,
|
fileSystem,
|
||||||
|
@ -202,8 +205,8 @@ export function extractTranslations({
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputPath = fs.resolve(rootPath, output);
|
const outputPath = fs.resolve(rootPath, output);
|
||||||
const serializer =
|
const serializer = getSerializer(
|
||||||
getSerializer(format, sourceLocale, fs.dirname(outputPath), useLegacyIds, formatOptions, fs);
|
format, sourceLocale, fs.dirname(outputPath), useLegacyIds, formatOptions, fs, diagnostics);
|
||||||
const translationFile = serializer.serialize(messages);
|
const translationFile = serializer.serialize(messages);
|
||||||
fs.ensureDir(fs.dirname(outputPath));
|
fs.ensureDir(fs.dirname(outputPath));
|
||||||
fs.writeFile(outputPath, translationFile);
|
fs.writeFile(outputPath, translationFile);
|
||||||
|
@ -213,9 +216,10 @@ export function extractTranslations({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSerializer(
|
function getSerializer(
|
||||||
format: string, sourceLocale: string, rootPath: AbsoluteFsPath, useLegacyIds: boolean,
|
format: string, sourceLocale: string, rootPath: AbsoluteFsPath, useLegacyIds: boolean,
|
||||||
formatOptions: FormatOptions = {}, fs: PathManipulation): TranslationSerializer {
|
formatOptions: FormatOptions = {}, fs: PathManipulation,
|
||||||
|
diagnostics: Diagnostics): TranslationSerializer {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'xlf':
|
case 'xlf':
|
||||||
case 'xlif':
|
case 'xlif':
|
||||||
|
@ -233,6 +237,8 @@ export function getSerializer(
|
||||||
return new SimpleJsonTranslationSerializer(sourceLocale);
|
return new SimpleJsonTranslationSerializer(sourceLocale);
|
||||||
case 'arb':
|
case 'arb':
|
||||||
return new ArbTranslationSerializer(sourceLocale, rootPath, fs);
|
return new ArbTranslationSerializer(sourceLocale, rootPath, fs);
|
||||||
|
case 'legacy-migrate':
|
||||||
|
return new LegacyMessageIdMigrationSerializer(diagnostics);
|
||||||
}
|
}
|
||||||
throw new Error(`No translation serializer can handle the provided format: ${format}`);
|
throw new Error(`No translation serializer can handle the provided format: ${format}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* @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 {ɵParsedMessage as ParsedMessage} from '@angular/localize';
|
||||||
|
import {Diagnostics} from '../../diagnostics';
|
||||||
|
import {TranslationSerializer} from './translation_serializer';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A translation serializer that generates the mapping file for the legacy message ID migration.
|
||||||
|
* The file is used by the `localize-migrate` script to migrate existing translation files from
|
||||||
|
* the legacy message IDs to the canonical ones.
|
||||||
|
*/
|
||||||
|
export class LegacyMessageIdMigrationSerializer implements TranslationSerializer {
|
||||||
|
constructor(private _diagnostics: Diagnostics) {}
|
||||||
|
|
||||||
|
serialize(messages: ParsedMessage[]): string {
|
||||||
|
let hasMessages = false;
|
||||||
|
const mapping = messages.reduce((output, message) => {
|
||||||
|
if (shouldMigrate(message)) {
|
||||||
|
for (const legacyId of message.legacyIds!) {
|
||||||
|
if (output.hasOwnProperty(legacyId)) {
|
||||||
|
this._diagnostics.warn(`Detected duplicate legacy ID ${legacyId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[legacyId] = message.id;
|
||||||
|
hasMessages = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
if (!hasMessages) {
|
||||||
|
this._diagnostics.warn(
|
||||||
|
'Could not find any legacy message IDs in source files while generating ' +
|
||||||
|
'the legacy message migration file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(mapping, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if a message needs to be migrated. */
|
||||||
|
function shouldMigrate(message: ParsedMessage): boolean {
|
||||||
|
return !message.customId && !!message.legacyIds && message.legacyIds.length > 0;
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @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 {getFileSystem, NodeJSFileSystem, setFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
|
||||||
|
import {ConsoleLogger, Logger, LogLevel} from '@angular/compiler-cli/src/ngtsc/logging';
|
||||||
|
import * as glob from 'glob';
|
||||||
|
import * as yargs from 'yargs';
|
||||||
|
import {migrateFile, MigrationMapping} from './migrate';
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options =
|
||||||
|
yargs
|
||||||
|
.option('r', {
|
||||||
|
alias: 'root',
|
||||||
|
default: '.',
|
||||||
|
describe: 'The root path for other paths provided in these options.\n' +
|
||||||
|
'This should either be absolute or relative to the current working directory.',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
.option('f', {
|
||||||
|
alias: 'files',
|
||||||
|
required: true,
|
||||||
|
describe:
|
||||||
|
'A glob pattern indicating what files to migrate. This should be relative to the root path',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
.option('m', {
|
||||||
|
alias: 'mapFile',
|
||||||
|
required: true,
|
||||||
|
describe:
|
||||||
|
'Path to the migration mapping file generated by `localize-extract`. This should be relative to the root path.',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.help()
|
||||||
|
.parse(args);
|
||||||
|
|
||||||
|
const fs = new NodeJSFileSystem();
|
||||||
|
setFileSystem(fs);
|
||||||
|
|
||||||
|
const rootPath = options.r;
|
||||||
|
const translationFilePaths = glob.sync(options.f, {cwd: rootPath, nodir: true});
|
||||||
|
const logger = new ConsoleLogger(LogLevel.warn);
|
||||||
|
|
||||||
|
migrateFiles({rootPath, translationFilePaths, mappingFilePath: options.m, logger});
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrateFilesOptions {
|
||||||
|
/**
|
||||||
|
* The base path for other paths provided in these options.
|
||||||
|
* This should either be absolute or relative to the current working directory.
|
||||||
|
*/
|
||||||
|
rootPath: string;
|
||||||
|
|
||||||
|
/** Paths to the files that should be migrated. Should be relative to the `rootPath`. */
|
||||||
|
translationFilePaths: string[];
|
||||||
|
|
||||||
|
/** Path to the file containing the message ID mappings. Should be relative to the `rootPath`. */
|
||||||
|
mappingFilePath: string;
|
||||||
|
|
||||||
|
/** Logger to use for diagnostic messages. */
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migrates the legacy message IDs based on the passed in configuration. */
|
||||||
|
export function migrateFiles({
|
||||||
|
rootPath,
|
||||||
|
translationFilePaths,
|
||||||
|
mappingFilePath,
|
||||||
|
logger,
|
||||||
|
}: MigrateFilesOptions) {
|
||||||
|
const fs = getFileSystem();
|
||||||
|
const absoluteMappingPath = fs.resolve(rootPath, mappingFilePath);
|
||||||
|
const mapping = JSON.parse(fs.readFile(absoluteMappingPath)) as MigrationMapping;
|
||||||
|
|
||||||
|
if (Object.keys(mapping).length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Mapping file at ${absoluteMappingPath} is empty. Either there are no messages ` +
|
||||||
|
`that need to be migrated, or the extraction step failed to find them.`);
|
||||||
|
} else {
|
||||||
|
translationFilePaths.forEach(path => {
|
||||||
|
const absolutePath = fs.resolve(rootPath, path);
|
||||||
|
const sourceCode = fs.readFile(absolutePath);
|
||||||
|
fs.writeFile(absolutePath, migrateFile(sourceCode, mapping));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mapping between legacy message IDs and their cannonical counterparts. */
|
||||||
|
export type MigrationMapping = {
|
||||||
|
[legacyId: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Migrates the legacy message IDs within a single file. */
|
||||||
|
export function migrateFile(sourceCode: string, mapping: MigrationMapping) {
|
||||||
|
const legacyIds = Object.keys(mapping);
|
||||||
|
|
||||||
|
for (const legacyId of legacyIds) {
|
||||||
|
const cannonicalId = mapping[legacyId];
|
||||||
|
const pattern = new RegExp(escapeRegExp(legacyId), 'g');
|
||||||
|
sourceCode = sourceCode.replace(pattern, cannonicalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes special regex characters in a string. */
|
||||||
|
function escapeRegExp(str: string): string {
|
||||||
|
return str.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
|
||||||
|
}
|
|
@ -494,6 +494,49 @@ runInNativeFileSystem(() => {
|
||||||
`}`,
|
`}`,
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should generate the migration map file, if requested', () => {
|
||||||
|
extractTranslations({
|
||||||
|
rootPath,
|
||||||
|
sourceLocale: 'en',
|
||||||
|
sourceFilePaths: [sourceFilePath],
|
||||||
|
format: 'legacy-migrate',
|
||||||
|
outputPath,
|
||||||
|
logger,
|
||||||
|
useSourceMaps: false,
|
||||||
|
useLegacyIds: true,
|
||||||
|
duplicateMessageHandling: 'ignore',
|
||||||
|
fileSystem: fs
|
||||||
|
});
|
||||||
|
expect(fs.readFile(outputPath)).toEqual([
|
||||||
|
`{`,
|
||||||
|
` "1234567890123456789012345678901234567890": "273296103957933077",`,
|
||||||
|
` "12345678901234567890": "273296103957933077"`,
|
||||||
|
`}`,
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log a warning if there are no legacy message IDs to migrate', () => {
|
||||||
|
extractTranslations({
|
||||||
|
rootPath,
|
||||||
|
sourceLocale: 'en',
|
||||||
|
sourceFilePaths: [textFile1],
|
||||||
|
format: 'legacy-migrate',
|
||||||
|
outputPath,
|
||||||
|
logger,
|
||||||
|
useSourceMaps: false,
|
||||||
|
useLegacyIds: true,
|
||||||
|
duplicateMessageHandling: 'ignore',
|
||||||
|
fileSystem: fs
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.readFile(outputPath)).toBe('{}');
|
||||||
|
expect(logger.logs.warn).toEqual([[
|
||||||
|
'Messages extracted with warnings\n' +
|
||||||
|
'WARNINGS:\n' +
|
||||||
|
' - Could not find any legacy message IDs in source files while generating the legacy message migration file.'
|
||||||
|
]]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* @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 {ɵParsedMessage} from '@angular/localize';
|
||||||
|
import {Diagnostics} from '../../../src/diagnostics';
|
||||||
|
import {LegacyMessageIdMigrationSerializer} from '../../../src/extract/translation_files/legacy_message_id_migration_serializer';
|
||||||
|
import {mockMessage} from './mock_message';
|
||||||
|
|
||||||
|
// Doesn't need to run in each file system since it doesn't interact with the file system.
|
||||||
|
|
||||||
|
describe('LegacyMessageIdMigrationSerializer', () => {
|
||||||
|
let serializer: LegacyMessageIdMigrationSerializer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serializer = new LegacyMessageIdMigrationSerializer(new Diagnostics());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert a set of parsed messages into a migration mapping file', () => {
|
||||||
|
const messages: ɵParsedMessage[] = [
|
||||||
|
mockMessage('one', [], [], {legacyIds: ['legacy-one', 'other-legacy-one']}),
|
||||||
|
mockMessage('two', [], [], {legacyIds: ['legacy-two']}),
|
||||||
|
mockMessage('three', [], [], {legacyIds: ['legacy-three', 'other-legacy-three']}),
|
||||||
|
];
|
||||||
|
const output = serializer.serialize(messages);
|
||||||
|
expect(output.split('\n')).toEqual([
|
||||||
|
'{',
|
||||||
|
' "legacy-one": "one",',
|
||||||
|
' "other-legacy-one": "one",',
|
||||||
|
' "legacy-two": "two",',
|
||||||
|
' "legacy-three": "three",',
|
||||||
|
' "other-legacy-three": "three"',
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include messages that have a custom ID', () => {
|
||||||
|
const messages: ɵParsedMessage[] = [
|
||||||
|
mockMessage('one', [], [], {legacyIds: ['legacy-one']}),
|
||||||
|
mockMessage('two', [], [], {legacyIds: ['legacy-two'], customId: 'custom-two'}),
|
||||||
|
mockMessage('three', [], [], {legacyIds: ['legacy-three']}),
|
||||||
|
];
|
||||||
|
const output = serializer.serialize(messages);
|
||||||
|
expect(output.split('\n')).toEqual([
|
||||||
|
'{',
|
||||||
|
' "legacy-one": "one",',
|
||||||
|
' "legacy-three": "three"',
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include messages that do not have legacy IDs', () => {
|
||||||
|
const messages: ɵParsedMessage[] = [
|
||||||
|
mockMessage('one', [], [], {legacyIds: ['legacy-one']}),
|
||||||
|
mockMessage('two', [], [], {}),
|
||||||
|
mockMessage('three', [], [], {legacyIds: ['legacy-three']}),
|
||||||
|
];
|
||||||
|
const output = serializer.serialize(messages);
|
||||||
|
expect(output.split('\n')).toEqual([
|
||||||
|
'{',
|
||||||
|
' "legacy-one": "one",',
|
||||||
|
' "legacy-three": "three"',
|
||||||
|
'}',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce an empty file if none of the messages need to be migrated', () => {
|
||||||
|
const messages: ɵParsedMessage[] = [
|
||||||
|
mockMessage('one', [], [], {legacyIds: ['legacy-one'], customId: 'custom-one'}),
|
||||||
|
mockMessage('two', [], [], {}),
|
||||||
|
mockMessage('three', [], [], {legacyIds: []}),
|
||||||
|
];
|
||||||
|
const output = serializer.serialize(messages);
|
||||||
|
expect(output).toBe('{}');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,40 @@
|
||||||
|
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
|
||||||
|
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "test_lib",
|
||||||
|
testonly = True,
|
||||||
|
srcs = glob(
|
||||||
|
["**/*_spec.ts"],
|
||||||
|
),
|
||||||
|
deps = [
|
||||||
|
"//packages:types",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/file_system",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/file_system/testing",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/logging",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/logging/testing",
|
||||||
|
"//packages/compiler-cli/src/ngtsc/testing",
|
||||||
|
"//packages/localize/src/tools",
|
||||||
|
"//packages/localize/src/tools/test:test_lib",
|
||||||
|
"//packages/localize/src/tools/test/helpers",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use copy_to_bin since filegroup doesn't seem to work on Windows.
|
||||||
|
copy_to_bin(
|
||||||
|
name = "test_files",
|
||||||
|
srcs = glob(["test_files/**/*"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
jasmine_node_test(
|
||||||
|
name = "integration",
|
||||||
|
bootstrap = ["//tools/testing:node_no_angular_es5"],
|
||||||
|
data = [
|
||||||
|
":test_files",
|
||||||
|
],
|
||||||
|
deps = [
|
||||||
|
":test_lib",
|
||||||
|
"@npm//glob",
|
||||||
|
"@npm//yargs",
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,196 @@
|
||||||
|
/**
|
||||||
|
* @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 {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
|
||||||
|
import {MockLogger} from '@angular/compiler-cli/src/ngtsc/logging/testing';
|
||||||
|
import {loadTestDirectory} from '@angular/compiler-cli/src/ngtsc/testing';
|
||||||
|
import {migrateFiles} from '../../../src/migrate/main';
|
||||||
|
import {runInNativeFileSystem} from '../../helpers';
|
||||||
|
|
||||||
|
runInNativeFileSystem(() => {
|
||||||
|
let fs: FileSystem;
|
||||||
|
let logger: MockLogger;
|
||||||
|
let rootPath: AbsoluteFsPath;
|
||||||
|
let mappingFilePath: AbsoluteFsPath;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs = getFileSystem();
|
||||||
|
logger = new MockLogger();
|
||||||
|
rootPath = absoluteFrom('/project');
|
||||||
|
mappingFilePath = fs.resolve(rootPath, 'test_files/mapping.json');
|
||||||
|
|
||||||
|
loadTestDirectory(fs, __dirname + '/test_files', absoluteFrom('/project/test_files'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('migrateFiles()', () => {
|
||||||
|
it('should log a warning if the migration file is empty', () => {
|
||||||
|
const emptyMappingPath = fs.resolve(rootPath, 'test_files/empty-mapping.json');
|
||||||
|
migrateFiles({
|
||||||
|
rootPath,
|
||||||
|
translationFilePaths: ['test_files/messages.json'],
|
||||||
|
logger,
|
||||||
|
mappingFilePath: emptyMappingPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logger.logs.warn).toEqual([
|
||||||
|
[`Mapping file at ${emptyMappingPath} is empty. Either there are no messages ` +
|
||||||
|
`that need to be migrated, or the extraction step failed to find them.`]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should migrate a json message file', () => {
|
||||||
|
const filePath = 'test_files/messages.json';
|
||||||
|
migrateFiles({
|
||||||
|
rootPath,
|
||||||
|
translationFilePaths: [filePath],
|
||||||
|
logger,
|
||||||
|
mappingFilePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
|
||||||
|
`{`,
|
||||||
|
` "locale": "en-GB",`,
|
||||||
|
` "translations": {`,
|
||||||
|
` "9876543": "Hello",`,
|
||||||
|
` "custom-id": "Custom id message",`,
|
||||||
|
` "987654321098765": "Goodbye"`,
|
||||||
|
` }`,
|
||||||
|
`}`,
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should migrate an arb message file', () => {
|
||||||
|
const filePath = 'test_files/messages.arb';
|
||||||
|
migrateFiles({
|
||||||
|
rootPath,
|
||||||
|
translationFilePaths: [filePath],
|
||||||
|
logger,
|
||||||
|
mappingFilePath,
|
||||||
|
});
|
||||||
|
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
|
||||||
|
`{`,
|
||||||
|
` "@@locale": "en-GB",`,
|
||||||
|
` "9876543": "Hello",`,
|
||||||
|
` "@9876543": {`,
|
||||||
|
` "x-locations": [`,
|
||||||
|
` {`,
|
||||||
|
` "file": "test.js",`,
|
||||||
|
` "start": { "line": "1", "column": "0" },`,
|
||||||
|
` "end": { "line": "1", "column": "0" }`,
|
||||||
|
` }`,
|
||||||
|
` ]`,
|
||||||
|
` },`,
|
||||||
|
` "custom-id": "Custom id message",`,
|
||||||
|
` "@custom-id": {`,
|
||||||
|
` "x-locations": [`,
|
||||||
|
` {`,
|
||||||
|
` "file": "test.js",`,
|
||||||
|
` "start": { "line": "2", "column": "0" },`,
|
||||||
|
` "end": { "line": "2", "column": "0" }`,
|
||||||
|
` }`,
|
||||||
|
` ]`,
|
||||||
|
` },`,
|
||||||
|
` "987654321098765": "Goodbye",`,
|
||||||
|
` "@987654321098765": {`,
|
||||||
|
` "x-locations": [`,
|
||||||
|
` {`,
|
||||||
|
` "file": "test.js",`,
|
||||||
|
` "start": { "line": "3", "column": "0" },`,
|
||||||
|
` "end": { "line": "3", "column": "0" }`,
|
||||||
|
` }`,
|
||||||
|
` ]`,
|
||||||
|
` }`,
|
||||||
|
`}`,
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should migrate an xmb message file', () => {
|
||||||
|
const filePath = 'test_files/messages.xmb';
|
||||||
|
migrateFiles({
|
||||||
|
rootPath,
|
||||||
|
translationFilePaths: [filePath],
|
||||||
|
logger,
|
||||||
|
mappingFilePath,
|
||||||
|
});
|
||||||
|
expect(readAndNormalize(fs.resolve(rootPath, filePath))).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="9876543"><source>test.js:1</source>Hello</msg>`,
|
||||||
|
` <msg id="custom-id"><source>test.js:2</source>Custom id message</msg>`,
|
||||||
|
` <msg id="987654321098765"><source>test.js:3</source>Goodbye</msg>`,
|
||||||
|
`</messagebundle>`,
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should migrate an xlf message file', () => {
|
||||||
|
const filePath = 'test_files/messages.xlf';
|
||||||
|
migrateFiles({
|
||||||
|
rootPath,
|
||||||
|
translationFilePaths: [filePath],
|
||||||
|
logger,
|
||||||
|
mappingFilePath,
|
||||||
|
});
|
||||||
|
expect(readAndNormalize(fs.resolve(rootPath, filePath))).toEqual([
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" ?>`,
|
||||||
|
`<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en-GB">`,
|
||||||
|
` <file id="ngi18n" original="ng.template" xml:space="preserve">`,
|
||||||
|
` <unit id="9876543">`,
|
||||||
|
` <notes>`,
|
||||||
|
` <note category="location">test.js:1</note>`,
|
||||||
|
` </notes>`,
|
||||||
|
` <segment>`,
|
||||||
|
` <source>Hello</source>`,
|
||||||
|
` </segment>`,
|
||||||
|
` </unit>`,
|
||||||
|
` <unit id="custom-id">`,
|
||||||
|
` <notes>`,
|
||||||
|
` <note category="location">test.js:2</note>`,
|
||||||
|
` </notes>`,
|
||||||
|
` <segment>`,
|
||||||
|
` <source>Custom id message</source>`,
|
||||||
|
` </segment>`,
|
||||||
|
` </unit>`,
|
||||||
|
` <unit id="987654321098765">`,
|
||||||
|
` <notes>`,
|
||||||
|
` <note category="location">test.js:3</note>`,
|
||||||
|
` </notes>`,
|
||||||
|
` <segment>`,
|
||||||
|
` <source>Goodbye</source>`,
|
||||||
|
` </segment>`,
|
||||||
|
` </unit>`,
|
||||||
|
` </file>`,
|
||||||
|
`</xliff>`,
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Reads a path from the file system and normalizes the line endings. */
|
||||||
|
function readAndNormalize(path: AbsoluteFsPath): string {
|
||||||
|
return fs.readFile(path).replace(/\r?\n/g, '\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"1234567890123456789012345678901234567890": "987654321098765",
|
||||||
|
"12345678901234567890": "9876543"
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"@@locale": "en-GB",
|
||||||
|
"12345678901234567890": "Hello",
|
||||||
|
"@12345678901234567890": {
|
||||||
|
"x-locations": [
|
||||||
|
{
|
||||||
|
"file": "test.js",
|
||||||
|
"start": { "line": "1", "column": "0" },
|
||||||
|
"end": { "line": "1", "column": "0" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"custom-id": "Custom id message",
|
||||||
|
"@custom-id": {
|
||||||
|
"x-locations": [
|
||||||
|
{
|
||||||
|
"file": "test.js",
|
||||||
|
"start": { "line": "2", "column": "0" },
|
||||||
|
"end": { "line": "2", "column": "0" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"1234567890123456789012345678901234567890": "Goodbye",
|
||||||
|
"@1234567890123456789012345678901234567890": {
|
||||||
|
"x-locations": [
|
||||||
|
{
|
||||||
|
"file": "test.js",
|
||||||
|
"start": { "line": "3", "column": "0" },
|
||||||
|
"end": { "line": "3", "column": "0" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"locale": "en-GB",
|
||||||
|
"translations": {
|
||||||
|
"12345678901234567890": "Hello",
|
||||||
|
"custom-id": "Custom id message",
|
||||||
|
"1234567890123456789012345678901234567890": "Goodbye"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en-GB">
|
||||||
|
<file id="ngi18n" original="ng.template" xml:space="preserve">
|
||||||
|
<unit id="12345678901234567890">
|
||||||
|
<notes>
|
||||||
|
<note category="location">test.js:1</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>Hello</source>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="custom-id">
|
||||||
|
<notes>
|
||||||
|
<note category="location">test.js:2</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>Custom id message</source>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="1234567890123456789012345678901234567890">
|
||||||
|
<notes>
|
||||||
|
<note category="location">test.js:3</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>Goodbye</source>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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="12345678901234567890"><source>test.js:1</source>Hello</msg>
|
||||||
|
<msg id="custom-id"><source>test.js:2</source>Custom id message</msg>
|
||||||
|
<msg id="1234567890123456789012345678901234567890"><source>test.js:3</source>Goodbye</msg>
|
||||||
|
</messagebundle>
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* @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 {migrateFile} from '../../src/migrate/migrate';
|
||||||
|
|
||||||
|
describe('migrateFile', () => {
|
||||||
|
it('should migrate all of the legacy message IDs', () => {
|
||||||
|
const source = `
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="123hello-legacy" datatype="html">
|
||||||
|
<source>Hello</source>
|
||||||
|
<target>Bonjour</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<trans-unit id="456goodbye-legacy" datatype="html">
|
||||||
|
<source>Goodbye</source>
|
||||||
|
<target>Au revoir</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = migrateFile(source, {
|
||||||
|
'123hello-legacy': 'hello-migrated',
|
||||||
|
'456goodbye-legacy': 'goodbye-migrated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
|
||||||
|
expect(result).toContain('<trans-unit id="goodbye-migrated" datatype="html">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should migrate messages whose ID contains special regex characters', () => {
|
||||||
|
const source = `
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="123hello(.*legacy" datatype="html">
|
||||||
|
<source>Hello</source>
|
||||||
|
<target>Bonjour</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = migrateFile(source, {'123hello(.*legacy': 'hello-migrated'});
|
||||||
|
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not migrate messages that are not in the mapping', () => {
|
||||||
|
const source = `
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="123hello-legacy" datatype="html">
|
||||||
|
<source>Hello</source>
|
||||||
|
<target>Bonjour</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<trans-unit id="456goodbye" datatype="html">
|
||||||
|
<source>Goodbye</source>
|
||||||
|
<target>Au revoir</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = migrateFile(source, {'123hello-legacy': 'hello-migrated'});
|
||||||
|
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
|
||||||
|
expect(result).toContain('<trans-unit id="456goodbye" datatype="html">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the file if none of the mappings match', () => {
|
||||||
|
const source = `
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="123hello-legacy" datatype="html">
|
||||||
|
<source>Hello</source>
|
||||||
|
<target>Bonjour</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<trans-unit id="456goodbye-legacy" datatype="html">
|
||||||
|
<source>Goodbye</source>
|
||||||
|
<target>Au revoir</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = migrateFile(source, {
|
||||||
|
'does-not-match': 'migrated-does-not-match',
|
||||||
|
'also-does-not-match': 'migrated-also-does-not-match',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: it shouldn't be possible for the ID to be repeated multiple times, but
|
||||||
|
// this assertion is here to make sure that it behaves as expected if it does.
|
||||||
|
it('should migrate if an ID appears in more than one place', () => {
|
||||||
|
const source = `
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="fr-FR" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="123hello-legacy" datatype="html">
|
||||||
|
<source>Hello</source>
|
||||||
|
<target>Bonjour</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<made-up-tag datatype="html" belongs-to="123hello-legacy">
|
||||||
|
<source id="123hello-legacy">Hello</source>
|
||||||
|
<target target-id="123hello-legacy">Bonjour</target>
|
||||||
|
</mage-up-tag>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = migrateFile(source, {'123hello-legacy': 'hello-migrated'});
|
||||||
|
expect(result).toContain('<trans-unit id="hello-migrated" datatype="html">');
|
||||||
|
expect(result).toContain('<made-up-tag datatype="html" belongs-to="hello-migrated">');
|
||||||
|
expect(result).toContain('<source id="hello-migrated">');
|
||||||
|
expect(result).toContain('<target target-id="hello-migrated">Bonjour</target>');
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue