@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.
- package/package.json +17 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -0
- package/src/a11y/fix-hints.ts +76 -0
- package/src/a11y/index.ts +18 -0
- package/src/a11y/run-audit.ts +394 -0
- package/src/a11y/types.ts +125 -0
- package/src/bundler/analyzer.ts +119 -4
- package/src/bundler/budget.ts +404 -0
- package/src/bundler/prerender.ts +432 -20
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +88 -0
- package/src/config/validate.ts +72 -0
- package/src/diagnose/__tests__/checks.test.ts +4 -2
- package/src/diagnose/checks.ts +121 -0
- package/src/diagnose/index.ts +1 -0
- package/src/diagnose/run.ts +7 -3
- package/src/perf/__tests__/user-marks.test.ts +354 -0
- package/src/perf/index.ts +29 -0
- package/src/perf/user-marks.ts +553 -0
- package/src/runtime/server.ts +257 -37
|
@@ -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,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
|
+
* <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
|
+
/**
|
|
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;
|
package/src/config/validate.ts
CHANGED
|
@@ -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
|
|
331
|
+
it("runs all 6 extended checks and returns a structured report", async () => {
|
|
332
332
|
const report = await runExtendedDiagnose(rootDir);
|
|
333
|
-
|
|
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 () => {
|
package/src/diagnose/checks.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/diagnose/index.ts
CHANGED
package/src/diagnose/run.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|