@mandujs/core 0.32.0 → 0.33.1
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/package.json +4 -1
- package/src/bundler/build.ts +29 -1
- package/src/bundler/generate-static-params.ts +302 -290
- package/src/bundler/prerender.ts +858 -368
- package/src/bundler/types.ts +20 -0
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +54 -0
- package/src/config/validate.ts +55 -0
- package/src/diagnose/__tests__/checks.test.ts +378 -0
- package/src/diagnose/checks.ts +599 -0
- package/src/diagnose/index.ts +15 -0
- package/src/diagnose/run.ts +87 -0
- package/src/diagnose/types.ts +53 -0
- package/src/guard/graph.ts +898 -0
- package/src/guard/index.ts +14 -0
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
- package/src/plugins/__tests__/runner.test.ts +409 -0
- package/src/plugins/define.ts +124 -0
- package/src/plugins/examples/dep-check-plugin.ts +80 -0
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
- package/src/plugins/examples/sitemap-plugin.ts +65 -0
- package/src/plugins/hooks.ts +297 -64
- package/src/plugins/index.ts +80 -41
- package/src/plugins/runner.ts +361 -0
- package/src/router/fs-routes.ts +64 -1
- package/src/runtime/server.ts +365 -36
- package/src/spec/schema.ts +25 -0
- package/src/testing/__tests__/reporter.test.ts +454 -0
- package/src/testing/index.ts +29 -0
- package/src/testing/reporter.ts +676 -0
package/src/bundler/prerender.ts
CHANGED
|
@@ -1,368 +1,858 @@
|
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
export
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
/**
|
|
36
|
+
* Issue #213 — link-crawler configuration.
|
|
37
|
+
*
|
|
38
|
+
* When the prerender engine crawls rendered HTML for internal links
|
|
39
|
+
* (`crawl: true`) it accidentally picks up `href` attributes embedded
|
|
40
|
+
* inside documentation code examples (`<pre>`, `<code>`, fenced
|
|
41
|
+
* blocks, inline code spans). These are illustrative, not real routes,
|
|
42
|
+
* and trying to prerender them produces spurious `/path/index.html`
|
|
43
|
+
* files or build failures.
|
|
44
|
+
*
|
|
45
|
+
* The crawl options let callers:
|
|
46
|
+
* 1. Trust the default behavior (strip code regions + a small
|
|
47
|
+
* hard-coded denylist of obvious placeholders).
|
|
48
|
+
* 2. Extend the denylist with project-specific placeholder globs.
|
|
49
|
+
* 3. Replace the denylist entirely for maximum control.
|
|
50
|
+
*/
|
|
51
|
+
export interface PrerenderCrawlOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Extra pathnames or prefixes to exclude when crawling links. Each
|
|
54
|
+
* entry is matched against the normalized crawl target:
|
|
55
|
+
* - Exact string (e.g. `"/example"`): matches that pathname only.
|
|
56
|
+
* - Glob suffix (e.g. `"/your-*"`): uses a simple `*` → `.*` regex
|
|
57
|
+
* translation to match any pathname with that prefix / pattern.
|
|
58
|
+
*
|
|
59
|
+
* Merged with the default denylist (see
|
|
60
|
+
* {@link DEFAULT_CRAWL_DENYLIST}). Use {@link PrerenderCrawlOptions.exclude}
|
|
61
|
+
* to ADD entries; set {@link PrerenderCrawlOptions.replaceDefaultExclude}
|
|
62
|
+
* to `true` to REPLACE the defaults.
|
|
63
|
+
*/
|
|
64
|
+
exclude?: string[];
|
|
65
|
+
/**
|
|
66
|
+
* When `true`, `exclude` replaces the built-in denylist entirely
|
|
67
|
+
* instead of extending it. Default `false` (safe — defaults win).
|
|
68
|
+
*/
|
|
69
|
+
replaceDefaultExclude?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Issue #219 — file extensions treated as non-HTML assets. When a
|
|
72
|
+
* discovered `<a href>` / `href` value has a pathname ending in one
|
|
73
|
+
* of these extensions, the crawler skips it instead of enqueuing it
|
|
74
|
+
* for prerender. Without this filter, markup like `<picture><source
|
|
75
|
+
* srcset="/hero.avif"><img src="/hero.webp"></picture>` would cause
|
|
76
|
+
* the engine to render the asset as HTML and overwrite it on disk.
|
|
77
|
+
*
|
|
78
|
+
* Matching is case-insensitive and ignores query strings / hash
|
|
79
|
+
* fragments. See {@link DEFAULT_ASSET_EXTENSIONS} for the built-in
|
|
80
|
+
* list.
|
|
81
|
+
*
|
|
82
|
+
* Merged with {@link DEFAULT_ASSET_EXTENSIONS} unless
|
|
83
|
+
* {@link PrerenderCrawlOptions.replaceDefaultAssetExtensions} is
|
|
84
|
+
* `true`. Entries may be given with or without a leading dot
|
|
85
|
+
* (`"webp"` and `".webp"` are equivalent).
|
|
86
|
+
*/
|
|
87
|
+
assetExtensions?: string[];
|
|
88
|
+
/**
|
|
89
|
+
* When `true`, `assetExtensions` replaces the built-in asset
|
|
90
|
+
* extension set entirely. Default `false` (safe — defaults win).
|
|
91
|
+
*/
|
|
92
|
+
replaceDefaultAssetExtensions?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PrerenderOptions {
|
|
96
|
+
/** Project root — all relative paths resolve from here. */
|
|
97
|
+
rootDir: string;
|
|
98
|
+
/**
|
|
99
|
+
* Output directory (absolute, or relative to `rootDir`).
|
|
100
|
+
* Defaults to `.mandu/static` to preserve behavior for older
|
|
101
|
+
* callers; `mandu build` opts into `.mandu/prerendered` +
|
|
102
|
+
* `writeIndex: true` to enable runtime pass-through.
|
|
103
|
+
*/
|
|
104
|
+
outDir?: string;
|
|
105
|
+
/** Extra URL paths to prerender in addition to the manifest. */
|
|
106
|
+
routes?: string[];
|
|
107
|
+
/** Follow internal `<a href>` links in rendered HTML (default: false). */
|
|
108
|
+
crawl?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Issue #213 — link-crawler configuration. Only consulted when
|
|
111
|
+
* `crawl: true`. Omitting the block uses the defaults (strip code
|
|
112
|
+
* regions, apply {@link DEFAULT_CRAWL_DENYLIST}).
|
|
113
|
+
*/
|
|
114
|
+
crawlOptions?: PrerenderCrawlOptions;
|
|
115
|
+
/**
|
|
116
|
+
* When true, also write `<outDir>/_manifest.json` listing every
|
|
117
|
+
* prerendered pathname. The runtime uses this index to short-circuit
|
|
118
|
+
* dispatch for matching URLs.
|
|
119
|
+
*/
|
|
120
|
+
writeIndex?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Optional injected `import` function. Tests pass a stub so we can
|
|
123
|
+
* exercise `generateStaticParams` without touching disk; production
|
|
124
|
+
* callers leave this undefined (the default dynamic import is used).
|
|
125
|
+
*/
|
|
126
|
+
importModule?: (specifier: string) => Promise<PageModuleWithStaticParams>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Phase 18.τ — plugins contributing `definePrerenderHook()`.
|
|
130
|
+
* Each plugin receives a {@link PrerenderContext} with the
|
|
131
|
+
* pathname + HTML and may return a {@link PrerenderOverride} to
|
|
132
|
+
* skip, rewrite, or replace the output. Omitted → zero overhead.
|
|
133
|
+
*/
|
|
134
|
+
plugins?: readonly ManduPlugin[];
|
|
135
|
+
configHooks?: Partial<ManduHooks>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Issue #216 — opt-out from hard-failing on route errors.
|
|
139
|
+
* When `true`, errors from individual routes (module load / throw /
|
|
140
|
+
* non-array return from `generateStaticParams`) are collected in
|
|
141
|
+
* `PrerenderResult.errors` as warnings and the orchestrator returns
|
|
142
|
+
* normally. When `false` (default) the prerender still collects
|
|
143
|
+
* every route's error but throws a `PrerenderError` aggregate at
|
|
144
|
+
* the end so CI can exit non-zero. Set by the CLI's
|
|
145
|
+
* `--prerender-skip-errors` flag.
|
|
146
|
+
*/
|
|
147
|
+
skipErrors?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface PrerenderResult {
|
|
151
|
+
/** Number of pages rendered successfully. */
|
|
152
|
+
generated: number;
|
|
153
|
+
/** Per-page telemetry. */
|
|
154
|
+
pages: PrerenderPageResult[];
|
|
155
|
+
/** Errors encountered during the run (non-fatal). */
|
|
156
|
+
errors: string[];
|
|
157
|
+
/** Pathnames that were rendered. */
|
|
158
|
+
paths: string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface PrerenderPageResult {
|
|
162
|
+
path: string;
|
|
163
|
+
size: number;
|
|
164
|
+
duration: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Shape of the index file written to `<outDir>/_manifest.json`. */
|
|
168
|
+
export interface PrerenderIndex {
|
|
169
|
+
version: 1;
|
|
170
|
+
generatedAt: string;
|
|
171
|
+
/** Pathname → relative HTML file path (posix separators). */
|
|
172
|
+
pages: Record<string, string>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** File name used for the runtime index. */
|
|
176
|
+
export const PRERENDER_INDEX_FILE = "_manifest.json";
|
|
177
|
+
|
|
178
|
+
/** Default output directory (runtime-aware location). */
|
|
179
|
+
export const DEFAULT_PRERENDER_DIR = ".mandu/prerendered";
|
|
180
|
+
|
|
181
|
+
/** Default output directory (legacy `prerenderRoutes` callers). */
|
|
182
|
+
export const LEGACY_PRERENDER_DIR = ".mandu/static";
|
|
183
|
+
|
|
184
|
+
/** Default cache policy stamped on runtime prerender responses. */
|
|
185
|
+
export const DEFAULT_PRERENDER_CACHE_CONTROL =
|
|
186
|
+
"public, max-age=31536000, immutable";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Issue #213 — default denylist for the link crawler.
|
|
190
|
+
*
|
|
191
|
+
* These entries match paths that appear in doc examples (and never
|
|
192
|
+
* correspond to real routes): the classic placeholders (`/path`,
|
|
193
|
+
* `/example`), the `/your-*` and `/my-*` scaffolds people write when
|
|
194
|
+
* illustrating URL shapes, and the `/...` catch-all literal.
|
|
195
|
+
*
|
|
196
|
+
* Exact strings match a full pathname; entries containing `*` are
|
|
197
|
+
* treated as simple globs (`*` → `.*`, anchored).
|
|
198
|
+
*/
|
|
199
|
+
export const DEFAULT_CRAWL_DENYLIST: readonly string[] = [
|
|
200
|
+
"/path",
|
|
201
|
+
"/...",
|
|
202
|
+
"/example",
|
|
203
|
+
"/your-*",
|
|
204
|
+
"/my-*",
|
|
205
|
+
"/foo",
|
|
206
|
+
"/bar",
|
|
207
|
+
"/baz",
|
|
208
|
+
"/some-path",
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Issue #219 — default non-HTML asset extensions the link crawler
|
|
213
|
+
* refuses to enqueue as prerender targets.
|
|
214
|
+
*
|
|
215
|
+
* Motivation: markup like `<picture><source srcset="/hero.avif"><img
|
|
216
|
+
* src="/hero.webp"></picture>` and `<a href="/whitepaper.pdf">` used
|
|
217
|
+
* to leak the asset URL into the render queue. The engine would then
|
|
218
|
+
* invoke the SSR handler, receive a non-HTML response (or an HTML
|
|
219
|
+
* error page), and write it to `.mandu/prerendered/hero.webp/index.html`
|
|
220
|
+
* — corrupting the static-asset dispatch for that URL on subsequent
|
|
221
|
+
* requests.
|
|
222
|
+
*
|
|
223
|
+
* Each entry is lowercased with a leading dot. Comparison is
|
|
224
|
+
* case-insensitive; the crawler strips query strings and hash
|
|
225
|
+
* fragments before extension testing.
|
|
226
|
+
*
|
|
227
|
+
* Extend or replace via `ManduConfig.build.crawl.assetExtensions` /
|
|
228
|
+
* `replaceDefaultAssetExtensions`.
|
|
229
|
+
*/
|
|
230
|
+
export const DEFAULT_ASSET_EXTENSIONS: readonly string[] = [
|
|
231
|
+
".webp",
|
|
232
|
+
".avif",
|
|
233
|
+
".png",
|
|
234
|
+
".jpg",
|
|
235
|
+
".jpeg",
|
|
236
|
+
".gif",
|
|
237
|
+
".svg",
|
|
238
|
+
".ico",
|
|
239
|
+
".pdf",
|
|
240
|
+
".zip",
|
|
241
|
+
".mp4",
|
|
242
|
+
".webm",
|
|
243
|
+
".mp3",
|
|
244
|
+
".wav",
|
|
245
|
+
".woff",
|
|
246
|
+
".woff2",
|
|
247
|
+
".ttf",
|
|
248
|
+
".otf",
|
|
249
|
+
".eot",
|
|
250
|
+
".css",
|
|
251
|
+
".js",
|
|
252
|
+
".map",
|
|
253
|
+
".json",
|
|
254
|
+
".xml",
|
|
255
|
+
".txt",
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Issue #216 — aggregate error thrown when one or more routes fail
|
|
260
|
+
* during prerender (and `skipErrors !== true`). Each entry carries the
|
|
261
|
+
* offending route pattern plus the underlying `cause`, so CI logs show
|
|
262
|
+
* both the symptom (the summary line) and the root cause chain.
|
|
263
|
+
*/
|
|
264
|
+
export class PrerenderError extends Error {
|
|
265
|
+
readonly errors: PrerenderRouteError[];
|
|
266
|
+
|
|
267
|
+
constructor(errors: PrerenderRouteError[]) {
|
|
268
|
+
const summary = errors
|
|
269
|
+
.map((e) => ` - [${e.pattern}] ${e.message}`)
|
|
270
|
+
.join("\n");
|
|
271
|
+
super(
|
|
272
|
+
`Prerender failed for ${errors.length} route(s):\n${summary}`,
|
|
273
|
+
);
|
|
274
|
+
this.name = "PrerenderError";
|
|
275
|
+
this.errors = errors;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface PrerenderRouteError {
|
|
280
|
+
/** The route pattern that failed (e.g. `/docs/:slug`). */
|
|
281
|
+
pattern: string;
|
|
282
|
+
/** Absolute module path that was loaded (or attempted). */
|
|
283
|
+
module: string;
|
|
284
|
+
/** Human-readable description of the failure. */
|
|
285
|
+
message: string;
|
|
286
|
+
/** The underlying error object, preserved for `cause` chaining. */
|
|
287
|
+
cause: unknown;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ========== Implementation ==========
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Prerender the routes declared in a manifest (plus any extras) to
|
|
294
|
+
* static HTML. See `PrerenderOptions` for the full contract.
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* const result = await prerenderRoutes(manifest, fetchHandler, {
|
|
299
|
+
* rootDir: process.cwd(),
|
|
300
|
+
* outDir: ".mandu/prerendered",
|
|
301
|
+
* writeIndex: true,
|
|
302
|
+
* });
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export async function prerenderRoutes(
|
|
306
|
+
manifest: RoutesManifest,
|
|
307
|
+
fetchHandler: (req: Request) => Promise<Response>,
|
|
308
|
+
options: PrerenderOptions
|
|
309
|
+
): Promise<PrerenderResult> {
|
|
310
|
+
const {
|
|
311
|
+
rootDir,
|
|
312
|
+
outDir = LEGACY_PRERENDER_DIR,
|
|
313
|
+
crawl = false,
|
|
314
|
+
crawlOptions,
|
|
315
|
+
writeIndex = false,
|
|
316
|
+
importModule,
|
|
317
|
+
skipErrors = false,
|
|
318
|
+
} = options;
|
|
319
|
+
|
|
320
|
+
// Phase 18.τ — resolve plugin hook bundle once so the hot render loop
|
|
321
|
+
// can short-circuit with a single falsy check.
|
|
322
|
+
const pluginArgs = {
|
|
323
|
+
plugins: options.plugins ?? [],
|
|
324
|
+
configHooks: options.configHooks,
|
|
325
|
+
};
|
|
326
|
+
const hasPrerenderHook =
|
|
327
|
+
pluginArgs.plugins.some((p) => p.hooks?.definePrerenderHook) ||
|
|
328
|
+
Boolean(pluginArgs.configHooks?.definePrerenderHook);
|
|
329
|
+
|
|
330
|
+
const outputDir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
|
|
331
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
332
|
+
|
|
333
|
+
const pages: PrerenderPageResult[] = [];
|
|
334
|
+
const errors: string[] = [];
|
|
335
|
+
/**
|
|
336
|
+
* Issue #216 — structured per-route errors used to build the
|
|
337
|
+
* aggregate thrown at the end of the run. `errors` (the flat string
|
|
338
|
+
* array on `PrerenderResult`) is preserved for backward-compat.
|
|
339
|
+
*/
|
|
340
|
+
const routeErrors: PrerenderRouteError[] = [];
|
|
341
|
+
const renderedPaths = new Set<string>();
|
|
342
|
+
const pageIndex: Record<string, string> = {};
|
|
343
|
+
|
|
344
|
+
// Issue #213 — compile the crawl denylist (defaults ∪ user extras, or
|
|
345
|
+
// user's replacement list) into an array of regexes once. Doing this
|
|
346
|
+
// outside the per-page crawl loop avoids recompiling N times.
|
|
347
|
+
const crawlDenylist = compileCrawlDenylist(crawlOptions);
|
|
348
|
+
// Issue #219 — resolve the non-HTML asset extension set once. Same
|
|
349
|
+
// rationale: the crawl loop runs N times, set lookup is O(1).
|
|
350
|
+
const crawlAssetExtensions = resolveAssetExtensions(crawlOptions);
|
|
351
|
+
|
|
352
|
+
// 1. Explicit user-supplied routes.
|
|
353
|
+
const pathsToRender = new Set<string>(options.routes ?? []);
|
|
354
|
+
|
|
355
|
+
// 2. Static page routes (no dynamic segments).
|
|
356
|
+
for (const route of manifest.routes) {
|
|
357
|
+
if (route.kind === "page" && !isDynamicPattern(route.pattern)) {
|
|
358
|
+
pathsToRender.add(route.pattern);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 3. Dynamic routes that export `generateStaticParams`.
|
|
363
|
+
const resolveModule =
|
|
364
|
+
importModule ?? ((specifier: string) => import(specifier));
|
|
365
|
+
|
|
366
|
+
for (const route of manifest.routes) {
|
|
367
|
+
if (route.kind !== "page" || !isDynamicPattern(route.pattern)) continue;
|
|
368
|
+
|
|
369
|
+
// ─── Issue #216 ─────────────────────────────────────────────────────────
|
|
370
|
+
// Distinguish the three failure modes that were previously collapsed
|
|
371
|
+
// into a single `try/catch` silent skip:
|
|
372
|
+
//
|
|
373
|
+
// 1. Module export missing (`generateStaticParams` is undefined)
|
|
374
|
+
// → legitimate "page doesn't opt into static params"; silent skip.
|
|
375
|
+
// 2. Module fails to load (compile error, missing import, etc.)
|
|
376
|
+
// → real bug, surface with route + cause chain.
|
|
377
|
+
// 3. User's `generateStaticParams` throws or returns non-array
|
|
378
|
+
// → real bug, surface with route + cause chain.
|
|
379
|
+
//
|
|
380
|
+
// The orchestrator still continues with the remaining routes so one
|
|
381
|
+
// broken page doesn't block the whole build; we just collect each
|
|
382
|
+
// failure in `routeErrors` and re-raise as a `PrerenderError` once
|
|
383
|
+
// the run finishes (unless `skipErrors === true`).
|
|
384
|
+
// ─── End Issue #216 ─────────────────────────────────────────────────────
|
|
385
|
+
let mod: PageModuleWithStaticParams;
|
|
386
|
+
try {
|
|
387
|
+
mod = await loadPageModule(rootDir, route, resolveModule);
|
|
388
|
+
} catch (loadErr) {
|
|
389
|
+
const message = `Failed to load page module for prerender of "${route.pattern}" (${route.module}): ${describeError(loadErr)}`;
|
|
390
|
+
errors.push(`[${route.pattern}] ${message}`);
|
|
391
|
+
routeErrors.push({
|
|
392
|
+
pattern: route.pattern,
|
|
393
|
+
module: route.module,
|
|
394
|
+
message,
|
|
395
|
+
cause: loadErr,
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ─── Issue #214 ─────────────────────────────────────────────────────────
|
|
401
|
+
// Capture `dynamicParams` export from the page module and stamp it onto
|
|
402
|
+
// the route spec so the runtime dispatch guard can consult it. Undefined
|
|
403
|
+
// export → undefined on the spec (default: allow SSR fallback, Next.js
|
|
404
|
+
// parity). Explicit `true` also round-trips for clarity.
|
|
405
|
+
if (typeof mod.dynamicParams === "boolean") {
|
|
406
|
+
(route as { dynamicParams?: boolean }).dynamicParams = mod.dynamicParams;
|
|
407
|
+
}
|
|
408
|
+
// ─── End Issue #214 ─────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
if (typeof mod.generateStaticParams !== "function") {
|
|
411
|
+
// Issue #216 — legitimate "no export" case. This is the only
|
|
412
|
+
// silent skip that survives the hardening: the whole point of
|
|
413
|
+
// the feature is that exporting the function is optional.
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const {
|
|
419
|
+
paths,
|
|
420
|
+
errors: paramErrors,
|
|
421
|
+
paramSets,
|
|
422
|
+
} = await collectStaticPaths(route.pattern, mod);
|
|
423
|
+
for (const p of paths) pathsToRender.add(p);
|
|
424
|
+
for (const e of paramErrors) {
|
|
425
|
+
errors.push(`[${route.pattern}] ${e}`);
|
|
426
|
+
// Validation errors from individual param sets are already
|
|
427
|
+
// fine-grained (`generateStaticParams()[i] for "pattern": ...`);
|
|
428
|
+
// promote them to route-level errors so the aggregate surfaces
|
|
429
|
+
// them too.
|
|
430
|
+
routeErrors.push({
|
|
431
|
+
pattern: route.pattern,
|
|
432
|
+
module: route.module,
|
|
433
|
+
message: e,
|
|
434
|
+
cause: new Error(e),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ─── Issue #214 ───────────────────────────────────────────────────────
|
|
439
|
+
// Persist the resolved param sets on the spec. The runtime #214 guard
|
|
440
|
+
// reads this to decide whether an incoming request matches the known
|
|
441
|
+
// set. Empty arrays are preserved (distinct from `undefined`) so users
|
|
442
|
+
// can opt into "no dynamic URLs at all" via `generateStaticParams: []`
|
|
443
|
+
// + `dynamicParams: false`.
|
|
444
|
+
if (paramSets.length > 0 || mod.dynamicParams === false) {
|
|
445
|
+
(route as { staticParams?: StaticParamSet[] }).staticParams = paramSets;
|
|
446
|
+
}
|
|
447
|
+
// ─── End Issue #214 ───────────────────────────────────────────────────
|
|
448
|
+
} catch (error) {
|
|
449
|
+
// Issue #216 — user's `generateStaticParams` threw. Capture with
|
|
450
|
+
// context (pattern + module + cause) so `PrerenderError` can
|
|
451
|
+
// rebuild a proper chain.
|
|
452
|
+
const message = `generateStaticParams threw: ${describeError(error)}`;
|
|
453
|
+
errors.push(`[${route.pattern}] ${message}`);
|
|
454
|
+
routeErrors.push({
|
|
455
|
+
pattern: route.pattern,
|
|
456
|
+
module: route.module,
|
|
457
|
+
message,
|
|
458
|
+
cause: error,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 4. Render every queued path.
|
|
464
|
+
for (const pathname of pathsToRender) {
|
|
465
|
+
if (renderedPaths.has(pathname)) continue;
|
|
466
|
+
renderedPaths.add(pathname);
|
|
467
|
+
|
|
468
|
+
const start = Date.now();
|
|
469
|
+
try {
|
|
470
|
+
const request = new Request(`http://localhost${pathname}`);
|
|
471
|
+
const response = await fetchHandler(request);
|
|
472
|
+
|
|
473
|
+
if (!response.ok) {
|
|
474
|
+
errors.push(`[${pathname}] HTTP ${response.status}`);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let html = await response.text();
|
|
479
|
+
let finalPathname = pathname;
|
|
480
|
+
|
|
481
|
+
// Phase 18.τ — let plugins inspect / rewrite / skip the output.
|
|
482
|
+
// Zero-overhead fast-path when no plugin provides the hook.
|
|
483
|
+
if (hasPrerenderHook) {
|
|
484
|
+
const override = await runDefinePrerenderHook(
|
|
485
|
+
{
|
|
486
|
+
rootDir,
|
|
487
|
+
mode: "production",
|
|
488
|
+
logger: {
|
|
489
|
+
debug: (m) => console.debug(`[prerender] ${m}`),
|
|
490
|
+
info: (m) => console.info(`[prerender] ${m}`),
|
|
491
|
+
warn: (m) => console.warn(`[prerender] ${m}`),
|
|
492
|
+
error: (m) => console.error(`[prerender] ${m}`),
|
|
493
|
+
},
|
|
494
|
+
pathname,
|
|
495
|
+
html,
|
|
496
|
+
},
|
|
497
|
+
pluginArgs,
|
|
498
|
+
);
|
|
499
|
+
for (const e of override.errors) {
|
|
500
|
+
errors.push(`definePrerenderHook[${e.source}] ${pathname}: ${e.error.message}`);
|
|
501
|
+
}
|
|
502
|
+
if (override.result.skip === true) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (typeof override.result.html === "string") {
|
|
506
|
+
html = override.result.html;
|
|
507
|
+
}
|
|
508
|
+
if (typeof override.result.pathname === "string") {
|
|
509
|
+
finalPathname = override.result.pathname;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const filePath = getOutputPath(outputDir, finalPathname);
|
|
514
|
+
|
|
515
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
516
|
+
await fs.writeFile(filePath, html, "utf-8");
|
|
517
|
+
|
|
518
|
+
const duration = Date.now() - start;
|
|
519
|
+
pages.push({ path: finalPathname, size: html.length, duration });
|
|
520
|
+
pageIndex[finalPathname] = toPosix(path.relative(outputDir, filePath));
|
|
521
|
+
|
|
522
|
+
// 5. Optional crawl — harvest internal links for next pass.
|
|
523
|
+
if (crawl) {
|
|
524
|
+
// Issue #213 — strip code regions + apply denylist before adding
|
|
525
|
+
// discovered paths to the render queue.
|
|
526
|
+
// Issue #219 — also filter out asset URLs (`/hero.webp`, etc.)
|
|
527
|
+
// so the engine doesn't try to render them as HTML.
|
|
528
|
+
const links = extractInternalLinks(html, crawlDenylist, crawlAssetExtensions);
|
|
529
|
+
for (const link of links) {
|
|
530
|
+
if (!renderedPaths.has(link) && !pathsToRender.has(link)) {
|
|
531
|
+
pathsToRender.add(link);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} catch (error) {
|
|
536
|
+
errors.push(`[${pathname}] ${describeError(error)}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 6. Emit runtime index.
|
|
541
|
+
if (writeIndex) {
|
|
542
|
+
const indexContents: PrerenderIndex = {
|
|
543
|
+
version: 1,
|
|
544
|
+
generatedAt: new Date().toISOString(),
|
|
545
|
+
pages: pageIndex,
|
|
546
|
+
};
|
|
547
|
+
await fs.writeFile(
|
|
548
|
+
path.join(outputDir, PRERENDER_INDEX_FILE),
|
|
549
|
+
JSON.stringify(indexContents, null, 2),
|
|
550
|
+
"utf-8"
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// 7. Issue #216 — if any route errored, surface as aggregate so CI
|
|
555
|
+
// can exit non-zero. `skipErrors: true` converts errors to
|
|
556
|
+
// warnings (collected in `errors` + the returned result).
|
|
557
|
+
if (routeErrors.length > 0 && !skipErrors) {
|
|
558
|
+
throw new PrerenderError(routeErrors);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
generated: pages.length,
|
|
563
|
+
pages,
|
|
564
|
+
errors,
|
|
565
|
+
paths: pages.map((p) => p.path),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Load the prerender manifest index emitted under `outDir`. Returns
|
|
571
|
+
* `null` if it doesn't exist or can't be parsed — callers should
|
|
572
|
+
* treat that as "no prerendered content" rather than an error.
|
|
573
|
+
*/
|
|
574
|
+
export async function loadPrerenderIndex(
|
|
575
|
+
rootDir: string,
|
|
576
|
+
outDir: string = DEFAULT_PRERENDER_DIR
|
|
577
|
+
): Promise<PrerenderIndex | null> {
|
|
578
|
+
const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
|
|
579
|
+
const file = path.join(dir, PRERENDER_INDEX_FILE);
|
|
580
|
+
try {
|
|
581
|
+
const contents = await fs.readFile(file, "utf-8");
|
|
582
|
+
const parsed = JSON.parse(contents) as PrerenderIndex;
|
|
583
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !parsed.pages) {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
return parsed;
|
|
587
|
+
} catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Resolve a pathname against a loaded index. Returns the absolute
|
|
594
|
+
* file path of the prerendered HTML, or `null` on miss.
|
|
595
|
+
*
|
|
596
|
+
* Tolerates both `/foo` and `/foo/` forms, and an optional `.html`
|
|
597
|
+
* suffix. Path-traversal in the index value is defensively rejected
|
|
598
|
+
* so a hand-edited / malicious index cannot escape the output root.
|
|
599
|
+
*/
|
|
600
|
+
export function resolvePrerenderedFile(
|
|
601
|
+
index: PrerenderIndex,
|
|
602
|
+
rootDir: string,
|
|
603
|
+
outDir: string,
|
|
604
|
+
pathname: string
|
|
605
|
+
): string | null {
|
|
606
|
+
const dir = path.isAbsolute(outDir) ? outDir : path.join(rootDir, outDir);
|
|
607
|
+
const candidates = [pathname];
|
|
608
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
609
|
+
candidates.push(pathname.slice(0, -1));
|
|
610
|
+
} else if (pathname !== "/") {
|
|
611
|
+
candidates.push(pathname + "/");
|
|
612
|
+
}
|
|
613
|
+
if (pathname.endsWith(".html")) {
|
|
614
|
+
candidates.push(pathname.slice(0, -".html".length));
|
|
615
|
+
}
|
|
616
|
+
for (const candidate of candidates) {
|
|
617
|
+
const rel = index.pages[candidate];
|
|
618
|
+
if (rel) {
|
|
619
|
+
const resolved = path.resolve(dir, rel);
|
|
620
|
+
const normalizedDir = path.resolve(dir) + path.sep;
|
|
621
|
+
if (resolved === path.resolve(dir) || resolved.startsWith(normalizedDir)) {
|
|
622
|
+
return resolved;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ========== Helpers ==========
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Dynamic-import a page module given its declared `module` path in
|
|
633
|
+
* the manifest. Normalizes the path for Windows dynamic-import
|
|
634
|
+
* (forward slashes + absolute) before delegating.
|
|
635
|
+
*/
|
|
636
|
+
async function loadPageModule(
|
|
637
|
+
rootDir: string,
|
|
638
|
+
route: RouteSpec,
|
|
639
|
+
importFn: (specifier: string) => Promise<PageModuleWithStaticParams>
|
|
640
|
+
): Promise<PageModuleWithStaticParams> {
|
|
641
|
+
const absolute = path.isAbsolute(route.module)
|
|
642
|
+
? route.module
|
|
643
|
+
: path.join(rootDir, route.module);
|
|
644
|
+
const specifier = absolute.replace(/\\/g, "/");
|
|
645
|
+
return importFn(specifier);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* URL path → output file path.
|
|
650
|
+
* / → <outDir>/index.html
|
|
651
|
+
* /about → <outDir>/about/index.html (clean URL)
|
|
652
|
+
* /blog/a/b → <outDir>/blog/a/b/index.html
|
|
653
|
+
*/
|
|
654
|
+
function getOutputPath(outDir: string, pathname: string): string {
|
|
655
|
+
const trimmed = pathname === "/" ? "/" : pathname.replace(/\/+$/, "");
|
|
656
|
+
if (trimmed === "/") return path.join(outDir, "index.html");
|
|
657
|
+
// Decode percent-encoding so on-disk names are stable across platforms.
|
|
658
|
+
const decoded = trimmed
|
|
659
|
+
.split("/")
|
|
660
|
+
.map((segment) => {
|
|
661
|
+
try {
|
|
662
|
+
return decodeURIComponent(segment);
|
|
663
|
+
} catch {
|
|
664
|
+
return segment;
|
|
665
|
+
}
|
|
666
|
+
})
|
|
667
|
+
.join("/");
|
|
668
|
+
return path.join(outDir, decoded, "index.html");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Issue #213 — strip regions of HTML/MDX that only contain illustrative
|
|
673
|
+
* markup (doc code examples) before scanning for crawl targets.
|
|
674
|
+
*
|
|
675
|
+
* The order below is deliberate:
|
|
676
|
+
* 1. HTML comments (`<!-- ... -->`) — may wrap real `<a>` / `<code>`
|
|
677
|
+
* tags users don't want crawled.
|
|
678
|
+
* 2. Fenced markdown code blocks (``` ... ```), including ~~~-fenced.
|
|
679
|
+
* 3. Block HTML code containers (`<pre>...</pre>`, `<code>...</code>`,
|
|
680
|
+
* including attributes like `<pre class="language-tsx">`).
|
|
681
|
+
* 4. Inline-code backticks (`` `...` ``).
|
|
682
|
+
*
|
|
683
|
+
* Each strip uses a non-greedy, multiline-aware regex. The replacements
|
|
684
|
+
* are whitespace-only so line-based tools don't get confused, but the
|
|
685
|
+
* string lengths stay similar (we don't need precise positions — we only
|
|
686
|
+
* re-scan for `href` attributes after the strip).
|
|
687
|
+
*
|
|
688
|
+
* Exported for test coverage.
|
|
689
|
+
*/
|
|
690
|
+
export function stripCodeRegions(html: string): string {
|
|
691
|
+
let out = html;
|
|
692
|
+
// 1. HTML comments — nested and multiline.
|
|
693
|
+
out = out.replace(/<!--[\s\S]*?-->/g, "");
|
|
694
|
+
// 2. Fenced markdown code blocks — both ``` and ~~~ fences.
|
|
695
|
+
// Allow optional info string on the opening fence.
|
|
696
|
+
out = out.replace(/```[^\n]*\n[\s\S]*?```/g, "");
|
|
697
|
+
out = out.replace(/~~~[^\n]*\n[\s\S]*?~~~/g, "");
|
|
698
|
+
// 3. <pre>...</pre> (case-insensitive, attributes allowed).
|
|
699
|
+
out = out.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, "");
|
|
700
|
+
// 4. <code>...</code> (case-insensitive, attributes allowed).
|
|
701
|
+
out = out.replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, "");
|
|
702
|
+
// 5. Inline markdown code spans — single backtick pairs. Avoid
|
|
703
|
+
// matching stray backticks by limiting to same-line and
|
|
704
|
+
// disallowing embedded backticks.
|
|
705
|
+
out = out.replace(/`[^`\r\n]+`/g, "");
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Issue #213 — compile the crawl denylist from options + defaults into
|
|
711
|
+
* an array of regexes once. Accepts exact strings and simple globs where
|
|
712
|
+
* `*` translates to `.*` (anchored).
|
|
713
|
+
*/
|
|
714
|
+
export function compileCrawlDenylist(
|
|
715
|
+
options: PrerenderCrawlOptions | undefined,
|
|
716
|
+
): RegExp[] {
|
|
717
|
+
const defaults = options?.replaceDefaultExclude
|
|
718
|
+
? []
|
|
719
|
+
: DEFAULT_CRAWL_DENYLIST;
|
|
720
|
+
const extras = options?.exclude ?? [];
|
|
721
|
+
const combined = Array.from(new Set([...defaults, ...extras]));
|
|
722
|
+
return combined.map((entry) => denylistEntryToRegex(entry));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function denylistEntryToRegex(entry: string): RegExp {
|
|
726
|
+
// Escape everything except `*`, then translate `*` → `.*`.
|
|
727
|
+
const escaped = entry.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
728
|
+
const pattern = escaped.replace(/\*/g, ".*");
|
|
729
|
+
return new RegExp(`^${pattern}$`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Issue #219 — resolve the effective asset extension set from options.
|
|
734
|
+
*
|
|
735
|
+
* Normalizes every entry to `.lowercase` with a leading dot (so users
|
|
736
|
+
* can write `"webp"` or `".WEBP"`), merges with
|
|
737
|
+
* {@link DEFAULT_ASSET_EXTENSIONS} unless `replaceDefaultAssetExtensions`
|
|
738
|
+
* is `true`, and returns a `Set<string>` for O(1) lookup in the crawl
|
|
739
|
+
* loop.
|
|
740
|
+
*
|
|
741
|
+
* Exported for test coverage.
|
|
742
|
+
*/
|
|
743
|
+
export function resolveAssetExtensions(
|
|
744
|
+
options: PrerenderCrawlOptions | undefined,
|
|
745
|
+
): Set<string> {
|
|
746
|
+
const defaults = options?.replaceDefaultAssetExtensions
|
|
747
|
+
? []
|
|
748
|
+
: DEFAULT_ASSET_EXTENSIONS;
|
|
749
|
+
const extras = options?.assetExtensions ?? [];
|
|
750
|
+
const out = new Set<string>();
|
|
751
|
+
for (const ext of [...defaults, ...extras]) {
|
|
752
|
+
out.add(normalizeAssetExtension(ext));
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function normalizeAssetExtension(ext: string): string {
|
|
758
|
+
const lower = ext.toLowerCase();
|
|
759
|
+
return lower.startsWith(".") ? lower : `.${lower}`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Issue #219 — does the given pathname end with a known asset
|
|
764
|
+
* extension? Extracts the basename's extension (case-insensitive) and
|
|
765
|
+
* tests it against the resolved set.
|
|
766
|
+
*
|
|
767
|
+
* `pathname` is the normalized crawl path (query + hash already
|
|
768
|
+
* stripped by {@link normalizeCrawlPath}) — we still defend in depth
|
|
769
|
+
* by splitting on `?` / `#` in case a caller passes a raw href.
|
|
770
|
+
*
|
|
771
|
+
* Exported for test coverage.
|
|
772
|
+
*/
|
|
773
|
+
export function isAssetPathname(
|
|
774
|
+
pathname: string,
|
|
775
|
+
assetExtensions: Set<string>,
|
|
776
|
+
): boolean {
|
|
777
|
+
if (assetExtensions.size === 0) return false;
|
|
778
|
+
const clean = pathname.split("?")[0].split("#")[0];
|
|
779
|
+
const lastSlash = clean.lastIndexOf("/");
|
|
780
|
+
const basename = lastSlash === -1 ? clean : clean.slice(lastSlash + 1);
|
|
781
|
+
const dot = basename.lastIndexOf(".");
|
|
782
|
+
if (dot === -1 || dot === 0) return false;
|
|
783
|
+
const ext = basename.slice(dot).toLowerCase();
|
|
784
|
+
return assetExtensions.has(ext);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Normalize a discovered pathname for de-duplication + matching.
|
|
789
|
+
* Lowercases (HTML href matching is case-insensitive) and strips a
|
|
790
|
+
* trailing slash except for the root.
|
|
791
|
+
*/
|
|
792
|
+
function normalizeCrawlPath(href: string): string {
|
|
793
|
+
const clean = href.split("?")[0].split("#")[0];
|
|
794
|
+
let norm = clean.toLowerCase();
|
|
795
|
+
if (norm.length > 1 && norm.endsWith("/")) {
|
|
796
|
+
norm = norm.slice(0, -1);
|
|
797
|
+
}
|
|
798
|
+
return norm;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Extract absolute internal `<a href>` paths (same-origin only).
|
|
803
|
+
*
|
|
804
|
+
* Issue #213 — strips HTML/MDX code regions before scanning so `href`
|
|
805
|
+
* attributes inside doc examples (e.g. `<pre><code><Link
|
|
806
|
+
* href="/example"></code></pre>` or fenced markdown) don't leak
|
|
807
|
+
* into the crawl queue. Also applies the configurable denylist so
|
|
808
|
+
* placeholder paths like `/path` or `/your-route` are filtered out.
|
|
809
|
+
*
|
|
810
|
+
* Issue #219 — filters out URLs whose pathname ends with a known
|
|
811
|
+
* non-HTML asset extension (`.webp`, `.avif`, `.pdf`, `.css`, …). This
|
|
812
|
+
* prevents the prerender engine from rendering `<img src>` / `<source
|
|
813
|
+
* srcset>` / `<a href="/whitepaper.pdf">` values as HTML and
|
|
814
|
+
* overwriting the real asset on disk. Pass a custom `Set` (e.g. built
|
|
815
|
+
* by {@link resolveAssetExtensions}) to extend or replace the default
|
|
816
|
+
* list; callers that want to disable the filter entirely may pass an
|
|
817
|
+
* empty `Set`.
|
|
818
|
+
*
|
|
819
|
+
* Ordering rationale: `stripCodeRegions` runs first so doc examples
|
|
820
|
+
* never reach the regex. The asset-extension filter runs AFTER the
|
|
821
|
+
* strip (so `<pre>` code doesn't contribute asset URLs) but BEFORE
|
|
822
|
+
* the denylist (Set.has is cheaper than an `Array.some` regex scan,
|
|
823
|
+
* and asset URLs are strictly orthogonal to placeholder denylist
|
|
824
|
+
* entries — see #213 vs #219).
|
|
825
|
+
*
|
|
826
|
+
* Exported for test coverage.
|
|
827
|
+
*/
|
|
828
|
+
export function extractInternalLinks(
|
|
829
|
+
html: string,
|
|
830
|
+
denylist: RegExp[] = [],
|
|
831
|
+
assetExtensions: Set<string> = resolveAssetExtensions(undefined),
|
|
832
|
+
): string[] {
|
|
833
|
+
const stripped = stripCodeRegions(html);
|
|
834
|
+
const links: string[] = [];
|
|
835
|
+
const hrefRegex = /href=["']([^"']+)["']/g;
|
|
836
|
+
let match: RegExpExecArray | null;
|
|
837
|
+
while ((match = hrefRegex.exec(stripped)) !== null) {
|
|
838
|
+
const href = match[1];
|
|
839
|
+
if (!href.startsWith("/") || href.startsWith("//")) continue;
|
|
840
|
+
const normalized = normalizeCrawlPath(href);
|
|
841
|
+
if (!normalized) continue;
|
|
842
|
+
// Issue #219 — asset URLs (`.webp`, `.pdf`, `.css`, …) never get
|
|
843
|
+
// prerendered. This supersedes the old hard-coded regex.
|
|
844
|
+
if (isAssetPathname(normalized, assetExtensions)) continue;
|
|
845
|
+
if (denylist.some((re) => re.test(normalized))) continue;
|
|
846
|
+
links.push(normalized);
|
|
847
|
+
}
|
|
848
|
+
return [...new Set(links)];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function toPosix(p: string): string {
|
|
852
|
+
return p.replace(/\\/g, "/");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function describeError(error: unknown): string {
|
|
856
|
+
if (error instanceof Error) return error.message;
|
|
857
|
+
return String(error);
|
|
858
|
+
}
|