diff --git a/.claude/commands/export-design-system.md b/.claude/commands/export-design-system.md index 468059e..9ce31a7 100644 --- a/.claude/commands/export-design-system.md +++ b/.claude/commands/export-design-system.md @@ -86,6 +86,21 @@ If the script exited non-zero, surface the exit code and the stderr message verb - **Output directory exists (exit 1):** Ask the user whether to `--force` overwrite or pick a new path. - **Unsupported framework (exit 3):** Only `react`, `vue`, `svelte`, `react-native` are supported. +## Round-trip / re-import + +The export is reversible. To reconstruct a `design-tokens.lock.json` from an +exported workspace — or to verify the round-trip in CI — use the inverse script: + +```bash +./scripts/import-design-tokens.sh --from dist/design-system --out design-tokens.lock.json +./scripts/import-design-tokens.sh --from dist/design-system --verify src/styles/design-tokens.lock.json +``` + +Only `tokens.json`/`tokens.ts` round-trip losslessly (`tokens.css` and the +Tailwind preset are derived views). See `docs/design-system-export/consumers.md` +for the interchange contract and how Flavian (WordPress `theme.json`) and Nerva +(typed tokens) consume the export. + ## Out of scope This command does NOT: diff --git a/.claude/skills/export-design-system/SKILL.md b/.claude/skills/export-design-system/SKILL.md index 19eb28a..b7b3da0 100644 --- a/.claude/skills/export-design-system/SKILL.md +++ b/.claude/skills/export-design-system/SKILL.md @@ -167,9 +167,33 @@ Common flag combinations: - **The script does not run `pnpm install` or `pnpm build`.** It only writes files. The user runs install/build to verify the workspace. - **Component file filtering is conservative.** `*.test.*`, `*.spec.*`, `*.stories.*`, `*.d.ts` are excluded. Co-located CSS modules and helper files with matching framework extensions ARE included. +## Re-importing & Round-Trip + +The exported `tokens.json` is a **lossless** snapshot of the lockfile, so the +export is fully reversible. `scripts/import-design-tokens.js` (the inverse of this +skill) reconstructs a `design-tokens.lock.json` from an exported package: + +```bash +# Reconstruct a lockfile from an exported workspace +./scripts/import-design-tokens.sh --from dist/design-system --out design-tokens.lock.json + +# Verify the round-trip (exit 0 if identical to the source, 1 if it drifted) +./scripts/import-design-tokens.sh --from dist/design-system --verify src/styles/design-tokens.lock.json +``` + +`--from` also accepts an installed package or a raw `tokens.json` path. Only +`tokens.json`/`tokens.ts` round-trip; `tokens.css` and `tailwind-preset.ts` are +derived, one-way views. See **`docs/design-system-export/consumers.md`** for the +interchange contract and how the named consumers ingest the export — **Flavian** +(WordPress/FSE `theme.json`) and **Nerva** (typed tokens in a Hono/Workers +backend). The guarantee is proved by `scripts/__tests__/design-system-roundtrip.test.js`. + ## Reference - Script: `scripts/export-design-system.js` - Wrapper: `scripts/export-design-system.sh` - Slash command: `/export-design-system` +- Re-importer: `scripts/import-design-tokens.js` / `scripts/import-design-tokens.sh` +- Round-trip test: `scripts/__tests__/design-system-roundtrip.test.js` +- Consumer contract: `docs/design-system-export/consumers.md` - Inputs: `design-tokens.lock.json` (from `design-token-lock` skill), `build-spec.json` (from `figma-intake`/`canva-intake`/`screenshot-intake` skills) diff --git a/CLAUDE.md b/CLAUDE.md index 741731c..fb444a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,9 @@ node scripts/validate-pipeline-config.js --config --schema # Export generated components as a publishable design-system workspace ./scripts/export-design-system.sh [--scope @org] [--output dir] [--framework ] [--dry-run] [--json] +# Re-import: reconstruct design-tokens.lock.json from an export (inverse of export; round-trip / consumer) +./scripts/import-design-tokens.sh [--from dir|tokens.json] [--out path|-] [--verify lockfile] [--force] [--json] + # Renderer registry: list / resolve / detect framework renderers (see docs/multi-framework/renderers.md) node scripts/renderer-registry.js (list | resolve | detect [dir]) [--json] node scripts/validate-renderer.js --dir # or --all [--renderers-root ] @@ -584,6 +587,7 @@ gh issue create # Create issue ./scripts/capture-baselines.sh # Capture regression baselines ./scripts/regression-test.sh # Visual regression testing ./scripts/export-design-system.sh # Export components + tokens as pnpm workspace +./scripts/import-design-tokens.sh # Reconstruct lockfile from an export (round-trip / consumer) ./scripts/validate-pipeline-config.sh # Validate pipeline.config.json against schema ./scripts/check-doc-counts.sh # Flag drift between documented agent/skill counts and disk ./scripts/verify-all.sh # Run all quality checks with summary diff --git a/README.md b/README.md index 322c4df..ba9b873 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ Details: `.claude/PLUGINS-REFERENCE.md` | Canva pipeline guide | `docs/canva-to-react/README.md` | Canva pipeline overview and troubleshooting | | Conversation pipeline guide | `docs/conversation-to-app/README.md` | Conversational app creation via generated Figma designs | | InDesign pipeline guide | `docs/pipelines/indesign.md` | Convert exported IDML/PDF into React components, tokens, and Storybook stories | +| Design system export | `docs/design-system-export/consumers.md` | Export format, round-trip re-import, and how Flavian/Nerva consume the tokens | | Agent catalog | `.claude/CUSTOM-AGENTS-GUIDE.md` | All 56 agents with use cases | | Skills catalog | `.claude/skills/README.md` | All 24 skills with triggers | | Plugin reference | `.claude/PLUGINS-REFERENCE.md` | Plugin configuration and commands | diff --git a/docs/design-system-export/consumers.md b/docs/design-system-export/consumers.md new file mode 100644 index 0000000..f2e124d --- /dev/null +++ b/docs/design-system-export/consumers.md @@ -0,0 +1,172 @@ +# Design System Export — Consumers & Round-Trip Contract + +`/export-design-system` turns a project's `design-tokens.lock.json` + components +into a publishable pnpm workspace (see the `export-design-system` skill for the +full output layout). This document defines **what a downstream project imports** +and the **round-trip guarantee** that makes the export a reliable interchange. + +## The interchange contract + +The exported `@scope/design-tokens` package ships four token artifacts. Two are +lossless reconstructions of the source lockfile; two are derived, ergonomic +_views_: + +| Artifact | Contents | Lossless? | Use it for | +|----------|----------|-----------|------------| +| `tokens.json` | verbatim JSON snapshot of `design-tokens.lock.json` | **Yes** | machine interchange, re-import, custom tooling | +| `tokens.ts` | `export const tokens = as const` | **Yes** | typed, tree-shakeable access in TS/JS consumers | +| `tokens.css` | flattened `--color-* / --space-* / --radius-*` custom properties | No | drop-in theming for web UIs | +| `tailwind-preset.ts` | `{ colors, spacing, borderRadius, fontFamily }` subset | No | extend a consumer's Tailwind config | + +**`tokens.json` is the canonical interchange.** It is a byte-for-byte snapshot of +the lockfile, so any consumer that reads it sees exactly the tokens the design +system was locked to. `tokens.css` and `tailwind-preset.ts` are one-way +projections — they intentionally drop structure (nested color scales, typography +families, `textContent`, `rgb`/`tailwind` metadata) and therefore **cannot** be +reversed back into a lockfile. + +## Round-trip guarantee + +`import-design-tokens` is the inverse of the exporter and the reference consumer. +It reads the lossless `tokens.json` and reconstructs an identical +`design-tokens.lock.json`: + +``` +lock ──/export-design-system──▶ @scope/design-tokens ──import-design-tokens──▶ lock′ + lock′ ≡ lock +``` + +```bash +# Reconstruct a lockfile from an exported workspace +./scripts/import-design-tokens.sh --from dist/design-system --out design-tokens.lock.json + +# From a published/installed package, or a raw tokens.json path +./scripts/import-design-tokens.sh --from node_modules/@acme/design-tokens/dist/tokens.json --out - + +# Verify a round-trip in CI (exit 0 when identical, 1 when it drifts) +./scripts/import-design-tokens.sh --from dist/design-system --verify src/styles/design-tokens.lock.json +``` + +The `--verify` mode is the executable form of the guarantee: it fails loudly if an +export ever stops being a faithful representation of its source tokens. The +automated proof lives in `scripts/__tests__/design-system-roundtrip.test.js` +(tokens → export → reimport → deep-equal). + +`--from` accepts an export root, a `design-tokens` package directory, or a +`tokens.json` file directly. It probes, in order: +`packages/design-tokens/src/tokens.json` → `.../dist/tokens.json` → +`src/tokens.json` → `dist/tokens.json` → `tokens.json`. + +## Consumers + +### Aurelius (self-consumer) + +Aurelius re-imports its own export to move a locked design system between +projects, or to validate that a hand-edited token package still round-trips. This +is the `import-design-tokens` path above. + +### Flavian — WordPress / FSE `theme.json` + +[Flavian](https://github.com/PMDevSolutions/Flavian) is a Claude Code-integrated +WordPress framework. A block theme's design surface is `theme.json`, so the +consumer installs the tokens package and maps `tokens.json` into +`theme.json.settings`: + +| Lockfile section | `theme.json` target | +|------------------|---------------------| +| `colors.semantic..hex` (fallback `colors.primitives`) | `settings.color.palette[]` — `{ slug, color, name }` | +| `typography.families.` (`value` + `fallback`) | `settings.typography.fontFamilies[]` — `{ slug, fontFamily, name }` | +| `spacing.scale..px` | `settings.spacing.spacingSizes[]` — `{ slug, size: "px", name }` | +| `borderRadius..px` | `settings.custom.radius.` → emits `--wp--custom--radius--` | + +Example adapter (run in the theme's build step): + +```js +// scripts/tokens-to-theme-json.mjs +import { readFileSync, writeFileSync } from "node:fs"; +// From the installed package: @acme/design-tokens/tokens.json +import tokens from "@acme/design-tokens/tokens.json" with { type: "json" }; + +const palette = Object.entries(tokens.colors?.semantic ?? {}).map(([slug, v]) => ({ + slug, + color: v.hex, + name: slug.replace(/(^|-)\w/g, (m) => m.toUpperCase().replace("-", " ")), +})); + +const fontFamilies = Object.entries(tokens.typography?.families ?? {}).map(([slug, v]) => ({ + slug, + fontFamily: [v.value, v.fallback].filter(Boolean).join(", "), + name: slug, +})); + +const spacingSizes = Object.entries(tokens.spacing?.scale ?? {}).map(([slug, v]) => ({ + slug, + size: `${v.px}px`, + name: slug, +})); + +const radius = Object.fromEntries( + Object.entries(tokens.borderRadius ?? {}).map(([k, v]) => [k, `${v.px}px`]), +); + +const themeJson = { + version: 3, + settings: { + color: { palette }, + typography: { fontFamilies }, + spacing: { spacingSizes }, + custom: { radius }, + }, +}; + +writeFileSync("theme.json", JSON.stringify(themeJson, null, 2) + "\n"); +``` + +Bumping the tokens package version (via Changesets) and re-running the adapter is +all that is needed to propagate a token change into the theme. + +### Nerva — Hono / Cloudflare Workers backend + +[Nerva](https://github.com/PMDevSolutions/Nerva) is a Claude Code-integrated API +framework (Hono, Cloudflare Workers, Drizzle ORM). Backends need tokens for +server-rendered surfaces — transactional emails, PDFs, or a design-tokens API — +where CSS is not available. The consumer imports the **typed** tokens, which are +tree-shakeable and require no runtime CSS: + +```ts +import { Hono } from "hono"; +import { tokens, type Tokens } from "@acme/design-tokens"; // typed, from tokens.ts + +const app = new Hono(); + +// Serve the canonical tokens to any client (mobile app, docs site, design tool) +app.get("/design-tokens", (c) => c.json(tokens)); + +// Or use tokens directly when rendering an email template server-side +function emailButton(label: string) { + const primary = tokens.colors.semantic.primary.hex; + const radius = tokens.borderRadius.md.px; + return `${label}`; +} + +export default app; +``` + +Because `@acme/design-tokens` is framework-agnostic and side-effect free (apart +from the optional `tokens.css`), it bundles cleanly into a Workers runtime. + +## Style Dictionary + +`tokens.json` is a plain nested map, so a consumer that standardizes on +[Style Dictionary](https://styledictionary.com/) can register it as a custom +source and run its own transforms. Aurelius does **not** require a Style +Dictionary build step — it ships ready-to-use CSS variables, a typed TS export, +and a Tailwind preset directly — but nothing prevents layering Style Dictionary on +top of the lossless `tokens.json`. + +## Reference + +- Exporter: `scripts/export-design-system.js` (skill: `export-design-system`, command: `/export-design-system`) +- Re-importer: `scripts/import-design-tokens.js` / `scripts/import-design-tokens.sh` +- Round-trip test: `scripts/__tests__/design-system-roundtrip.test.js` +- Lockfile source of truth: `design-token-lock` skill → `design-tokens.lock.json` diff --git a/docs/plans/2026-07-01-design-system-export-consumer-roundtrip-design.md b/docs/plans/2026-07-01-design-system-export-consumer-roundtrip-design.md new file mode 100644 index 0000000..6f8c044 --- /dev/null +++ b/docs/plans/2026-07-01-design-system-export-consumer-roundtrip-design.md @@ -0,0 +1,124 @@ +# Design System Export — Consumer Contract & Round-Trip + +**Issue:** #82 — Design system export command (`/export-design-system`) +**Date:** 2026-07-01 +**Milestone:** v2.0.0 + +## Problem + +`/export-design-system`, `scripts/export-design-system.{js,sh}`, and the +`export-design-system` skill already ship on `main` (acceptance criteria #1 and +#2). Two gaps remain from the v2.0.0 scope: + +- **#3 — a defined consumer.** The export is produced but nothing documents how a + downstream project ingests it. Sibling frameworks + [Flavian](https://github.com/PMDevSolutions/Flavian) (WordPress/FSE) and + [Nerva](https://github.com/PMDevSolutions/Nerva) (Hono + Cloudflare Workers) are + the named consumers. +- **#4 — a round-trip guarantee.** Nothing proves the export is a faithful, + reversible representation of the source tokens. + +## Key insight: which artifact round-trips + +The exported `@scope/design-tokens` package ships four token artifacts: + +| Artifact | Contents | Lossless? | +|----------|----------|-----------| +| `tokens.json` | `JSON.stringify(lockfile)` — verbatim snapshot | **Yes** | +| `tokens.ts` | `export const tokens = as const` | **Yes** | +| `tokens.css` | flattened `--color/--space/--radius` custom properties | No (drops `typography`, `textContent`, `rgb`/`tailwind` metadata, nesting) | +| `tailwind-preset.ts` | `{colors, spacing, borderRadius, fontFamily}` subset | No | + +The lockfile is a rich nested object (`colors.primitives`/`colors.semantic`, +`typography.families`, `spacing.scale`, `borderRadius`, `textContent`, plus +`version`/`figmaFileKey`/`figmaLastModified` metadata). Only `tokens.json` and +`tokens.ts` preserve it fully. **`tokens.json` is therefore the canonical lossless +interchange**; `tokens.css`/`tailwind-preset.ts` are derived, consumer-facing +*views*, never the source of truth for a re-import. + +## Solution + +### 1. Reimporter — `scripts/import-design-tokens.js` (+ `.sh` wrapper) + +The inverse of the exporter and the first concrete consumer. Reads an exported +tokens package and reconstructs a `design-tokens.lock.json`. + +**CLI** + +``` +node scripts/import-design-tokens.js [options] + --from export root, tokens package dir, or a tokens.json file + (default: dist/design-system — matches the exporter default) + --out where to write the reconstructed lockfile + (default: design-tokens.lock.json; use "-" for stdout) + --verify compare the reconstructed lockfile against a reference + lockfile; exit 0 if identical, 1 if not (no file written) + --force overwrite --out if it exists + --json machine-readable summary + -h, --help +``` + +**`--from` resolution order** (first match wins): a `*.json` file used directly; +otherwise a directory is probed at `packages/design-tokens/src/tokens.json` → +`packages/design-tokens/dist/tokens.json` → `src/tokens.json` → +`dist/tokens.json` → `tokens.json`. + +**Behavior:** parse `tokens.json`; reject input that is not a token lockfile +(must contain at least one of `colors`/`typography`/`spacing`/`borderRadius`); +then either `--verify` (deep-equal vs reference) or write the lockfile +(refusing to clobber `--out` without `--force`). + +**Exit codes** mirror the exporter: `0` ok, `1` output exists / verify mismatch, +`2` no tokens.json found under `--from`, `3` input is not a token lockfile. + +### 2. Round-trip test — `scripts/__tests__/design-system-roundtrip.test.js` + +Vitest, driving the real scripts via `execFileSync`: + +1. Write a rich fixture `design-tokens.lock.json` (every top-level section). +2. Export: `export-design-system.js --lockfile --framework react --output --force`. +3. Assert `/packages/design-tokens/src/tokens.json` deep-equals the fixture. +4. Reimport: `import-design-tokens.js --from --out /reimported.lock.json`. +5. **Deep-equal the reimported lockfile against the original** — the core guarantee. +6. `--verify` returns exit 0 for the matching lockfile, non-zero for a mutated one. +7. Import directly from a `tokens.json` path also round-trips. + +### 3. Consumer documentation — `docs/design-system-export/consumers.md` + +- The interchange contract: `tokens.json` is lossless and stable; `tokens.css` / + `tailwind-preset.ts` / typed `tokens.ts` are the ergonomic views. +- **Aurelius** (self-consumer): the reimporter + the round-trip guarantee. +- **Flavian** (WordPress/FSE): `pnpm add @scope/design-tokens`, then map + `tokens.json` → `theme.json` (`settings.color.palette`, + `settings.typography.fontFamilies`, `settings.spacing.spacingSizes`) with a + documented mapping table and an example adapter snippet. +- **Nerva** (Hono/Workers): `import { tokens } from '@scope/design-tokens'` for + typed, tree-shakeable tokens in backend/email/PDF theming; example. +- Style Dictionary note: `tokens.json` is a plain nested map and can be fed to + Style Dictionary as a custom source if a consumer prefers that toolchain — we + do not require it. + +### 4. Wiring + +- `export-design-system` skill + `/export-design-system` command: add a + "Re-importing / round-trip" section pointing at the reimporter and the doc. +- `CLAUDE.md` + `README.md`: document the new script; bump the manual script count. + +## Out of scope (YAGNI) + +- Generating consumer-specific adapter files (e.g. emitting `theme.json`) — the + export→adapter mapping is *documented* with example snippets, not generated. +- Reconstructing a lockfile from lossy `tokens.css` — impossible to do faithfully; + `tokens.json` is the interchange. + +## Files + +| File | Action | +|------|--------| +| `scripts/import-design-tokens.js` | Create — reimporter | +| `scripts/import-design-tokens.sh` | Create — thin wrapper | +| `scripts/__tests__/design-system-roundtrip.test.js` | Create — round-trip test | +| `docs/design-system-export/consumers.md` | Create — consumer contract + Flavian/Nerva | +| `.claude/skills/export-design-system/SKILL.md` | Update — round-trip/reimport section | +| `.claude/commands/export-design-system.md` | Update — reference reimport + round-trip | +| `CLAUDE.md`, `README.md` | Update — script docs + counts | diff --git a/scripts/__tests__/design-system-roundtrip.test.js b/scripts/__tests__/design-system-roundtrip.test.js new file mode 100644 index 0000000..9ddd61b --- /dev/null +++ b/scripts/__tests__/design-system-roundtrip.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { execFileSync } from "child_process"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync } from "fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXPORT = join(__dirname, "..", "export-design-system.js"); +const IMPORT = join(__dirname, "..", "import-design-tokens.js"); + +let counter = 0; + +/** Unique temp dir under scripts/__tests__/fixtures/ds-roundtrip-* */ +function createTmpDir() { + counter++; + const dir = join(__dirname, "fixtures", `ds-roundtrip-${counter}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** Run a node script; return { stdout, stderr, exitCode } (never throws). */ +function runNode(script, args = [], cwd = process.cwd()) { + try { + const stdout = execFileSync("node", [script, ...args], { + encoding: "utf-8", + timeout: 30000, + cwd, + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (err) { + return { stdout: err.stdout || "", stderr: err.stderr || "", exitCode: err.status ?? 1 }; + } +} + +/** + * A rich, representative design-tokens.lock.json exercising every top-level + * section: metadata, nested colors, typography, spacing, radii, and text. + */ +function makeLockfile() { + return { + version: "1.0.0", + figmaFileKey: "ABC123", + figmaLastModified: "2026-03-15T10:30:00Z", + colors: { + primitives: { + "blue-50": { hex: "#eff6ff", rgb: "239, 246, 255", tailwind: "blue-50" }, + "blue-500": { hex: "#3b82f6", rgb: "59, 130, 246", tailwind: "blue-500" }, + }, + semantic: { + primary: { hex: "#3b82f6", ref: "blue-500" }, + }, + }, + typography: { + families: { + sans: { value: "Inter", fallback: "system-ui, -apple-system, sans-serif" }, + }, + sizes: { base: { px: 16, rem: 1 } }, + }, + spacing: { scale: { 0.5: { px: 2 }, 1: { px: 4 } } }, + borderRadius: { sm: { px: 4 }, md: { px: 6 } }, + textContent: { + "hero-heading": "Build faster with AI", + "hero-subheading": "Ship production apps in days, not months", + }, + }; +} + +/** Write a lockfile object to / and return its path. */ +function writeLock(dir, obj, name = "design-tokens.lock.json") { + const p = join(dir, name); + writeFileSync(p, JSON.stringify(obj, null, 2) + "\n"); + return p; +} + +/** Export a design system from a lockfile; return the export root dir. */ +function exportFrom(lockPath, cwd) { + const outDir = join(cwd, "export"); + const res = runNode( + EXPORT, + ["--lockfile", lockPath, "--framework", "react", "--output", outDir, "--force"], + cwd, + ); + expect(res.exitCode, res.stderr).toBe(0); + return outDir; +} + +afterAll(() => { + const fixturesDir = join(__dirname, "fixtures"); + if (!existsSync(fixturesDir)) return; + for (const entry of readdirSync(fixturesDir)) { + if (entry.startsWith("ds-roundtrip-")) { + try { + rmSync(join(fixturesDir, entry), { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + } +}); + +describe("export writes a lossless tokens.json snapshot", () => { + it("tokens.json deep-equals the source lockfile", () => { + const dir = createTmpDir(); + const lock = makeLockfile(); + const lockPath = writeLock(dir, lock); + const exportRoot = exportFrom(lockPath, dir); + + const tokensJson = join(exportRoot, "packages", "design-tokens", "src", "tokens.json"); + expect(existsSync(tokensJson)).toBe(true); + expect(JSON.parse(readFileSync(tokensJson, "utf-8"))).toEqual(lock); + }); +}); + +describe("round-trip: tokens → export → reimport → identical lockfile", () => { + it("reconstructs a byte-for-byte identical token lockfile from the export root", () => { + const dir = createTmpDir(); + const lock = makeLockfile(); + const lockPath = writeLock(dir, lock); + const exportRoot = exportFrom(lockPath, dir); + + const outPath = join(dir, "reimported.lock.json"); + const res = runNode(IMPORT, ["--from", exportRoot, "--out", outPath, "--force"], dir); + expect(res.exitCode, res.stderr).toBe(0); + expect(existsSync(outPath)).toBe(true); + + const reimported = JSON.parse(readFileSync(outPath, "utf-8")); + expect(reimported).toEqual(lock); + }); + + it("imports directly from a tokens.json file path", () => { + const dir = createTmpDir(); + const lock = makeLockfile(); + const lockPath = writeLock(dir, lock); + const exportRoot = exportFrom(lockPath, dir); + + const tokensJson = join(exportRoot, "packages", "design-tokens", "src", "tokens.json"); + const res = runNode(IMPORT, ["--from", tokensJson, "--out", "-"], dir); + expect(res.exitCode, res.stderr).toBe(0); + expect(JSON.parse(res.stdout)).toEqual(lock); + }); +}); + +describe("--verify checks round-trip fidelity", () => { + it("exits 0 when the reconstructed lockfile matches the reference", () => { + const dir = createTmpDir(); + const lock = makeLockfile(); + const lockPath = writeLock(dir, lock); + const exportRoot = exportFrom(lockPath, dir); + + const res = runNode(IMPORT, ["--from", exportRoot, "--verify", lockPath], dir); + expect(res.exitCode, res.stderr).toBe(0); + }); + + it("exits non-zero when the reference lockfile differs", () => { + const dir = createTmpDir(); + const lock = makeLockfile(); + const lockPath = writeLock(dir, lock); + const exportRoot = exportFrom(lockPath, dir); + + const mutated = makeLockfile(); + mutated.colors.semantic.primary.hex = "#000000"; + const mutatedPath = writeLock(dir, mutated, "mutated.lock.json"); + + const res = runNode(IMPORT, ["--from", exportRoot, "--verify", mutatedPath], dir); + expect(res.exitCode).not.toBe(0); + }); +}); + +describe("input validation", () => { + it("rejects input that is not a token lockfile", () => { + const dir = createTmpDir(); + const bogus = join(dir, "bogus.json"); + writeFileSync(bogus, JSON.stringify({ hello: "world" })); + const res = runNode(IMPORT, ["--from", bogus, "--out", "-"], dir); + expect(res.exitCode).toBe(3); + }); + + it("exits 2 when no tokens.json can be found under --from", () => { + const dir = createTmpDir(); + const res = runNode(IMPORT, ["--from", dir, "--out", "-"], dir); + expect(res.exitCode).toBe(2); + }); +}); + +describe("--json summary", () => { + it("emits a machine-readable summary with identical flag on verify", () => { + const dir = createTmpDir(); + const lock = makeLockfile(); + const lockPath = writeLock(dir, lock); + const exportRoot = exportFrom(lockPath, dir); + + const res = runNode(IMPORT, ["--from", exportRoot, "--verify", lockPath, "--json"], dir); + expect(res.exitCode, res.stderr).toBe(0); + const summary = JSON.parse(res.stdout); + expect(summary.identical).toBe(true); + expect(summary.tokensJson).toContain("tokens.json"); + }); +}); diff --git a/scripts/import-design-tokens.js b/scripts/import-design-tokens.js new file mode 100644 index 0000000..a66683a --- /dev/null +++ b/scripts/import-design-tokens.js @@ -0,0 +1,234 @@ +#!/usr/bin/env node +/** + * import-design-tokens.js + * + * Reconstruct a design-tokens.lock.json from an exported @scope/design-tokens + * package — the inverse of export-design-system.js and the reference consumer of + * the export. + * + * The export ships four token artifacts; only two are lossless: + * - tokens.json verbatim JSON snapshot of the lockfile (LOSSLESS) + * - tokens.ts `export const tokens = as const` (lossless) + * - tokens.css flattened CSS custom properties (derived/lossy) + * - tailwind-preset.ts colors/spacing/radii/font subset (derived/lossy) + * + * This tool reads the lossless tokens.json only, so + * lock -> export -> import round-trips to a byte-identical lockfile. + * tokens.css / tailwind-preset.ts are consumer-facing views and cannot be + * reversed faithfully, so they are never used for reconstruction. + * + * Exit codes: 0 ok, 1 output exists / verify mismatch, 2 tokens.json not found / + * unreadable, 3 input is not a valid token lockfile. + */ +import fs from "node:fs"; +import path from "node:path"; + +function die(msg, code = 1) { + process.stderr.write(`✗ ${msg}\n`); + process.exit(code); +} + +function parseArgs(argv) { + const args = { + from: "dist/design-system", + out: "design-tokens.lock.json", + verify: null, + force: false, + json: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case "--from": + args.from = next(); + break; + case "--out": + args.out = next(); + break; + case "--verify": + args.verify = next(); + break; + case "--force": + args.force = true; + break; + case "--json": + args.json = true; + break; + case "-h": + case "--help": + printHelp(); + process.exit(0); + break; + default: + if (a.startsWith("--")) die(`Unknown flag: ${a}`, 1); + } + } + return args; +} + +function printHelp() { + process.stdout.write(`Usage: import-design-tokens.js [options] + +Reconstruct design-tokens.lock.json from an exported @scope/design-tokens package. +Reads the lossless tokens.json snapshot (never the derived tokens.css). + +Options: + --from Export root, tokens package dir, or a tokens.json file + (default: dist/design-system) + --out Where to write the lockfile ("-" for stdout) + (default: design-tokens.lock.json) + --verify Compare the reconstructed lockfile against a reference + lockfile; exit 0 if identical, 1 if not (writes nothing) + --force Overwrite --out if it already exists + --json Emit a machine-readable summary + -h, --help Show this help +`); +} + +// Locate the lossless tokens.json under --from (file used directly, or a dir +// probed in export-root → package-dir order). +function resolveTokensJson(from) { + let stat; + try { + stat = fs.statSync(from); + } catch { + die(`--from path does not exist: ${from}`, 2); + } + if (stat.isFile()) { + if (!from.endsWith(".json")) { + die(`--from file must be a .json tokens snapshot: ${from}`, 2); + } + return from; + } + const candidates = [ + path.join(from, "packages", "design-tokens", "src", "tokens.json"), + path.join(from, "packages", "design-tokens", "dist", "tokens.json"), + path.join(from, "src", "tokens.json"), + path.join(from, "dist", "tokens.json"), + path.join(from, "tokens.json"), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + die( + `No tokens.json found under ${from}. Looked at: ${candidates + .map((c) => path.relative(from, c)) + .join(", ")}.`, + 2, + ); +} + +function readJson(p, notFoundCode = 2) { + let raw; + try { + raw = fs.readFileSync(p, "utf8"); + } catch (e) { + die(`Cannot read ${p}: ${e.message}`, notFoundCode); + } + try { + return JSON.parse(raw); + } catch (e) { + die(`Failed to parse JSON at ${p}: ${e.message}`, 3); + } +} + +// A token lockfile must carry at least one recognized token section. +const TOKEN_SECTIONS = ["colors", "typography", "spacing", "borderRadius", "radii"]; +function assertLockfile(obj, source) { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + die(`${source} is not a token lockfile (expected a JSON object).`, 3); + } + if (!TOKEN_SECTIONS.some((k) => k in obj)) { + die( + `${source} does not look like a design-tokens lockfile (needs one of: ${TOKEN_SECTIONS.join( + ", ", + )}).`, + 3, + ); + } +} + +// Canonicalize key order so equality/diff are order-independent. +function canonical(v) { + if (Array.isArray(v)) return v.map(canonical); + if (v && typeof v === "object") { + const out = {}; + for (const k of Object.keys(v).sort()) out[k] = canonical(v[k]); + return out; + } + return v; +} + +function deepEqual(a, b) { + return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); +} + +function diffKeys(a, b) { + const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]); + const out = []; + for (const k of keys) { + if (JSON.stringify(canonical(a && a[k])) !== JSON.stringify(canonical(b && b[k]))) { + out.push(k); + } + } + return out; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const tokensJsonPath = resolveTokensJson(args.from); + const lock = readJson(tokensJsonPath); + assertLockfile(lock, tokensJsonPath); + + const serialized = JSON.stringify(lock, null, 2) + "\n"; + + // Verify mode: compare against a reference lockfile, write nothing. + if (args.verify) { + const ref = readJson(args.verify); + assertLockfile(ref, args.verify); + const identical = deepEqual(lock, ref); + if (args.json) { + process.stdout.write( + JSON.stringify({ tokensJson: tokensJsonPath, verify: args.verify, identical }) + "\n", + ); + } else if (identical) { + process.stdout.write(`✓ Round-trip identical: ${tokensJsonPath} matches ${args.verify}\n`); + } else { + const keys = diffKeys(lock, ref); + process.stderr.write( + `✗ Round-trip mismatch: ${tokensJsonPath} differs from ${args.verify}\n`, + ); + if (keys.length) { + process.stderr.write(` Differing top-level sections: ${keys.join(", ")}\n`); + } + } + process.exit(identical ? 0 : 1); + } + + // Stdout mode: the reconstructed lockfile IS the output. + if (args.out === "-") { + process.stdout.write(serialized); + return; + } + + // Write mode. + const outPath = path.resolve(args.out); + if (fs.existsSync(outPath) && !args.force) { + die(`Output file already exists: ${outPath}. Pass --force to overwrite or use --out -.`, 1); + } + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, serialized); + + if (args.json) { + process.stdout.write( + JSON.stringify({ tokensJson: tokensJsonPath, out: outPath, identical: null }) + "\n", + ); + } else { + process.stdout.write( + `Reconstructed design-tokens.lock.json from ${tokensJsonPath}\n Written: ${outPath}\n`, + ); + } +} + +main(); diff --git a/scripts/import-design-tokens.sh b/scripts/import-design-tokens.sh new file mode 100644 index 0000000..20025a7 --- /dev/null +++ b/scripts/import-design-tokens.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# import-design-tokens.sh — Thin wrapper for import-design-tokens.js +# Reconstruct design-tokens.lock.json from an exported @scope/design-tokens package. +set -euo pipefail +exec node "$(dirname "$0")/import-design-tokens.js" "$@"