#!/usr/bin/env npx tsx /** * Generates dist/greyhaven.htmx.css — a framework-agnostic CSS companion * to the React component library. Exposes every component via `data-slot` * (+ `data-variant` / `data-size`) attribute selectors so HTMX / server- * rendered projects can use the design system without React. * * Input: * components/ui/*.tsx * * Output: * dist/greyhaven.htmx.css * * Extraction strategy: * 1. AST-walk each .tsx file * 2. For `const xVariants = cva("base", { variants, defaultVariants })`, * capture base + variants + defaults. * 3. For any JSX element with `data-slot="X"`, capture the static string * in its `className` (direct string, or first arg of cn(...)). * 4. Merge the two: a slot with both a cva binding and static cn classes * (rare) gets both; otherwise one or the other. * * Limitations: * - Dynamic / conditional classes are dropped (logged as warnings). * - Components relying on runtime state (data-state, Radix Portals) emit * only their static visual rules. Open/close / focus / positioning JS * is the consumer's problem. */ import * as ts from 'typescript' import * as fs from 'fs' import * as path from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const ROOT = path.resolve(__dirname, '..') const UI_DIR = path.join(ROOT, 'components/ui') const OUT_FILE = path.join(ROOT, 'dist/greyhaven.htmx.css') type CvaExtract = { sourceFile: string variableName: string base: string variants: Record> defaultVariants: Record } type SlotExtract = { sourceFile: string slot: string classes: string /** * Names of CVA functions referenced inside the slot's `cn(...)` call — e.g. * `cn(toggleVariants({variant,size}), 'rounded-none ...')` on * ToggleGroupItem records `['toggleVariants']`. Used to emit the * referenced CVA's base + variant rules under this slot's selector, so * slots that *compose* another component's variant system (instead of * declaring their own cva) still inherit its padding/height/states. */ viaVariants: string[] } type Warning = { sourceFile: string; message: string } const cvaExtracts: CvaExtract[] = [] const slotExtracts: SlotExtract[] = [] const warnings: Warning[] = [] // ─── String extraction helpers ────────────────────────────────────────── function getStringLiteral(node: ts.Node): string | null { if (ts.isStringLiteral(node)) return node.text if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text // Template with only literal parts: `${''}foo${''}` — rare, skip return null } // Object-literal property shortcut: returns first ObjectLiteralExpression named `name`. function getPropertyInitializer( obj: ts.ObjectLiteralExpression, name: string, ): ts.Expression | null { for (const prop of obj.properties) { if (ts.isPropertyAssignment(prop) && prop.name && ts.isIdentifier(prop.name) && prop.name.text === name) { return prop.initializer } } return null } // ─── CVA extractor ────────────────────────────────────────────────────── function extractCva( declList: ts.VariableDeclaration, call: ts.CallExpression, sourceFile: string, ): void { if (!ts.isIdentifier(declList.name)) return const variableName = declList.name.text const baseArg = call.arguments[0] const configArg = call.arguments[1] const base = baseArg ? (getStringLiteral(baseArg) ?? '') : '' if (!base) { warnings.push({ sourceFile, message: `cva: ${variableName}: non-literal base, skipping` }) return } if (!configArg || !ts.isObjectLiteralExpression(configArg)) { warnings.push({ sourceFile, message: `cva: ${variableName}: no config object, skipping` }) return } const variants: Record> = {} const variantsNode = getPropertyInitializer(configArg, 'variants') if (variantsNode && ts.isObjectLiteralExpression(variantsNode)) { for (const prop of variantsNode.properties) { if (!ts.isPropertyAssignment(prop)) continue if (!prop.name || !ts.isIdentifier(prop.name)) continue const axisName = prop.name.text if (!ts.isObjectLiteralExpression(prop.initializer)) continue const values: Record = {} for (const subProp of prop.initializer.properties) { if (!ts.isPropertyAssignment(subProp)) continue let key: string | null = null if (subProp.name) { if (ts.isIdentifier(subProp.name)) key = subProp.name.text else if (ts.isStringLiteral(subProp.name)) key = subProp.name.text } if (!key) continue const classes = getStringLiteral(subProp.initializer) if (classes === null) { warnings.push({ sourceFile, message: `cva: ${variableName}.${axisName}.${key}: non-literal classes, skipping value`, }) continue } values[key] = classes } variants[axisName] = values } } const defaultVariants: Record = {} const defaultsNode = getPropertyInitializer(configArg, 'defaultVariants') if (defaultsNode && ts.isObjectLiteralExpression(defaultsNode)) { for (const prop of defaultsNode.properties) { if (!ts.isPropertyAssignment(prop)) continue if (!prop.name || !ts.isIdentifier(prop.name)) continue const axisName = prop.name.text const value = getStringLiteral(prop.initializer) if (value !== null) defaultVariants[axisName] = value } } cvaExtracts.push({ sourceFile, variableName, base, variants, defaultVariants }) } // ─── Slot extractor ───────────────────────────────────────────────────── function extractSlot( element: ts.JsxOpeningLikeElement, sourceFile: string, ): void { let slot: string | null = null let classes: string | null = null const viaVariants: string[] = [] const attrs = element.attributes.properties for (const attr of attrs) { if (!ts.isJsxAttribute(attr)) continue const attrName = attr.name.getText() if (attrName === 'data-slot') { if (attr.initializer && ts.isStringLiteral(attr.initializer)) { slot = attr.initializer.text } } else if (attrName === 'className' && attr.initializer) { if (ts.isStringLiteral(attr.initializer)) { classes = attr.initializer.text } else if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) { const expr = attr.initializer.expression if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { classes = (expr as ts.StringLiteral).text } else if (ts.isCallExpression(expr)) { const callName = expr.expression.getText() if (callName === 'cn' || callName.endsWith('Variants')) { // First string-literal arg is the static class baseline. const first = expr.arguments.find((a) => getStringLiteral(a) !== null) if (first) classes = getStringLiteral(first) // Any *call* arg whose callee is a `xVariants` identifier means this // slot inherits from another component's variant system (e.g. // ToggleGroupItem: `cn(toggleVariants({variant,size}), 'rounded-none...')`). // Record the names so the emitter can pull in those CVAs' base + variants. for (const arg of expr.arguments) { if (!ts.isCallExpression(arg)) continue const argCallee = arg.expression if (ts.isIdentifier(argCallee) && argCallee.text.endsWith('Variants')) { viaVariants.push(argCallee.text) } } } } } } } if (slot) { slotExtracts.push({ sourceFile, slot, classes: classes ?? '', viaVariants }) } } // ─── AST walker ───────────────────────────────────────────────────────── function walkFile(filePath: string): void { const relPath = path.relative(ROOT, filePath) const source = fs.readFileSync(filePath, 'utf-8') const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) function visit(node: ts.Node): void { // CVA binding: `const xVariants = cva(...)` if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) { const callee = node.initializer.expression if (ts.isIdentifier(callee) && callee.text === 'cva') { extractCva(node, node.initializer, relPath) } } // JSX element with data-slot if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { extractSlot(node, relPath) } ts.forEachChild(node, visit) } visit(sf) } // ─── CSS emitter ──────────────────────────────────────────────────────── // Classes that can't be used with @apply in Tailwind v4. // peer / group — sibling/parent markers, pure class hooks with no CSS // peer/ / group/ — named variants of the above // contents — reserved Tailwind escape // not-prose — ships with @tailwindcss/typography (optional plugin) // Consumers who need these can add them directly on the HTML element. const NON_APPLIABLE_EXACT = new Set([ 'peer', // sibling marker 'group', // parent marker 'contents', // reserved 'not-prose', // @tailwindcss/typography 'origin-top-center', // not a stock Tailwind v4 utility (upstream bug) 'destructive', // toast's own marker class (used with group-[.destructive]: selectors) ]) function isNonAppliable(cls: string): boolean { if (NON_APPLIABLE_EXACT.has(cls)) return true // Named peer/group markers: e.g., `group/drawer-content`, `peer/email` if (/^(peer|group)\/[A-Za-z0-9_-]+$/.test(cls)) return true return false } function uniqueClasses(s: string): string { // Collapse whitespace; preserve order and arbitrary variants. // Strip non-appliable marker classes (peer/group); consumers add them directly // on the HTML element when they need sibling/parent state styling. let tokens = s .replace(/\s+/g, ' ') .trim() .split(' ') .filter((c) => !isNonAppliable(c)) // Always strip `leading-*` utilities from @apply. Tailwind v4's `text-*` // size utilities use `--tw-leading` as an override mechanism, but once // `leading-*` is applied via @apply it sets `--tw-leading` on the element, // and subsequent user-passed `text-sm`/`text-xl` classes still resolve // line-height through that inherited variable — defeating the override. // React + tailwind-merge removes `leading-*` at className-merge time when // a text-size utility is passed; replicate that behavior by stripping it // unconditionally so user `class="text-xl"` overrides produce the same // line-height React ends up with. const LEADING = /^leading-/ tokens = tokens.filter((t) => !LEADING.test(t)) return tokens.join(' ') } function emitCss(): string { const lines: string[] = [] const header = `/*! Greyhaven Design System — HTMX / Framework-Agnostic CSS Layer * Auto-generated from components/ui/*.tsx by scripts/generate-htmx-css.ts — DO NOT EDIT * * Usage: * * * Requires: * - Tokens: import tokens-light.css + tokens-dark.css before this file * - Tailwind v4: this file uses @apply against Tailwind utility classes. * It must be processed by Tailwind v4 (e.g., via \`tailwindcss -i input.css\`). * Your consumer Tailwind input should \`@import "./greyhaven.htmx.css";\`. * * Consume via data-slot / data-variant / data-size attributes: *
*
Hello
*
*
* * Active */ ` lines.push(header) // Emit in @layer utilities so individual Tailwind utility classes on child // elements (e.g. ) don't override our compound selectors // by layer precedence alone. Within the same layer, specificity decides. lines.push('@layer utilities {\n') // Index slot → classes (dedupe: multiple JSX elements may declare the same slot). const slotMap = new Map>() for (const s of slotExtracts) { if (!s.classes) continue if (!slotMap.has(s.slot)) slotMap.set(s.slot, new Set()) slotMap.get(s.slot)!.add(uniqueClasses(s.classes)) } // Index cva by slot-name heuristic: `xVariants` → component slot "x" when JSX // in the same file uses `data-slot="x"`. If ambiguous, fall back to stripping // "Variants" and kebab-casing. const cvaBySlot = new Map() const cvaByName = new Map() for (const cva of cvaExtracts) { cvaByName.set(cva.variableName, cva) const stripped = cva.variableName.replace(/Variants$/, '') const slot = stripped .replace(/([a-z])([A-Z])/g, '$1-$2') .toLowerCase() cvaBySlot.set(slot, cva) } // Aggregate viaVariants per slot (a slot may appear in multiple JSX sites). const slotVia = new Map>() for (const s of slotExtracts) { if (s.viaVariants.length === 0) continue if (!slotVia.has(s.slot)) slotVia.set(s.slot, new Set()) for (const v of s.viaVariants) slotVia.get(s.slot)!.add(v) } // Emit slots in sorted order for stable output. const allSlots = Array.from( new Set([...slotMap.keys(), ...cvaBySlot.keys()]), ).sort() for (const slot of allSlots) { const selfCva = cvaBySlot.get(slot) // Gather CVAs to apply under this slot's selector: its own (if any), plus // any CVAs referenced via `cn(xVariants(...), ...)` in the JSX (e.g. // ToggleGroupItem inherits from toggleVariants). Dedup. const cvas: CvaExtract[] = [] if (selfCva) cvas.push(selfCva) for (const name of slotVia.get(slot) ?? []) { const aliasCva = cvaByName.get(name) if (aliasCva && !cvas.includes(aliasCva)) cvas.push(aliasCva) } const staticSets = slotMap.get(slot) const staticClasses = staticSets ? Array.from(staticSets).join(' ') : '' lines.push(` /* ── ${slot} ─────────────────────────────────────────── */`) // Emit selectors wrapped in :where() so the attribute selectors contribute // zero specificity. This matches how React + tailwind-merge behave: user // overrides passed as `className` (e.g., `class="bg-primary/90"`) must // win over the variant defaults. If we used bare [data-slot][data-variant] // selectors, their specificity (0,2,0) would beat a plain utility class // (0,1,0) and silently drop user overrides. const SLOT = `:where([data-slot="${slot}"])` // Base rule: every contributing CVA's base + any static classes from JSX. const basePieces: string[] = [] for (const c of cvas) if (c.base) basePieces.push(c.base) if (staticClasses) basePieces.push(staticClasses) const base = uniqueClasses(basePieces.join(' ')) if (base) { lines.push(` ${SLOT} { @apply ${base}; }`) } // Emit variant rules for each contributing CVA, under this slot's selector. // When a slot inherits (e.g. toggle-group-item via toggleVariants), its // data-variant/data-size attributes on the DOM drive the inherited CVA's // rules just like they drive the self-CVA's. for (const c of cvas) { for (const [axis, values] of Object.entries(c.variants)) { const defaultValue = c.defaultVariants[axis] const axisAttr = `data-${axis}` for (const [key, classes] of Object.entries(values)) { if (!classes.trim()) continue if (key === defaultValue) { // Default: apply when attr absent OR explicitly set to this key. // Emit as TWO separate rules (no comma-joined selector list), because // Tailwind v4 miscompiles arbitrary variants like `has-[>svg]:px-3` // when @applied inside a rule with a comma-separated selector list // (it emits a stray `)` and the resulting selector is invalid). // Wrap :not() in :where() so it contributes zero specificity; // otherwise plain utility classes like .bg-primary/90 would tie on // specificity (both at 0,1,0) and lose by source order, breaking // user className overrides. lines.push( ` ${SLOT}:where(:not([${axisAttr}])) { @apply ${uniqueClasses(classes)}; }`, ) lines.push( ` ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`, ) } else { lines.push( ` ${SLOT}:where([${axisAttr}="${key}"]) { @apply ${uniqueClasses(classes)}; }`, ) } } } } lines.push('') } lines.push('}\n') if (warnings.length > 0) { lines.push('/* Extraction warnings:') for (const w of warnings) { lines.push(` * [${w.sourceFile}] ${w.message}`) } lines.push(' */\n') } return lines.join('\n') } // ─── Entry ────────────────────────────────────────────────────────────── function main(): void { const files = fs .readdirSync(UI_DIR) .filter((f) => f.endsWith('.tsx')) .map((f) => path.join(UI_DIR, f)) for (const file of files) { walkFile(file) } const css = emitCss() fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }) fs.writeFileSync(OUT_FILE, css, 'utf-8') console.log(`Parsed ${files.length} component files`) console.log(` ${cvaExtracts.length} cva bindings`) console.log(` ${slotExtracts.length} data-slot occurrences`) console.log(` ${warnings.length} warnings`) console.log(`Wrote ${path.relative(ROOT, OUT_FILE)} (${(fs.statSync(OUT_FILE).size / 1024).toFixed(1)} KB)`) } main()