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.
455 lines
15 KiB
455 lines
15 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}`);
|
|
}
|
|
}
|