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.
454 lines
14 KiB
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}`)
|
|
}
|
|
}
|