Skip to content

Commit a5293fe

Browse files
clydinalan-agius4
authored andcommitted
feat(@angular-devkit/build-angular): support i18n message extraction with Ivy
This change adds support for extracting i18n translation messages with an Ivy enabled application. This is accomplished by using the new extraction capabilities present in the `@angular/localize` package and will require version 10.1 or later of the package. Since this change uses an new extraction method, it currently must be enabled during extraction by using the `--ivy` flag. The flag is a precaution to prevent unintentional breakage for existing applications but will become the default behavior for all Ivy enabled applications in a future release. Closes #18275
1 parent b12de7a commit a5293fe

File tree

6 files changed

+298
-24
lines changed

6 files changed

+298
-24
lines changed

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
},
7676
"peerDependencies": {
7777
"@angular/compiler-cli": ">=10.1.0-next.0 < 11",
78+
"@angular/localize": ">=10.1.0-next.0 < 11",
7879
"ng-packagr": "^10.0.0",
7980
"typescript": ">=3.9 < 3.10"
8081
},

packages/angular_devkit/build_angular/src/extract-i18n/index.ts

Lines changed: 133 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
} from '@angular-devkit/architect';
1313
import { BuildResult, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack';
1414
import { JsonObject } from '@angular-devkit/core';
15+
import type { ɵParsedMessage as LocalizeMessage } from '@angular/localize';
16+
import * as fs from 'fs';
1517
import * as path from 'path';
18+
import { gte as semverGte } from 'semver';
1619
import * as webpack from 'webpack';
1720
import {
1821
getAotConfig,
@@ -44,6 +47,32 @@ function getI18nOutfile(format: string | undefined) {
4447
}
4548
}
4649

50+
async function getSerializer(format: Format, sourceLocale: string, basePath: string, useLegacyIds = true) {
51+
switch (format) {
52+
case Format.Xmb:
53+
const { XmbTranslationSerializer } =
54+
await import('@angular/localize/src/tools/src/extract/translation_files/xmb_translation_serializer');
55+
56+
// tslint:disable-next-line: no-any
57+
return new XmbTranslationSerializer(basePath as any, useLegacyIds);
58+
case Format.Xlf:
59+
case Format.Xlif:
60+
case Format.Xliff:
61+
const { Xliff1TranslationSerializer } =
62+
await import('@angular/localize/src/tools/src/extract/translation_files/xliff1_translation_serializer');
63+
64+
// tslint:disable-next-line: no-any
65+
return new Xliff1TranslationSerializer(sourceLocale, basePath as any, useLegacyIds);
66+
case Format.Xlf2:
67+
case Format.Xliff2:
68+
const { Xliff2TranslationSerializer } =
69+
await import('@angular/localize/src/tools/src/extract/translation_files/xliff2_translation_serializer');
70+
71+
// tslint:disable-next-line: no-any
72+
return new Xliff2TranslationSerializer(sourceLocale, basePath as any, useLegacyIds);
73+
}
74+
}
75+
4776
class InMemoryOutputPlugin {
4877
apply(compiler: webpack.Compiler): void {
4978
// tslint:disable-next-line:no-any
@@ -78,6 +107,9 @@ export async function execute(
78107
case Format.Xliff2:
79108
options.format = Format.Xlf2;
80109
break;
110+
case undefined:
111+
options.format = Format.Xlf;
112+
break;
81113
}
82114

83115
// We need to determine the outFile name so that AngularCompiler can retrieve it.
@@ -87,22 +119,25 @@ export async function execute(
87119
outFile = path.join(options.outputPath, outFile);
88120
}
89121

90-
const projectName = context.target && context.target.project;
91-
if (!projectName) {
122+
if (!context.target || !context.target.project) {
92123
throw new Error('The builder requires a target.');
93124
}
94-
// target is verified in the above call
95-
// tslint:disable-next-line: no-non-null-assertion
96-
const metadata = await context.getProjectMetadata(context.target!);
125+
126+
const metadata = await context.getProjectMetadata(context.target);
97127
const i18n = createI18nOptions(metadata);
98128

99-
const { config } = await generateBrowserWebpackConfigFromContext(
129+
let usingIvy = false;
130+
const ivyMessages: LocalizeMessage[] = [];
131+
const { config, projectRoot } = await generateBrowserWebpackConfigFromContext(
100132
{
101133
...browserOptions,
102134
optimization: {
103135
scripts: false,
104136
styles: false,
105137
},
138+
sourceMap: {
139+
scripts: true,
140+
},
106141
buildOptimizer: false,
107142
i18nLocale: options.i18nLocale || i18n.sourceLocale,
108143
i18nFormat: options.format,
@@ -115,15 +150,70 @@ export async function execute(
115150
deleteOutputPath: false,
116151
},
117152
context,
118-
wco => [
119-
{ plugins: [new InMemoryOutputPlugin()] },
120-
getCommonConfig(wco),
121-
getAotConfig(wco, true),
122-
getStylesConfig(wco),
123-
getStatsConfig(wco),
124-
],
153+
(wco) => {
154+
const isIvyApplication = wco.tsConfig.options.enableIvy !== false;
155+
156+
// Ivy-based extraction is currently opt-in
157+
if (options.ivy) {
158+
if (!isIvyApplication) {
159+
context.logger.warn(
160+
'Ivy extraction enabled but application is not Ivy enabled. Extraction may fail.',
161+
);
162+
}
163+
usingIvy = true;
164+
} else if (isIvyApplication) {
165+
context.logger.warn(
166+
'Ivy extraction not enabled but application is Ivy enabled. ' +
167+
'If the extraction fails, the `--ivy` flag will enable Ivy extraction.',
168+
);
169+
}
170+
171+
const partials = [
172+
{ plugins: [new InMemoryOutputPlugin()] },
173+
getCommonConfig(wco),
174+
// Only use VE extraction if not using Ivy
175+
getAotConfig(wco, !usingIvy),
176+
getStylesConfig(wco),
177+
getStatsConfig(wco),
178+
];
179+
180+
// Add Ivy application file extractor support
181+
if (usingIvy) {
182+
partials.unshift({
183+
module: {
184+
rules: [
185+
{
186+
test: /\.ts$/,
187+
loader: require.resolve('./ivy-extract-loader'),
188+
options: {
189+
messageHandler: (messages: LocalizeMessage[]) => ivyMessages.push(...messages),
190+
},
191+
},
192+
],
193+
},
194+
});
195+
}
196+
197+
return partials;
198+
},
125199
);
126200

201+
if (usingIvy) {
202+
let validLocalizePackage = false;
203+
try {
204+
const { version: localizeVersion } = require('@angular/localize/package.json');
205+
validLocalizePackage = semverGte(localizeVersion, '10.1.0-next.0', { includePrerelease: true });
206+
} catch {}
207+
208+
if (!validLocalizePackage) {
209+
context.logger.error(
210+
"Ivy extraction requires the '@angular/localize' package version 10.1.0 or higher.",
211+
);
212+
213+
return { success: false };
214+
}
215+
}
216+
127217
const logging: WebpackLoggingCallback = (stats, config) => {
128218
const json = stats.toJson({ errors: true, warnings: true });
129219

@@ -136,10 +226,39 @@ export async function execute(
136226
}
137227
};
138228

139-
return runWebpack(config, context, {
229+
const webpackResult = await runWebpack(config, context, {
140230
logging,
141231
webpackFactory: await import('webpack'),
142232
}).toPromise();
233+
234+
// Complete if using VE
235+
if (!usingIvy) {
236+
return webpackResult;
237+
}
238+
239+
// Nothing to process if the Webpack build failed
240+
if (!webpackResult.success) {
241+
return webpackResult;
242+
}
243+
244+
// Serialize all extracted messages
245+
const serializer = await getSerializer(
246+
options.format,
247+
i18n.sourceLocale,
248+
config.context || projectRoot,
249+
);
250+
const content = serializer.serialize(ivyMessages);
251+
252+
// Ensure directory exists
253+
const outputPath = path.dirname(outFile);
254+
if (!fs.existsSync(outputPath)) {
255+
fs.mkdirSync(outputPath, { recursive: true });
256+
}
257+
258+
// Write translation file
259+
fs.writeFileSync(outFile, content);
260+
261+
return webpackResult;
143262
}
144263

145264
export default createBuilder<JsonObject & ExtractI18nBuilderOptions>(execute);
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { MessageExtractor } from '@angular/localize/src/tools/src/extract/extraction';
9+
import { getOptions } from 'loader-utils';
10+
import * as nodePath from 'path';
11+
12+
interface LocalizeExtractLoaderOptions {
13+
messageHandler: (messages: import('@angular/localize').ɵParsedMessage[]) => void;
14+
}
15+
16+
export default function localizeExtractLoader(
17+
this: import('webpack').loader.LoaderContext,
18+
content: string,
19+
// Source map types are broken in the webpack type definitions
20+
// tslint:disable-next-line: no-any
21+
map: any,
22+
) {
23+
const loaderContext = this;
24+
25+
// Casts are needed to workaround the loader-utils typings limited support for option values
26+
const options = (getOptions(this) as unknown) as LocalizeExtractLoaderOptions | undefined;
27+
28+
// Setup a Webpack-based logger instance
29+
const logger = {
30+
// level 2 is warnings
31+
level: 2,
32+
debug(...args: string[]): void {
33+
// tslint:disable-next-line: no-console
34+
console.debug(...args);
35+
},
36+
info(...args: string[]): void {
37+
loaderContext.emitWarning(args.join(''));
38+
},
39+
warn(...args: string[]): void {
40+
loaderContext.emitWarning(args.join(''));
41+
},
42+
error(...args: string[]): void {
43+
loaderContext.emitError(args.join(''));
44+
},
45+
};
46+
47+
// Setup a virtual file system instance for the extractor
48+
// * MessageExtractor itself uses readFile and resolve
49+
// * Internal SourceFileLoader (sourcemap support) uses dirname, exists, readFile, and resolve
50+
const filesystem = {
51+
readFile(path: string): string {
52+
if (path === loaderContext.resourcePath) {
53+
return content;
54+
} else if (path === loaderContext.resourcePath + '.map') {
55+
return typeof map === 'string' ? map : JSON.stringify(map);
56+
} else {
57+
throw new Error('Unknown file requested.');
58+
}
59+
},
60+
resolve(...paths: string[]): string {
61+
return nodePath.resolve(...paths);
62+
},
63+
exists(path: string): boolean {
64+
return path === loaderContext.resourcePath || path === loaderContext.resourcePath + '.map';
65+
},
66+
dirname(path: string): string {
67+
return nodePath.dirname(path);
68+
},
69+
};
70+
71+
// tslint:disable-next-line: no-any
72+
const extractor = new MessageExtractor(filesystem as any, logger, {
73+
// tslint:disable-next-line: no-any
74+
basePath: this.rootContext as any,
75+
useSourceMaps: !!map,
76+
});
77+
78+
const messages = extractor.extractMessages(loaderContext.resourcePath);
79+
if (messages.length > 0) {
80+
options?.messageHandler(messages);
81+
}
82+
83+
// Pass through the original content now that messages have been extracted
84+
this.callback(undefined, content, map);
85+
}

packages/angular_devkit/build_angular/src/extract-i18n/schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
"description": "Specifies the source language of the application.",
4242
"x-deprecated": "Use 'i18n' project level sub-option 'sourceLocale' instead."
4343
},
44+
"ivy": {
45+
"type": "boolean",
46+
"description": "Use Ivy compiler to extract translations."
47+
},
4448
"progress": {
4549
"type": "boolean",
4650
"description": "Log progress to the console.",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { join } from 'path';
2+
import { getGlobalVariable } from '../../utils/env';
3+
import { writeFile } from '../../utils/fs';
4+
import { ng, npm } from '../../utils/process';
5+
import { updateJsonFile } from '../../utils/project';
6+
import { expectToFail } from '../../utils/utils';
7+
import { readNgVersion } from '../../utils/version';
8+
9+
export default async function() {
10+
// Ivy only test
11+
if (getGlobalVariable('argv')['ve']) {
12+
return;
13+
}
14+
15+
// Setup an i18n enabled component
16+
await ng('generate', 'component', 'i18n-test');
17+
await writeFile(
18+
join('src/app/i18n-test', 'i18n-test.component.html'),
19+
'<p i18n>Hello world</p>',
20+
);
21+
22+
// Should fail with --ivy flag if `@angular/localize` is missing
23+
const { message: message1 } = await expectToFail(() => ng('xi18n', '--ivy'));
24+
if (!message1.includes(`Ivy extraction requires the '@angular/localize' package version 10.1.0 or higher.`)) {
25+
throw new Error('Expected localize package error message when missing');
26+
}
27+
28+
// Should fail with --ivy flag if `@angular/localize` is wrong version
29+
await npm('install', '@angular/localize@9');
30+
const { message: message2 } = await expectToFail(() => ng('xi18n', '--ivy'));
31+
if (!message2.includes(`Ivy extraction requires the '@angular/localize' package version 10.1.0 or higher.`)) {
32+
throw new Error('Expected localize package error message when wrong version');
33+
}
34+
35+
// Install correct version
36+
let localizeVersion = '@angular/localize@' + readNgVersion();
37+
if (getGlobalVariable('argv')['ng-snapshots']) {
38+
localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize'];
39+
}
40+
await npm('install', `${localizeVersion}`);
41+
42+
// Should show ivy enabled application warning without --ivy flag
43+
const { stderr: message3 } = await ng('xi18n');
44+
if (!message3.includes(`Ivy extraction not enabled but application is Ivy enabled.`)) {
45+
throw new Error('Expected ivy enabled application warning');
46+
}
47+
48+
// Disable Ivy
49+
await updateJsonFile('tsconfig.json', config => {
50+
const { angularCompilerOptions = {} } = config;
51+
angularCompilerOptions.enableIvy = false;
52+
config.angularCompilerOptions = angularCompilerOptions;
53+
});
54+
55+
// Should show ivy disabled application warning with --ivy flag and enableIvy false
56+
const { message: message4 } = await expectToFail(() => ng('xi18n', '--ivy'));
57+
if (!message4.includes(`Ivy extraction enabled but application is not Ivy enabled.`)) {
58+
throw new Error('Expected ivy disabled application warning');
59+
}
60+
}

0 commit comments

Comments
 (0)