Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion goldens/public-api/angular/build/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function executeUnitTestBuilder(options: UnitTestBuilderOptions, context:
// @public
export type ExtractI18nBuilderOptions = {
buildTarget?: string;
format?: Format;
format?: Format_2;
i18nDuplicateTranslation?: I18NDuplicateTranslation;
outFile?: string;
outputPath?: string;
Expand Down
22 changes: 20 additions & 2 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
OutputMode,
OutputPathClass,
Platform,
Format as PrerenderFormat,
} from './schema';

/**
Expand Down Expand Up @@ -257,14 +258,21 @@ export async function normalizeOptions(
}
}

const prerenderFormat =
(typeof options.prerender === 'object' && options.prerender.format) ||
PrerenderFormat.Directory;

if (options.outputMode) {
if (!options.server) {
options.ssr = false;
}

if (options.prerender !== undefined) {
if (
Comment thread
alan-agius4 marked this conversation as resolved.
options.prerender !== undefined &&
(typeof options.prerender === 'boolean' || !('format' in options.prerender))
) {
context.logger.warn(
'The "prerender" option is not considered when "outputMode" is specified.',
'The "prerender" option has no effect when "outputMode" is specified (only "format" is supported).',
);
}

Expand Down Expand Up @@ -302,6 +310,7 @@ export async function normalizeOptions(
prerenderOptions = {
discoverRoutes,
routesFile: routesFile && path.join(workspaceRoot, routesFile),
format: prerenderFormat,
};
}

Expand Down Expand Up @@ -342,6 +351,15 @@ export async function normalizeOptions(
options.outputMode === OutputMode.Static,
};

if (prerenderOptions?.format === PrerenderFormat.File && !outputOptions.ignoreServer) {
// The server runtime of '@angular/ssr' looks up prerendered pages as '<route>/index.html'.
context.logger.warn(
'The "prerender.format" option is not considered when the build produces a server.',
);

prerenderOptions.format = PrerenderFormat.Directory;
}

const outputNames = {
bundles:
options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles
Expand Down
12 changes: 12 additions & 0 deletions packages/angular/build/src/builders/application/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,18 @@
}
},
"additionalProperties": false
},
{
"type": "object",
"properties": {
"format": {
Comment thread
alan-agius4 marked this conversation as resolved.
"type": "string",
"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.",
"enum": ["directory", "file"]
}
},
"additionalProperties": false,
"required": ["format"]
}
]
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
Comment thread
JohannesHoppe marked this conversation as resolved.
* @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 { Format, OutputMode } from '../../schema';
import {
APPLICATION_BUILDER_INFO,
BASE_OPTIONS,
describeBuilder,
expectLog,
expectNoLog,
} from '../setup';

const routeFiles: Record<string, string> = {
'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: '<p>home works!</p>' })
export class HomeComponent {}

@Component({ selector: 'app-foo', template: '<p>foo works!</p>' })
export class FooComponent {}

@Component({ selector: 'app-bar', template: '<p>foo-bar works!</p>' })
export class BarComponent {}

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': `<router-outlet></router-outlet>`,
'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);
});

describe('Option: "prerender.format"', () => {
it(`should write routes to '<route>.html' when set to 'file'`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
polyfills: ['zone.js'],
outputMode: OutputMode.Static,
prerender: { format: Format.File },
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBeTrue();
expectNoLog(logs, 'The "prerender" option has no effect');

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('<meta http-equiv="refresh" content="0; url=/foo">');
harness.expectFile('dist/browser/foo/index.html').toNotExist();

const content = harness.readFile('dist/prerendered-routes.json');
expect(Object.keys(JSON.parse(content).routes)).toEqual(
jasmine.arrayContaining(['/', '/foo', '/foo/bar']),
);
});

it(`should warn when 'prerender' is an object without 'format' and 'outputMode' is specified`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
polyfills: ['zone.js'],
outputMode: OutputMode.Static,
prerender: { routesFile: 'routes.txt' },
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBeTrue();

expectLog(logs, 'The "prerender" option has no effect when "outputMode" is specified');
});

it(`should write routes to '<route>/index.html' by default`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
polyfills: ['zone.js'],
outputMode: OutputMode.Static,
});

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 warn and write '<route>/index.html' when set to 'file' and the build produces a server`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
polyfills: ['zone.js'],
outputMode: OutputMode.Server,
ssr: { entry: 'src/server.ts' },
prerender: { format: Format.File },
});

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBeTrue();

expectLog(
logs,
'The "prerender.format" option 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();
});
});
});
22 changes: 18 additions & 4 deletions packages/angular/build/src/utils/server-rendering/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Format as PrerenderFormat } from '../../builders/application/schema';
import {
BuildOutputAsset,
PrerenderedRoutesRecord,
Expand Down Expand Up @@ -200,6 +200,7 @@ export async function prerenderPages(
outputFilesForWorker,
assetsReversed,
outputMode,
prerenderOptions?.format,
appShellRoute ?? appShellOptions?.route,
);

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

for (const metadata of serializableRouteTreeNodeForPrerender) {
const outPath = getRouteOutPath(metadata.route, baseHrefPathnameWithLeadingSlash);
const outPath = getRouteOutPath(
metadata.route,
baseHrefPathnameWithLeadingSlash,
prerenderOptions?.format,
);

if (output[outPath]) {
prerenderedRoutes[metadata.route] = { headers: metadata.headers };
Expand All @@ -234,6 +239,7 @@ async function renderPages(
outputFilesForWorker: Record<string, Uint8Array>,
assetFilesForWorker: Record<string, string>,
outputMode: OutputMode | undefined,
format: PrerenderFormat | undefined,
appShellRoute: string | undefined,
): Promise<{
output: PrerenderOutput;
Expand All @@ -252,7 +258,7 @@ 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);
const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash, format);

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

function getRouteOutPath(route: string, baseHrefPathname: string): string {
function getRouteOutPath(
route: string,
baseHrefPathname: string,
format: PrerenderFormat | undefined,
): string {
const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname);

if (format === PrerenderFormat.File && routeWithoutBaseHref !== '/') {
return stripLeadingSlash(`${routeWithoutBaseHref}.html`);
}

return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html'));
}
Loading