also looking at this
Merge @stacksjs/ts-css into this repo as packages/toolkit
#27
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.
| @@ -175,6 +175,19 @@ import { selectAll } from '@stacksjs/ts-css/select' | ||
| 175 | 175 | import { minify } from '@stacksjs/ts-css/optimize' |
| 176 | 176 | ``` |
| 177 | 177 | |
| 178 | ## Performance | |
| 179 | ||
| 180 | End-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 | |
| 188 | in [`docs/benchmarks.md`](./docs/benchmarks.md). Run them yourself with | |
| 189 | `bun run bench`. | |
| 190 | ||
| 178 | 191 | ## Documentation |
| 179 | 192 | |
| 180 | 193 | Full docs: see [`./docs`](./docs) or run `bun run dev:docs` to start the |
| @@ -186,6 +199,7 @@ local docs site. | ||
| 186 | 199 | - [Selector matcher API](./docs/api/select.md) |
| 187 | 200 | - [Optimizer API](./docs/api/optimize.md) |
| 188 | 201 | - [Migration guide](./docs/migration.md) |
| 202 | - [Benchmarks](./docs/benchmarks.md) | |
| 189 | 203 | |
| 190 | 204 | ## License |
| 191 | 205 | |
| @@ -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 | ||
| 12 | import { readFileSync } from 'node:fs' | |
| 13 | import { join } from 'node:path' | |
| 14 | import * as cssTree from 'css-tree' | |
| 15 | import * as cssSelect from 'css-select' | |
| 16 | import * as cssWhat from 'css-what' | |
| 17 | import * as csso from 'csso' | |
| 18 | import { barplot, bench, group, run, summary } from 'mitata' | |
| 19 | ||
| 20 | import * as ourCss from '../src' | |
| 21 | import * as ourCsso from '../src/optimize' | |
| 22 | import * as ourSelect from '../src/select' | |
| 23 | import * as ourWhat from '../src/what' | |
| 24 | ||
| 25 | const FIX = join(import.meta.dir, '..', 'test', 'fixtures') | |
| 26 | const samples = ['reset.css', 'utilities.css', 'component.css'].map(f => readFileSync(join(FIX, f), 'utf8')) | |
| 27 | const sample = samples.join('\n') | |
| 28 | ||
| 29 | console.log(`Fixture: ${sample.length} bytes (concatenation of reset+utilities+component).`) | |
| 30 | console.log(`Bun ${Bun.version}`) | |
| 31 | console.log() | |
| 32 | ||
| 33 | // ---- Parse ---- | |
| 34 | group('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 ---- | |
| 74 | const 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 | ] | |
| 83 | group('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 ---- | |
| 166 | group('minify stylesheet', () => { | |
| 167 | barplot(() => { | |
| 168 | bench('ts-css', () => ourCsso.minify(sample)) | |
| 169 | bench('csso', () => csso.minify(sample)) | |
| 170 | }) | |
| 171 | }) | |
| 172 | ||
| 173 | // ---- summary ---- | |
| 174 | summary(() => { | |
| 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 | ||
| 185 | await run() | |
| @@ -9,7 +9,14 @@ | ||
| 9 | 9 | }, |
| 10 | 10 | "devDependencies": { |
| 11 | 11 | "@types/bun": "latest", |
| 12 | "@types/css-tree": "^2.3.11", | |
| 13 | "@types/csso": "^5.0.4", | |
| 12 | 14 | "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", | |
| 13 | 20 | }, |
| 14 | 21 | }, |
| 15 | 22 | }, |
| @@ -40,6 +47,10 @@ | ||
| 40 | 47 | |
| 41 | 48 | "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], |
| 42 | 49 | |
| 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 | ||
| 43 | 54 | "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], |
| 44 | 55 | |
| 45 | 56 | "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], |
| @@ -48,6 +59,8 @@ | ||
| 48 | 59 | |
| 49 | 60 | "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=="], |
| 50 | 61 | |
| 62 | "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], | |
| 63 | ||
| 51 | 64 | "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=="], |
| 52 | 65 | |
| 53 | 66 | "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 @@ | ||
| 58 | 71 | |
| 59 | 72 | "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=="], |
| 60 | 73 | |
| 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 | ||
| 61 | 90 | "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| 62 | 91 | |
| 92 | "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], | |
| 93 | ||
| 63 | 94 | "fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="], |
| 64 | 95 | |
| 65 | 96 | "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 @@ | ||
| 70 | 101 | |
| 71 | 102 | "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], |
| 72 | 103 | |
| 104 | "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], | |
| 105 | ||
| 106 | "mitata": ["mitata@1.0.34", "", {}, "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA=="], | |
| 107 | ||
| 73 | 108 | "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], |
| 74 | 109 | |
| 110 | "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], | |
| 111 | ||
| 75 | 112 | "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], |
| 76 | 113 | |
| 77 | 114 | "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 @@ | ||
| 82 | 119 | |
| 83 | 120 | "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], |
| 84 | 121 | |
| 122 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | |
| 123 | ||
| 85 | 124 | "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=="], |
| 86 | 125 | |
| 87 | 126 | "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
| @@ -109,5 +148,9 @@ | ||
| 109 | 148 | "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=="], |
| 110 | 149 | |
| 111 | 150 | "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=="], | |
| 112 | 155 | } |
| 113 | 156 | } |
| @@ -0,0 +1,87 @@ | ||
| 1 | # Benchmarks | |
| 2 | ||
| 3 | Numbers 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 | |
| 10 | bun run bench | |
| 11 | ``` | |
| 12 | ||
| 13 | The bench script lives at `bench/index.bench.ts` and benchmarks every | |
| 14 | public 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 | ``` | |
| 29 | Fixture: ~6 KB stylesheet | |
| 30 | Bun ≥1.3 | |
| 31 | ||
| 32 | parse stylesheet | |
| 33 | ts-css 179 µs/iter | |
| 34 | css-tree 89 µs/iter (css-tree 2.0× faster) | |
| 35 | ||
| 36 | generate stylesheet | |
| 37 | ts-css 34 µs/iter (ts-css 2.4× faster) | |
| 38 | css-tree 81 µs/iter | |
| 39 | ||
| 40 | walk all nodes | |
| 41 | ts-css 38 µs/iter (ts-css 1.2× faster) | |
| 42 | css-tree 45 µs/iter | |
| 43 | ||
| 44 | parse 7 selectors | |
| 45 | ts-css 3.3 µs/iter | |
| 46 | css-what 2.7 µs/iter (css-what 1.2× faster) | |
| 47 | ||
| 48 | selectAll on a 250-node tree | |
| 49 | ts-css 66 µs/iter | |
| 50 | css-select 47 µs/iter (css-select 1.4× faster) | |
| 51 | ||
| 52 | minify stylesheet | |
| 53 | ts-css 446 µs/iter (ts-css 2.1× faster) | |
| 54 | csso 923 µs/iter | |
| 55 | ||
| 56 | end-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 | ||
| 84 | For the SVGO/optimize/lint use case, the *combined* pipeline runs | |
| 85 | parse → walk → mutate → generate. ts-css completes the whole loop in | |
| 86 | **half the time** of the original four-library stack — and ships **zero | |
| 87 | runtime dependencies** instead of four. | |
| @@ -81,6 +81,7 @@ | ||
| 81 | 81 | "fresh": "bunx rimraf node_modules/ bun.lock && bun i", |
| 82 | 82 | "prepublishOnly": "bun --bun run build && bun run compile:all && bun run zip", |
| 83 | 83 | "test": "bun test", |
| 84 | "bench": "bun bench/index.bench.ts", | |
| 84 | 85 | "lint": "bunx --bun pickier .", |
| 85 | 86 | "lint:fix": "bunx --bun pickier . --fix", |
| 86 | 87 | "changelog": "bunx logsmith --verbose", |
| @@ -101,7 +102,14 @@ | ||
| 101 | 102 | }, |
| 102 | 103 | "devDependencies": { |
| 103 | 104 | "@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" | |
| 105 | 113 | }, |
| 106 | 114 | "git-hooks": { |
| 107 | 115 | "pre-commit": { |
| @@ -56,6 +56,38 @@ const COLOR_PROPERTIES: ReadonlySet<string> = new Set([ | ||
| 56 | 56 | 'scrollbar-color', |
| 57 | 57 | ]) |
| 58 | 58 | |
| 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 | */ | |
| 64 | function 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 | ||
| 59 | 91 | export function compressTree(ast: CssNode): void { |
| 60 | 92 | walk(ast, function compressVisitor(node, item, list) { |
| 61 | 93 | switch (node.type) { |
| @@ -108,4 +140,5 @@ export function compressTree(ast: CssNode): void { | ||
| 108 | 140 | return |
| 109 | 141 | } |
| 110 | 142 | }) |
| 143 | compactWhitespace(ast) | |
| 111 | 144 | } |
| @@ -13,7 +13,7 @@ export function generate(node: CssNode): string { | ||
| 13 | 13 | case 'Rule': |
| 14 | 14 | return `${generate(node.prelude)}{${generate(node.block as CssNode)}}` |
| 15 | 15 | case 'Block': |
| 16 | return joinChildren(node.children, ';') | |
| 16 | return joinBlockChildren(node.children) | |
| 17 | 17 | case 'Atrule': { |
| 18 | 18 | let out = `@${node.name}` |
| 19 | 19 | if (node.prelude) { |
| @@ -100,7 +100,12 @@ export function generate(node: CssNode): string { | ||
| 100 | 100 | case 'Raw': |
| 101 | 101 | return node.value |
| 102 | 102 | 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}*/` : '' | |
| 104 | 109 | case 'WhiteSpace': |
| 105 | 110 | return ' ' |
| 106 | 111 | case 'CDO': |
| @@ -144,16 +149,73 @@ export function generate(node: CssNode): string { | ||
| 144 | 149 | function joinChildren(list: { head: any, [Symbol.iterator]: any }, separator: string): string { |
| 145 | 150 | let out = '' |
| 146 | 151 | 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 | } | |
| 148 | 167 | const text = generate(child) |
| 149 | if (!first && separator) | |
| 168 | if (!text) | |
| 169 | continue | |
| 170 | if (!first && separator && !isStructuralSeparator(prev)) | |
| 150 | 171 | out += separator |
| 151 | 172 | first = false |
| 152 | 173 | 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 | */ | |
| 183 | function 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 | |
| 153 | 194 | } |
| 154 | 195 | return out |
| 155 | 196 | } |
| 156 | 197 | |
| 198 | function 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 | ||
| 204 | function 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 | ||
| 211 | function 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 | ||
| 157 | 219 | /** |
| 158 | 220 | * Escape `"` and `\\` for CSS string output. The parser stores decoded |
| 159 | 221 | * string values in the AST, so the generator is responsible for putting |
| @@ -273,16 +273,17 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 273 | 273 | // nothing useful or throws. No more hardcoded at-rule whitelist: |
| 274 | 274 | // custom at-rules (`@property`, `@scope`, `@layer`, future specs) |
| 275 | 275 | // 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. | |
| 276 | 282 | try { |
| 277 | 283 | const sub = makeState(raw, { positions: false }) |
| 278 | 284 | sub.onParseError = s.onParseError |
| 279 | 285 | const children = newList<CssNode>() |
| 280 | 286 | while (peekType(sub) !== TokenType.EOF) { |
| 281 | const tok = sub.tokens[sub.pos]! | |
| 282 | if (tok.type === TokenType.WhiteSpace) { | |
| 283 | sub.pos++ | |
| 284 | continue | |
| 285 | } | |
| 286 | 287 | const node = parseValueChild(sub) |
| 287 | 288 | if (node) |
| 288 | 289 | children.appendData(node) |
| @@ -329,6 +330,11 @@ const AT_RULES_WITH_NESTED_RULES: ReadonlySet<string> = new Set([ | ||
| 329 | 330 | 'scope', |
| 330 | 331 | 'starting-style', |
| 331 | 332 | '-moz-document', |
| 333 | // keyframes — children are rules with `from` / `to` / `50%` preludes | |
| 334 | 'keyframes', | |
| 335 | '-webkit-keyframes', | |
| 336 | '-moz-keyframes', | |
| 337 | '-o-keyframes', | |
| 332 | 338 | ]) |
| 333 | 339 | |
| 334 | 340 | function parseAtrulePreludeContext(s: ParserState): AtrulePrelude { |
| @@ -802,6 +808,18 @@ function parseSelectorSegment(s: ParserState): CssNode | null { | ||
| 802 | 808 | const node: TypeSelector = { type: 'TypeSelector', name: tokenSlice(s, t), loc: loc(s, t, t) } |
| 803 | 809 | return node |
| 804 | 810 | } |
| 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 | } | |
| 805 | 823 | case TokenType.Delim: { |
| 806 | 824 | const ch = s.source[t.start]! |
| 807 | 825 | if (ch === '*') { |
| @@ -8,9 +8,11 @@ describe('parse + generate round-trip', () => { | ||
| 8 | 8 | expect(ast.children.first.type).toBe('Rule') |
| 9 | 9 | }) |
| 10 | 10 | |
| 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}') | |
| 14 | 16 | }) |
| 15 | 17 | |
| 16 | 18 | it('preserves !important', () => { |