@mandujs/core 0.32.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -164,4 +164,24 @@ export interface BundlerOptions {
164
164
  * resolved config flag straight through.
165
165
  */
166
166
  blockGeneratedImport?: boolean;
167
+
168
+ /**
169
+ * Phase 18.τ — consumer-supplied `BunPlugin`s contributed via plugin
170
+ * `defineBundlerPlugin()` hook. Composed AFTER Mandu's defaults so
171
+ * user transforms see already-resolved imports. Omitted / empty
172
+ * array is a zero-overhead passthrough.
173
+ *
174
+ * Resolved by the CLI (`cli/commands/build.ts`, `cli/commands/dev.ts`)
175
+ * via `runDefineBundlerPlugin()` and fed into every `safeBuild(...)`
176
+ * call-site; library users may populate it directly.
177
+ */
178
+ pluginBundlerPlugins?: readonly import("bun").BunPlugin[];
179
+
180
+ /**
181
+ * Phase 18.τ — fire per-build `onBundleComplete(stats)` hook after
182
+ * `buildClientBundles()` finishes. Paired with `configHooks` for
183
+ * config-level hooks. Zero-overhead when both are omitted.
184
+ */
185
+ plugins?: readonly import("../plugins/hooks").ManduPlugin[];
186
+ configHooks?: Partial<import("../plugins/hooks").ManduHooks>;
167
187
  }
@@ -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,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
+ * &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
+ 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;
@@ -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
 
@@ -242,10 +275,32 @@ const TestE2EConfigSchema = z
242
275
  })
243
276
  .strict();
244
277
 
278
+ /**
279
+ * Per-metric threshold sub-block (Phase 18.σ).
280
+ *
281
+ * All four metrics (lines / branches / functions / statements) are
282
+ * independently optional. Omitting the whole sub-block keeps the
283
+ * legacy behavior — the CLI only enforces thresholds for metrics
284
+ * that are explicitly set.
285
+ */
286
+ const TestCoverageThresholdsSchema = z
287
+ .object({
288
+ lines: z.number().min(0).max(100).optional(),
289
+ branches: z.number().min(0).max(100).optional(),
290
+ functions: z.number().min(0).max(100).optional(),
291
+ statements: z.number().min(0).max(100).optional(),
292
+ })
293
+ .strict();
294
+
245
295
  const TestCoverageConfigSchema = z
246
296
  .object({
297
+ // Legacy top-level shorthand (Phase 12.3). When set, the CLI
298
+ // mirrors it into `thresholds.lines` so users who already had
299
+ // `coverage.lines: 80` in their config keep working unchanged.
247
300
  lines: z.number().min(0).max(100).optional(),
248
301
  branches: z.number().min(0).max(100).optional(),
302
+ // Phase 18.σ — preferred per-metric threshold block.
303
+ thresholds: TestCoverageThresholdsSchema.optional(),
249
304
  })
250
305
  .strict();
251
306