Skip to content

Commit 5f65028

Browse files
feat(@angular/build): add prerender.format option to prerender routes as <route>.html
The new `format` property of the `prerender` option accepts `directory` (default, unchanged behavior) and `file`, like the `build.format` option of Astro. With `file`, routes are written to `<route>.html` instead of `<route>/index.html`. Static hosts serve `<route>/index.html` under `/<route>/`, so a request to `/<route>` is first redirected to the URL with a trailing slash, which the Angular router then removes again. Hosts that serve `<route>.html` for `/<route>` respond without that redirect.
1 parent f2d16f7 commit 5f65028

5 files changed

Lines changed: 204 additions & 7 deletions

File tree

‎goldens/public-api/angular/build/index.api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export function executeUnitTestBuilder(options: UnitTestBuilderOptions, context:
167167
// @public
168168
export type ExtractI18nBuilderOptions = {
169169
buildTarget?: string;
170-
format?: Format;
170+
format?: Format_2;
171171
i18nDuplicateTranslation?: I18NDuplicateTranslation;
172172
outFile?: string;
173173
outputPath?: string;

‎packages/angular/build/src/builders/application/options.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
OutputMode,
3333
OutputPathClass,
3434
Platform,
35+
Format as PrerenderFormat,
3536
} from './schema';
3637

3738
/**
@@ -261,14 +262,21 @@ export async function normalizeOptions(
261262
}
262263
}
263264

265+
const prerenderFormat =
266+
(typeof options.prerender === 'object' && options.prerender.format) ||
267+
PrerenderFormat.Directory;
268+
264269
if (options.outputMode) {
265270
if (!options.server) {
266271
options.ssr = false;
267272
}
268273

269-
if (options.prerender !== undefined) {
274+
if (
275+
options.prerender !== undefined &&
276+
(typeof options.prerender === 'boolean' || !('format' in options.prerender))
277+
) {
270278
context.logger.warn(
271-
'The "prerender" option is not considered when "outputMode" is specified.',
279+
'The "prerender" option has no effect when "outputMode" is specified (only "format" is supported).',
272280
);
273281
}
274282

@@ -306,6 +314,7 @@ export async function normalizeOptions(
306314
prerenderOptions = {
307315
discoverRoutes,
308316
routesFile: routesFile && path.join(workspaceRoot, routesFile),
317+
format: prerenderFormat,
309318
};
310319
}
311320

@@ -346,6 +355,15 @@ export async function normalizeOptions(
346355
options.outputMode === OutputMode.Static,
347356
};
348357

358+
if (prerenderOptions?.format === PrerenderFormat.File && !outputOptions.ignoreServer) {
359+
// The server runtime of '@angular/ssr' looks up prerendered pages as '<route>/index.html'.
360+
context.logger.warn(
361+
'The "prerender.format" option is not considered when the build produces a server.',
362+
);
363+
364+
prerenderOptions.format = PrerenderFormat.Directory;
365+
}
366+
349367
const outputNames = {
350368
bundles:
351369
options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles

‎packages/angular/build/src/builders/application/schema.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,18 @@
584584
}
585585
},
586586
"additionalProperties": false
587+
},
588+
{
589+
"type": "object",
590+
"properties": {
591+
"format": {
592+
"type": "string",
593+
"description": "Defines the file layout of prerendered 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'. Only considered when the build does not produce a server.",
594+
"enum": ["directory", "file"]
595+
}
596+
},
597+
"additionalProperties": false,
598+
"required": ["format"]
587599
}
588600
]
589601
},
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { Format, OutputMode } from '../../schema';
11+
import {
12+
APPLICATION_BUILDER_INFO,
13+
BASE_OPTIONS,
14+
describeBuilder,
15+
expectLog,
16+
expectNoLog,
17+
} from '../setup';
18+
19+
const routeFiles: Record<string, string> = {
20+
'src/app/app.module.ts': `
21+
import { NgModule } from '@angular/core';
22+
import { BrowserModule } from '@angular/platform-browser';
23+
import { RouterModule } from '@angular/router';
24+
import { AppComponent } from './app.component';
25+
import { routes } from './app.routes';
26+
27+
@NgModule({
28+
declarations: [AppComponent],
29+
imports: [BrowserModule, RouterModule.forRoot(routes)],
30+
bootstrap: [AppComponent],
31+
})
32+
export class AppModule {}
33+
`,
34+
'src/app/app.routes.ts': `
35+
import { Component } from '@angular/core';
36+
import { Routes } from '@angular/router';
37+
38+
@Component({ selector: 'app-home', template: '<p>home works!</p>' })
39+
export class HomeComponent {}
40+
41+
@Component({ selector: 'app-foo', template: '<p>foo works!</p>' })
42+
export class FooComponent {}
43+
44+
@Component({ selector: 'app-bar', template: '<p>foo-bar works!</p>' })
45+
export class BarComponent {}
46+
47+
export const routes: Routes = [
48+
{ path: '', component: HomeComponent },
49+
{ path: 'foo', component: FooComponent },
50+
{ path: 'foo/bar', component: BarComponent },
51+
{ path: 'old-foo', redirectTo: 'foo' },
52+
];
53+
`,
54+
'src/app/app.component.html': `<router-outlet></router-outlet>`,
55+
'src/server.ts': `console.log('Hello!');`,
56+
};
57+
58+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
59+
beforeEach(async () => {
60+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
61+
const tsConfig = JSON.parse(content);
62+
tsConfig.files ??= [];
63+
tsConfig.files.push('main.server.ts', 'server.ts');
64+
65+
return JSON.stringify(tsConfig);
66+
});
67+
68+
await harness.writeFiles(routeFiles);
69+
});
70+
71+
describe('Option: "prerender.format"', () => {
72+
it(`should write routes to '<route>.html' when set to 'file'`, async () => {
73+
harness.useTarget('build', {
74+
...BASE_OPTIONS,
75+
server: 'src/main.server.ts',
76+
polyfills: ['zone.js'],
77+
outputMode: OutputMode.Static,
78+
prerender: { format: Format.File },
79+
});
80+
81+
const { result, logs } = await harness.executeOnce();
82+
expect(result?.success).toBeTrue();
83+
expectNoLog(logs, 'The "prerender" option has no effect');
84+
85+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
86+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
87+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
88+
harness
89+
.expectFile('dist/browser/old-foo.html')
90+
.content.toContain('<meta http-equiv="refresh" content="0; url=/foo">');
91+
harness.expectFile('dist/browser/foo/index.html').toNotExist();
92+
93+
const content = harness.readFile('dist/prerendered-routes.json');
94+
expect(Object.keys(JSON.parse(content).routes)).toEqual(
95+
jasmine.arrayContaining(['/', '/foo', '/foo/bar']),
96+
);
97+
});
98+
99+
it(`should warn when 'prerender' is an object without 'format' and 'outputMode' is specified`, async () => {
100+
harness.useTarget('build', {
101+
...BASE_OPTIONS,
102+
server: 'src/main.server.ts',
103+
polyfills: ['zone.js'],
104+
outputMode: OutputMode.Static,
105+
prerender: { routesFile: 'routes.txt' },
106+
});
107+
108+
const { result, logs } = await harness.executeOnce();
109+
expect(result?.success).toBeTrue();
110+
111+
expectLog(logs, 'The "prerender" option has no effect when "outputMode" is specified');
112+
});
113+
114+
it(`should write routes to '<route>/index.html' by default`, async () => {
115+
harness.useTarget('build', {
116+
...BASE_OPTIONS,
117+
server: 'src/main.server.ts',
118+
polyfills: ['zone.js'],
119+
outputMode: OutputMode.Static,
120+
});
121+
122+
const { result } = await harness.executeOnce();
123+
expect(result?.success).toBeTrue();
124+
125+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
126+
harness.expectFile('dist/browser/foo/index.html').content.toContain('foo works!');
127+
harness.expectFile('dist/browser/foo/bar/index.html').content.toContain('foo-bar works!');
128+
harness.expectFile('dist/browser/foo.html').toNotExist();
129+
});
130+
131+
it(`should warn and write '<route>/index.html' when set to 'file' and the build produces a server`, async () => {
132+
harness.useTarget('build', {
133+
...BASE_OPTIONS,
134+
server: 'src/main.server.ts',
135+
polyfills: ['zone.js'],
136+
outputMode: OutputMode.Server,
137+
ssr: { entry: 'src/server.ts' },
138+
prerender: { format: Format.File },
139+
});
140+
141+
const { result, logs } = await harness.executeOnce();
142+
expect(result?.success).toBeTrue();
143+
144+
expectLog(
145+
logs,
146+
'The "prerender.format" option is not considered when the build produces a server.',
147+
);
148+
149+
harness.expectFile('dist/browser/foo/index.html').content.toContain('foo works!');
150+
harness.expectFile('dist/browser/foo.html').toNotExist();
151+
});
152+
});
153+
});

‎packages/angular/build/src/utils/server-rendering/prerender.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { readFile } from 'node:fs/promises';
1010
import { extname, posix } from 'node:path';
1111
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
12-
import { OutputMode } from '../../builders/application/schema';
12+
import { OutputMode, Format as PrerenderFormat } from '../../builders/application/schema';
1313
import {
1414
BuildOutputAsset,
1515
PrerenderedRoutesRecord,
@@ -200,6 +200,7 @@ export async function prerenderPages(
200200
outputFilesForWorker,
201201
assetsReversed,
202202
outputMode,
203+
prerenderOptions?.format,
203204
appShellRoute ?? appShellOptions?.route,
204205
);
205206

@@ -209,7 +210,11 @@ export async function prerenderPages(
209210
const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname;
210211

211212
for (const metadata of serializableRouteTreeNodeForPrerender) {
212-
const outPath = getRouteOutPath(metadata.route, baseHrefPathnameWithLeadingSlash);
213+
const outPath = getRouteOutPath(
214+
metadata.route,
215+
baseHrefPathnameWithLeadingSlash,
216+
prerenderOptions?.format,
217+
);
213218

214219
if (output[outPath]) {
215220
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
@@ -234,6 +239,7 @@ async function renderPages(
234239
outputFilesForWorker: Record<string, Uint8Array>,
235240
assetFilesForWorker: Record<string, string>,
236241
outputMode: OutputMode | undefined,
242+
format: PrerenderFormat | undefined,
237243
appShellRoute: string | undefined,
238244
): Promise<{
239245
output: PrerenderOutput;
@@ -252,7 +258,7 @@ async function renderPages(
252258
for (const { route, redirectTo } of serializableRouteTreeNode) {
253259
// Remove the base href from the file output path.
254260
const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash);
255-
const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash);
261+
const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash, format);
256262

257263
if (typeof redirectTo === 'string') {
258264
output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false };
@@ -456,8 +462,16 @@ function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): strin
456462
: route;
457463
}
458464

459-
function getRouteOutPath(route: string, baseHrefPathname: string): string {
465+
function getRouteOutPath(
466+
route: string,
467+
baseHrefPathname: string,
468+
format: PrerenderFormat | undefined,
469+
): string {
460470
const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname);
461471

472+
if (format === PrerenderFormat.File && routeWithoutBaseHref !== '/') {
473+
return stripLeadingSlash(`${routeWithoutBaseHref}.html`);
474+
}
475+
462476
return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html'));
463477
}

0 commit comments

Comments
 (0)