@mandujs/core 0.43.1 → 0.45.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 +2 -1
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -0
- package/src/bundler/plugins/index.ts +6 -0
- package/src/bundler/plugins/react-compiler-config.ts +108 -0
- package/src/client/router.ts +12 -2
- package/src/client/spa-nav-helper.ts +1 -1
- package/src/config/validate.ts +36 -0
- package/src/design/__tests__/parser.test.ts +195 -0
- package/src/design/index.ts +49 -0
- package/src/design/parser.ts +555 -0
- package/src/design/scaffold.ts +147 -0
- package/src/design/types.ts +210 -0
- package/src/guard/__tests__/design-inline-class.test.ts +219 -0
- package/src/guard/check.ts +15 -0
- package/src/guard/design-inline-class.ts +353 -0
- package/src/guard/rules.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./content/llms-txt": "./src/content/llms-txt.ts",
|
|
26
26
|
"./content/schema": "./src/content/schema.ts",
|
|
27
27
|
"./db": "./src/db/index.ts",
|
|
28
|
+
"./design": "./src/design/index.ts",
|
|
28
29
|
"./desktop": "./src/desktop/index.ts",
|
|
29
30
|
"./desktop/worker": "./src/desktop/worker.ts",
|
|
30
31
|
"./email": "./src/email/index.ts",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveReactCompilerConfig` — #240 Phase 2 auto-detect tests.
|
|
3
|
+
*
|
|
4
|
+
* The probe only fires when `enabled` is undefined. Explicit `true` /
|
|
5
|
+
* `false` veto the probe so user intent always wins.
|
|
6
|
+
*
|
|
7
|
+
* Cache lifetime is per-process; we reset it between cases so a probe
|
|
8
|
+
* from one fixture doesn't leak to the next.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
resolveReactCompilerConfig,
|
|
17
|
+
_resetReactCompilerConfigCache,
|
|
18
|
+
} from "../react-compiler-config";
|
|
19
|
+
|
|
20
|
+
async function makeRoot(prefix: string): Promise<string> {
|
|
21
|
+
return fs.mkdtemp(path.join(os.tmpdir(), `mandu-rc-${prefix}-`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
_resetReactCompilerConfigCache();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveReactCompilerConfig", () => {
|
|
29
|
+
it("explicit enabled:true honours the user even when peers are missing", async () => {
|
|
30
|
+
const root = await makeRoot("explicit-on");
|
|
31
|
+
const result = resolveReactCompilerConfig({ enabled: true }, root);
|
|
32
|
+
expect(result.enabled).toBe(true);
|
|
33
|
+
expect(result.autoDetected).toBe(false);
|
|
34
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("explicit enabled:false vetos the probe", async () => {
|
|
38
|
+
const root = await makeRoot("explicit-off");
|
|
39
|
+
const result = resolveReactCompilerConfig({ enabled: false }, root);
|
|
40
|
+
expect(result.enabled).toBe(false);
|
|
41
|
+
expect(result.autoDetected).toBe(false);
|
|
42
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("undefined enabled + missing peers → disabled silently", async () => {
|
|
46
|
+
const root = await makeRoot("auto-no-peers");
|
|
47
|
+
// Empty rootDir — no node_modules, no package.json, no peer deps.
|
|
48
|
+
const result = resolveReactCompilerConfig(undefined, root);
|
|
49
|
+
expect(result.enabled).toBe(false);
|
|
50
|
+
expect(result.autoDetected).toBe(false);
|
|
51
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("forwards compilerConfig regardless of enabled state", async () => {
|
|
55
|
+
const root = await makeRoot("compiler-config");
|
|
56
|
+
const cfg = { compilationMode: "annotation" };
|
|
57
|
+
const result = resolveReactCompilerConfig(
|
|
58
|
+
{ enabled: true, compilerConfig: cfg },
|
|
59
|
+
root,
|
|
60
|
+
);
|
|
61
|
+
expect(result.compilerConfig).toBe(cfg);
|
|
62
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("caches by (rootDir, explicit-enabled) — second call hits cache", async () => {
|
|
66
|
+
const root = await makeRoot("cache");
|
|
67
|
+
const a = resolveReactCompilerConfig(undefined, root);
|
|
68
|
+
const b = resolveReactCompilerConfig(undefined, root);
|
|
69
|
+
// Same identity — cache hit returns the stored object.
|
|
70
|
+
expect(a).toBe(b);
|
|
71
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats explicit-true vs auto as separate cache keys", async () => {
|
|
75
|
+
const root = await makeRoot("cache-key");
|
|
76
|
+
const auto = resolveReactCompilerConfig(undefined, root);
|
|
77
|
+
const explicit = resolveReactCompilerConfig({ enabled: true }, root);
|
|
78
|
+
expect(auto).not.toBe(explicit);
|
|
79
|
+
expect(auto.enabled).toBe(false);
|
|
80
|
+
expect(explicit.enabled).toBe(true);
|
|
81
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -36,6 +36,12 @@ export {
|
|
|
36
36
|
type FormatCompilerReportOptions,
|
|
37
37
|
} from "./react-compiler-lint";
|
|
38
38
|
|
|
39
|
+
export {
|
|
40
|
+
resolveReactCompilerConfig,
|
|
41
|
+
type RawReactCompilerConfig,
|
|
42
|
+
type ResolvedReactCompilerConfig,
|
|
43
|
+
} from "./react-compiler-config";
|
|
44
|
+
|
|
39
45
|
/**
|
|
40
46
|
* Subset of `ManduConfig.guard` consumed by `defaultBundlerPlugins()`.
|
|
41
47
|
* We deliberately don't import the full `ManduConfig` type to keep the
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Compiler config resolver (#240 Phase 2 — auto-detect).
|
|
3
|
+
*
|
|
4
|
+
* The `experimental.reactCompiler` block in `mandu.config.ts` has three
|
|
5
|
+
* meaningful states for the `enabled` field:
|
|
6
|
+
*
|
|
7
|
+
* - `true` — user explicitly opts in. The transform plugin runs and
|
|
8
|
+
* warns if peer deps (`@babel/core`, `babel-plugin-react-compiler`)
|
|
9
|
+
* are missing.
|
|
10
|
+
* - `false` — user explicitly opts out. Plugin never runs.
|
|
11
|
+
* - `undefined` (the default) — Phase 2: probe whether the peer deps
|
|
12
|
+
* are installed in the project. If both resolve, treat as enabled
|
|
13
|
+
* so installing `babel-plugin-react-compiler` is the only step
|
|
14
|
+
* needed to turn auto-memoization on (zero-config goal of #240).
|
|
15
|
+
* If either is missing, stay disabled silently — no warning, no
|
|
16
|
+
* surface change for projects that haven't asked for the Compiler.
|
|
17
|
+
*
|
|
18
|
+
* The probe is synchronous (`Bun.resolveSync`) so it composes with the
|
|
19
|
+
* non-async `manduClientPlugins()` gate. Resolutions are cached per
|
|
20
|
+
* `(rootDir, enabled)` pair because the bundler asks for plugins many
|
|
21
|
+
* times during a single build (one for each entry / shim / island).
|
|
22
|
+
*
|
|
23
|
+
* @module core/bundler/plugins/react-compiler-config
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface RawReactCompilerConfig {
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
compilerConfig?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResolvedReactCompilerConfig {
|
|
32
|
+
/**
|
|
33
|
+
* Final on/off decision after applying auto-detect. Always a concrete
|
|
34
|
+
* boolean — callers do not need to repeat the probe.
|
|
35
|
+
*/
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
/** Forwarded to `babel-plugin-react-compiler`. */
|
|
38
|
+
compilerConfig?: Record<string, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* `true` when `enabled` was implicitly resolved from peer-dep probe
|
|
41
|
+
* (vs. set explicitly by the user). Surfaced so the bundler's plugin
|
|
42
|
+
* can suppress the "peer dep missing" warning — the implicit path
|
|
43
|
+
* already short-circuits before the plugin runs, but a future caller
|
|
44
|
+
* that bypasses this resolver would otherwise spam the warning.
|
|
45
|
+
*/
|
|
46
|
+
autoDetected: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cache = new Map<string, ResolvedReactCompilerConfig>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Probe whether `@babel/core` and `babel-plugin-react-compiler` resolve
|
|
53
|
+
* from `rootDir`. Both must be present — the transform plugin loads
|
|
54
|
+
* them as a pair. Returns `false` on any resolution failure (missing
|
|
55
|
+
* dep, broken symlink, weird workspace layout) so the failure mode is
|
|
56
|
+
* "stay off" rather than "blow up boot".
|
|
57
|
+
*/
|
|
58
|
+
function peerDepsInstalled(rootDir: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
Bun.resolveSync("@babel/core", rootDir);
|
|
61
|
+
Bun.resolveSync("babel-plugin-react-compiler", rootDir);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the user's `experimental.reactCompiler` block into a final
|
|
70
|
+
* on/off decision plus carried-over compiler options.
|
|
71
|
+
*
|
|
72
|
+
* Cache key includes `rootDir` and the explicit-enabled value so we
|
|
73
|
+
* can have, in tests, two projects in the same process with different
|
|
74
|
+
* enablement states.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveReactCompilerConfig(
|
|
77
|
+
raw: RawReactCompilerConfig | undefined,
|
|
78
|
+
rootDir: string,
|
|
79
|
+
): ResolvedReactCompilerConfig {
|
|
80
|
+
const explicit = raw?.enabled;
|
|
81
|
+
const cacheKey = `${rootDir}::${explicit ?? "auto"}`;
|
|
82
|
+
const hit = cache.get(cacheKey);
|
|
83
|
+
if (hit) return hit;
|
|
84
|
+
|
|
85
|
+
let enabled: boolean;
|
|
86
|
+
let autoDetected = false;
|
|
87
|
+
if (explicit === true) {
|
|
88
|
+
enabled = true;
|
|
89
|
+
} else if (explicit === false) {
|
|
90
|
+
enabled = false;
|
|
91
|
+
} else {
|
|
92
|
+
enabled = peerDepsInstalled(rootDir);
|
|
93
|
+
autoDetected = enabled;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result: ResolvedReactCompilerConfig = {
|
|
97
|
+
enabled,
|
|
98
|
+
compilerConfig: raw?.compilerConfig,
|
|
99
|
+
autoDetected,
|
|
100
|
+
};
|
|
101
|
+
cache.set(cacheKey, result);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Test-only — drop cached probes between fixture setups. */
|
|
106
|
+
export function _resetReactCompilerConfigCache(): void {
|
|
107
|
+
cache.clear();
|
|
108
|
+
}
|
package/src/client/router.ts
CHANGED
|
@@ -346,9 +346,19 @@ export async function navigate(
|
|
|
346
346
|
// `startViewTransition` is part of the View Transitions API which
|
|
347
347
|
// is not yet in every lib.dom.d.ts. Narrow the cast to the only
|
|
348
348
|
// method we call rather than widening to `any`.
|
|
349
|
-
(document as Document & {
|
|
350
|
-
startViewTransition: (callback: () => void) =>
|
|
349
|
+
const transition = (document as Document & {
|
|
350
|
+
startViewTransition: (callback: () => void) => {
|
|
351
|
+
finished?: Promise<unknown>;
|
|
352
|
+
ready?: Promise<unknown>;
|
|
353
|
+
updateCallbackDone?: Promise<unknown>;
|
|
354
|
+
};
|
|
351
355
|
}).startViewTransition(applyUpdate);
|
|
356
|
+
// ViewTransition.finished/ready reject with InvalidStateError when a
|
|
357
|
+
// newer transition aborts this one (rapid navigation, popstate, etc.).
|
|
358
|
+
// Swallow those — they are expected and not actionable for the app.
|
|
359
|
+
transition?.finished?.catch(() => {});
|
|
360
|
+
transition?.ready?.catch(() => {});
|
|
361
|
+
transition?.updateCallbackDone?.catch(() => {});
|
|
352
362
|
} else {
|
|
353
363
|
applyUpdate();
|
|
354
364
|
}
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
* flow is documented in this file's JSDoc; anyone editing this string
|
|
98
98
|
* MUST update the exclusion-matrix test and the body-swap test to match.
|
|
99
99
|
*/
|
|
100
|
-
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 esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}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)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)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;}try{var cRoot=document.getElementById?document.getElementById("root"):null;var nRoot=doc.getElementById?doc.getElementById("root"):null;var ck=cRoot&&cRoot.getAttribute?cRoot.getAttribute("data-mandu-layout"):"";var nk=nRoot&&nRoot.getAttribute?nRoot.getAttribute("data-mandu-layout"):"";if(ck&&nk&&ck!==nk){hardNav(url,"cross-layout transition ("+ck+" -> "+nk+")");return false;}}catch(_){}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);scrollToHash(extractHash(url),url);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));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}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();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}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;})();`;
|
|
100
|
+
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 esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}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)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)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;}try{var cRoot=document.getElementById?document.getElementById("root"):null;var nRoot=doc.getElementById?doc.getElementById("root"):null;var ck=cRoot&&cRoot.getAttribute?cRoot.getAttribute("data-mandu-layout"):"";var nk=nRoot&&nRoot.getAttribute?nRoot.getAttribute("data-mandu-layout"):"";if(ck&&nk&&ck!==nk){hardNav(url,"cross-layout transition ("+ck+" -> "+nk+")");return false;}}catch(_){}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);scrollToHash(extractHash(url),url);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{var vt=document.startViewTransition(run);if(vt){if(vt.finished&&typeof vt.finished.catch==="function")vt.finished.catch(function(){});if(vt.ready&&typeof vt.ready.catch==="function")vt.ready.catch(function(){});if(vt.updateCallbackDone&&typeof vt.updateCallbackDone.catch==="function")vt.updateCallbackDone.catch(function(){});}}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));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}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();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}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;})();`;
|
|
101
101
|
|
|
102
102
|
/** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
|
|
103
103
|
export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
|
package/src/config/validate.ts
CHANGED
|
@@ -114,6 +114,34 @@ const GuardTypeAwareConfigSchema = z
|
|
|
114
114
|
* - `GuardRule[]` (Phase 18.ν) → consumer-defined custom rules.
|
|
115
115
|
* The runner dispatches on `Array.isArray()`.
|
|
116
116
|
*/
|
|
117
|
+
/**
|
|
118
|
+
* Issue #245 — DESIGN.md-driven design system enforcement.
|
|
119
|
+
*
|
|
120
|
+
* `forbidInlineClasses` stops agents from re-inlining the same UI
|
|
121
|
+
* pattern across pages (the regression #245 was filed for); when set,
|
|
122
|
+
* the named classes trigger a Guard violation if they appear directly
|
|
123
|
+
* in `className` outside the canonical component dirs.
|
|
124
|
+
*
|
|
125
|
+
* `requireComponent` maps a forbidden class to the component that
|
|
126
|
+
* should be used instead — surfaced verbatim in the violation message
|
|
127
|
+
* so an agent reading the diagnostic knows the fix.
|
|
128
|
+
*
|
|
129
|
+
* `autoFromDesignMd` extracts the forbid list from DESIGN.md §7
|
|
130
|
+
* Do's & Don'ts when set, so users only need to maintain the spec.
|
|
131
|
+
*/
|
|
132
|
+
const GuardDesignConfigSchema = z
|
|
133
|
+
.object({
|
|
134
|
+
designMd: z.string().default("DESIGN.md"),
|
|
135
|
+
forbidInlineClasses: z.array(z.string().min(1)).default([]),
|
|
136
|
+
autoFromDesignMd: z.boolean().default(false),
|
|
137
|
+
requireComponent: z.record(z.string()).default({}),
|
|
138
|
+
exclude: z
|
|
139
|
+
.array(z.string())
|
|
140
|
+
.default(["src/client/shared/ui/**", "src/client/widgets/**"]),
|
|
141
|
+
severity: z.enum(["warning", "error"]).default("error"),
|
|
142
|
+
})
|
|
143
|
+
.strict();
|
|
144
|
+
|
|
117
145
|
const GuardConfigSchema = z
|
|
118
146
|
.object({
|
|
119
147
|
preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic", "cqrs"]).default("mandu"),
|
|
@@ -136,6 +164,14 @@ const GuardConfigSchema = z
|
|
|
136
164
|
* Follow-up E — `oxlint --type-aware` bridge. Optional.
|
|
137
165
|
*/
|
|
138
166
|
typeAware: GuardTypeAwareConfigSchema.optional(),
|
|
167
|
+
/**
|
|
168
|
+
* Issue #245 — design system enforcement. Optional; when present,
|
|
169
|
+
* the design-inline-class checker runs alongside the standard
|
|
170
|
+
* Guard pipeline. Most projects start by setting `designMd:
|
|
171
|
+
* "DESIGN.md"` + `autoFromDesignMd: true` and let the spec drive
|
|
172
|
+
* the forbid list.
|
|
173
|
+
*/
|
|
174
|
+
design: GuardDesignConfigSchema.optional(),
|
|
139
175
|
})
|
|
140
176
|
.strict();
|
|
141
177
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DESIGN.md parser tests.
|
|
3
|
+
*
|
|
4
|
+
* Cover the structural contract: each of the 9 sections is recognised
|
|
5
|
+
* across reasonable formatting variants, and a malformed row never
|
|
6
|
+
* throws — it just gets skipped.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
parseDesignMd,
|
|
11
|
+
validateDesignSpec,
|
|
12
|
+
EMPTY_DESIGN_MD,
|
|
13
|
+
DESIGN_SECTION_IDS,
|
|
14
|
+
} from "../index";
|
|
15
|
+
|
|
16
|
+
describe("parseDesignMd", () => {
|
|
17
|
+
it("parses an empty source without throwing", () => {
|
|
18
|
+
const spec = parseDesignMd("");
|
|
19
|
+
expect(spec.title).toBeUndefined();
|
|
20
|
+
for (const id of DESIGN_SECTION_IDS) {
|
|
21
|
+
expect(spec.sections[id].present).toBe(false);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("captures the H1 title", () => {
|
|
26
|
+
const spec = parseDesignMd("# Acme DESIGN.md\n\n## Color Palette\n\n- primary — #000\n");
|
|
27
|
+
expect(spec.title).toBe("Acme DESIGN.md");
|
|
28
|
+
expect(spec.sections["color-palette"].present).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("recognises all 9 sections via fuzzy heading matching", () => {
|
|
32
|
+
const md = `# X
|
|
33
|
+
|
|
34
|
+
## Visual Theme & Philosophy
|
|
35
|
+
Minimal and dense.
|
|
36
|
+
|
|
37
|
+
## Color Palette
|
|
38
|
+
- primary — #FF8C42 — brand action
|
|
39
|
+
|
|
40
|
+
## Typography
|
|
41
|
+
- display — Inter, 48px, weight 700 — hero
|
|
42
|
+
|
|
43
|
+
## Components
|
|
44
|
+
### Button
|
|
45
|
+
variant: primary | secondary | ghost
|
|
46
|
+
|
|
47
|
+
## Layout
|
|
48
|
+
- md — 16px
|
|
49
|
+
|
|
50
|
+
## Depth & Elevation
|
|
51
|
+
- card: 0 1px 2px rgba(0,0,0,.06)
|
|
52
|
+
|
|
53
|
+
## Do's & Don'ts
|
|
54
|
+
### Do
|
|
55
|
+
- Use tokens
|
|
56
|
+
|
|
57
|
+
### Don't
|
|
58
|
+
- Inline btn-hard
|
|
59
|
+
|
|
60
|
+
## Responsive
|
|
61
|
+
- mobile — 640px
|
|
62
|
+
|
|
63
|
+
## Agent Prompts
|
|
64
|
+
### hero
|
|
65
|
+
Generate using display token.
|
|
66
|
+
`;
|
|
67
|
+
const spec = parseDesignMd(md);
|
|
68
|
+
expect(spec.sections.theme.present).toBe(true);
|
|
69
|
+
expect(spec.sections.theme.summary).toBe("Minimal and dense.");
|
|
70
|
+
expect(spec.sections["color-palette"].tokens).toEqual([
|
|
71
|
+
{ name: "primary", value: "#FF8C42", role: "brand action" },
|
|
72
|
+
]);
|
|
73
|
+
expect(spec.sections.typography.tokens[0]?.name).toBe("display");
|
|
74
|
+
expect(spec.sections.typography.tokens[0]?.size).toBe("48px");
|
|
75
|
+
expect(spec.sections.components.tokens[0]?.name).toBe("Button");
|
|
76
|
+
expect(spec.sections.components.tokens[0]?.variants.variant).toEqual([
|
|
77
|
+
"primary",
|
|
78
|
+
"secondary",
|
|
79
|
+
"ghost",
|
|
80
|
+
]);
|
|
81
|
+
expect(spec.sections.layout.tokens[0]).toEqual({ name: "md", value: "16px" });
|
|
82
|
+
expect(spec.sections.shadows.tokens[0]?.name).toBe("card");
|
|
83
|
+
expect(spec.sections["dos-donts"].rules.find((r) => r.kind === "do")?.text).toBe(
|
|
84
|
+
"Use tokens",
|
|
85
|
+
);
|
|
86
|
+
expect(spec.sections["dos-donts"].rules.find((r) => r.kind === "dont")?.text).toBe(
|
|
87
|
+
"Inline btn-hard",
|
|
88
|
+
);
|
|
89
|
+
expect(spec.sections.responsive.breakpoints[0]).toEqual({
|
|
90
|
+
name: "mobile",
|
|
91
|
+
value: "640px",
|
|
92
|
+
notes: undefined,
|
|
93
|
+
});
|
|
94
|
+
expect(spec.sections["agent-prompts"].prompts[0]?.title).toBe("hero");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("treats unknown H2 sections as extras (round-trip)", () => {
|
|
98
|
+
const md = `## Color Palette\n- primary — #000\n\n## Internationalization\nKO + EN only.\n`;
|
|
99
|
+
const spec = parseDesignMd(md);
|
|
100
|
+
expect(spec.sections["color-palette"].present).toBe(true);
|
|
101
|
+
expect(spec.extraSections).toHaveLength(1);
|
|
102
|
+
expect(spec.extraSections[0]?.heading).toBe("Internationalization");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("absorbs malformed rows without throwing", () => {
|
|
106
|
+
const md = `## Color Palette
|
|
107
|
+
- primary — #FF8C42 — brand
|
|
108
|
+
- garbage row with no shape at all
|
|
109
|
+
- — — — —
|
|
110
|
+
- accent — not-a-color — ok
|
|
111
|
+
`;
|
|
112
|
+
expect(() => parseDesignMd(md)).not.toThrow();
|
|
113
|
+
const spec = parseDesignMd(md);
|
|
114
|
+
const tokens = spec.sections["color-palette"].tokens;
|
|
115
|
+
// primary (with hex) survives. accent (no hex) is captured with name+role only.
|
|
116
|
+
expect(tokens.find((t) => t.name === "primary")?.value).toBe("#FF8C42");
|
|
117
|
+
expect(tokens.length).toBeGreaterThanOrEqual(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("supports markdown table rows for color palette", () => {
|
|
121
|
+
const md = `## Color Palette
|
|
122
|
+
| Name | Hex | Role |
|
|
123
|
+
|------|-----|------|
|
|
124
|
+
| primary | #000000 | brand |
|
|
125
|
+
| surface | #ffffff | background |
|
|
126
|
+
`;
|
|
127
|
+
const spec = parseDesignMd(md);
|
|
128
|
+
const names = spec.sections["color-palette"].tokens.map((t) => t.name);
|
|
129
|
+
expect(names).toContain("primary");
|
|
130
|
+
expect(names).toContain("surface");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("parses the empty skeleton without finding tokens", () => {
|
|
134
|
+
const spec = parseDesignMd(EMPTY_DESIGN_MD);
|
|
135
|
+
expect(spec.title).toBe("DESIGN.md");
|
|
136
|
+
// All sections are present (headings exist) but have no structured tokens.
|
|
137
|
+
for (const id of DESIGN_SECTION_IDS) {
|
|
138
|
+
expect(spec.sections[id].present).toBe(true);
|
|
139
|
+
}
|
|
140
|
+
expect(spec.sections["color-palette"].tokens).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("validateDesignSpec", () => {
|
|
145
|
+
it("flags empty source with all 9 missing", () => {
|
|
146
|
+
const result = validateDesignSpec(parseDesignMd(""));
|
|
147
|
+
expect(result.ok).toBe(false);
|
|
148
|
+
expect(result.issues.filter((i) => i.kind === "missing")).toHaveLength(9);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("flags the empty skeleton as `empty` for every section", () => {
|
|
152
|
+
const result = validateDesignSpec(parseDesignMd(EMPTY_DESIGN_MD));
|
|
153
|
+
// All sections present, all empty — so no `missing`, all `empty`.
|
|
154
|
+
expect(result.issues.every((i) => i.kind === "empty")).toBe(true);
|
|
155
|
+
expect(result.issues).toHaveLength(9);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns ok=true when every section has structured content", () => {
|
|
159
|
+
const md = `# X
|
|
160
|
+
|
|
161
|
+
## Visual Theme & Philosophy
|
|
162
|
+
Minimal.
|
|
163
|
+
|
|
164
|
+
## Color Palette
|
|
165
|
+
- primary — #000
|
|
166
|
+
|
|
167
|
+
## Typography
|
|
168
|
+
- body — Inter, 16px
|
|
169
|
+
|
|
170
|
+
## Components
|
|
171
|
+
### Button
|
|
172
|
+
variant: primary | secondary
|
|
173
|
+
|
|
174
|
+
## Layout
|
|
175
|
+
- md — 16px
|
|
176
|
+
|
|
177
|
+
## Depth & Elevation
|
|
178
|
+
- card: 0 1px 2px rgba(0,0,0,.06)
|
|
179
|
+
|
|
180
|
+
## Do's & Don'ts
|
|
181
|
+
### Do
|
|
182
|
+
- Use tokens
|
|
183
|
+
|
|
184
|
+
## Responsive
|
|
185
|
+
- mobile — 640px
|
|
186
|
+
|
|
187
|
+
## Agent Prompts
|
|
188
|
+
### default
|
|
189
|
+
Use tokens.
|
|
190
|
+
`;
|
|
191
|
+
const result = validateDesignSpec(parseDesignMd(md));
|
|
192
|
+
expect(result.ok).toBe(true);
|
|
193
|
+
expect(result.issues).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@mandujs/core/design` — DESIGN.md primitives.
|
|
3
|
+
*
|
|
4
|
+
* Public surface for Issue #245 M1 (parser + scaffold + import +
|
|
5
|
+
* validate). Other modules — Guard rule, MCP discovery, token bridge —
|
|
6
|
+
* consume `parseDesignMd` and the `DesignSpec` type.
|
|
7
|
+
*
|
|
8
|
+
* @module core/design
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
parseDesignMd,
|
|
13
|
+
validateDesignSpec,
|
|
14
|
+
humanizeSectionId,
|
|
15
|
+
} from "./parser";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
EMPTY_DESIGN_MD,
|
|
19
|
+
fetchUpstreamDesignMd,
|
|
20
|
+
AWESOME_DESIGN_MD_RAW_BASE,
|
|
21
|
+
} from "./scaffold";
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
AgentPrompt,
|
|
25
|
+
AgentPromptsSection,
|
|
26
|
+
AnyDesignSection,
|
|
27
|
+
ColorPaletteSection,
|
|
28
|
+
ColorToken,
|
|
29
|
+
ComponentToken,
|
|
30
|
+
ComponentsSection,
|
|
31
|
+
DesignSection,
|
|
32
|
+
DesignSectionId,
|
|
33
|
+
DesignSpec,
|
|
34
|
+
DoDontRule,
|
|
35
|
+
DosDontsSection,
|
|
36
|
+
LayoutSection,
|
|
37
|
+
ResponsiveBreakpoint,
|
|
38
|
+
ResponsiveSection,
|
|
39
|
+
ShadowToken,
|
|
40
|
+
ShadowsSection,
|
|
41
|
+
SpacingToken,
|
|
42
|
+
ThemeSection,
|
|
43
|
+
TypographyToken,
|
|
44
|
+
TypographySection,
|
|
45
|
+
ValidationIssue,
|
|
46
|
+
ValidationResult,
|
|
47
|
+
} from "./types";
|
|
48
|
+
|
|
49
|
+
export { DESIGN_SECTION_IDS } from "./types";
|