diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index f212659a14be..058a2fa3d6a4 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -42,14 +42,13 @@ export async function inlineI18n( warnings: string[]; prerenderedRoutes: PrerenderedRoutesRecord; }> { - const { i18nOptions, optimizationOptions, baseHref, cacheOptions } = options; + const { i18nOptions, baseHref, cacheOptions } = options; // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new I18nInliner( { missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', outputFiles: executionResult.outputFiles, - shouldOptimize: optimizationOptions.scripts, persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, localizeVersion: i18nOptions.localizeVersion, }, diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts index af9d9dce134b..d6a56ad10d74 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -7,6 +7,7 @@ */ import remapping, { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import type { ɵParsedTranslation } from '@angular/localize'; import type { Node } from '@oxc-project/types'; import { MagicString } from 'magic-string'; import assert from 'node:assert'; @@ -16,29 +17,6 @@ import { parseSync, visitorKeys } from 'oxc-parser'; import { loadLocaleData } from './i18n-locale-plugin'; import { createSharedTranslationProxy } from './i18n-translation-reader'; -/** - * The options passed to the inliner for each file request - */ -interface InlineFileRequest { - /** - * 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 serialized translation messages for the locale that should be used during the inlining - * process of the file. A SharedArrayBuffer or Blob is used so that the messages are shared with - * the Worker by reference instead of being copied into it for every request. - */ - translation?: Blob | SharedArrayBuffer; -} - /** * The options passed to the inliner for each code request */ @@ -77,9 +55,9 @@ interface InlineFileBatchRequest { filename: string; /** - * The locale specifiers or locale objects that should be used during the inlining process of the file. + * The locale specifiers and optional translations to use during the inlining process of the file. */ - locales: (string | { locale: string; translation?: Blob | SharedArrayBuffer })[]; + locales: ReadonlyMap; /** * Whether the file data should be treated as ephemeral and not cached long-term in the Worker. @@ -113,10 +91,9 @@ interface InlineFileBatchResult { } // Extract the application files and common options used for inline requests from the Worker context -const { files, missingTranslation, translations } = (workerData || {}) as { +const { files, missingTranslation } = (workerData || {}) as { files: ReadonlyMap; missingTranslation: 'error' | 'warning' | 'ignore'; - translations?: ReadonlyMap; }; /** @@ -135,7 +112,7 @@ const fileDataCache = new Map>(); /** * Cache of deserialized translation messages keyed by locale. */ -const deserializedTranslations = new Map>>(); +const deserializedTranslations = new Map>>(); /** * Retrieves the file data for a filename, loading and extracting localization metadata. @@ -183,24 +160,23 @@ function loadFileData(filename: string, cache = true): Promise { function loadTranslation( locale: string, translation?: Blob | SharedArrayBuffer, -): Promise> | undefined { - const translationData = translation ?? translations?.get(locale); - if (!translationData) { +): Promise> | undefined { + if (!translation) { return undefined; } let messagesPromise = deserializedTranslations.get(locale); if (!messagesPromise) { - if (translationData instanceof Blob) { - messagesPromise = translationData + if (translation instanceof Blob) { + messagesPromise = translation .arrayBuffer() - .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) .catch((error) => { deserializedTranslations.delete(locale); throw error; }); } else { - messagesPromise = Promise.resolve(createSharedTranslationProxy(translationData)); + messagesPromise = Promise.resolve(createSharedTranslationProxy(translation)); } deserializedTranslations.set(locale, messagesPromise); } @@ -208,38 +184,6 @@ function loadTranslation( return messagesPromise; } -/** - * 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 object containing the inlined file and optional map content. - */ -export default async function inlineFile(request: InlineFileRequest) { - const { code, metadata } = await loadFileData(request.filename, true); - - // Sourcemaps are parsed on demand per request rather than cached long-term to prevent - // monotonic memory growth as a worker processes multiple files across the build. - const rawMap = await files.get(request.filename + '.map')?.text(); - const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; - - const result = await inlineLocalize( - code, - map, - metadata, - request.locale, - await loadTranslation(request.locale, request.translation), - request.filename, - ); - - return { - file: request.filename, - code: result.code, - map: result.map, - messages: result.diagnostics.messages, - }; -} - /** * Inlines multiple locales and translations into a JavaScript file that contains `$localize` usage. * @@ -266,9 +210,7 @@ export async function inlineFileBatch( const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; const results = await Promise.all( - request.locales.map(async (entry) => { - const locale = typeof entry === 'string' ? entry : entry.locale; - const translation = typeof entry === 'string' ? undefined : entry.translation; + Array.from(request.locales, async ([locale, translation]) => { const result = await inlineLocalize( code, map, @@ -490,7 +432,7 @@ async function inlineLocalize( map: SourceMapInput | undefined, metadata: FileLocalizeMetadata, locale: string, - translation: Record | undefined, + translation: Record | undefined, filename: string, ) { const magicString = new MagicString(code); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index 41de55de666f..33b570a46017 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import assert from 'node:assert'; import { extname, join } from 'node:path'; import { serialize } from 'node:v8'; @@ -38,7 +39,7 @@ const DEFAULT_LOCALE_WINDOW_SIZE = 8; * @returns A SharedArrayBuffer or Blob containing the serialized messages, or undefined if none. */ function serializeTranslation( - translation: Record | undefined, + translation: Record | undefined, ): SharedArrayBuffer | Blob | undefined { if (!translation) { return undefined; @@ -57,10 +58,8 @@ function serializeTranslation( export interface I18nInlinerOptions { missingTranslation: 'error' | 'warning' | 'ignore'; outputFiles: BuildOutputFile[]; - shouldOptimize?: boolean; persistentCachePath?: string; localizeVersion?: string; - translations?: ReadonlyMap; } /** @@ -75,7 +74,7 @@ export interface LocaleInlineOptions { /** * The translation messages for the locale, or undefined for the source/untranslated locale. */ - translation?: Record; + translation?: Record; /** * An optional content integrity hash of the translation file(s) for fast cache key calculation. @@ -155,7 +154,7 @@ export class I18nInliner { maxThreads?: number, ) { this.#unmodifiedFiles = []; - const { outputFiles, shouldOptimize, missingTranslation, translations } = options; + const { outputFiles, missingTranslation } = options; const files = new Map(); const pendingMaps = []; @@ -206,8 +205,6 @@ export class I18nInliner { // Extract options to ensure only the named options are serialized and sent to the worker workerData: { missingTranslation, - shouldOptimize, - translations, // 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 @@ -233,7 +230,7 @@ export class I18nInliner { ): Promise> { await this.initCache(); - const { shouldOptimize, missingTranslation, localizeVersion } = this.options; + const { missingTranslation, localizeVersion } = this.options; const localeList = Array.from(locales); if (localeList.length === 0) { @@ -270,7 +267,6 @@ export class I18nInliner { locale, translation: translationIntegrity || translation, missingTranslation, - shouldOptimize, localizeVersion, }), ), @@ -426,10 +422,7 @@ export class I18nInliner { const batchResult = (await this.#workerPool.run( { filename, - locales: batchEntries.map((e) => ({ - locale: e.locale, - translation: e.translation, - })), + locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), ephemeral, activeLocales, }, @@ -478,7 +471,7 @@ export class I18nInliner { */ async inlineForLocale( locale: string, - translation: Record | undefined, + translation: Record | undefined, translationIntegrity?: string, ): Promise { const results = await this.inlineAll([{ locale, translation, translationIntegrity }]); @@ -490,7 +483,7 @@ export class I18nInliner { async inlineTemplateUpdate( locale: string, - translation: Record | undefined, + translation: Record | undefined, templateCode: string, templateId: string, ): Promise<{ code: string; errors: string[]; warnings: string[] }> { diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts index 46d1cb4145d4..acceab7513b5 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import { transform } from 'esbuild'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -21,11 +22,22 @@ import { I18nInliner } from './i18n-inliner'; const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n'; /** - * Creates the parsed translation form that `@angular/localize` expects for a message without - * placeholders. + * Creates the parsed translation form that `@angular/localize` expects. */ -function translationFor(message: string): Record { - return { messageParts: [message], placeholderNames: [], text: message }; +function parsedTranslation( + parts: string[], + placeholderNames: string[] = [], + text?: string, +): ɵParsedTranslation { + return { + messageParts: Object.assign([...parts], { raw: [...parts] }), + placeholderNames, + text: text ?? parts.join(''), + }; +} + +function translationFor(message: string): ɵParsedTranslation { + return parsedTranslation([message], [], message); } function browserFile(path: string, contents: string): BuildOutputFile { @@ -222,11 +234,7 @@ describe('I18nInliner', () => { const { outputFiles, errors, warnings } = await createInliner([ browserFile('main.js', source), ]).inlineForLocale('fr', { - welcome: { - messageParts: ['Bonjour ', ' !'], - placeholderNames: ['PH'], - text: 'Bonjour {$PH} !', - }, + welcome: parsedTranslation(['Bonjour ', ' !'], ['PH'], 'Bonjour {$PH} !'), }); expect(errors).toEqual([]); @@ -294,11 +302,11 @@ describe('I18nInliner', () => { browserFile('main.js', source), ]).inlineForLocale('fr', { inner: translationFor('Pomme'), - outer: { - messageParts: ['Vous avez sélectionné ', ' pour la livraison.'], - placeholderNames: ['PH'], - text: 'Vous avez sélectionné {$PH} pour la livraison.', - }, + outer: parsedTranslation( + ['Vous avez sélectionné ', ' pour la livraison.'], + ['PH'], + 'Vous avez sélectionné {$PH} pour la livraison.', + ), }); expect(errors).toEqual([]); diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts index 091d9ba0ff26..cc8f1ae50f31 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; + /** * Magic header identifier for i18n SharedArrayBuffer translation tables ('I18N'). */ @@ -19,7 +21,9 @@ export const I18N_MAGIC_ID = 0x4931384e; * @param translation The translation dictionary object. * @returns A SharedArrayBuffer containing the binary encoded translation catalog. */ -export function encodeTranslationToBuffer(translation: Record): SharedArrayBuffer { +export function encodeTranslationToBuffer( + translation: Record, +): SharedArrayBuffer { const encoder = new TextEncoder(); const entries = Object.entries(translation); const entryCount = entries.length; diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts index c3b6409d40b5..dd660f9a4059 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-translation-encoder_spec.ts @@ -22,7 +22,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const dictionary = new SharedTranslationDictionary(buffer); + const dictionary = new SharedTranslationDictionary(buffer); expect(dictionary.get('greeting')).toEqual('Hello'); expect(dictionary.get('farewell')).toEqual('Goodbye'); @@ -38,7 +38,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const proxy = createSharedTranslationProxy(buffer); + const proxy = createSharedTranslationProxy(buffer); expect(proxy['msg1']).toEqual('Message 1'); expect(proxy['msg2']).toEqual('Message 2'); @@ -54,7 +54,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const proxy = createSharedTranslationProxy(buffer); + const proxy = createSharedTranslationProxy(buffer); expect(Object.prototype.hasOwnProperty.call(proxy, 'msg1')).toBeTrue(); expect(Object.prototype.hasOwnProperty.call(proxy, 'unknown')).toBeFalse(); @@ -83,7 +83,7 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { }; const buffer = encodeTranslationToBuffer(translation); - const dictionary = new SharedTranslationDictionary(buffer); + const dictionary = new SharedTranslationDictionary(buffer); expect(dictionary.get('😀')).toEqual('emoji message'); expect(dictionary.get('\uE000')).toEqual('uE000 message'); @@ -102,8 +102,8 @@ describe('SharedArrayBuffer Translation Encoder & Reader', () => { msg1: 'Message 1', }; - const buffer = encodeTranslationToBuffer(translation); - const dictionary = new SharedTranslationDictionary(buffer); + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); // First lookup for missing key expect(dictionary.get('missingKey')).toBeUndefined(); diff --git a/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts b/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts index bc87633da5dc..c332b47f1941 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-translation-reader.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import { I18N_MAGIC_ID } from './i18n-translation-encoder'; /** @@ -43,13 +44,13 @@ function compareBytes( * A zero-copy reader that queries translation messages directly from a SharedArrayBuffer * using binary search over a sorted key index. */ -export class SharedTranslationDictionary { +export class SharedTranslationDictionary { private readonly entryCount: number; private readonly uint32Index: Uint32Array; private readonly uint8Pool: Uint8Array; private readonly decoder = new TextDecoder(); private readonly encoder = new TextEncoder(); - private readonly lazyCache = new Map(); + private readonly lazyCache = new Map(); constructor(buffer: SharedArrayBuffer) { if (buffer.byteLength < 16) { @@ -86,7 +87,7 @@ export class SharedTranslationDictionary { * @param targetKey The message key ID to search for. * @returns The parsed translation message, or undefined if not found. */ - get(targetKey: string): unknown | undefined { + get(targetKey: string): T | undefined { const cached = this.lazyCache.get(targetKey); if (cached !== undefined) { return cached === NOT_FOUND ? undefined : cached; @@ -117,7 +118,7 @@ export class SharedTranslationDictionary { const valJson = this.decoder.decode(valBytes); try { - const val = JSON.parse(valJson); + const val = JSON.parse(valJson) as T; this.lazyCache.set(targetKey, val); return val; @@ -150,8 +151,10 @@ export class SharedTranslationDictionary { * @param buffer The SharedArrayBuffer containing binary encoded translation catalog. * @returns A Proxy object that intercepts property reads and queries the SharedTranslationDictionary. */ -export function createSharedTranslationProxy(buffer: SharedArrayBuffer): Record { - const dictionary = new SharedTranslationDictionary(buffer); +export function createSharedTranslationProxy( + buffer: SharedArrayBuffer, +): Record { + const dictionary = new SharedTranslationDictionary(buffer); return new Proxy( {}, diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts index 6a288622d053..9a55656e6798 100644 --- a/packages/angular/build/src/utils/i18n-options.ts +++ b/packages/angular/build/src/utils/i18n-options.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import type { ɵParsedTranslation } from '@angular/localize'; import path from 'node:path'; import type { TranslationLoader } from './load-translations'; @@ -15,7 +16,7 @@ export interface LocaleDescription { integrity?: string; format?: string; }[]; - translation?: Record; + translation?: Record; dataPath?: string; baseHref?: string; subPath: string; @@ -244,7 +245,7 @@ export function loadTranslations( usedFormats?: Set, duplicateTranslation?: 'ignore' | 'error' | 'warning', ) { - let translations: Record | undefined = undefined; + let translations: Record | undefined = undefined; for (const file of desc.files) { const loadResult = loader(path.join(workspaceRoot, file.path));