From a6dd90a0648ba258e0de59fbee10bc3009d32992 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 25 Sep 2026 06:15:37 +0200 Subject: [PATCH 1/2] feat(@angular/build): add `prerenderFormat` option to prerender routes as `.html` Prerendered routes are written to `/index.html`. Static hosts serve such a file under `//`, so a request to `/` is first redirected to the URL with a trailing slash, which the Angular router then removes again. The new `prerenderFormat` option of the application builder accepts `directory` (default, unchanged behavior) and `file`, like the `build.format` option of Astro. With `file`, routes are written to `.html`, which avoids the redirect on hosts that serve `.html` for `/`. The root route (after removing the `baseHref` option) is still written to `index.html`. Static redirect pages follow the same layout. A route that cannot be written safely to `.html` keeps `/index.html` and the build reports a warning: routes whose last segment is `index` in any casing, routes whose file would be the index output of the application, and routes whose file name is already used by another route. `file` is only considered when the build does not produce a server (for example with `outputMode: "static"`), because the `@angular/ssr` runtime looks up prerendered pages as `/index.html`. When the build produces a server, a warning is reported and routes are written to `/index.html`. Closes #29173 --- goldens/public-api/angular/build/index.api.md | 1 + .../application/execute-post-bundle.ts | 3 + .../build/src/builders/application/options.ts | 16 + .../src/builders/application/schema.json | 6 + .../tests/options/prerender-format_spec.ts | 368 ++++++++++++++++++ .../src/utils/server-rendering/prerender.ts | 126 +++++- .../server-routes-output-mode-static.ts | 16 + 7 files changed, 527 insertions(+), 9 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/options/prerender-format_spec.ts diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index b08d70c07e64..fbb6e397335b 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -55,6 +55,7 @@ export type ApplicationBuilderOptions = { poll?: number; polyfills?: string[]; prerender?: PrerenderUnion; + prerenderFormat?: PrerenderFormat; preserveSymlinks?: boolean; progress?: boolean; scripts?: ScriptElement[]; diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 99e23bcb30c9..4caa3cd8e37c 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -71,6 +71,7 @@ export async function executePostBundleSteps( outputMode, serverEntryPoint, prerenderOptions, + prerenderFormat, appShellOptions, publicPath, workspaceRoot, @@ -168,6 +169,8 @@ export async function executePostBundleSteps( [...outputFiles, ...additionalOutputFiles], assetFiles, outputMode, + prerenderFormat, + indexHtmlOptions.output, sourcemapOptions.scripts, maxWorkers, ); diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index f7e60f4cc029..4e6c892339e9 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -32,6 +32,7 @@ import { OutputMode, OutputPathClass, Platform, + PrerenderFormat, } from './schema'; /** @@ -342,6 +343,20 @@ export async function normalizeOptions( options.outputMode === OutputMode.Static, }; + let prerenderFormat = options.prerenderFormat ?? PrerenderFormat.Directory; + if (prerenderFormat === PrerenderFormat.File && !outputOptions.ignoreServer) { + // The server runtime of '@angular/ssr' looks up prerendered pages as '/index.html'. + // The warning is only relevant when pages are actually prerendered, which the dev-server skips. + if ((prerenderOptions || appShellOptions) && !(options.partialSSRBuild || usePartialSsrBuild)) { + context.logger.warn( + 'The "prerenderFormat" option set to "file" is not considered when the build produces a ' + + 'server ("outputMode" set to "server", or "ssr" without "outputMode").', + ); + } + + prerenderFormat = PrerenderFormat.Directory; + } + const outputNames = { bundles: options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles @@ -492,6 +507,7 @@ export async function normalizeOptions( subresourceIntegrity, serverEntryPoint, prerenderOptions, + prerenderFormat, appShellOptions, outputMode, ssrOptions, diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 8458d385a3fc..c5c161188c7b 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -587,6 +587,12 @@ } ] }, + "prerenderFormat": { + "type": "string", + "description": "Defines the file layout of prerendered (SSG) pages. 'directory': '/foo' is written to 'foo/index.html'. 'file': '/foo' is written to 'foo.html', which some hosting services serve for '/foo' without a redirect to '/foo/'. The root route of the application and of each locale is always written to 'index.html'. Routes that cannot be written safely to '.html' keep '/index.html' with a warning. Only considered when the build does not produce a server.", + "enum": ["directory", "file"], + "default": "directory" + }, "ssr": { "description": "Server side render (SSR) pages of your application during runtime.", "default": false, diff --git a/packages/angular/build/src/builders/application/tests/options/prerender-format_spec.ts b/packages/angular/build/src/builders/application/tests/options/prerender-format_spec.ts new file mode 100644 index 000000000000..eb45bb1632b9 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/prerender-format_spec.ts @@ -0,0 +1,368 @@ +/** + * @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.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputMode, PrerenderFormat } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectLog } from '../setup'; + +const routeFiles: Record = { + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { AppComponent } from './app.component'; + import { routes } from './app.routes'; + + @NgModule({ + declarations: [AppComponent], + imports: [BrowserModule, RouterModule.forRoot(routes)], + bootstrap: [AppComponent], + }) + export class AppModule {} + `, + 'src/app/app.routes.ts': ` + import { Component } from '@angular/core'; + import { Routes } from '@angular/router'; + + @Component({ selector: 'app-home', template: '

home works!

' }) + export class HomeComponent {} + + @Component({ selector: 'app-foo', template: '

foo works!

' }) + export class FooComponent {} + + @Component({ selector: 'app-bar', template: '

foo-bar works!

' }) + export class BarComponent {} + + @Component({ selector: 'app-not-found', template: '

not-found works!

' }) + export class NotFoundComponent {} + + export const routes: Routes = [ + { path: '', component: HomeComponent }, + { path: 'foo', component: FooComponent }, + { path: 'foo/bar', component: BarComponent }, + { path: 'old-foo', redirectTo: 'foo' }, + ]; + `, + 'src/app/app.component.html': ``, + 'src/server.ts': `console.log('Hello!');`, +}; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles(routeFiles); + }); + + async function addRoutes(routes: string): Promise { + await harness.modifyFile('src/app/app.routes.ts', (content) => + content.replace( + `{ path: 'foo', component: FooComponent },`, + `{ path: 'foo', component: FooComponent },\n${routes}`, + ), + ); + } + + describe('Option: "prerenderFormat"', () => { + it(`should write routes to '/index.html' by default`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + prerender: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/index.html').content.toContain('home works!'); + harness.expectFile('dist/browser/foo/index.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/bar/index.html').content.toContain('foo-bar works!'); + harness.expectFile('dist/browser/foo.html').toNotExist(); + }); + + it(`should write routes to '.html' when set to 'file'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + prerender: true, + prerenderFormat: PrerenderFormat.File, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/index.html').content.toContain('home works!'); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!'); + harness.expectFile('dist/browser/foo/index.html').toNotExist(); + harness.expectFile('dist/browser/foo/bar/index.html').toNotExist(); + }); + + it(`should support 'file' with 'outputMode' set to 'static' and an 'ssr' entry`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + outputMode: OutputMode.Static, + ssr: { entry: 'src/server.ts' }, + prerenderFormat: PrerenderFormat.File, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/index.html').content.toContain('home works!'); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!'); + harness + .expectFile('dist/browser/old-foo.html') + .content.toContain(''); + harness.expectFile('dist/browser/foo/index.html').toNotExist(); + harness.expectDirectory('dist/server').toNotExist(); + + const content = harness.readFile('dist/prerendered-routes.json'); + expect(Object.keys(JSON.parse(content).routes)).toEqual( + jasmine.arrayContaining(['/', '/foo', '/foo/bar']), + ); + }); + + it(`should remove the 'baseHref' from the file names when set to 'file'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + outputMode: OutputMode.Static, + baseHref: '/app/', + prerenderFormat: PrerenderFormat.File, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain('home works!'); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!'); + harness.expectFile('dist/browser/app.html').toNotExist(); + harness.expectFile('dist/browser/app/foo.html').toNotExist(); + }); + + it(`should keep 'index.html' for the source locale and each locale when set to 'file'`, async () => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + sourceLocale: { + code: 'en-US', + subPath: '', + }, + locales: { + 'fr': { + translation: 'src/locales/messages.fr.xlf', + subPath: 'fr', + }, + 'de': { + translation: 'src/locales/messages.de.xlf', + subPath: 'deutsch', + }, + }, + }, + }); + + await harness.writeFiles({ + 'src/locales/messages.fr.xlf': EMPTY_TRANSLATION_FILE_CONTENT, + 'src/locales/messages.de.xlf': EMPTY_TRANSLATION_FILE_CONTENT.replace( + 'target-language="fr"', + 'target-language="de"', + ), + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + outputMode: OutputMode.Static, + localize: true, + prerenderFormat: PrerenderFormat.File, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/index.html').content.toContain(''); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!'); + + harness.expectFile('dist/browser/fr/index.html').content.toContain(''); + harness.expectFile('dist/browser/fr/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/fr/fr.html').toNotExist(); + + harness + .expectFile('dist/browser/deutsch/index.html') + .content.toContain(''); + harness.expectFile('dist/browser/deutsch/foo/bar.html').content.toContain('foo-bar works!'); + harness.expectFile('dist/browser/deutsch/deutsch.html').toNotExist(); + }); + + it(`should keep '/index.html' with a warning for routes named 'index' when set to 'file'`, async () => { + await addRoutes(` + { path: 'index', component: FooComponent }, + { path: 'foo/Index', component: BarComponent }, + `); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + outputMode: OutputMode.Static, + prerenderFormat: PrerenderFormat.File, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expectLog( + logs, + `Route '/index' is written to 'index/index.html' because 'index.html' would be served for '/'.`, + ); + expectLog( + logs, + `Route '/foo/Index' is written to 'foo/Index/index.html' because 'foo/Index.html' ` + + `would be served for '/foo/'.`, + ); + + harness.expectFile('dist/browser/index.html').content.toContain('home works!'); + harness.expectFile('dist/browser/index/index.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/Index/index.html').content.toContain('foo-bar works!'); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo/index.html').toNotExist(); + }); + + it(`should keep '/index.html' with a warning for a route named like the index file when set to 'file'`, async () => { + await addRoutes(`{ path: '404', component: NotFoundComponent },`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + outputMode: OutputMode.Static, + index: { input: 'src/index.html', output: '404.html' }, + prerenderFormat: PrerenderFormat.File, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expectLog( + logs, + `Route '/404' is written to '404/index.html' because '404.html' is the index file ` + + `of the application.`, + ); + + harness.expectFile('dist/browser/404/index.html').content.toContain('not-found works!'); + harness.expectFile('dist/browser/404.html').content.toContain(''); + harness.expectFile('dist/browser/404.html').content.not.toContain('not-found works!'); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + }); + + it(`should keep '/index.html' with a warning for routes with the same file name when set to 'file'`, async () => { + await addRoutes(`{ path: 'Foo', component: BarComponent },`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + outputMode: OutputMode.Static, + prerenderFormat: PrerenderFormat.File, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expectLog( + logs, + `Route '/Foo' is written to 'Foo/index.html' because 'Foo.html' is already used by ` + + `route '/foo'.`, + ); + + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/Foo/index.html').content.toContain('foo-bar works!'); + }); + + it(`should write a root route written as '/.' to 'index.html' when set to 'file'`, async () => { + await harness.writeFile('src/routes.txt', '.\nfoo\n'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + prerender: { + discoverRoutes: false, + routesFile: 'src/routes.txt', + }, + prerenderFormat: PrerenderFormat.File, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/index.html').content.toContain('home works!'); + harness.expectFile('dist/browser/foo.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/..html').toNotExist(); + }); + + for (const [description, serverOptions] of [ + ["'outputMode' is set to 'server'", { outputMode: OutputMode.Server }], + ["server-side rendering is used without 'outputMode'", { prerender: true }], + ] as const) { + it(`should warn and write '/index.html' when set to 'file' and ${description}`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + ssr: { entry: 'src/server.ts' }, + ...serverOptions, + prerenderFormat: PrerenderFormat.File, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expectLog( + logs, + 'The "prerenderFormat" option set to "file" is not considered when the build produces a server', + ); + + harness.expectFile('dist/browser/foo/index.html').content.toContain('foo works!'); + harness.expectFile('dist/browser/foo.html').toNotExist(); + }); + } + }); +}); + +const EMPTY_TRANSLATION_FILE_CONTENT = ` + + + + + + +`; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 98502c63587d..6179fed84f43 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -9,7 +9,7 @@ import { readFile } from 'node:fs/promises'; import { extname, posix } from 'node:path'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; -import { OutputMode } from '../../builders/application/schema'; +import { OutputMode, PrerenderFormat } from '../../builders/application/schema'; import { BuildOutputAsset, PrerenderedRoutesRecord, @@ -17,7 +17,13 @@ import { import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; import { assertIsError } from '../error'; import { toPosixPath } from '../path'; -import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; +import { + addLeadingSlash, + addTrailingSlash, + joinUrlParts, + stripLeadingSlash, + stripTrailingSlash, +} from '../url'; import { WorkerPool } from '../worker-pool'; import { IMPORT_EXEC_ARGV, @@ -51,6 +57,8 @@ type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions']; * '/index.html': { content: '...', appShell: false }, * '/shell/index.html': { content: '...', appShellRoute: true } * } + * + * With the 'file' format, non-root routes are keyed as `.html` (e.g. 'shell.html'). */ type PrerenderOutput = Record; @@ -62,6 +70,8 @@ export async function prerenderPages( outputFiles: Readonly, assets: Readonly, outputMode: OutputMode | undefined, + format: PrerenderFormat, + indexOutput: string | undefined, sourcemap = false, maxThreads = 1, ): Promise<{ @@ -191,7 +201,12 @@ export async function prerenderPages( } // Render routes - const { errors: renderingErrors, output } = await renderPages( + const { + errors: renderingErrors, + warnings: renderingWarnings, + output, + outPaths, + } = await renderPages( baseHref, sourcemap, serializableRouteTreeNodeForPrerender, @@ -200,18 +215,20 @@ export async function prerenderPages( outputFilesForWorker, assetsReversed, outputMode, + format, + indexOutput, appShellRoute ?? appShellOptions?.route, ); errors.push(...renderingErrors); + warnings.push(...renderingWarnings); const prerenderedRoutes: PrerenderedRoutesRecord = {}; - const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; for (const metadata of serializableRouteTreeNodeForPrerender) { - const outPath = getRouteOutPath(metadata.route, baseHrefPathnameWithLeadingSlash); + const outPath = outPaths.get(metadata.route); - if (output[outPath]) { + if (outPath !== undefined && output[outPath]) { prerenderedRoutes[metadata.route] = { headers: metadata.headers }; } } @@ -234,13 +251,22 @@ async function renderPages( outputFilesForWorker: Record, assetFilesForWorker: Record, outputMode: OutputMode | undefined, + format: PrerenderFormat, + indexOutput: string | undefined, appShellRoute: string | undefined, ): Promise<{ output: PrerenderOutput; + outPaths: Map; errors: string[]; + warnings: string[]; }> { const output: PrerenderOutput = {}; + const outPaths = new Map(); const errors: string[] = []; + const warnings: string[] = []; + // Output files taken by routes in the 'file' format, lower-cased because + // 'Foo.html' and 'foo.html' are the same file on case-insensitive file systems. + const usedFiles = new Map(); const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; const appShellRouteWithoutBaseHref = appShellRoute @@ -252,7 +278,26 @@ async function renderPages( for (const { route, redirectTo } of serializableRouteTreeNode) { // Remove the base href from the file output path. const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash); - const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash); + let outPath = getRouteOutPath(routeWithoutBaseHref, PrerenderFormat.Directory); + + if (format === PrerenderFormat.File) { + const filePath = getRouteOutPath(routeWithoutBaseHref, PrerenderFormat.File); + const reason = getFileFormatConflict( + routeWithoutBaseHref, + route, + filePath, + indexOutput, + usedFiles, + ); + if (reason) { + warnings.push(`Route '${route}' is written to '${outPath}' because ${reason}.`); + } else { + usedFiles.set(filePath.toLowerCase(), route); + outPath = filePath; + } + } + + outPaths.set(route, outPath); if (typeof redirectTo === 'string') { output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; @@ -270,7 +315,9 @@ async function renderPages( if (routesToRender.length === 0) { return { errors, + warnings, output, + outPaths, }; } @@ -351,7 +398,9 @@ async function renderPages( return { errors, + warnings, output, + outPaths, }; } @@ -456,8 +505,67 @@ function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): strin : route; } -function getRouteOutPath(route: string, baseHrefPathname: string): string { - const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname); +/** + * Normalizes a route path to a leading slash and no trailing slash, e.g. `foo/./bar/` to `/foo/bar`. + */ +function getNormalizedRoutePath(route: string): string { + return stripTrailingSlash(posix.normalize(addLeadingSlash(route))); +} + +/** + * Returns the output file path of a prerendered route, relative to the browser output directory. + * The route must not include the `baseHref` option. + * + * - `directory`: `/foo/bar` is written to `foo/bar/index.html`. + * - `file`: `/foo/bar` is written to `foo/bar.html`. + * + * The root route (after removing the `baseHref` option) is written to `index.html` in both formats, + * so that the entry page of the application and of each locale is served for its base path. + */ +function getRouteOutPath(routeWithoutBaseHref: string, format: PrerenderFormat): string { + if (format === PrerenderFormat.File) { + const routePath = getNormalizedRoutePath(routeWithoutBaseHref); + if (routePath !== '/') { + return `${stripLeadingSlash(routePath)}.html`; + } + } return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); } + +/** + * Returns why a route cannot be written to `filePath` in the 'file' format, or `undefined` if it can. + * Such a route keeps the 'directory' format. + */ +function getFileFormatConflict( + routeWithoutBaseHref: string, + route: string, + filePath: string, + indexOutput: string | undefined, + usedFiles: ReadonlyMap, +): string | undefined { + const routePath = getNormalizedRoutePath(routeWithoutBaseHref); + if (routePath === '/') { + return undefined; + } + + // 'index.html' is served for the parent path, and on case-insensitive file systems + // 'Index.html' is the same file. + if (posix.basename(routePath).toLowerCase() === 'index') { + const parentPath = addTrailingSlash(posix.dirname(getNormalizedRoutePath(route))); + + return `'${filePath}' would be served for '${parentPath}'`; + } + + const lowerFilePath = filePath.toLowerCase(); + if (indexOutput !== undefined && lowerFilePath === indexOutput.toLowerCase()) { + return `'${filePath}' is the index file of the application`; + } + + const existingRoute = usedFiles.get(lowerFilePath); + if (existingRoute !== undefined) { + return `'${filePath}' is already used by route '${existingRoute}'`; + } + + return undefined; +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts index 77f954be4f4d..ad13168e9d5d 100644 --- a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts @@ -131,4 +131,20 @@ export default async function () { // Should not prerender the catch all await expectFileNotToExist(join('dist/test-project/browser/**/index.html')); + + // Write each route to '.html' + await noSilentNg('build', '--output-mode=static', '--prerender-format=file'); + + for (const [directoryPath, fileMatch] of Object.entries(expects)) { + const filePath = + directoryPath === 'index.html' + ? directoryPath + : directoryPath.replace(/\/index\.html$/, '.html'); + + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + + if (filePath !== directoryPath) { + await expectFileNotToExist(join('dist/test-project/browser', directoryPath)); + } + } } From 7a9034fd0f08c43b16920fa47aa552fb92532b56 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sat, 26 Sep 2026 16:00:58 +0200 Subject: [PATCH 2/2] fixup! feat(@angular/build): add `prerenderFormat` option to prerender routes as `.html` --- .../angular/build/src/utils/server-rendering/prerender.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 6179fed84f43..f3e7cf244797 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -269,6 +269,7 @@ async function renderPages( const usedFiles = new Map(); const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + const lowerIndexOutput = indexOutput?.toLowerCase(); const appShellRouteWithoutBaseHref = appShellRoute ? addLeadingSlash(getRouteWithoutBaseHref(appShellRoute, baseHrefPathnameWithLeadingSlash)) : undefined; @@ -286,7 +287,7 @@ async function renderPages( routeWithoutBaseHref, route, filePath, - indexOutput, + lowerIndexOutput, usedFiles, ); if (reason) { @@ -541,7 +542,7 @@ function getFileFormatConflict( routeWithoutBaseHref: string, route: string, filePath: string, - indexOutput: string | undefined, + lowerIndexOutput: string | undefined, usedFiles: ReadonlyMap, ): string | undefined { const routePath = getNormalizedRoutePath(routeWithoutBaseHref); @@ -558,7 +559,7 @@ function getFileFormatConflict( } const lowerFilePath = filePath.toLowerCase(); - if (indexOutput !== undefined && lowerFilePath === indexOutput.toLowerCase()) { + if (lowerIndexOutput !== undefined && lowerFilePath === lowerIndexOutput) { return `'${filePath}' is the index file of the application`; }