From bcbf3e41231cd1682e72851ae6a1dec6e481b94b Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 1 Oct 2019 14:58:49 +0100 Subject: [PATCH] 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 --- .../ngcc/src/analysis/decoration_analyzer.ts | 4 +- packages/compiler-cli/src/main.ts | 9 +++ .../src/ngtsc/annotations/src/component.ts | 8 ++- .../ngtsc/annotations/test/component_spec.ts | 4 +- packages/compiler-cli/src/ngtsc/program.ts | 4 +- packages/compiler-cli/src/transformers/api.ts | 13 +++++ .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 56 +++++++++++++++++++ packages/compiler/src/i18n/digest.ts | 26 +++++++-- packages/compiler/src/i18n/i18n_ast.ts | 4 +- .../compiler/src/render3/view/i18n/meta.ts | 22 ++++++-- .../compiler/src/render3/view/template.ts | 20 ++++++- 11 files changed, 148 insertions(+), 22 deletions(-) diff --git a/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts b/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts index 13bf8ba0e5..3996fa0711 100644 --- a/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts +++ b/packages/compiler-cli/ngcc/src/analysis/decoration_analyzer.ts @@ -76,8 +76,8 @@ export class DecorationAnalyzer { this.reflectionHost, this.evaluator, this.fullRegistry, this.fullMetaReader, this.scopeRegistry, this.scopeRegistry, this.isCore, this.resourceManager, this.rootDirs, /* defaultPreserveWhitespaces */ false, - /* i18nUseExternalIds */ true, this.moduleResolver, this.cycleAnalyzer, this.refEmitter, - NOOP_DEFAULT_IMPORT_RECORDER), + /* i18nUseExternalIds */ true, /* i18nLegacyMessageIdFormat */ '', this.moduleResolver, + this.cycleAnalyzer, this.refEmitter, NOOP_DEFAULT_IMPORT_RECORDER), new DirectiveDecoratorHandler( this.reflectionHost, this.evaluator, this.fullRegistry, NOOP_DEFAULT_IMPORT_RECORDER, this.isCore), diff --git a/packages/compiler-cli/src/main.ts b/packages/compiler-cli/src/main.ts index aad3add832..047b620738 100644 --- a/packages/compiler-cli/src/main.ts +++ b/packages/compiler-cli/src/main.ts @@ -32,6 +32,7 @@ export function main( if (configErrors.length) { return reportErrorsAndExit(configErrors, /*options*/ undefined, consoleError); } + warnForDeprecatedOptions(options); if (watch) { const result = watchMode(project, options, consoleError); return reportErrorsAndExit(result.firstCompileResult, options, consoleError); @@ -226,6 +227,14 @@ export function watchMode( }, 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 if (require.main === module) { const args = process.argv.slice(2); diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index 374390f6e5..a0205bd27d 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -52,8 +52,9 @@ export class ComponentDecoratorHandler implements private scopeReader: ComponentScopeReader, private scopeRegistry: LocalModuleScopeRegistry, private isCore: boolean, private resourceLoader: ResourceLoader, private rootDirs: string[], private defaultPreserveWhitespaces: boolean, private i18nUseExternalIds: boolean, - private moduleResolver: ModuleResolver, private cycleAnalyzer: CycleAnalyzer, - private refEmitter: ReferenceEmitter, private defaultImportRecorder: DefaultImportRecorder, + private i18nLegacyMessageIdFormat: string, private moduleResolver: ModuleResolver, + private cycleAnalyzer: CycleAnalyzer, private refEmitter: ReferenceEmitter, + private defaultImportRecorder: DefaultImportRecorder, private resourceDependencies: ResourceDependencyRecorder = new NoopResourceDependencyRecorder()) {} @@ -697,7 +698,8 @@ export class ComponentDecoratorHandler implements ...parseTemplate(templateStr, templateUrl, { preserveWhitespaces, interpolationConfig: interpolation, - range: templateRange, escapedString, ...options, + range: templateRange, escapedString, + i18nLegacyMessageIdFormat: this.i18nLegacyMessageIdFormat, ...options, }), template: templateStr, templateUrl, isInline: component.has('template'), diff --git a/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts index b1481d11ea..8156c77e71 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts @@ -61,8 +61,8 @@ runInEachFileSystem(() => { const handler = new ComponentDecoratorHandler( reflectionHost, evaluator, metaRegistry, metaReader, scopeRegistry, scopeRegistry, false, - new NoopResourceLoader(), [''], false, true, moduleResolver, cycleAnalyzer, refEmitter, - NOOP_DEFAULT_IMPORT_RECORDER); + new NoopResourceLoader(), [''], false, true, '', moduleResolver, cycleAnalyzer, + refEmitter, NOOP_DEFAULT_IMPORT_RECORDER); const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); const detected = handler.detect(TestCmp, reflectionHost.getDecoratorsOfDeclaration(TestCmp)); if (detected === undefined) { diff --git a/packages/compiler-cli/src/ngtsc/program.ts b/packages/compiler-cli/src/ngtsc/program.ts index 4c53cc12f2..19126f8b05 100644 --- a/packages/compiler-cli/src/ngtsc/program.ts +++ b/packages/compiler-cli/src/ngtsc/program.ts @@ -509,8 +509,8 @@ export class NgtscProgram implements api.Program { this.reflector, evaluator, metaRegistry, this.metaReader !, scopeReader, scopeRegistry, this.isCore, this.resourceManager, this.rootDirs, this.options.preserveWhitespaces || false, this.options.i18nUseExternalIds !== false, - this.moduleResolver, this.cycleAnalyzer, this.refEmitter, this.defaultImportTracker, - this.incrementalState), + this.options.i18nLegacyMessageIdFormat || '', this.moduleResolver, this.cycleAnalyzer, + this.refEmitter, this.defaultImportTracker, this.incrementalState), new DirectiveDecoratorHandler( this.reflector, evaluator, metaRegistry, this.defaultImportTracker, this.isCore), new InjectableDecoratorHandler( diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index 86b77464fb..4d30349b7c 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -161,6 +161,19 @@ export interface CompilerOptions extends ts.CompilerOptions { // (used by Closure Compiler's output of `goog.getMsg` for transition period) 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 // from Angular 6. preserveWhitespaces?: boolean; diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index f55217066d..b4b720ed14 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -2052,6 +2052,62 @@ runInEachFileSystem(os => { 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: '
Some text
' + }) + 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: '
Some text
' + }) + 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: '
Some text
' + }) + 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: '
Some text
' + }) + 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', () => { env.write(`test.ts`, ` import {Component} from '@angular/core'; diff --git a/packages/compiler/src/i18n/digest.ts b/packages/compiler/src/i18n/digest.ts index 6c735e2a20..eec617a81b 100644 --- a/packages/compiler/src/i18n/digest.ts +++ b/packages/compiler/src/i18n/digest.ts @@ -10,15 +10,31 @@ import {newArray, utf8Encode} from '../util'; import * as i18n from './i18n_ast'; +/** + * Return the message id or compute it using the XLIFF1 digest. + */ 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) { - return message.id; - } +/** + * Compute the message id using the XLIFF1 digest. + */ +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 parts = message.nodes.map(a => a.visit(visitor, null)); return computeMsgId(parts.join(''), message.meaning); diff --git a/packages/compiler/src/i18n/i18n_ast.ts b/packages/compiler/src/i18n/i18n_ast.ts index 03663ab030..395e2e8510 100644 --- a/packages/compiler/src/i18n/i18n_ast.ts +++ b/packages/compiler/src/i18n/i18n_ast.ts @@ -11,6 +11,8 @@ import {ParseSourceSpan} from '../parse_util'; export class Message { sources: MessageSpan[]; 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 @@ -18,7 +20,7 @@ export class Message { * @param placeholderToMessage maps placeholder names to messages (used for nested ICU messages) * @param meaning * @param description - * @param id + * @param customId */ constructor( public nodes: Node[], public placeholders: {[phName: string]: string}, diff --git a/packages/compiler/src/render3/view/i18n/meta.ts b/packages/compiler/src/render3/view/i18n/meta.ts index 52c84a72ac..46a873082b 100644 --- a/packages/compiler/src/render3/view/i18n/meta.ts +++ b/packages/compiler/src/render3/view/i18n/meta.ts @@ -6,12 +6,11 @@ * 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 {createI18nMessageFactory} from '../../../i18n/i18n_parser'; import * as html from '../../../ml_parser/ast'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../../../ml_parser/interpolation_config'; -import {ParseTreeResult} from '../../../ml_parser/parser'; import * as o from '../../../output/output_ast'; 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 = { id?: string, customId?: string, + legacyId?: string, description?: string, meaning?: string }; @@ -51,6 +51,19 @@ export class I18nMetaVisitor implements html.Visitor { // generate (or restore) message id if not specified in template 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; } @@ -130,6 +143,7 @@ export function metaFromI18nMessage(message: i18n.Message, id: string | null = n return { id: typeof id === 'string' ? id : message.id || '', customId: message.customId, + legacyId: message.legacyId, meaning: message.meaning || '', description: message.description || '' }; @@ -180,8 +194,8 @@ export function serializeI18nHead(meta: I18nMeta, messagePart: string): string { if (meta.meaning) { metaBlock = `${meta.meaning}|${metaBlock}`; } - if (meta.customId) { - metaBlock = `${metaBlock}@@${meta.customId}`; + if (meta.customId || meta.legacyId) { + metaBlock = `${metaBlock}@@${meta.customId || meta.legacyId}`; } if (metaBlock === '') { // There is no metaBlock, so we must ensure that any starting colon is escaped. diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 59ef9a94b5..4036f7bc32 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -1905,6 +1905,19 @@ export interface ParseTemplateOptions { * included in source-map segments. A common example is whitespace. */ 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( template: string, templateUrl: string, options: ParseTemplateOptions = {}): {errors?: ParseError[], nodes: t.Node[], styleUrls: string[], styles: string[]} { - const {interpolationConfig, preserveWhitespaces} = options; + const {interpolationConfig, preserveWhitespaces, i18nLegacyMessageIdFormat} = options; const bindingParser = makeBindingParser(interpolationConfig); const htmlParser = new HtmlParser(); const parseResult = htmlParser.parse( @@ -1934,8 +1947,9 @@ export function parseTemplate( // before we run whitespace removal process, because existing i18n // extraction process (ng xi18n) relies on a raw content to generate // message ids - rootNodes = - html.visitAll(new I18nMetaVisitor(interpolationConfig, !preserveWhitespaces), rootNodes); + rootNodes = html.visitAll( + new I18nMetaVisitor(interpolationConfig, !preserveWhitespaces, i18nLegacyMessageIdFormat), + rootNodes); if (!preserveWhitespaces) { rootNodes = html.visitAll(new WhitespaceVisitor(), rootNodes);