From 0b03829bcefea5c250c6a9ff880a737fcc351b2e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:58:42 -0400 Subject: [PATCH] feat(@angular-devkit/build-angular): move i18n extraction for application builder to new build system package With the `application` builder already within the new `@angular/build` package, the `extract-i18n` builder with application builder support is now also contained within this package. Only the application builder aspects of `extract-i18n` have been moved. The compatibility builder `browser-esbuild` is not supported with `@angular/build:extract-i18n`. The existing `extract-i18n` builder found within `@angular-devkit/build-angular` should continue to be used for both the Webpack-based `browser` builder and the esbuild-based compatibility `browser-esbuild` builder. To maintain backwards compatibility, the existing `@angular-devkit/build-angular:extract-i18n` builder continues to support builders it has previously. No change to existing applications is required. --- goldens/public-api/angular/build/index.md | 12 ++ packages/angular/build/BUILD.bazel | 6 + packages/angular/build/builders.json | 5 + .../extract-i18n/application-extraction.ts | 171 ++++++++++++++++++ .../src/builders/extract-i18n/builder.ts | 165 +++++++++++++++++ .../build/src/builders/extract-i18n/index.ts | 14 ++ .../src/builders/extract-i18n/options.ts | 90 +++++++++ .../src/builders/extract-i18n/schema.json | 34 ++++ packages/angular/build/src/index.ts | 5 + packages/angular/cli/BUILD.bazel | 2 + .../cli/lib/config/workspace-schema.json | 46 +++++ 11 files changed, 550 insertions(+) create mode 100644 packages/angular/build/src/builders/extract-i18n/application-extraction.ts create mode 100644 packages/angular/build/src/builders/extract-i18n/builder.ts create mode 100644 packages/angular/build/src/builders/extract-i18n/index.ts create mode 100644 packages/angular/build/src/builders/extract-i18n/options.ts create mode 100644 packages/angular/build/src/builders/extract-i18n/schema.json diff --git a/goldens/public-api/angular/build/index.md b/goldens/public-api/angular/build/index.md index 6ca5168f6145..cad7c7b3fbf1 100644 --- a/goldens/public-api/angular/build/index.md +++ b/goldens/public-api/angular/build/index.md @@ -153,6 +153,18 @@ export function executeDevServerBuilder(options: DevServerBuilderOptions, contex indexHtmlTransformer?: IndexHtmlTransform; }): AsyncIterable; +// @public +export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): Promise; + +// @public +export interface ExtractI18nBuilderOptions { + buildTarget: string; + format?: Format; + outFile?: string; + outputPath?: string; + progress?: boolean; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index fda717f67f37..8899bc1874bd 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -17,6 +17,11 @@ ts_json_schema( src = "src/builders/dev-server/schema.json", ) +ts_json_schema( + name = "extract_i18n_schema", + src = "src/builders/extract-i18n/schema.json", +) + ts_library( name = "build", package_name = "@angular/build", @@ -34,6 +39,7 @@ ts_library( ) + [ "//packages/angular/build:src/builders/application/schema.ts", "//packages/angular/build:src/builders/dev-server/schema.ts", + "//packages/angular/build:src/builders/extract-i18n/schema.ts", ], data = glob( include = [ diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json index b12954b8cc81..b0174fc3fee9 100644 --- a/packages/angular/build/builders.json +++ b/packages/angular/build/builders.json @@ -9,6 +9,11 @@ "implementation": "./src/builders/dev-server/index", "schema": "./src/builders/dev-server/schema.json", "description": "Execute a development server for an application." + }, + "extract-i18n": { + "implementation": "./src/builders/extract-i18n/index", + "schema": "./src/builders/extract-i18n/schema.json", + "description": "Extract i18n messages from an application." } } } diff --git a/packages/angular/build/src/builders/extract-i18n/application-extraction.ts b/packages/angular/build/src/builders/extract-i18n/application-extraction.ts new file mode 100644 index 000000000000..22b318c25890 --- /dev/null +++ b/packages/angular/build/src/builders/extract-i18n/application-extraction.ts @@ -0,0 +1,171 @@ +/** + * @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 { + ApplicationBuilderExtensions, + ApplicationBuilderInternalOptions, +} from '../application/options'; +import type { NormalizedExtractI18nOptions } from './options'; + +export async function extractMessages( + options: NormalizedExtractI18nOptions, + builderName: string, + context: BuilderContext, + extractorConstructor: typeof MessageExtractor, + extensions?: ApplicationBuilderExtensions, +): Promise<{ + builderResult: BuilderOutput; + basePath: string; + messages: LocalizeMessage[]; + useLegacyIds: boolean; +}> { + const messages: LocalizeMessage[] = []; + + // Setup the build options for the application based on the buildTarget option + const buildOptions = (await context.validateOptions( + await context.getTargetOptions(options.buildTarget), + builderName, + )) as unknown as ApplicationBuilderInternalOptions; + buildOptions.optimization = false; + buildOptions.sourceMap = { scripts: true, vendor: true, styles: false }; + buildOptions.localize = false; + buildOptions.budgets = undefined; + buildOptions.index = false; + buildOptions.serviceWorker = false; + + buildOptions.ssr = false; + buildOptions.appShell = false; + buildOptions.prerender = false; + + // Build the application with the build options + let builderResult; + try { + for await (const result of buildApplicationInternal( + buildOptions, + context, + { write: false }, + extensions, + )) { + 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/build/src/builders/extract-i18n/builder.ts b/packages/angular/build/src/builders/extract-i18n/builder.ts new file mode 100644 index 000000000000..ac4ab5f3fd81 --- /dev/null +++ b/packages/angular/build/src/builders/extract-i18n/builder.ts @@ -0,0 +1,165 @@ +/** + * @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 { Diagnostics } from '@angular/localize/tools'; +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import fs from 'node:fs'; +import path from 'node:path'; +import { loadEsmModule } from '../../utils/load-esm'; +import { assertCompatibleAngularVersion } from '../../utils/version'; +import type { ApplicationBuilderExtensions } from '../application/options'; +import { normalizeOptions } from './options'; +import { Schema as ExtractI18nBuilderOptions, Format } from './schema'; + +/** + * @experimental Direct usage of this function is considered experimental. + */ +export async function execute( + options: ExtractI18nBuilderOptions, + context: BuilderContext, + extensions?: ApplicationBuilderExtensions, +): Promise { + // Determine project name from builder context target + const projectName = context.target?.project; + if (!projectName) { + context.logger.error(`The 'extract-i18n' builder requires a target to be specified.`); + + return { success: false }; + } + + // Check Angular version. + assertCompatibleAngularVersion(context.workspaceRoot); + + // Load the Angular localize package. + // The package is a peer dependency and might not be present + let localizeToolsModule; + try { + localizeToolsModule = + await loadEsmModule('@angular/localize/tools'); + } catch { + return { + success: false, + error: + `i18n extraction requires the '@angular/localize' package.` + + ` You can add it by using 'ng add @angular/localize'.`, + }; + } + + // Normalize options + const normalizedOptions = await normalizeOptions(context, projectName, options); + const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget); + + // Extract messages based on configured builder + const { extractMessages } = await import('./application-extraction'); + const extractionResult = await extractMessages( + normalizedOptions, + builderName, + context, + localizeToolsModule.MessageExtractor, + extensions, + ); + + // Return the builder result if it failed + if (!extractionResult.builderResult.success) { + return extractionResult.builderResult; + } + + // Perform duplicate message checks + const { checkDuplicateMessages } = localizeToolsModule; + + // The filesystem is used to create a relative path for each file + // from the basePath. This relative path is then used in the error message. + const checkFileSystem = { + relative(from: string, to: string): string { + return path.relative(from, to); + }, + }; + const diagnostics = checkDuplicateMessages( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + checkFileSystem as any, + extractionResult.messages, + 'warning', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extractionResult.basePath as any, + ); + if (diagnostics.messages.length > 0) { + context.logger.warn(diagnostics.formatDiagnostics('')); + } + + // Serialize all extracted messages + const serializer = await createSerializer( + localizeToolsModule, + normalizedOptions.format, + normalizedOptions.i18nOptions.sourceLocale, + extractionResult.basePath, + extractionResult.useLegacyIds, + diagnostics, + ); + const content = serializer.serialize(extractionResult.messages); + + // Ensure directory exists + const outputPath = path.dirname(normalizedOptions.outFile); + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }); + } + + // Write translation file + fs.writeFileSync(normalizedOptions.outFile, content); + + if (normalizedOptions.progress) { + context.logger.info(`Extraction Complete. (Messages: ${extractionResult.messages.length})`); + } + + return { success: true, outputPath: normalizedOptions.outFile }; +} + +async function createSerializer( + localizeToolsModule: typeof import('@angular/localize/tools'), + format: Format, + sourceLocale: string, + basePath: string, + useLegacyIds: boolean, + diagnostics: Diagnostics, +) { + const { + XmbTranslationSerializer, + LegacyMessageIdMigrationSerializer, + ArbTranslationSerializer, + Xliff1TranslationSerializer, + Xliff2TranslationSerializer, + SimpleJsonTranslationSerializer, + } = localizeToolsModule; + + switch (format) { + case Format.Xmb: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new XmbTranslationSerializer(basePath as any, useLegacyIds); + case Format.Xlf: + case Format.Xlif: + case Format.Xliff: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new Xliff1TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {}); + case Format.Xlf2: + case Format.Xliff2: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new Xliff2TranslationSerializer(sourceLocale, basePath as any, useLegacyIds, {}); + case Format.Json: + return new SimpleJsonTranslationSerializer(sourceLocale); + case Format.LegacyMigrate: + return new LegacyMessageIdMigrationSerializer(diagnostics); + case Format.Arb: + const fileSystem = { + relative(from: string, to: string): string { + return path.relative(from, to); + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new ArbTranslationSerializer(sourceLocale, basePath as any, fileSystem as any); + } +} diff --git a/packages/angular/build/src/builders/extract-i18n/index.ts b/packages/angular/build/src/builders/extract-i18n/index.ts new file mode 100644 index 000000000000..e38999f63d1b --- /dev/null +++ b/packages/angular/build/src/builders/extract-i18n/index.ts @@ -0,0 +1,14 @@ +/** + * @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 { createBuilder } from '@angular-devkit/architect'; +import { execute } from './builder'; +import type { Schema as ExtractI18nBuilderOptions } from './schema'; + +export { ExtractI18nBuilderOptions, execute }; +export default createBuilder(execute); diff --git a/packages/angular/build/src/builders/extract-i18n/options.ts b/packages/angular/build/src/builders/extract-i18n/options.ts new file mode 100644 index 000000000000..f09d9dad23a7 --- /dev/null +++ b/packages/angular/build/src/builders/extract-i18n/options.ts @@ -0,0 +1,90 @@ +/** + * @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 { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import { fail } from 'node:assert'; +import path from 'node:path'; +import { createI18nOptions } from '../../utils/i18n-options'; +import { Schema as ExtractI18nOptions, Format } from './schema'; + +export type NormalizedExtractI18nOptions = Awaited>; + +/** + * Normalize the user provided options by creating full paths for all path based options + * and converting multi-form options into a single form that can be directly used + * by the build process. + * + * @param context The context for current builder execution. + * @param projectName The name of the project for the current execution. + * @param options An object containing the options to use for the build. + * @returns An object containing normalized options required to perform the build. + */ +export async function normalizeOptions( + context: BuilderContext, + projectName: string, + options: ExtractI18nOptions, +) { + const workspaceRoot = context.workspaceRoot; + const projectMetadata = await context.getProjectMetadata(projectName); + const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? ''); + + // Target specifier defaults to the current project's build target with no specified configuration + const buildTargetSpecifier = options.buildTarget ?? ':'; + const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); + + const i18nOptions = createI18nOptions(projectMetadata); + + // Normalize xliff format extensions + let format = options.format; + switch (format) { + case undefined: + // Default format is xliff1 + case Format.Xlf: + case Format.Xlif: + case Format.Xliff: + format = Format.Xliff; + break; + case Format.Xlf2: + case Format.Xliff2: + format = Format.Xliff2; + break; + } + + let outFile = options.outFile || getDefaultOutFile(format); + if (options.outputPath) { + outFile = path.join(options.outputPath, outFile); + } + outFile = path.resolve(context.workspaceRoot, outFile); + + return { + workspaceRoot, + projectRoot, + buildTarget, + i18nOptions, + format, + outFile, + progress: options.progress ?? true, + }; +} + +function getDefaultOutFile(format: Format) { + switch (format) { + case Format.Xmb: + return 'messages.xmb'; + case Format.Xliff: + case Format.Xliff2: + return 'messages.xlf'; + case Format.Json: + case Format.LegacyMigrate: + return 'messages.json'; + case Format.Arb: + return 'messages.arb'; + default: + fail(`Invalid Format enum value: ${format as unknown}`); + } +} diff --git a/packages/angular/build/src/builders/extract-i18n/schema.json b/packages/angular/build/src/builders/extract-i18n/schema.json new file mode 100644 index 000000000000..8a8cfe4eb5df --- /dev/null +++ b/packages/angular/build/src/builders/extract-i18n/schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Extract i18n Target", + "description": "Extract i18n target options for Build Facade.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "A builder target to extract i18n messages in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.", + "pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$" + }, + "format": { + "type": "string", + "description": "Output format for the generated file.", + "default": "xlf", + "enum": ["xmb", "xlf", "xlif", "xliff", "xlf2", "xliff2", "json", "arb", "legacy-migrate"] + }, + "progress": { + "type": "boolean", + "description": "Log progress to the console.", + "default": true + }, + "outputPath": { + "type": "string", + "description": "Path where output will be placed." + }, + "outFile": { + "type": "string", + "description": "Name of the file to output." + } + }, + "additionalProperties": false, + "required": ["buildTarget"] +} diff --git a/packages/angular/build/src/index.ts b/packages/angular/build/src/index.ts index e39ca84f9686..fde77c895959 100644 --- a/packages/angular/build/src/index.ts +++ b/packages/angular/build/src/index.ts @@ -19,3 +19,8 @@ export { DevServerBuilderOptions, DevServerBuilderOutput, } from './builders/dev-server'; + +export { + execute as executeExtractI18nBuilder, + ExtractI18nBuilderOptions, +} from './builders/extract-i18n'; diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index 95e5f0904c99..2f7c426d8ddd 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -78,6 +78,8 @@ ts_library( # @external_begin CLI_SCHEMA_DATA = [ "//packages/angular/build:src/builders/application/schema.json", + "//packages/angular/build:src/builders/dev-server/schema.json", + "//packages/angular/build:src/builders/extract-i18n/schema.json", "//packages/angular_devkit/build_angular:src/builders/app-shell/schema.json", "//packages/angular_devkit/build_angular:src/builders/browser/schema.json", "//packages/angular_devkit/build_angular:src/builders/browser-esbuild/schema.json", diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index eafbeb452891..650ae3ae18f2 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -355,6 +355,8 @@ "not": { "enum": [ "@angular/build:application", + "@angular/build:dev-server", + "@angular/build:extract-i18n", "@angular-devkit/build-angular:application", "@angular-devkit/build-angular:app-shell", "@angular-devkit/build-angular:browser", @@ -500,6 +502,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -522,6 +546,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:extract-i18n" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false,