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:
Charles Lyding 2023-08-10 19:09:32 -04:00 committed by Alan Agius
parent f067ea1ab6
commit 3c0719bde2
5 changed files with 193 additions and 12 deletions

View File

@ -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;
}

View File

@ -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(

View File

@ -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

View File

@ -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();

View File

@ -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'));