refactor(compiler-cli): support xi18n in ngtsc (#42485)

xi18n is the operation of extracting i18n messages from templates in the
compilation. Previously, only View Engine was able to perform xi18n. This
commit implements xi18n in the Ivy compiler, and a copy of the View Engine
test for Ivy verifies that the results are identical.

PR Close #42485
This commit is contained in:
Alex Rickabaugh 2021-06-04 15:52:16 -07:00 committed by Dylan Hunn
parent 4538bd6c96
commit e83d7cb2d3
16 changed files with 497 additions and 11 deletions

View File

@ -7,6 +7,9 @@ export interface I18nOptions {
enableI18nLegacyMessageIdFormat?: boolean;
i18nInLocale?: string;
i18nNormalizeLineEndingsInICUs?: boolean;
i18nOutFile?: string;
i18nOutFormat?: string;
i18nOutLocale?: string;
i18nUseExternalIds?: boolean;
}

View File

@ -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",
],

View File

@ -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<ComponentAnalysisData>):
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;

View File

@ -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",
],
)

View File

@ -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.
*

View File

@ -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();

View File

@ -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, () => {

View File

@ -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",
],
)

View File

@ -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<D, A, S extends SemanticSymbol|null, R> {
*/
resolve?(node: ClassDeclaration, analysis: Readonly<A>, symbol: S): ResolveResult<R>;
/**
* Extract i18n messages into the `Xi18nContext`, which is useful for generating various formats
* of message file outputs.
*/
xi18n?(bundle: Xi18nContext, node: ClassDeclaration, analysis: Readonly<A>): void;
typeCheck?
(ctx: TypeCheckContext, node: ClassDeclaration, analysis: Readonly<A>,
resolution: Readonly<R>): void;

View File

@ -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;

View File

@ -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",
],
)

View File

@ -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';

View File

@ -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;
}

View File

@ -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

View File

@ -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 {

View File

@ -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 = `<?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="8136548302122759730" desc="desc" meaning="meaning"><source>src/basic.html:1</source><source>src/comp2.ts:1</source><source>src/basic.html:1</source>translate me</msg>
<msg id="9038505069473852515"><source>src/basic.html:3,4</source><source>src/comp2.ts:3,4</source><source>src/comp2.ts:2,3</source><source>src/basic.html:3,4</source>
Welcome</msg>
<msg id="5611534349548281834" desc="with ICU"><source>src/icu.html:1,3</source><source>src/icu.html:5</source>{VAR_PLURAL, plural, =1 {book} other {books} }</msg>
<msg id="5811701742971715242" desc="with ICU and other things"><source>src/icu.html:4,6</source>
foo <ph name="ICU"><ex>{ count, plural, =1 {...} other {...}}</ex>{ count, plural, =1 {...} other {...}}</ph>
</msg>
<msg id="7254052530614200029" desc="with placeholders"><source>src/placeholders.html:1,3</source>Name: <ph name="START_BOLD_TEXT"><ex>&lt;b&gt;</ex>&lt;b&gt;</ph><ph name="NAME"><ex>{{
name // i18n(ph=&quot;name&quot;)
}}</ex>{{
name // i18n(ph=&quot;name&quot;)
}}</ph><ph name="CLOSE_BOLD_TEXT"><ex>&lt;/b&gt;</ex>&lt;/b&gt;</ph></msg>
</messagebundle>
`;
const EXPECTED_XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="76e1eccb1b772fa9f294ef9c146ea6d0efa8a2d4" datatype="html">
<source>translate me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/basic.html</context>
<context context-type="linenumber">1</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/comp2.ts</context>
<context context-type="linenumber">1</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/basic.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">desc</note>
<note priority="1" from="meaning">meaning</note>
</trans-unit>
<trans-unit id="085a5ecc40cc87451d216725b2befd50866de18a" datatype="html">
<source>
Welcome</source>
<context-group purpose="location">
<context context-type="sourcefile">src/basic.html</context>
<context context-type="linenumber">3</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/comp2.ts</context>
<context context-type="linenumber">3</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/comp2.ts</context>
<context context-type="linenumber">2</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/basic.html</context>
<context context-type="linenumber">3</context>
</context-group>
</trans-unit>
<trans-unit id="83937c05b1216e7f4c02a85454260e28fd72d1e3" datatype="html">
<source>{VAR_PLURAL, plural, =1 {book} other {books} }</source>
<context-group purpose="location">
<context context-type="sourcefile">src/icu.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">with ICU</note>
</trans-unit>
<trans-unit id="540c5f481129419ef21017f396b6c2d0869ca4d2" datatype="html">
<source>
foo <x id="ICU" equiv-text="{ count, plural, =1 {...} other {...}}"/>
</source>
<context-group purpose="location">
<context context-type="sourcefile">src/icu.html</context>
<context context-type="linenumber">4</context>
</context-group>
<note priority="1" from="description">with ICU and other things</note>
</trans-unit>
<trans-unit id="ca7678090fddd04441d63b1218177af65f23342d" datatype="html">
<source>{VAR_PLURAL, plural, =1 {book} other {books} }</source>
<context-group purpose="location">
<context context-type="sourcefile">src/icu.html</context>
<context context-type="linenumber">5</context>
</context-group>
</trans-unit>
<trans-unit id="9311399c1ca7c75f771d77acb129e50581c6ec1f" datatype="html">
<source>Name: <x id="START_BOLD_TEXT" ctype="x-b" equiv-text="&lt;b&gt;"/><x id="NAME" equiv-text="{{
name // i18n(ph=&quot;name&quot;)
}}"/><x id="CLOSE_BOLD_TEXT" ctype="x-b" equiv-text="&lt;/b&gt;"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/placeholders.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">with placeholders</note>
</trans-unit>
</body>
</file>
</xliff>
`;
const EXPECTED_XLIFF2 = `<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en">
<file original="ng.template" id="ngi18n">
<unit id="8136548302122759730">
<notes>
<note category="description">desc</note>
<note category="meaning">meaning</note>
<note category="location">src/basic.html:1</note>
<note category="location">src/comp2.ts:1</note>
<note category="location">src/basic.html:1</note>
</notes>
<segment>
<source>translate me</source>
</segment>
</unit>
<unit id="9038505069473852515">
<notes>
<note category="location">src/basic.html:3,4</note>
<note category="location">src/comp2.ts:3,4</note>
<note category="location">src/comp2.ts:2,3</note>
<note category="location">src/basic.html:3,4</note>
</notes>
<segment>
<source>
Welcome</source>
</segment>
</unit>
<unit id="5611534349548281834">
<notes>
<note category="description">with ICU</note>
<note category="location">src/icu.html:1,3</note>
<note category="location">src/icu.html:5</note>
</notes>
<segment>
<source>{VAR_PLURAL, plural, =1 {book} other {books} }</source>
</segment>
</unit>
<unit id="5811701742971715242">
<notes>
<note category="description">with ICU and other things</note>
<note category="location">src/icu.html:4,6</note>
</notes>
<segment>
<source>
foo <ph id="0" equiv="ICU" disp="{ count, plural, =1 {...} other {...}}"/>
</source>
</segment>
</unit>
<unit id="7254052530614200029">
<notes>
<note category="description">with placeholders</note>
<note category="location">src/placeholders.html:1,3</note>
</notes>
<segment>
<source>Name: <pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt" dispStart="&lt;b&gt;" dispEnd="&lt;/b&gt;"><ph id="1" equiv="NAME" disp="{{
name // i18n(ph=&quot;name&quot;)
}}"/></pc></source>
</segment>
</unit>
</file>
</xliff>
`;
/**
* Note: the indentation here is load-bearing.
*/
function writeTestCode(env: NgtscTestEnvironment): void {
const welcomeMessage = `
<!--i18n-->
Welcome<!--/i18n-->
`;
env.write('src/basic.html', `<div title="translate me" i18n-title="meaning|desc"></div>
<p id="welcomeMessage">${welcomeMessage}</p>`);
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: \`<div title="translate me" i18n-title="meaning|desc"></div>
<p id="welcomeMessage">${welcomeMessage}</p>\`,
})
export class BasicCmp2 {}
@Component({
selector: 'basic4',
template: \`<p id="welcomeMessage">${welcomeMessage}</p>\`,
})
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', `<div i18n="with placeholders">Name: <b>{{
name // i18n(ph="name")
}}</b></div>`);
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', `<div i18n="with ICU">{
count, plural, =1 {book} other {books}
}</div>
<div i18n="with ICU and other things">
foo { count, plural, =1 {book} other {books} }
</div>`);
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 {}
`);
}