You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/eidos-purge.ts

454 lines
14 KiB

#!/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<string> {
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 `<div data-toggle>` 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<string>
components: Set<string>
} {
const tokens = new Set<string>()
const components = new Set<string>()
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<string, number>
}
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<string>()
const usedComponents = new Set<string>()
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<string>()
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<string, number>()
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}`)
}
}

Powered by TurnKey Linux.