Skip to content

Commit 0da3613

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 resets the option when it switches prerendering to on-demand SSR. Closes #29173
1 parent 91b0947 commit 0da3613

9 files changed

Lines changed: 442 additions & 8 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: 11 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,15 @@ 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" is not supported when the build produces a server ' +
351+
'("outputMode" set to "server", or "ssr" without "outputMode").',
352+
);
353+
}
354+
345355
const outputNames = {
346356
bundles:
347357
options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles
@@ -492,6 +502,7 @@ export async function normalizeOptions(
492502
subresourceIntegrity,
493503
serverEntryPoint,
494504
prerenderOptions,
505+
prerenderFormat,
495506
appShellOptions,
496507
outputMode,
497508
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: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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+
const content = harness.readFile('dist/prerendered-routes.json');
125+
expect(Object.keys(JSON.parse(content).routes)).toEqual(
126+
jasmine.arrayContaining(['/', '/foo', '/foo/bar']),
127+
);
128+
});
129+
130+
it(`should remove the 'baseHref' from the file names when set to 'file'`, async () => {
131+
harness.useTarget('build', {
132+
...BASE_OPTIONS,
133+
server: 'src/main.server.ts',
134+
polyfills: ['zone.js'],
135+
outputMode: OutputMode.Static,
136+
baseHref: '/app/',
137+
prerenderFormat: PrerenderFormat.File,
138+
});
139+
140+
const { result } = await harness.executeOnce();
141+
expect(result?.success).toBeTrue();
142+
143+
harness.expectFile('dist/browser/index.html').content.toContain('<base href="/app/">');
144+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
145+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
146+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
147+
harness.expectFile('dist/browser/app.html').toNotExist();
148+
harness.expectFile('dist/browser/app/foo.html').toNotExist();
149+
});
150+
151+
it(`should keep 'index.html' for the source locale and each locale when set to 'file'`, async () => {
152+
harness.useProject('test', {
153+
root: '.',
154+
sourceRoot: 'src',
155+
cli: {
156+
cache: {
157+
enabled: false,
158+
},
159+
},
160+
i18n: {
161+
sourceLocale: {
162+
code: 'en-US',
163+
subPath: '',
164+
},
165+
locales: {
166+
'fr': {
167+
translation: 'src/locales/messages.fr.xlf',
168+
subPath: 'fr',
169+
},
170+
'de': {
171+
translation: 'src/locales/messages.de.xlf',
172+
subPath: 'deutsch',
173+
},
174+
},
175+
},
176+
});
177+
178+
await harness.writeFiles({
179+
'src/locales/messages.fr.xlf': EMPTY_TRANSLATION_FILE_CONTENT,
180+
'src/locales/messages.de.xlf': EMPTY_TRANSLATION_FILE_CONTENT.replace(
181+
'target-language="fr"',
182+
'target-language="de"',
183+
),
184+
});
185+
186+
harness.useTarget('build', {
187+
...BASE_OPTIONS,
188+
server: 'src/main.server.ts',
189+
polyfills: ['zone.js'],
190+
outputMode: OutputMode.Static,
191+
localize: true,
192+
prerenderFormat: PrerenderFormat.File,
193+
});
194+
195+
const { result } = await harness.executeOnce();
196+
expect(result?.success).toBeTrue();
197+
198+
harness.expectFile('dist/browser/index.html').content.toContain('<base href="/">');
199+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
200+
harness.expectFile('dist/browser/foo/bar.html').content.toContain('foo-bar works!');
201+
202+
harness.expectFile('dist/browser/fr/index.html').content.toContain('<base href="/fr/">');
203+
harness.expectFile('dist/browser/fr/foo.html').content.toContain('foo works!');
204+
harness.expectFile('dist/browser/fr/fr.html').toNotExist();
205+
206+
harness
207+
.expectFile('dist/browser/deutsch/index.html')
208+
.content.toContain('<base href="/deutsch/">');
209+
harness.expectFile('dist/browser/deutsch/foo/bar.html').content.toContain('foo-bar works!');
210+
harness.expectFile('dist/browser/deutsch/deutsch.html').toNotExist();
211+
});
212+
213+
for (const path of ['index', 'Index']) {
214+
it(`should error for a route named '${path}' when set to 'file'`, async () => {
215+
await harness.modifyFile('src/app/app.routes.ts', (content) =>
216+
content.replace(
217+
`{ path: 'foo', component: FooComponent },`,
218+
`{ path: 'foo', component: FooComponent },\n { path: '${path}', component: FooComponent },`,
219+
),
220+
);
221+
222+
harness.useTarget('build', {
223+
...BASE_OPTIONS,
224+
server: 'src/main.server.ts',
225+
polyfills: ['zone.js'],
226+
outputMode: OutputMode.Static,
227+
prerenderFormat: PrerenderFormat.File,
228+
});
229+
230+
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
231+
expect(result?.success).toBeFalse();
232+
expect(logs).toContain(
233+
jasmine.objectContaining({
234+
message: jasmine.stringContaining(
235+
`Route '/${path}' cannot be prerendered when 'prerenderFormat' is set to 'file' ` +
236+
`because it would be served for '/'.`,
237+
),
238+
}),
239+
);
240+
});
241+
}
242+
243+
it(`should write a root route written as '/.' to 'index.html' when set to 'file'`, async () => {
244+
await harness.writeFile('src/routes.txt', '.\nfoo\n');
245+
246+
harness.useTarget('build', {
247+
...BASE_OPTIONS,
248+
server: 'src/main.server.ts',
249+
polyfills: ['zone.js'],
250+
prerender: {
251+
discoverRoutes: false,
252+
routesFile: 'src/routes.txt',
253+
},
254+
prerenderFormat: PrerenderFormat.File,
255+
});
256+
257+
const { result } = await harness.executeOnce();
258+
expect(result?.success).toBeTrue();
259+
260+
harness.expectFile('dist/browser/index.html').content.toContain('home works!');
261+
harness.expectFile('dist/browser/foo.html').content.toContain('foo works!');
262+
});
263+
264+
for (const [description, serverOptions] of [
265+
["'outputMode' is set to 'server'", { outputMode: OutputMode.Server }],
266+
["server-side rendering is used without 'outputMode'", { prerender: true }],
267+
] as const) {
268+
it(`should error when set to 'file' and ${description}`, async () => {
269+
harness.useTarget('build', {
270+
...BASE_OPTIONS,
271+
server: 'src/main.server.ts',
272+
polyfills: ['zone.js'],
273+
ssr: { entry: 'src/server.ts' },
274+
...serverOptions,
275+
prerenderFormat: PrerenderFormat.File,
276+
});
277+
278+
const { error } = await harness.executeOnce({ outputLogsOnException: false });
279+
expect(error?.message).toContain('"prerenderFormat" option set to "file"');
280+
});
281+
}
282+
});
283+
});
284+
285+
const EMPTY_TRANSLATION_FILE_CONTENT = `<?xml version="1.0" encoding="UTF-8" ?>
286+
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
287+
<file target-language="fr" datatype="plaintext" original="ng2.template">
288+
<body>
289+
</body>
290+
</file>
291+
</xliff>
292+
`;

0 commit comments

Comments
 (0)