Skip to content

Commit 3c0719b

Browse files
clydinalan-agius4
authored andcommitted
feat(@angular-devkit/build-angular): initial i18n extraction support for application builder
The `ng extract-i18n` command now supports using either the developer preview esbuild-based browser or application builders. Support for the existing Webpack-based build system has been maintained. The extraction process will now build the application based on the build target defined builder in the case of either `@angular-devkit/build-angular:browser-esbuild` and `@angular-devkit/build-angular:application`. In the case of the application builder, SSR output code generation is disabled to prevent duplicate messages for the same underlying source code.
1 parent f067ea1 commit 3c0719b

File tree

5 files changed

+193
-12
lines changed

5 files changed

+193
-12
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
9+
import type { ɵParsedMessage as LocalizeMessage } from '@angular/localize';
10+
import type { MessageExtractor } from '@angular/localize/tools';
11+
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
12+
import assert from 'node:assert';
13+
import nodePath from 'node:path';
14+
import { buildApplicationInternal } from '../application';
15+
import type { ApplicationBuilderInternalOptions } from '../application/options';
16+
import { buildEsbuildBrowser } from '../browser-esbuild';
17+
import type { NormalizedExtractI18nOptions } from './options';
18+
19+
export async function extractMessages(
20+
options: NormalizedExtractI18nOptions,
21+
builderName: string,
22+
context: BuilderContext,
23+
extractorConstructor: typeof MessageExtractor,
24+
): Promise<{
25+
builderResult: BuilderOutput;
26+
basePath: string;
27+
messages: LocalizeMessage[];
28+
useLegacyIds: boolean;
29+
}> {
30+
const messages: LocalizeMessage[] = [];
31+
32+
// Setup the build options for the application based on the browserTarget option
33+
const buildOptions = (await context.validateOptions(
34+
await context.getTargetOptions(options.browserTarget),
35+
builderName,
36+
)) as unknown as ApplicationBuilderInternalOptions;
37+
buildOptions.optimization = false;
38+
buildOptions.sourceMap = { scripts: true, vendor: true };
39+
40+
let build;
41+
if (builderName === '@angular-devkit/build-angular:application') {
42+
build = buildApplicationInternal;
43+
44+
buildOptions.ssr = false;
45+
buildOptions.appShell = false;
46+
buildOptions.prerender = false;
47+
} else {
48+
build = buildEsbuildBrowser;
49+
}
50+
51+
// Build the application with the build options
52+
let builderResult;
53+
try {
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
55+
for await (const result of build(buildOptions as any, context, { write: false })) {
56+
builderResult = result;
57+
break;
58+
}
59+
60+
assert(builderResult !== undefined, 'Application builder did not provide a result.');
61+
} catch (err) {
62+
builderResult = {
63+
success: false,
64+
error: (err as Error).message,
65+
};
66+
}
67+
68+
// Extract messages from each output JavaScript file.
69+
// Output files are only present on a successful build.
70+
if (builderResult.outputFiles) {
71+
// Store the JS and JS map files for lookup during extraction
72+
const files = new Map<string, string>();
73+
for (const outputFile of builderResult.outputFiles) {
74+
if (outputFile.path.endsWith('.js')) {
75+
files.set(outputFile.path, outputFile.text);
76+
} else if (outputFile.path.endsWith('.js.map')) {
77+
files.set(outputFile.path, outputFile.text);
78+
}
79+
}
80+
81+
// Setup the localize message extractor based on the in-memory files
82+
const extractor = setupLocalizeExtractor(extractorConstructor, files, context);
83+
84+
// Attempt extraction of all output JS files
85+
for (const filePath of files.keys()) {
86+
if (!filePath.endsWith('.js')) {
87+
continue;
88+
}
89+
90+
const fileMessages = extractor.extractMessages(filePath);
91+
messages.push(...fileMessages);
92+
}
93+
}
94+
95+
return {
96+
builderResult,
97+
basePath: context.workspaceRoot,
98+
messages,
99+
// Legacy i18n identifiers are not supported with the new application builder
100+
useLegacyIds: false,
101+
};
102+
}
103+
104+
function setupLocalizeExtractor(
105+
extractorConstructor: typeof MessageExtractor,
106+
files: Map<string, string>,
107+
context: BuilderContext,
108+
): MessageExtractor {
109+
// Setup a virtual file system instance for the extractor
110+
// * MessageExtractor itself uses readFile, relative and resolve
111+
// * Internal SourceFileLoader (sourcemap support) uses dirname, exists, readFile, and resolve
112+
const filesystem = {
113+
readFile(path: string): string {
114+
// Output files are stored as relative to the workspace root
115+
const requestedPath = nodePath.relative(context.workspaceRoot, path);
116+
117+
const content = files.get(requestedPath);
118+
if (content === undefined) {
119+
throw new Error('Unknown file requested: ' + requestedPath);
120+
}
121+
122+
return content;
123+
},
124+
relative(from: string, to: string): string {
125+
return nodePath.relative(from, to);
126+
},
127+
resolve(...paths: string[]): string {
128+
return nodePath.resolve(...paths);
129+
},
130+
exists(path: string): boolean {
131+
// Output files are stored as relative to the workspace root
132+
const requestedPath = nodePath.relative(context.workspaceRoot, path);
133+
134+
return files.has(requestedPath);
135+
},
136+
dirname(path: string): string {
137+
return nodePath.dirname(path);
138+
},
139+
};
140+
141+
const logger = {
142+
// level 2 is warnings
143+
level: 2,
144+
debug(...args: string[]): void {
145+
// eslint-disable-next-line no-console
146+
console.debug(...args);
147+
},
148+
info(...args: string[]): void {
149+
context.logger.info(args.join(''));
150+
},
151+
warn(...args: string[]): void {
152+
context.logger.warn(args.join(''));
153+
},
154+
error(...args: string[]): void {
155+
context.logger.error(args.join(''));
156+
},
157+
};
158+
159+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
160+
const extractor = new extractorConstructor(filesystem as any, logger, {
161+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
162+
basePath: context.workspaceRoot as any,
163+
useSourceMaps: true,
164+
});
165+
166+
return extractor;
167+
}

packages/angular_devkit/build_angular/src/builders/extract-i18n/builder.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,29 +49,34 @@ export async function execute(
4949
} catch {
5050
return {
5151
success: false,
52-
error: `i18n extraction requires the '@angular/localize' package.`,
52+
error:
53+
`i18n extraction requires the '@angular/localize' package.` +
54+
` You can add it by using 'ng add @angular/localize'.`,
5355
};
5456
}
5557

56-
// Purge old build disk cache.
57-
await purgeStaleBuildCache(context);
58-
5958
// Normalize options
6059
const normalizedOptions = await normalizeOptions(context, projectName, options);
6160
const builderName = await context.getBuilderNameForTarget(normalizedOptions.browserTarget);
6261

6362
// Extract messages based on configured builder
64-
// TODO: Implement application/browser-esbuild support
6563
let extractionResult;
6664
if (
6765
builderName === '@angular-devkit/build-angular:application' ||
6866
builderName === '@angular-devkit/build-angular:browser-esbuild'
6967
) {
70-
return {
71-
error: 'i18n extraction is currently only supported with the "browser" builder.',
72-
success: false,
73-
};
68+
const { extractMessages } = await import('./application-extraction');
69+
extractionResult = await extractMessages(
70+
normalizedOptions,
71+
builderName,
72+
context,
73+
localizeToolsModule.MessageExtractor,
74+
);
7475
} else {
76+
// Purge old build disk cache.
77+
// Other build systems handle stale cache purging directly.
78+
await purgeStaleBuildCache(context);
79+
7580
const { extractMessages } = await import('./webpack-extraction');
7681
extractionResult = await extractMessages(normalizedOptions, builderName, context, transforms);
7782
}
@@ -123,7 +128,11 @@ export async function execute(
123128
// Write translation file
124129
fs.writeFileSync(normalizedOptions.outFile, content);
125130

126-
return extractionResult.builderResult;
131+
if (normalizedOptions.progress) {
132+
context.logger.info(`Extraction Complete. (Messages: ${extractionResult.messages.length})`);
133+
}
134+
135+
return { success: true, outputPath: normalizedOptions.outFile };
127136
}
128137

129138
async function createSerializer(

tests/legacy-cli/e2e.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ ESBUILD_TESTS = [
3434
"tests/build/relative-sourcemap.js",
3535
"tests/build/styles/**",
3636
"tests/commands/add/add-pwa.js",
37+
"tests/i18n/extract-ivy*",
3738
]
3839

3940
# Tests excluded for esbuild

tests/legacy-cli/e2e/tests/i18n/extract-ivy-disk-cache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { join } from 'path';
22
import { getGlobalVariable } from '../../utils/env';
3-
import { expectFileToMatch, rimraf, writeFile } from '../../utils/fs';
3+
import { appendToFile, expectFileToMatch, rimraf, writeFile } from '../../utils/fs';
44
import { installPackage, uninstallPackage } from '../../utils/packages';
55
import { ng } from '../../utils/process';
66
import { updateJsonFile } from '../../utils/project';
@@ -16,6 +16,8 @@ export default async function () {
1616
// Setup an i18n enabled component
1717
await ng('generate', 'component', 'i18n-test');
1818
await writeFile(join('src/app/i18n-test', 'i18n-test.component.html'), '<p i18n>Hello world</p>');
19+
// Actually use the generated component to ensure it is present in the application output
20+
await appendToFile('src/app/app.component.html', '<app-i18n-test>');
1921

2022
// Install correct version
2123
let localizeVersion = '@angular/localize@' + readNgVersion();

tests/legacy-cli/e2e/tests/i18n/extract-ivy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { join } from 'path';
22
import { getGlobalVariable } from '../../utils/env';
3-
import { expectFileToMatch, writeFile } from '../../utils/fs';
3+
import { appendToFile, expectFileToMatch, writeFile } from '../../utils/fs';
44
import { installPackage, uninstallPackage } from '../../utils/packages';
55
import { ng } from '../../utils/process';
66
import { expectToFail } from '../../utils/utils';
@@ -10,6 +10,8 @@ export default async function () {
1010
// Setup an i18n enabled component
1111
await ng('generate', 'component', 'i18n-test');
1212
await writeFile(join('src/app/i18n-test', 'i18n-test.component.html'), '<p i18n>Hello world</p>');
13+
// Actually use the generated component to ensure it is present in the application output
14+
await appendToFile('src/app/app.component.html', '<app-i18n-test>');
1315

1416
// Should fail if `@angular/localize` is missing
1517
const { message: message1 } = await expectToFail(() => ng('extract-i18n'));

0 commit comments

Comments
 (0)