@mandujs/core 0.32.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/bundler/build.ts +29 -1
- package/src/bundler/generate-static-params.ts +302 -290
- package/src/bundler/prerender.ts +858 -368
- package/src/bundler/types.ts +20 -0
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +54 -0
- package/src/config/validate.ts +55 -0
- package/src/diagnose/__tests__/checks.test.ts +378 -0
- package/src/diagnose/checks.ts +599 -0
- package/src/diagnose/index.ts +15 -0
- package/src/diagnose/run.ts +87 -0
- package/src/diagnose/types.ts +53 -0
- package/src/guard/graph.ts +898 -0
- package/src/guard/index.ts +14 -0
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
- package/src/plugins/__tests__/runner.test.ts +409 -0
- package/src/plugins/define.ts +124 -0
- package/src/plugins/examples/dep-check-plugin.ts +80 -0
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
- package/src/plugins/examples/sitemap-plugin.ts +65 -0
- package/src/plugins/hooks.ts +297 -64
- package/src/plugins/index.ts +80 -41
- package/src/plugins/runner.ts +361 -0
- package/src/router/fs-routes.ts +64 -1
- package/src/runtime/server.ts +365 -36
- package/src/spec/schema.ts +25 -0
- package/src/testing/__tests__/reporter.test.ts +454 -0
- package/src/testing/index.ts +29 -0
- package/src/testing/reporter.ts +676 -0
package/src/bundler/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
|
@@ -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
|
|