Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/angular/build/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
84 changes: 13 additions & 71 deletions packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
*/
Expand Down Expand Up @@ -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<string, Blob | SharedArrayBuffer | undefined>;

/**
* Whether the file data should be treated as ephemeral and not cached long-term in the Worker.
Expand Down Expand Up @@ -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<string, Blob>;
missingTranslation: 'error' | 'warning' | 'ignore';
translations?: ReadonlyMap<string, Blob | SharedArrayBuffer>;
};

/**
Expand All @@ -135,7 +112,7 @@ const fileDataCache = new Map<string, Promise<CachedFileData>>();
/**
* Cache of deserialized translation messages keyed by locale.
*/
const deserializedTranslations = new Map<string, Promise<Record<string, unknown>>>();
const deserializedTranslations = new Map<string, Promise<Record<string, ɵParsedTranslation>>>();

/**
* Retrieves the file data for a filename, loading and extracting localization metadata.
Expand Down Expand Up @@ -183,63 +160,30 @@ function loadFileData(filename: string, cache = true): Promise<CachedFileData> {
function loadTranslation(
locale: string,
translation?: Blob | SharedArrayBuffer,
): Promise<Record<string, unknown>> | undefined {
const translationData = translation ?? translations?.get(locale);
if (!translationData) {
): Promise<Record<string, ɵParsedTranslation>> | 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<string, unknown>)
.then((buffer) => deserialize(new Uint8Array(buffer)) as Record<string, ɵParsedTranslation>)
.catch((error) => {
deserializedTranslations.delete(locale);
throw error;
});
} else {
messagesPromise = Promise.resolve(createSharedTranslationProxy(translationData));
messagesPromise = Promise.resolve(createSharedTranslationProxy(translation));
}
deserializedTranslations.set(locale, messagesPromise);
}

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.
*
Expand All @@ -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,
Expand Down Expand Up @@ -490,7 +432,7 @@ async function inlineLocalize(
map: SourceMapInput | undefined,
metadata: FileLocalizeMetadata,
locale: string,
translation: Record<string, unknown> | undefined,
translation: Record<string, ɵParsedTranslation> | undefined,
filename: string,
) {
const magicString = new MagicString(code);
Expand Down
23 changes: 8 additions & 15 deletions packages/angular/build/src/tools/esbuild/i18n-inliner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> | undefined,
translation: Record<string, ɵParsedTranslation> | undefined,
): SharedArrayBuffer | Blob | undefined {
if (!translation) {
return undefined;
Expand All @@ -57,10 +58,8 @@ function serializeTranslation(
export interface I18nInlinerOptions {
missingTranslation: 'error' | 'warning' | 'ignore';
outputFiles: BuildOutputFile[];
shouldOptimize?: boolean;
persistentCachePath?: string;
localizeVersion?: string;
translations?: ReadonlyMap<string, Blob | SharedArrayBuffer>;
}

/**
Expand All @@ -75,7 +74,7 @@ export interface LocaleInlineOptions {
/**
* The translation messages for the locale, or undefined for the source/untranslated locale.
*/
translation?: Record<string, unknown>;
translation?: Record<string, ɵParsedTranslation>;

/**
* An optional content integrity hash of the translation file(s) for fast cache key calculation.
Expand Down Expand Up @@ -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<string, BuildOutputFile>();

const pendingMaps = [];
Expand Down Expand Up @@ -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
Expand All @@ -233,7 +230,7 @@ export class I18nInliner {
): Promise<Map<string, LocaleInlineResult>> {
await this.initCache();

const { shouldOptimize, missingTranslation, localizeVersion } = this.options;
const { missingTranslation, localizeVersion } = this.options;
const localeList = Array.from(locales);

if (localeList.length === 0) {
Expand Down Expand Up @@ -270,7 +267,6 @@ export class I18nInliner {
locale,
translation: translationIntegrity || translation,
missingTranslation,
shouldOptimize,
localizeVersion,
}),
),
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -478,7 +471,7 @@ export class I18nInliner {
*/
async inlineForLocale(
locale: string,
translation: Record<string, unknown> | undefined,
translation: Record<string, ɵParsedTranslation> | undefined,
translationIntegrity?: string,
): Promise<LocaleInlineResult> {
const results = await this.inlineAll([{ locale, translation, translationIntegrity }]);
Expand All @@ -490,7 +483,7 @@ export class I18nInliner {

async inlineTemplateUpdate(
locale: string,
translation: Record<string, unknown> | undefined,
translation: Record<string, ɵParsedTranslation> | undefined,
templateCode: string,
templateId: string,
): Promise<{ code: string; errors: string[]; warnings: string[] }> {
Expand Down
36 changes: 22 additions & 14 deletions packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, unknown> {
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 {
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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').
*/
Expand All @@ -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<string, unknown>): SharedArrayBuffer {
export function encodeTranslationToBuffer<T = ɵParsedTranslation>(
translation: Record<string, T>,
): SharedArrayBuffer {
const encoder = new TextEncoder();
const entries = Object.entries(translation);
const entryCount = entries.length;
Expand Down
Loading