Skip to content

Commit 0b03829

Browse files
committed
feat(@angular-devkit/build-angular): move i18n extraction for application builder to new build system package
With the `application` builder already within the new `@angular/build` package, the `extract-i18n` builder with application builder support is now also contained within this package. Only the application builder aspects of `extract-i18n` have been moved. The compatibility builder `browser-esbuild` is not supported with `@angular/build:extract-i18n`. The existing `extract-i18n` builder found within `@angular-devkit/build-angular` should continue to be used for both the Webpack-based `browser` builder and the esbuild-based compatibility `browser-esbuild` builder. To maintain backwards compatibility, the existing `@angular-devkit/build-angular:extract-i18n` builder continues to support builders it has previously. No change to existing applications is required.
1 parent 7cedcc8 commit 0b03829

File tree

11 files changed

+550
-0
lines changed

11 files changed

+550
-0
lines changed

goldens/public-api/angular/build/index.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@ export function executeDevServerBuilder(options: DevServerBuilderOptions, contex
153153
indexHtmlTransformer?: IndexHtmlTransform;
154154
}): AsyncIterable<DevServerBuilderOutput>;
155155

156+
// @public
157+
export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): Promise<BuilderOutput>;
158+
159+
// @public
160+
export interface ExtractI18nBuilderOptions {
161+
buildTarget: string;
162+
format?: Format;
163+
outFile?: string;
164+
outputPath?: string;
165+
progress?: boolean;
166+
}
167+
156168
// (No @packageDocumentation comment for this package)
157169

158170
```

packages/angular/build/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ ts_json_schema(
1717
src = "src/builders/dev-server/schema.json",
1818
)
1919

20+
ts_json_schema(
21+
name = "extract_i18n_schema",
22+
src = "src/builders/extract-i18n/schema.json",
23+
)
24+
2025
ts_library(
2126
name = "build",
2227
package_name = "@angular/build",
@@ -34,6 +39,7 @@ ts_library(
3439
) + [
3540
"//packages/angular/build:src/builders/application/schema.ts",
3641
"//packages/angular/build:src/builders/dev-server/schema.ts",
42+
"//packages/angular/build:src/builders/extract-i18n/schema.ts",
3743
],
3844
data = glob(
3945
include = [

packages/angular/build/builders.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
"implementation": "./src/builders/dev-server/index",
1010
"schema": "./src/builders/dev-server/schema.json",
1111
"description": "Execute a development server for an application."
12+
},
13+
"extract-i18n": {
14+
"implementation": "./src/builders/extract-i18n/index",
15+
"schema": "./src/builders/extract-i18n/schema.json",
16+
"description": "Extract i18n messages from an application."
1217
}
1318
}
1419
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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 {
16+
ApplicationBuilderExtensions,
17+
ApplicationBuilderInternalOptions,
18+
} from '../application/options';
19+
import type { NormalizedExtractI18nOptions } from './options';
20+
21+
export async function extractMessages(
22+
options: NormalizedExtractI18nOptions,
23+
builderName: string,
24+
context: BuilderContext,
25+
extractorConstructor: typeof MessageExtractor,
26+
extensions?: ApplicationBuilderExtensions,
27+
): Promise<{
28+
builderResult: BuilderOutput;
29+
basePath: string;
30+
messages: LocalizeMessage[];
31+
useLegacyIds: boolean;
32+
}> {
33+
const messages: LocalizeMessage[] = [];
34+
35+
// Setup the build options for the application based on the buildTarget option
36+
const buildOptions = (await context.validateOptions(
37+
await context.getTargetOptions(options.buildTarget),
38+
builderName,
39+
)) as unknown as ApplicationBuilderInternalOptions;
40+
buildOptions.optimization = false;
41+
buildOptions.sourceMap = { scripts: true, vendor: true, styles: false };
42+
buildOptions.localize = false;
43+
buildOptions.budgets = undefined;
44+
buildOptions.index = false;
45+
buildOptions.serviceWorker = false;
46+
47+
buildOptions.ssr = false;
48+
buildOptions.appShell = false;
49+
buildOptions.prerender = false;
50+
51+
// Build the application with the build options
52+
let builderResult;
53+
try {
54+
for await (const result of buildApplicationInternal(
55+
buildOptions,
56+
context,
57+
{ write: false },
58+
extensions,
59+
)) {
60+
builderResult = result;
61+
break;
62+
}
63+
64+
assert(builderResult !== undefined, 'Application builder did not provide a result.');
65+
} catch (err) {
66+
builderResult = {
67+
success: false,
68+
error: (err as Error).message,
69+
};
70+
}
71+
72+
// Extract messages from each output JavaScript file.
73+
// Output files are only present on a successful build.
74+
if (builderResult.outputFiles) {
75+
// Store the JS and JS map files for lookup during extraction
76+
const files = new Map<string, string>();
77+
for (const outputFile of builderResult.outputFiles) {
78+
if (outputFile.path.endsWith('.js')) {
79+
files.set(outputFile.path, outputFile.text);
80+
} else if (outputFile.path.endsWith('.js.map')) {
81+
files.set(outputFile.path, outputFile.text);
82+
}
83+
}
84+
85+
// Setup the localize message extractor based on the in-memory files
86+
const extractor = setupLocalizeExtractor(extractorConstructor, files, context);
87+
88+
// Attempt extraction of all output JS files
89+
for (const filePath of files.keys()) {
90+
if (!filePath.endsWith('.js')) {
91+
continue;
92+
}
93+
94+
const fileMessages = extractor.extractMessages(filePath);
95+
messages.push(...fileMessages);
96+
}
97+
}
98+
99+
return {
100+
builderResult,
101+
basePath: context.workspaceRoot,
102+
messages,
103+
// Legacy i18n identifiers are not supported with the new application builder
104+
useLegacyIds: false,
105+
};
106+
}
107+
108+
function setupLocalizeExtractor(
109+
extractorConstructor: typeof MessageExtractor,
110+
files: Map<string, string>,
111+
context: BuilderContext,
112+
): MessageExtractor {
113+
// Setup a virtual file system instance for the extractor
114+
// * MessageExtractor itself uses readFile, relative and resolve
115+
// * Internal SourceFileLoader (sourcemap support) uses dirname, exists, readFile, and resolve
116+
const filesystem = {
117+
readFile(path: string): string {
118+
// Output files are stored as relative to the workspace root
119+
const requestedPath = nodePath.relative(context.workspaceRoot, path);
120+
121+
const content = files.get(requestedPath);
122+
if (content === undefined) {
123+
throw new Error('Unknown file requested: ' + requestedPath);
124+
}
125+
126+
return content;
127+
},
128+
relative(from: string, to: string): string {
129+
return nodePath.relative(from, to);
130+
},
131+
resolve(...paths: string[]): string {
132+
return nodePath.resolve(...paths);
133+
},
134+
exists(path: string): boolean {
135+
// Output files are stored as relative to the workspace root
136+
const requestedPath = nodePath.relative(context.workspaceRoot, path);
137+
138+
return files.has(requestedPath);
139+
},
140+
dirname(path: string): string {
141+
return nodePath.dirname(path);
142+
},
143+
};
144+
145+
const logger = {
146+
// level 2 is warnings
147+
level: 2,
148+
debug(...args: string[]): void {
149+
// eslint-disable-next-line no-console
150+
console.debug(...args);
151+
},
152+
info(...args: string[]): void {
153+
context.logger.info(args.join(''));
154+
},
155+
warn(...args: string[]): void {
156+
context.logger.warn(args.join(''));
157+
},
158+
error(...args: string[]): void {
159+
context.logger.error(args.join(''));
160+
},
161+
};
162+
163+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
164+
const extractor = new extractorConstructor(filesystem as any, logger, {
165+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
166+
basePath: context.workspaceRoot as any,
167+
useSourceMaps: true,
168+
});
169+
170+
return extractor;
171+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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 { Diagnostics } from '@angular/localize/tools';
10+
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
11+
import fs from 'node:fs';
12+
import path from 'node:path';
13+
import { loadEsmModule } from '../../utils/load-esm';
14+
import { assertCompatibleAngularVersion } from '../../utils/version';
15+
import type { ApplicationBuilderExtensions } from '../application/options';
16+
import { normalizeOptions } from './options';
17+
import { Schema as ExtractI18nBuilderOptions, Format } from './schema';
18+
19+
/**
20+
* @experimental Direct usage of this function is considered experimental.
21+
*/
22+
export async function execute(
23+
options: ExtractI18nBuilderOptions,
24+
context: BuilderContext,
25+
extensions?: ApplicationBuilderExtensions,
26+
): Promise<BuilderOutput> {
27+
// Determine project name from builder context target
28+
const projectName = context.target?.project;
29+
if (!projectName) {
30+
context.logger.error(`The 'extract-i18n' builder requires a target to be specified.`);
31+
32+
return { success: false };
33+
}
34+
35+
// Check Angular version.
36+
assertCompatibleAngularVersion(context.workspaceRoot);
37+
38+
// Load the Angular localize package.
39+
// The package is a peer dependency and might not be present
40+
let localizeToolsModule;
41+
try {
42+
localizeToolsModule =
43+
await loadEsmModule<typeof import('@angular/localize/tools')>('@angular/localize/tools');
44+
} catch {
45+
return {
46+
success: false,
47+
error:
48+
`i18n extraction requires the '@angular/localize' package.` +
49+
` You can add it by using 'ng add @angular/localize'.`,
50+
};
51+
}
52+
53+
// Normalize options
54+
const normalizedOptions = await normalizeOptions(context, projectName, options);
55+
const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget);
56+
57+
// Extract messages based on configured builder
58+
const { extractMessages } = await import('./application-extraction');
59+
const extractionResult = await extractMessages(
60+
normalizedOptions,
61+
builderName,
62+
context,
63+
localizeToolsModule.MessageExtractor,
64+
extensions,
65+
);
66+
67+
// Return the builder result if it failed
68+
if (!extractionResult.builderResult.success) {
69+
return extractionResult.builderResult;
70+
}
71+
72+
// Perform duplicate message checks
73+
const { checkDuplicateMessages } = localizeToolsModule;
74+
75+
// The filesystem is used to create a relative path for each file
76+
// from the basePath. This relative path is then used in the error message.
77+
const checkFileSystem = {
78+
relative(from: string, to: string): string {
79+
return path.relative(from, to);
80+
},
81+
};
82+
const diagnostics = checkDuplicateMessages(
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
84+
checkFileSystem as any,
85+
extractionResult.messages,
86+
'warning',
87+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
88+
extractionResult.basePath as any,
89+
);
90+
if (diagnostics.messages.length > 0) {
91+
context.logger.warn(diagnostics.formatDiagnostics(''));
92+
}
93+
94+
// Serialize all extracted messages
95+
const serializer = await createSerializer(
96+
localizeToolsModule,
97+
normalizedOptions.format,
98+
normalizedOptions.i18nOptions.sourceLocale,
99+
extractionResult.basePath,
100+
extractionResult.useLegacyIds,
101+
diagnostics,
102+
);
103+
const content = serializer.serialize(extractionResult.messages);
104+
105+
// Ensure directory exists
106+
const outputPath = path.dirname(normalizedOptions.outFile);
107+
if (!fs.existsSync(outputPath)) {
108+
fs.mkdirSync(outputPath, { recursive: true });
109+
}
110+
111+
// Write translation file
112+
fs.writeFileSync(normalizedOptions.outFile, content);
113+
114+
if (normalizedOptions.progress) {
115+
context.logger.info(`Extraction Complete. (Messages: ${extractionResult.messages.length})`);
116+
}
117+
118+
return { success: true, outputPath: normalizedOptions.outFile };
119+
}
120+
121+
async function createSerializer(
122+
localizeToolsModule: typeof import('@angular/localize/tools'),
123+
format: Format,
124+
sourceLocale: string,
125+
basePath: string,
126+
useLegacyIds: boolean,
127+
diagnostics: Diagnostics,
128+
) {
129+
const {
130+
XmbTranslationSerializer,
131+
LegacyMessageIdMigrationSerializer,
132+
ArbTranslationSerializer,
133+
Xliff1TranslationSerializer,
134+
Xliff2TranslationSerializer,
135+
SimpleJsonTranslationSerializer,
136+
} = localizeToolsModule;
137+
138+
switch (format) {
139+
case Format.Xmb:
140+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
141+
return new XmbTranslationSerializer(basePath as any, useLegacyIds);
142+
case Format.Xlf:
143+
case Format.Xlif:
144+
case Format.Xliff:
145+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
146+
return new Xliff1TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
147+
case Format.Xlf2:
148+
case Format.Xliff2:
149+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
150+
return new Xliff2TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {});
151+
case Format.Json:
152+
return new SimpleJsonTranslationSerializer(sourceLocale);
153+
case Format.LegacyMigrate:
154+
return new LegacyMessageIdMigrationSerializer(diagnostics);
155+
case Format.Arb:
156+
const fileSystem = {
157+
relative(from: string, to: string): string {
158+
return path.relative(from, to);
159+
},
160+
};
161+
162+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
163+
return new ArbTranslationSerializer(sourceLocale, basePath as any, fileSystem as any);
164+
}
165+
}

0 commit comments

Comments
 (0)