From 2dc6566ad069c88ecdca62ef3c05e488de34960d Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 18 Oct 2023 08:28:13 +0000 Subject: [PATCH] fix(@angular-devkit/build-angular): generate a file containing a list of prerendered routes With this change when SSG is enabled a `prerendered-routes.json` file is emitted that contains all the prerendered routes. This is useful for Cloud providers and other server engines to have server rules to serve these files as static. --- .../src/builders/application/execute-build.ts | 44 +++++++++++++------ .../application/execute-post-bundle.ts | 11 ++++- .../src/builders/application/i18n.ts | 39 ++++++++++------ .../src/utils/server-rendering/prerender.ts | 15 +++++-- .../prerender/discover-routes-standalone.ts | 19 +++++++- 5 files changed, 95 insertions(+), 33 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index 963daa5e334e..3a74c43228b1 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -198,25 +198,41 @@ export async function executeBuild( } // Perform i18n translation inlining if enabled + let prerenderedRoutes: string[]; + let errors: string[]; + let warnings: string[]; if (i18nOptions.shouldInline) { - const { errors, warnings } = await inlineI18n(options, executionResult, initialFiles); - printWarningsAndErrorsToConsole(context, warnings, errors); + const result = await inlineI18n(options, executionResult, initialFiles); + errors = result.errors; + warnings = result.warnings; + prerenderedRoutes = result.prerenderedRoutes; } else { - const { errors, warnings, additionalAssets, additionalOutputFiles } = - await executePostBundleSteps( - options, - executionResult.outputFiles, - executionResult.assetFiles, - initialFiles, - // Set lang attribute to the defined source locale if present - i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, - ); + const result = await executePostBundleSteps( + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); - executionResult.outputFiles.push(...additionalOutputFiles); - executionResult.assetFiles.push(...additionalAssets); - printWarningsAndErrorsToConsole(context, warnings, errors); + errors = result.errors; + warnings = result.warnings; + prerenderedRoutes = result.prerenderedRoutes; + executionResult.outputFiles.push(...result.additionalOutputFiles); + executionResult.assetFiles.push(...result.additionalAssets); } + if (prerenderOptions) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: prerenderedRoutes.sort((a, b) => a.localeCompare(b)) }, null, 2), + BuildOutputFileType.Root, + ); + } + + printWarningsAndErrorsToConsole(context, warnings, errors); + logBuildStats(context, metafile, initialFiles, budgetFailures, estimatedTransferSizes); const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts index cceb04d3b665..6a5d1869ebfa 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts @@ -39,11 +39,13 @@ export async function executePostBundleSteps( warnings: string[]; additionalOutputFiles: BuildOutputFile[]; additionalAssets: BuildOutputAsset[]; + prerenderedRoutes: string[]; }> { const additionalAssets: BuildOutputAsset[] = []; const additionalOutputFiles: BuildOutputFile[] = []; const allErrors: string[] = []; const allWarnings: string[] = []; + const prerenderedRoutes: string[] = []; const { serviceWorker, @@ -105,7 +107,12 @@ export async function executePostBundleSteps( 'The "index" option is required when using the "ssg" or "appShell" options.', ); - const { output, warnings, errors } = await prerenderPages( + const { + output, + warnings, + errors, + prerenderedRoutes: generatedRoutes, + } = await prerenderPages( workspaceRoot, appShellOptions, prerenderOptions, @@ -119,6 +126,7 @@ export async function executePostBundleSteps( allErrors.push(...errors); allWarnings.push(...warnings); + prerenderedRoutes.push(...Array.from(generatedRoutes)); for (const [path, content] of Object.entries(output)) { additionalOutputFiles.push( @@ -155,6 +163,7 @@ export async function executePostBundleSteps( errors: allErrors, warnings: allWarnings, additionalAssets, + prerenderedRoutes, additionalOutputFiles, }; } diff --git a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts index f48c958e9608..e871c7298790 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts @@ -7,7 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import { join } from 'node:path'; +import { join, posix } 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'; @@ -29,7 +29,7 @@ export async function inlineI18n( options: NormalizedApplicationBuildOptions, executionResult: ExecutionResult, initialFiles: Map, -): Promise<{ errors: string[]; warnings: string[] }> { +): Promise<{ errors: string[]; warnings: string[]; prerenderedRoutes: string[] }> { // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new I18nInliner( { @@ -40,9 +40,10 @@ export async function inlineI18n( maxWorkers, ); - const inlineResult: { errors: string[]; warnings: string[] } = { + const inlineResult: { errors: string[]; warnings: string[]; prerenderedRoutes: string[] } = { errors: [], warnings: [], + prerenderedRoutes: [], }; // For each active locale, use the inliner to process the output files of the build. @@ -59,17 +60,22 @@ export async function inlineI18n( const baseHref = getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref; - const { errors, warnings, additionalAssets, additionalOutputFiles } = - await executePostBundleSteps( - { - ...options, - baseHref, - }, - localeOutputFiles, - executionResult.assetFiles, - initialFiles, - locale, - ); + const { + errors, + warnings, + additionalAssets, + additionalOutputFiles, + prerenderedRoutes: generatedRoutes, + } = await executePostBundleSteps( + { + ...options, + baseHref, + }, + localeOutputFiles, + executionResult.assetFiles, + initialFiles, + locale, + ); localeOutputFiles.push(...additionalOutputFiles); inlineResult.errors.push(...errors); @@ -87,7 +93,12 @@ export async function inlineI18n( destination: join(locale, assetFile.destination), }); } + + inlineResult.prerenderedRoutes.push( + ...generatedRoutes.map((route) => posix.join('/', locale, route)), + ); } else { + inlineResult.prerenderedRoutes.push(...generatedRoutes); executionResult.assetFiles.push(...additionalAssets); } diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts index d0b8c59b7ccd..cd1f91f0c0f3 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts @@ -41,6 +41,7 @@ export async function prerenderPages( output: Record; warnings: string[]; errors: string[]; + prerenderedRoutes: Set; }> { const output: Record = {}; const warnings: string[] = []; @@ -92,6 +93,7 @@ export async function prerenderPages( errors, warnings, output, + prerenderedRoutes: allRoutes, }; } @@ -114,7 +116,7 @@ export async function prerenderPages( try { const renderingPromises: Promise[] = []; - const appShellRoute = appShellOptions.route && removeLeadingSlash(appShellOptions.route); + const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); for (const route of allRoutes) { const isAppShellRoute = appShellRoute === route; @@ -123,7 +125,9 @@ export async function prerenderPages( const render: Promise = renderWorker.run({ route, serverContext }); const renderResult: Promise = render.then(({ content, warnings, errors }) => { if (content !== undefined) { - const outPath = isAppShellRoute ? 'index.html' : posix.join(route, 'index.html'); + const outPath = isAppShellRoute + ? 'index.html' + : removeLeadingSlash(posix.join(route, 'index.html')); output[outPath] = content; } @@ -148,12 +152,13 @@ export async function prerenderPages( errors, warnings, output, + prerenderedRoutes: allRoutes, }; } class RoutesSet extends Set { override add(value: string): this { - return super.add(removeLeadingSlash(value)); + return super.add(addLeadingSlash(value)); } } @@ -213,6 +218,10 @@ async function getAllRoutes( return { routes, warnings }; } +function addLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value : '/' + value; +} + function removeLeadingSlash(value: string): string { return value.charAt(0) === '/' ? value.slice(1) : value; } diff --git a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts index 49674fe2b2c4..7c2ac56f097b 100644 --- a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts +++ b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts @@ -1,9 +1,10 @@ import { join } from 'path'; import { getGlobalVariable } from '../../../utils/env'; -import { expectFileToMatch, rimraf, writeFile } from '../../../utils/fs'; +import { expectFileToMatch, readFile, rimraf, writeFile } from '../../../utils/fs'; import { installWorkspacePackages } from '../../../utils/packages'; import { ng } from '../../../utils/process'; import { useSha } from '../../../utils/project'; +import { deepStrictEqual } from 'node:assert'; export default async function () { const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; @@ -111,5 +112,21 @@ export default async function () { for (const [filePath, fileMatch] of Object.entries(expects)) { await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); } + + if (!useWebpackBuilder) { + // prerendered-routes.json file is only generated when using esbuild. + const generatedRoutesStats = await readFile('dist/test-project/prerendered-routes.json'); + deepStrictEqual(JSON.parse(generatedRoutesStats), { + routes: [ + '/', + '/lazy-one', + '/lazy-one/lazy-one-child', + '/lazy-two', + '/two', + '/two/two-child-one', + '/two/two-child-two', + ], + }); + } } }