feat(ivy): i18n - render legacy message ids in $localize if requested (#32937)

The `$localize` library uses a new message digest function for
computing message ids. This means that translations in legacy
translation files will no longer match the message ids in the code
and so will not be translated.

This commit adds the ability to specify the format of your legacy
translation files, so that the appropriate message id can be rendered
in the `$localize` tagged strings. This results in larger code size
and requires that all translations are in the legacy format.

Going forward the developer should migrate their translation files
to use the new message id format.

PR Close #32937
This commit is contained in:
Pete Bacon Darwin 2019-10-01 14:58:49 +01:00 committed by atscott
parent fc28b266cd
commit bcbf3e4123
11 changed files with 148 additions and 22 deletions

View File

@ -76,8 +76,8 @@ export class DecorationAnalyzer {
this.reflectionHost, this.evaluator, this.fullRegistry, this.fullMetaReader, this.reflectionHost, this.evaluator, this.fullRegistry, this.fullMetaReader,
this.scopeRegistry, this.scopeRegistry, this.isCore, this.resourceManager, this.rootDirs, this.scopeRegistry, this.scopeRegistry, this.isCore, this.resourceManager, this.rootDirs,
/* defaultPreserveWhitespaces */ false, /* defaultPreserveWhitespaces */ false,
/* i18nUseExternalIds */ true, this.moduleResolver, this.cycleAnalyzer, this.refEmitter, /* i18nUseExternalIds */ true, /* i18nLegacyMessageIdFormat */ '', this.moduleResolver,
NOOP_DEFAULT_IMPORT_RECORDER), this.cycleAnalyzer, this.refEmitter, NOOP_DEFAULT_IMPORT_RECORDER),
new DirectiveDecoratorHandler( new DirectiveDecoratorHandler(
this.reflectionHost, this.evaluator, this.fullRegistry, NOOP_DEFAULT_IMPORT_RECORDER, this.reflectionHost, this.evaluator, this.fullRegistry, NOOP_DEFAULT_IMPORT_RECORDER,
this.isCore), this.isCore),

View File

@ -32,6 +32,7 @@ export function main(
if (configErrors.length) { if (configErrors.length) {
return reportErrorsAndExit(configErrors, /*options*/ undefined, consoleError); return reportErrorsAndExit(configErrors, /*options*/ undefined, consoleError);
} }
warnForDeprecatedOptions(options);
if (watch) { if (watch) {
const result = watchMode(project, options, consoleError); const result = watchMode(project, options, consoleError);
return reportErrorsAndExit(result.firstCompileResult, options, consoleError); return reportErrorsAndExit(result.firstCompileResult, options, consoleError);
@ -226,6 +227,14 @@ export function watchMode(
}, options, options => createEmitCallback(options))); }, options, options => createEmitCallback(options)));
} }
function warnForDeprecatedOptions(options: api.CompilerOptions) {
if (options.i18nLegacyMessageIdFormat !== undefined) {
console.warn(
'The `i18nLegacyMessageIdFormat` option is deprecated.\n' +
'Migrate your legacy translation files to the new `$localize` message id format and remove this option.');
}
}
// CLI entry point // CLI entry point
if (require.main === module) { if (require.main === module) {
const args = process.argv.slice(2); const args = process.argv.slice(2);

View File

@ -52,8 +52,9 @@ export class ComponentDecoratorHandler implements
private scopeReader: ComponentScopeReader, private scopeRegistry: LocalModuleScopeRegistry, private scopeReader: ComponentScopeReader, private scopeRegistry: LocalModuleScopeRegistry,
private isCore: boolean, private resourceLoader: ResourceLoader, private rootDirs: string[], private isCore: boolean, private resourceLoader: ResourceLoader, private rootDirs: string[],
private defaultPreserveWhitespaces: boolean, private i18nUseExternalIds: boolean, private defaultPreserveWhitespaces: boolean, private i18nUseExternalIds: boolean,
private moduleResolver: ModuleResolver, private cycleAnalyzer: CycleAnalyzer, private i18nLegacyMessageIdFormat: string, private moduleResolver: ModuleResolver,
private refEmitter: ReferenceEmitter, private defaultImportRecorder: DefaultImportRecorder, private cycleAnalyzer: CycleAnalyzer, private refEmitter: ReferenceEmitter,
private defaultImportRecorder: DefaultImportRecorder,
private resourceDependencies: private resourceDependencies:
ResourceDependencyRecorder = new NoopResourceDependencyRecorder()) {} ResourceDependencyRecorder = new NoopResourceDependencyRecorder()) {}
@ -697,7 +698,8 @@ export class ComponentDecoratorHandler implements
...parseTemplate(templateStr, templateUrl, { ...parseTemplate(templateStr, templateUrl, {
preserveWhitespaces, preserveWhitespaces,
interpolationConfig: interpolation, interpolationConfig: interpolation,
range: templateRange, escapedString, ...options, range: templateRange, escapedString,
i18nLegacyMessageIdFormat: this.i18nLegacyMessageIdFormat, ...options,
}), }),
template: templateStr, templateUrl, template: templateStr, templateUrl,
isInline: component.has('template'), isInline: component.has('template'),

View File

@ -61,8 +61,8 @@ runInEachFileSystem(() => {
const handler = new ComponentDecoratorHandler( const handler = new ComponentDecoratorHandler(
reflectionHost, evaluator, metaRegistry, metaReader, scopeRegistry, scopeRegistry, false, reflectionHost, evaluator, metaRegistry, metaReader, scopeRegistry, scopeRegistry, false,
new NoopResourceLoader(), [''], false, true, moduleResolver, cycleAnalyzer, refEmitter, new NoopResourceLoader(), [''], false, true, '', moduleResolver, cycleAnalyzer,
NOOP_DEFAULT_IMPORT_RECORDER); refEmitter, NOOP_DEFAULT_IMPORT_RECORDER);
const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration);
const detected = handler.detect(TestCmp, reflectionHost.getDecoratorsOfDeclaration(TestCmp)); const detected = handler.detect(TestCmp, reflectionHost.getDecoratorsOfDeclaration(TestCmp));
if (detected === undefined) { if (detected === undefined) {

View File

@ -509,8 +509,8 @@ export class NgtscProgram implements api.Program {
this.reflector, evaluator, metaRegistry, this.metaReader !, scopeReader, scopeRegistry, this.reflector, evaluator, metaRegistry, this.metaReader !, scopeReader, scopeRegistry,
this.isCore, this.resourceManager, this.rootDirs, this.isCore, this.resourceManager, this.rootDirs,
this.options.preserveWhitespaces || false, this.options.i18nUseExternalIds !== false, this.options.preserveWhitespaces || false, this.options.i18nUseExternalIds !== false,
this.moduleResolver, this.cycleAnalyzer, this.refEmitter, this.defaultImportTracker, this.options.i18nLegacyMessageIdFormat || '', this.moduleResolver, this.cycleAnalyzer,
this.incrementalState), this.refEmitter, this.defaultImportTracker, this.incrementalState),
new DirectiveDecoratorHandler( new DirectiveDecoratorHandler(
this.reflector, evaluator, metaRegistry, this.defaultImportTracker, this.isCore), this.reflector, evaluator, metaRegistry, this.defaultImportTracker, this.isCore),
new InjectableDecoratorHandler( new InjectableDecoratorHandler(

View File

@ -161,6 +161,19 @@ export interface CompilerOptions extends ts.CompilerOptions {
// (used by Closure Compiler's output of `goog.getMsg` for transition period) // (used by Closure Compiler's output of `goog.getMsg` for transition period)
i18nUseExternalIds?: boolean; i18nUseExternalIds?: boolean;
/**
* Render `$localize` message ids with the specified legacy format (xlf, xlf2 or xmb).
*
* Use this option when use are using the `$localize` based localization messages but
* have not migrated the translation files to use the new `$localize` message id format.
*
* @deprecated
* `i18nLegacyMessageIdFormat` should only be used while migrating from legacy message id
* formatted translation files and will be removed at the same time as ViewEngine support is
* removed.
*/
i18nLegacyMessageIdFormat?: string;
// Whether to remove blank text nodes from compiled templates. It is `false` by default starting // Whether to remove blank text nodes from compiled templates. It is `false` by default starting
// from Angular 6. // from Angular 6.
preserveWhitespaces?: boolean; preserveWhitespaces?: boolean;

View File

@ -2052,6 +2052,62 @@ runInEachFileSystem(os => {
expect(jsContents).not.toContain('MSG_EXTERNAL_'); expect(jsContents).not.toContain('MSG_EXTERNAL_');
}); });
it('should render legacy id when i18nLegacyMessageIdFormat config is set to xlf', () => {
env.tsconfig({i18nLegacyMessageIdFormat: 'xlf'});
env.write(`test.ts`, `
import {Component} from '@angular/core';
@Component({
selector: 'test',
template: '<div i18n>Some text</div>'
})
class FooCmp {}`);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).toContain(':@@5dbba0a3da8dff890e20cf76eb075d58900fbcd3:Some text');
});
it('should render legacy id when i18nLegacyMessageIdFormat config is set to xlf2', () => {
env.tsconfig({i18nLegacyMessageIdFormat: 'xlf2'});
env.write(`test.ts`, `
import {Component} from '@angular/core';
@Component({
selector: 'test',
template: '<div i18n>Some text</div>'
})
class FooCmp {}`);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).toContain(':@@8321000940098097247:Some text');
});
it('should render legacy id when i18nLegacyMessageIdFormat config is set to xmb', () => {
env.tsconfig({i18nLegacyMessageIdFormat: 'xmb'});
env.write(`test.ts`, `
import {Component} from '@angular/core';
@Component({
selector: 'test',
template: '<div i18n>Some text</div>'
})
class FooCmp {}`);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).toContain(':@@8321000940098097247:Some text');
});
it('should render custom id even if i18nLegacyMessageIdFormat config is set', () => {
env.tsconfig({i18nLegacyMessageIdFormat: 'xlf'});
env.write(`test.ts`, `
import {Component} from '@angular/core';
@Component({
selector: 'test',
template: '<div i18n="@@custom">Some text</div>'
})
class FooCmp {}`);
env.driveMain();
const jsContents = env.getContents('test.js');
expect(jsContents).toContain(':@@custom:Some text');
});
it('@Component\'s `interpolation` should override default interpolation config', () => { it('@Component\'s `interpolation` should override default interpolation config', () => {
env.write(`test.ts`, ` env.write(`test.ts`, `
import {Component} from '@angular/core'; import {Component} from '@angular/core';

View File

@ -10,15 +10,31 @@ import {newArray, utf8Encode} from '../util';
import * as i18n from './i18n_ast'; import * as i18n from './i18n_ast';
/**
* Return the message id or compute it using the XLIFF1 digest.
*/
export function digest(message: i18n.Message): string { export function digest(message: i18n.Message): string {
return message.id || sha1(serializeNodes(message.nodes).join('') + `[${message.meaning}]`); return message.id || computeDigest(message);
} }
export function decimalDigest(message: i18n.Message): string { /**
if (message.id) { * Compute the message id using the XLIFF1 digest.
return message.id; */
} export function computeDigest(message: i18n.Message): string {
return sha1(serializeNodes(message.nodes).join('') + `[${message.meaning}]`);
}
/**
* Return the message id or compute it using the XLIFF2/XMB/$localize digest.
*/
export function decimalDigest(message: i18n.Message): string {
return message.id || computeDecimalDigest(message);
}
/**
* Compute the message id using the XLIFF2/XMB/$localize digest.
*/
export function computeDecimalDigest(message: i18n.Message): string {
const visitor = new _SerializerIgnoreIcuExpVisitor(); const visitor = new _SerializerIgnoreIcuExpVisitor();
const parts = message.nodes.map(a => a.visit(visitor, null)); const parts = message.nodes.map(a => a.visit(visitor, null));
return computeMsgId(parts.join(''), message.meaning); return computeMsgId(parts.join(''), message.meaning);

View File

@ -11,6 +11,8 @@ import {ParseSourceSpan} from '../parse_util';
export class Message { export class Message {
sources: MessageSpan[]; sources: MessageSpan[];
id: string = this.customId; id: string = this.customId;
/** The id to use if there is no custom id and if `i18nLegacyMessageIdFormat` is true */
legacyId?: string = '';
/** /**
* @param nodes message AST * @param nodes message AST
@ -18,7 +20,7 @@ export class Message {
* @param placeholderToMessage maps placeholder names to messages (used for nested ICU messages) * @param placeholderToMessage maps placeholder names to messages (used for nested ICU messages)
* @param meaning * @param meaning
* @param description * @param description
* @param id * @param customId
*/ */
constructor( constructor(
public nodes: Node[], public placeholders: {[phName: string]: string}, public nodes: Node[], public placeholders: {[phName: string]: string},

View File

@ -6,12 +6,11 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {decimalDigest} from '../../../i18n/digest'; import {computeDecimalDigest, computeDigest, decimalDigest} from '../../../i18n/digest';
import * as i18n from '../../../i18n/i18n_ast'; import * as i18n from '../../../i18n/i18n_ast';
import {createI18nMessageFactory} from '../../../i18n/i18n_parser'; import {createI18nMessageFactory} from '../../../i18n/i18n_parser';
import * as html from '../../../ml_parser/ast'; import * as html from '../../../ml_parser/ast';
import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../../../ml_parser/interpolation_config'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../../../ml_parser/interpolation_config';
import {ParseTreeResult} from '../../../ml_parser/parser';
import * as o from '../../../output/output_ast'; import * as o from '../../../output/output_ast';
import {I18N_ATTR, I18N_ATTR_PREFIX, hasI18nAttrs, icuFromI18nMessage} from './util'; import {I18N_ATTR, I18N_ATTR_PREFIX, hasI18nAttrs, icuFromI18nMessage} from './util';
@ -19,6 +18,7 @@ import {I18N_ATTR, I18N_ATTR_PREFIX, hasI18nAttrs, icuFromI18nMessage} from './u
export type I18nMeta = { export type I18nMeta = {
id?: string, id?: string,
customId?: string, customId?: string,
legacyId?: string,
description?: string, description?: string,
meaning?: string meaning?: string
}; };
@ -51,6 +51,19 @@ export class I18nMetaVisitor implements html.Visitor {
// generate (or restore) message id if not specified in template // generate (or restore) message id if not specified in template
message.id = typeof meta !== 'string' && (meta as i18n.Message).id || decimalDigest(message); message.id = typeof meta !== 'string' && (meta as i18n.Message).id || decimalDigest(message);
} }
if (this.i18nLegacyMessageIdFormat === 'xlf') {
message.legacyId = computeDigest(message);
} else if (
this.i18nLegacyMessageIdFormat === 'xlf2' || this.i18nLegacyMessageIdFormat === 'xmb') {
message.legacyId = computeDecimalDigest(message);
} else if (typeof meta !== 'string') {
// This occurs if we are doing the 2nd pass after whitespace removal
// In that case we want to reuse the legacy message generated in the 1st pass
// See `parseTemplate()` in `packages/compiler/src/render3/view/template.ts`
message.legacyId = (meta as i18n.Message).legacyId;
}
return message; return message;
} }
@ -130,6 +143,7 @@ export function metaFromI18nMessage(message: i18n.Message, id: string | null = n
return { return {
id: typeof id === 'string' ? id : message.id || '', id: typeof id === 'string' ? id : message.id || '',
customId: message.customId, customId: message.customId,
legacyId: message.legacyId,
meaning: message.meaning || '', meaning: message.meaning || '',
description: message.description || '' description: message.description || ''
}; };
@ -180,8 +194,8 @@ export function serializeI18nHead(meta: I18nMeta, messagePart: string): string {
if (meta.meaning) { if (meta.meaning) {
metaBlock = `${meta.meaning}|${metaBlock}`; metaBlock = `${meta.meaning}|${metaBlock}`;
} }
if (meta.customId) { if (meta.customId || meta.legacyId) {
metaBlock = `${metaBlock}@@${meta.customId}`; metaBlock = `${metaBlock}@@${meta.customId || meta.legacyId}`;
} }
if (metaBlock === '') { if (metaBlock === '') {
// There is no metaBlock, so we must ensure that any starting colon is escaped. // There is no metaBlock, so we must ensure that any starting colon is escaped.

View File

@ -1905,6 +1905,19 @@ export interface ParseTemplateOptions {
* included in source-map segments. A common example is whitespace. * included in source-map segments. A common example is whitespace.
*/ */
leadingTriviaChars?: string[]; leadingTriviaChars?: string[];
/**
* Render `$localize` message ids with the specified legacy format (xlf, xlf2 or xmb).
*
* Use this option when use are using the `$localize` based localization messages but
* have not migrated the translation files to use the new `$localize` message id format.
*
* @deprecated
* `i18nLegacyMessageIdFormat` should only be used while migrating from legacy message id
* formatted translation files and will be removed at the same time as ViewEngine support is
* removed.
*/
i18nLegacyMessageIdFormat?: string;
} }
/** /**
@ -1917,7 +1930,7 @@ export interface ParseTemplateOptions {
export function parseTemplate( export function parseTemplate(
template: string, templateUrl: string, options: ParseTemplateOptions = {}): template: string, templateUrl: string, options: ParseTemplateOptions = {}):
{errors?: ParseError[], nodes: t.Node[], styleUrls: string[], styles: string[]} { {errors?: ParseError[], nodes: t.Node[], styleUrls: string[], styles: string[]} {
const {interpolationConfig, preserveWhitespaces} = options; const {interpolationConfig, preserveWhitespaces, i18nLegacyMessageIdFormat} = options;
const bindingParser = makeBindingParser(interpolationConfig); const bindingParser = makeBindingParser(interpolationConfig);
const htmlParser = new HtmlParser(); const htmlParser = new HtmlParser();
const parseResult = htmlParser.parse( const parseResult = htmlParser.parse(
@ -1934,8 +1947,9 @@ export function parseTemplate(
// before we run whitespace removal process, because existing i18n // before we run whitespace removal process, because existing i18n
// extraction process (ng xi18n) relies on a raw content to generate // extraction process (ng xi18n) relies on a raw content to generate
// message ids // message ids
rootNodes = rootNodes = html.visitAll(
html.visitAll(new I18nMetaVisitor(interpolationConfig, !preserveWhitespaces), rootNodes); new I18nMetaVisitor(interpolationConfig, !preserveWhitespaces, i18nLegacyMessageIdFormat),
rootNodes);
if (!preserveWhitespaces) { if (!preserveWhitespaces) {
rootNodes = html.visitAll(new WhitespaceVisitor(), rootNodes); rootNodes = html.visitAll(new WhitespaceVisitor(), rootNodes);