mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-17 19:13:34 +08:00
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.
This commit is contained in:
parent
f067ea1ab6
commit
3c0719bde2
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @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 type { ɵParsedMessage as LocalizeMessage } from '@angular/localize';
|
||||
import type { MessageExtractor } from '@angular/localize/tools';
|
||||
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
|
||||
import assert from 'node:assert';
|
||||
import nodePath from 'node:path';
|
||||
import { buildApplicationInternal } from '../application';
|
||||
import type { ApplicationBuilderInternalOptions } from '../application/options';
|
||||
import { buildEsbuildBrowser } from '../browser-esbuild';
|
||||
import type { NormalizedExtractI18nOptions } from './options';
|
||||
|
||||
export async function extractMessages(
|
||||
options: NormalizedExtractI18nOptions,
|
||||
builderName: string,
|
||||
context: BuilderContext,
|
||||
extractorConstructor: typeof MessageExtractor,
|
||||
): Promise<{
|
||||
builderResult: BuilderOutput;
|
||||
basePath: string;
|
||||
messages: LocalizeMessage[];
|
||||
useLegacyIds: boolean;
|
||||
}> {
|
||||
const messages: LocalizeMessage[] = [];
|
||||
|
||||
// Setup the build options for the application based on the browserTarget option
|
||||
const buildOptions = (await context.validateOptions(
|
||||
await context.getTargetOptions(options.browserTarget),
|
||||
builderName,
|
||||
)) as unknown as ApplicationBuilderInternalOptions;
|
||||
buildOptions.optimization = false;
|
||||
buildOptions.sourceMap = { scripts: true, vendor: true };
|
||||
|
||||
let build;
|
||||
if (builderName === '@angular-devkit/build-angular:application') {
|
||||
build = buildApplicationInternal;
|
||||
|
||||
buildOptions.ssr = false;
|
||||
buildOptions.appShell = false;
|
||||
buildOptions.prerender = false;
|
||||
} else {
|
||||
build = buildEsbuildBrowser;
|
||||
}
|
||||
|
||||
// Build the application with the build options
|
||||
let builderResult;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for await (const result of build(buildOptions as any, context, { write: false })) {
|
||||
builderResult = result;
|
||||
break;
|
||||
}
|
||||
|
||||
assert(builderResult !== undefined, 'Application builder did not provide a result.');
|
||||
} catch (err) {
|
||||
builderResult = {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract messages from each output JavaScript file.
|
||||
// Output files are only present on a successful build.
|
||||
if (builderResult.outputFiles) {
|
||||
// Store the JS and JS map files for lookup during extraction
|
||||
const files = new Map<string, string>();
|
||||
for (const outputFile of builderResult.outputFiles) {
|
||||
if (outputFile.path.endsWith('.js')) {
|
||||
files.set(outputFile.path, outputFile.text);
|
||||
} else if (outputFile.path.endsWith('.js.map')) {
|
||||
files.set(outputFile.path, outputFile.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the localize message extractor based on the in-memory files
|
||||
const extractor = setupLocalizeExtractor(extractorConstructor, files, context);
|
||||
|
||||
// Attempt extraction of all output JS files
|
||||
for (const filePath of files.keys()) {
|
||||
if (!filePath.endsWith('.js')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileMessages = extractor.extractMessages(filePath);
|
||||
messages.push(...fileMessages);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
builderResult,
|
||||
basePath: context.workspaceRoot,
|
||||
messages,
|
||||
// Legacy i18n identifiers are not supported with the new application builder
|
||||
useLegacyIds: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setupLocalizeExtractor(
|
||||
extractorConstructor: typeof MessageExtractor,
|
||||
files: Map<string, string>,
|
||||
context: BuilderContext,
|
||||
): MessageExtractor {
|
||||
// Setup a virtual file system instance for the extractor
|
||||
// * MessageExtractor itself uses readFile, relative and resolve
|
||||
// * Internal SourceFileLoader (sourcemap support) uses dirname, exists, readFile, and resolve
|
||||
const filesystem = {
|
||||
readFile(path: string): string {
|
||||
// Output files are stored as relative to the workspace root
|
||||
const requestedPath = nodePath.relative(context.workspaceRoot, path);
|
||||
|
||||
const content = files.get(requestedPath);
|
||||
if (content === undefined) {
|
||||
throw new Error('Unknown file requested: ' + requestedPath);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
relative(from: string, to: string): string {
|
||||
return nodePath.relative(from, to);
|
||||
},
|
||||
resolve(...paths: string[]): string {
|
||||
return nodePath.resolve(...paths);
|
||||
},
|
||||
exists(path: string): boolean {
|
||||
// Output files are stored as relative to the workspace root
|
||||
const requestedPath = nodePath.relative(context.workspaceRoot, path);
|
||||
|
||||
return files.has(requestedPath);
|
||||
},
|
||||
dirname(path: string): string {
|
||||
return nodePath.dirname(path);
|
||||
},
|
||||
};
|
||||
|
||||
const logger = {
|
||||
// level 2 is warnings
|
||||
level: 2,
|
||||
debug(...args: string[]): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(...args);
|
||||
},
|
||||
info(...args: string[]): void {
|
||||
context.logger.info(args.join(''));
|
||||
},
|
||||
warn(...args: string[]): void {
|
||||
context.logger.warn(args.join(''));
|
||||
},
|
||||
error(...args: string[]): void {
|
||||
context.logger.error(args.join(''));
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const extractor = new extractorConstructor(filesystem as any, logger, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
basePath: context.workspaceRoot as any,
|
||||
useSourceMaps: true,
|
||||
});
|
||||
|
||||
return extractor;
|
||||
}
|
@ -49,29 +49,34 @@ export async function execute(
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: `i18n extraction requires the '@angular/localize' package.`,
|
||||
error:
|
||||
`i18n extraction requires the '@angular/localize' package.` +
|
||||
` You can add it by using 'ng add @angular/localize'.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Purge old build disk cache.
|
||||
await purgeStaleBuildCache(context);
|
||||
|
||||
// Normalize options
|
||||
const normalizedOptions = await normalizeOptions(context, projectName, options);
|
||||
const builderName = await context.getBuilderNameForTarget(normalizedOptions.browserTarget);
|
||||
|
||||
// Extract messages based on configured builder
|
||||
// TODO: Implement application/browser-esbuild support
|
||||
let extractionResult;
|
||||
if (
|
||||
builderName === '@angular-devkit/build-angular:application' ||
|
||||
builderName === '@angular-devkit/build-angular:browser-esbuild'
|
||||
) {
|
||||
return {
|
||||
error: 'i18n extraction is currently only supported with the "browser" builder.',
|
||||
success: false,
|
||||
};
|
||||
const { extractMessages } = await import('./application-extraction');
|
||||
extractionResult = await extractMessages(
|
||||
normalizedOptions,
|
||||
builderName,
|
||||
context,
|
||||
localizeToolsModule.MessageExtractor,
|
||||
);
|
||||
} else {
|
||||
// Purge old build disk cache.
|
||||
// Other build systems handle stale cache purging directly.
|
||||
await purgeStaleBuildCache(context);
|
||||
|
||||
const { extractMessages } = await import('./webpack-extraction');
|
||||
extractionResult = await extractMessages(normalizedOptions, builderName, context, transforms);
|
||||
}
|
||||
@ -123,7 +128,11 @@ export async function execute(
|
||||
// Write translation file
|
||||
fs.writeFileSync(normalizedOptions.outFile, content);
|
||||
|
||||
return extractionResult.builderResult;
|
||||
if (normalizedOptions.progress) {
|
||||
context.logger.info(`Extraction Complete. (Messages: ${extractionResult.messages.length})`);
|
||||
}
|
||||
|
||||
return { success: true, outputPath: normalizedOptions.outFile };
|
||||
}
|
||||
|
||||
async function createSerializer(
|
||||
|
@ -34,6 +34,7 @@ ESBUILD_TESTS = [
|
||||
"tests/build/relative-sourcemap.js",
|
||||
"tests/build/styles/**",
|
||||
"tests/commands/add/add-pwa.js",
|
||||
"tests/i18n/extract-ivy*",
|
||||
]
|
||||
|
||||
# Tests excluded for esbuild
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { join } from 'path';
|
||||
import { getGlobalVariable } from '../../utils/env';
|
||||
import { expectFileToMatch, rimraf, writeFile } from '../../utils/fs';
|
||||
import { appendToFile, expectFileToMatch, rimraf, writeFile } from '../../utils/fs';
|
||||
import { installPackage, uninstallPackage } from '../../utils/packages';
|
||||
import { ng } from '../../utils/process';
|
||||
import { updateJsonFile } from '../../utils/project';
|
||||
@ -16,6 +16,8 @@ export default async function () {
|
||||
// Setup an i18n enabled component
|
||||
await ng('generate', 'component', 'i18n-test');
|
||||
await writeFile(join('src/app/i18n-test', 'i18n-test.component.html'), '<p i18n>Hello world</p>');
|
||||
// Actually use the generated component to ensure it is present in the application output
|
||||
await appendToFile('src/app/app.component.html', '<app-i18n-test>');
|
||||
|
||||
// Install correct version
|
||||
let localizeVersion = '@angular/localize@' + readNgVersion();
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { join } from 'path';
|
||||
import { getGlobalVariable } from '../../utils/env';
|
||||
import { expectFileToMatch, writeFile } from '../../utils/fs';
|
||||
import { appendToFile, expectFileToMatch, writeFile } from '../../utils/fs';
|
||||
import { installPackage, uninstallPackage } from '../../utils/packages';
|
||||
import { ng } from '../../utils/process';
|
||||
import { expectToFail } from '../../utils/utils';
|
||||
@ -10,6 +10,8 @@ export default async function () {
|
||||
// Setup an i18n enabled component
|
||||
await ng('generate', 'component', 'i18n-test');
|
||||
await writeFile(join('src/app/i18n-test', 'i18n-test.component.html'), '<p i18n>Hello world</p>');
|
||||
// Actually use the generated component to ensure it is present in the application output
|
||||
await appendToFile('src/app/app.component.html', '<app-i18n-test>');
|
||||
|
||||
// Should fail if `@angular/localize` is missing
|
||||
const { message: message1 } = await expectToFail(() => ng('extract-i18n'));
|
||||
|
Loading…
x
Reference in New Issue
Block a user