From c5f3ec71f536e7ebb1c8cd0d7523b42e58f9611a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:53:24 -0400 Subject: [PATCH] feat(@angular-devkit/build-angular): support i18n inlining with esbuild-based builder When using the esbuild-based application build system through either the `application` or `browser-esbuild` builder, the `localize` option will now allow inlining project defined localizations. The process to configure and enable the i18n system is the same as with the Webpack-based `browser` builder. The implementation uses a similar approach to the `browser` builder in which the application is built once and then post-processed for each active locale. In addition to inlining translations, the locale identifier is injected and the locale specific data is added to the applications. Currently, this implementation adds all the locale specific data to each application during the initial building. While this may cause a small increase in the polyfills bundle (locale data is very small in size), it has a benefit of faster builds and a significantly less complicated build process. Additional size optimizations to the data itself are also being considered to even further reduce impact. Also, with the eventual shift towards the standard `Intl` web APIs, the need for the locale data will become obsolete in addition to the build time code necessary to add it to the application. While build capabilities are functional, there are several areas which have not yet been fully implemented but will be in future changes. These include console progress information, efficient watch support, and app-shell/service worker support. --- .../src/builders/application/execute-build.ts | 20 +- .../src/builders/application/i18n.ts | 155 ++++++++++++++ .../src/builders/application/index.ts | 17 ++ .../src/builders/application/options.ts | 9 + .../builder-status-warnings.ts | 6 - .../src/builders/dev-server/vite-server.ts | 17 ++ .../extract-i18n/application-extraction.ts | 1 + .../tools/esbuild/application-code-bundle.ts | 56 +++++- .../tools/esbuild/bundler-execution-result.ts | 4 +- .../src/tools/esbuild/i18n-inliner-worker.ts | 189 ++++++++++++++++++ .../src/tools/esbuild/i18n-inliner.ts | 138 +++++++++++++ .../src/tools/esbuild/i18n-locale-plugin.ts | 96 +++++++++ .../src/tools/esbuild/index-html-generator.ts | 9 +- .../build_angular/src/tools/esbuild/utils.ts | 33 +++ .../tools/esbuild/virtual-module-plugin.ts | 12 +- .../src/tools/vite/i18n-locale-plugin.ts | 64 ++++++ tests/legacy-cli/e2e.bzl | 2 +- .../ivy-localize-app-shell-service-worker.ts | 5 + .../e2e/tests/i18n/ivy-localize-app-shell.ts | 5 + .../i18n/ivy-localize-basehref-absolute.ts | 4 +- .../e2e/tests/i18n/ivy-localize-es2015-e2e.ts | 1 + .../e2e/tests/i18n/ivy-localize-es2015.ts | 10 +- .../i18n/ivy-localize-locale-data-augment.ts | 11 +- .../tests/i18n/ivy-localize-sourcelocale.ts | 16 +- tests/legacy-cli/e2e/tests/i18n/setup.ts | 8 +- 25 files changed, 858 insertions(+), 30 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/application/i18n.ts create mode 100644 packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner-worker.ts create mode 100644 packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts create mode 100644 packages/angular_devkit/build_angular/src/tools/esbuild/i18n-locale-plugin.ts create mode 100644 packages/angular_devkit/build_angular/src/tools/vite/i18n-locale-plugin.ts 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;