Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): initial i18n extraction support …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
clydin authored and alan-agius4 committed Aug 14, 2023
1 parent f067ea1 commit 3c0719b
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/legacy-cli/e2e.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/legacy-cli/e2e/tests/i18n/extract-ivy-disk-cache.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion tests/legacy-cli/e2e/tests/i18n/extract-ivy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'));
Expand Down

0 comments on commit 3c0719b

Please sign in to comment.