@angular-schule/prerender-format 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Angular.Schule (by Johannes Hoppe)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # @angular-schule/prerender-format
2
+
3
+ [![NPM version][npm-image]][npm-url]
4
+ [![GitHub Actions](https://github.com/angular-schule/prerender-format/actions/workflows/main.yml/badge.svg)](https://github.com/angular-schule/prerender-format/actions/workflows/main.yml)
5
+ [![The MIT License](https://img.shields.io/badge/license-MIT-orange.svg?color=blue&style=flat-square)](http://opensource.org/licenses/MIT)
6
+
7
+ **Nice looking URLs and flawless SEO for your prerendered Angular app: `about.html` instead of `about/index.html`, no more trailing slash redirects! 🚀**
8
+
9
+ **Table of contents:**
10
+
11
+ 1. [💡 Why?](#why)
12
+ 2. [⚠️ Prerequisites](#prerequisites)
13
+ 3. [🚀 Quick Start](#quickstart)
14
+ 4. [⚙️ Installation](#installation)
15
+ 5. [📦 Options](#options)
16
+ 6. [🔭 Other frameworks](#other-frameworks)
17
+ 7. [🌍 Hosts](#hosts)
18
+ 8. [🔧 How it works](#how-it-works)
19
+ 9. [📁 Known limitations](#limitations)
20
+ 10. [🏁 License](#license)
21
+
22
+ <hr>
23
+
24
+ ## 💡 Why? <a name="why"></a>
25
+
26
+ ### Today: nice URLs or good SEO, you can't have both
27
+
28
+ Angular's prerendering (SSG) writes every route into its own folder: the route `blog/my-article` becomes `blog/my-article/index.html`.
29
+ Static hosts see a folder and redirect `/blog/my-article` to `/blog/my-article/`.
30
+ So you have to choose:
31
+
32
+ - **Nice looking URLs, but redirects:** links, canonical tags and sitemap have to use `/blog/my-article`. Every direct visit (search engine, bookmark, shared link) starts with a 301/308 redirect to `/blog/my-article/`, and the Angular router then removes the trailing slash again.
33
+ - **No redirects, but trailing slashes everywhere:** links, canonical tags and sitemap have to use `/blog/my-article/`. Pages answer with 200, but every URL ends with a slash, and Angular needs an extra provider to keep it in the address bar: `{ provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy }` (see [`TrailingSlashPathLocationStrategy`](https://angular.dev/api/common/TrailingSlashPathLocationStrategy)).
34
+
35
+ ### With this builder: nice URLs and good SEO, we deserve both!
36
+
37
+ The same route becomes `blog/my-article.html`, and hosts like Cloudflare Pages serve it under `/blog/my-article` directly, with status 200.
38
+
39
+ - **Nice looking URLs:** `/blog/my-article`, without a trailing slash, in links, in the address bar and in the server response alike.
40
+ - **Flawless SEO:** every page answers directly with 200. Search engines see no redirect, and the URL they crawl is the same one your canonical tag, hreflang links and sitemap point to.
41
+ - **Old links keep working:** addresses with a trailing slash redirect to the address without it (measured on Cloudflare Pages, see [Hosts](#hosts)).
42
+
43
+ This is the option proposed in [angular/angular-cli#29173](https://github.com/angular/angular-cli/issues/29173).
44
+ The builder is a stopgap: developed and tested for Angular 22, until Angular has a built-in option and this package is no longer needed.
45
+
46
+ ## ⚠️ Prerequisites <a name="prerequisites"></a>
47
+
48
+ - Angular 22 with the application builder (`@angular/build:application`)
49
+ - `"outputMode": "static"`
50
+ - A host that serves `foo.html` under `/foo` without a redirect (see [Hosts](#hosts))
51
+
52
+ ## 🚀 Quick Start <a name="quickstart"></a>
53
+
54
+ ```sh
55
+ ng add @angular-schule/prerender-format
56
+ ng build
57
+ ```
58
+
59
+ ## ⚙️ Installation <a name="installation"></a>
60
+
61
+ `ng add @angular-schule/prerender-format` installs the package and changes the builder of your build target in `angular.json` and sets `prerenderFormat`.
62
+ Your build must already use `"outputMode": "static"`: if the build target (or one of its configurations) ships an SSR server, `ng add` stops and tells you so.
63
+
64
+ ```json
65
+ "build": {
66
+ "builder": "@angular-schule/prerender-format:application",
67
+ "options": {
68
+ "outputMode": "static",
69
+ "prerenderFormat": "file"
70
+ }
71
+ }
72
+ ```
73
+
74
+ All other options stay as they are: the builder accepts every option of `@angular/build:application` and passes it on.
75
+ Use `--project` to choose the project in a workspace with several projects.
76
+
77
+ ## 📦 Options <a name="options"></a>
78
+
79
+ #### prerenderFormat
80
+
81
+ - **optional**
82
+ - Default: `directory`
83
+
84
+ | `prerenderFormat` | Route `blog/my-article` | Start page |
85
+ |---|---|---|
86
+ | `directory` | `blog/my-article/index.html` | `index.html` |
87
+ | `file` | `blog/my-article.html` | `index.html` |
88
+
89
+ The name and the values follow Astro's [`build.format`](#other-frameworks).
90
+
91
+ Parent and child routes live side by side: `blog.html` next to the folder `blog/`.
92
+ The start page of each locale (for example with base href `/en/`) stays `index.html`.
93
+
94
+ A route whose last segment is `index` (`/index`, `/docs/index`) fails the build with a clear message: as `index.html` it would be served under the parent path, and `/index` would overwrite the start page.
95
+
96
+ ## 🔭 Other frameworks <a name="other-frameworks"></a>
97
+
98
+ Static site generators have offered this choice for a long time. We decided to borrow the terminology from Astro: `build.format` with `'directory'` and `'file'` became `prerenderFormat` with the same values.
99
+
100
+ | Framework | Option | `about/index.html` | `about.html` |
101
+ |---|---|---|---|
102
+ | **Astro** | `build.format` | `'directory'` (default) | `'file'` |
103
+ | Next.js (static export) | `trailingSlash` | `true` | `false` (default) |
104
+ | SvelteKit | `trailingSlash` | `'always'` | `'never'` (default) |
105
+ | Nuxt 2 | `generate.subFolders` | `true` (default) | `false` |
106
+ | Hugo | `uglyURLs` | `false` (default) | `true` |
107
+ | **Angular** | **NEW: `prerenderFormat`** | **`'directory'` (default)** | **`'file'`** |
108
+
109
+ Astro's documentation recommends `build.format: 'file'` together with `trailingSlash: 'never'`, which is exactly the combination this builder enables for Angular.
110
+
111
+ ## 🌍 Hosts <a name="hosts"></a>
112
+
113
+ Measured on **Cloudflare Pages** with `prerenderFormat: "file"`:
114
+
115
+ | Request | Response |
116
+ |---|---|
117
+ | `/foo` | 200, `foo.html` |
118
+ | `/foo/` | 308 → `/foo` |
119
+ | `/foo.html` | 308 → `/foo` |
120
+ | `/foo` with `foo.html` next to the folder `foo/` | 200, `foo.html` |
121
+
122
+ Old addresses with a trailing slash keep working, they redirect to the address without it.
123
+
124
+ GitHub Pages, Firebase Hosting, Vercel and Netlify are listed in the Angular issue as supporting this, some of them behind a setting.
125
+ Check your host before switching.
126
+
127
+ ## 🔧 How it works <a name="how-it-works"></a>
128
+
129
+ The builder calls `buildApplication` from `@angular/build` and wraps its internal `prerenderPages()` function, which returns the prerendered pages as a record of output paths.
130
+ The wrapper renames `foo/index.html` to `foo.html` before anything is written, so the service worker manifest and all later build steps see the final file names.
131
+
132
+ `prerenderPages()` is internal API, so this package supports Angular 22 only.
133
+ After a successful build, the builder checks that the prerendered pages actually went through the wrapper, and fails otherwise.
134
+
135
+ ## 📁 Known limitations <a name="limitations"></a>
136
+
137
+ - **Static builds only, by design.** `prerenderFormat: "file"` solves a problem of static hosting and makes no sense in other setups. An `ssr` entry is fine as long as `"outputMode"` is `"static"`: Angular then uses it only during `ng build` to prerender the pages, and no server is deployed. If a server is deployed, the build fails: a server needs no `.html` files, it answers `/foo` directly without redirecting to `/foo/`, and the Angular SSR server looks up prerendered pages as `index.html`.
138
+
139
+ ✅ Works: static output, the `ssr` entry only renders at build time
140
+
141
+ ```json
142
+ "options": {
143
+ "outputMode": "static",
144
+ "server": "src/main.server.ts",
145
+ "ssr": { "entry": "src/server.ts" },
146
+ "prerenderFormat": "file"
147
+ }
148
+ ```
149
+
150
+ ✅ Works: prerendering without SSR
151
+
152
+ ```json
153
+ "options": {
154
+ "server": "src/main.server.ts",
155
+ "prerender": true,
156
+ "prerenderFormat": "file"
157
+ }
158
+ ```
159
+
160
+ ❌ Fails: a server is deployed
161
+
162
+ ```json
163
+ "options": {
164
+ "outputMode": "server",
165
+ "server": "src/main.server.ts",
166
+ "ssr": { "entry": "src/server.ts" },
167
+ "prerenderFormat": "file"
168
+ }
169
+ ```
170
+
171
+ ❌ Fails: SSR without `outputMode` also deploys a server
172
+
173
+ ```json
174
+ "options": {
175
+ "server": "src/main.server.ts",
176
+ "ssr": { "entry": "src/server.ts" },
177
+ "prerenderFormat": "file"
178
+ }
179
+ ```
180
+
181
+ - **`ng test` warning.** The `@angular/build:unit-test` builder logs a warning when its `buildTarget` uses a builder other than `@angular/build:application`. Tests run normally.
182
+
183
+ ## 🏁 License <a name="license"></a>
184
+
185
+ Code released under the [MIT license](LICENSE).
186
+
187
+ [npm-url]: https://www.npmjs.com/package/@angular-schule/prerender-format
188
+ [npm-image]: https://badge.fury.io/js/@angular-schule%2Fprerender-format.svg
@@ -0,0 +1,9 @@
1
+ import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
2
+ import { ApplicationBuilderOptions } from '@angular/build';
3
+ export type PrerenderFormat = 'directory' | 'file';
4
+ export interface Schema extends ApplicationBuilderOptions {
5
+ prerenderFormat?: PrerenderFormat;
6
+ }
7
+ export declare function executeBuild(options: Schema, context: BuilderContext): AsyncIterable<BuilderOutput>;
8
+ declare const _default: import("@angular-devkit/architect").Builder<Schema & import("@angular-devkit/core").JsonObject>;
9
+ export default _default;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.executeBuild = executeBuild;
37
+ const architect_1 = require("@angular-devkit/architect");
38
+ const build_1 = require("@angular/build");
39
+ const path = __importStar(require("path"));
40
+ const file_format_1 = require("./file-format");
41
+ const ships_server_1 = require("./ships-server");
42
+ async function* executeBuild(options, context) {
43
+ const { prerenderFormat = 'directory', ...applicationOptions } = options;
44
+ if (prerenderFormat !== 'file') {
45
+ yield* (0, build_1.buildApplication)(applicationOptions, context);
46
+ return;
47
+ }
48
+ if ((0, ships_server_1.shipsServer)(applicationOptions)) {
49
+ context.logger.error(`❌ 'prerenderFormat: "file"' requires 'outputMode: "static"': ` +
50
+ `the Angular SSR server looks up prerendered pages as 'index.html'.`);
51
+ yield { success: false };
52
+ return;
53
+ }
54
+ try {
55
+ (0, file_format_1.installFileFormat)(path.dirname(require.resolve('@angular/build/package.json')));
56
+ }
57
+ catch (e) {
58
+ context.logger.error('❌ ' + (e instanceof Error ? e.message : String(e)));
59
+ yield { success: false };
60
+ return;
61
+ }
62
+ let callsBefore = (0, file_format_1.getPrerenderCalls)();
63
+ for await (const result of (0, build_1.buildApplication)(applicationOptions, context)) {
64
+ const calls = (0, file_format_1.getPrerenderCalls)();
65
+ if (result.success && calls === callsBefore) {
66
+ context.logger.error(`❌ 'prerenderFormat: "file"' had no effect: no pages were prerendered through @angular-schule/prerender-format. ` +
67
+ `Check that prerendering is enabled ('outputMode: "static"' with server routes, or 'prerender'). ` +
68
+ `If it is, this version of @angular/build is not supported.`);
69
+ yield { ...result, success: false };
70
+ }
71
+ else {
72
+ yield result;
73
+ }
74
+ callsBefore = calls;
75
+ }
76
+ }
77
+ exports.default = (0, architect_1.createBuilder)(executeBuild);
78
+ //# sourceMappingURL=builder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder.js","sourceRoot":"","sources":["../../application/builder.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuBA,oCAgDC;AAvED,yDAAyF;AACzF,0CAA6E;AAC7E,2CAA6B;AAE7B,+CAAqE;AACrE,iDAA6C;AAkBtC,KAAK,SAAS,CAAC,CAAC,YAAY,CACjC,OAAe,EACf,OAAuB;IAEvB,MAAM,EAAE,eAAe,GAAG,WAAW,EAAE,GAAG,kBAAkB,EAAE,GAAG,OAAO,CAAC;IAEzE,IAAI,eAAe,KAAK,MAAM,EAAE,CAAC;QAC/B,KAAK,CAAC,CAAC,IAAA,wBAAgB,EAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;QAErD,OAAO;IACT,CAAC;IAED,IAAI,IAAA,0BAAW,EAAC,kBAAkB,CAAC,EAAE,CAAC;QACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,+DAA+D;YAC7D,oEAAoE,CACvE,CAAC;QACF,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QAEzB,OAAO;IACT,CAAC;IAED,IAAI,CAAC;QACH,IAAA,+BAAiB,EAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,6BAA6B,CAAC,CAAC,CAAC,CAAC;IAClF,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1E,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QAEzB,OAAO;IACT,CAAC;IAID,IAAI,WAAW,GAAG,IAAA,+BAAiB,GAAE,CAAC;IACtC,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI,IAAA,wBAAgB,EAAC,kBAAkB,EAAE,OAAO,CAAC,EAAE,CAAC;QACzE,MAAM,KAAK,GAAG,IAAA,+BAAiB,GAAE,CAAC;QAClC,IAAI,MAAM,CAAC,OAAO,IAAI,KAAK,KAAK,WAAW,EAAE,CAAC;YAC5C,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,iHAAiH;gBAC/G,kGAAkG;gBAClG,4DAA4D,CAC/D,CAAC;YACF,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACtC,CAAC;aAAM,CAAC;YACN,MAAM,MAAM,CAAC;QACf,CAAC;QACD,WAAW,GAAG,KAAK,CAAC;IACtB,CAAC;AACH,CAAC;AAED,kBAAe,IAAA,yBAAa,EAAS,YAAY,CAAC,CAAC","sourcesContent":["import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';\nimport { ApplicationBuilderOptions, buildApplication } from '@angular/build';\nimport * as path from 'path';\n\nimport { getPrerenderCalls, installFileFormat } from './file-format';\nimport { shipsServer } from './ships-server';\n\nexport type PrerenderFormat = 'directory' | 'file';\n\nexport interface Schema extends ApplicationBuilderOptions {\n /**\n * File layout of prerendered routes.\n * - `directory` (default): `foo/index.html`\n * - `file`: `foo.html`, the start page stays `index.html`\n */\n prerenderFormat?: PrerenderFormat;\n}\n\n/**\n * Runs `@angular/build:application` and, with `prerenderFormat: \"file\"`,\n * writes prerendered routes as `foo.html` instead of `foo/index.html`.\n * Exported separately for testing purposes.\n */\nexport async function* executeBuild(\n options: Schema,\n context: BuilderContext\n): AsyncIterable<BuilderOutput> {\n const { prerenderFormat = 'directory', ...applicationOptions } = options;\n\n if (prerenderFormat !== 'file') {\n yield* buildApplication(applicationOptions, context);\n\n return;\n }\n\n if (shipsServer(applicationOptions)) {\n context.logger.error(\n `❌ 'prerenderFormat: \"file\"' requires 'outputMode: \"static\"': ` +\n `the Angular SSR server looks up prerendered pages as 'index.html'.`\n );\n yield { success: false };\n\n return;\n }\n\n try {\n installFileFormat(path.dirname(require.resolve('@angular/build/package.json')));\n } catch (e) {\n context.logger.error('❌ ' + (e instanceof Error ? e.message : String(e)));\n yield { success: false };\n\n return;\n }\n\n // Every successful build must have passed its prerendered pages through the wrapper.\n // Otherwise nothing was prerendered, or @angular/build no longer calls the wrapped function.\n let callsBefore = getPrerenderCalls();\n for await (const result of buildApplication(applicationOptions, context)) {\n const calls = getPrerenderCalls();\n if (result.success && calls === callsBefore) {\n context.logger.error(\n `❌ 'prerenderFormat: \"file\"' had no effect: no pages were prerendered through @angular-schule/prerender-format. ` +\n `Check that prerendering is enabled ('outputMode: \"static\"' with server routes, or 'prerender'). ` +\n `If it is, this version of @angular/build is not supported.`\n );\n yield { ...result, success: false };\n } else {\n yield result;\n }\n callsBefore = calls;\n }\n}\n\nexport default createBuilder<Schema>(executeBuild);\n"]}
@@ -0,0 +1,20 @@
1
+ export interface PrerenderedFile {
2
+ content: string;
3
+ appShellRoute: boolean;
4
+ }
5
+ export type PrerenderOutput = Record<string, PrerenderedFile>;
6
+ export interface PrerenderResult {
7
+ output: PrerenderOutput;
8
+ errors?: string[];
9
+ [key: string]: unknown;
10
+ }
11
+ export type PrerenderPages = (...args: unknown[]) => Promise<PrerenderResult>;
12
+ export interface FileOutputResult {
13
+ output: PrerenderOutput;
14
+ errors: string[];
15
+ }
16
+ export declare function getPrerenderCalls(): number;
17
+ export declare function toFilePath(outPath: string): string;
18
+ export declare function toFileOutput(output: PrerenderOutput): FileOutputResult;
19
+ export declare function wrapPrerenderPages(prerenderPages: PrerenderPages): PrerenderPages;
20
+ export declare function installFileFormat(angularBuildRoot: string): void;
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getPrerenderCalls = getPrerenderCalls;
37
+ exports.toFilePath = toFilePath;
38
+ exports.toFileOutput = toFileOutput;
39
+ exports.wrapPrerenderPages = wrapPrerenderPages;
40
+ exports.installFileFormat = installFileFormat;
41
+ const path = __importStar(require("path"));
42
+ const INDEX_FILE = 'index.html';
43
+ const INDEX_SUFFIX = '/' + INDEX_FILE;
44
+ const PATCHED = Symbol.for('@angular-schule/prerender-format:patched');
45
+ const PRERENDER_MODULE = 'src/utils/server-rendering/prerender.js';
46
+ let prerenderCalls = 0;
47
+ function getPrerenderCalls() {
48
+ return prerenderCalls;
49
+ }
50
+ function toFilePath(outPath) {
51
+ if (!outPath.endsWith(INDEX_SUFFIX)) {
52
+ return outPath;
53
+ }
54
+ return outPath.slice(0, -INDEX_SUFFIX.length) + '.html';
55
+ }
56
+ function routeOf(outPath) {
57
+ if (outPath === INDEX_FILE) {
58
+ return '/';
59
+ }
60
+ return '/' + (outPath.endsWith(INDEX_SUFFIX) ? outPath.slice(0, -INDEX_SUFFIX.length) : outPath);
61
+ }
62
+ function toFileOutput(output) {
63
+ const renamed = {};
64
+ const errors = [];
65
+ const routeByFilePath = new Map();
66
+ for (const [outPath, file] of Object.entries(output)) {
67
+ const route = routeOf(outPath);
68
+ const filePath = toFilePath(outPath);
69
+ if (outPath.endsWith('/index' + INDEX_SUFFIX) || outPath === 'index' + INDEX_SUFFIX) {
70
+ errors.push(`Route '${route}' cannot be prerendered with 'prerenderFormat: "file"': ` +
71
+ `its file '${filePath}' would be served as '${route.slice(0, -'index'.length)}', not as '${route}'. ` +
72
+ `Rename the route or use 'prerenderFormat: "directory"'.`);
73
+ continue;
74
+ }
75
+ const existingRoute = routeByFilePath.get(filePath);
76
+ if (existingRoute !== undefined) {
77
+ errors.push(`Routes '${existingRoute}' and '${route}' both map to the file '${filePath}' with 'prerenderFormat: "file"'.`);
78
+ continue;
79
+ }
80
+ routeByFilePath.set(filePath, route);
81
+ renamed[filePath] = file;
82
+ }
83
+ return { output: renamed, errors };
84
+ }
85
+ function wrapPrerenderPages(prerenderPages) {
86
+ const wrapped = async function (...args) {
87
+ const result = await prerenderPages.apply(this, args);
88
+ if (!result ||
89
+ typeof result.output !== 'object' ||
90
+ result.output === null ||
91
+ !Array.isArray(result.errors)) {
92
+ throw new Error('@angular-schule/prerender-format: prerenderPages() returned an unknown result. ' +
93
+ 'This version of @angular/build is not supported.');
94
+ }
95
+ prerenderCalls++;
96
+ const { output, errors } = toFileOutput(result.output);
97
+ return { ...result, output, errors: [...result.errors, ...errors] };
98
+ };
99
+ Object.defineProperty(wrapped, PATCHED, { value: true });
100
+ return wrapped;
101
+ }
102
+ function isPatched(fn) {
103
+ return fn[PATCHED] === true;
104
+ }
105
+ function installFileFormat(angularBuildRoot) {
106
+ const modulePath = path.join(angularBuildRoot, PRERENDER_MODULE);
107
+ let prerenderModule;
108
+ try {
109
+ prerenderModule = require(modulePath);
110
+ }
111
+ catch (error) {
112
+ throw new Error(`@angular-schule/prerender-format: cannot load '${modulePath}'. This version of @angular/build is not supported.`, { cause: error });
113
+ }
114
+ const descriptor = Object.getOwnPropertyDescriptor(prerenderModule, 'prerenderPages');
115
+ const prerenderPages = prerenderModule.prerenderPages;
116
+ if (typeof prerenderPages !== 'function' || !descriptor?.writable) {
117
+ throw new Error(`@angular-schule/prerender-format: '${modulePath}' exports no replaceable prerenderPages(). ` +
118
+ 'This version of @angular/build is not supported.');
119
+ }
120
+ if (!isPatched(prerenderPages)) {
121
+ prerenderModule.prerenderPages = wrapPrerenderPages(prerenderPages);
122
+ }
123
+ }
124
+ //# sourceMappingURL=file-format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-format.js","sourceRoot":"","sources":["../../application/file-format.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,8CAEC;AAMD,gCAMC;AAgBD,oCA+BC;AAMD,gDAuBC;AAWD,8CAwBC;AA5JD,2CAA6B;AAuB7B,MAAM,UAAU,GAAG,YAAY,CAAC;AAChC,MAAM,YAAY,GAAG,GAAG,GAAG,UAAU,CAAC;AACtC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,0CAA0C,CAAC,CAAC;AACvE,MAAM,gBAAgB,GAAG,yCAAyC,CAAC;AAEnE,IAAI,cAAc,GAAG,CAAC,CAAC;AAGvB,SAAgB,iBAAiB;IAC/B,OAAO,cAAc,CAAC;AACxB,CAAC;AAMD,SAAgB,UAAU,CAAC,OAAe;IACxC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;QACpC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC;AAC1D,CAAC;AAGD,SAAS,OAAO,CAAC,OAAe;IAC9B,IAAI,OAAO,KAAK,UAAU,EAAE,CAAC;QAC3B,OAAO,GAAG,CAAC;IACb,CAAC;IAED,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;AACnG,CAAC;AAOD,SAAgB,YAAY,CAAC,MAAuB;IAClD,MAAM,OAAO,GAAoB,EAAE,CAAC;IACpC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,eAAe,GAAG,IAAI,GAAG,EAAkB,CAAC;IAElD,KAAK,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACrD,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QAC/B,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;QAErC,IAAI,OAAO,CAAC,QAAQ,CAAC,QAAQ,GAAG,YAAY,CAAC,IAAI,OAAO,KAAK,OAAO,GAAG,YAAY,EAAE,CAAC;YACpF,MAAM,CAAC,IAAI,CACT,UAAU,KAAK,0DAA0D;gBACvE,aAAa,QAAQ,yBAAyB,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,KAAK,KAAK;gBACrG,yDAAyD,CAC5D,CAAC;YACF,SAAS;QACX,CAAC;QAED,MAAM,aAAa,GAAG,eAAe,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACpD,IAAI,aAAa,KAAK,SAAS,EAAE,CAAC;YAChC,MAAM,CAAC,IAAI,CACT,WAAW,aAAa,UAAU,KAAK,2BAA2B,QAAQ,mCAAmC,CAC9G,CAAC;YACF,SAAS;QACX,CAAC;QAED,eAAe,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QACrC,OAAO,CAAC,QAAQ,CAAC,GAAG,IAAI,CAAC;IAC3B,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;AACrC,CAAC;AAMD,SAAgB,kBAAkB,CAAC,cAA8B;IAC/D,MAAM,OAAO,GAAG,KAAK,WAA0B,GAAG,IAAe;QAC/D,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACtD,IACE,CAAC,MAAM;YACP,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ;YACjC,MAAM,CAAC,MAAM,KAAK,IAAI;YACtB,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,KAAK,CACb,iFAAiF;gBAC/E,kDAAkD,CACrD,CAAC;QACJ,CAAC;QACD,cAAc,EAAE,CAAC;QAEjB,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAEvD,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC;IACtE,CAAC,CAAC;IACF,MAAM,CAAC,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAEzD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,SAAS,SAAS,CAAC,EAAkB;IACnC,OAAQ,EAAyC,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;AACtE,CAAC;AAOD,SAAgB,iBAAiB,CAAC,gBAAwB;IACxD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC;IACjE,IAAI,eAAoD,CAAC;IACzD,IAAI,CAAC;QACH,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACxC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,kDAAkD,UAAU,qDAAqD,EACjH,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAC;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,MAAM,CAAC,wBAAwB,CAAC,eAAe,EAAE,gBAAgB,CAAC,CAAC;IACtF,MAAM,cAAc,GAAG,eAAe,CAAC,cAAc,CAAC;IACtD,IAAI,OAAO,cAAc,KAAK,UAAU,IAAI,CAAC,UAAU,EAAE,QAAQ,EAAE,CAAC;QAClE,MAAM,IAAI,KAAK,CACb,sCAAsC,UAAU,6CAA6C;YAC3F,kDAAkD,CACrD,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,EAAE,CAAC;QAC/B,eAAe,CAAC,cAAc,GAAG,kBAAkB,CAAC,cAAc,CAAC,CAAC;IACtE,CAAC;AACH,CAAC","sourcesContent":["import * as path from 'path';\n\n/** A prerendered page as returned by `prerenderPages()` of `@angular/build`. */\nexport interface PrerenderedFile {\n content: string;\n appShellRoute: boolean;\n}\n\nexport type PrerenderOutput = Record<string, PrerenderedFile>;\n\nexport interface PrerenderResult {\n output: PrerenderOutput;\n errors?: string[];\n [key: string]: unknown;\n}\n\nexport type PrerenderPages = (...args: unknown[]) => Promise<PrerenderResult>;\n\nexport interface FileOutputResult {\n output: PrerenderOutput;\n errors: string[];\n}\n\nconst INDEX_FILE = 'index.html';\nconst INDEX_SUFFIX = '/' + INDEX_FILE;\nconst PATCHED = Symbol.for('@angular-schule/prerender-format:patched');\nconst PRERENDER_MODULE = 'src/utils/server-rendering/prerender.js';\n\nlet prerenderCalls = 0;\n\n/** Number of `prerenderPages()` calls that went through the wrapper in this process. */\nexport function getPrerenderCalls(): number {\n return prerenderCalls;\n}\n\n/**\n * Maps a prerender output path from `foo/index.html` to `foo.html`.\n * The root `index.html` of a build (start page, or locale start page with its base href) stays as is.\n */\nexport function toFilePath(outPath: string): string {\n if (!outPath.endsWith(INDEX_SUFFIX)) {\n return outPath;\n }\n\n return outPath.slice(0, -INDEX_SUFFIX.length) + '.html';\n}\n\n/** Route of an output path, for error messages: `blog/index/index.html` → `/blog/index`. */\nfunction routeOf(outPath: string): string {\n if (outPath === INDEX_FILE) {\n return '/';\n }\n\n return '/' + (outPath.endsWith(INDEX_SUFFIX) ? outPath.slice(0, -INDEX_SUFFIX.length) : outPath);\n}\n\n/**\n * Renames all keys of a prerender `output` record.\n * Routes whose last segment is `index` are reported as errors: as `…/index.html` they would be\n * served under the parent path, and `/index` would overwrite the start page.\n */\nexport function toFileOutput(output: PrerenderOutput): FileOutputResult {\n const renamed: PrerenderOutput = {};\n const errors: string[] = [];\n const routeByFilePath = new Map<string, string>();\n\n for (const [outPath, file] of Object.entries(output)) {\n const route = routeOf(outPath);\n const filePath = toFilePath(outPath);\n\n if (outPath.endsWith('/index' + INDEX_SUFFIX) || outPath === 'index' + INDEX_SUFFIX) {\n errors.push(\n `Route '${route}' cannot be prerendered with 'prerenderFormat: \"file\"': ` +\n `its file '${filePath}' would be served as '${route.slice(0, -'index'.length)}', not as '${route}'. ` +\n `Rename the route or use 'prerenderFormat: \"directory\"'.`\n );\n continue;\n }\n\n const existingRoute = routeByFilePath.get(filePath);\n if (existingRoute !== undefined) {\n errors.push(\n `Routes '${existingRoute}' and '${route}' both map to the file '${filePath}' with 'prerenderFormat: \"file\"'.`\n );\n continue;\n }\n\n routeByFilePath.set(filePath, route);\n renamed[filePath] = file;\n }\n\n return { output: renamed, errors };\n}\n\n/**\n * Wraps `prerenderPages` so that its `output` record uses the \"file\" format.\n * Problems are returned as build errors of `prerenderPages`, so Angular reports them like any other build error.\n */\nexport function wrapPrerenderPages(prerenderPages: PrerenderPages): PrerenderPages {\n const wrapped = async function (this: unknown, ...args: unknown[]) {\n const result = await prerenderPages.apply(this, args);\n if (\n !result ||\n typeof result.output !== 'object' ||\n result.output === null ||\n !Array.isArray(result.errors)\n ) {\n throw new Error(\n '@angular-schule/prerender-format: prerenderPages() returned an unknown result. ' +\n 'This version of @angular/build is not supported.'\n );\n }\n prerenderCalls++;\n\n const { output, errors } = toFileOutput(result.output);\n\n return { ...result, output, errors: [...result.errors, ...errors] };\n };\n Object.defineProperty(wrapped, PATCHED, { value: true });\n\n return wrapped;\n}\n\nfunction isPatched(fn: PrerenderPages): boolean {\n return (fn as unknown as Record<symbol, unknown>)[PATCHED] === true;\n}\n\n/**\n * Replaces `prerenderPages` of the given `@angular/build` installation.\n * `execute-post-bundle.js` reads the function from the module's exports object on every call,\n * so the replacement takes effect for regular and localized builds alike.\n */\nexport function installFileFormat(angularBuildRoot: string): void {\n const modulePath = path.join(angularBuildRoot, PRERENDER_MODULE);\n let prerenderModule: { prerenderPages?: PrerenderPages };\n try {\n prerenderModule = require(modulePath);\n } catch (error) {\n throw new Error(\n `@angular-schule/prerender-format: cannot load '${modulePath}'. This version of @angular/build is not supported.`,\n { cause: error }\n );\n }\n\n const descriptor = Object.getOwnPropertyDescriptor(prerenderModule, 'prerenderPages');\n const prerenderPages = prerenderModule.prerenderPages;\n if (typeof prerenderPages !== 'function' || !descriptor?.writable) {\n throw new Error(\n `@angular-schule/prerender-format: '${modulePath}' exports no replaceable prerenderPages(). ` +\n 'This version of @angular/build is not supported.'\n );\n }\n\n if (!isPatched(prerenderPages)) {\n prerenderModule.prerenderPages = wrapPrerenderPages(prerenderPages);\n }\n}\n"]}