diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index b08d70c07e64..ca0a0b34f1ff 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -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; diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index f7e60f4cc029..eb16135a23ed 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, + Format as PrerenderFormat, } from './schema'; /** @@ -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 ( + 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).', ); } @@ -302,6 +310,7 @@ export async function normalizeOptions( prerenderOptions = { discoverRoutes, routesFile: routesFile && path.join(workspaceRoot, routesFile), + format: prerenderFormat, }; } @@ -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 '/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 diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 8458d385a3fc..18b52a0e04f1 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -584,6 +584,18 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "properties": { + "format": { + "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"] } ] }, 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..eed289619136 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/prerender-format_spec.ts @@ -0,0 +1,153 @@ +/** + * @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 = { + '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 {} + + 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); + }); + + describe('Option: "prerender.format"', () => { + it(`should write routes to '.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(''); + 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 '/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 '/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(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 98502c63587d..ec687be0e011 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, Format as PrerenderFormat } from '../../builders/application/schema'; import { BuildOutputAsset, PrerenderedRoutesRecord, @@ -200,6 +200,7 @@ export async function prerenderPages( outputFilesForWorker, assetsReversed, outputMode, + prerenderOptions?.format, appShellRoute ?? appShellOptions?.route, ); @@ -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 }; @@ -234,6 +239,7 @@ async function renderPages( outputFilesForWorker: Record, assetFilesForWorker: Record, outputMode: OutputMode | undefined, + format: PrerenderFormat | undefined, appShellRoute: string | undefined, ): Promise<{ output: PrerenderOutput; @@ -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 }; @@ -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')); }