Make ir/ the unit of versioned downstream consumption so consuming repos can pin a version, inspect it, and follow changes at their own pace. - T03 ir/manifest.json: per-version inventory + diff anchor with deterministic sha256-over-canonicalised-JSON hashes; no-churn generatedAt; manifest schema. - T07 ir/INDEX.md: human-readable catalog generated by make ir. - T04 .whynot-design.lock sync-point format + lock schema. - T05 npx @whynot/design drift: consumer drift-check (bin entry), exit 0/2/3, --json/--update/--manifest/--version/--lock. - T06 CONSUMING.md guide + examples/consumer-fixture/ runnable demo; README + MultiFrameworkSupport cross-links; fix README version pin (@0.3.0 not @v0.3.0). - T09 CONSUMER_CONTRACT_PARITY.md design-only note (live-UI parity deferred). T02 (publish) and T08 (showcase, blocked on WP-0002 T11) remain wait. Repo stays in dev mode; no outward publish performed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
16 KiB
JavaScript
396 lines
16 KiB
JavaScript
#!/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/<kit>/Atoms.jsx → component fn signatures (props/defaults)
|
|
// designbook/ui_kits/<kit>/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("<!-- GENERATED by scripts/ir-extract.mjs (make ir) — do not hand-edit. -->");
|
|
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/<Name>.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();
|