mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-18 03:23:57 +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 {
|
} catch {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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
|
// Normalize options
|
||||||
const normalizedOptions = await normalizeOptions(context, projectName, options);
|
const normalizedOptions = await normalizeOptions(context, projectName, options);
|
||||||
const builderName = await context.getBuilderNameForTarget(normalizedOptions.browserTarget);
|
const builderName = await context.getBuilderNameForTarget(normalizedOptions.browserTarget);
|
||||||
|
|
||||||
// Extract messages based on configured builder
|
// Extract messages based on configured builder
|
||||||
// TODO: Implement application/browser-esbuild support
|
|
||||||
let extractionResult;
|
let extractionResult;
|
||||||
if (
|
if (
|
||||||
builderName === '@angular-devkit/build-angular:application' ||
|
builderName === '@angular-devkit/build-angular:application' ||
|
||||||
builderName === '@angular-devkit/build-angular:browser-esbuild'
|
builderName === '@angular-devkit/build-angular:browser-esbuild'
|
||||||
) {
|
) {
|
||||||
return {
|
const { extractMessages } = await import('./application-extraction');
|
||||||
error: 'i18n extraction is currently only supported with the "browser" builder.',
|
extractionResult = await extractMessages(
|
||||||
success: false,
|
normalizedOptions,
|
||||||
};
|
builderName,
|
||||||
|
context,
|
||||||
|
localizeToolsModule.MessageExtractor,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Purge old build disk cache.
|
||||||
|
// Other build systems handle stale cache purging directly.
|
||||||
|
await purgeStaleBuildCache(context);
|
||||||
|
|
||||||
const { extractMessages } = await import('./webpack-extraction');
|
const { extractMessages } = await import('./webpack-extraction');
|
||||||
extractionResult = await extractMessages(normalizedOptions, builderName, context, transforms);
|
extractionResult = await extractMessages(normalizedOptions, builderName, context, transforms);
|
||||||
}
|
}
|
||||||
@ -123,7 +128,11 @@ export async function execute(
|
|||||||
// Write translation file
|
// Write translation file
|
||||||
fs.writeFileSync(normalizedOptions.outFile, content);
|
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(
|
async function createSerializer(
|
||||||
|
@ -34,6 +34,7 @@ ESBUILD_TESTS = [
|
|||||||
"tests/build/relative-sourcemap.js",
|
"tests/build/relative-sourcemap.js",
|
||||||
"tests/build/styles/**",
|
"tests/build/styles/**",
|
||||||
"tests/commands/add/add-pwa.js",
|
"tests/commands/add/add-pwa.js",
|
||||||
|
"tests/i18n/extract-ivy*",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tests excluded for esbuild
|
# Tests excluded for esbuild
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getGlobalVariable } from '../../utils/env';
|
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 { installPackage, uninstallPackage } from '../../utils/packages';
|
||||||
import { ng } from '../../utils/process';
|
import { ng } from '../../utils/process';
|
||||||
import { updateJsonFile } from '../../utils/project';
|
import { updateJsonFile } from '../../utils/project';
|
||||||
@ -16,6 +16,8 @@ export default async function () {
|
|||||||
// Setup an i18n enabled component
|
// Setup an i18n enabled component
|
||||||
await ng('generate', 'component', 'i18n-test');
|
await ng('generate', 'component', 'i18n-test');
|
||||||
await writeFile(join('src/app/i18n-test', 'i18n-test.component.html'), '<p i18n>Hello world</p>');
|
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
|
// Install correct version
|
||||||
let localizeVersion = '@angular/localize@' + readNgVersion();
|
let localizeVersion = '@angular/localize@' + readNgVersion();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getGlobalVariable } from '../../utils/env';
|
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 { installPackage, uninstallPackage } from '../../utils/packages';
|
||||||
import { ng } from '../../utils/process';
|
import { ng } from '../../utils/process';
|
||||||
import { expectToFail } from '../../utils/utils';
|
import { expectToFail } from '../../utils/utils';
|
||||||
@ -10,6 +10,8 @@ export default async function () {
|
|||||||
// Setup an i18n enabled component
|
// Setup an i18n enabled component
|
||||||
await ng('generate', 'component', 'i18n-test');
|
await ng('generate', 'component', 'i18n-test');
|
||||||
await writeFile(join('src/app/i18n-test', 'i18n-test.component.html'), '<p i18n>Hello world</p>');
|
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
|
// Should fail if `@angular/localize` is missing
|
||||||
const { message: message1 } = await expectToFail(() => ng('extract-i18n'));
|
const { message: message1 } = await expectToFail(() => ng('extract-i18n'));
|
||||||
|
Loading…
x
Reference in New Issue
Block a user