From 261e2eda89bac4311daca0de7b3acbe9927eea04 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 19 May 2026 21:48:15 +0200 Subject: [PATCH 1/3] adjust the benchmarks --- benchmark/index.ts | 379 ++++++++++++++++++++++++++++++++------------ benchmark/memory.ts | 281 -------------------------------- package.json | 3 +- 3 files changed, 279 insertions(+), 384 deletions(-) delete mode 100644 benchmark/memory.ts diff --git a/benchmark/index.ts b/benchmark/index.ts index f921a3e..c6fa01c 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -1,4 +1,5 @@ // oxlint-disable no-unused-vars +/// import { Bench } from 'tinybench' import { parse, tokenize, walk } from '../dist/index.js' import * as fs from 'node:fs' @@ -7,7 +8,6 @@ import * as path from 'node:path' import * as csstree from 'css-tree' import * as postcss from 'postcss' -// Sample CSS for benchmarking - realistic production-like CSS const largeCSS = fs.readFileSync(path.resolve('benchmark/medium.css'), 'utf-8') const bootstrapCSS = fs.readFileSync( path.resolve('node_modules/bootstrap/dist/css/bootstrap.css'), @@ -18,129 +18,306 @@ const tailwindCSS = fs.readFileSync( 'utf-8', ) -const bench = new Bench({ time: 1000, warmup: true }) +type CSSFile = 'Large' | 'Bootstrap' | 'Tailwind' -// Tokenizer benchmarks -bench - .add('Tokenizer - Large CSS', () => { - for (const _token of tokenize(largeCSS)) { - // Just iterate - } - }) - .add('Tokenizer - Bootstrap CSS', () => { - for (const _token of tokenize(bootstrapCSS)) { - // Just iterate - } - }) - .add('Tokenizer - Tailwind CSS', () => { - for (const _token of tokenize(tailwindCSS)) { - // Just iterate +const files: CSSFile[] = ['Large', 'Bootstrap', 'Tailwind'] + +const cssMap: Record = { + Large: largeCSS, + Bootstrap: bootstrapCSS, + Tailwind: tailwindCSS, +} + +const fileSizes: Record = { + Large: largeCSS.length, + Bootstrap: bootstrapCSS.length, + Tailwind: tailwindCSS.length, +} + +// Pre-parse once for walk-only benchmarks so parse time doesn't pollute walk timings +const parsedMap = { + Large: parse(largeCSS), + Bootstrap: parse(bootstrapCSS), + Tailwind: parse(tailwindCSS), +} + +const quick = process.argv.includes('--quick') + +// ─── Speed benchmarks ───────────────────────────────────────────────────── + +const bench = new Bench({ warmup: true }) + +for (const file of files) { + const css = cssMap[file] + const parsed = parsedMap[file] + + bench.add(`Tokenize|${file}`, () => { + for (const _token of tokenize(css)) { + // iterate } }) -// Parser benchmarks -bench - .add('Parser - Large CSS', () => { - parse(largeCSS) + bench.add(`Parse|${file}`, () => { + parse(css) }) - .add('Parser - Bootstrap CSS', () => { - parse(bootstrapCSS) - }) - .add('Parser - Tailwind CSS', () => { - parse(tailwindCSS) + + bench.add(`Walk|${file}`, () => { + walk(parsed, (node, _depth) => { + void node.type + void node.line + }) }) -bench - .add('Parse/walk - Wallace - Bootstrap CSS', () => { - let ast = parse(bootstrapCSS) - let count = 0 + bench.add(`Parse+Walk|${file}`, () => { + let ast = parse(css) walk(ast, (node, _depth) => { - let type = node.type - let line = node.line - count++ + void node.type + void node.line }) }) - .add('Parse/walk - CSSTree - Bootstrap CSS', () => { - let ast = csstree.parse(bootstrapCSS, { positions: true }) - let count = 0 - // @ts-expect-error: no type definitions for css-tree - csstree.walk(ast, (node) => { - let type = node.type - let line = node.loc?.start.line - count++ + + if (!quick) { + bench.add(`Fair-Wallace|${file}`, () => { + let ast = parse(css, { + parse_selectors: false, + parse_values: false, + parse_atrule_preludes: false, + }) + walk(ast, (node, _depth) => { + void node.type + void node.line + }) }) - }) - .add('Parse/walk - PostCSS - Bootstrap CSS', () => { - let root = postcss.parse(bootstrapCSS) - let count = 0 - root.walk((node) => { - let type = node.type - let line = node.source?.start?.line - count++ + + bench.add(`Fair-CSSTree|${file}`, () => { + let ast = csstree.parse(css, { + positions: true, + parseValue: false, + parseAtrulePrelude: false, + parseRulePrelude: false, + }) + // @ts-expect-error: no type definitions for css-tree + csstree.walk(ast, (node) => { + void node.type + void node.loc?.start.line + }) }) - }) -bench - .add('Parse/walk - Wallace - Tailwind CSS', () => { - let ast = parse(tailwindCSS) - let count = 0 - walk(ast, (node, _depth) => { - let type = node.type - let line = node.line - count++ + bench.add(`CSSTree-Parse|${file}`, () => { + csstree.parse(css, { positions: true }) }) - }) - .add('Parse/walk - CSSTree - Tailwind CSS', () => { - let ast = csstree.parse(tailwindCSS, { positions: true }) - let count = 0 - // @ts-expect-error: no type definitions for css-tree - csstree.walk(ast, (node) => { - let type = node.type - let line = node.loc?.start.line - count++ + + bench.add(`PostCSS-Parse|${file}`, () => { + postcss.parse(css) }) - }) - .add('Parse/walk - PostCSS - Tailwind CSS', () => { - let root = postcss.parse(tailwindCSS) - let count = 0 - root.walk((node) => { - let type = node.type - let line = node.source?.start?.line - count++ + + bench.add(`CSSTree|${file}`, () => { + let ast = csstree.parse(css, { positions: true }) + // @ts-expect-error: no type definitions for css-tree + csstree.walk(ast, (node) => { + void node.type + void node.loc?.start.line + }) }) - }) + + bench.add(`PostCSS|${file}`, () => { + let root = postcss.parse(css) + root.walk((node) => { + void node.type + void node.source?.start?.line + }) + }) + } +} await bench.run() -// File sizes -const fileSizes = { - large: largeCSS.length, - bootstrap: bootstrapCSS.length, - tailwind: tailwindCSS.length, +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function ops(name: string): number { + const result = bench.tasks.find((t) => t.name === name)?.result + const stats = result && 'latency' in result ? result : null + return stats?.throughput?.mean ?? 0 } -function getFileSize(taskName: string): string { - const name = taskName.toLowerCase() - if (name.includes('bootstrap')) { - return `${(fileSizes.bootstrap / 1024).toFixed(2)} KB` - } else if (name.includes('large')) { - return `${(fileSizes.large / 1024).toFixed(2)} KB` - } else if (name.includes('tailwind')) { - return `${(fileSizes.tailwind / 1024).toFixed(2)} KB` +function fmtOps(n: number): string { + return n > 0 ? n.toFixed(0) : 'N/A' +} + +function fmtSize(file: CSSFile): string { + return `${(fileSizes[file] / 1024).toFixed(0)} KB` +} + +function fmtMB(mb: number): string { + return `${mb.toFixed(1)} MB` +} + +function forceGC(rounds = 5): void { + for (let i = 0; i < rounds; i++) { + ;(globalThis as { gc?: () => void }).gc!() } - return 'N/A' } -// Display results -console.table( - bench.tasks.map(({ name, result }) => { - const stats = result && 'latency' in result ? result : null - return { - 'Task Name': name, - 'File Size': getFileSize(name), - 'ops/sec': stats?.throughput.mean.toFixed(0) ?? 'N/A', - 'Average Time (ms)': stats?.latency.mean.toFixed(4) ?? 'N/A', - Margin: stats?.latency.rme === null ? 'N/A' : `±${stats.latency.rme.toFixed(2)}%`, +function measureMemoryMB( + css: string, + parser: 'wallace' | 'csstree' | 'postcss', + iterations = 3, +): number { + const deltas: number[] = [] + + for (let i = 0; i < iterations; i++) { + forceGC() + const before = process.memoryUsage() + + if (parser === 'wallace') { + let ast = parse(css) + walk(ast, (node, _depth) => { + void node.type + void node.line + }) + } else if (parser === 'csstree') { + let ast = csstree.parse(css, { positions: true }) + // @ts-expect-error: no type definitions for css-tree + csstree.walk(ast, (node) => { + void node.type + void node.loc?.start.line + }) + } else { + let root = postcss.parse(css) + root.walk((node) => { + void node.type + void node.source?.start?.line + }) } - }), + + const after = process.memoryUsage() + deltas.push(after.heapUsed + after.external - (before.heapUsed + before.external)) + } + + const avg = deltas.reduce((a, b) => a + b, 0) / iterations + return avg / 1024 / 1024 +} + +// ─── Table 1: Wallace metrics ────────────────────────────────────────────── + +console.log('\n── Table 1: Wallace CSS Parser ──────────────────────────────────────────\n') + +console.table( + files.map((file) => ({ + File: file, + Size: fmtSize(file), + 'Tokenize (ops/sec)': fmtOps(ops(`Tokenize|${file}`)), + 'Parse (ops/sec)': fmtOps(ops(`Parse|${file}`)), + 'Walk (ops/sec)': fmtOps(ops(`Walk|${file}`)), + 'Parse+Walk (ops/sec)': fmtOps(ops(`Parse+Walk|${file}`)), + })), ) + +if (!quick) { + // ─── Table 2: Parse speed comparison ──────────────────────────────────── + + console.log('\n── Table 2: Parse Speed – Wallace (baseline) vs CSSTree vs PostCSS ───────\n') + + console.table( + files.map((file) => { + const w = ops(`Parse|${file}`) + const c = ops(`CSSTree-Parse|${file}`) + const p = ops(`PostCSS-Parse|${file}`) + return { + File: file, + Size: fmtSize(file), + 'Wallace (ops/sec)': fmtOps(w), + 'CSSTree (ops/sec)': fmtOps(c), + 'PostCSS (ops/sec)': fmtOps(p), + 'vs CSSTree': c > 0 ? `${(w / c).toFixed(1)}x faster` : 'N/A', + 'vs PostCSS': p > 0 ? `${(w / p).toFixed(1)}x faster` : 'N/A', + } + }), + ) + + // ─── Table 3: Parse+Walk speed comparison ─────────────────────────────── + + console.log('\n── Table 3: Parse+Walk Speed – Wallace (baseline) vs CSSTree vs PostCSS ──\n') + + console.table( + files.map((file) => { + const w = ops(`Parse+Walk|${file}`) + const c = ops(`CSSTree|${file}`) + const p = ops(`PostCSS|${file}`) + return { + File: file, + Size: fmtSize(file), + 'Wallace (ops/sec)': fmtOps(w), + 'CSSTree (ops/sec)': fmtOps(c), + 'PostCSS (ops/sec)': fmtOps(p), + 'vs CSSTree': c > 0 ? `${(w / c).toFixed(1)}x faster` : 'N/A', + 'vs PostCSS': p > 0 ? `${(w / p).toFixed(1)}x faster` : 'N/A', + } + }), + ) + + // ─── Table 4: Fair Parse+Walk comparison (no sub-parsing) ─────────────── + + console.log( + '\n── Table 4: Parse+Walk Speed (fair) – Wallace no sub-parsing vs CSSTree no sub-parsing vs PostCSS ──\n', + ) + + console.table( + files.map((file) => { + const w = ops(`Fair-Wallace|${file}`) + const c = ops(`Fair-CSSTree|${file}`) + const p = ops(`PostCSS|${file}`) + return { + File: file, + Size: fmtSize(file), + 'Wallace (ops/sec)': fmtOps(w), + 'CSSTree (ops/sec)': fmtOps(c), + 'PostCSS (ops/sec)': fmtOps(p), + 'vs CSSTree': c > 0 ? `${(w / c).toFixed(1)}x faster` : 'N/A', + 'vs PostCSS': p > 0 ? `${(w / p).toFixed(1)}x faster` : 'N/A', + } + }), + ) +} + +// ─── Memory comparison ───────────────────────────────────────────────────── + +const hasGC = typeof (globalThis as { gc?: () => void }).gc === 'function' +const memTableNum = quick ? 2 : 5 + +if (hasGC) { + if (quick) { + console.log('\n── Table 2: Parse+Walk Memory – Wallace ─────────────────────────────────\n') + + console.table( + files.map((file) => { + const w = measureMemoryMB(cssMap[file], 'wallace') + return { File: file, Size: fmtSize(file), Wallace: fmtMB(w) } + }), + ) + } else { + console.log('\n── Table 5: Parse+Walk Memory – Wallace (baseline) vs CSSTree vs PostCSS ─\n') + + console.table( + files.map((file) => { + const css = cssMap[file] + const w = measureMemoryMB(css, 'wallace') + const c = measureMemoryMB(css, 'csstree') + const p = measureMemoryMB(css, 'postcss') + return { + File: file, + Size: fmtSize(file), + Wallace: fmtMB(w), + CSSTree: fmtMB(c), + PostCSS: fmtMB(p), + 'vs CSSTree': c > 0 ? `${(c / w).toFixed(1)}x less` : 'N/A', + 'vs PostCSS': p > 0 ? `${(p / w).toFixed(1)}x less` : 'N/A', + } + }), + ) + } +} else { + console.log( + `\n── Table ${memTableNum}: Memory Usage – skipped (run with: node --expose-gc benchmark/index.js) ──\n`, + ) +} diff --git a/benchmark/memory.ts b/benchmark/memory.ts deleted file mode 100644 index f954b32..0000000 --- a/benchmark/memory.ts +++ /dev/null @@ -1,281 +0,0 @@ -// oxlint-disable no-unused-vars -// Memory benchmark for CSS parsers -// Run with: node --expose-gc benchmark/memory.js - -import { parse, walk } from '../dist/index.js' -import * as fs from 'node:fs' -import * as path from 'node:path' -// @ts-expect-error: no type definitions for css-tree -import * as csstree from 'css-tree' -import * as postcss from 'postcss' - -// Check if GC is available -if (typeof global.gc !== 'function') { - console.error('Error: This benchmark requires --expose-gc flag') - console.error('Run with: node --expose-gc benchmark/memory.js') - process.exit(1) -} - -// Load CSS files -const smallCSS = fs.readFileSync(path.resolve('benchmark/small.css'), 'utf-8') -const mediumCSS = fs.readFileSync(path.resolve('benchmark/medium.css'), 'utf-8') -const bootstrapCSS = fs.readFileSync( - path.resolve('node_modules/bootstrap/dist/css/bootstrap.css'), - 'utf-8', -) -const tailwindCSS = fs.readFileSync( - path.resolve('node_modules/tailwindcss/dist/tailwind.css'), - 'utf-8', -) - -interface MemorySnapshot { - heapUsed: number - external: number - total: number -} - -interface MemoryResult { - fileName: string - fileSize: number - baseline: MemorySnapshot - afterParse: MemorySnapshot - afterWalk: MemorySnapshot - afterCleanup: MemorySnapshot - parseDelta: number - walkDelta: number - totalDelta: number - retainedDelta: number -} - -function formatBytes(bytes: number): string { - return `${(bytes / 1024 / 1024).toFixed(2)} MB` -} - -function getMemorySnapshot(): MemorySnapshot { - const mem = process.memoryUsage() - return { - heapUsed: mem.heapUsed, - external: mem.external, - total: mem.heapUsed + mem.external, - } -} - -function forceGC(rounds = 5): void { - for (let i = 0; i < rounds; i++) { - global.gc!() - } -} - -function measureMemory( - fileName: string, - cssContent: string, - parser: 'wallace' | 'csstree' | 'postcss', -): MemoryResult { - // Force GC and get baseline - forceGC() - const baseline = getMemorySnapshot() - - let ast: any = null - - // Parse - if (parser === 'wallace') { - ast = parse(cssContent) - } else if (parser === 'csstree') { - ast = csstree.parse(cssContent, { positions: true }) - } else if (parser === 'postcss') { - ast = postcss.parse(cssContent) - } - - const afterParse = getMemorySnapshot() - - // Walk - let count = 0 - if (parser === 'wallace') { - walk(ast, (node, _depth) => { - const type = node.type - const line = node.line - count++ - }) - } else if (parser === 'csstree') { - csstree.walk(ast, (node: any) => { - const type = node.type - const line = node.loc?.start.line - count++ - }) - } else if (parser === 'postcss') { - ast.walk((node: any) => { - const type = node.type - const line = node.source?.start?.line - count++ - }) - } - - const afterWalk = getMemorySnapshot() - - // Cleanup and measure retained memory - ast = null - forceGC() - const afterCleanup = getMemorySnapshot() - - const parseDelta = afterParse.total - baseline.total - const walkDelta = afterWalk.total - afterParse.total - const totalDelta = afterWalk.total - baseline.total - const retainedDelta = afterCleanup.total - baseline.total - - return { - fileName, - fileSize: cssContent.length, - baseline, - afterParse, - afterWalk, - afterCleanup, - parseDelta, - walkDelta, - totalDelta, - retainedDelta, - } -} - -function runBenchmark( - name: string, - css: string, - parser: 'wallace' | 'csstree' | 'postcss', - iterations = 3, -): MemoryResult { - const results: MemoryResult[] = [] - - for (let i = 0; i < iterations; i++) { - results.push(measureMemory(name, css, parser)) - } - - // Average the results - const avg: MemoryResult = { - fileName: name, - fileSize: results[0].fileSize, - baseline: results[0].baseline, - afterParse: results[0].afterParse, - afterWalk: results[0].afterWalk, - afterCleanup: results[0].afterCleanup, - parseDelta: results.reduce((sum, r) => sum + r.parseDelta, 0) / iterations, - walkDelta: results.reduce((sum, r) => sum + r.walkDelta, 0) / iterations, - totalDelta: results.reduce((sum, r) => sum + r.totalDelta, 0) / iterations, - retainedDelta: results.reduce((sum, r) => sum + r.retainedDelta, 0) / iterations, - } - - return avg -} - -console.log('Memory Benchmark for CSS Parsers') -console.log('=================================\n') - -const testFiles = [ - { name: 'small.css', content: smallCSS }, - { name: 'medium.css', content: mediumCSS }, - { name: 'bootstrap.css', content: bootstrapCSS }, - { name: 'tailwind.css', content: tailwindCSS }, -] - -const parsers: Array<'wallace' | 'csstree' | 'postcss'> = ['wallace', 'csstree', 'postcss'] - -for (const parser of parsers) { - console.log(`\n${parser.toUpperCase()} Parser`) - console.log('-'.repeat(80)) - - const results: MemoryResult[] = [] - - for (const file of testFiles) { - const result = runBenchmark(file.name, file.content, parser, 3) - results.push(result) - } - - console.table( - results.map((r) => ({ - File: r.fileName, - 'Size (KB)': (r.fileSize / 1024).toFixed(2), - 'Parse (+MB)': formatBytes(r.parseDelta), - 'Walk (+MB)': formatBytes(r.walkDelta), - 'Total (+MB)': formatBytes(r.totalDelta), - 'Retained (+MB)': formatBytes(r.retainedDelta), - 'Memory Efficiency': `${((r.fileSize / r.totalDelta) * 100).toFixed(1)}%`, - })), - ) -} - -// Comparison table -console.log('\n\nCOMPARISON: Parse + Walk Memory Usage') -console.log('-'.repeat(80)) - -const comparisonResults: { [key: string]: { [parser: string]: number } } = {} - -for (const file of testFiles) { - comparisonResults[file.name] = {} - for (const parser of parsers) { - const result = runBenchmark(file.name, file.content, parser, 3) - comparisonResults[file.name][parser] = result.totalDelta - } -} - -const comparisonTable = testFiles.map((file) => { - const wallace = comparisonResults[file.name]['wallace'] - const tree = comparisonResults[file.name]['csstree'] - const post = comparisonResults[file.name]['postcss'] - - return { - File: file.name, - 'Size (KB)': (file.content.length / 1024).toFixed(2), - 'Wallace (MB)': formatBytes(wallace), - 'CSSTree (MB)': formatBytes(tree), - 'PostCSS (MB)': formatBytes(post), - 'Wallace vs CSSTree': `${((wallace / tree) * 100).toFixed(1)}%`, - 'Wallace vs PostCSS': `${((wallace / post) * 100).toFixed(1)}%`, - } -}) - -console.table(comparisonTable) - -console.log('\n\nUNDERSTANDING THE METRICS') -console.log('-'.repeat(80)) -console.log('\n📊 Memory Measurements Explained:\n') - -console.log('Parse (+MB):') -console.log(' Memory consumed during parsing (creating the AST)') -console.log(' Lower is better - indicates efficient AST representation\n') - -console.log('Walk (+MB):') -console.log(' Additional memory used during AST traversal') -console.log(' Can be negative if GC runs during walking\n') - -console.log('Total (+MB):') -console.log(' Peak memory usage = Parse + Walk') -console.log(' Total memory overhead for parse + walk operation\n') - -console.log('Retained (+MB): ⚠️ IMPORTANT METRIC') -console.log(' Memory that remains allocated AFTER clearing references and forcing GC') -console.log(' This represents memory leaks or objects kept alive by closures/caches') -console.log(' ') -console.log(' What it means:') -console.log(' • Close to 0 MB: Excellent - minimal retention, GC cleaned up everything') -console.log(' • Low (< 5% of Total): Good - some minor caching or retained references') -console.log(' • High (> 20% of Total): Concerning - potential memory leaks or large caches') -console.log(' ') -console.log(' Why it matters:') -console.log(' • In long-running processes (servers, build tools, linters), retained memory') -console.log(' accumulates over time if not properly released') -console.log(' • Low retained memory means the parser can be used repeatedly without') -console.log(' gradually consuming all available memory\n') - -console.log('Memory Efficiency:') -console.log(' Ratio of input file size to memory consumed') -console.log(' Higher percentage = more efficient (closer to file size)\n') - -console.log('Wallace vs X:') -console.log(' Percentage of memory used compared to competitor') -console.log(' Lower is better (e.g., 47.3% = Wallace uses less than half the memory)\n') - -console.log('-'.repeat(80)) -console.log('\n✅ Key Takeaways:') -console.log(" • Wallace's arena-based design minimizes allocations during parsing") -console.log(' • Lower "Total" memory means faster parsing (better cache locality)') -console.log(' • Lower "Retained" memory means safer for long-running applications') -console.log(' • All parsers can be garbage collected when done (references cleared)') -console.log('') diff --git a/package.json b/package.json index 805397e..98ebb72 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,7 @@ "test-coverage": "vitest --coverage", "test-build": "pnpm run build && vitest run --config vitest.config.build.ts", "build": "tsdown", - "benchmark": "pnpm run build && node benchmark/index.ts", - "benchmark:memory": "pnpm run build && node --expose-gc benchmark/memory.ts", + "benchmark": "pnpm run build && node --expose-gc benchmark/index.ts", "lint": "oxlint --config .oxlintrc.json && oxfmt --check", "check": "tsc --noEmit", "knip": "knip", From 9e6b09c089b394fc6db5122c1897fc56cc8e9c95 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 19 May 2026 22:04:38 +0200 Subject: [PATCH 2/3] knip --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 807fac5..0dff574 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,4 @@ { "ignore": ["/benchmark/**"], - "ignoreDependencies": ["@projectwallace/preset-oxlint", "bootstrap", "tailwindcss"] + "ignoreDependencies": ["@projectwallace/preset-oxlint", "bootstrap", "css-tree", "postcss", "tailwindcss", "tinybench"] } From 5191b3c930526b19e9d0e13c75ad32c351faa871 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 19 May 2026 22:05:34 +0200 Subject: [PATCH 3/3] knip --- knip.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 0dff574..50744cd 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,11 @@ { "ignore": ["/benchmark/**"], - "ignoreDependencies": ["@projectwallace/preset-oxlint", "bootstrap", "css-tree", "postcss", "tailwindcss", "tinybench"] + "ignoreDependencies": [ + "@projectwallace/preset-oxlint", + "bootstrap", + "css-tree", + "postcss", + "tailwindcss", + "tinybench" + ] }