@mandujs/core 0.33.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 +1 -1
- package/src/bundler/prerender.ts +432 -20
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +54 -0
- package/src/config/validate.ts +33 -0
- package/src/runtime/server.ts +233 -35
package/package.json
CHANGED
package/src/bundler/prerender.ts
CHANGED
|
@@ -32,6 +32,66 @@ import { runDefinePrerenderHook } from "../plugins/runner";
|
|
|
32
32
|
|
|
33
33
|
// ========== Types ==========
|
|
34
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
|
+
|
|
35
95
|
export interface PrerenderOptions {
|
|
36
96
|
/** Project root — all relative paths resolve from here. */
|
|
37
97
|
rootDir: string;
|
|
@@ -46,6 +106,12 @@ export interface PrerenderOptions {
|
|
|
46
106
|
routes?: string[];
|
|
47
107
|
/** Follow internal `<a href>` links in rendered HTML (default: false). */
|
|
48
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;
|
|
49
115
|
/**
|
|
50
116
|
* When true, also write `<outDir>/_manifest.json` listing every
|
|
51
117
|
* prerendered pathname. The runtime uses this index to short-circuit
|
|
@@ -67,6 +133,18 @@ export interface PrerenderOptions {
|
|
|
67
133
|
*/
|
|
68
134
|
plugins?: readonly ManduPlugin[];
|
|
69
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;
|
|
70
148
|
}
|
|
71
149
|
|
|
72
150
|
export interface PrerenderResult {
|
|
@@ -107,6 +185,108 @@ export const LEGACY_PRERENDER_DIR = ".mandu/static";
|
|
|
107
185
|
export const DEFAULT_PRERENDER_CACHE_CONTROL =
|
|
108
186
|
"public, max-age=31536000, immutable";
|
|
109
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
|
+
|
|
110
290
|
// ========== Implementation ==========
|
|
111
291
|
|
|
112
292
|
/**
|
|
@@ -131,8 +311,10 @@ export async function prerenderRoutes(
|
|
|
131
311
|
rootDir,
|
|
132
312
|
outDir = LEGACY_PRERENDER_DIR,
|
|
133
313
|
crawl = false,
|
|
314
|
+
crawlOptions,
|
|
134
315
|
writeIndex = false,
|
|
135
316
|
importModule,
|
|
317
|
+
skipErrors = false,
|
|
136
318
|
} = options;
|
|
137
319
|
|
|
138
320
|
// Phase 18.τ — resolve plugin hook bundle once so the hot render loop
|
|
@@ -150,9 +332,23 @@ export async function prerenderRoutes(
|
|
|
150
332
|
|
|
151
333
|
const pages: PrerenderPageResult[] = [];
|
|
152
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[] = [];
|
|
153
341
|
const renderedPaths = new Set<string>();
|
|
154
342
|
const pageIndex: Record<string, string> = {};
|
|
155
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
|
+
|
|
156
352
|
// 1. Explicit user-supplied routes.
|
|
157
353
|
const pathsToRender = new Set<string>(options.routes ?? []);
|
|
158
354
|
|
|
@@ -170,12 +366,34 @@ export async function prerenderRoutes(
|
|
|
170
366
|
for (const route of manifest.routes) {
|
|
171
367
|
if (route.kind !== "page" || !isDynamicPattern(route.pattern)) continue;
|
|
172
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 ─────────────────────────────────────────────────────
|
|
173
385
|
let mod: PageModuleWithStaticParams;
|
|
174
386
|
try {
|
|
175
387
|
mod = await loadPageModule(rootDir, route, resolveModule);
|
|
176
|
-
} catch {
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
});
|
|
179
397
|
continue;
|
|
180
398
|
}
|
|
181
399
|
|
|
@@ -190,7 +408,9 @@ export async function prerenderRoutes(
|
|
|
190
408
|
// ─── End Issue #214 ─────────────────────────────────────────────────────
|
|
191
409
|
|
|
192
410
|
if (typeof mod.generateStaticParams !== "function") {
|
|
193
|
-
//
|
|
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.
|
|
194
414
|
continue;
|
|
195
415
|
}
|
|
196
416
|
|
|
@@ -201,7 +421,19 @@ export async function prerenderRoutes(
|
|
|
201
421
|
paramSets,
|
|
202
422
|
} = await collectStaticPaths(route.pattern, mod);
|
|
203
423
|
for (const p of paths) pathsToRender.add(p);
|
|
204
|
-
for (const e of paramErrors)
|
|
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
|
+
}
|
|
205
437
|
|
|
206
438
|
// ─── Issue #214 ───────────────────────────────────────────────────────
|
|
207
439
|
// Persist the resolved param sets on the spec. The runtime #214 guard
|
|
@@ -214,11 +446,17 @@ export async function prerenderRoutes(
|
|
|
214
446
|
}
|
|
215
447
|
// ─── End Issue #214 ───────────────────────────────────────────────────
|
|
216
448
|
} catch (error) {
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
);
|
|
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
|
+
});
|
|
222
460
|
}
|
|
223
461
|
}
|
|
224
462
|
|
|
@@ -283,7 +521,11 @@ export async function prerenderRoutes(
|
|
|
283
521
|
|
|
284
522
|
// 5. Optional crawl — harvest internal links for next pass.
|
|
285
523
|
if (crawl) {
|
|
286
|
-
|
|
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);
|
|
287
529
|
for (const link of links) {
|
|
288
530
|
if (!renderedPaths.has(link) && !pathsToRender.has(link)) {
|
|
289
531
|
pathsToRender.add(link);
|
|
@@ -309,6 +551,13 @@ export async function prerenderRoutes(
|
|
|
309
551
|
);
|
|
310
552
|
}
|
|
311
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
|
+
|
|
312
561
|
return {
|
|
313
562
|
generated: pages.length,
|
|
314
563
|
pages,
|
|
@@ -419,19 +668,182 @@ function getOutputPath(outDir: string, pathname: string): string {
|
|
|
419
668
|
return path.join(outDir, decoded, "index.html");
|
|
420
669
|
}
|
|
421
670
|
|
|
422
|
-
/**
|
|
423
|
-
|
|
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);
|
|
424
834
|
const links: string[] = [];
|
|
425
835
|
const hrefRegex = /href=["']([^"']+)["']/g;
|
|
426
836
|
let match: RegExpExecArray | null;
|
|
427
|
-
while ((match = hrefRegex.exec(
|
|
837
|
+
while ((match = hrefRegex.exec(stripped)) !== null) {
|
|
428
838
|
const href = match[1];
|
|
429
|
-
if (href.startsWith("/")
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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);
|
|
435
847
|
}
|
|
436
848
|
return [...new Set(links)];
|
|
437
849
|
}
|
|
@@ -1,82 +1,92 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Issue #208 — Minimal inline SPA-navigation helper.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* is
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Issue #208 — Minimal inline SPA-navigation helper.
|
|
3
|
+
* Issue #220 — body-swap observability + fallback + script re-execution.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained IIFE injected into the SSR `<head>` that upgrades plain
|
|
6
|
+
* full-page navigations into client-side `history.pushState` +
|
|
7
|
+
* `fetch` + DOM-swap transitions, without loading any JS bundle.
|
|
8
|
+
*
|
|
9
|
+
* Motivating use case: docs / blog / marketing sites that build with
|
|
10
|
+
* `hydration: "none"` (no islands). Under Issue #193 the opt-out SPA
|
|
11
|
+
* router lives in `@mandujs/core/client` (`router.ts`), which only ships
|
|
12
|
+
* inside a hydration bundle. Zero-JS pages therefore lost the "feels
|
|
13
|
+
* like a SPA" behavior that `spa: true` (the framework default) promises.
|
|
14
|
+
*
|
|
15
|
+
* This helper fills the gap: ~2.8 KB of inline JavaScript that the
|
|
16
|
+
* browser parses and runs immediately, no module graph, no network
|
|
17
|
+
* round-trip. Paired with the `@view-transition { navigation: auto }`
|
|
18
|
+
* style block (#192) the result is a visually-animated pushState
|
|
19
|
+
* navigation on every internal link click.
|
|
20
|
+
*
|
|
21
|
+
* Design constraints (locked — changing any of these needs an explicit
|
|
22
|
+
* rationale in the PR):
|
|
23
|
+
*
|
|
24
|
+
* 1. **Exclusion parity with the full router** (`router.ts`
|
|
25
|
+
* `handleLinkClick`): every browser-owned escape hatch — modifier
|
|
26
|
+
* keys, non-left click, `target` other than `_self`, `download`,
|
|
27
|
+
* `mailto:` / `tel:` / `javascript:` / …, cross-origin, hash-only,
|
|
28
|
+
* no `href`, `data-no-spa`, and `event.defaultPrevented` — is
|
|
29
|
+
* checked here too. Regression matrix lives at
|
|
30
|
+
* `tests/client/spa-nav-helper-exclusions.test.ts`.
|
|
31
|
+
*
|
|
32
|
+
* 2. **Co-existence with the full router**: both handlers listen
|
|
33
|
+
* on `document` `click`. The helper bails out early when
|
|
34
|
+
* `window.__MANDU_ROUTER_STATE__` is present — that global is
|
|
35
|
+
* installed by `initializeRouter()` before it calls
|
|
36
|
+
* `addEventListener`, so on hydrated pages the full router wins.
|
|
37
|
+
* On pure-SSR pages the state global is missing and the helper
|
|
38
|
+
* is authoritative.
|
|
39
|
+
*
|
|
40
|
+
* 3. **View Transitions API** — we call
|
|
41
|
+
* `document.startViewTransition(cb)` when available, mirroring the
|
|
42
|
+
* `@view-transition` at-rule we already inject. Browsers without
|
|
43
|
+
* the API (Firefox, Safari < 18.2) execute the callback
|
|
44
|
+
* synchronously so the feature is a pure progressive enhancement.
|
|
45
|
+
*
|
|
46
|
+
* 4. **DOM swap strategy** (issue #220 rework):
|
|
47
|
+
* - Prefer `<main>` → `<#root>` → whole `<body>` (in that order).
|
|
48
|
+
* We log which container matched via `console.debug`.
|
|
49
|
+
* - Scripts inside the swapped region are extracted and
|
|
50
|
+
* re-executed via `document.createElement("script")` so
|
|
51
|
+
* island bootstraps / inline user scripts still fire.
|
|
52
|
+
* - Head `<title>` + selective meta tags are merged from the
|
|
53
|
+
* incoming document.
|
|
54
|
+
*
|
|
55
|
+
* 5. **Observability + fallback** (issue #220): every failure path
|
|
56
|
+
* (fetch !ok, DOMParser unavailable, parser error, no container
|
|
57
|
+
* matched, exception inside swap, exception inside
|
|
58
|
+
* startViewTransition) logs a `console.warn("[mandu-spa-nav] …")`
|
|
59
|
+
* and performs a hard navigation (`location.href = url`) so the
|
|
60
|
+
* user always sees fresh content. No silent stuck-URL state.
|
|
61
|
+
*
|
|
62
|
+
* 6. **Hydration marker** (issue #220): after a successful swap we
|
|
63
|
+
* dispatch `__MANDU_SPA_NAV__` on `window` with
|
|
64
|
+
* `detail: { url, durationMs, container }` so islands and
|
|
65
|
+
* integrations can re-hydrate if needed.
|
|
66
|
+
*
|
|
67
|
+
* 7. **Inline, not external**: same rationale as #192's prefetch
|
|
68
|
+
* helper — inline removes the extra round-trip on every SSR
|
|
69
|
+
* response, keeps the CSP posture simple, and sidesteps the
|
|
70
|
+
* "zero-JS but loads one JS file anyway" awkwardness.
|
|
71
|
+
*
|
|
72
|
+
* 8. **Opt-out via `ssr.spa: false`**: the injection site
|
|
73
|
+
* (`ssr.ts::renderToHTML`, `streaming-ssr.ts::generateHTMLShell`)
|
|
74
|
+
* omits the `<script>` block entirely when the user's config sets
|
|
75
|
+
* `spa: false`. No runtime check needed inside the IIFE.
|
|
76
|
+
*
|
|
77
|
+
* Size target: ≤3 KB gzipped (currently ≈2.8 KB raw, ≈1.4 KB gz). If
|
|
78
|
+
* this grows past 3 KB gz we should revisit the inline-vs-external
|
|
79
|
+
* trade-off.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inner IIFE — exposed for unit tests that want to parse the source.
|
|
84
|
+
*
|
|
85
|
+
* Byte-minified on purpose (no comments, short names). The high-level
|
|
86
|
+
* flow is documented in this file's JSDoc; anyone editing this string
|
|
87
|
+
* MUST update the exclusion-matrix test and the body-swap test to match.
|
|
88
|
+
*/
|
|
89
|
+
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h||h.charAt(0)==="#")return null;var u;try{u=new URL(h,L.origin);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);try{window.scrollTo(0,0);}catch(_){}var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
|
|
90
|
+
|
|
91
|
+
/** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
|
|
92
|
+
export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
|
package/src/config/mandu.ts
CHANGED
|
@@ -197,6 +197,60 @@ export interface ManduConfig {
|
|
|
197
197
|
* writes JSON only (useful for CI, skips the HTML render cost).
|
|
198
198
|
*/
|
|
199
199
|
analyze?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* Issue #213 — tune the prerender link-crawler.
|
|
202
|
+
*
|
|
203
|
+
* The crawler (enabled by `mandu build` when `build.prerender !== false`)
|
|
204
|
+
* scans rendered HTML for `<a href="/...">` and enqueues those paths
|
|
205
|
+
* for prerendering. Doc sites that ship code examples (`<pre><code>
|
|
206
|
+
* <Link href="/path" /></code></pre>`) previously leaked those
|
|
207
|
+
* illustrative URLs into the render queue, producing spurious
|
|
208
|
+
* `.mandu/static/path/index.html` files.
|
|
209
|
+
*
|
|
210
|
+
* The engine strips `<pre>`, `<code>`, fenced markdown, and inline
|
|
211
|
+
* code spans before scanning. It also applies a small default
|
|
212
|
+
* denylist of placeholder paths (`/path`, `/example`, `/your-*`,
|
|
213
|
+
* etc.). Use this block to extend or replace the denylist for your
|
|
214
|
+
* project.
|
|
215
|
+
*/
|
|
216
|
+
crawl?: {
|
|
217
|
+
/**
|
|
218
|
+
* Extra pathnames or simple globs (`*`) to exclude when crawling.
|
|
219
|
+
* Merged with the built-in default denylist unless
|
|
220
|
+
* {@link replaceDefaultExclude} is `true`.
|
|
221
|
+
*/
|
|
222
|
+
exclude?: string[];
|
|
223
|
+
/**
|
|
224
|
+
* When `true`, `exclude` replaces the built-in denylist entirely.
|
|
225
|
+
* Default `false`.
|
|
226
|
+
*/
|
|
227
|
+
replaceDefaultExclude?: boolean;
|
|
228
|
+
/**
|
|
229
|
+
* Issue #219 — file extensions treated as non-HTML assets. When
|
|
230
|
+
* a discovered href's pathname ends with one of these, the
|
|
231
|
+
* crawler skips it instead of enqueuing it for prerender.
|
|
232
|
+
*
|
|
233
|
+
* Example: `<picture><source srcset="/hero.avif"><img
|
|
234
|
+
* src="/hero.webp"></picture>` used to make the engine render
|
|
235
|
+
* the asset URL as HTML and overwrite the real `.webp` on disk.
|
|
236
|
+
* The default set covers common image / font / document /
|
|
237
|
+
* media / text-asset extensions.
|
|
238
|
+
*
|
|
239
|
+
* Entries may be written with or without a leading dot
|
|
240
|
+
* (`"webp"` and `".webp"` are equivalent). Matching is
|
|
241
|
+
* case-insensitive; query strings and hash fragments are
|
|
242
|
+
* stripped before comparison.
|
|
243
|
+
*
|
|
244
|
+
* Merged with the built-in default set unless
|
|
245
|
+
* {@link replaceDefaultAssetExtensions} is `true`.
|
|
246
|
+
*/
|
|
247
|
+
assetExtensions?: string[];
|
|
248
|
+
/**
|
|
249
|
+
* When `true`, `assetExtensions` replaces the built-in set
|
|
250
|
+
* entirely. Default `false`.
|
|
251
|
+
*/
|
|
252
|
+
replaceDefaultAssetExtensions?: boolean;
|
|
253
|
+
};
|
|
200
254
|
};
|
|
201
255
|
dev?: {
|
|
202
256
|
hmr?: boolean;
|
package/src/config/validate.ts
CHANGED
|
@@ -119,6 +119,33 @@ const GuardConfigSchema = z
|
|
|
119
119
|
})
|
|
120
120
|
.strict();
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Issue #213 — prerender link-crawler sub-block (strict).
|
|
124
|
+
*
|
|
125
|
+
* `exclude` entries are matched against the normalized crawl target:
|
|
126
|
+
* - Exact string (`"/example"`): matches that pathname only.
|
|
127
|
+
* - Simple glob (`"/your-*"`): `*` → `.*`, anchored start + end.
|
|
128
|
+
*
|
|
129
|
+
* When `replaceDefaultExclude === true`, `exclude` replaces the built-in
|
|
130
|
+
* default denylist entirely. Otherwise (default), user entries are
|
|
131
|
+
* merged on top of the defaults.
|
|
132
|
+
*
|
|
133
|
+
* Issue #219 — `assetExtensions` filters out non-HTML asset URLs
|
|
134
|
+
* (`.webp`, `.pdf`, `.css`, …) from the crawl queue so the engine
|
|
135
|
+
* doesn't overwrite real assets with rendered HTML. Entries may be
|
|
136
|
+
* written with or without a leading dot; matching is case-insensitive.
|
|
137
|
+
* Merges with the built-in defaults unless
|
|
138
|
+
* `replaceDefaultAssetExtensions === true`.
|
|
139
|
+
*/
|
|
140
|
+
const BuildCrawlConfigSchema = z
|
|
141
|
+
.object({
|
|
142
|
+
exclude: z.array(z.string().min(1)).default([]),
|
|
143
|
+
replaceDefaultExclude: z.boolean().default(false),
|
|
144
|
+
assetExtensions: z.array(z.string().min(1)).default([]),
|
|
145
|
+
replaceDefaultAssetExtensions: z.boolean().default(false),
|
|
146
|
+
})
|
|
147
|
+
.strict();
|
|
148
|
+
|
|
122
149
|
/**
|
|
123
150
|
* Build 설정 스키마 (strict)
|
|
124
151
|
*/
|
|
@@ -141,6 +168,12 @@ const BuildConfigSchema = z
|
|
|
141
168
|
* for this project" switch.
|
|
142
169
|
*/
|
|
143
170
|
analyze: z.boolean().default(false),
|
|
171
|
+
/**
|
|
172
|
+
* Issue #213 — prerender link-crawler denylist. See
|
|
173
|
+
* {@link BuildCrawlConfigSchema}. Omit the block to trust the
|
|
174
|
+
* default (strip code regions + built-in placeholder denylist).
|
|
175
|
+
*/
|
|
176
|
+
crawl: BuildCrawlConfigSchema.optional(),
|
|
144
177
|
})
|
|
145
178
|
.strict();
|
|
146
179
|
|
package/src/runtime/server.ts
CHANGED
|
@@ -597,6 +597,25 @@ export interface ServerOptions {
|
|
|
597
597
|
* use `ctx.locale.code` and ignore `ctx.t`.
|
|
598
598
|
*/
|
|
599
599
|
messages?: MessageRegistry;
|
|
600
|
+
/**
|
|
601
|
+
* Issue #217 — suppress the "🥟 Mandu server listening"/"🥟 Mandu Dev
|
|
602
|
+
* Server listening" banner printed at boot. The HTTP listener still
|
|
603
|
+
* binds and `ManduServer.server.port` still reports the chosen port;
|
|
604
|
+
* only the stdout banner (plus its auxiliary lines — HMR hint, CORS
|
|
605
|
+
* hint, static-file hint, streaming hint, Kitchen hint, "also
|
|
606
|
+
* reachable at" hint) is gated.
|
|
607
|
+
*
|
|
608
|
+
* Intended for internal callers such as the build-time prerender
|
|
609
|
+
* orchestrator that spin up a transient listener on an ephemeral
|
|
610
|
+
* port (`port: 0`) and tear it down seconds later — the banner's
|
|
611
|
+
* URL is always wrong by the time a human (or an LLM reading build
|
|
612
|
+
* logs) tries to curl it, so printing it causes confusion.
|
|
613
|
+
*
|
|
614
|
+
* User-facing commands (`mandu dev`, `mandu start`) leave this
|
|
615
|
+
* `undefined` / `false` to preserve the normal banner. Default:
|
|
616
|
+
* `false`.
|
|
617
|
+
*/
|
|
618
|
+
silent?: boolean;
|
|
600
619
|
}
|
|
601
620
|
|
|
602
621
|
export interface ManduServer {
|
|
@@ -1325,6 +1344,155 @@ function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
|
|
|
1325
1344
|
return new Response(body, { status });
|
|
1326
1345
|
}
|
|
1327
1346
|
|
|
1347
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1348
|
+
// Static asset cache policy — Issue #218
|
|
1349
|
+
//
|
|
1350
|
+
// The `immutable` Cache-Control directive is a *contract* with browsers:
|
|
1351
|
+
// "the bytes at this URL will never change." Violating it (by overwriting a
|
|
1352
|
+
// stable-name file between builds) means users keep stale CSS/JS until they
|
|
1353
|
+
// hard-refresh. Mandu historically emitted `/.mandu/client/globals.css`
|
|
1354
|
+
// and `/.mandu/client/runtime*.js` with fixed URLs but stamped the response
|
|
1355
|
+
// with `immutable`, which is the exact failure mode.
|
|
1356
|
+
//
|
|
1357
|
+
// Policy:
|
|
1358
|
+
// - Hashed URL (e.g. `.../chunk.a1b2c3d4.js`) → `immutable` is safe,
|
|
1359
|
+
// 1-year max-age.
|
|
1360
|
+
// - Stable URL (no hash in filename) → `max-age=0, must-revalidate`.
|
|
1361
|
+
// The client revalidates on every request; a matching `If-None-Match`
|
|
1362
|
+
// short-circuits to 304 with no body, so the cost is one HEAD-sized
|
|
1363
|
+
// round-trip, not a full re-download.
|
|
1364
|
+
//
|
|
1365
|
+
// Strong ETag (content-hash) is emitted for every `/.mandu/client/*`
|
|
1366
|
+
// response so conditional GETs are cheap. We use `Bun.hash` (wyhash, ~5GB/s)
|
|
1367
|
+
// for the digest and cache results keyed by `path + size + mtime` to avoid
|
|
1368
|
+
// re-hashing on every hit.
|
|
1369
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Heuristic: does the filename look like it carries a content hash?
|
|
1373
|
+
*
|
|
1374
|
+
* Matches:
|
|
1375
|
+
* - `name.<hash>.ext` where hash is >=8 hex chars (e.g. `chunk.a1b2c3d4.js`)
|
|
1376
|
+
* - `name-<hash>.ext` (e.g. `vendor-8f3a2b9c.js`)
|
|
1377
|
+
* - `name.<hash>.chunk.ext` common bundler shape
|
|
1378
|
+
*
|
|
1379
|
+
* A hash segment is 8+ lowercase hex chars. Longer digests (16, 20, 32) also
|
|
1380
|
+
* match. We deliberately avoid matching ALL-hex short names like `abc.js`
|
|
1381
|
+
* (requires min length 8).
|
|
1382
|
+
*/
|
|
1383
|
+
function hasContentHashInFilename(filename: string): boolean {
|
|
1384
|
+
// `.` or `-` separator, 8+ hex chars, then `.` before extension
|
|
1385
|
+
// Examples that match: chunk.a1b2c3d4.js, vendor-8f3a2b9c.js, app.1234567890abcdef.css
|
|
1386
|
+
// Examples that DON'T match: globals.css, runtime.js, chunk.js
|
|
1387
|
+
return /[.\-][a-f0-9]{8,}\.[a-z0-9]+$/i.test(filename);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Compute Cache-Control for a static asset.
|
|
1392
|
+
*
|
|
1393
|
+
* - Dev: no caching (always refetch).
|
|
1394
|
+
* - Prod, hashed filename: `public, max-age=31536000, immutable` (1 year).
|
|
1395
|
+
* - Prod, stable filename: `public, max-age=0, must-revalidate` (force
|
|
1396
|
+
* revalidation; 304 via `If-None-Match` keeps it cheap).
|
|
1397
|
+
*/
|
|
1398
|
+
function computeStaticCacheControl(filename: string, isDev: boolean): string {
|
|
1399
|
+
if (isDev) return "no-cache, no-store, must-revalidate";
|
|
1400
|
+
if (hasContentHashInFilename(filename)) {
|
|
1401
|
+
return "public, max-age=31536000, immutable";
|
|
1402
|
+
}
|
|
1403
|
+
return "public, max-age=0, must-revalidate";
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* In-process ETag cache keyed by absolute filePath. Entry is invalidated
|
|
1408
|
+
* when `size` or `mtime` changes. Avoids re-hashing hot files on every
|
|
1409
|
+
* request. The hot path is a single lookup + two scalar compares.
|
|
1410
|
+
*/
|
|
1411
|
+
interface EtagCacheEntry {
|
|
1412
|
+
size: number;
|
|
1413
|
+
mtime: number;
|
|
1414
|
+
etag: string;
|
|
1415
|
+
}
|
|
1416
|
+
const etagCache = new Map<string, EtagCacheEntry>();
|
|
1417
|
+
const ETAG_CACHE_MAX = 2048;
|
|
1418
|
+
|
|
1419
|
+
/** Cheap LRU eviction: drop oldest insert when over cap. */
|
|
1420
|
+
function evictEtagCacheIfNeeded(): void {
|
|
1421
|
+
if (etagCache.size <= ETAG_CACHE_MAX) return;
|
|
1422
|
+
const oldestKey = etagCache.keys().next().value;
|
|
1423
|
+
if (oldestKey !== undefined) etagCache.delete(oldestKey);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Compute a strong ETag from file bytes (Bun.hash / wyhash). Cached by
|
|
1428
|
+
* `path + size + mtime` so we only re-hash when the file changes.
|
|
1429
|
+
*
|
|
1430
|
+
* Strong (not weak `W/`) because we actually hashed the payload — this
|
|
1431
|
+
* preserves byte-range / delta semantics per RFC 7232.
|
|
1432
|
+
*/
|
|
1433
|
+
async function computeStrongEtag(
|
|
1434
|
+
filePath: string,
|
|
1435
|
+
file: import("bun").BunFile,
|
|
1436
|
+
): Promise<string> {
|
|
1437
|
+
const size = file.size;
|
|
1438
|
+
const mtime = file.lastModified;
|
|
1439
|
+
|
|
1440
|
+
const cached = etagCache.get(filePath);
|
|
1441
|
+
if (cached && cached.size === size && cached.mtime === mtime) {
|
|
1442
|
+
return cached.etag;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Bun.hash returns a number/bigint; stringify in base36 for compact ETag.
|
|
1446
|
+
// Fall back to size+mtime-derived ETag if hashing ever throws (edge-runtime
|
|
1447
|
+
// polyfills etc. — `Bun.hash` is a Bun-native primitive).
|
|
1448
|
+
let digest: string;
|
|
1449
|
+
try {
|
|
1450
|
+
const bytes = await file.arrayBuffer();
|
|
1451
|
+
const h = Bun.hash(bytes);
|
|
1452
|
+
digest = typeof h === "bigint" ? h.toString(36) : Number(h).toString(36);
|
|
1453
|
+
} catch {
|
|
1454
|
+
digest = `${size.toString(36)}-${mtime.toString(36)}`;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const etag = `"${digest}"`;
|
|
1458
|
+
etagCache.set(filePath, { size, mtime, etag });
|
|
1459
|
+
evictEtagCacheIfNeeded();
|
|
1460
|
+
return etag;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** Exposed for tests — allows clearing the ETag cache between cases. */
|
|
1464
|
+
export function __clearStaticEtagCacheForTests(): void {
|
|
1465
|
+
etagCache.clear();
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* RFC 7232 §3.2 — `If-None-Match` comparison.
|
|
1470
|
+
*
|
|
1471
|
+
* Accepts:
|
|
1472
|
+
* - `*` wildcard (matches any current representation)
|
|
1473
|
+
* - a single ETag (`"abc"` or `W/"abc"`)
|
|
1474
|
+
* - a comma-separated list
|
|
1475
|
+
*
|
|
1476
|
+
* Uses weak-comparison semantics (strip leading `W/`) because that is the
|
|
1477
|
+
* RFC-prescribed form for `If-None-Match`; a strong server-side ETag still
|
|
1478
|
+
* matches a weak client token if the opaque-string portion is equal.
|
|
1479
|
+
*/
|
|
1480
|
+
function matchesEtag(ifNoneMatch: string, currentEtag: string): boolean {
|
|
1481
|
+
const trimmed = ifNoneMatch.trim();
|
|
1482
|
+
if (trimmed === "*") return true;
|
|
1483
|
+
|
|
1484
|
+
const normalize = (tag: string): string => {
|
|
1485
|
+
const t = tag.trim();
|
|
1486
|
+
return t.startsWith("W/") ? t.slice(2) : t;
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const currentNormalized = normalize(currentEtag);
|
|
1490
|
+
for (const part of trimmed.split(",")) {
|
|
1491
|
+
if (normalize(part) === currentNormalized) return true;
|
|
1492
|
+
}
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1328
1496
|
/**
|
|
1329
1497
|
* 경로가 허용된 디렉토리 내에 있는지 검증
|
|
1330
1498
|
* Path traversal 공격 방지
|
|
@@ -1441,26 +1609,39 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
1441
1609
|
}
|
|
1442
1610
|
|
|
1443
1611
|
const mimeType = getMimeType(filePath);
|
|
1612
|
+
const filename = path.basename(filePath);
|
|
1444
1613
|
|
|
1445
|
-
// Cache-Control
|
|
1614
|
+
// Cache-Control — Issue #218: `immutable` is only safe when the URL
|
|
1615
|
+
// contains a content hash. Stable-name bundles (globals.css, runtime.js)
|
|
1616
|
+
// must use `max-age=0, must-revalidate` or clients will serve stale
|
|
1617
|
+
// bytes until a hard refresh.
|
|
1618
|
+
//
|
|
1619
|
+
// Bundle files go through the hash-aware policy; non-bundle assets
|
|
1620
|
+
// (public/*, favicon, etc.) keep the conservative 1-day cache they had
|
|
1621
|
+
// before — they're user-controlled and unlikely to change per deploy.
|
|
1446
1622
|
let cacheControl: string;
|
|
1447
1623
|
if (settings.isDev) {
|
|
1448
|
-
// 개발 모드: 캐시 없음
|
|
1449
1624
|
cacheControl = "no-cache, no-store, must-revalidate";
|
|
1450
1625
|
} else if (isBundleFile) {
|
|
1451
|
-
|
|
1452
|
-
cacheControl = "public, max-age=31536000, immutable";
|
|
1626
|
+
cacheControl = computeStaticCacheControl(filename, /* isDev */ false);
|
|
1453
1627
|
} else {
|
|
1454
|
-
// 프로덕션 일반 정적 파일: 1일 캐시
|
|
1455
1628
|
cacheControl = "public, max-age=86400";
|
|
1456
1629
|
}
|
|
1457
1630
|
|
|
1458
|
-
// ETag
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1631
|
+
// Strong ETag from content hash for bundle files — enables cheap 304
|
|
1632
|
+
// round-trips when the client revalidates (`If-None-Match`).
|
|
1633
|
+
// Non-bundle static files keep a weak size+mtime validator (same as
|
|
1634
|
+
// pre-#218 behaviour) — we don't pay the hash cost for user-owned
|
|
1635
|
+
// `public/*` content the framework doesn't control.
|
|
1636
|
+
const etag = isBundleFile
|
|
1637
|
+
? await computeStrongEtag(filePath, file)
|
|
1638
|
+
: `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
|
|
1639
|
+
|
|
1640
|
+
// 304 Not Modified — unnecessary transfer avoidance. We compare the
|
|
1641
|
+
// full `If-None-Match` string; RFC 7232 also allows a comma-separated
|
|
1642
|
+
// list and `*`, so handle those two forms explicitly.
|
|
1462
1643
|
const ifNoneMatch = request?.headers.get("If-None-Match");
|
|
1463
|
-
if (ifNoneMatch
|
|
1644
|
+
if (ifNoneMatch && matchesEtag(ifNoneMatch, etag)) {
|
|
1464
1645
|
return {
|
|
1465
1646
|
handled: true,
|
|
1466
1647
|
response: new Response(null, {
|
|
@@ -3979,6 +4160,11 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3979
4160
|
messages: messagesOption,
|
|
3980
4161
|
plugins: pluginsOption,
|
|
3981
4162
|
configHooks: configHooksOption,
|
|
4163
|
+
// #217 — internal flag: suppress the "listening" banner. Threaded
|
|
4164
|
+
// through by `mandu build`'s transient prerender server so that
|
|
4165
|
+
// ephemeral `port: 0` listeners don't confuse humans/LLMs with a
|
|
4166
|
+
// URL that's already torn down by the time they curl it.
|
|
4167
|
+
silent = false,
|
|
3982
4168
|
} = options;
|
|
3983
4169
|
|
|
3984
4170
|
// Phase 18.μ — validate i18n + messages shape. Both are branded via
|
|
@@ -4262,31 +4448,43 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
4262
4448
|
|
|
4263
4449
|
const addresses = formatServerAddresses(hostname, actualPort);
|
|
4264
4450
|
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4451
|
+
// ─── #217 — gate the boot banner on `!silent` ─────────────────────────
|
|
4452
|
+
// `silent: true` is passed by internal callers that spawn a transient
|
|
4453
|
+
// listener on an ephemeral port (e.g. the build-time prerender
|
|
4454
|
+
// orchestrator). The HTTP listener still binds; only the stdout banner
|
|
4455
|
+
// — "🥟 Mandu server listening" / "🥟 Mandu Dev Server listening" plus
|
|
4456
|
+
// its auxiliary lines (additional addresses, HMR, static-file hint,
|
|
4457
|
+
// CORS, streaming, Kitchen) — is suppressed. User-facing commands
|
|
4458
|
+
// (`mandu dev`, `mandu start`) leave `silent` undefined/false and
|
|
4459
|
+
// therefore see the banner unchanged.
|
|
4460
|
+
// ─── End #217 ─────────────────────────────────────────────────────────
|
|
4461
|
+
if (!silent) {
|
|
4462
|
+
if (isDev) {
|
|
4463
|
+
console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
|
|
4464
|
+
if (addresses.additional.length > 0) {
|
|
4465
|
+
console.log(` (also reachable at ${addresses.additional.join(", ")})`);
|
|
4466
|
+
}
|
|
4467
|
+
if (registry.settings.hmrPort) {
|
|
4468
|
+
console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
|
|
4469
|
+
}
|
|
4470
|
+
console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
|
|
4471
|
+
if (corsOptions) {
|
|
4472
|
+
console.log(`🌐 CORS enabled`);
|
|
4473
|
+
}
|
|
4474
|
+
if (streaming) {
|
|
4475
|
+
console.log(`🌊 Streaming SSR enabled`);
|
|
4476
|
+
}
|
|
4477
|
+
if (registry.kitchen) {
|
|
4478
|
+
console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
|
|
4479
|
+
}
|
|
4480
|
+
} else {
|
|
4481
|
+
console.log(`🥟 Mandu server listening at ${addresses.primary}`);
|
|
4482
|
+
if (addresses.additional.length > 0) {
|
|
4483
|
+
console.log(` (also reachable at ${addresses.additional.join(", ")})`);
|
|
4484
|
+
}
|
|
4485
|
+
if (streaming) {
|
|
4486
|
+
console.log(`🌊 Streaming SSR enabled`);
|
|
4487
|
+
}
|
|
4290
4488
|
}
|
|
4291
4489
|
}
|
|
4292
4490
|
|