diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index fce939058857..ee1d25801f8e 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -31,6 +31,7 @@ import { maxWorkers } from '../../utils/environment-options'; import { prerenderPages } from '../../utils/server-rendering/prerender'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; +import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; // eslint-disable-next-line max-lines-per-function @@ -59,6 +60,12 @@ export async function executeBuild( const browsers = getSupportedBrowsers(projectRoot, context.logger); const target = transformSupportedBrowsersToTargets(browsers); + // Load active translations if inlining + // TODO: Integrate into watch mode and only load changed translations + if (options.i18nOptions.shouldInline) { + await loadActiveTranslations(context, options.i18nOptions); + } + // Reuse rebuild state or create new bundle contexts for code and global stylesheets let bundlerContexts = rebuildState?.rebuildContexts; const codeBundleCache = @@ -154,14 +161,18 @@ export async function executeBuild( let indexContentOutputNoCssInlining: string | undefined; // Generate index HTML file - if (indexHtmlOptions) { + // If localization is enabled, index generation is handled in the inlining process. + // NOTE: Localization with SSR is not currently supported. + if (indexHtmlOptions && !options.i18nOptions.shouldInline) { const { content, contentWithoutCriticalCssInlined, errors, warnings } = await generateIndexHtml( initialFiles, - executionResult, + executionResult.outputFiles, { ...options, optimizationOptions, }, + // Set lang attribute to the defined source locale if present + options.i18nOptions.hasDefinedSourceLocale ? options.i18nOptions.sourceLocale : undefined, ); indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined; @@ -249,6 +260,11 @@ export async function executeBuild( const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; context.logger.info(`Application bundle generation complete. [${buildTime.toFixed(3)} seconds]`); + // Perform i18n translation inlining if enabled + if (options.i18nOptions.shouldInline) { + await inlineI18n(options, executionResult, initialFiles); + } + return executionResult; } diff --git a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts new file mode 100644 index 000000000000..05787285c3d4 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts @@ -0,0 +1,155 @@ +/** + * @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 } from '@angular-devkit/architect'; +import { join } from 'node:path'; +import { InitialFileRecord } from '../../tools/esbuild/bundler-context'; +import { ExecutionResult } from '../../tools/esbuild/bundler-execution-result'; +import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; +import { generateIndexHtml } from '../../tools/esbuild/index-html-generator'; +import { createOutputFileFromText } from '../../tools/esbuild/utils'; +import { maxWorkers } from '../../utils/environment-options'; +import { loadTranslations } from '../../utils/i18n-options'; +import { createTranslationLoader } from '../../utils/load-translations'; +import { urlJoin } from '../../utils/url'; +import { NormalizedApplicationBuildOptions } from './options'; + +/** + * Inlines all active locales as specified by the application build options into all + * application JavaScript files created during the build. + * @param options The normalized application builder options used to create the build. + * @param executionResult The result of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + */ +export async function inlineI18n( + options: NormalizedApplicationBuildOptions, + executionResult: ExecutionResult, + initialFiles: Map, +): Promise { + // Create the multi-threaded inliner with common options and the files generated from the build. + const inliner = new I18nInliner( + { + missingTranslation: options.i18nOptions.missingTranslationBehavior ?? 'warning', + outputFiles: executionResult.outputFiles, + shouldOptimize: options.optimizationOptions.scripts, + }, + maxWorkers, + ); + + // For each active locale, use the inliner to process the output files of the build. + const updatedOutputFiles = []; + const updatedAssetFiles = []; + try { + for (const locale of options.i18nOptions.inlineLocales) { + // A locale specific set of files is returned from the inliner. + const localeOutputFiles = await inliner.inlineForLocale( + locale, + options.i18nOptions.locales[locale].translation, + ); + + // Generate locale specific index HTML files + if (options.indexHtmlOptions) { + const { content, errors, warnings } = await generateIndexHtml( + initialFiles, + localeOutputFiles, + { + ...options, + baseHref: + getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref, + }, + locale, + ); + + localeOutputFiles.push(createOutputFileFromText(options.indexHtmlOptions.output, content)); + } + + // Update directory with locale base + if (options.i18nOptions.flatOutput !== true) { + localeOutputFiles.forEach((file) => { + file.path = join(locale, file.path); + }); + + for (const assetFile of executionResult.assetFiles) { + updatedAssetFiles.push({ + source: assetFile.source, + destination: join(locale, assetFile.destination), + }); + } + } + + updatedOutputFiles.push(...localeOutputFiles); + } + } finally { + await inliner.close(); + } + + // Update the result with all localized files + executionResult.outputFiles = updatedOutputFiles; + + // Assets are only changed if not using the flat output option + if (options.i18nOptions.flatOutput !== true) { + executionResult.assetFiles = updatedAssetFiles; + } +} + +function getLocaleBaseHref( + baseHref: string | undefined, + i18n: NormalizedApplicationBuildOptions['i18nOptions'], + locale: string, +): string | undefined { + if (i18n.flatOutput) { + return undefined; + } + + if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') { + return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`); + } + + return undefined; +} + +/** + * Loads all active translations using the translation loaders from the `@angular/localize` package. + * @param context The architect builder context for the current build. + * @param i18n The normalized i18n options to use. + */ +export async function loadActiveTranslations( + context: BuilderContext, + i18n: NormalizedApplicationBuildOptions['i18nOptions'], +) { + // Load locale data and translations (if present) + let loader; + for (const [locale, desc] of Object.entries(i18n.locales)) { + if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) { + continue; + } + + if (!desc.files.length) { + continue; + } + + loader ??= await createTranslationLoader(); + + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + undefined, + i18n.duplicateTranslationBehavior, + ); + } +} diff --git a/packages/angular_devkit/build_angular/src/builders/application/index.ts b/packages/angular_devkit/build_angular/src/builders/application/index.ts index c834bfb7cac0..f9426ffb5c4e 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/index.ts @@ -42,6 +42,23 @@ export async function* buildApplicationInternal( } const normalizedOptions = await normalizeOptions(context, projectName, options); + + // Warn about prerender/ssr not yet supporting localize + if ( + normalizedOptions.i18nOptions.shouldInline && + (normalizedOptions.prerenderOptions || + normalizedOptions.ssrOptions || + normalizedOptions.appShellOptions) + ) { + context.logger.warn( + `Prerendering, App Shell, and SSR are not yet supported with the 'localize' option and will be disabled for this build.`, + ); + normalizedOptions.prerenderOptions = + normalizedOptions.ssrOptions = + normalizedOptions.appShellOptions = + undefined; + } + yield* runEsBuildBuildAction( (rebuildState) => executeBuild(normalizedOptions, context, rebuildState), { diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts index 1d1fb9311233..0a8664ed5ee1 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -41,6 +41,12 @@ interface InternalOptions { * Currently used by the dev-server to support prebundling. */ externalPackages?: boolean; + + /** + * Forces the output from the localize post-processing to not create nested directories per locale output. + * This is only used by the development server which currently only supports a single locale per build. + */ + forceI18nFlatOutput?: boolean; } /** Full set of options for `application` builder. */ @@ -87,6 +93,9 @@ export async function normalizeOptions( } = createI18nOptions(projectMetadata, options.localize); i18nOptions.duplicateTranslationBehavior = options.i18nDuplicateTranslation; i18nOptions.missingTranslationBehavior = options.i18nMissingTranslation; + if (options.forceI18nFlatOutput) { + i18nOptions.flatOutput = true; + } const entryPoints = normalizeEntryPoints(workspaceRoot, options.browser, options.entryPoints); const tsconfig = path.join(workspaceRoot, options.tsConfig); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/builder-status-warnings.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/builder-status-warnings.ts index 213710b9f0b8..b4b5f9d2ad62 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/builder-status-warnings.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/builder-status-warnings.ts @@ -12,12 +12,6 @@ import { Schema as BrowserBuilderOptions } from './schema'; const UNSUPPORTED_OPTIONS: Array = [ 'budgets', - // * i18n support - 'localize', - // The following two have no effect when localize is not enabled - // 'i18nDuplicateTranslation', - // 'i18nMissingTranslation', - // * Deprecated 'deployUrl', diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts index 993733fc0387..6856bf1fbc1d 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts @@ -18,6 +18,7 @@ import type { AddressInfo } from 'node:net'; import path, { posix } from 'node:path'; import type { Connect, InlineConfig, ViteDevServer } from 'vite'; import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; +import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin'; import { RenderOptions, renderPage } from '../../utils/server-rendering/render-page'; import { getIndexOutputFile } from '../../utils/webpack-browser-config'; import { buildEsbuildBrowser } from '../browser-esbuild'; @@ -66,6 +67,21 @@ export async function* serveWithVite( serverOptions.servePath = browserOptions.baseHref; } + // The development server currently only supports a single locale when localizing. + // This matches the behavior of the Webpack-based development server but could be expanded in the future. + if ( + browserOptions.localize === true || + (Array.isArray(browserOptions.localize) && browserOptions.localize.length > 1) + ) { + context.logger.warn( + 'Localization (`localize` option) has been disabled. The development server only supports localizing a single locale per build.', + ); + browserOptions.localize = false; + } else if (browserOptions.localize) { + // When localization is enabled with a single locale, force a flat path to maintain behavior with the existing Webpack-based dev server. + browserOptions.forceI18nFlatOutput = true; + } + // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new JavaScriptTransformer( // Always enable JIT linking to support applications built with and without AOT. @@ -310,6 +326,7 @@ export async function setupServer( external: prebundleExclude, }, plugins: [ + createAngularLocaleDataPlugin(), { name: 'vite:angular-memory', // Ensures plugin hooks run before built-in Vite hooks 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 index 4e1c6851c0b8..699641f107b4 100644 --- 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 @@ -36,6 +36,7 @@ export async function extractMessages( )) as unknown as ApplicationBuilderInternalOptions; buildOptions.optimization = false; buildOptions.sourceMap = { scripts: true, vendor: true }; + buildOptions.localize = false; let build; if (builderName === '@angular-devkit/build-angular:application') { diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts index 8b2e80afdac3..f59cc498b983 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts @@ -8,12 +8,14 @@ import type { BuildOptions } from 'esbuild'; import assert from 'node:assert'; +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { allowMangle } from '../../utils/environment-options'; import { SourceFileCache, createCompilerPlugin } from './angular/compiler-plugin'; import { createCompilerPluginOptions } from './compiler-plugin-options'; +import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; import { getFeatureSupport } from './utils'; @@ -60,11 +62,47 @@ export function createBrowserCodeBundleOptions( } const polyfills = options.polyfills ? [...options.polyfills] : []; + + // Angular JIT mode requires the runtime compiler if (jit) { polyfills.push('@angular/compiler'); } - if (polyfills?.length) { + // Add Angular's global locale data if i18n options are present. + // Locale data should go first so that project provided polyfill code can augment if needed. + let needLocaleDataPlugin = false; + if (options.i18nOptions.shouldInline) { + // When inlining, a placeholder is used to allow the post-processing step to inject the $localize locale identifier + polyfills.unshift('angular:locale/placeholder'); + buildOptions.plugins?.unshift( + createVirtualModulePlugin({ + namespace: 'angular:locale/placeholder', + entryPointOnly: false, + loadContent: () => ({ + contents: `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n`, + loader: 'js', + resolveDir: workspaceRoot, + }), + }), + ); + + // Add locale data for all active locales + // TODO: Inject each individually within the inlining process itself + for (const locale of options.i18nOptions.inlineLocales) { + polyfills.unshift(`angular:locale/data:${locale}`); + } + needLocaleDataPlugin = true; + } else if (options.i18nOptions.hasDefinedSourceLocale) { + // When not inlining and a source local is present, use the source locale data directly + polyfills.unshift(`angular:locale/data:${options.i18nOptions.sourceLocale}`); + needLocaleDataPlugin = true; + } + if (needLocaleDataPlugin) { + buildOptions.plugins?.push(createAngularLocaleDataPlugin()); + } + + // Add polyfill entry point if polyfills are present + if (polyfills.length) { const namespace = 'angular:polyfills'; buildOptions.entryPoints = { ...buildOptions.entryPoints, @@ -244,6 +282,21 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu jit, } = options; + // Ensure unique hashes for i18n translation changes when using post-process inlining. + // This hash value is added as a footer to each file and ensures that the output file names (with hashes) + // change when translation files have changed. If this is not done the post processed files may have + // different content but would retain identical production file names which would lead to browser caching problems. + let footer; + if (options.i18nOptions.shouldInline) { + // Update file hashes to include translation file content + const i18nHash = Object.values(options.i18nOptions.locales).reduce( + (data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'), + '', + ); + + footer = { js: `/**i18n:${createHash('sha256').update(i18nHash).digest('hex')}*/` }; + } + return { absWorkingDir: workspaceRoot, bundle: true, @@ -274,5 +327,6 @@ function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): Bu ...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined), 'ngJitMode': jit ? 'true' : 'false', }, + footer, }; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts index 40e879ed2bbe..b64e66c37980 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts @@ -22,8 +22,8 @@ export interface RebuildState { * Represents the result of a single builder execute call. */ export class ExecutionResult { - readonly outputFiles: OutputFile[] = []; - readonly assetFiles: { source: string; destination: string }[] = []; + outputFiles: OutputFile[] = []; + assetFiles: { source: string; destination: string }[] = []; constructor( private rebuildContexts: BundlerContext[], diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts new file mode 100644 index 000000000000..186236133182 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts @@ -0,0 +1,189 @@ +/** + * @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 remapping, { SourceMapInput } from '@ampproject/remapping'; +import { PluginObj, parseSync, transformFromAstAsync, types } from '@babel/core'; +import assert from 'node:assert'; +import { workerData } from 'node:worker_threads'; +import { assertIsError } from '../../utils/error'; +import { loadEsmModule } from '../../utils/load-esm'; + +/** + * The options passed to the inliner for each file request + */ +interface InlineRequest { + /** + * The filename that should be processed. The data for the file is provided to the Worker + * during Worker initialization. + */ + filename: string; + /** + * The locale specifier that should be used during the inlining process of the file. + */ + locale: string; + /** + * The translation messages for the locale that should be used during the inlining process of the file. + */ + translation?: Record; +} + +// Extract the application files and common options used for inline requests from the Worker context +// TODO: Evaluate overall performance difference of passing translations here as well +const { files, missingTranslation, shouldOptimize } = (workerData || {}) as { + files: ReadonlyMap; + missingTranslation: 'error' | 'warning' | 'ignore'; + shouldOptimize: boolean; +}; + +/** + * Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage. + * This function is the main entry for the Worker's action that is called by the worker pool. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An array containing the inlined file and optional map content. + */ +export default async function inlineLocale(request: InlineRequest) { + const data = files.get(request.filename); + + assert(data !== undefined, `Invalid inline request for file '${request.filename}'.`); + + const code = await data.text(); + const map = await files.get(request.filename + '.map')?.text(); + const result = await transformWithBabel( + code, + map && (JSON.parse(map) as SourceMapInput), + request, + ); + + // TODO: Return diagnostics + // TODO: Consider buffer transfer instead of string copying + const response = [{ file: request.filename, contents: result.code }]; + if (result.map) { + response.push({ file: request.filename + '.map', contents: result.map }); + } + + return response; +} + +/** + * A Type representing the localize tools module. + */ +type LocalizeUtilityModule = typeof import('@angular/localize/tools'); + +/** + * Cached instance of the `@angular/localize/tools` module. + * This is used to remove the need to repeatedly import the module per file translation. + */ +let localizeToolsModule: LocalizeUtilityModule | undefined; + +/** + * Attempts to load the `@angular/localize/tools` module containing the functionality to + * perform the file translations. + * This module must be dynamically loaded as it is an ESM module and this file is CommonJS. + */ +async function loadLocalizeTools(): Promise { + // Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + localizeToolsModule ??= await loadEsmModule('@angular/localize/tools'); + + return localizeToolsModule; +} + +/** + * Creates the needed Babel plugins to inline a given locale and translation for a JavaScript file. + * @param locale A string containing the locale specifier to use. + * @param translation A object record containing locale specific messages to use. + * @returns An array of Babel plugins. + */ +async function createI18nPlugins(locale: string, translation: Record | undefined) { + const { Diagnostics, makeEs2015TranslatePlugin } = await loadLocalizeTools(); + + const plugins: PluginObj[] = []; + const diagnostics = new Diagnostics(); + + plugins.push( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeEs2015TranslatePlugin(diagnostics, (translation || {}) as any, { + missingTranslation: translation === undefined ? 'ignore' : missingTranslation, + }), + ); + + // Create a plugin to replace the locale specifier constant inject by the build system with the actual specifier + plugins.push({ + visitor: { + StringLiteral(path) { + if (path.node.value === '___NG_LOCALE_INSERT___') { + path.replaceWith(types.stringLiteral(locale)); + } + }, + }, + }); + + return { diagnostics, plugins }; +} + +/** + * Transforms a JavaScript file using Babel to inline the request locale and translation. + * @param code A string containing the JavaScript code to transform. + * @param map A sourcemap object for the provided JavaScript code. + * @param options The inline request options to use. + * @returns An object containing the code, map, and diagnostics from the transformation. + */ +async function transformWithBabel( + code: string, + map: SourceMapInput | undefined, + options: InlineRequest, +) { + let ast; + try { + ast = parseSync(code, { + babelrc: false, + configFile: false, + sourceType: 'unambiguous', + filename: options.filename, + }); + } catch (error) { + assertIsError(error); + + // Make the error more readable. + // Same errors will contain the full content of the file as the error message + // Which makes it hard to find the actual error message. + const index = error.message.indexOf(')\n'); + const msg = index !== -1 ? error.message.slice(0, index + 1) : error.message; + throw new Error(`${msg}\nAn error occurred inlining file "${options.filename}"`); + } + + if (!ast) { + throw new Error(`Unknown error occurred inlining file "${options.filename}"`); + } + + const { diagnostics, plugins } = await createI18nPlugins(options.locale, options.translation); + const transformResult = await transformFromAstAsync(ast, code, { + filename: options.filename, + // false is a valid value but not included in the type definition + inputSourceMap: false as unknown as undefined, + sourceMaps: !!map, + compact: shouldOptimize, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins, + }); + + if (!transformResult || !transformResult.code) { + throw new Error(`Unknown error occurred processing bundle for "${options.filename}".`); + } + + let outputMap; + if (map && transformResult.map) { + outputMap = remapping([transformResult.map as SourceMapInput, map], () => null); + } + + return { code: transformResult.code, map: outputMap && JSON.stringify(outputMap), diagnostics }; +} diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts new file mode 100644 index 000000000000..20dd1389f762 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts @@ -0,0 +1,138 @@ +/** + * @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 { OutputFile } from 'esbuild'; +import Piscina from 'piscina'; +import { cloneOutputFile, createOutputFileFromData } from './utils'; + +/** + * A keyword used to indicate if a JavaScript file may require inlining of translations. + * This keyword is used to avoid processing files that would not otherwise need i18n processing. + */ +const LOCALIZE_KEYWORD = '$localize'; + +/** + * Inlining options that should apply to all transformed code. + */ +export interface I18nInlinerOptions { + missingTranslation: 'error' | 'warning' | 'ignore'; + outputFiles: OutputFile[]; + shouldOptimize?: boolean; +} + +/** + * A class that performs i18n translation inlining of JavaScript code. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Inlining is only performed on code that contains the + * localize function (`$localize`). + */ +export class I18nInliner { + #workerPool: Piscina; + readonly #localizeFiles: ReadonlyMap; + readonly #unmodifiedFiles: Array; + + constructor(options: I18nInlinerOptions, maxThreads?: number) { + this.#unmodifiedFiles = []; + + const files = new Map(); + const pendingMaps = []; + for (const file of options.outputFiles) { + if (file.path.endsWith('.js')) { + // Check if localizations are present + const contentBuffer = Buffer.isBuffer(file.contents) + ? file.contents + : Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength); + const hasLocalize = contentBuffer.includes(LOCALIZE_KEYWORD); + + if (hasLocalize) { + // A Blob is an immutable data structure that allows sharing the data between workers + // without copying until the data is actually used within a Worker. This is useful here + // since each file may not actually be processed in each Worker and the Blob avoids + // unneeded repeat copying of potentially large JavaScript files. + files.set(file.path, new Blob([file.contents])); + + continue; + } + } else if (file.path.endsWith('.js.map')) { + // The related JS file may not have been checked yet. To ensure that map files are not + // missed, store any pending map files and check them after all output files. + pendingMaps.push(file); + continue; + } + + this.#unmodifiedFiles.push(file); + } + + // Check if any pending map files should be processed by checking if the parent JS file is present + for (const file of pendingMaps) { + if (files.has(file.path.slice(0, -4))) { + files.set(file.path, new Blob([file.contents])); + } else { + this.#unmodifiedFiles.push(file); + } + } + + this.#localizeFiles = files; + + this.#workerPool = new Piscina({ + filename: require.resolve('./i18n-inliner-worker'), + maxThreads, + // Extract options to ensure only the named options are serialized and sent to the worker + workerData: { + missingTranslation: options.missingTranslation, + shouldOptimize: options.shouldOptimize, + files, + }, + }); + } + + /** + * Performs inlining of translations for the provided locale and translations. The files that + * are processed originate from the files passed to the class constructor and filter by presence + * of the localize function keyword. + * @param locale The string representing the locale to inline. + * @param translation The translation messages to use when inlining. + * @returns A promise that resolves to an array of OutputFiles representing a translated result. + */ + async inlineForLocale( + locale: string, + translation: Record | undefined, + ): Promise { + // Request inlining for each file that contains localize calls + const requests = []; + for (const filename of this.#localizeFiles.keys()) { + if (filename.endsWith('.map')) { + continue; + } + + const fileRequest = this.#workerPool.run({ + filename, + locale, + translation, + }); + requests.push(fileRequest); + } + + // Wait for all file requests to complete + const rawResults = await Promise.all(requests); + + // Convert raw results to output file objects and include all unmodified files + return [ + ...rawResults.flat().map(({ file, contents }) => createOutputFileFromData(file, contents)), + ...this.#unmodifiedFiles.map((file) => cloneOutputFile(file)), + ]; + } + + /** + * Stops all active transformation tasks and shuts down all workers. + * @returns A void promise that resolves when closing is complete. + */ + close(): Promise { + return this.#workerPool.destroy(); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-locale-plugin.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-locale-plugin.ts new file mode 100644 index 000000000000..751f648cf736 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-locale-plugin.ts @@ -0,0 +1,96 @@ +/** + * @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 { Plugin } from 'esbuild'; + +/** + * The base module location used to search for locale specific data. + */ +export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +/** + * Creates an esbuild plugin that resolves Angular locale data files from `@angular/common`. + * + * @returns An esbuild plugin. + */ +export function createAngularLocaleDataPlugin(): Plugin { + return { + name: 'angular-locale-data', + setup(build): void { + // If packages are configured to be external then leave the original angular locale import path. + // This happens when using the development server with caching enabled to allow Vite prebundling to work. + // There currently is no option on the esbuild resolve function to resolve while disabling the option. To + // workaround the inability to resolve the full locale location here, the Vite dev server prebundling also + // contains a plugin to allow the locales to be correctly resolved when prebundling. + // NOTE: If esbuild eventually allows controlling the external package options in a build.resolve call, this + // workaround can be removed. + if (build.initialOptions.packages === 'external') { + return; + } + + build.onResolve({ filter: /^angular:locale\/data:/ }, async ({ path }) => { + // Extract the locale from the path + const originalLocale = path.split(':', 3)[2]; + + // Remove any private subtags since these will never match + let partialLocale = originalLocale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, ''); + + let exact = true; + while (partialLocale) { + const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocale}`; + + const result = await build.resolve(potentialPath, { + kind: 'import-statement', + resolveDir: build.initialOptions.absWorkingDir, + }); + if (result.path) { + if (exact) { + return result; + } else { + return { + ...result, + warnings: [ + ...result.warnings, + { + location: null, + text: `Locale data for '${originalLocale}' cannot be found. Using locale data for '${partialLocale}'.`, + }, + ], + }; + } + } + + // Remove the last subtag and try again with a less specific locale + const parts = partialLocale.split('-'); + partialLocale = parts.slice(0, -1).join('-'); + exact = false; + // The locales "en" and "en-US" are considered exact to retain existing behavior + if (originalLocale === 'en-US' && partialLocale === 'en') { + exact = true; + } + } + + // Not found so issue a warning and use an empty loader. Framework built-in `en-US` data will be used. + // This retains existing behavior as in the Webpack-based builder. + return { + path: originalLocale, + namespace: 'angular:locale/data', + warnings: [ + { + location: null, + text: `Locale data for '${originalLocale}' cannot be found. No locale data will be included for this locale.`, + }, + ], + }; + }); + + // Locales that cannot be found will be loaded as empty content with a warning from the resolve step + build.onLoad({ filter: /./, namespace: 'angular:locale/data' }, () => ({ loader: 'empty' })); + }, + }; +} diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts index dda0cb47d7c0..d51ee821b9c8 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts @@ -6,18 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ +import type { OutputFile } from 'esbuild'; import assert from 'node:assert'; import path from 'node:path'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; import { InlineCriticalCssProcessor } from '../../utils/index-file/inline-critical-css'; import { InitialFileRecord } from './bundler-context'; -import type { ExecutionResult } from './bundler-execution-result'; export async function generateIndexHtml( initialFiles: Map, - executionResult: ExecutionResult, + outputFiles: OutputFile[], buildOptions: NormalizedApplicationBuildOptions, + lang?: string, ): Promise<{ content: string; contentWithoutCriticalCssInlined: string; @@ -60,7 +61,7 @@ export async function generateIndexHtml( const readAsset = async function (filePath: string): Promise { // Remove leading directory separator const relativefilePath = path.relative(virtualOutputPath, filePath); - const file = executionResult.outputFiles.find((file) => file.path === relativefilePath); + const file = outputFiles.find((file) => file.path === relativefilePath); if (file) { return file.text; } @@ -87,7 +88,7 @@ export async function generateIndexHtml( const transformResult = await indexHtmlGenerator.process({ baseHref, - lang: undefined, + lang, outputPath: virtualOutputPath, files: [...initialFiles].map(([file, record]) => ({ name: record.name ?? '', diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts index 4aa892538979..ad420f1d5468 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts @@ -211,6 +211,39 @@ export function createOutputFileFromText(path: string, text: string): OutputFile }; } +export function createOutputFileFromData(path: string, data: Uint8Array): OutputFile { + return { + path, + get text() { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf-8'); + }, + get hash() { + return createHash('sha256').update(data).digest('hex'); + }, + get contents() { + return data; + }, + }; +} + +export function cloneOutputFile(file: OutputFile): OutputFile { + const path = file.path; + const data = file.contents; + + return { + path, + get text() { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf-8'); + }, + get hash() { + return createHash('sha256').update(data).digest('hex'); + }, + get contents() { + return data; + }, + }; +} + /** * Transform browserlists result to esbuild target. * @see https://esbuild.github.io/api/#target diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/virtual-module-plugin.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/virtual-module-plugin.ts index 02faea72bf37..8bc2a9671956 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/virtual-module-plugin.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/virtual-module-plugin.ts @@ -24,6 +24,8 @@ export interface VirtualModulePluginOptions { args: OnLoadArgs, build: PluginBuild, ) => ReturnType[1]>; + /** Restrict to only entry points. Defaults to `true`. */ + entryPointOnly?: boolean; } /** @@ -32,13 +34,19 @@ export interface VirtualModulePluginOptions { * @returns An esbuild plugin. */ export function createVirtualModulePlugin(options: VirtualModulePluginOptions): Plugin { - const { namespace, external, transformPath: pathTransformer, loadContent } = options; + const { + namespace, + external, + transformPath: pathTransformer, + loadContent, + entryPointOnly = true, + } = options; return { name: namespace.replace(/[/:]/g, '-'), setup(build): void { build.onResolve({ filter: new RegExp('^' + namespace) }, ({ kind, path }) => { - if (kind !== 'entry-point') { + if (entryPointOnly && kind !== 'entry-point') { return null; } diff --git a/packages/angular_devkit/build_angular/src/tools/vite/i18n-locale-plugin.ts b/packages/angular_devkit/build_angular/src/tools/vite/i18n-locale-plugin.ts new file mode 100644 index 000000000000..5e2021b9bcc3 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/vite/i18n-locale-plugin.ts @@ -0,0 +1,64 @@ +/** + * @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 { Plugin } from 'vite'; + +/** + * The base module location used to search for locale specific data. + */ +export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +/** + * Creates a Vite plugin that resolves Angular locale data files from `@angular/common`. + * + * @returns A Vite plugin. + */ +export function createAngularLocaleDataPlugin(): Plugin { + return { + name: 'angular-locale-data', + enforce: 'pre', + async resolveId(source) { + if (!source.startsWith('angular:locale/data:')) { + return; + } + + // Extract the locale from the path + const originalLocale = source.split(':', 3)[2]; + + // Remove any private subtags since these will never match + let partialLocale = originalLocale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, ''); + + let exact = true; + while (partialLocale) { + const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocale}`; + + const result = await this.resolve(potentialPath); + if (result) { + if (!exact) { + this.warn( + `Locale data for '${originalLocale}' cannot be found. Using locale data for '${partialLocale}'.`, + ); + } + + return result; + } + + // Remove the last subtag and try again with a less specific locale + const parts = partialLocale.split('-'); + partialLocale = parts.slice(0, -1).join('-'); + exact = false; + // The locales "en" and "en-US" are considered exact to retain existing behavior + if (originalLocale === 'en-US' && partialLocale === 'en') { + exact = true; + } + } + + return null; + }, + }; +} diff --git a/tests/legacy-cli/e2e.bzl b/tests/legacy-cli/e2e.bzl index 83e1556e715e..81ccd66a2cac 100644 --- a/tests/legacy-cli/e2e.bzl +++ b/tests/legacy-cli/e2e.bzl @@ -39,7 +39,7 @@ ESBUILD_TESTS = [ "tests/build/styles/**", "tests/build/prerender/**", "tests/commands/add/**", - "tests/i18n/extract-ivy*", + "tests/i18n/**", ] def _to_glob(patterns): diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts index 3493148b6678..6f9e194503e4 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts @@ -8,6 +8,11 @@ import { readNgVersion } from '../../utils/version'; const snapshots = require('../../ng-snapshot/package.json'); export default async function () { + // TODO: Update to support application builder + if (getGlobalVariable('argv')['esbuild']) { + return; + } + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; await updateJsonFile('package.json', (packageJson) => { diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell.ts index 9dac69df0190..627c6e5bb9f7 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell.ts @@ -14,6 +14,11 @@ import { readNgVersion } from '../../utils/version'; const snapshots = require('../../ng-snapshot/package.json'); export default async function () { + // TODO: Update to support application builder + if (getGlobalVariable('argv')['esbuild']) { + return; + } + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; await updateJsonFile('package.json', (packageJson) => { diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref-absolute.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref-absolute.ts index a95bfda6f5bc..640686fee1e4 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref-absolute.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref-absolute.ts @@ -43,12 +43,12 @@ export default async function () { }); // Test absolute base href. - await ng('build', '--base-href', 'http://www.domain.com/', '--configuration=development'); + await ng('build', '--base-href', 'http://www.example.com/', '--configuration=development'); for (const { lang, outputPath } of langTranslations) { // Verify the HTML base HREF attribute is present await expectFileToMatch( `${outputPath}/index.html`, - `href="http://www.domain.com${baseHrefs[lang] || '/'}"`, + `href="http://www.example.com${baseHrefs[lang] || '/'}"`, ); } } diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015-e2e.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015-e2e.ts index 9a5943043ffd..3fe337c4c90f 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015-e2e.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015-e2e.ts @@ -1,3 +1,4 @@ +import { getGlobalVariable } from '../../utils/env'; import { ng } from '../../utils/process'; import { langTranslations, setupI18nConfig } from './setup'; diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts index 3dbb1da3176d..b8425795cab4 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts @@ -1,3 +1,4 @@ +import { getGlobalVariable } from '../../utils/env'; import { expectFileToMatch } from '../../utils/fs'; import { ng } from '../../utils/process'; import { expectToFail } from '../../utils/utils'; @@ -19,8 +20,13 @@ export default async function () { await expectToFail(() => expectFileToMatch(`${outputPath}/main.js`, '$localize`')); // Ensure locale is inlined (@angular/localize plugin inlines `$localize.locale` references) - // The only reference in a new application is in @angular/core - await expectFileToMatch(`${outputPath}/vendor.js`, lang); + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // The only reference in a new application with Webpack is in @angular/core + await expectFileToMatch(`${outputPath}/vendor.js`, lang); + } else { + await expectFileToMatch(`${outputPath}/polyfills.js`, lang); + } // Verify the HTML lang attribute is present await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`); diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-locale-data-augment.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-locale-data-augment.ts index 8c27229fc4cf..e2c408a74c69 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-locale-data-augment.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-locale-data-augment.ts @@ -1,3 +1,4 @@ +import { getGlobalVariable } from '../../utils/env'; import { expectFileToMatch, prependToFile, @@ -35,9 +36,13 @@ export default async function () { continue; } - // Ensure locale is inlined (@angular/localize plugin inlines `$localize.locale` references) - // The only reference in a new application is in @angular/core - await expectFileToMatch(`${outputPath}/vendor.js`, lang); + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // The only reference in a new application with Webpack is in @angular/core + await expectFileToMatch(`${outputPath}/vendor.js`, lang); + } else { + await expectFileToMatch(`${outputPath}/polyfills.js`, lang); + } // Execute Application E2E tests with dev server await ng('e2e', `--configuration=${lang}`, '--port=0'); diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-sourcelocale.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-sourcelocale.ts index 4294eb88fb16..daaa0fe45099 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-sourcelocale.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-sourcelocale.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import { getGlobalVariable } from '../../utils/env'; import { expectFileToMatch } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; @@ -34,10 +35,19 @@ export default async function () { continue; } - await expectFileToMatch(`${outputPath}/vendor.js`, lang); + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // The only reference in a new application with Webpack is in @angular/core + await expectFileToMatch(`${outputPath}/vendor.js`, lang); - // Verify the locale data is registered using the global files - await expectFileToMatch(`${outputPath}/vendor.js`, '.ng.common.locales'); + // Verify the locale data is registered using the global files + await expectFileToMatch(`${outputPath}/vendor.js`, '.ng.common.locales'); + } else { + await expectFileToMatch(`${outputPath}/polyfills.js`, lang); + + // Verify the locale data is registered using the global files + await expectFileToMatch(`${outputPath}/polyfills.js`, '.ng.common.locales'); + } // Verify the HTML lang attribute is present await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`); diff --git a/tests/legacy-cli/e2e/tests/i18n/setup.ts b/tests/legacy-cli/e2e/tests/i18n/setup.ts index 90e5f93e4c48..215bfe3c9259 100644 --- a/tests/legacy-cli/e2e/tests/i18n/setup.ts +++ b/tests/legacy-cli/e2e/tests/i18n/setup.ts @@ -214,13 +214,17 @@ export async function setupI18nConfig() { // Always error on missing translations. appArchitect['build'].options.optimization = true; - appArchitect['build'].options.buildOptimizer = true; appArchitect['build'].options.aot = true; appArchitect['build'].options.i18nMissingTranslation = 'error'; - appArchitect['build'].options.vendorChunk = true; appArchitect['build'].options.sourceMap = true; appArchitect['build'].options.outputHashing = 'none'; + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + appArchitect['build'].options.buildOptimizer = true; + appArchitect['build'].options.vendorChunk = true; + } + // Enable localization for all locales appArchitect['build'].options.localize = true;