Skip to content

Commit 5a28f55

Browse files
committed
feat(@angular/build): add prerenderFormat option to prerender routes as <route>.html
Prerendered routes are written to `<route>/index.html`. Static hosts serve such a file under `/<route>/`, so a request to `/<route>` 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 `<route>.html`, which avoids the redirect on hosts that serve `<route>.html` for `/<route>`. The root route of the application and of each locale is still written to `index.html`. Static redirect pages follow the same layout. A route whose last segment is `index` is reported as a build error with `file`. `file` is only supported when the build does not produce a server (for example with `outputMode: "static"`), because the `@angular/ssr` runtime looks up prerendered pages as `<route>/index.html`. The dev server does not prerender and resets the option. Closes #29173
1 parent 91b0947 commit 5a28f55

9 files changed

Lines changed: 454 additions & 4 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type ApplicationBuilderOptions = {
5555
poll?: number;
5656
polyfills?: string[];
5757
prerender?: PrerenderUnion;
58+
prerenderFormat?: PrerenderFormat;
5859
preserveSymlinks?: boolean;
5960
progress?: boolean;
6061
scripts?: ScriptElement[];

‎packages/angular/build/src/builders/application/execute-post-bundle.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export async function executePostBundleSteps(
7171
outputMode,
7272
serverEntryPoint,
7373
prerenderOptions,
74+
prerenderFormat,
7475
appShellOptions,
7576
publicPath,
7677
workspaceRoot,
@@ -168,6 +169,7 @@ export async function executePostBundleSteps(
168169
[...outputFiles, ...additionalOutputFiles],
169170
assetFiles,
170171
outputMode,
172+
prerenderFormat,
171173
sourcemapOptions.scripts,
172174
maxWorkers,
173175
);

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

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

3738
/**
@@ -342,6 +343,14 @@ export async function normalizeOptions(
342343
options.outputMode === OutputMode.Static,
343344
};
344345

346+
const prerenderFormat = options.prerenderFormat ?? PrerenderFormat.Directory;
347+
if (prerenderFormat === PrerenderFormat.File && !outputOptions.ignoreServer) {
348+
// The server runtime of '@angular/ssr' looks up prerendered pages as '<route>/index.html'.
349+
throw new Error(
350+
'The "prerenderFormat" option set to "file" requires "outputMode" to be set to "static".',
351+
);
352+
}
353+
345354
const outputNames = {
346355
bundles:
347356
options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles
@@ -492,6 +501,7 @@ export async function normalizeOptions(
492501
subresourceIntegrity,
493502
serverEntryPoint,
494503
prerenderOptions,
504+
prerenderFormat,
495505
appShellOptions,
496506
outputMode,
497507
ssrOptions,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,12 @@
587587
}
588588
]
589589
},
590+
"prerenderFormat": {
591+
"type": "string",
592+
"description": "Defines the file layout of prerendered (SSG) pages. 'directory': Writes each route to '<route>/index.html'. 'file': Writes each route to '<route>.html', so that hosting services which serve '<route>.html' for '/<route>' respond without a redirect to a trailing slash. Not all hosting services support this. The root route of the application and of each locale is always written to 'index.html'. Only supported when the build does not produce a server, such as when 'outputMode' is set to 'static'.",
593+
"enum": ["directory", "file"],
594+
"default": "directory"
595+
},
590596
"ssr": {
591597
"description": "Server side render (SSR) pages of your application during runtime.",
592598
"default": false,
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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 { OutputMode, PrerenderFormat } from '../../schema';
11+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
12+
13+
const routeFiles: Record<string, string> = {
14+
'src/app/app.module.ts': `
15+
import { NgModule } from '@angular/core';
16+
import { BrowserModule } from '@angular/platform-browser';
17+
import { RouterModule } from '@angular/router';
18+
import { AppComponent } from './app.component';
19+
import { routes } from './app.routes';
20+
21+
@NgModule({
22+
declarations: [AppComponent],
23+
imports: [BrowserModule, RouterModule.forRoot(routes)],
24+
bootstrap: [AppComponent],
25+
})
26+
export class AppModule {}
27+
`,
28+
'src/app/app.routes.ts': `
29+
import { Component } from '@angular/core';
30+
import { Routes } from '@angular/router';
31+
32+
@Component({ selector: 'app-home', template: '<p>home works!</p>' })
33+
export class HomeComponent {}
34+
35+
@Component({ selector: 'app-foo', template: '<p>foo works!</p>' })
36+
export class FooComponent {}
37+
38+
@Component({ selector: 'app-bar', template: '<p>foo-bar works!</p>' })
39+
export class BarComponent {}
40+
41+
export const routes: Routes = [
42+
{ path: '', component: HomeComponent },
43+
{ path: 'foo', component: FooComponent },
44+
{ path: 'foo/bar', component: BarComponent },
45+
{ path: 'old-foo', redirectTo: 'foo' },
46+
];
47+
`,
48+
'src/app/app.component.html': `<router-outlet></router-outlet>`,
49+
'src/server.ts': `console.log('Hello!');`,
50+
};
51+
52+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
53+
beforeEach(async () => {
54+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
55+
const tsConfig = JSON.parse(content);
56+
tsConfig.files ??= [];
57+
tsConfig.files.push('main.server.ts', 'server.ts');
58+
59+
return JSON.stringify(tsConfig);
60+
});
61+
62+
await harness.writeFiles(routeFiles);
63+
});
64+
65+
describe('Option: "prerenderFormat"', () => {
66+
it(`should write routes to '<route>/index.html' by default`, async () => {
67+
harness.useTarget('build', {
68+
...BASE_OPTIONS,
69+
server: 'src/main.server.ts',
70+
polyfills: ['zone.js'],
71+
prerender: true,
72+
});
73+
74+
const { result } = await harness.executeOnce();
75+
expect(result?.success).toBeTrue();
76+
77+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
78+
harness.expectFile('dist/browser/foo/index.html').content.toContain('foo works!');
79+
harness.expectFile('dist/browser/foo/bar/index.html').content.toContain('foo-bar works!');
80+
harness.expectFile('dist/browser/foo.html').toNotExist();
81+
});
82+
83+
it(`should write routes to '<route>.html' when set to 'file'`, async () => {
84+
harness.useTarget('build', {
85+
...BASE_OPTIONS,
86+
server: 'src/main.server.ts',
87+
polyfills: ['zone.js'],
88+
prerender: true,
89+
prerenderFormat: PrerenderFormat.File,
90+
});
91+
92+
const { result } = await harness.executeOnce();
93+
expect(result?.success).toBeTrue();
94+
95+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
96+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
97+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
98+
harness.expectFile('dist/browser/foo/index.html').toNotExist();
99+
harness.expectFile('dist/browser/foo/bar/index.html').toNotExist();
100+
});
101+
102+
it(`should support 'file' with 'outputMode' set to 'static' and an 'ssr' entry`, async () => {
103+
harness.useTarget('build', {
104+
...BASE_OPTIONS,
105+
server: 'src/main.server.ts',
106+
polyfills: ['zone.js'],
107+
outputMode: OutputMode.Static,
108+
ssr: { entry: 'src/server.ts' },
109+
prerenderFormat: PrerenderFormat.File,
110+
});
111+
112+
const { result } = await harness.executeOnce();
113+
expect(result?.success).toBeTrue();
114+
115+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
116+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
117+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
118+
harness
119+
.expectFile('dist/browser/old-foo.html')
120+
.content.toContain('<meta http-equiv="refresh" content="0; url=/foo">');
121+
harness.expectFile('dist/browser/foo/index.html').toNotExist();
122+
harness.expectDirectory('dist/server').toNotExist();
123+
});
124+
125+
it(`should list the prerendered routes in 'prerendered-routes.json' when set to 'file'`, async () => {
126+
harness.useTarget('build', {
127+
...BASE_OPTIONS,
128+
server: 'src/main.server.ts',
129+
polyfills: ['zone.js'],
130+
outputMode: OutputMode.Static,
131+
ssr: { entry: 'src/server.ts' },
132+
prerenderFormat: PrerenderFormat.File,
133+
});
134+
135+
const { result } = await harness.executeOnce();
136+
expect(result?.success).toBeTrue();
137+
138+
const content = harness.readFile('dist/prerendered-routes.json');
139+
expect(Object.keys(JSON.parse(content).routes)).toEqual(
140+
jasmine.arrayContaining(['/', '/foo', '/foo/bar']),
141+
);
142+
});
143+
144+
it(`should remove the 'baseHref' from the file names when set to 'file'`, async () => {
145+
harness.useTarget('build', {
146+
...BASE_OPTIONS,
147+
server: 'src/main.server.ts',
148+
polyfills: ['zone.js'],
149+
outputMode: OutputMode.Static,
150+
baseHref: '/app/',
151+
prerenderFormat: PrerenderFormat.File,
152+
});
153+
154+
const { result } = await harness.executeOnce();
155+
expect(result?.success).toBeTrue();
156+
157+
harness.expectFile('dist/browser/index.html').content.toContain('<base href="/app/">');
158+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
159+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
160+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
161+
harness.expectFile('dist/browser/app.html').toNotExist();
162+
harness.expectFile('dist/browser/app/foo.html').toNotExist();
163+
});
164+
165+
it(`should keep 'index.html' for the source locale and each locale when set to 'file'`, async () => {
166+
harness.useProject('test', {
167+
root: '.',
168+
sourceRoot: 'src',
169+
cli: {
170+
cache: {
171+
enabled: false,
172+
},
173+
},
174+
i18n: {
175+
sourceLocale: {
176+
code: 'en-US',
177+
subPath: '',
178+
},
179+
locales: {
180+
'fr': {
181+
translation: 'src/locales/messages.fr.xlf',
182+
subPath: 'fr',
183+
},
184+
'de': {
185+
translation: 'src/locales/messages.de.xlf',
186+
subPath: 'deutsch',
187+
},
188+
},
189+
},
190+
});
191+
192+
await harness.writeFiles({
193+
'src/locales/messages.fr.xlf': EMPTY_TRANSLATION_FILE_CONTENT,
194+
'src/locales/messages.de.xlf': EMPTY_TRANSLATION_FILE_CONTENT.replace(
195+
'target-language="fr"',
196+
'target-language="de"',
197+
),
198+
});
199+
200+
harness.useTarget('build', {
201+
...BASE_OPTIONS,
202+
server: 'src/main.server.ts',
203+
polyfills: ['zone.js'],
204+
outputMode: OutputMode.Static,
205+
localize: true,
206+
prerenderFormat: PrerenderFormat.File,
207+
});
208+
209+
const { result } = await harness.executeOnce();
210+
expect(result?.success).toBeTrue();
211+
212+
harness.expectFile('dist/browser/index.html').content.toContain('<base href="/">');
213+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
214+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
215+
216+
harness.expectFile('dist/browser/fr/index.html').content.toContain('<base href="/fr/">');
217+
harness.expectFile('dist/browser/fr/foo.html').content.toContain('foo works!');
218+
harness.expectFile('dist/browser/fr.html').toNotExist();
219+
220+
harness
221+
.expectFile('dist/browser/deutsch/index.html')
222+
.content.toContain('<base href="/deutsch/">');
223+
harness.expectFile('dist/browser/deutsch/foo/bar.html').content.toContain('foo-bar works!');
224+
harness.expectFile('dist/browser/deutsch.html').toNotExist();
225+
});
226+
227+
it(`should error for a route ending in 'index' when set to 'file'`, async () => {
228+
await harness.modifyFile('src/app/app.routes.ts', (content) =>
229+
content.replace(
230+
`{ path: 'foo', component: FooComponent },`,
231+
`{ path: 'foo', component: FooComponent },\n { path: 'index', component: FooComponent },`,
232+
),
233+
);
234+
235+
harness.useTarget('build', {
236+
...BASE_OPTIONS,
237+
server: 'src/main.server.ts',
238+
polyfills: ['zone.js'],
239+
outputMode: OutputMode.Static,
240+
prerenderFormat: PrerenderFormat.File,
241+
});
242+
243+
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
244+
expect(result?.success).toBeFalse();
245+
expect(logs).toContain(
246+
jasmine.objectContaining({
247+
message: jasmine.stringContaining(
248+
`Route '/index' cannot be prerendered when 'prerenderFormat' is set to 'file' because ` +
249+
`its output file 'index.html' would be served for '/'.`,
250+
),
251+
}),
252+
);
253+
});
254+
255+
it(`should accept a routes file with empty and duplicate lines when set to 'file'`, async () => {
256+
await harness.writeFile('src/routes.txt', '/\n/foo\n/foo\n\n');
257+
258+
harness.useTarget('build', {
259+
...BASE_OPTIONS,
260+
server: 'src/main.server.ts',
261+
polyfills: ['zone.js'],
262+
prerender: {
263+
discoverRoutes: false,
264+
routesFile: 'src/routes.txt',
265+
},
266+
prerenderFormat: PrerenderFormat.File,
267+
});
268+
269+
const { result } = await harness.executeOnce();
270+
expect(result?.success).toBeTrue();
271+
272+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
273+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
274+
});
275+
276+
it('should not log anything about the option when not prerendering', async () => {
277+
harness.useTarget('build', {
278+
...BASE_OPTIONS,
279+
});
280+
281+
const { result, logs } = await harness.executeOnce();
282+
expect(result?.success).toBeTrue();
283+
expect(logs).not.toContain(
284+
jasmine.objectContaining({ message: jasmine.stringContaining('prerenderFormat') }),
285+
);
286+
});
287+
288+
it(`should error when set to 'file' and 'outputMode' is set to 'server'`, async () => {
289+
harness.useTarget('build', {
290+
...BASE_OPTIONS,
291+
server: 'src/main.server.ts',
292+
polyfills: ['zone.js'],
293+
outputMode: OutputMode.Server,
294+
ssr: { entry: 'src/server.ts' },
295+
prerenderFormat: PrerenderFormat.File,
296+
});
297+
298+
const { error } = await harness.executeOnce({ outputLogsOnException: false });
299+
expect(error?.message).toContain('"prerenderFormat" option set to "file"');
300+
});
301+
302+
it(`should error when set to 'file' with server-side rendering and no 'outputMode'`, async () => {
303+
harness.useTarget('build', {
304+
...BASE_OPTIONS,
305+
server: 'src/main.server.ts',
306+
polyfills: ['zone.js'],
307+
prerender: true,
308+
ssr: { entry: 'src/server.ts' },
309+
prerenderFormat: PrerenderFormat.File,
310+
});
311+
312+
const { error } = await harness.executeOnce({ outputLogsOnException: false });
313+
expect(error?.message).toContain('"prerenderFormat" option set to "file"');
314+
});
315+
});
316+
});
317+
318+
const EMPTY_TRANSLATION_FILE_CONTENT = `<?xml version="1.0" encoding="UTF-8" ?>
319+
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
320+
<file target-language="fr" datatype="plaintext" original="ng2.template">
321+
<body>
322+
</body>
323+
</file>
324+
</xliff>
325+
`;

0 commit comments

Comments
 (0)