#!/usr/bin/env node // ============================================================= // ir-extract.mjs — the pivot (WHYNOT-WP-0002 · T05) // // Reads the local React designbook mirror (designbook/) and emits the // technology-neutral IR (ir/): tokens, per-component contracts, and exemplars. // One-way only: React → IR. Never hand-edit ir/tokens.json, ir/components/*, // or ir/exemplars/* — they are regenerated here (`make ir`) and committed so a // blueprint change shows up as a git diff. See ir/SCHEMA.md and // .claude/rules/designbook-propagation.md. // // Source layout (a bundled .jsx ui-kit, not per-component .d.ts — see // designbook/REACT_CANONICAL_DECISION.md): // designbook/_ds_manifest.json → structured tokens[] + preview cards[] // designbook/ui_kits//Atoms.jsx → component fn signatures (props/defaults) // designbook/ui_kits//Chrome.jsx → component fn signatures // designbook/preview/comp-*.html → exemplar renders // ============================================================= import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, existsSync, readdirSync } from "node:fs"; import { createHash } from "node:crypto"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const REPO = join(dirname(fileURLToPath(import.meta.url)), ".."); const DESIGNBOOK = join(REPO, "designbook"); const IR = join(REPO, "ir"); const KIT = "whynot-control"; // Bump when the SHAPE of ir/manifest.json changes (consumers branch on this). // Hash stability is governed here: any change that would alter existing hashes // without a meaningful contract change must bump this so a consumer can tell a // re-canonicalisation apart from a real design change. See ir/SCHEMA.md. const MANIFEST_SCHEMA_VERSION = "1.0.0"; // Which ui-kit files hold reusable design-system components (NOT app screens/demo data). const COMPONENT_SOURCES = ["Atoms.jsx", "Chrome.jsx"]; const log = (...a) => console.log(...a); const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); // ---------- Tokens: _ds_manifest.json tokens[] → W3C DTCG ---------- // Each manifest token: { name:"--ink", value:"#0A0A0A", kind:"color", definedIn }. // Group + DTCG $type are decided by name prefix (kind is a fallback hint). const GROUP_BY_PREFIX = [ ["--ff-", "fontFamily", "fontFamily"], ["--fs-", "fontSize", "dimension"], ["--lh-", "lineHeight", "number"], ["--tr-", "letterSpacing", "dimension"], ["--sp-", "space", "dimension"], ["--r-", "radius", "dimension"], ["--shadow-", "shadow", "shadow"], ]; function classify(name) { for (const [prefix, group, type] of GROUP_BY_PREFIX) { if (name.startsWith(prefix)) return { group, type }; } return { group: "color", type: "color" }; // every remaining token is a colour } function extractTokens() { const manifest = JSON.parse(readFileSync(join(DESIGNBOOK, "_ds_manifest.json"), "utf8")); const tokens = manifest.tokens || []; // First pass: map each css var → its DTCG reference path {group.key}. const refOf = new Map(); for (const t of tokens) { const { group } = classify(t.name); refOf.set(t.name, `${group}.${t.name.slice(2)}`); } // Second pass: build the nested DTCG tree. const out = {}; for (const t of tokens) { const { group, type } = classify(t.name); const key = t.name.slice(2); out[group] ||= { $type: type }; // Resolve `var(--x)` aliases to DTCG references; literals pass through. const aliasMatch = /^var\(\s*(--[A-Za-z0-9-]+)\s*\)$/.exec(t.value.trim()); let value = t.value.replace(/\s+/g, " ").trim(); if (aliasMatch && refOf.has(aliasMatch[1])) value = `{${refOf.get(aliasMatch[1])}}`; out[group][key] = { $value: value }; } return out; } // ---------- Components: parse .jsx function signatures ---------- // Matches: function Name({ a, b = 'x', children, onClick, style }) { … } const FN_RE = /function\s+([A-Z][A-Za-z0-9]*)\s*\(\s*\{([^}]*)\}\s*\)\s*\{/g; function parseParams(raw) { // Split top-level commas (defaults here have no nested commas/objects). return raw.split(",").map((s) => s.trim()).filter(Boolean).map((p) => { const eq = p.indexOf("="); if (eq === -1) return { name: p.trim(), default: undefined }; return { name: p.slice(0, eq).trim(), default: p.slice(eq + 1).trim() }; }); } // Collect enum values from `name === 'x'` / `name !== 'x'` comparisons in the body. function enumValuesFor(name, body) { const re = new RegExp(`${name}\\s*[=!]==\\s*'([^']+)'`, "g"); const vals = new Set(); let m; while ((m = re.exec(body))) vals.add(m[1]); return [...vals]; } function usedAsBoolean(name, body) { return new RegExp(`(if\\s*\\(\\s*${name}\\b|\\b${name}\\s*\\?|\\b${name}\\s*&&)`).test(body) && !new RegExp(`${name}\\s*===`).test(body); } function defaultLiteral(raw) { if (raw === undefined) return undefined; if (/^'[^']*'$/.test(raw) || /^"[^"]*"$/.test(raw)) return raw.slice(1, -1); if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw); if (raw === "true" || raw === "false") return raw === "true"; return raw; // expression default; keep verbatim } function propFor(param, body) { const { name, default: dflt } = param; const def = defaultLiteral(dflt); // children → slot, handled by caller. style/object & callbacks → non-portable. if (name === "style") { return { name, type: "object", attribute: false, portable: false, description: "React inline style override — not portable to an attribute." }; } if (/^on[A-Z]/.test(name)) { return { name, type: "function", attribute: false, portable: false, description: "React callback prop — surface as an event on attribute-driven stacks." }; } const prop = { name, attribute: kebab(name) }; const enums = enumValuesFor(name, body); if (typeof def === "string" && enums.length) { prop.type = "enum"; prop.enum = [...new Set([def, ...enums])]; } else if (enums.length) { prop.type = "enum"; prop.enum = enums; } else if (typeof def === "number") { prop.type = "number"; } else if (typeof def === "boolean" || usedAsBoolean(name, body)) { prop.type = "boolean"; } else { prop.type = "string"; } if (def !== undefined) prop.default = def; return prop; } function bodyOf(src, startIdx) { // Return the balanced { … } body beginning at the first { at/after startIdx. let depth = 0, i = src.indexOf("{", startIdx); const begin = i; for (; i < src.length; i++) { if (src[i] === "{") depth++; else if (src[i] === "}" && --depth === 0) return src.slice(begin, i + 1); } return src.slice(begin); } // Map a component to a preview exemplar via the manifest cards (best-effort). function buildExemplarIndex() { const manifest = JSON.parse(readFileSync(join(DESIGNBOOK, "_ds_manifest.json"), "utf8")); return (manifest.cards || []).filter((c) => c.group === "Components"); } function matchExemplar(name, cards) { const n = name.toLowerCase(); // direct hints: Button→comp-buttons, Tag/StageDot→comp-labels-tags, etc. const hint = { stagedot: "labels-tags", tag: "labels-tags", eyebrow: "labels-tags", pipelinestrip: "pipeline", topnav: "topnav", sidebar: "left-nav", pageheader: "topnav", button: "buttons" }[n]; const want = hint || n; return cards.find((c) => c.path.includes(want)) || null; } function extractComponents() { const cards = buildExemplarIndex(); const components = []; for (const file of COMPONENT_SOURCES) { const path = join(DESIGNBOOK, "ui_kits", KIT, file); if (!existsSync(path)) { log(` (skip ${file} — not present)`); continue; } const src = readFileSync(path, "utf8"); const group = file.replace(".jsx", "").toLowerCase(); let m; FN_RE.lastIndex = 0; while ((m = FN_RE.exec(src))) { const name = m[1]; const params = parseParams(m[2]); // FN_RE ends at the function body's opening brace; start the body scan there // (not at m.index, whose first `{` is the param-destructuring brace). const body = bodyOf(src, m.index + m[0].length - 1); const props = []; const slots = []; for (const p of params) { if (p.name === "children") { slots.push({ name: "default", description: "Default content." }); continue; } props.push(propFor(p, body)); } const events = props.filter((p) => /^on[A-Z]/.test(p.name)) .map((p) => ({ name: kebab(p.name.replace(/^on/, "wn-")), description: `Emitted for ${p.name}.` })); const variants = props.filter((p) => p.type === "enum") .map((p) => ({ axis: p.name, values: p.enum, ...(p.default ? { default: p.default } : {}) })); const card = matchExemplar(name, cards); const contract = { name, tag: `wn-${kebab(name)}`, group, description: `${name} — extracted from designbook ui_kits/${KIT}/${file}.`, props, ...(slots.length ? { slots } : {}), ...(events.length ? { events } : {}), ...(variants.length ? { variants } : {}), docsRef: `designbook/ui_kits/${KIT}/${file}`, ...(card ? { exemplarRef: `ir/exemplars/${name}.html` } : {}), }; components.push({ contract, card }); } } return components; } // ---------- Lightweight contract validation (invariants from ir/schema) ---------- function validateContract(c) { const errs = []; if (!/^[A-Z][A-Za-z0-9]*$/.test(c.name)) errs.push(`bad name ${c.name}`); if (!c.group || !c.description) errs.push(`${c.name}: missing group/description`); for (const p of c.props) { if (!p.name) errs.push(`${c.name}: prop without name`); if (p.type === "enum" && !(p.enum && p.enum.length)) errs.push(`${c.name}.${p.name}: enum without values`); if (!("attribute" in p)) errs.push(`${c.name}.${p.name}: missing attribute mapping`); } return errs; } // ---------- Manifest: deterministic content hashes (WHYNOT-WP-0003 · T03) ---------- // Canonicalise before hashing so the hash is invariant to key order and // whitespace and sensitive ONLY to meaningful contract/token change. function canonicalise(value) { if (Array.isArray(value)) return value.map(canonicalise); if (value && typeof value === "object") { return Object.keys(value).sort().reduce((acc, k) => { acc[k] = canonicalise(value[k]); return acc; }, {}); } return value; } function contentHash(value) { return "sha256:" + createHash("sha256") .update(JSON.stringify(canonicalise(value))) .digest("hex") .slice(0, 16); } // Build ir/manifest.json. `generatedAt` is reused from the prior manifest when // nothing hashed changed, so a no-op `make ir` does not churn the committed file. function buildManifest(tokens, contracts) { const pkg = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")); const components = contracts .map((c) => ({ name: c.name, group: c.group, hash: contentHash(c) })) .sort((a, b) => a.name.localeCompare(b.name)); const tokensHash = contentHash(tokens); const manifestPath = join(IR, "manifest.json"); let generatedAt = new Date().toISOString(); if (existsSync(manifestPath)) { try { const prev = JSON.parse(readFileSync(manifestPath, "utf8")); const unchanged = prev.schemaVersion === MANIFEST_SCHEMA_VERSION && prev.designVersion === pkg.version && prev.tokensHash === tokensHash && JSON.stringify(prev.components) === JSON.stringify(components); if (unchanged && prev.generatedAt) generatedAt = prev.generatedAt; } catch { /* malformed prior manifest — regenerate fresh */ } } return { schemaVersion: MANIFEST_SCHEMA_VERSION, designVersion: pkg.version, generatedAt, tokensHash, components, }; } // ---------- Index: human-readable catalog (WHYNOT-WP-0003 · T07) ---------- // A browsable view of a version — no clone or run needed. Generated from the // same contracts the manifest hashes, so the two never disagree. function buildIndex(manifest, contracts) { const byGroup = new Map(); for (const c of contracts) { if (!byGroup.has(c.group)) byGroup.set(c.group, []); byGroup.get(c.group).push(c); } const hashOf = new Map(manifest.components.map((m) => [m.name, m.hash])); const lines = []; lines.push(""); lines.push(`# whynot-design IR catalog`); lines.push(""); lines.push(`**designVersion** \`${manifest.designVersion}\` · **components** ${contracts.length} · **generated** ${manifest.generatedAt}`); lines.push(""); lines.push("Machine-readable companion: [`manifest.json`](./manifest.json) (per-component + token hashes)."); lines.push(""); for (const group of [...byGroup.keys()].sort()) { lines.push(`## ${group}`); lines.push(""); for (const c of byGroup.get(group).sort((a, b) => a.name.localeCompare(b.name))) { lines.push(`### ${c.name} \`<${c.tag}>\``); lines.push(""); lines.push(c.description); lines.push(""); const portableProps = (c.props || []).filter((p) => p.portable !== false); if (portableProps.length) { lines.push("| prop | attribute | type | default |"); lines.push("| --- | --- | --- | --- |"); for (const p of portableProps) { const type = p.type === "enum" ? `enum(${(p.enum || []).join(" \\| ")})` : p.type; lines.push(`| \`${p.name}\` | \`${p.attribute}\` | ${type} | ${p.default !== undefined ? `\`${p.default}\`` : "—"} |`); } lines.push(""); } const nonPortable = (c.props || []).filter((p) => p.portable === false); if (nonPortable.length) { lines.push(`_Non-portable (React-only) props:_ ${nonPortable.map((p) => `\`${p.name}\``).join(", ")}.`); lines.push(""); } if (c.slots) lines.push(`**Slots:** ${c.slots.map((s) => `\`${s.name}\``).join(", ")} `); if (c.events) lines.push(`**Events:** ${c.events.map((e) => `\`${e.name}\``).join(", ")} `); if (c.variants) lines.push(`**Variants:** ${c.variants.map((v) => `${v.axis} (${v.values.join("/")})`).join(", ")} `); lines.push(`**Contract:** [\`components/${c.name}.json\`](./components/${c.name}.json) · **hash** \`${hashOf.get(c.name)}\`` + (c.exemplarRef ? ` · **exemplar:** [\`exemplars/${c.name}.html\`](./exemplars/${c.name}.html)` : "")); lines.push(""); } } return lines.join("\n"); } // ---------- Emit ---------- function resetDir(dir) { if (existsSync(dir)) for (const f of readdirSync(dir)) rmSync(join(dir, f), { recursive: true, force: true }); else mkdirSync(dir, { recursive: true }); } function main() { if (!existsSync(join(DESIGNBOOK, "_ds_manifest.json"))) { console.error("No designbook/_ds_manifest.json — run `make designbook-pull` first."); process.exit(2); } mkdirSync(IR, { recursive: true }); log("Tokens → ir/tokens.json"); const tokens = extractTokens(); writeFileSync(join(IR, "tokens.json"), JSON.stringify(tokens, null, 2) + "\n"); const tokenCount = Object.values(tokens).reduce((n, g) => n + Object.keys(g).filter((k) => k !== "$type").length, 0); log(` ${tokenCount} tokens in ${Object.keys(tokens).length} groups`); log("Components → ir/components/.json"); const comps = extractComponents(); resetDir(join(IR, "components")); resetDir(join(IR, "exemplars")); const allErrs = []; for (const { contract, card } of comps) { allErrs.push(...validateContract(contract)); writeFileSync(join(IR, "components", `${contract.name}.json`), JSON.stringify(contract, null, 2) + "\n"); if (card) { const srcHtml = join(DESIGNBOOK, card.path); if (existsSync(srcHtml)) copyFileSync(srcHtml, join(IR, "exemplars", `${contract.name}.html`)); } } log(` ${comps.length} components, ${comps.filter((c) => c.card).length} with exemplars`); if (allErrs.length) { console.error("\nContract validation errors:"); for (const e of allErrs) console.error(" - " + e); process.exit(5); } log("Manifest → ir/manifest.json"); const manifest = buildManifest(tokens, comps.map((c) => c.contract)); writeFileSync(join(IR, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); log(` designVersion ${manifest.designVersion}, ${manifest.components.length} components, tokensHash ${manifest.tokensHash}`); log("Index → ir/INDEX.md"); writeFileSync(join(IR, "INDEX.md"), buildIndex(manifest, comps.map((c) => c.contract))); log(` catalog for ${manifest.components.length} components`); log("\nIR extracted. Review the ir/ git diff (the blueprint change)."); } main();