Skip to content

Commit c5f3ec7

Browse files
clydinalan-agius4
authored andcommitted
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.
1 parent 11449b1 commit c5f3ec7

25 files changed

+858
-30
lines changed

Diff for: packages/angular_devkit/build_angular/src/builders/application/execute-build.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { maxWorkers } from '../../utils/environment-options';
3131
import { prerenderPages } from '../../utils/server-rendering/prerender';
3232
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
3333
import { getSupportedBrowsers } from '../../utils/supported-browsers';
34+
import { inlineI18n, loadActiveTranslations } from './i18n';
3435
import { NormalizedApplicationBuildOptions } from './options';
3536

3637
// eslint-disable-next-line max-lines-per-function
@@ -59,6 +60,12 @@ export async function executeBuild(
5960
const browsers = getSupportedBrowsers(projectRoot, context.logger);
6061
const target = transformSupportedBrowsersToTargets(browsers);
6162

63+
// Load active translations if inlining
64+
// TODO: Integrate into watch mode and only load changed translations
65+
if (options.i18nOptions.shouldInline) {
66+
await loadActiveTranslations(context, options.i18nOptions);
67+
}
68+
6269
// Reuse rebuild state or create new bundle contexts for code and global stylesheets
6370
let bundlerContexts = rebuildState?.rebuildContexts;
6471
const codeBundleCache =
@@ -154,14 +161,18 @@ export async function executeBuild(
154161
let indexContentOutputNoCssInlining: string | undefined;
155162

156163
// Generate index HTML file
157-
if (indexHtmlOptions) {
164+
// If localization is enabled, index generation is handled in the inlining process.
165+
// NOTE: Localization with SSR is not currently supported.
166+
if (indexHtmlOptions && !options.i18nOptions.shouldInline) {
158167
const { content, contentWithoutCriticalCssInlined, errors, warnings } = await generateIndexHtml(
159168
initialFiles,
160-
executionResult,
169+
executionResult.outputFiles,
161170
{
162171
...options,
163172
optimizationOptions,
164173
},
174+
// Set lang attribute to the defined source locale if present
175+
options.i18nOptions.hasDefinedSourceLocale ? options.i18nOptions.sourceLocale : undefined,
165176
);
166177

167178
indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined;
@@ -249,6 +260,11 @@ export async function executeBuild(
249260
const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;
250261
context.logger.info(`Application bundle generation complete. [${buildTime.toFixed(3)} seconds]`);
251262

263+
// Perform i18n translation inlining if enabled
264+
if (options.i18nOptions.shouldInline) {
265+
await inlineI18n(options, executionResult, initialFiles);
266+
}
267+
252268
return executionResult;
253269
}
254270

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { BuilderContext } from '@angular-devkit/architect';
10+
import { join } from 'node:path';
11+
import { InitialFileRecord } from '../../tools/esbuild/bundler-context';
12+
import { ExecutionResult } from '../../tools/esbuild/bundler-execution-result';
13+
import { I18nInliner } from '../../tools/esbuild/i18n-inliner';
14+
import { generateIndexHtml } from '../../tools/esbuild/index-html-generator';
15+
import { createOutputFileFromText } from '../../tools/esbuild/utils';
16+
import { maxWorkers } from '../../utils/environment-options';
17+
import { loadTranslations } from '../../utils/i18n-options';
18+
import { createTranslationLoader } from '../../utils/load-translations';
19+
import { urlJoin } from '../../utils/url';
20+
import { NormalizedApplicationBuildOptions } from './options';
21+
22+
/**
23+
* Inlines all active locales as specified by the application build options into all
24+
* application JavaScript files created during the build.
25+
* @param options The normalized application builder options used to create the build.
26+
* @param executionResult The result of an executed build.
27+
* @param initialFiles A map containing initial file information for the executed build.
28+
*/
29+
export async function inlineI18n(
30+
options: NormalizedApplicationBuildOptions,
31+
executionResult: ExecutionResult,
32+
initialFiles: Map<string, InitialFileRecord>,
33+
): Promise<void> {
34+
// Create the multi-threaded inliner with common options and the files generated from the build.
35+
const inliner = new I18nInliner(
36+
{
37+
missingTranslation: options.i18nOptions.missingTranslationBehavior ?? 'warning',
38+
outputFiles: executionResult.outputFiles,
39+
shouldOptimize: options.optimizationOptions.scripts,
40+
},
41+
maxWorkers,
42+
);
43+
44+
// For each active locale, use the inliner to process the output files of the build.
45+
const updatedOutputFiles = [];
46+
const updatedAssetFiles = [];
47+
try {
48+
for (const locale of options.i18nOptions.inlineLocales) {
49+
// A locale specific set of files is returned from the inliner.
50+
const localeOutputFiles = await inliner.inlineForLocale(
51+
locale,
52+
options.i18nOptions.locales[locale].translation,
53+
);
54+
55+
// Generate locale specific index HTML files
56+
if (options.indexHtmlOptions) {
57+
const { content, errors, warnings } = await generateIndexHtml(
58+
initialFiles,
59+
localeOutputFiles,
60+
{
61+
...options,
62+
baseHref:
63+
getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref,
64+
},
65+
locale,
66+
);
67+
68+
localeOutputFiles.push(createOutputFileFromText(options.indexHtmlOptions.output, content));
69+
}
70+
71+
// Update directory with locale base
72+
if (options.i18nOptions.flatOutput !== true) {
73+
localeOutputFiles.forEach((file) => {
74+
file.path = join(locale, file.path);
75+
});
76+
77+
for (const assetFile of executionResult.assetFiles) {
78+
updatedAssetFiles.push({
79+
source: assetFile.source,
80+
destination: join(locale, assetFile.destination),
81+
});
82+
}
83+
}
84+
85+
updatedOutputFiles.push(...localeOutputFiles);
86+
}
87+
} finally {
88+
await inliner.close();
89+
}
90+
91+
// Update the result with all localized files
92+
executionResult.outputFiles = updatedOutputFiles;
93+
94+
// Assets are only changed if not using the flat output option
95+
if (options.i18nOptions.flatOutput !== true) {
96+
executionResult.assetFiles = updatedAssetFiles;
97+
}
98+
}
99+
100+
function getLocaleBaseHref(
101+
baseHref: string | undefined,
102+
i18n: NormalizedApplicationBuildOptions['i18nOptions'],
103+
locale: string,
104+
): string | undefined {
105+
if (i18n.flatOutput) {
106+
return undefined;
107+
}
108+
109+
if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') {
110+
return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`);
111+
}
112+
113+
return undefined;
114+
}
115+
116+
/**
117+
* Loads all active translations using the translation loaders from the `@angular/localize` package.
118+
* @param context The architect builder context for the current build.
119+
* @param i18n The normalized i18n options to use.
120+
*/
121+
export async function loadActiveTranslations(
122+
context: BuilderContext,
123+
i18n: NormalizedApplicationBuildOptions['i18nOptions'],
124+
) {
125+
// Load locale data and translations (if present)
126+
let loader;
127+
for (const [locale, desc] of Object.entries(i18n.locales)) {
128+
if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) {
129+
continue;
130+
}
131+
132+
if (!desc.files.length) {
133+
continue;
134+
}
135+
136+
loader ??= await createTranslationLoader();
137+
138+
loadTranslations(
139+
locale,
140+
desc,
141+
context.workspaceRoot,
142+
loader,
143+
{
144+
warn(message) {
145+
context.logger.warn(message);
146+
},
147+
error(message) {
148+
throw new Error(message);
149+
},
150+
},
151+
undefined,
152+
i18n.duplicateTranslationBehavior,
153+
);
154+
}
155+
}

Diff for: packages/angular_devkit/build_angular/src/builders/application/index.ts

+17
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ export async function* buildApplicationInternal(
4242
}
4343

4444
const normalizedOptions = await normalizeOptions(context, projectName, options);
45+
46+
// Warn about prerender/ssr not yet supporting localize
47+
if (
48+
normalizedOptions.i18nOptions.shouldInline &&
49+
(normalizedOptions.prerenderOptions ||
50+
normalizedOptions.ssrOptions ||
51+
normalizedOptions.appShellOptions)
52+
) {
53+
context.logger.warn(
54+
`Prerendering, App Shell, and SSR are not yet supported with the 'localize' option and will be disabled for this build.`,
55+
);
56+
normalizedOptions.prerenderOptions =
57+
normalizedOptions.ssrOptions =
58+
normalizedOptions.appShellOptions =
59+
undefined;
60+
}
61+
4562
yield* runEsBuildBuildAction(
4663
(rebuildState) => executeBuild(normalizedOptions, context, rebuildState),
4764
{

Diff for: packages/angular_devkit/build_angular/src/builders/application/options.ts

+9
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ interface InternalOptions {
4141
* Currently used by the dev-server to support prebundling.
4242
*/
4343
externalPackages?: boolean;
44+
45+
/**
46+
* Forces the output from the localize post-processing to not create nested directories per locale output.
47+
* This is only used by the development server which currently only supports a single locale per build.
48+
*/
49+
forceI18nFlatOutput?: boolean;
4450
}
4551

4652
/** Full set of options for `application` builder. */
@@ -87,6 +93,9 @@ export async function normalizeOptions(
8793
} = createI18nOptions(projectMetadata, options.localize);
8894
i18nOptions.duplicateTranslationBehavior = options.i18nDuplicateTranslation;
8995
i18nOptions.missingTranslationBehavior = options.i18nMissingTranslation;
96+
if (options.forceI18nFlatOutput) {
97+
i18nOptions.flatOutput = true;
98+
}
9099

91100
const entryPoints = normalizeEntryPoints(workspaceRoot, options.browser, options.entryPoints);
92101
const tsconfig = path.join(workspaceRoot, options.tsConfig);

Diff for: packages/angular_devkit/build_angular/src/builders/browser-esbuild/builder-status-warnings.ts

-6
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ import { Schema as BrowserBuilderOptions } from './schema';
1212
const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
1313
'budgets',
1414

15-
// * i18n support
16-
'localize',
17-
// The following two have no effect when localize is not enabled
18-
// 'i18nDuplicateTranslation',
19-
// 'i18nMissingTranslation',
20-
2115
// * Deprecated
2216
'deployUrl',
2317

Diff for: packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts

+17
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { AddressInfo } from 'node:net';
1818
import path, { posix } from 'node:path';
1919
import type { Connect, InlineConfig, ViteDevServer } from 'vite';
2020
import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer';
21+
import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin';
2122
import { RenderOptions, renderPage } from '../../utils/server-rendering/render-page';
2223
import { getIndexOutputFile } from '../../utils/webpack-browser-config';
2324
import { buildEsbuildBrowser } from '../browser-esbuild';
@@ -66,6 +67,21 @@ export async function* serveWithVite(
6667
serverOptions.servePath = browserOptions.baseHref;
6768
}
6869

70+
// The development server currently only supports a single locale when localizing.
71+
// This matches the behavior of the Webpack-based development server but could be expanded in the future.
72+
if (
73+
browserOptions.localize === true ||
74+
(Array.isArray(browserOptions.localize) && browserOptions.localize.length > 1)
75+
) {
76+
context.logger.warn(
77+
'Localization (`localize` option) has been disabled. The development server only supports localizing a single locale per build.',
78+
);
79+
browserOptions.localize = false;
80+
} else if (browserOptions.localize) {
81+
// When localization is enabled with a single locale, force a flat path to maintain behavior with the existing Webpack-based dev server.
82+
browserOptions.forceI18nFlatOutput = true;
83+
}
84+
6985
// Setup the prebundling transformer that will be shared across Vite prebundling requests
7086
const prebundleTransformer = new JavaScriptTransformer(
7187
// Always enable JIT linking to support applications built with and without AOT.
@@ -310,6 +326,7 @@ export async function setupServer(
310326
external: prebundleExclude,
311327
},
312328
plugins: [
329+
createAngularLocaleDataPlugin(),
313330
{
314331
name: 'vite:angular-memory',
315332
// Ensures plugin hooks run before built-in Vite hooks

Diff for: packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export async function extractMessages(
3636
)) as unknown as ApplicationBuilderInternalOptions;
3737
buildOptions.optimization = false;
3838
buildOptions.sourceMap = { scripts: true, vendor: true };
39+
buildOptions.localize = false;
3940

4041
let build;
4142
if (builderName === '@angular-devkit/build-angular:application') {

0 commit comments

Comments
 (0)