diff --git a/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts b/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts new file mode 100644 index 0000000000..4e1c6851c0 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts @@ -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(); + 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, + 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; +} diff --git a/packages/angular_devkit/build_angular/src/builders/extract-i18n/builder.ts b/packages/angular_devkit/build_angular/src/builders/extract-i18n/builder.ts index 17104f3710..fa34a8b486 100644 --- a/packages/angular_devkit/build_angular/src/builders/extract-i18n/builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/extract-i18n/builder.ts @@ -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( diff --git a/tests/legacy-cli/e2e.bzl b/tests/legacy-cli/e2e.bzl index 57a22478df..37d7d6b157 100644 --- a/tests/legacy-cli/e2e.bzl +++ b/tests/legacy-cli/e2e.bzl @@ -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 diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-ivy-disk-cache.ts b/tests/legacy-cli/e2e/tests/i18n/extract-ivy-disk-cache.ts index 61a2f48e62..ad4535539c 100644 --- a/tests/legacy-cli/e2e/tests/i18n/extract-ivy-disk-cache.ts +++ b/tests/legacy-cli/e2e/tests/i18n/extract-ivy-disk-cache.ts @@ -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'), '

Hello world

'); + // Actually use the generated component to ensure it is present in the application output + await appendToFile('src/app/app.component.html', ''); // Install correct version let localizeVersion = '@angular/localize@' + readNgVersion(); diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-ivy.ts b/tests/legacy-cli/e2e/tests/i18n/extract-ivy.ts index 0fba95b632..bb82c8f1e8 100644 --- a/tests/legacy-cli/e2e/tests/i18n/extract-ivy.ts +++ b/tests/legacy-cli/e2e/tests/i18n/extract-ivy.ts @@ -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'), '

Hello world

'); + // Actually use the generated component to ensure it is present in the application output + await appendToFile('src/app/app.component.html', ''); // Should fail if `@angular/localize` is missing const { message: message1 } = await expectToFail(() => ng('extract-i18n'));