@mandujs/core 0.32.0 → 0.33.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.
@@ -1,368 +1,446 @@
1
- /**
2
- * Mandu Prerender Engine
3
- *
4
- * Build-time static HTML generation (SSG) driven by two signals:
5
- *
6
- * 1. Static page routes (no dynamic segments) in the routes manifest.
7
- * 2. Dynamic page routes whose module exports `generateStaticParams`
8
- * — see `./generate-static-params.ts` for the contract.
9
- *
10
- * For each resolved URL the engine invokes the build's fetch handler
11
- * (a transient server spun up by `mandu build`) and writes the HTML
12
- * payload under `.mandu/prerendered/` when callers opt into the new
13
- * runtime-aware layout, or `.mandu/static/` for legacy callers.
14
- *
15
- * When `writeIndex: true` the engine also emits `_manifest.json`
16
- * alongside the HTML — the runtime consults that index to serve
17
- * prerendered pages directly with `Cache-Control: immutable`, skipping
18
- * SSR entirely.
19
- */
20
-
21
- import path from "path";
22
- import fs from "fs/promises";
23
- import type { RoutesManifest, RouteSpec } from "../spec/schema";
24
- import {
25
- collectStaticPaths,
26
- isDynamicPattern,
27
- type PageModuleWithStaticParams,
28
- } from "./generate-static-params";
29
-
30
- // ========== Types ==========
31
-
32
- export interface PrerenderOptions {
33
- /** Project root — all relative paths resolve from here. */
34
- rootDir: string;
35
- /**
36
- * Output directory (absolute, or relative to `rootDir`).
37
- * Defaults to `.mandu/static` to preserve behavior for older
38
- * callers; `mandu build` opts into `.mandu/prerendered` +
39
- * `writeIndex: true` to enable runtime pass-through.
40
- */
41
- outDir?: string;
42
- /** Extra URL paths to prerender in addition to the manifest. */
43
- routes?: string[];
44
- /** Follow internal `<a href>` links in rendered HTML (default: false). */
45
- crawl?: boolean;
46
- /**
47
- * When true, also write `<outDir>/_manifest.json` listing every
48
- * prerendered pathname. The runtime uses this index to short-circuit
49
- * dispatch for matching URLs.
50
- */
51
- writeIndex?: boolean;
52
- /**
53
- * Optional injected `import` function. Tests pass a stub so we can
54
- * exercise `generateStaticParams` without touching disk; production
55
- * callers leave this undefined (the default dynamic import is used).
56
- */
57
- importModule?: (specifier: string) => Promise<PageModuleWithStaticParams>;
58
- }
59
-
60
- export interface PrerenderResult {
61
- /** Number of pages rendered successfully. */
62
- generated: number;
63
- /** Per-page telemetry. */
64
- pages: PrerenderPageResult[];
65
- /** Errors encountered during the run (non-fatal). */
66
- errors: string[];
67
- /** Pathnames that were rendered. */
68
- paths: string[];
69
- }
70
-
71
- export interface PrerenderPageResult {
72
- path: string;
73
- size: number;
74
- duration: number;
75
- }
76
-
77
- /** Shape of the index file written to `<outDir>/_manifest.json`. */
78
- export interface PrerenderIndex {
79
- version: 1;
80
- generatedAt: string;
81
- /** Pathname → relative HTML file path (posix separators). */
82
- pages: Record<string, string>;
83
- }
84
-
85
- /** File name used for the runtime index. */
86
- export const PRERENDER_INDEX_FILE = "_manifest.json";
87
-
88
- /** Default output directory (runtime-aware location). */
89
- export const DEFAULT_PRERENDER_DIR = ".mandu/prerendered";
90
-
91
- /** Default output directory (legacy `prerenderRoutes` callers). */
92
- export const LEGACY_PRERENDER_DIR = ".mandu/static";
93
-
94
- /** Default cache policy stamped on runtime prerender responses. */
95
- export const DEFAULT_PRERENDER_CACHE_CONTROL =
96
- "public, max-age=31536000, immutable";
97
-
98
- // ========== Implementation ==========
99
-
100
- /**
101
- * Prerender the routes declared in a manifest (plus any extras) to
102
- * static HTML. See `PrerenderOptions` for the full contract.
103
- *
104
- * @example
105
- * ```typescript
106
- * const result = await prerenderRoutes(manifest, fetchHandler, {
107
- * rootDir: process.cwd(),
108
- * outDir: ".mandu/prerendered",
109
- * writeIndex: true,
110
- * });
111
- * ```
112
- */
113
- export async function prerenderRoutes(
114
- manifest: RoutesManifest,
115
- fetchHandler: (req: Request) => Promise<Response>,
116
- options: PrerenderOptions
117
- ): Promise<PrerenderResult> {
118
- const {
119
- rootDir,
120
- outDir = LEGACY_PRERENDER_DIR,
121
- crawl = false,
122
- writeIndex = false,
123
- importModule,
124
- } = options;
125
-
126
- const outputDir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
127
- await fs.mkdir(outputDir, { recursive: true });
128
-
129
- const pages: PrerenderPageResult[] = [];
130
- const errors: string[] = [];
131
- const renderedPaths = new Set<string>();
132
- const pageIndex: Record<string, string> = {};
133
-
134
- // 1. Explicit user-supplied routes.
135
- const pathsToRender = new Set<string>(options.routes ?? []);
136
-
137
- // 2. Static page routes (no dynamic segments).
138
- for (const route of manifest.routes) {
139
- if (route.kind === "page" && !isDynamicPattern(route.pattern)) {
140
- pathsToRender.add(route.pattern);
141
- }
142
- }
143
-
144
- // 3. Dynamic routes that export `generateStaticParams`.
145
- const resolveModule =
146
- importModule ?? ((specifier: string) => import(specifier));
147
-
148
- for (const route of manifest.routes) {
149
- if (route.kind !== "page" || !isDynamicPattern(route.pattern)) continue;
150
-
151
- let mod: PageModuleWithStaticParams;
152
- try {
153
- mod = await loadPageModule(rootDir, route, resolveModule);
154
- } catch {
155
- // Module failed to load entirely. Silent skip — the page may
156
- // simply not opt into static params; SSR can still serve it.
157
- continue;
158
- }
159
-
160
- if (typeof mod.generateStaticParams !== "function") {
161
- // Not opted-in for this route — perfectly fine.
162
- continue;
163
- }
164
-
165
- try {
166
- const { paths, errors: paramErrors } = await collectStaticPaths(
167
- route.pattern,
168
- mod
169
- );
170
- for (const p of paths) pathsToRender.add(p);
171
- for (const e of paramErrors) errors.push(`[${route.pattern}] ${e}`);
172
- } catch (error) {
173
- // User code threw. Surface the error but keep going — other
174
- // routes should not be blocked by one buggy generator.
175
- errors.push(
176
- `[${route.pattern}] generateStaticParams threw: ${describeError(error)}`
177
- );
178
- }
179
- }
180
-
181
- // 4. Render every queued path.
182
- for (const pathname of pathsToRender) {
183
- if (renderedPaths.has(pathname)) continue;
184
- renderedPaths.add(pathname);
185
-
186
- const start = Date.now();
187
- try {
188
- const request = new Request(`http://localhost${pathname}`);
189
- const response = await fetchHandler(request);
190
-
191
- if (!response.ok) {
192
- errors.push(`[${pathname}] HTTP ${response.status}`);
193
- continue;
194
- }
195
-
196
- const html = await response.text();
197
- const filePath = getOutputPath(outputDir, pathname);
198
-
199
- await fs.mkdir(path.dirname(filePath), { recursive: true });
200
- await fs.writeFile(filePath, html, "utf-8");
201
-
202
- const duration = Date.now() - start;
203
- pages.push({ path: pathname, size: html.length, duration });
204
- pageIndex[pathname] = toPosix(path.relative(outputDir, filePath));
205
-
206
- // 5. Optional crawl — harvest internal links for next pass.
207
- if (crawl) {
208
- const links = extractInternalLinks(html);
209
- for (const link of links) {
210
- if (!renderedPaths.has(link) && !pathsToRender.has(link)) {
211
- pathsToRender.add(link);
212
- }
213
- }
214
- }
215
- } catch (error) {
216
- errors.push(`[${pathname}] ${describeError(error)}`);
217
- }
218
- }
219
-
220
- // 6. Emit runtime index.
221
- if (writeIndex) {
222
- const indexContents: PrerenderIndex = {
223
- version: 1,
224
- generatedAt: new Date().toISOString(),
225
- pages: pageIndex,
226
- };
227
- await fs.writeFile(
228
- path.join(outputDir, PRERENDER_INDEX_FILE),
229
- JSON.stringify(indexContents, null, 2),
230
- "utf-8"
231
- );
232
- }
233
-
234
- return {
235
- generated: pages.length,
236
- pages,
237
- errors,
238
- paths: pages.map((p) => p.path),
239
- };
240
- }
241
-
242
- /**
243
- * Load the prerender manifest index emitted under `outDir`. Returns
244
- * `null` if it doesn't exist or can't be parsed — callers should
245
- * treat that as "no prerendered content" rather than an error.
246
- */
247
- export async function loadPrerenderIndex(
248
- rootDir: string,
249
- outDir: string = DEFAULT_PRERENDER_DIR
250
- ): Promise<PrerenderIndex | null> {
251
- const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
252
- const file = path.join(dir, PRERENDER_INDEX_FILE);
253
- try {
254
- const contents = await fs.readFile(file, "utf-8");
255
- const parsed = JSON.parse(contents) as PrerenderIndex;
256
- if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !parsed.pages) {
257
- return null;
258
- }
259
- return parsed;
260
- } catch {
261
- return null;
262
- }
263
- }
264
-
265
- /**
266
- * Resolve a pathname against a loaded index. Returns the absolute
267
- * file path of the prerendered HTML, or `null` on miss.
268
- *
269
- * Tolerates both `/foo` and `/foo/` forms, and an optional `.html`
270
- * suffix. Path-traversal in the index value is defensively rejected
271
- * so a hand-edited / malicious index cannot escape the output root.
272
- */
273
- export function resolvePrerenderedFile(
274
- index: PrerenderIndex,
275
- rootDir: string,
276
- outDir: string,
277
- pathname: string
278
- ): string | null {
279
- const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
280
- const candidates = [pathname];
281
- if (pathname.length > 1 && pathname.endsWith("/")) {
282
- candidates.push(pathname.slice(0, -1));
283
- } else if (pathname !== "/") {
284
- candidates.push(pathname + "/");
285
- }
286
- if (pathname.endsWith(".html")) {
287
- candidates.push(pathname.slice(0, -".html".length));
288
- }
289
- for (const candidate of candidates) {
290
- const rel = index.pages[candidate];
291
- if (rel) {
292
- const resolved = path.resolve(dir, rel);
293
- const normalizedDir = path.resolve(dir) + path.sep;
294
- if (resolved === path.resolve(dir) || resolved.startsWith(normalizedDir)) {
295
- return resolved;
296
- }
297
- }
298
- }
299
- return null;
300
- }
301
-
302
- // ========== Helpers ==========
303
-
304
- /**
305
- * Dynamic-import a page module given its declared `module` path in
306
- * the manifest. Normalizes the path for Windows dynamic-import
307
- * (forward slashes + absolute) before delegating.
308
- */
309
- async function loadPageModule(
310
- rootDir: string,
311
- route: RouteSpec,
312
- importFn: (specifier: string) => Promise<PageModuleWithStaticParams>
313
- ): Promise<PageModuleWithStaticParams> {
314
- const absolute = path.isAbsolute(route.module)
315
- ? route.module
316
- : path.join(rootDir, route.module);
317
- const specifier = absolute.replace(/\\/g, "/");
318
- return importFn(specifier);
319
- }
320
-
321
- /**
322
- * URL path output file path.
323
- * / → <outDir>/index.html
324
- * /about → <outDir>/about/index.html (clean URL)
325
- * /blog/a/b → <outDir>/blog/a/b/index.html
326
- */
327
- function getOutputPath(outDir: string, pathname: string): string {
328
- const trimmed = pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
329
- if (trimmed === "/") return path.join(outDir, "index.html");
330
- // Decode percent-encoding so on-disk names are stable across platforms.
331
- const decoded = trimmed
332
- .split("/")
333
- .map((segment) => {
334
- try {
335
- return decodeURIComponent(segment);
336
- } catch {
337
- return segment;
338
- }
339
- })
340
- .join("/");
341
- return path.join(outDir, decoded, "index.html");
342
- }
343
-
344
- /** Extract absolute internal `<a href>` paths (same-origin only). */
345
- function extractInternalLinks(html: string): string[] {
346
- const links: string[] = [];
347
- const hrefRegex = /href=["']([^"']+)["']/g;
348
- let match: RegExpExecArray | null;
349
- while ((match = hrefRegex.exec(html)) !== null) {
350
- const href = match[1];
351
- if (href.startsWith("/") && !href.startsWith("//")) {
352
- const cleanPath = href.split("?")[0].split("#")[0];
353
- if (!cleanPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) {
354
- links.push(cleanPath);
355
- }
356
- }
357
- }
358
- return [...new Set(links)];
359
- }
360
-
361
- function toPosix(p: string): string {
362
- return p.replace(/\\/g, "/");
363
- }
364
-
365
- function describeError(error: unknown): string {
366
- if (error instanceof Error) return error.message;
367
- return String(error);
368
- }
1
+ /**
2
+ * Mandu Prerender Engine
3
+ *
4
+ * Build-time static HTML generation (SSG) driven by two signals:
5
+ *
6
+ * 1. Static page routes (no dynamic segments) in the routes manifest.
7
+ * 2. Dynamic page routes whose module exports `generateStaticParams`
8
+ * — see `./generate-static-params.ts` for the contract.
9
+ *
10
+ * For each resolved URL the engine invokes the build's fetch handler
11
+ * (a transient server spun up by `mandu build`) and writes the HTML
12
+ * payload under `.mandu/prerendered/` when callers opt into the new
13
+ * runtime-aware layout, or `.mandu/static/` for legacy callers.
14
+ *
15
+ * When `writeIndex: true` the engine also emits `_manifest.json`
16
+ * alongside the HTML — the runtime consults that index to serve
17
+ * prerendered pages directly with `Cache-Control: immutable`, skipping
18
+ * SSR entirely.
19
+ */
20
+
21
+ import path from "path";
22
+ import fs from "fs/promises";
23
+ import type { RoutesManifest, RouteSpec } from "../spec/schema";
24
+ import {
25
+ collectStaticPaths,
26
+ isDynamicPattern,
27
+ type PageModuleWithStaticParams,
28
+ type StaticParamSet,
29
+ } from "./generate-static-params";
30
+ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
31
+ import { runDefinePrerenderHook } from "../plugins/runner";
32
+
33
+ // ========== Types ==========
34
+
35
+ export interface PrerenderOptions {
36
+ /** Project root all relative paths resolve from here. */
37
+ rootDir: string;
38
+ /**
39
+ * Output directory (absolute, or relative to `rootDir`).
40
+ * Defaults to `.mandu/static` to preserve behavior for older
41
+ * callers; `mandu build` opts into `.mandu/prerendered` +
42
+ * `writeIndex: true` to enable runtime pass-through.
43
+ */
44
+ outDir?: string;
45
+ /** Extra URL paths to prerender in addition to the manifest. */
46
+ routes?: string[];
47
+ /** Follow internal `<a href>` links in rendered HTML (default: false). */
48
+ crawl?: boolean;
49
+ /**
50
+ * When true, also write `<outDir>/_manifest.json` listing every
51
+ * prerendered pathname. The runtime uses this index to short-circuit
52
+ * dispatch for matching URLs.
53
+ */
54
+ writeIndex?: boolean;
55
+ /**
56
+ * Optional injected `import` function. Tests pass a stub so we can
57
+ * exercise `generateStaticParams` without touching disk; production
58
+ * callers leave this undefined (the default dynamic import is used).
59
+ */
60
+ importModule?: (specifier: string) => Promise<PageModuleWithStaticParams>;
61
+
62
+ /**
63
+ * Phase 18.τ — plugins contributing `definePrerenderHook()`.
64
+ * Each plugin receives a {@link PrerenderContext} with the
65
+ * pathname + HTML and may return a {@link PrerenderOverride} to
66
+ * skip, rewrite, or replace the output. Omitted → zero overhead.
67
+ */
68
+ plugins?: readonly ManduPlugin[];
69
+ configHooks?: Partial<ManduHooks>;
70
+ }
71
+
72
+ export interface PrerenderResult {
73
+ /** Number of pages rendered successfully. */
74
+ generated: number;
75
+ /** Per-page telemetry. */
76
+ pages: PrerenderPageResult[];
77
+ /** Errors encountered during the run (non-fatal). */
78
+ errors: string[];
79
+ /** Pathnames that were rendered. */
80
+ paths: string[];
81
+ }
82
+
83
+ export interface PrerenderPageResult {
84
+ path: string;
85
+ size: number;
86
+ duration: number;
87
+ }
88
+
89
+ /** Shape of the index file written to `<outDir>/_manifest.json`. */
90
+ export interface PrerenderIndex {
91
+ version: 1;
92
+ generatedAt: string;
93
+ /** Pathname → relative HTML file path (posix separators). */
94
+ pages: Record<string, string>;
95
+ }
96
+
97
+ /** File name used for the runtime index. */
98
+ export const PRERENDER_INDEX_FILE = "_manifest.json";
99
+
100
+ /** Default output directory (runtime-aware location). */
101
+ export const DEFAULT_PRERENDER_DIR = ".mandu/prerendered";
102
+
103
+ /** Default output directory (legacy `prerenderRoutes` callers). */
104
+ export const LEGACY_PRERENDER_DIR = ".mandu/static";
105
+
106
+ /** Default cache policy stamped on runtime prerender responses. */
107
+ export const DEFAULT_PRERENDER_CACHE_CONTROL =
108
+ "public, max-age=31536000, immutable";
109
+
110
+ // ========== Implementation ==========
111
+
112
+ /**
113
+ * Prerender the routes declared in a manifest (plus any extras) to
114
+ * static HTML. See `PrerenderOptions` for the full contract.
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * const result = await prerenderRoutes(manifest, fetchHandler, {
119
+ * rootDir: process.cwd(),
120
+ * outDir: ".mandu/prerendered",
121
+ * writeIndex: true,
122
+ * });
123
+ * ```
124
+ */
125
+ export async function prerenderRoutes(
126
+ manifest: RoutesManifest,
127
+ fetchHandler: (req: Request) => Promise<Response>,
128
+ options: PrerenderOptions
129
+ ): Promise<PrerenderResult> {
130
+ const {
131
+ rootDir,
132
+ outDir = LEGACY_PRERENDER_DIR,
133
+ crawl = false,
134
+ writeIndex = false,
135
+ importModule,
136
+ } = options;
137
+
138
+ // Phase 18.τ resolve plugin hook bundle once so the hot render loop
139
+ // can short-circuit with a single falsy check.
140
+ const pluginArgs = {
141
+ plugins: options.plugins ?? [],
142
+ configHooks: options.configHooks,
143
+ };
144
+ const hasPrerenderHook =
145
+ pluginArgs.plugins.some((p) => p.hooks?.definePrerenderHook) ||
146
+ Boolean(pluginArgs.configHooks?.definePrerenderHook);
147
+
148
+ const outputDir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
149
+ await fs.mkdir(outputDir, { recursive: true });
150
+
151
+ const pages: PrerenderPageResult[] = [];
152
+ const errors: string[] = [];
153
+ const renderedPaths = new Set<string>();
154
+ const pageIndex: Record<string, string> = {};
155
+
156
+ // 1. Explicit user-supplied routes.
157
+ const pathsToRender = new Set<string>(options.routes ?? []);
158
+
159
+ // 2. Static page routes (no dynamic segments).
160
+ for (const route of manifest.routes) {
161
+ if (route.kind === "page" && !isDynamicPattern(route.pattern)) {
162
+ pathsToRender.add(route.pattern);
163
+ }
164
+ }
165
+
166
+ // 3. Dynamic routes that export `generateStaticParams`.
167
+ const resolveModule =
168
+ importModule ?? ((specifier: string) => import(specifier));
169
+
170
+ for (const route of manifest.routes) {
171
+ if (route.kind !== "page" || !isDynamicPattern(route.pattern)) continue;
172
+
173
+ let mod: PageModuleWithStaticParams;
174
+ try {
175
+ mod = await loadPageModule(rootDir, route, resolveModule);
176
+ } catch {
177
+ // Module failed to load entirely. Silent skip — the page may
178
+ // simply not opt into static params; SSR can still serve it.
179
+ continue;
180
+ }
181
+
182
+ // ─── Issue #214 ─────────────────────────────────────────────────────────
183
+ // Capture `dynamicParams` export from the page module and stamp it onto
184
+ // the route spec so the runtime dispatch guard can consult it. Undefined
185
+ // export → undefined on the spec (default: allow SSR fallback, Next.js
186
+ // parity). Explicit `true` also round-trips for clarity.
187
+ if (typeof mod.dynamicParams === "boolean") {
188
+ (route as { dynamicParams?: boolean }).dynamicParams = mod.dynamicParams;
189
+ }
190
+ // ─── End Issue #214 ─────────────────────────────────────────────────────
191
+
192
+ if (typeof mod.generateStaticParams !== "function") {
193
+ // Not opted-in for this route — perfectly fine.
194
+ continue;
195
+ }
196
+
197
+ try {
198
+ const {
199
+ paths,
200
+ errors: paramErrors,
201
+ paramSets,
202
+ } = await collectStaticPaths(route.pattern, mod);
203
+ for (const p of paths) pathsToRender.add(p);
204
+ for (const e of paramErrors) errors.push(`[${route.pattern}] ${e}`);
205
+
206
+ // ─── Issue #214 ───────────────────────────────────────────────────────
207
+ // Persist the resolved param sets on the spec. The runtime #214 guard
208
+ // reads this to decide whether an incoming request matches the known
209
+ // set. Empty arrays are preserved (distinct from `undefined`) so users
210
+ // can opt into "no dynamic URLs at all" via `generateStaticParams: []`
211
+ // + `dynamicParams: false`.
212
+ if (paramSets.length > 0 || mod.dynamicParams === false) {
213
+ (route as { staticParams?: StaticParamSet[] }).staticParams = paramSets;
214
+ }
215
+ // ─── End Issue #214 ───────────────────────────────────────────────────
216
+ } catch (error) {
217
+ // User code threw. Surface the error but keep going — other
218
+ // routes should not be blocked by one buggy generator.
219
+ errors.push(
220
+ `[${route.pattern}] generateStaticParams threw: ${describeError(error)}`
221
+ );
222
+ }
223
+ }
224
+
225
+ // 4. Render every queued path.
226
+ for (const pathname of pathsToRender) {
227
+ if (renderedPaths.has(pathname)) continue;
228
+ renderedPaths.add(pathname);
229
+
230
+ const start = Date.now();
231
+ try {
232
+ const request = new Request(`http://localhost${pathname}`);
233
+ const response = await fetchHandler(request);
234
+
235
+ if (!response.ok) {
236
+ errors.push(`[${pathname}] HTTP ${response.status}`);
237
+ continue;
238
+ }
239
+
240
+ let html = await response.text();
241
+ let finalPathname = pathname;
242
+
243
+ // Phase 18.τ let plugins inspect / rewrite / skip the output.
244
+ // Zero-overhead fast-path when no plugin provides the hook.
245
+ if (hasPrerenderHook) {
246
+ const override = await runDefinePrerenderHook(
247
+ {
248
+ rootDir,
249
+ mode: "production",
250
+ logger: {
251
+ debug: (m) => console.debug(`[prerender] ${m}`),
252
+ info: (m) => console.info(`[prerender] ${m}`),
253
+ warn: (m) => console.warn(`[prerender] ${m}`),
254
+ error: (m) => console.error(`[prerender] ${m}`),
255
+ },
256
+ pathname,
257
+ html,
258
+ },
259
+ pluginArgs,
260
+ );
261
+ for (const e of override.errors) {
262
+ errors.push(`definePrerenderHook[${e.source}] ${pathname}: ${e.error.message}`);
263
+ }
264
+ if (override.result.skip === true) {
265
+ continue;
266
+ }
267
+ if (typeof override.result.html === "string") {
268
+ html = override.result.html;
269
+ }
270
+ if (typeof override.result.pathname === "string") {
271
+ finalPathname = override.result.pathname;
272
+ }
273
+ }
274
+
275
+ const filePath = getOutputPath(outputDir, finalPathname);
276
+
277
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
278
+ await fs.writeFile(filePath, html, "utf-8");
279
+
280
+ const duration = Date.now() - start;
281
+ pages.push({ path: finalPathname, size: html.length, duration });
282
+ pageIndex[finalPathname] = toPosix(path.relative(outputDir, filePath));
283
+
284
+ // 5. Optional crawl — harvest internal links for next pass.
285
+ if (crawl) {
286
+ const links = extractInternalLinks(html);
287
+ for (const link of links) {
288
+ if (!renderedPaths.has(link) && !pathsToRender.has(link)) {
289
+ pathsToRender.add(link);
290
+ }
291
+ }
292
+ }
293
+ } catch (error) {
294
+ errors.push(`[${pathname}] ${describeError(error)}`);
295
+ }
296
+ }
297
+
298
+ // 6. Emit runtime index.
299
+ if (writeIndex) {
300
+ const indexContents: PrerenderIndex = {
301
+ version: 1,
302
+ generatedAt: new Date().toISOString(),
303
+ pages: pageIndex,
304
+ };
305
+ await fs.writeFile(
306
+ path.join(outputDir, PRERENDER_INDEX_FILE),
307
+ JSON.stringify(indexContents, null, 2),
308
+ "utf-8"
309
+ );
310
+ }
311
+
312
+ return {
313
+ generated: pages.length,
314
+ pages,
315
+ errors,
316
+ paths: pages.map((p) => p.path),
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Load the prerender manifest index emitted under `outDir`. Returns
322
+ * `null` if it doesn't exist or can't be parsed — callers should
323
+ * treat that as "no prerendered content" rather than an error.
324
+ */
325
+ export async function loadPrerenderIndex(
326
+ rootDir: string,
327
+ outDir: string = DEFAULT_PRERENDER_DIR
328
+ ): Promise<PrerenderIndex | null> {
329
+ const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
330
+ const file = path.join(dir, PRERENDER_INDEX_FILE);
331
+ try {
332
+ const contents = await fs.readFile(file, "utf-8");
333
+ const parsed = JSON.parse(contents) as PrerenderIndex;
334
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !parsed.pages) {
335
+ return null;
336
+ }
337
+ return parsed;
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Resolve a pathname against a loaded index. Returns the absolute
345
+ * file path of the prerendered HTML, or `null` on miss.
346
+ *
347
+ * Tolerates both `/foo` and `/foo/` forms, and an optional `.html`
348
+ * suffix. Path-traversal in the index value is defensively rejected
349
+ * so a hand-edited / malicious index cannot escape the output root.
350
+ */
351
+ export function resolvePrerenderedFile(
352
+ index: PrerenderIndex,
353
+ rootDir: string,
354
+ outDir: string,
355
+ pathname: string
356
+ ): string | null {
357
+ const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
358
+ const candidates = [pathname];
359
+ if (pathname.length > 1 && pathname.endsWith("/")) {
360
+ candidates.push(pathname.slice(0, -1));
361
+ } else if (pathname !== "/") {
362
+ candidates.push(pathname + "/");
363
+ }
364
+ if (pathname.endsWith(".html")) {
365
+ candidates.push(pathname.slice(0, -".html".length));
366
+ }
367
+ for (const candidate of candidates) {
368
+ const rel = index.pages[candidate];
369
+ if (rel) {
370
+ const resolved = path.resolve(dir, rel);
371
+ const normalizedDir = path.resolve(dir) + path.sep;
372
+ if (resolved === path.resolve(dir) || resolved.startsWith(normalizedDir)) {
373
+ return resolved;
374
+ }
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+
380
+ // ========== Helpers ==========
381
+
382
+ /**
383
+ * Dynamic-import a page module given its declared `module` path in
384
+ * the manifest. Normalizes the path for Windows dynamic-import
385
+ * (forward slashes + absolute) before delegating.
386
+ */
387
+ async function loadPageModule(
388
+ rootDir: string,
389
+ route: RouteSpec,
390
+ importFn: (specifier: string) => Promise<PageModuleWithStaticParams>
391
+ ): Promise<PageModuleWithStaticParams> {
392
+ const absolute = path.isAbsolute(route.module)
393
+ ? route.module
394
+ : path.join(rootDir, route.module);
395
+ const specifier = absolute.replace(/\\/g, "/");
396
+ return importFn(specifier);
397
+ }
398
+
399
+ /**
400
+ * URL path → output file path.
401
+ * / → <outDir>/index.html
402
+ * /about → <outDir>/about/index.html (clean URL)
403
+ * /blog/a/b → <outDir>/blog/a/b/index.html
404
+ */
405
+ function getOutputPath(outDir: string, pathname: string): string {
406
+ const trimmed = pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
407
+ if (trimmed === "/") return path.join(outDir, "index.html");
408
+ // Decode percent-encoding so on-disk names are stable across platforms.
409
+ const decoded = trimmed
410
+ .split("/")
411
+ .map((segment) => {
412
+ try {
413
+ return decodeURIComponent(segment);
414
+ } catch {
415
+ return segment;
416
+ }
417
+ })
418
+ .join("/");
419
+ return path.join(outDir, decoded, "index.html");
420
+ }
421
+
422
+ /** Extract absolute internal `<a href>` paths (same-origin only). */
423
+ function extractInternalLinks(html: string): string[] {
424
+ const links: string[] = [];
425
+ const hrefRegex = /href=["']([^"']+)["']/g;
426
+ let match: RegExpExecArray | null;
427
+ while ((match = hrefRegex.exec(html)) !== null) {
428
+ const href = match[1];
429
+ if (href.startsWith("/") && !href.startsWith("//")) {
430
+ const cleanPath = href.split("?")[0].split("#")[0];
431
+ if (!cleanPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) {
432
+ links.push(cleanPath);
433
+ }
434
+ }
435
+ }
436
+ return [...new Set(links)];
437
+ }
438
+
439
+ function toPosix(p: string): string {
440
+ return p.replace(/\\/g, "/");
441
+ }
442
+
443
+ function describeError(error: unknown): string {
444
+ if (error instanceof Error) return error.message;
445
+ return String(error);
446
+ }