Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): support i18n inlining with esbui…
Browse files Browse the repository at this point in the history
…ld-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.
  • Loading branch information
clydin authored and alan-agius4 committed Sep 22, 2023
1 parent 11449b1 commit c5f3ec7
Show file tree
Hide file tree
Showing 25 changed files with 858 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
155 changes: 155 additions & 0 deletions packages/angular_devkit/build_angular/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
@@ -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<string, InitialFileRecord>,
): Promise<void> {
// 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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@ import { Schema as BrowserBuilderOptions } from './schema';
const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
'budgets',

// * i18n support
'localize',
// The following two have no effect when localize is not enabled
// 'i18nDuplicateTranslation',
// 'i18nMissingTranslation',

// * Deprecated
'deployUrl',

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down

0 comments on commit c5f3ec7

Please sign in to comment.