#!/usr/bin/env -S node --import tsx/esm /** * Eidos CSS purge — JIT tree-shaking for `generated/base.css`. * * Reads the foundation CSS, scans a set of source globs for token references * (`var(--XXX)` and `--XXX:` declarations), then emits a stripped CSS that * contains ONLY: * * 1. Tokens explicitly referenced in the scanned sources. * 2. Foundation tokens (scales, primitives, color roles, sizes, etc.) * that are deemed always-kept. * 3. Transitive dependencies of (1) and (2) — if `--toggle-on-bg` is kept * and its value is `var(--toggle-palette-solid)`, the palette dep is * kept too. * * The closure walk runs until fixed point. Foundation prefixes are the * categories ActiveEidos emits via `renderStaticCss` / `renderThemeCss` * that are NOT component-recipe owned — they're consumed transitively * by recipes and themes themselves. * * Usage: * npm run eidos:purge -- --src 'web/routes/** /*.svelte' --src 'src/uix/eidos/components/** /*.css' * * --input generated CSS path (default: src/uix/eidos/generated/base.css) * --output purged CSS path (default: src/uix/eidos/generated/base.purged.css) * --src glob (repeatable). Defaults to web + eidos recipes if none given. * --verbose print per-component drop summary * * Comparable systems: * - Tailwind v4 uses JIT scanning over source classes. * - Chakra Panda v3 uses build-time conditional emission. * - Eidos's purge runs over CSS custom property references (atomic but at * the token level rather than the class level). */ import { globSync } from 'node:fs' import { readFileSync, writeFileSync } from 'node:fs' import { gzipSync } from 'node:zlib' import { resolve } from 'node:path' import { THEME_BASE_RECIPE_TOKENS } from '../src/uix/eidos/lib/recipes/base' // ── Foundation tokens — always kept ──────────────────────────────────────── /** * Token names whose prefix matches any of these are deemed foundation and * always kept. These are emitted by `renderStaticCss` / `renderThemeCss` * and are consumed by recipes transitively; purging them would break the * cascade of any kept recipe token. */ const FOUNDATION_PREFIXES = [ 'scale-', 'primitive-', 'color-', 'size-', 'opacity-', 'z-index-', 'shadow-', 'border-', 'radius-', 'space-', 'control-height-', 'focus-ring', 'duration-', 'ease-', 'motion-', 'icon-size-', 'icon-stroke-width-', 'font-', 'leading-', 'tracking-', 'tab-size-', 'aspect-ratio-', 'container-', 'content-', 'layout-', 'density-', 'style-', 'transition-' ] const FOUNDATION_EXACT = new Set([ 'font-ui', 'font-prose', 'leading-ui', 'leading-prose', 'leading-text', 'leading-display', 'sans', 'serif', 'mono', 'border-width', 'border-style', 'border' ]) function isFoundation(name: string): boolean { if (FOUNDATION_EXACT.has(name)) return true for (const p of FOUNDATION_PREFIXES) if (name.startsWith(p)) return true return false } // ── CSS parser (minimal — handles flat rules + media queries) ───────────── interface ParsedDeclaration { readonly name: string // without leading "--" readonly value: string } interface ParsedRule { readonly selector: string // raw selector text, e.g. ":root" or "[data-toggle][data-color='affirm']" readonly declarations: ParsedDeclaration[] readonly mediaWrapper?: string // e.g. "@media (min-width: 768px)" if nested under one } function parseCss(css: string): ParsedRule[] { const rules: ParsedRule[] = [] const stack: string[] = [] // active @media wrappers let i = 0 while (i < css.length) { // Skip whitespace while (i < css.length && /\s/.test(css[i])) i++ if (i >= css.length) break // Skip /* */ comments if (css.startsWith('/*', i)) { const end = css.indexOf('*/', i) i = end === -1 ? css.length : end + 2 continue } // Handle closing brace (end of media or rule we're inside) if (css[i] === '}') { i++ if (stack.length) stack.pop() continue } // Read tokens until '{' const headStart = i while (i < css.length && css[i] !== '{') i++ const head = css.slice(headStart, i).trim() if (!head) { i++ continue } i++ // consume '{' // @media (or other @-rule with a block) → push and continue if (head.startsWith('@')) { stack.push(head) continue } // Plain rule — collect declarations until matching '}' const block = readBlock(css, i) i = block.endIndex const declarations: ParsedDeclaration[] = [] for (const piece of block.text.split(';')) { const trimmed = piece.trim() if (!trimmed) continue const colon = trimmed.indexOf(':') if (colon === -1) continue const name = trimmed.slice(0, colon).trim() if (!name.startsWith('--')) continue declarations.push({ name: name.slice(2), value: trimmed.slice(colon + 1).trim() }) } rules.push({ selector: head, declarations, mediaWrapper: stack[stack.length - 1] }) } return rules } /** * Build the set of "known component names" from the canonical recipe * config. Every key in `THEME_BASE_RECIPE_TOKENS` is a component the * generator emits tokens for, regardless of whether that component's * selectors live in the foundation CSS or in a separate recipe file. * * This is what filters source-side `data-{c}` matches so generic attrs * (`data-uix-control`, `data-active`, `data-state`) never get mistaken * for component-usage signals. */ function collectKnownComponents(): Set { return new Set(Object.keys(THEME_BASE_RECIPE_TOKENS)) } function readBlock(css: string, start: number): { text: string; endIndex: number } { let depth = 1 let i = start while (i < css.length && depth > 0) { if (css[i] === '{') depth++ else if (css[i] === '}') depth-- if (depth > 0) i++ } return { text: css.slice(start, i), endIndex: i + 1 } } // ── Reference extraction ────────────────────────────────────────────────── const VAR_REF_REGEX = /var\(\s*--([a-z][a-z0-9-]*)/g const DECL_REGEX = /(?:^|[\s;{])--([a-z][a-z0-9-]+)\s*:/gm /** Imports of any UIX component layer: `from '$uix/eidos/components/{c}'`, * `from '$soma/components/{c}'`, `from '@/uix/.../components/{c}'`. * Non-greedy on the path so multiple `/components/` segments don't * confuse the matcher and `/components/{c}` is found near the end. */ const COMPONENT_IMPORT_REGEX = /from\s+['"][^'"]*?\/components\/([a-z][a-z0-9-]*)['"/]/g /** Inline `data-{c}` attribute usage (covers raw HTML / Svelte markup that * doesn't go through an import — e.g. a `
` written by hand. */ const DATA_ATTR_REGEX = /data-([a-z][a-z0-9-]*?)(?:[=\s>"'])/g function extractVarRefs(value: string): string[] { const out: string[] = [] for (const m of value.matchAll(VAR_REF_REGEX)) out.push(m[1]) return out } /** * Scan a source file for signals that we should keep tokens alive: * * - explicit `var(--XXX)` references → keep XXX * - explicit `--XXX:` declarations → keep XXX * - `from '...components/{c}'` imports → keep entire `--{c}-*` block * - `data-{c}` attribute usage → keep entire `--{c}-*` block * * The first two pin individual tokens (precise); the latter two pin * whole component recipes (coarse but reliable — if you import the * component, you need its recipe entirely). */ function scanSource(content: string): { tokens: Set components: Set } { const tokens = new Set() const components = new Set() for (const m of content.matchAll(VAR_REF_REGEX)) tokens.add(m[1]) for (const m of content.matchAll(DECL_REGEX)) tokens.add(m[1]) for (const m of content.matchAll(COMPONENT_IMPORT_REGEX)) components.add(m[1]) for (const m of content.matchAll(DATA_ATTR_REGEX)) components.add(m[1]) return { tokens, components } } // ── Closure computation ─────────────────────────────────────────────────── interface PurgeOptions { readonly input: string readonly output: string readonly sources: readonly string[] readonly verbose?: boolean } interface PurgeResult { readonly scannedFiles: number readonly usedFromSource: number readonly usedComponents: number readonly keptTotal: number readonly purgedTotal: number readonly beforeBytes: number readonly afterBytes: number readonly beforeGzip: number readonly afterGzip: number readonly perComponentDropped: ReadonlyMap } function purgeEidosCss(opts: PurgeOptions): PurgeResult { const css = readFileSync(opts.input, 'utf-8') const beforeBytes = Buffer.byteLength(css) const beforeGzip = gzipSync(css).length // Parse the generated CSS so we can walk its rules later. The set of // known component names comes from the canonical recipe config (not // from the CSS) so multi-file recipes like button/select/dialog are // also recognized as "components in use" via import scanning. const rules = parseCss(css) const knownComponents = collectKnownComponents() // 1. Scan all source files for token references AND component usage. const sourceTokens = new Set() const usedComponents = new Set() let scannedFiles = 0 for (const pattern of opts.sources) { const matches = globSync(pattern) for (const file of matches) { scannedFiles++ const content = readFileSync(file, 'utf-8') const scan = scanSource(content) for (const name of scan.tokens) sourceTokens.add(name) for (const c of scan.components) { if (knownComponents.has(c)) usedComponents.add(c) } } } // 3. Build initial kept set: // - tokens referenced explicitly // - foundation tokens (always) // - every `--{c}-*` token whose `{c}` is a used component const kept = new Set() for (const name of sourceTokens) kept.add(name) for (const rule of rules) { for (const d of rule.declarations) { if (isFoundation(d.name)) { kept.add(d.name) continue } // Match the longest used-component prefix. Component names // can contain hyphens (e.g. 'dropdown-menu') so we test // each registered name and pick the longest match. for (const c of usedComponents) { if (d.name === c || d.name.startsWith(`${c}-`)) { kept.add(d.name) break } } } } // 4. Transitive closure: if a kept declaration references --YYY, keep YYY. let changed = true while (changed) { changed = false for (const rule of rules) { for (const d of rule.declarations) { if (!kept.has(d.name)) continue for (const ref of extractVarRefs(d.value)) { if (!kept.has(ref)) { kept.add(ref) changed = true } } } } } // 5. Emit purged CSS, preserving @media wrappers. const out: string[] = [] let keptTotal = 0 let purgedTotal = 0 const perComponentDropped = new Map() let activeMedia: string | undefined for (const rule of rules) { const keptDecls = rule.declarations.filter((d) => { if (kept.has(d.name)) { keptTotal++ return true } purgedTotal++ const componentPrefix = d.name.split('-')[0] perComponentDropped.set( componentPrefix, (perComponentDropped.get(componentPrefix) ?? 0) + 1 ) return false }) if (keptDecls.length === 0) continue // Open / close media wrappers as needed. if (rule.mediaWrapper !== activeMedia) { if (activeMedia) out.push('}') if (rule.mediaWrapper) out.push(`${rule.mediaWrapper} {`) activeMedia = rule.mediaWrapper } const indent = activeMedia ? '\t' : '' out.push(`${indent}${rule.selector} {`) for (const d of keptDecls) out.push(`${indent}\t--${d.name}: ${d.value};`) out.push(`${indent}}`) } if (activeMedia) out.push('}') const purgedCss = out.join('\n') + '\n' const afterBytes = Buffer.byteLength(purgedCss) const afterGzip = gzipSync(purgedCss).length writeFileSync(opts.output, purgedCss) return { scannedFiles, usedFromSource: sourceTokens.size, usedComponents: usedComponents.size, keptTotal, purgedTotal, beforeBytes, afterBytes, beforeGzip, afterGzip, perComponentDropped } } // ── CLI ─────────────────────────────────────────────────────────────────── function parseArgs(argv: string[]): PurgeOptions { const sources: string[] = [] let input = 'src/uix/eidos/generated/base.css' let output = 'src/uix/eidos/generated/base.purged.css' let verbose = false for (let i = 0; i < argv.length; i++) { const arg = argv[i] if (arg === '--input' || arg === '-i') input = argv[++i] else if (arg === '--output' || arg === '-o') output = argv[++i] else if (arg === '--src' || arg === '-s') sources.push(argv[++i]) else if (arg === '--verbose' || arg === '-v') verbose = true } if (sources.length === 0) { // Defaults: scan the demo routes + every eidos component recipe. sources.push('web/routes/**/*.svelte') sources.push('web/routes/**/*.ts') sources.push('web/routes/**/*.css') sources.push('src/uix/eidos/components/**/*.css') sources.push('src/uix/eidos/components/**/*.svelte') } return { input: resolve(input), output: resolve(output), sources, verbose } } function formatBytes(n: number): string { if (n < 1024) return `${n} B` return `${(n / 1024).toFixed(1)} KB` } function formatPercent(before: number, after: number): string { const pct = (1 - after / before) * 100 return `${pct.toFixed(1)}% smaller` } const opts = parseArgs(process.argv.slice(2)) const result = purgeEidosCss(opts) console.log(`Eidos CSS purge — ${opts.input}`) console.log(` Scanned: ${result.scannedFiles} files`) console.log(` Sources used directly: ${result.usedFromSource} tokens`) console.log(` Components in use: ${result.usedComponents}`) console.log(` Kept (incl. foundation): ${result.keptTotal} declarations`) console.log(` Purged: ${result.purgedTotal} declarations`) console.log(` Raw: ${formatBytes(result.beforeBytes)} → ${formatBytes(result.afterBytes)} (${formatPercent(result.beforeBytes, result.afterBytes)})`) console.log(` Gzip: ${formatBytes(result.beforeGzip)} → ${formatBytes(result.afterGzip)} (${formatPercent(result.beforeGzip, result.afterGzip)})`) console.log(` Output: ${opts.output}`) if (opts.verbose) { const sorted = [...result.perComponentDropped.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30) console.log(`\n Per-prefix drop count (top 30):`) for (const [prefix, n] of sorted) { console.log(` ${prefix.padEnd(30)} ${n}`) } }