@mandujs/core 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- // Module failed to load entirely. Silent skip the page may
178
- // simply not opt into static params; SSR can still serve it.
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
- // Not opted-in for this route perfectly fine.
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) errors.push(`[${route.pattern}] ${e}`);
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
- // User code threw. Surface the error but keep going — other
218
- // routes should not be blocked by one buggy generator.
219
- errors.push(
220
- `[${route.pattern}] generateStaticParams threw: ${describeError(error)}`
221
- );
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
- const links = extractInternalLinks(html);
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
- /** Extract absolute internal `<a href>` paths (same-origin only). */
423
- function extractInternalLinks(html: string): string[] {
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>&lt;Link
806
+ * href="/example"&gt;</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(html)) !== null) {
837
+ while ((match = hrefRegex.exec(stripped)) !== null) {
428
838
  const href = match[1];
429
- if (href.startsWith("/") && !href.startsWith("//")) {
430
- const cleanPath = href.split("?")[0].split("#")[0];
431
- if (!cleanPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) {
432
- links.push(cleanPath);
433
- }
434
- }
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
  }