feat(designbook): technology-neutral IR + stack-adapter pipeline (WHYNOT-WP-0002 T01-T06)
Author the design language once in the canonical React designbook and project it
one-way onto each stack: React -> designbook/ -> ir/ -> adapters/<stack>/.
Phase 0 — contracts & governance (T01-T03):
- ir/SCHEMA.md + ir/schema/{component,tokens}.schema.json — neutral IR contract
(W3C DTCG tokens; React prop -> HTML attribute mapping; non-portable props flagged).
- adapters/ADAPTER_CONTRACT.md — inputs, drift-report + parity-result shapes,
idempotency rules, CI exit codes (0 ok / 2 usage / 3 drift / 4 parity / 5 internal).
- .claude/rules/designbook-propagation.md + DesignSystemIntroduction.md §5.1 —
one-way directionality + drift-resolution workflow.
T04 — canonical React designbook + the missing pull tool:
- The bundled /design-sync skill only PUSHES repo->cloud; it cannot populate
designbook/. Added scripts/designbook_pull.py + `make designbook-pull`, which drives
the local claude binary headless (acceptEdits) so DesignSync fetch+write runs in a
subprocess (contents never hit the orchestrator's context). Pulled 44 files;
excludes the _whynot-design-seed/ self-copy. Corrected the docs that wrongly called
/design-sync the pull.
T05 — IR extractor (scripts/ir-extract.mjs + `make ir`):
- ir/tokens.json (80 tokens, DTCG, var() -> {ref} alias resolution); ir/components/*.json
(10 contracts parsed from .jsx signatures: enum/boolean/number inference, prop->attr
map, style/callback marked non-portable); ir/exemplars/*.
T06 — Lit token adapter (adapters/lit/ + `make adapt-lit`):
- Full-gen tokens into src/styles/colors_and_type.css :root (marker-bounded, idempotent
no-op on re-run; hand-authored type CSS preserved).
NOTE: token regen synced Lit to canonical React — fonts IBM Plex -> system stacks and 8
status tokens added. This is a VISUAL change: review and run `pnpm test:visual:update`
before merge. Remaining: T07 scaffold+drift, T08 parity, T09 runbook, T10 2nd-adapter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
226
scripts/designbook_pull.py
Normal file
226
scripts/designbook_pull.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pull the canonical React designbook from Claude Design into ``designbook/``.
|
||||
|
||||
WHYNOT-WP-0002 needs a one-way flow: the React designbook in Claude Design is the
|
||||
source of truth, and ``make ir`` extracts the technology-neutral blueprint from a
|
||||
*local mirror* of it (``designbook/``). The bundled ``/design-sync`` skill only goes
|
||||
the other way (it *pushes* a repo up to Claude Design), so it cannot populate
|
||||
``designbook/``. This script is the missing **pull** half.
|
||||
|
||||
The only thing that can read your Claude Design project is the local ``claude``
|
||||
binary, which has the DesignSync tool over your claude.ai login. This script drives
|
||||
it directly in headless mode (``claude --print --permission-mode acceptEdits``): the
|
||||
subprocess fetches AND writes the files itself, in *its* context, and returns only a
|
||||
small JSON manifest. The (potentially large) file contents never pass through the
|
||||
orchestrating agent's context, so the pull is cheap no matter how many files it
|
||||
moves. ``acceptEdits`` is required because a plain ``claude --print`` (e.g. the
|
||||
llm-connect claude-code adapter used by check_designbook_staleness.py) auto-denies
|
||||
``Write`` in non-interactive mode — fine for that read-only check, but this pull must
|
||||
write. No secret goes in the prompt — DesignSync authenticates through the local
|
||||
login (see .claude/rules/credential-routing.md).
|
||||
|
||||
python scripts/designbook_pull.py # pull, then stamp freshness
|
||||
python scripts/designbook_pull.py --project <uuid> # override the target project
|
||||
python scripts/designbook_pull.py --dry-run # print the plan; fetch nothing
|
||||
python scripts/designbook_pull.py --no-stamp # skip the --mark-synced step
|
||||
|
||||
What is pulled is governed by ``designbook/.design-pull.json`` (created with sane
|
||||
defaults on first run): ``include``/``exclude`` glob lists over the project's paths.
|
||||
The defaults take the React ui-kit, the preview/exemplar cards, the manifest and the
|
||||
style/token layers, and deliberately EXCLUDE ``_whynot-design-seed/**`` (a copy of
|
||||
this very repo that lives in the cloud project and must not shadow the real repo).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
DESIGNBOOK = REPO / "designbook"
|
||||
MARKER = DESIGNBOOK / ".design-sync.json"
|
||||
PIN = REPO / ".design-sync" / "config.json" # the /design-sync skill's pin
|
||||
PULL_CONFIG = DESIGNBOOK / ".design-pull.json" # what this script mirrors
|
||||
|
||||
# Sane first-run defaults. Editable; committed so the pull is reproducible.
|
||||
DEFAULT_PULL_CONFIG = {
|
||||
"comment": "Globs (over Claude Design project paths) that designbook_pull.py mirrors "
|
||||
"into designbook/. Exclude _whynot-design-seed/** — it is a copy of THIS "
|
||||
"repo living in the cloud project and must not shadow the real source.",
|
||||
"include": [
|
||||
"ui_kits/**",
|
||||
"preview/**",
|
||||
"_ds_manifest.json",
|
||||
"_ds_bundle.js",
|
||||
"styles.css",
|
||||
"colors_and_type.css",
|
||||
],
|
||||
"exclude": [
|
||||
"_whynot-design-seed/**",
|
||||
"uploads/**",
|
||||
"_check/**",
|
||||
".thumbnail",
|
||||
"assets/**",
|
||||
],
|
||||
}
|
||||
|
||||
# Strict output contract for the headless claude call. The subprocess does ALL the
|
||||
# fetching and writing; it returns only a manifest so file bytes never reach us.
|
||||
PROMPT = """\
|
||||
You have the DesignSync tool (claude.ai/design) and Write/Bash tools. Mirror selected
|
||||
files from a Claude Design project into a local directory. Do NOT modify the remote
|
||||
project (no write_files/delete_files/create_project/finalize_plan). Do not request any
|
||||
secret or API key.
|
||||
|
||||
Target project: pick projectId {project_id!r} if non-null, else the writable project
|
||||
whose name best matches {project_name!r} (use DesignSync "list_projects").
|
||||
|
||||
Local destination root (absolute): {dest_root!r}
|
||||
|
||||
Selection — mirror every project path that matches ANY of these include globs:
|
||||
{include}
|
||||
…and matches NONE of these exclude globs:
|
||||
{exclude}
|
||||
(`**` matches any depth, `*` matches within one path segment.)
|
||||
|
||||
Steps:
|
||||
1. DesignSync "list_files" on the chosen project.
|
||||
2. Compute the selected set per the include/exclude globs above.
|
||||
3. For each selected path: DesignSync "get_file", then Write its content to
|
||||
{dest_root!r} + "/" + path (create parent dirs; preserve the relative path exactly;
|
||||
for base64/binary files decode before writing).
|
||||
4. Output ONLY a single JSON object, no prose, no code fence:
|
||||
{{"projectId": "<id>", "name": "<name>", "updatedAt": "<ISO-8601>",
|
||||
"written": ["<relpath>", ...], "skipped": ["<relpath>", ...]}}
|
||||
On failure output {{"error": "<short reason>"}}.
|
||||
"""
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text()) if path.exists() else {}
|
||||
|
||||
|
||||
def ensure_pull_config() -> dict:
|
||||
if not PULL_CONFIG.exists():
|
||||
DESIGNBOOK.mkdir(parents=True, exist_ok=True)
|
||||
PULL_CONFIG.write_text(json.dumps(DEFAULT_PULL_CONFIG, indent=2) + "\n")
|
||||
print(f"Wrote default pull config: {PULL_CONFIG.relative_to(REPO)}")
|
||||
return load_json(PULL_CONFIG)
|
||||
|
||||
|
||||
def target_project(cli_project: str | None) -> tuple[str | None, str]:
|
||||
"""Resolve (projectId, projectName) from CLI > marker > skill pin."""
|
||||
marker, pin = load_json(MARKER), load_json(PIN)
|
||||
project_id = cli_project or marker.get("projectId") or pin.get("projectId")
|
||||
project_name = marker.get("projectName") or pin.get("projectName") or "WhyNot Design System"
|
||||
return project_id, project_name
|
||||
|
||||
|
||||
def extract_json(text: str) -> dict:
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not match:
|
||||
sys.exit(f"Could not parse a JSON object from the model reply:\n{text[:600]}")
|
||||
return json.loads(match.group(0))
|
||||
|
||||
|
||||
def resolve_claude_cli() -> str:
|
||||
"""Mirror the llm-connect adapter's resolution: env override, else ~/.local/bin."""
|
||||
configured = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if configured:
|
||||
return configured
|
||||
local_cli = Path.home() / ".local" / "bin" / "claude"
|
||||
return str(local_cli) if local_cli.exists() else "claude"
|
||||
|
||||
|
||||
def run_pull(project_id: str | None, project_name: str, cfg: dict) -> dict:
|
||||
prompt = PROMPT.format(
|
||||
project_id=project_id,
|
||||
project_name=project_name,
|
||||
dest_root=str(DESIGNBOOK),
|
||||
include="\n".join(f" - {g}" for g in cfg.get("include", [])),
|
||||
exclude="\n".join(f" - {g}" for g in cfg.get("exclude", [])),
|
||||
)
|
||||
# acceptEdits auto-approves the subprocess's Write calls (it creates parent dirs);
|
||||
# DesignSync reads are already permitted under default policy. cwd=REPO so any
|
||||
# relative reasoning stays inside the repo, though dest paths are absolute.
|
||||
cmd = [resolve_claude_cli(), "--print", "--permission-mode", "acceptEdits"]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, input=prompt, cwd=REPO,
|
||||
capture_output=True, text=True, timeout=1800,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
sys.exit("Could not find the `claude` CLI. Set CLAUDE_CLI_PATH or add it to PATH.")
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.exit("claude CLI timed out after 1800s during the pull.")
|
||||
if result.returncode != 0:
|
||||
sys.exit(f"claude CLI exited {result.returncode}:\n{result.stderr[:600]}")
|
||||
return extract_json(result.stdout)
|
||||
|
||||
|
||||
def stamp(project_id: str | None, project_name: str, updated_at: str | None) -> None:
|
||||
cmd = ["node", "scripts/designbook-sync.mjs", "--mark-synced"]
|
||||
if project_id:
|
||||
cmd += ["--project", project_id]
|
||||
if project_name:
|
||||
cmd += ["--project-name", project_name]
|
||||
if updated_at:
|
||||
cmd += ["--remote-updated", updated_at]
|
||||
# Flags match designbook-sync.mjs --mark-synced [--remote-updated] [--project] [--project-name].
|
||||
subprocess.run(cmd, cwd=REPO, check=False)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--project", metavar="UUID", help="Override the target project id.")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="Print the resolved target + selection plan; fetch nothing.")
|
||||
ap.add_argument("--no-stamp", action="store_true",
|
||||
help="Do not run designbook-sync.mjs --mark-synced afterwards.")
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = ensure_pull_config()
|
||||
project_id, project_name = target_project(args.project)
|
||||
|
||||
print(f"Target : {project_name} ({project_id or 'by name match'})")
|
||||
print(f"Dest : {DESIGNBOOK.relative_to(REPO)}/")
|
||||
print(f"Include: {', '.join(cfg.get('include', [])) or '(none)'}")
|
||||
print(f"Exclude: {', '.join(cfg.get('exclude', [])) or '(none)'}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n(--dry-run: no DesignSync calls made, nothing written)")
|
||||
return 0
|
||||
|
||||
result = run_pull(project_id, project_name, cfg)
|
||||
if "error" in result:
|
||||
print(f"\nPull failed: {result['error']}")
|
||||
return 1
|
||||
|
||||
written = result.get("written", [])
|
||||
skipped = result.get("skipped", [])
|
||||
print(f"\nPulled {len(written)} file(s) into {DESIGNBOOK.relative_to(REPO)}/"
|
||||
f" (skipped {len(skipped)}).")
|
||||
for path in written[:40]:
|
||||
print(f" + {path}")
|
||||
if len(written) > 40:
|
||||
print(f" … and {len(written) - 40} more")
|
||||
|
||||
if not written:
|
||||
print("Nothing was written — check the include/exclude globs or the project.")
|
||||
return 1
|
||||
|
||||
if not args.no_stamp:
|
||||
stamp(result.get("projectId") or project_id,
|
||||
result.get("name") or project_name,
|
||||
result.get("updatedAt"))
|
||||
print("Stamped freshness. Next: review the designbook/ diff, then `make ir`.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
271
scripts/ir-extract.mjs
Normal file
271
scripts/ir-extract.mjs
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/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 { 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";
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ---------- 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("\nIR extracted. Review the ir/ git diff (the blueprint change).");
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user