feat: htmx derivation home page 1:1 from react
This commit is contained in:
424
scripts/generate-htmx-css.ts
Normal file
424
scripts/generate-htmx-css.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
#!/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<string, Record<string, string>>
|
||||
defaultVariants: Record<string, string>
|
||||
}
|
||||
|
||||
type SlotExtract = {
|
||||
sourceFile: string
|
||||
slot: string
|
||||
classes: 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<string, Record<string, string>> = {}
|
||||
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<string, string> = {}
|
||||
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<string, string> = {}
|
||||
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 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)) {
|
||||
// cn(...) or xVariants(...) — we extract the first string-literal arg
|
||||
// Variant calls are already covered by CVA extraction; cn's first literal
|
||||
// is the static baseline.
|
||||
const callName = expr.expression.getText()
|
||||
if (callName === 'cn' || callName.endsWith('Variants')) {
|
||||
const first = expr.arguments.find((a) => getStringLiteral(a) !== null)
|
||||
if (first) classes = getStringLiteral(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (slot) {
|
||||
if (classes === null) {
|
||||
// data-slot is present but no static class extractable (maybe dynamic).
|
||||
// We still record the slot with empty classes so rule ordering is preserved.
|
||||
slotExtracts.push({ sourceFile, slot, classes: '' })
|
||||
} else {
|
||||
slotExtracts.push({ sourceFile, slot, classes })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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/<name> / group/<name> — 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:
|
||||
* <link href="greyhaven.htmx.css" rel="stylesheet">
|
||||
*
|
||||
* 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:
|
||||
* <div data-slot="card">
|
||||
* <div data-slot="card-header"><div data-slot="card-title">Hello</div></div>
|
||||
* <div data-slot="card-content">…</div>
|
||||
* </div>
|
||||
* <button data-slot="button" data-variant="outline" data-size="sm">Click</button>
|
||||
* <span data-slot="badge" data-variant="success">Active</span>
|
||||
*/
|
||||
|
||||
`
|
||||
lines.push(header)
|
||||
// Emit in @layer utilities so individual Tailwind utility classes on child
|
||||
// elements (e.g. <svg class="h-3.5">) 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<string, Set<string>>()
|
||||
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<string, CvaExtract>()
|
||||
for (const cva of cvaExtracts) {
|
||||
const stripped = cva.variableName.replace(/Variants$/, '')
|
||||
const slot = stripped
|
||||
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||
.toLowerCase()
|
||||
cvaBySlot.set(slot, cva)
|
||||
}
|
||||
|
||||
// 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 cva = cvaBySlot.get(slot)
|
||||
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: cva.base + any static classes found on the slot's JSX
|
||||
const basePieces: string[] = []
|
||||
if (cva?.base) basePieces.push(cva.base)
|
||||
if (staticClasses) basePieces.push(staticClasses)
|
||||
const base = uniqueClasses(basePieces.join(' '))
|
||||
if (base) {
|
||||
lines.push(` ${SLOT} { @apply ${base}; }`)
|
||||
}
|
||||
|
||||
// CVA variants
|
||||
if (cva) {
|
||||
for (const [axis, values] of Object.entries(cva.variants)) {
|
||||
const defaultValue = cva.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()
|
||||
@@ -182,6 +182,50 @@ function buildCompositionRules(): string {
|
||||
`
|
||||
}
|
||||
|
||||
function buildHtmxLayer(): string {
|
||||
return `## HTMX / Server-Rendered Usage
|
||||
|
||||
For projects that cannot use React (HTMX, Django templates, Rails ERB, Go \`html/template\`, Astro SSR, etc.), the design system ships a framework-agnostic CSS layer: \`dist/greyhaven.htmx.css\`.
|
||||
|
||||
### What it is
|
||||
|
||||
An auto-generated stylesheet derived from \`components/ui/*.tsx\`. Every \`data-slot\` attribute gets a \`@layer components\` rule. \`cva\` variants become attribute selectors (\`[data-variant=...]\`, \`[data-size=...]\`). Default variants apply via \`:not([data-variant])\` so consumers can omit the attribute.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Install: \`./skill/install.sh /path/to/project --htmx-css\`
|
||||
2. Import in your Tailwind v4 input CSS: \`@import "./greyhaven.htmx.css";\`
|
||||
3. Emit HTML with \`data-slot\` / \`data-variant\` / \`data-size\` attributes:
|
||||
|
||||
\`\`\`html
|
||||
<div data-slot="card">
|
||||
<div data-slot="card-header">
|
||||
<div data-slot="card-title">Title</div>
|
||||
<div data-slot="card-description">Description</div>
|
||||
</div>
|
||||
<div data-slot="card-content">Body</div>
|
||||
</div>
|
||||
|
||||
<button data-slot="button" data-variant="default">Save</button>
|
||||
<button data-slot="button" data-variant="outline" data-size="sm">Cancel</button>
|
||||
|
||||
<span data-slot="badge" data-variant="success">Active</span>
|
||||
\`\`\`
|
||||
|
||||
### Scope
|
||||
|
||||
- **Fully static** (pure CSS, no JS): Card, Button, Badge, Input, Label, Textarea, Table, Separator, Code, Kbd, Progress, Avatar, Skeleton, Alert, Pagination, Breadcrumb, Navbar (solid variant), Spinner, AspectRatio, Empty, Hero, Section, Footer, CtaSection, ButtonGroup, InputGroup, Toast.
|
||||
- **Visual-only** (CSS is correct but needs your own state JS): Dialog, Dropdown, Popover, Select, Combobox, Accordion, Tabs, Tooltip, Drawer, Sheet, Sidebar, Collapsible, NavigationMenu, Menubar, ContextMenu, HoverCard, Command, AlertDialog, InputOtp, Carousel. Pair with Alpine.js (\`x-data\`, \`x-show\`, \`@click\`) or native HTML primitives (\`<dialog>\`, \`<details>\`).
|
||||
|
||||
### Regenerate
|
||||
|
||||
\`\`\`bash
|
||||
pnpm htmx-css:build
|
||||
\`\`\`
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
function buildExtensionProtocol(): string {
|
||||
return `## Extension Protocol
|
||||
|
||||
@@ -258,6 +302,8 @@ This skill gives you full context to generate pixel-perfect, on-brand UI using t
|
||||
'---\n',
|
||||
buildCompositionRules(),
|
||||
'---\n',
|
||||
buildHtmxLayer(),
|
||||
'---\n',
|
||||
buildExtensionProtocol(),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user