Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): support i18n with service worker…
Browse files Browse the repository at this point in the history
… and app-shell with esbuild builders

When using the esbuild-based application build system through the `application` builder, the `localize`
option will now allow inlining project defined localizations when using the app shell, prerendering,
and service worker features. Previously a warning was issued and these features were disabled when the
`localize` option was enabled.
  • Loading branch information
clydin committed Oct 4, 2023
1 parent afc0593 commit fd62a93
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ export async function executeBuild(
}

// Pre-render (SSG) and App-shell
if (prerenderOptions || appShellOptions) {
// If localization is enabled, prerendering is handled in the inlining process.
if ((prerenderOptions || appShellOptions) && !options.i18nOptions.shouldInline) {
assert(
indexContentOutputNoCssInlining,
'The "index" option is required when using the "ssg" or "appShell" options.',
Expand Down Expand Up @@ -217,11 +218,6 @@ export async function executeBuild(
executionResult.assetFiles.push(...(await copyAssets(assets, [], workspaceRoot)));
}

// Write metafile if stats option is enabled
if (options.stats) {
executionResult.addOutputFile('stats.json', JSON.stringify(metafile, null, 2));
}

// Extract and write licenses for used packages
if (options.extractLicenses) {
executionResult.addOutputFile(
Expand All @@ -231,7 +227,8 @@ export async function executeBuild(
}

// Augment the application with service worker support
if (serviceWorker) {
// If localization is enabled, service worker is handled in the inlining process.
if (serviceWorker && !options.i18nOptions.shouldInline) {
try {
const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild(
workspaceRoot,
Expand Down Expand Up @@ -262,7 +259,13 @@ export async function executeBuild(

// Perform i18n translation inlining if enabled
if (options.i18nOptions.shouldInline) {
await inlineI18n(options, executionResult, initialFiles);
const { errors, warnings } = await inlineI18n(options, executionResult, initialFiles);
printWarningsAndErrorsToConsole(context, warnings, errors);
}

// Write metafile if stats option is enabled
if (options.stats) {
executionResult.addOutputFile('stats.json', JSON.stringify(metafile, null, 2));
}

return executionResult;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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 { prerenderPages } from '../../utils/server-rendering/prerender';
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
import { urlJoin } from '../../utils/url';
import { NormalizedApplicationBuildOptions } from './options';

Expand All @@ -30,7 +32,7 @@ export async function inlineI18n(
options: NormalizedApplicationBuildOptions,
executionResult: ExecutionResult,
initialFiles: Map<string, InitialFileRecord>,
): Promise<void> {
): Promise<{ errors: string[]; warnings: string[] }> {
// Create the multi-threaded inliner with common options and the files generated from the build.
const inliner = new I18nInliner(
{
Expand All @@ -41,6 +43,11 @@ export async function inlineI18n(
maxWorkers,
);

const inlineResult: { errors: string[]; warnings: string[] } = {
errors: [],
warnings: [],
};

// For each active locale, use the inliner to process the output files of the build.
const updatedOutputFiles = [];
const updatedAssetFiles = [];
Expand All @@ -52,20 +59,64 @@ export async function inlineI18n(
options.i18nOptions.locales[locale].translation,
);

const baseHref =
getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref;

// 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,
);
const { content, contentWithoutCriticalCssInlined, errors, warnings } =
await generateIndexHtml(
initialFiles,
localeOutputFiles,
{
...options,
baseHref,
},
locale,
);

localeOutputFiles.push(createOutputFileFromText(options.indexHtmlOptions.output, content));
inlineResult.errors.push(...errors);
inlineResult.warnings.push(...warnings);

// Pre-render (SSG) and App-shell
if (options.prerenderOptions || options.appShellOptions) {
const { output, warnings, errors } = await prerenderPages(
options.workspaceRoot,
options.appShellOptions,
options.prerenderOptions,
localeOutputFiles,
contentWithoutCriticalCssInlined,
options.optimizationOptions.styles.inlineCritical,
maxWorkers,
options.verbose,
);

inlineResult.errors.push(...errors);
inlineResult.warnings.push(...warnings);

for (const [path, content] of Object.entries(output)) {
localeOutputFiles.push(createOutputFileFromText(path, content));
}
}
}

if (options.serviceWorker) {
try {
const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild(
options.workspaceRoot,
options.serviceWorker,
baseHref || '/',
localeOutputFiles,
executionResult.assetFiles,
);
localeOutputFiles.push(
createOutputFileFromText('ngsw.json', serviceWorkerResult.manifest),
);
executionResult.assetFiles.push(...serviceWorkerResult.assetFiles);
} catch (error) {
inlineResult.errors.push(error instanceof Error ? error.message : `${error}`);
}
}

// Update directory with locale base
Expand Down Expand Up @@ -95,6 +146,8 @@ export async function inlineI18n(
if (options.i18nOptions.flatOutput !== true) {
executionResult.assetFiles = updatedAssetFiles;
}

return inlineResult;
}

function getLocaleBaseHref(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,12 @@ 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)
) {
// Warn about SSR not yet supporting localize
if (normalizedOptions.i18nOptions.shouldInline && normalizedOptions.ssrOptions) {
context.logger.warn(
`Prerendering, App Shell, and SSR are not yet supported with the 'localize' option and will be disabled for this build.`,
`SSR is not yet supported with the 'localize' option and will be disabled for this build.`,
);
normalizedOptions.prerenderOptions =
normalizedOptions.ssrOptions =
normalizedOptions.appShellOptions =
undefined;
normalizedOptions.ssrOptions = undefined;
}

yield* runEsBuildBuildAction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import type { OutputFile } from 'esbuild';
import Piscina from 'piscina';
import { cloneOutputFile, createOutputFileFromData } from './utils';
import { cloneOutputFile, createOutputFileFromText } from './utils';

/**
* A keyword used to indicate if a JavaScript file may require inlining of translations.
Expand Down Expand Up @@ -42,7 +42,7 @@ export class I18nInliner {
const files = new Map<string, Blob>();
const pendingMaps = [];
for (const file of options.outputFiles) {
if (file.path.endsWith('.js')) {
if (file.path.endsWith('.js') || file.path.endsWith('.mjs')) {
// Check if localizations are present
const contentBuffer = Buffer.isBuffer(file.contents)
? file.contents
Expand Down Expand Up @@ -123,7 +123,7 @@ export class I18nInliner {

// Convert raw results to output file objects and include all unmodified files
return [
...rawResults.flat().map(({ file, contents }) => createOutputFileFromData(file, contents)),
...rawResults.flat().map(({ file, contents }) => createOutputFileFromText(file, contents)),
...this.#unmodifiedFiles.map((file) => cloneOutputFile(file)),
];
}
Expand Down
11 changes: 4 additions & 7 deletions packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,19 +227,16 @@ export function createOutputFileFromData(path: string, data: Uint8Array): Output
}

export function cloneOutputFile(file: OutputFile): OutputFile {
const path = file.path;
const data = file.contents;

return {
path,
path: file.path,
get text() {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf-8');
return file.text;
},
get hash() {
return createHash('sha256').update(data).digest('hex');
return file.hash;
},
get contents() {
return data;
return file.contents;
},
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { getGlobalVariable } from '../../utils/env';
import { appendToFile, createDir, expectFileToMatch, writeFile } from '../../utils/fs';
import { installWorkspacePackages } from '../../utils/packages';
import { silentNg } from '../../utils/process';
import { ng, silentNg } from '../../utils/process';
import { updateJsonFile } from '../../utils/project';
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 useWebpackBuilder = !getGlobalVariable('argv')['esbuild'];
const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots'];

await updateJsonFile('package.json', (packageJson) => {
Expand All @@ -39,7 +35,7 @@ export default async function () {

await installWorkspacePackages();

const browserBaseDir = 'dist/test-project/browser';
const browserBaseDir = useWebpackBuilder ? 'dist/test-project/browser' : 'dist/test-project';

// Set configurations for each locale.
const langTranslations = [
Expand All @@ -51,13 +47,14 @@ export default async function () {
const appProject = workspaceJson.projects['test-project'];
const appArchitect = appProject.architect;
const buildOptions = appArchitect['build'].options;
const serverOptions = appArchitect['server'].options;

// Enable localization for all locales
buildOptions.localize = true;
buildOptions.outputHashing = 'none';
serverOptions.localize = true;
serverOptions.outputHashing = 'none';
if (useWebpackBuilder) {
const serverOptions = appArchitect['server'].options;
serverOptions.localize = true;
serverOptions.outputHashing = 'none';
}

// Add locale definitions to the project
const i18n: Record<string, any> = (appProject.i18n = { locales: {} });
Expand Down Expand Up @@ -85,7 +82,11 @@ export default async function () {
}

// Build each locale and verify the SW output.
await silentNg('run', 'test-project:app-shell:development');
if (useWebpackBuilder) {
await ng('run', 'test-project:app-shell:development');
} else {
await ng('build', '--output-hashing=none');
}
for (const { lang } of langTranslations) {
await Promise.all([
expectFileToMatch(`${browserBaseDir}/${lang}/ngsw.json`, `/${lang}/main.js`),
Expand Down
25 changes: 11 additions & 14 deletions tests/legacy-cli/e2e/tests/i18n/ivy-localize-app-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,7 @@ 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 useWebpackBuilder = !getGlobalVariable('argv')['esbuild'];
const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots'];

await updateJsonFile('package.json', (packageJson) => {
Expand All @@ -41,7 +37,7 @@ export default async function () {

await installWorkspacePackages();

const browserBaseDir = 'dist/test-project/browser';
const browserBaseDir = useWebpackBuilder ? 'dist/test-project/browser' : 'dist/test-project';

// Set configurations for each locale.
const langTranslations = [
Expand All @@ -53,16 +49,13 @@ export default async function () {
const appProject = workspaceJson.projects['test-project'];
const appArchitect = appProject.architect || appProject.targets;
const buildOptions = appArchitect['build'].options;
const serverOptions = appArchitect['server'].options;

// Make default builds prod.
buildOptions.optimization = true;
buildOptions.buildOptimizer = true;
buildOptions.aot = true;

// Enable localization for all locales
buildOptions.localize = true;
serverOptions.localize = true;
if (useWebpackBuilder) {
const serverOptions = appArchitect['server'].options;
serverOptions.localize = true;
}

// Add locale definitions to the project
const i18n: Record<string, any> = (appProject.i18n = { locales: {} });
Expand Down Expand Up @@ -114,7 +107,11 @@ export default async function () {
}

// Build each locale and verify the output.
await ng('run', 'test-project:app-shell');
if (useWebpackBuilder) {
await ng('run', 'test-project:app-shell');
} else {
await ng('build');
}
for (const { lang, translation } of langTranslations) {
await expectFileToMatch(`${browserBaseDir}/${lang}/index.html`, translation);
}
Expand Down

0 comments on commit fd62a93

Please sign in to comment.