diff --git a/goldens/public-api/compiler-cli/compiler_options.d.ts b/goldens/public-api/compiler-cli/compiler_options.d.ts index e972a3e99c..2c3fb6bda2 100644 --- a/goldens/public-api/compiler-cli/compiler_options.d.ts +++ b/goldens/public-api/compiler-cli/compiler_options.d.ts @@ -7,6 +7,9 @@ export interface I18nOptions { enableI18nLegacyMessageIdFormat?: boolean; i18nInLocale?: string; i18nNormalizeLineEndingsInICUs?: boolean; + i18nOutFile?: string; + i18nOutFormat?: string; + i18nOutLocale?: string; i18nUseExternalIds?: boolean; } diff --git a/packages/compiler-cli/src/ngtsc/annotations/BUILD.bazel b/packages/compiler-cli/src/ngtsc/annotations/BUILD.bazel index b10a14584e..fed9544064 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/annotations/BUILD.bazel @@ -27,6 +27,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/typecheck/api", "//packages/compiler-cli/src/ngtsc/typecheck/diagnostics", "//packages/compiler-cli/src/ngtsc/util", + "//packages/compiler-cli/src/ngtsc/xi18n", "@npm//@types/node", "@npm//typescript", ], diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index c497ab6564..3ec40f3265 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -25,6 +25,7 @@ import {ComponentScopeReader, LocalModuleScopeRegistry, TypeCheckScopeRegistry} import {AnalysisOutput, CompileResult, DecoratorHandler, DetectResult, HandlerFlags, HandlerPrecedence, ResolveResult} from '../../transform'; import {TemplateSourceMapping, TypeCheckContext} from '../../typecheck/api'; import {SubsetOfKeys} from '../../util/src/typescript'; +import {Xi18nContext} from '../../xi18n'; import {ResourceLoader} from './api'; import {createValueHasWrongTypeError, getDirectiveDiagnostics, getProviderDiagnostics} from './diagnostics'; @@ -837,6 +838,13 @@ export class ComponentDecoratorHandler implements return {data}; } + xi18n(ctx: Xi18nContext, node: ClassDeclaration, analysis: Readonly): + void { + ctx.updateFromTemplate( + analysis.template.content, analysis.template.declaration.resolvedTemplateUrl, + analysis.template.interpolationConfig ?? DEFAULT_INTERPOLATION_CONFIG); + } + updateResources(node: ClassDeclaration, analysis: ComponentAnalysisData): void { const containingFile = node.getSourceFile().fileName; diff --git a/packages/compiler-cli/src/ngtsc/core/BUILD.bazel b/packages/compiler-cli/src/ngtsc/core/BUILD.bazel index 03f7b7aef0..04122d3613 100644 --- a/packages/compiler-cli/src/ngtsc/core/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/core/BUILD.bazel @@ -38,6 +38,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/typecheck/api", "//packages/compiler-cli/src/ngtsc/typecheck/diagnostics", "//packages/compiler-cli/src/ngtsc/util", + "//packages/compiler-cli/src/ngtsc/xi18n", "@npm//typescript", ], ) diff --git a/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts b/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts index 7af887196d..9a3d1cb2bd 100644 --- a/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts +++ b/packages/compiler-cli/src/ngtsc/core/api/src/public_options.ts @@ -309,6 +309,22 @@ export interface I18nOptions { */ i18nInLocale?: string; + /** + * Export format (xlf, xlf2 or xmb) when the xi18n operation is requested. + */ + i18nOutFormat?: string; + + /** + * Path to the extracted message file to emit when the xi18n operation is requested. + */ + i18nOutFile?: string; + + + /** + * Locale of the application (used when xi18n is requested). + */ + i18nOutLocale?: string; + /** * Render `$localize` messages with legacy format ids. * diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index f7c2c605a6..fa95ac48e9 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -31,6 +31,7 @@ import {aliasTransformFactory, CompilationMode, declarationTransformFactory, Dec import {TemplateTypeCheckerImpl} from '../../typecheck'; import {OptimizeFor, TemplateTypeChecker, TypeCheckingConfig} from '../../typecheck/api'; import {getSourceFileOrNull, isDtsPath, resolveModuleName, toUnredirectedSourceFile} from '../../util/src/typescript'; +import {Xi18nContext} from '../../xi18n'; import {LazyRoute, NgCompilerAdapter, NgCompilerOptions} from '../api'; import {compileUndecoratedClassesWithAngularFeatures} from './config'; @@ -674,6 +675,16 @@ export class NgCompiler { return generateAnalysis(context); } + /** + * Collect i18n messages into the `Xi18nContext`. + */ + xi18n(ctx: Xi18nContext): void { + // Note that the 'resolve' phase is not strictly necessary for xi18n, but this is not currently + // optimized. + const compilation = this.ensureAnalyzed(); + compilation.traitCompiler.xi18n(ctx); + } + private ensureAnalyzed(this: NgCompiler): LazyCompilationState { if (this.compilation === null) { this.analyzeSync(); diff --git a/packages/compiler-cli/src/ngtsc/program.ts b/packages/compiler-cli/src/ngtsc/program.ts index 8fab53d43a..2f967a3f9c 100644 --- a/packages/compiler-cli/src/ngtsc/program.ts +++ b/packages/compiler-cli/src/ngtsc/program.ts @@ -6,15 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import {GeneratedFile} from '@angular/compiler'; +import {GeneratedFile, HtmlParser, MessageBundle} from '@angular/compiler'; import * as ts from 'typescript'; import * as api from '../transformers/api'; +import {i18nExtract} from '../transformers/i18n'; import {verifySupportedTypeScriptVersion} from '../typescript_support'; import {CompilationTicket, freshCompilationTicket, incrementalFromCompilerTicket, NgCompiler, NgCompilerHost} from './core'; import {NgCompilerOptions} from './core/api'; -import {absoluteFrom, AbsoluteFsPath, getFileSystem} from './file_system'; +import {absoluteFrom, AbsoluteFsPath, getFileSystem, resolve} from './file_system'; import {TrackedIncrementalBuildStrategy} from './incremental'; import {IndexedComponent} from './indexer'; import {ActivePerfRecorder, PerfCheckpoint as PerfCheckpoint, PerfEvent, PerfPhase} from './perf'; @@ -23,8 +24,6 @@ import {DeclarationNode} from './reflection'; import {retagAllTsFiles, untagAllTsFiles} from './shims'; import {OptimizeFor} from './typecheck/api'; - - /** * Entrypoint to the Angular Compiler (Ivy+) which sits behind the `api.Program` interface, allowing * it to be a drop-in replacement for the legacy View Engine compiler to tooling such as the @@ -227,6 +226,14 @@ export class NgtscProgram implements api.Program { return this.compiler.listLazyRoutes(entryRoute); } + private emitXi18n(): void { + const ctx = new MessageBundle(new HtmlParser(), [], {}, this.options.i18nOutLocale ?? null); + this.compiler.xi18n(ctx); + i18nExtract( + this.options.i18nOutFormat ?? null, this.options.i18nOutFile ?? null, this.host, + this.options, ctx, resolve); + } + emit(opts?: { emitFlags?: api.EmitFlags|undefined; cancellationToken?: ts.CancellationToken | undefined; @@ -234,6 +241,23 @@ export class NgtscProgram implements api.Program { emitCallback?: api.TsEmitCallback | undefined; mergeEmitResultsCallback?: api.TsMergeEmitResultsCallback | undefined; }|undefined): ts.EmitResult { + // Check if emission of the i18n messages bundle was requested. + if (opts !== undefined && opts.emitFlags !== undefined && + opts.emitFlags & api.EmitFlags.I18nBundle) { + this.emitXi18n(); + + // `api.EmitFlags` is a View Engine compiler concept. We only pay attention to the absence of + // the other flags here if i18n emit was requested (since this is usually done in the xi18n + // flow, where we don't want to emit JS at all). + if (!(opts.emitFlags & api.EmitFlags.JS)) { + return { + diagnostics: [], + emitSkipped: true, + emittedFiles: [], + }; + } + } + this.compiler.perfRecorder.memory(PerfCheckpoint.PreEmit); const res = this.compiler.perfRecorder.inPhase(PerfPhase.TypeScriptEmit, () => { diff --git a/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel b/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel index f0d4e36d21..86d831269d 100644 --- a/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/transform/BUILD.bazel @@ -20,6 +20,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/translator", "//packages/compiler-cli/src/ngtsc/typecheck/api", "//packages/compiler-cli/src/ngtsc/util", + "//packages/compiler-cli/src/ngtsc/xi18n", "@npm//typescript", ], ) diff --git a/packages/compiler-cli/src/ngtsc/transform/src/api.ts b/packages/compiler-cli/src/ngtsc/transform/src/api.ts index 01054b5f71..4387a651c5 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/api.ts @@ -15,6 +15,7 @@ import {IndexingContext} from '../../indexer'; import {ClassDeclaration, Decorator} from '../../reflection'; import {ImportManager} from '../../translator'; import {TypeCheckContext} from '../../typecheck/api'; +import {Xi18nContext} from '../../xi18n'; /** * Specifies the compilation mode that is used for the compilation. @@ -176,6 +177,12 @@ export interface DecoratorHandler { */ resolve?(node: ClassDeclaration, analysis: Readonly, symbol: S): ResolveResult; + /** + * Extract i18n messages into the `Xi18nContext`, which is useful for generating various formats + * of message file outputs. + */ + xi18n?(bundle: Xi18nContext, node: ClassDeclaration, analysis: Readonly): void; + typeCheck? (ctx: TypeCheckContext, node: ClassDeclaration, analysis: Readonly, resolution: Readonly): void; diff --git a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts index a189f5d4a9..a5bf2798ae 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts @@ -17,6 +17,7 @@ import {PerfEvent, PerfRecorder} from '../../perf'; import {ClassDeclaration, DeclarationNode, Decorator, ReflectionHost} from '../../reflection'; import {ProgramTypeCheckAdapter, TypeCheckContext} from '../../typecheck/api'; import {getSourceFile, isExported} from '../../util/src/typescript'; +import {Xi18nContext} from '../../xi18n'; import {AnalysisOutput, CompilationMode, CompileResult, DecoratorHandler, HandlerFlags, HandlerPrecedence, ResolveResult} from './api'; import {DtsTransformRegistry} from './declaration'; @@ -494,6 +495,25 @@ export class TraitCompiler implements ProgramTypeCheckAdapter { } } + xi18n(bundle: Xi18nContext): void { + for (const clazz of this.classes.keys()) { + const record = this.classes.get(clazz)!; + for (const trait of record.traits) { + if (trait.state !== TraitState.Analyzed && trait.state !== TraitState.Resolved) { + // Skip traits that haven't been analyzed successfully. + continue; + } else if (trait.handler.xi18n === undefined) { + // Skip traits that don't support xi18n. + continue; + } + + if (trait.analysis !== null) { + trait.handler.xi18n(bundle, clazz, trait.analysis); + } + } + } + } + updateResources(clazz: DeclarationNode): void { if (!this.reflector.isClass(clazz) || !this.classes.has(clazz)) { return; diff --git a/packages/compiler-cli/src/ngtsc/xi18n/BUILD.bazel b/packages/compiler-cli/src/ngtsc/xi18n/BUILD.bazel new file mode 100644 index 0000000000..4097839c15 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/xi18n/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "xi18n", + srcs = glob([ + "*.ts", + "src/**/*.ts", + ]), + deps = [ + "//packages/compiler", + "@npm//typescript", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/xi18n/index.ts b/packages/compiler-cli/src/ngtsc/xi18n/index.ts new file mode 100644 index 0000000000..aebb55aca9 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/xi18n/index.ts @@ -0,0 +1,9 @@ +/** + * @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 * from './src/context'; diff --git a/packages/compiler-cli/src/ngtsc/xi18n/src/context.ts b/packages/compiler-cli/src/ngtsc/xi18n/src/context.ts new file mode 100644 index 0000000000..7a3f0a90af --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/xi18n/src/context.ts @@ -0,0 +1,27 @@ +/** + * @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 {InterpolationConfig} from '@angular/compiler'; + +/** + * Captures template information intended for extraction of i18n messages from a template. + * + * This interface is compatible with the View Engine compiler's `MessageBundle` class, which is used + * to implement xi18n for VE. Due to the dependency graph of ngtsc, an interface is needed as it + * can't depend directly on `MessageBundle`. + */ +export interface Xi18nContext { + /** + * Capture i18n messages from the template. + * + * In `MessageBundle` itself, this returns any `ParseError`s from the template. In this interface, + * the return type is declared as `void` for simplicity, since any parse errors would be reported + * as diagnostics anyway. + */ + updateFromTemplate(html: string, url: string, interpolationConfig: InterpolationConfig): void; +} diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index bb10b826c7..e9d39f4810 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -91,13 +91,6 @@ export interface CompilerOptions extends NgCompilerOptions, ts.CompilerOptions { // position. disableExpressionLowering?: boolean; - // Locale of the application - i18nOutLocale?: string; - // Export format (xlf, xlf2 or xmb) - i18nOutFormat?: string; - // Path to the extracted message file - i18nOutFile?: string; - // Import format if different from `i18nFormat` i18nInFormat?: string; // Path to the translation file diff --git a/packages/compiler-cli/test/ngtsc/env.ts b/packages/compiler-cli/test/ngtsc/env.ts index b456573d72..27db4c96a8 100644 --- a/packages/compiler-cli/test/ngtsc/env.ts +++ b/packages/compiler-cli/test/ngtsc/env.ts @@ -11,6 +11,7 @@ import * as api from '@angular/compiler-cli/src/transformers/api'; import * as ts from 'typescript'; import {createCompilerHost, createProgram} from '../../index'; +import {mainXi18n} from '../../src/extract_i18n'; import {main, mainDiagnosticsForTest, readNgcCommandLineAndConfiguration} from '../../src/main'; import {absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, relativeFrom} from '../../src/ngtsc/file_system'; import {Folder, MockFileSystem} from '../../src/ngtsc/file_system/testing'; @@ -275,6 +276,21 @@ export class NgtscTestEnvironment { const program = createProgram({rootNames, host, options}); return (program as NgtscProgram).getIndexedComponents(); } + + driveXi18n(format: string, outputFileName: string, locale: string|null = null): void { + const errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error); + const args = [ + ...this.commandLineArgs, + `--i18nFormat=${format}`, + `--outFile=${outputFileName}`, + ]; + if (locale !== null) { + args.push(`--locale=${locale}`); + } + const exitCode = mainXi18n(args, errorSpy); + expect(errorSpy).not.toHaveBeenCalled(); + expect(exitCode).toEqual(0); + } } class AugmentedCompilerHost extends NgtscTestCompilerHost { diff --git a/packages/compiler-cli/test/ngtsc/xi18n_spec.ts b/packages/compiler-cli/test/ngtsc/xi18n_spec.ts new file mode 100644 index 0000000000..2fa94355bf --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/xi18n_spec.ts @@ -0,0 +1,334 @@ +/** + * @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 {runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '@angular/compiler-cli/src/ngtsc/testing'; +import {platform} from 'os'; + +import {NgtscTestEnvironment} from './env'; + +const testFiles = loadStandardTestFiles({fakeCore: true, fakeCommon: true}); + +runInEachFileSystem(os => { + let env!: NgtscTestEnvironment; + + if (os === 'Windows' || platform() === 'win32') { + // xi18n tests are skipped on Windows as the paths in the expected message files are platform- + // sensitive. These tests will be deleted when xi18n is removed, so it's not a major priority + // to make them work with Windows. + return; + } + + describe('ngtsc xi18n', () => { + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + writeTestCode(env); + }); + + it('should extract xmb', () => { + env.driveXi18n('xmb', 'messages.xmb'); + expect(env.getContents('messages.xmb')).toEqual(EXPECTED_XMB); + }); + + it('should extract xlf', () => { + // Note that only in XLF mode do we pass a locale into the extraction. + env.driveXi18n('xlf', 'messages.xlf', 'fr'); + expect(env.getContents('messages.xlf')).toEqual(EXPECTED_XLIFF); + }); + + it('should extract xlf', () => { + env.driveXi18n('xlf2', 'messages.xliff2.xlf'); + expect(env.getContents('messages.xliff2.xlf')).toEqual(EXPECTED_XLIFF2); + }); + + it('should not emit js', () => { + env.driveXi18n('xlf2', 'messages.xliff2.xlf'); + env.assertDoesNotExist('src/module.js'); + }); + }); +}); + +const EXPECTED_XMB = ` + + + + + + + + + + + + + + + + + + + +]> + + src/basic.html:1src/comp2.ts:1src/basic.html:1translate me + src/basic.html:3,4src/comp2.ts:3,4src/comp2.ts:2,3src/basic.html:3,4 + Welcome + src/icu.html:1,3src/icu.html:5{VAR_PLURAL, plural, =1 {book} other {books} } + src/icu.html:4,6 + foo { count, plural, =1 {...} other {...}}{ count, plural, =1 {...} other {...}} + + src/placeholders.html:1,3Name: <b><b>{{ + name // i18n(ph="name") + }}{{ + name // i18n(ph="name") + }}</b></b> + +`; + +const EXPECTED_XLIFF = ` + + + + + translate me + + src/basic.html + 1 + + + src/comp2.ts + 1 + + + src/basic.html + 1 + + desc + meaning + + + + Welcome + + src/basic.html + 3 + + + src/comp2.ts + 3 + + + src/comp2.ts + 2 + + + src/basic.html + 3 + + + + {VAR_PLURAL, plural, =1 {book} other {books} } + + src/icu.html + 1 + + with ICU + + + + foo + + + src/icu.html + 4 + + with ICU and other things + + + {VAR_PLURAL, plural, =1 {book} other {books} } + + src/icu.html + 5 + + + + Name: + + src/placeholders.html + 1 + + with placeholders + + + + +`; + +const EXPECTED_XLIFF2 = ` + + + + + desc + meaning + src/basic.html:1 + src/comp2.ts:1 + src/basic.html:1 + + + translate me + + + + + src/basic.html:3,4 + src/comp2.ts:3,4 + src/comp2.ts:2,3 + src/basic.html:3,4 + + + + Welcome + + + + + with ICU + src/icu.html:1,3 + src/icu.html:5 + + + {VAR_PLURAL, plural, =1 {book} other {books} } + + + + + with ICU and other things + src/icu.html:4,6 + + + + foo + + + + + + with placeholders + src/placeholders.html:1,3 + + + Name: + + + + +`; + +/** + * Note: the indentation here is load-bearing. + */ +function writeTestCode(env: NgtscTestEnvironment): void { + const welcomeMessage = ` + + Welcome + `; + env.write('src/basic.html', `
+

${welcomeMessage}

`); + + env.write('src/comp1.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'basic', + templateUrl: './basic.html', + }) + export class BasicCmp1 {}`); + + env.write('src/comp2.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'basic2', + template: \`
+

${welcomeMessage}

\`, + }) + export class BasicCmp2 {} + @Component({ + selector: 'basic4', + template: \`

${welcomeMessage}

\`, + }) + export class BasicCmp4 {}`); + + env.write('src/comp3.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'basic3', + templateUrl: './basic.html', + }) + export class BasicCmp3 {}`); + + env.write('src/placeholders.html', `
Name: {{ + name // i18n(ph="name") + }}
`); + + env.write('src/placeholder_cmp.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'placeholders', + templateUrl: './placeholders.html', + }) + export class PlaceholderCmp { name = 'whatever'; }`); + + env.write('src/icu.html', `
{ + count, plural, =1 {book} other {books} + }
+
+ foo { count, plural, =1 {book} other {books} } +
`); + + env.write('src/icu_cmp.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'icu', + templateUrl: './icu.html', + }) + export class IcuCmp { count = 3; }`); + + env.write('src/module.ts', ` + import {NgModule} from '@angular/core'; + import {CommonModule} from '@angular/common'; + import {BasicCmp1} from './comp1'; + import {BasicCmp2, BasicCmp4} from './comp2'; + import {BasicCmp3} from './comp3'; + import {PlaceholderCmp} from './placeholder_cmp'; + import {IcuCmp} from './icu_cmp'; + + @NgModule({ + declarations: [ + BasicCmp1, + BasicCmp2, + BasicCmp3, + BasicCmp4, + PlaceholderCmp, + IcuCmp, + ], + imports: [CommonModule], + }) + export class I18nModule {} + `); +}