@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.
@@ -1,82 +1,92 @@
1
- /**
2
- * Issue #208 — Minimal inline SPA-navigation helper.
3
- *
4
- * Self-contained IIFE injected into the SSR `<head>` that upgrades plain
5
- * full-page navigations into client-side `history.pushState` +
6
- * `fetch` + DOM-swap transitions, without loading any JS bundle.
7
- *
8
- * Motivating use case: docs / blog / marketing sites that build with
9
- * `hydration: "none"` (no islands). Under Issue #193 the opt-out SPA
10
- * router lives in `@mandujs/core/client` (`router.ts`), which only ships
11
- * inside a hydration bundle. Zero-JS pages therefore lost the "feels
12
- * like a SPA" behavior that `spa: true` (the framework default) promises.
13
- *
14
- * This helper fills the gap: ~1.6 KB of inline JavaScript that the
15
- * browser parses and runs immediately, no module graph, no network
16
- * round-trip. Paired with the `@view-transition { navigation: auto }`
17
- * style block (#192) the result is a visually-animated pushState
18
- * navigation on every internal link click.
19
- *
20
- * Design constraints (locked — changing any of these needs an explicit
21
- * rationale in the PR):
22
- *
23
- * 1. **Exclusion parity with the full router** (`router.ts`
24
- * `handleLinkClick`): every browser-owned escape hatch modifier
25
- * keys, non-left click, `target` other than `_self`, `download`,
26
- * `mailto:` / `tel:` / `javascript:` / …, cross-origin, hash-only,
27
- * no `href`, `data-no-spa`, and `event.defaultPrevented` is
28
- * checked here too. Regression matrix lives at
29
- * `tests/client/spa-nav-helper-exclusions.test.ts`.
30
- *
31
- * 2. **Co-existence with the full router**: both handlers listen
32
- * on `document` `click`. The helper bails out early when
33
- * `window.__MANDU_ROUTER_STATE__` is present that global is
34
- * installed by `initializeRouter()` before it calls
35
- * `addEventListener`, so on hydrated pages the full router wins.
36
- * On pure-SSR pages the state global is missing and the helper
37
- * is authoritative.
38
- *
39
- * 3. **View Transitions API** — we call
40
- * `document.startViewTransition(cb)` when available, mirroring the
41
- * `@view-transition` at-rule we already inject. Browsers without
42
- * the API (Firefox, Safari < 18.2) execute the callback
43
- * synchronously so the feature is a pure progressive enhancement.
44
- *
45
- * 4. **DOM swap strategy**: replace `document.body.innerHTML` using
46
- * the parsed incoming document's `<body>`. This preserves the
47
- * `<head>` across navigations (avoids re-running inline scripts
48
- * like this helper) while still picking up `<title>` and
49
- * `<meta>` changes via a selective head-element merge. We also
50
- * reset `document.title`.
51
- *
52
- * 5. **Inline, not external**: same rationale as #192's prefetch
53
- * helper — inline removes the extra round-trip on every SSR
54
- * response, keeps the CSP posture simple (only two inline scripts:
55
- * prefetch + spa-nav), and sidesteps the "zero-JS but loads one
56
- * JS file anyway" awkwardness.
57
- *
58
- * 6. **Opt-out via `ssr.spa: false`**: the injection site
59
- * (`ssr.ts::renderToHTML`, `streaming-ssr.ts::generateHTMLShell`)
60
- * omits the `<script>` block entirely when the user's config sets
61
- * `spa: false`. No runtime check needed inside the IIFE.
62
- *
63
- * The exported `SPA_NAV_HELPER_SCRIPT` wraps the IIFE in a
64
- * `<script>` tag, ready to paste into `<head>` alongside the prefetch
65
- * helper and `@view-transition` style block.
66
- *
67
- * Size target: ≤3 KB raw (currently ≈2.7 KB after the defensive
68
- * hardNav / DOMParser-availability guards). If this grows past 3 KB we
69
- * should revisit the inline-vs-external trade-off.
70
- */
71
-
72
- /**
73
- * Inner IIFE — exposed for unit tests that want to parse the source.
74
- *
75
- * Byte-minified on purpose (no comments, short names). The high-level
76
- * flow is documented in this file's JSDoc; anyone editing this string
77
- * MUST update the exclusion-matrix test to match.
78
- */
79
- export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;function hardNav(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 swap(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){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));}}var nb=doc.body;if(nb)document.body.innerHTML=nb.innerHTML;try{window.scrollTo(0,0);}catch(_){}}catch(_){}}function nav(url,push){fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok||!r.headers.get("content-type")||r.headers.get("content-type").indexOf("text/html")<0){hardNav(url);return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url);return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(_){hardNav(url);return;}if(push){try{H.pushState({mandu:1},"",url);}catch(_){hardNav(url);return;}}var run=function(){swap(doc);try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(_){run();}}else{run();}}).catch(function(){hardNav(url);});}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;})();`;
80
-
81
- /** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
82
- export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
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>`;
@@ -197,6 +197,94 @@ 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
+ * &lt;Link href="/path" /&gt;</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
+ /**
217
+ * Phase 18.φ — bundle-size budget. Declaring any field turns on
218
+ * framework-level size-ceiling enforcement during `mandu build`.
219
+ *
220
+ * - `maxRawBytes` : per-island raw-byte cap.
221
+ * - `maxGzBytes` : per-island gzip-byte cap.
222
+ * - `maxTotalRawBytes` : project-wide raw cap (islands + shared).
223
+ * - `maxTotalGzBytes` : project-wide gzip cap.
224
+ * - `perIsland` : per-island overrides, additive per axis.
225
+ * `{ home: { gz: 50_000 } }` tightens only
226
+ * `home`'s gzip and leaves its raw cap at
227
+ * the global `maxRawBytes`.
228
+ * - `mode` : `'warning'` (default) prints a table and
229
+ * continues; `'error'` exits non-zero.
230
+ *
231
+ * Declaring the empty block `budget: {}` is interpreted as "I know
232
+ * about budgets" and auto-applies a 250 KB gzip per-island ceiling
233
+ * (matches Next.js `largePageDataBytes` + Astro rules of thumb).
234
+ * Omitting the block entirely is the zero-overhead opt-out.
235
+ *
236
+ * CLI override: `mandu build --no-budget` skips enforcement for a
237
+ * single run regardless of config.
238
+ *
239
+ * @see `docs/architect/bundle-budget.md`
240
+ * @see `@mandujs/core/bundler/budget`
241
+ */
242
+ budget?: {
243
+ maxRawBytes?: number;
244
+ maxGzBytes?: number;
245
+ maxTotalRawBytes?: number;
246
+ maxTotalGzBytes?: number;
247
+ perIsland?: Record<string, { raw?: number; gz?: number }>;
248
+ mode?: "error" | "warning";
249
+ };
250
+ crawl?: {
251
+ /**
252
+ * Extra pathnames or simple globs (`*`) to exclude when crawling.
253
+ * Merged with the built-in default denylist unless
254
+ * {@link replaceDefaultExclude} is `true`.
255
+ */
256
+ exclude?: string[];
257
+ /**
258
+ * When `true`, `exclude` replaces the built-in denylist entirely.
259
+ * Default `false`.
260
+ */
261
+ replaceDefaultExclude?: boolean;
262
+ /**
263
+ * Issue #219 — file extensions treated as non-HTML assets. When
264
+ * a discovered href's pathname ends with one of these, the
265
+ * crawler skips it instead of enqueuing it for prerender.
266
+ *
267
+ * Example: `<picture><source srcset="/hero.avif"><img
268
+ * src="/hero.webp"></picture>` used to make the engine render
269
+ * the asset URL as HTML and overwrite the real `.webp` on disk.
270
+ * The default set covers common image / font / document /
271
+ * media / text-asset extensions.
272
+ *
273
+ * Entries may be written with or without a leading dot
274
+ * (`"webp"` and `".webp"` are equivalent). Matching is
275
+ * case-insensitive; query strings and hash fragments are
276
+ * stripped before comparison.
277
+ *
278
+ * Merged with the built-in default set unless
279
+ * {@link replaceDefaultAssetExtensions} is `true`.
280
+ */
281
+ assetExtensions?: string[];
282
+ /**
283
+ * When `true`, `assetExtensions` replaces the built-in set
284
+ * entirely. Default `false`.
285
+ */
286
+ replaceDefaultAssetExtensions?: boolean;
287
+ };
200
288
  };
201
289
  dev?: {
202
290
  hmr?: boolean;
@@ -119,6 +119,65 @@ 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
+
149
+ /**
150
+ * Phase 18.φ — bundle-size budget config (strict).
151
+ *
152
+ * Every ceiling field is an optional non-negative integer so "pin this
153
+ * island to 0 bytes" (an explicit assertion that no code should ship for
154
+ * a route) stays expressible. `perIsland` keys are arbitrary route / island
155
+ * ids; Zod can't validate those against the project's actual route set at
156
+ * config-load time, so we accept any non-empty string and let the evaluator
157
+ * silently ignore keys that don't match a real island (the doc explains this).
158
+ *
159
+ * `mode` defaults to `'warning'` so a first-time user doesn't get a
160
+ * surprise non-zero exit code. Flip to `'error'` in CI once you've
161
+ * calibrated your limits.
162
+ */
163
+ const BuildBudgetPerIslandSchema = z
164
+ .object({
165
+ raw: z.number().int().nonnegative().optional(),
166
+ gz: z.number().int().nonnegative().optional(),
167
+ })
168
+ .strict();
169
+
170
+ const BuildBudgetConfigSchema = z
171
+ .object({
172
+ maxRawBytes: z.number().int().nonnegative().optional(),
173
+ maxGzBytes: z.number().int().nonnegative().optional(),
174
+ maxTotalRawBytes: z.number().int().nonnegative().optional(),
175
+ maxTotalGzBytes: z.number().int().nonnegative().optional(),
176
+ perIsland: z.record(BuildBudgetPerIslandSchema).optional(),
177
+ mode: z.enum(["error", "warning"]).default("warning"),
178
+ })
179
+ .strict();
180
+
122
181
  /**
123
182
  * Build 설정 스키마 (strict)
124
183
  */
@@ -141,6 +200,19 @@ const BuildConfigSchema = z
141
200
  * for this project" switch.
142
201
  */
143
202
  analyze: z.boolean().default(false),
203
+ /**
204
+ * Issue #213 — prerender link-crawler denylist. See
205
+ * {@link BuildCrawlConfigSchema}. Omit the block to trust the
206
+ * default (strip code regions + built-in placeholder denylist).
207
+ */
208
+ crawl: BuildCrawlConfigSchema.optional(),
209
+ /**
210
+ * Phase 18.φ — bundle-size budget. See {@link BuildBudgetConfigSchema}.
211
+ * Omit the block for zero-overhead passthrough. Declaring the empty
212
+ * block `{}` turns on the default 250 KB gzip per-island ceiling in
213
+ * warning mode. See `@mandujs/core/bundler/budget` for the evaluator.
214
+ */
215
+ budget: BuildBudgetConfigSchema.optional(),
144
216
  })
145
217
  .strict();
146
218
 
@@ -328,9 +328,10 @@ describe("runExtendedDiagnose", () => {
328
328
  beforeEach(async () => { rootDir = await mkTmpRoot(); });
329
329
  afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
330
330
 
331
- it("runs all 5 extended checks and returns a structured report", async () => {
331
+ it("runs all 6 extended checks and returns a structured report", async () => {
332
332
  const report = await runExtendedDiagnose(rootDir);
333
- expect(report.summary.total).toBe(5);
333
+ // Phase 18.χ added `a11y_hints` — total is now 6.
334
+ expect(report.summary.total).toBe(6);
334
335
  // manifest is missing → at least one error
335
336
  expect(report.healthy).toBe(false);
336
337
  expect(report.errorCount).toBeGreaterThanOrEqual(1);
@@ -340,6 +341,7 @@ describe("runExtendedDiagnose", () => {
340
341
  expect(rules).toContain("cloneelement_warnings");
341
342
  expect(rules).toContain("dev_artifacts_in_prod");
342
343
  expect(rules).toContain("package_export_gaps");
344
+ expect(rules).toContain("a11y_hints");
343
345
  });
344
346
 
345
347
  it("returns healthy=true when all checks pass (production manifest, no gaps)", async () => {
@@ -597,3 +597,124 @@ export async function checkPackageExportGaps(rootDir: string): Promise<DiagnoseC
597
597
  details: { gapCount: gaps.length, gaps },
598
598
  };
599
599
  }
600
+
601
+ // ────────────────────────────────────────────────────────────────────────
602
+ // 6. a11y_hints (Phase 18.χ)
603
+ // ────────────────────────────────────────────────────────────────────────
604
+
605
+ /**
606
+ * Find the first prerendered HTML leaf under `.mandu/prerendered/` or
607
+ * `.mandu/static/`. Returns `null` when nothing has been prerendered.
608
+ * Used as a cheap smoke target — auditing every page would be orders
609
+ * of magnitude heavier than every other diagnose check.
610
+ */
611
+ async function findFirstPrerenderedHtml(rootDir: string): Promise<string | null> {
612
+ const dirs = [
613
+ path.join(rootDir, ".mandu", "prerendered"),
614
+ path.join(rootDir, ".mandu", "static"),
615
+ ];
616
+ async function walk(dir: string, depth: number): Promise<string | null> {
617
+ if (depth > 8) return null;
618
+ let entries: Dirent[];
619
+ try {
620
+ entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
621
+ } catch {
622
+ return null;
623
+ }
624
+ // Stable sort so the smoke sample is deterministic across runs —
625
+ // otherwise a flaky a11y hint could bounce on/off depending on
626
+ // FS iteration order.
627
+ entries.sort((a, b) => a.name.localeCompare(b.name));
628
+ for (const entry of entries) {
629
+ if (entry.name.startsWith(".") || entry.name === "_manifest.json") continue;
630
+ const full = path.join(dir, entry.name);
631
+ if (entry.isDirectory()) {
632
+ const found = await walk(full, depth + 1);
633
+ if (found) return found;
634
+ } else if (entry.isFile() && entry.name.endsWith(".html")) {
635
+ return full;
636
+ }
637
+ }
638
+ return null;
639
+ }
640
+ for (const dir of dirs) {
641
+ const found = await walk(dir, 0);
642
+ if (found) return found;
643
+ }
644
+ return null;
645
+ }
646
+
647
+ /**
648
+ * Phase 18.χ — accessibility smoke hint.
649
+ *
650
+ * Runs the a11y audit against a single prerendered HTML file — the
651
+ * first one we can find. This is strictly a smoke test (auditing a
652
+ * whole build is too expensive for `mandu diagnose`, which is meant to
653
+ * run in < 1s). The check stays `warning` severity at worst; a11y
654
+ * failures should surface attention, not block deploys (that's what
655
+ * `mandu build --audit --audit-fail-on=critical` is for).
656
+ *
657
+ * Behaviour matrix:
658
+ * - No prerendered HTML on disk → ok (nothing to audit yet)
659
+ * - axe-core / DOM provider missing → ok (optional deps; informational)
660
+ * - Runner succeeds, zero criticals → ok
661
+ * - Runner succeeds with criticals → warning + suggestion pointing
662
+ * at the full-build command
663
+ */
664
+ export async function checkA11yHints(rootDir: string): Promise<DiagnoseCheckResult> {
665
+ const sample = await findFirstPrerenderedHtml(rootDir);
666
+ if (!sample) {
667
+ return {
668
+ ok: true,
669
+ rule: "a11y_hints",
670
+ message: "No prerendered HTML found — nothing to audit.",
671
+ details: { scanned: 0 },
672
+ };
673
+ }
674
+
675
+ // Lazy import — keeps the diagnose bundle tree free of the a11y
676
+ // module unless this specific check is actually invoked.
677
+ const { runAudit } = await import("../a11y/run-audit");
678
+ const report = await runAudit([sample], { minImpact: "critical" });
679
+
680
+ if (report.outcome === "axe-missing") {
681
+ return {
682
+ ok: true,
683
+ rule: "a11y_hints",
684
+ message: "axe-core not installed — a11y smoke skipped (optional).",
685
+ details: {
686
+ sample: path.relative(rootDir, sample),
687
+ note: report.note,
688
+ },
689
+ };
690
+ }
691
+
692
+ if (report.outcome === "ok") {
693
+ return {
694
+ ok: true,
695
+ rule: "a11y_hints",
696
+ message: `a11y smoke PASS on ${path.relative(rootDir, sample)} (no critical violations).`,
697
+ details: {
698
+ sample: path.relative(rootDir, sample),
699
+ filesScanned: report.filesScanned,
700
+ },
701
+ };
702
+ }
703
+
704
+ const top = report.violations[0];
705
+ return {
706
+ ok: false,
707
+ rule: "a11y_hints",
708
+ severity: "warning",
709
+ message:
710
+ `a11y smoke found ${report.violations.length} critical violation(s) in ` +
711
+ `${path.relative(rootDir, sample)}. First: ${top.rule} — ${top.help}`,
712
+ suggestion: "Run `mandu build --audit` to audit every prerendered page.",
713
+ details: {
714
+ sample: path.relative(rootDir, sample),
715
+ violationCount: report.violations.length,
716
+ firstRule: top.rule,
717
+ impactCounts: report.impactCounts,
718
+ },
719
+ };
720
+ }
@@ -11,5 +11,6 @@ export {
11
11
  checkCloneElementWarnings,
12
12
  checkDevArtifactsInProd,
13
13
  checkPackageExportGaps,
14
+ checkA11yHints,
14
15
  } from "./checks";
15
16
  export { EXTENDED_CHECKS, runExtendedDiagnose, buildReport } from "./run";
@@ -12,14 +12,17 @@ import {
12
12
  checkCloneElementWarnings,
13
13
  checkDevArtifactsInProd,
14
14
  checkPackageExportGaps,
15
+ checkA11yHints,
15
16
  } from "./checks";
16
17
 
17
18
  /**
18
- * Registered extended checks (Issue #215). These are the five checks that
19
- * supplement the legacy guard/contract/manifest/kitchen validation in MCP.
19
+ * Registered extended checks (Issue #215 + Phase 18.χ).
20
20
  *
21
21
  * Order matters for display purposes only — result aggregation is
22
- * order-independent.
22
+ * order-independent. The a11y smoke is last because it's the most
23
+ * expensive check by an order of magnitude (dynamic import + DOM
24
+ * parse + axe rules) and we prefer failing fast on cheaper structural
25
+ * checks first.
23
26
  */
24
27
  export const EXTENDED_CHECKS = [
25
28
  { name: "manifest_freshness", run: checkManifestFreshness },
@@ -27,6 +30,7 @@ export const EXTENDED_CHECKS = [
27
30
  { name: "cloneelement_warnings", run: checkCloneElementWarnings },
28
31
  { name: "dev_artifacts_in_prod", run: checkDevArtifactsInProd },
29
32
  { name: "package_export_gaps", run: checkPackageExportGaps },
33
+ { name: "a11y_hints", run: checkA11yHints },
30
34
  ] as const;
31
35
 
32
36
  /**