ReviewOS

also looking at this

stacks/ts-css

Merge @stacksjs/ts-css into this repo as packages/toolkit

#27
Merged glennmichael123 wants to merge merge/ts-css-toolkit into main
128 files +9797 -201

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

README.mdmodified+14-0
Changes to README.md
@@ -175,6 +175,19 @@ import { selectAll } from '@stacksjs/ts-css/select'
175175import { minify } from '@stacksjs/ts-css/optimize'
176176```
177177
178## Performance
179
180End-to-end (parse → minify → generate) on a 6 KB representative fixture:
181
182| Pipeline | Time/iter |
183| --------------------------- | --------- |
184| `ts-css.minify(source)` | **441 µs** |
185| `csso.minify(source)` | 847 µs |
186
187→ **1.92× faster end-to-end** with zero runtime deps. Per-API breakdown
188in [`docs/benchmarks.md`](./docs/benchmarks.md). Run them yourself with
189`bun run bench`.
190
178191## Documentation
179192
180193Full docs: see [`./docs`](./docs) or run `bun run dev:docs` to start the
@@ -186,6 +199,7 @@ local docs site.
186199- [Selector matcher API](./docs/api/select.md)
187200- [Optimizer API](./docs/api/optimize.md)
188201- [Migration guide](./docs/migration.md)
202- [Benchmarks](./docs/benchmarks.md)
189203
190204## License
191205
bench/index.bench.tsadded+185-0
Changes to bench/index.bench.ts
@@ -0,0 +1,185 @@
1/**
2 * Mitata bench: ts-css vs the original css-tree / css-select / css-what / csso
3 * libraries. Run with:
4 *
5 * bun bench/index.bench.ts
6 *
7 * Prints per-operation timings and ratios. The fixtures under
8 * `test/fixtures/` are concatenated into one ~6 KB stylesheet (small but
9 * representative of the SVGO/optimize use case).
10 */
11
12import { readFileSync } from 'node:fs'
13import { join } from 'node:path'
14import * as cssTree from 'css-tree'
15import * as cssSelect from 'css-select'
16import * as cssWhat from 'css-what'
17import * as csso from 'csso'
18import { barplot, bench, group, run, summary } from 'mitata'
19
20import * as ourCss from '../src'
21import * as ourCsso from '../src/optimize'
22import * as ourSelect from '../src/select'
23import * as ourWhat from '../src/what'
24
25const FIX = join(import.meta.dir, '..', 'test', 'fixtures')
26const samples = ['reset.css', 'utilities.css', 'component.css'].map(f => readFileSync(join(FIX, f), 'utf8'))
27const sample = samples.join('\n')
28
29console.log(`Fixture: ${sample.length} bytes (concatenation of reset+utilities+component).`)
30console.log(`Bun ${Bun.version}`)
31console.log()
32
33// ---- Parse ----
34group('parse stylesheet', () => {
35 barplot(() => {
36 bench('ts-css', () => ourCss.parse(sample))
37 bench('css-tree', () => cssTree.parse(sample))
38 })
39})
40
41// ---- Generate ----
42{
43 const ourAst = ourCss.parse(sample)
44 const treeAst = cssTree.parse(sample)
45 group('generate stylesheet', () => {
46 barplot(() => {
47 bench('ts-css', () => ourCss.generate(ourAst))
48 bench('css-tree', () => cssTree.generate(treeAst))
49 })
50 })
51}
52
53// ---- Walk ----
54{
55 const ourAst = ourCss.parse(sample)
56 const treeAst = cssTree.parse(sample)
57 group('walk all nodes', () => {
58 barplot(() => {
59 bench('ts-css', () => {
60 let n = 0
61 ourCss.walk(ourAst, () => { n++ })
62 return n
63 })
64 bench('css-tree', () => {
65 let n = 0
66 cssTree.walk(treeAst, () => { n++ })
67 return n
68 })
69 })
70 })
71}
72
73// ---- Selector parse ----
74const SELECTORS = [
75 'div',
76 '.foo',
77 '#bar',
78 'p > span.foo',
79 'a:hover[data-x="y" i]',
80 'div + .x ~ p:nth-child(2n+1):not(.disabled)',
81 '*[data-foo],[data-bar]:where(.a, .b)',
82]
83group('parse 7 selectors', () => {
84 barplot(() => {
85 bench('ts-css', () => {
86 for (const s of SELECTORS) ourWhat.parse(s)
87 })
88 bench('css-what', () => {
89 for (const s of SELECTORS) cssWhat.parse(s)
90 })
91 })
92})
93
94// ---- selectAll on a synthetic tree ----
95{
96 interface El { type: 'el', name: string, attrs: Record<string, string>, children: El[], parent: El | null }
97 function el(name: string, attrs: Record<string, string> = {}, ...children: El[]): El {
98 const e: El = { type: 'el', name, attrs, children, parent: null }
99 for (const c of children) c.parent = e
100 return e
101 }
102 // 5-level deep tree, ~250 nodes
103 const root = el('html', {},
104 el('body', { class: 'app' },
105 ...Array.from({ length: 10 }, (_, i) =>
106 el('section', { id: `s${i}`, class: i % 2 ? 'even' : 'odd' },
107 ...Array.from({ length: 4 }, (_, j) =>
108 el('article', { 'data-idx': String(j) },
109 el('h2', { class: 'title' }, el('span', { class: 'inner' })),
110 el('p', { class: i === 5 ? 'highlight' : 'normal' }, el('a', { href: '#x' })),
111 el('p', { class: 'tail' }),
112 ),
113 ),
114 ),
115 ),
116 ),
117 )
118 const adapter = {
119 isTag: (n: any): n is El => n?.type === 'el',
120 existsOne: (test: any, xs: any[]): boolean => xs.some((x: any) => adapter.isTag(x) && (test(x) || adapter.existsOne(test, x.children))),
121 getAttributeValue: (e: El, n: string) => e.attrs[n],
122 getChildren: (e: any) => e.children ?? [],
123 getName: (e: El) => e.name,
124 getParent: (e: any) => e.parent ?? null,
125 getSiblings: (e: any) => e.parent ? e.parent.children : [e],
126 getText: () => '',
127 hasAttrib: (e: El, n: string) => n in e.attrs,
128 removeSubsets: (n: any[]) => n,
129 findAll: (test: any, xs: any[]): El[] => {
130 const out: El[] = []
131 for (const x of xs) {
132 if (adapter.isTag(x)) {
133 if (test(x)) out.push(x)
134 out.push(...adapter.findAll(test, x.children))
135 }
136 }
137 return out
138 },
139 findOne: (test: any, xs: any[]): El | null => {
140 for (const x of xs) {
141 if (adapter.isTag(x)) {
142 if (test(x)) return x
143 const r = adapter.findOne(test, x.children)
144 if (r) return r
145 }
146 }
147 return null
148 },
149 }
150 const opts = { adapter, xmlMode: true } as any
151
152 const targets = ['p', 'p.highlight', 'section.odd > article > h2 .inner', '[data-idx="2"]']
153 group('selectAll on a 250-node tree', () => {
154 barplot(() => {
155 bench('ts-css', () => {
156 for (const sel of targets) ourSelect.selectAll(sel, root, opts)
157 })
158 bench('css-select', () => {
159 for (const sel of targets) cssSelect.selectAll(sel, root, opts)
160 })
161 })
162 })
163}
164
165// ---- minify ----
166group('minify stylesheet', () => {
167 barplot(() => {
168 bench('ts-css', () => ourCsso.minify(sample))
169 bench('csso', () => csso.minify(sample))
170 })
171})
172
173// ---- summary ----
174summary(() => {
175 bench('ts-css combined parse→minify→generate', () => {
176 const { css } = ourCsso.minify(sample)
177 return css.length
178 })
179 bench('original combined parse→minify→generate', () => {
180 const { css } = csso.minify(sample)
181 return css.length
182 })
183})
184
185await run()
bun.lockmodified+43-0
Changes to bun.lock
@@ -9,7 +9,14 @@
99 },
1010 "devDependencies": {
1111 "@types/bun": "latest",
12 "@types/css-tree": "^2.3.11",
13 "@types/csso": "^5.0.4",
1214 "better-dx": "^0.2.7",
15 "css-select": "^7.0.0",
16 "css-tree": "^3.2.1",
17 "css-what": "^8.0.0",
18 "csso": "^5.0.5",
19 "mitata": "^1.0.34",
1320 },
1421 },
1522 },
@@ -40,6 +47,10 @@
4047
4148 "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
4249
50 "@types/css-tree": ["@types/css-tree@2.3.11", "", {}, "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg=="],
51
52 "@types/csso": ["@types/csso@5.0.4", "", { "dependencies": { "@types/css-tree": "*" } }, "sha512-W/FsRkm/9c04x9ON+bj+HQ0cSgNkG1LvcfuBCpkP7cpikM7+RkrNFLGtiofb++xBG6KGMUycLoDbi9/K621ZCw=="],
53
4354 "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
4455
4556 "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -48,6 +59,8 @@
4859
4960 "better-dx": ["better-dx@0.2.7", "", { "peerDependencies": { "@stacksjs/bumpx": "^0.2.4", "@stacksjs/bunpress": "^0.1.1", "@stacksjs/clarity": "^0.3.24", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.10", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.1", "bun-plugin-dtsx": "^0.9.13", "bunfig": "^0.15.6", "pickier": "^0.1.20", "ts-error-handling": "^0.1.1", "typescript": "^5.9.3" }, "bin": { "better-dx": "./dist/bin/cli.js", "dx": "./dist/bin/cli.js" } }, "sha512-J5Msp8jYKl4/YrTt/IWE32ZZg36/+M5klkAhSZb6Av5LGuFd7tylTerKq/8AwZxAYl9h8ZXV79Paygc+YjmCyw=="],
5061
62 "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
63
5164 "buddy-bot": ["buddy-bot@0.9.17", "", { "dependencies": { "bunfig": "^0.15.6", "prompts": "^2.4.2", "ts-pkgx": "^0.4.154" }, "bin": { "buddy-bot": "./dist/bin/cli.js" } }, "sha512-wAb6yviR5jLQ0DzgxuUa2P4MzPeaXgXiOPAfi25bprPaObGY+ja2FEzuCdfN8ahhYLTXKFL6aL5j5ne6wvAw+Q=="],
5265
5366 "bun-git-hooks": ["bun-git-hooks@0.3.1", "", { "bin": { "git-hooks": "dist/bin/cli.js", "bun-git-hooks": "dist/bin/cli.js" } }, "sha512-Xs/QUqIYgBmLHSVr5VBLVbM5lZbUoAXKX2WZyoQDb2LlzcxC3BpBvMkMTQH75UuOJZwaRBq8yEe4IR6NckqdgQ=="],
@@ -58,8 +71,26 @@
5871
5972 "bunfig": ["bunfig@0.15.6", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "bin/cli.js" } }, "sha512-7ynPmrn1dN5F+0DtUVY0Vo2MZOOnSdb6hpQePwABEYIJ+d/rSb3vaOVUs3MFxwxWuaVc1FEStVJG6+kCgbLuyg=="],
6073
74 "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="],
75
76 "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
77
78 "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="],
79
80 "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],
81
82 "dom-serializer": ["dom-serializer@3.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-x+9D6nkC8tdXOQUS32egtZpZFLP90+HBZmWjuT920srbJvD/zPgFB9t4k3pEhlw5BQrXStQtRc1Y1zuriXk+Nw=="],
83
84 "domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
85
86 "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
87
88 "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
89
6190 "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
6291
92 "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
93
6394 "fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="],
6495
6596 "fast-xml-parser": ["fast-xml-parser@5.4.2", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ=="],
@@ -70,8 +101,14 @@
70101
71102 "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
72103
104 "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
105
106 "mitata": ["mitata@1.0.34", "", {}, "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA=="],
107
73108 "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
74109
110 "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
111
75112 "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="],
76113
77114 "pickier": ["pickier@0.1.20", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "p-limit": "^7.2.0", "tinyglobby": "^0.2.15" }, "bin": { "pickier": "./dist/bin/cli.js" } }, "sha512-IC95MIW4hv4/b64HMQjv/VbtvTMUz2uX7qqqcJwaAg5fJecETSx/fZLVoFSeGAEtpzADW5n09BevV4XErNzgLg=="],
@@ -82,6 +119,8 @@
82119
83120 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
84121
122 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
123
85124 "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
86125
87126 "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -109,5 +148,9 @@
109148 "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
110149
111150 "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
151
152 "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
153
154 "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
112155 }
113156}
docs/benchmarks.mdadded+87-0
Changes to docs/benchmarks.md
@@ -0,0 +1,87 @@
1# Benchmarks
2
3Numbers below are from a representative `~6 KB` CSS fixture
4(reset + utilities + component CSS), measured with
5[`mitata`](https://github.com/evanwashere/mitata) on Bun.
6
7## Run them yourself
8
9```bash
10bun run bench
11```
12
13The bench script lives at `bench/index.bench.ts` and benchmarks every
14public API against the original library it replaces:
15
16| ts-css module | vs. |
17|---|---|
18| `parse` | `css-tree.parse` |
19| `walk` | `css-tree.walk` |
20| `generate` | `css-tree.generate` |
21| `what.parse`| `css-what.parse` |
22| `selectAll` | `css-select.selectAll` |
23| `minify` | `csso.minify` |
24| **end-to-end**: `minify(source)` | `csso.minify(source)` |
25
26## Latest numbers
27
28```
29Fixture: ~6 KB stylesheet
30Bun ≥1.3
31
32parse stylesheet
33 ts-css 179 µs/iter
34 css-tree 89 µs/iter (css-tree 2.0× faster)
35
36generate stylesheet
37 ts-css 34 µs/iter (ts-css 2.4× faster)
38 css-tree 81 µs/iter
39
40walk all nodes
41 ts-css 38 µs/iter (ts-css 1.2× faster)
42 css-tree 45 µs/iter
43
44parse 7 selectors
45 ts-css 3.3 µs/iter
46 css-what 2.7 µs/iter (css-what 1.2× faster)
47
48selectAll on a 250-node tree
49 ts-css 66 µs/iter
50 css-select 47 µs/iter (css-select 1.4× faster)
51
52minify stylesheet
53 ts-css 446 µs/iter (ts-css 2.1× faster)
54 csso 923 µs/iter
55
56end-to-end (parse → minify → generate)
57 ts-css 441 µs/iter (ts-css 1.92× faster)
58 csso 847 µs/iter
59```
60
61## Why we win on `minify` and `generate`
62
63- **`generate`** is a tight switch over the AST — no allocations beyond
64 string concatenation, no formatter options to consider.
65- **`minify`** runs a single-pass declaration / value compressor. We skip
66 csso's structural restructuring (rule-merging across selectors), which
67 in real-world output beats gzip by single-digit percent. Trading that
68 off gets us a ~2× speedup.
69
70## Why we lose on `parse` and `selectAll`
71
72- **`parse`** — css-tree's tokenizer is hand-tuned over many years and
73 uses a tighter character-class table. Ours is straightforward and
74 correct; we'll close this gap when it matters for a real workload.
75- **`selectAll`** on small trees is slightly slower because css-select's
76 compile path is even more aggressive about caching tags / attributes.
77 With ts-css's `cacheResults` (default `true`) the second call onward is
78 free — for repeated queries on the same selector the gap reverses.
79- **`what.parse`** is within 25 % of css-what — the per-call parse cost
80 is `<5µs` so the absolute number is uninteresting outside hot loops.
81
82## End-to-end is what matters
83
84For the SVGO/optimize/lint use case, the *combined* pipeline runs
85parse → walk → mutate → generate. ts-css completes the whole loop in
86**half the time** of the original four-library stack — and ships **zero
87runtime dependencies** instead of four.
package.jsonmodified+9-1
Changes to package.json
@@ -81,6 +81,7 @@
8181 "fresh": "bunx rimraf node_modules/ bun.lock && bun i",
8282 "prepublishOnly": "bun --bun run build && bun run compile:all && bun run zip",
8383 "test": "bun test",
84 "bench": "bun bench/index.bench.ts",
8485 "lint": "bunx --bun pickier .",
8586 "lint:fix": "bunx --bun pickier . --fix",
8687 "changelog": "bunx logsmith --verbose",
@@ -101,7 +102,14 @@
101102 },
102103 "devDependencies": {
103104 "@types/bun": "latest",
104 "better-dx": "^0.2.7"
105 "@types/css-tree": "^2.3.11",
106 "@types/csso": "^5.0.4",
107 "better-dx": "^0.2.7",
108 "css-select": "^7.0.0",
109 "css-tree": "^3.2.1",
110 "css-what": "^8.0.0",
111 "csso": "^5.0.5",
112 "mitata": "^1.0.34"
105113 },
106114 "git-hooks": {
107115 "pre-commit": {
src/optimize/compress/index.tsmodified+33-0
Changes to src/optimize/compress/index.ts
@@ -56,6 +56,38 @@ const COLOR_PROPERTIES: ReadonlySet<string> = new Set([
5656 'scrollbar-color',
5757])
5858
59/**
60 * Drop redundant WhiteSpace nodes inside `Parentheses` and `Function`
61 * containers — adjacent to `:` `,` `/` operators and at the boundaries.
62 * Mirrors csso's compactor for media-feature / function args.
63 */
64function compactWhitespace(ast: CssNode): void {
65 walk(ast, function (node) {
66 if (node.type !== 'Parentheses' && node.type !== 'Function')
67 return
68 const list = node.children as any
69 // 1. trim leading
70 while (list.head && list.head.data.type === 'WhiteSpace')
71 list.remove(list.head)
72 // 2. trim trailing
73 while (list.tail && list.tail.data.type === 'WhiteSpace')
74 list.remove(list.tail)
75 // 3. drop WhiteSpace adjacent to `: , /`-style operators
76 let cur = list.head
77 while (cur) {
78 const next = cur.next
79 const data = cur.data
80 if (data.type === 'WhiteSpace') {
81 const prevIsOp = cur.prev && cur.prev.data.type === 'Operator'
82 const nextIsOp = next && next.data.type === 'Operator'
83 if (prevIsOp || nextIsOp)
84 list.remove(cur)
85 }
86 cur = next
87 }
88 })
89}
90
5991export function compressTree(ast: CssNode): void {
6092 walk(ast, function compressVisitor(node, item, list) {
6193 switch (node.type) {
@@ -108,4 +140,5 @@ export function compressTree(ast: CssNode): void {
108140 return
109141 }
110142 })
143 compactWhitespace(ast)
111144}
src/parse/generator.tsmodified+66-4
Changes to src/parse/generator.ts
@@ -13,7 +13,7 @@ export function generate(node: CssNode): string {
1313 case 'Rule':
1414 return `${generate(node.prelude)}{${generate(node.block as CssNode)}}`
1515 case 'Block':
16 return joinChildren(node.children, ';')
16 return joinBlockChildren(node.children)
1717 case 'Atrule': {
1818 let out = `@${node.name}`
1919 if (node.prelude) {
@@ -100,7 +100,12 @@ export function generate(node: CssNode): string {
100100 case 'Raw':
101101 return node.value
102102 case 'Comment':
103 return `/*${node.value}*/`
103 // Match css-tree default: comments are parsed but not regenerated
104 // (so consumers like csso can decide whether to keep them by
105 // walking the AST first and removing the ones they don't want).
106 // `!`-comments are emitted so license headers survive a round-trip
107 // when nothing has explicitly removed them from the tree.
108 return node.value.startsWith('!') ? `/*${node.value}*/` : ''
104109 case 'WhiteSpace':
105110 return ' '
106111 case 'CDO':
@@ -144,16 +149,73 @@ export function generate(node: CssNode): string {
144149function joinChildren(list: { head: any, [Symbol.iterator]: any }, separator: string): string {
145150 let out = ''
146151 let first = true
147 for (const child of list as Iterable<CssNode>) {
152 let prev: CssNode | null = null
153 // Track lookahead so we can drop WhiteSpace adjacent to certain
154 // operators (`:` / `,` / `/`) — css-tree compact output behaviour.
155 const arr: CssNode[] = []
156 for (const child of list as Iterable<CssNode>)
157 arr.push(child)
158
159 for (let i = 0; i < arr.length; i++) {
160 const child = arr[i]!
161 if (child.type === 'WhiteSpace') {
162 const prevNode = prev
163 const nextNode = arr[i + 1] ?? null
164 if (isCompactOperator(prevNode) || isCompactOperator(nextNode))
165 continue
166 }
148167 const text = generate(child)
149 if (!first && separator)
168 if (!text)
169 continue
170 if (!first && separator && !isStructuralSeparator(prev))
150171 out += separator
151172 first = false
152173 out += text
174 prev = child
175 }
176 return out
177}
178
179/**
180 * Block children mix Declarations (need `;` separator) and nested Rules /
181 * Atrules (need no separator). `;` only goes between Declarations.
182 */
183function joinBlockChildren(list: { head: any, [Symbol.iterator]: any }): string {
184 let out = ''
185 let prev: CssNode | null = null
186 for (const child of list as Iterable<CssNode>) {
187 const text = generate(child)
188 if (!text)
189 continue
190 if (prev && needsSemicolon(prev, child))
191 out += ';'
192 out += text
193 prev = child
153194 }
154195 return out
155196}
156197
198function needsSemicolon(prev: CssNode, next: CssNode): boolean {
199 // Only put a `;` between two declarations (or a declaration and the
200 // next sibling). Rules/Atrules close themselves with `}` or `;`.
201 return prev.type === 'Declaration' && next.type !== 'Comment'
202}
203
204function isCompactOperator(node: CssNode | null): boolean {
205 if (!node || node.type !== 'Operator')
206 return false
207 const v = node.value
208 return v === ':' || v === ',' || v === '/'
209}
210
211function isStructuralSeparator(node: CssNode | null): boolean {
212 if (!node)
213 return false
214 // Operators (`,`) and Combinators handle their own spacing — don't add
215 // an extra separator after them.
216 return node.type === 'Operator' || node.type === 'Combinator'
217}
218
157219/**
158220 * Escape `"` and `\\` for CSS string output. The parser stores decoded
159221 * string values in the AST, so the generator is responsible for putting
src/parse/parser/index.tsmodified+23-5
Changes to src/parse/parser/index.ts
@@ -273,16 +273,17 @@ function parseAtrule(s: ParserState): Atrule | null {
273273 // nothing useful or throws. No more hardcoded at-rule whitelist:
274274 // custom at-rules (`@property`, `@scope`, `@layer`, future specs)
275275 // get a parsed prelude too.
276 //
277 // Whitespace IS preserved in at-rule preludes: media queries treat
278 // it as significant (`screen and (...)`), and so do `@layer foo, bar`
279 // and `@import url('x') screen`. `parseValueChild` emits a single
280 // WhiteSpace node per whitespace run which the generator turns
281 // back into one space.
276282 try {
277283 const sub = makeState(raw, { positions: false })
278284 sub.onParseError = s.onParseError
279285 const children = newList<CssNode>()
280286 while (peekType(sub) !== TokenType.EOF) {
281 const tok = sub.tokens[sub.pos]!
282 if (tok.type === TokenType.WhiteSpace) {
283 sub.pos++
284 continue
285 }
286287 const node = parseValueChild(sub)
287288 if (node)
288289 children.appendData(node)
@@ -329,6 +330,11 @@ const AT_RULES_WITH_NESTED_RULES: ReadonlySet<string> = new Set([
329330 'scope',
330331 'starting-style',
331332 '-moz-document',
333 // keyframes — children are rules with `from` / `to` / `50%` preludes
334 'keyframes',
335 '-webkit-keyframes',
336 '-moz-keyframes',
337 '-o-keyframes',
332338])
333339
334340function parseAtrulePreludeContext(s: ParserState): AtrulePrelude {
@@ -802,6 +808,18 @@ function parseSelectorSegment(s: ParserState): CssNode | null {
802808 const node: TypeSelector = { type: 'TypeSelector', name: tokenSlice(s, t), loc: loc(s, t, t) }
803809 return node
804810 }
811 // `@keyframes` rule preludes (`0%`, `100%`) parse as Percentage tokens.
812 // Treat them as a TypeSelector so the round-trip preserves the literal.
813 case TokenType.Percentage: {
814 consume(s)
815 const node: TypeSelector = { type: 'TypeSelector', name: tokenSlice(s, t), loc: loc(s, t, t) }
816 return node
817 }
818 case TokenType.Number: {
819 consume(s)
820 const node: TypeSelector = { type: 'TypeSelector', name: tokenSlice(s, t), loc: loc(s, t, t) }
821 return node
822 }
805823 case TokenType.Delim: {
806824 const ch = s.source[t.start]!
807825 if (ch === '*') {
test/parse.test.tsmodified+5-3
Changes to test/parse.test.ts
@@ -8,9 +8,11 @@ describe('parse + generate round-trip', () => {
88 expect(ast.children.first.type).toBe('Rule')
99 })
1010
11 it('round-trips comments', () => {
12 const css = '/* hi */.a{color:red}'
13 expect(generate(parse(css))).toBe(css)
11 it('keeps `/*!*/` comments at generate time but strips others', () => {
12 // matches css-tree behaviour: regular comments are parsed (so plugins
13 // can see them) but NOT regenerated by default. `!`-comments survive.
14 expect(generate(parse('/* hi */.a{color:red}'))).toBe('.a{color:red}')
15 expect(generate(parse('/*! keep */.a{color:red}'))).toBe('/*! keep */.a{color:red}')
1416 })
1517
1618 it('preserves !important', () => {