#!/usr/bin/env node // Stage .ds-pkg/ — the synthetic "published package" the design-sync converter // consumes. // // This repo is a Next.js app, not a component library: no dist/, no exports // map, no .d.ts tree. The converter's synth-entry fallback would work, but it // leaves every Props body empty (props are resolved by ts-morph from a // .d.ts tree that doesn't exist here), and an empty props contract is what the // claude.ai/design agent would code against. // // So we give it a real one, derived entirely from the repo's own source: // // .ds-pkg/package.json name/version/module/types, so PKG_DIR + findTypesRoot // resolve the way they would for a published package // .ds-pkg/index.d.ts re-exports the tsc declaration emit (tsconfig.dts.json) // for the ROOT of each module only. The converter reads // this entry as the component list, and shadcn ships // ~292 flat exports (CardHeader, CardTitle, …) — every // one of which would otherwise become its own preview // card. Roots here, parts in the docs table below. // .ds-pkg/index.js re-exports the SOURCE .tsx — every export, parts // included, so window.Cloudrite carries the whole API. // esbuild bundles from source, so the runtime bundle is // the real components, never a recompiled copy // .ds-pkg/styles.css copy of .design-sync/compiled.css, with fonts/ // .ds-pkg/fonts/*.woff2 alongside it. cfg.cssEntry is bounded to PKG_DIR by // the converter, so the stylesheet has to live inside // the staged package; the relative url(./fonts/…) // references survive the copy because both move together // .design-sync/docs/*.md per-root docs: the group, and the compound parts // table (shadcn exports parts flat — CardHeader, not // Card.Header — so the converter's namespace-based // subcomponent grouping can't see them, and without // this the agent never learns the parts exist) // // Run order (all three, in this order — see cfg.buildCmd): // node_modules/.bin/tsc -p .design-sync/tsconfig.dts.json // node .design-sync/build-css.mjs // node .design-sync/make-pkg.mjs import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, '..'); const PKG_DIR = join(REPO, '.ds-pkg'); const TYPES = join(PKG_DIR, 'types'); const DOCS = join(HERE, 'docs'); // ── source files to expose ─────────────────────────────────────────────── // Skipped, with reasons: // ui/toaster.tsx — exports `Toaster`, colliding with ui/sonner.tsx's. The // app ships both (v0 scaffolding); sonner is the current // one, so it wins and the legacy pair stays out of the DS. // chatwidget.tsx — a Chatwoot script injector. Renders no markup and fires a // fetch('/api/chatwoot') on mount; nothing to design with. // ui/use-mobile — hook only, no component export. const SKIP = new Set(['components/ui/toaster.tsx', 'components/chatwidget.tsx', 'components/ui/use-mobile.tsx']); // The root component of each file is its first PascalCase export — true for // every shadcn primitive except toast.tsx, which lists the provider first. const ROOT_OVERRIDE = { 'components/ui/toast.tsx': 'Toast' }; // Not a design component — a context wrapper. Stays in the bundle (the agent // may need to wrap), but gets no card. const NO_CARD = new Set(['ThemeProvider']); const GROUPS = { sections: ['Header', 'Hero', 'Services', 'Features', 'Process', 'Contact', 'Footer'], actions: ['Button', 'ButtonGroup', 'Toggle', 'ToggleGroup'], forms: ['Input', 'Textarea', 'Label', 'Checkbox', 'RadioGroup', 'Select', 'Switch', 'Slider', 'Form', 'Field', 'InputGroup', 'InputOTP', 'Calendar'], layout: ['Card', 'Separator', 'AspectRatio', 'ScrollArea', 'ResizablePanelGroup', 'Sidebar', 'Item', 'Empty'], navigation: ['Breadcrumb', 'NavigationMenu', 'Menubar', 'Pagination', 'Tabs', 'Command'], overlays: ['Dialog', 'AlertDialog', 'Sheet', 'Drawer', 'Popover', 'HoverCard', 'Tooltip', 'DropdownMenu', 'ContextMenu'], feedback: ['Alert', 'Badge', 'Progress', 'Skeleton', 'Spinner', 'Toast', 'Toaster'], display: ['Table', 'Avatar', 'Accordion', 'Collapsible', 'Carousel', 'ChartContainer', 'Kbd'], }; const groupOf = (name) => Object.entries(GROUPS).find(([, names]) => names.includes(name))?.[0] ?? 'misc'; // ── collect exports per source file, in declaration order ──────────────── function walk(dir, test, out = []) { for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const p = join(dir, e.name); if (e.isDirectory()) walk(p, test, out); else if (test(e.name)) out.push(p); } return out; } // Ordered PascalCase exports of a .d.ts: `export { A, B }` lists win (they // carry the file's own ordering), else `export declare` order. function exportsOf(dtsPath) { const s = readFileSync(dtsPath, 'utf8'); const names = []; const push = (n) => { if (/^[A-Z]/.test(n) && !names.includes(n)) names.push(n); }; const lists = [...s.matchAll(/export\s*\{([^}]*)\}/g)]; if (lists.length) { for (const m of lists) { for (const raw of m[1].split(',')) { const n = raw.trim(); if (!n) continue; const as = n.split(/\s+as\s+/); push((as[1] ?? as[0]).trim()); } } } for (const m of s.matchAll(/export\s+declare\s+(?:const|function|class)\s+([A-Za-z0-9_$]+)/g)) push(m[1]); return names; } // Prop signature of a part, for the docs table. Falls back to '—' when the // declaration isn't a plain function (forwardRef consts etc.). function propSigOf(dtsPath, name) { const s = readFileSync(dtsPath, 'utf8'); const re = new RegExp(`declare (?:function|const) ${name}\\b([\\s\\S]*?)(?=\\ndeclare |\\nexport |$)`); const m = re.exec(s); if (!m) return null; const props = /:\s*([^)]*?)\)\s*:/.exec(m[1].replace(/\{[^{}]*\}/g, '{…}')); return props ? props[1].replace(/\s+/g, ' ').trim() : null; } const srcFiles = walk(join(REPO, 'components'), (n) => /\.tsx$/.test(n)) .map((p) => relative(REPO, p).split('\\').join('/')) .filter((p) => !SKIP.has(p)); const units = []; for (const src of srcFiles) { const dts = join(TYPES, src.replace(/\.tsx$/, '.d.ts')); if (!existsSync(dts)) { console.error(`! no declaration for ${src} — skipped`); continue; } const names = exportsOf(dts); if (!names.length) continue; const root = ROOT_OVERRIDE[src] ?? names[0]; units.push({ src, dts, names, root, parts: names.filter((n) => n !== root) }); } // ── emit the package ───────────────────────────────────────────────────── mkdirSync(PKG_DIR, { recursive: true }); const appPkg = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')); writeFileSync(join(PKG_DIR, 'package.json'), JSON.stringify({ name: 'cloudrite', version: appPkg.version ?? '0.1.0', private: true, type: 'module', module: 'index.js', main: 'index.js', types: 'index.d.ts', }, null, 2) + '\n'); // Stylesheet + fonts move into the package together so url(./fonts/…) still // resolves. build-css.mjs must have run first. const compiled = join(HERE, 'compiled.css'); if (!existsSync(compiled)) { console.error('! .design-sync/compiled.css missing — run node .design-sync/build-css.mjs first'); process.exit(1); } copyFileSync(compiled, join(PKG_DIR, 'styles.css')); mkdirSync(join(PKG_DIR, 'fonts'), { recursive: true }); for (const f of readdirSync(join(HERE, 'fonts')).filter((f) => /\.(woff2?|ttf|otf)$/.test(f))) { copyFileSync(join(HERE, 'fonts', f), join(PKG_DIR, 'fonts', f)); } const banner = '// Generated by .design-sync/make-pkg.mjs — do not edit.\n'; writeFileSync(join(PKG_DIR, 'index.js'), banner + units.map((u) => `export * from '../${u.src.replace(/\.tsx$/, '')}';`).join('\n') + '\n'); writeFileSync(join(PKG_DIR, 'index.d.ts'), banner + units .filter((u) => !NO_CARD.has(u.root)) .map((u) => `export { ${u.root} } from './types/${u.src.replace(/\.tsx$/, '')}';`) .join('\n') + '\n'); // ── per-root docs ──────────────────────────────────────────────────────── rmSync(DOCS, { recursive: true, force: true }); mkdirSync(DOCS, { recursive: true }); let withParts = 0; for (const u of units) { if (NO_CARD.has(u.root)) continue; const lines = [ '---', `category: ${groupOf(u.root)}`, '---', '', `# ${u.root}`, '', `Source: \`${u.src}\`.`, '', ]; if (u.parts.length) { withParts++; lines.push( `\`${u.root}\` is a compound component. Its parts are exported **flat** (\`${u.parts[0]}\`, not`, `\`${u.root}.${u.parts[0].startsWith(u.root) ? u.parts[0].slice(u.root.length) : u.parts[0]}\`) and each is a separate top-level export of the bundle:`, '', '| Part | Props |', '| --- | --- |', ); for (const p of u.parts) { const sig = propSigOf(u.dts, p); lines.push(`| \`${p}\` | ${sig ? `\`${sig}\`` : '—'} |`); } lines.push('', `All parts accept \`className\` and are composed as children of \`${u.root}\`.`, ''); } writeFileSync(join(DOCS, `${u.root}.md`), lines.join('\n')); } const roots = units.filter((u) => !NO_CARD.has(u.root)).map((u) => u.root); console.error(`.ds-pkg: ${units.length} modules, ${units.reduce((n, u) => n + u.names.length, 0)} exports`); console.error(`docs: ${roots.length} roots (${withParts} compound) → ${relative(REPO, DOCS)}`); console.error(`roots: ${roots.join(' ')}`);