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+135-44
Changes to README.md
@@ -1,109 +1,200 @@
11# @stacksjs/ts-css
22
3Pure-TypeScript CSS toolkit for Bun/Node. Zero runtime deps.
3> Pure-TypeScript CSS toolkit for Bun & Node. Zero runtime dependencies.
44
5Drop-in replacement for the `css-tree` + `css-select` + `css-what` + `csso`
6quartet that most CSS processing pipelines pull in.
5`ts-css` is a single, dependency-free package that replaces the four-library
6quartet most CSS pipelines pull in:
77
8## What's included
8| You used to install | Now you only install |
9| -------------------------------- | ---------------------------- |
10| `css-tree` `css-select` `css-what` `csso` | `@stacksjs/ts-css` |
911
10| Module | Replaces | What you get |
11| -------------------------- | ------------- | ------------------------------------------------------------- |
12| `@stacksjs/ts-css/parse` | `css-tree` | Tokenizer, recursive-descent parser, walker, generator, clone |
13| `@stacksjs/ts-css/what` | `css-what` | Selector parser / stringifier |
14| `@stacksjs/ts-css/select` | `css-select` | `selectAll` / `selectOne` / `is` against any tree-like adapter |
15| `@stacksjs/ts-css/optimize`| `csso` | `minify` / `minifyBlock`, `syntax.specificity` |
12It's a drop-in for the API surface that real consumers (CSSO, SVGO, lint
13tools, design-system pipelines) actually touch — written from scratch in
14TypeScript with strict types, the CSS Syntax Module Level 3 tokenizer, and
15the `css-select` adapter pattern preserved verbatim so existing call sites
16don't change.
1617
17The top-level `@stacksjs/ts-css` package re-exports all four under their
18familiar namespaces (`csstree`, `cssWhat`, `cssSelect`, `csso`) plus the
19most-used functions directly.
18## Highlights
19
20- **Zero runtime deps.** Only ts-css's own code. Ships ~4.5k lines of TS.
21- **Four packages worth of API in one import.** Sub-modules at
22 `@stacksjs/ts-css/parse`, `/what`, `/select`, `/optimize`.
23- **Drop-in compatible.** Namespace exports (`csstree`, `cssWhat`,
24 `cssSelect`, `csso`) keep existing call sites intact.
25- **Bun-first.** Built and tested on Bun, works on Node ≥18.
26- **Strict TypeScript.** Strict mode, isolatedDeclarations, verbatimModuleSyntax.
2027
2128## Install
2229
2330```bash
2431bun add @stacksjs/ts-css
32# or
33npm i @stacksjs/ts-css
2534```
2635
2736## Quick start
2837
29### Parse → walk → generate
38### Parse, walk, generate
3039
3140```ts
3241import { generate, parse, walk } from '@stacksjs/ts-css'
3342
34const ast = parse('.foo { color: red }')
35walk(ast, (node) => {
36 if (node.type === 'Declaration')
37 console.log(node.property)
43const ast = parse(`
44 .foo, .bar > .baz {
45 color: red;
46 background: url("/x.png") no-repeat;
47 }
48`)
49
50walk(ast, {
51 visit: 'Declaration',
52 enter(decl) {
53 console.log(decl.property, '→', generate(decl.value))
54 },
3855})
56// color → red
57// background → url("/x.png") no-repeat
58
3959console.log(generate(ast))
60// .foo,.bar>.baz{color:red;background:url("/x.png") no-repeat}
4061```
4162
42### Selector matching
63### Match selectors against any tree
4364
4465```ts
4566import { selectAll } from '@stacksjs/ts-css'
4667
47const els = selectAll('p > span.foo', root, { adapter, xmlMode: true })
68const adapter = {
69 isTag: (n) => n?.type === 'el',
70 getName: (e) => e.name,
71 getAttributeValue: (e, n) => e.attrs[n],
72 // …other adapter methods (see docs/api/select.md)
73}
74
75selectAll('p > span.foo:not(.disabled)', root, { adapter, xmlMode: true })
4876```
4977
50The adapter shape is identical to `css-select`'s — pass your own
51implementation for any tree (HTML, XML, your custom AST, etc.).
78The adapter shape is **identical** to `css-select`'s — pass your own and
79ts-css matches selectors against your custom tree (HTML, XML, your
80design-system AST, anything).
5281
5382### Minify CSS
5483
5584```ts
5685import { minify } from '@stacksjs/ts-css'
5786
58const { css } = minify('.a { margin: 0px 0.5em 10.000px; color:#aabbcc }')
59// → ".a{margin:0 .5em 10px;color:#abc}"
87minify(`
88 .a {
89 margin: 0px 0.5em 10.000px;
90 color: #aabbcc;
91 color: blue; /* later wins */
92 }
93`).css
94// → ".a{margin:0 .5em 10px;color:blue}"
6095```
6196
62### Specificity
97### Compute selector specificity
6398
6499```ts
65import { syntax } from '@stacksjs/ts-css'
66import { parse } from '@stacksjs/ts-css'
100import { parse, syntax } from '@stacksjs/ts-css'
67101
68102const sel = parse('#a.b div', { context: 'selector' })
69103syntax.specificity(sel) // [1, 1, 1]
70104```
71105
72### Drop-in compatibility
106## Migration: drop-in compat
73107
74If you're migrating off the original quartet, the namespace import lines
75change but the call sites stay the same:
108If you already use `css-tree`/`css-select`/`css-what`/`csso`, the migration
109is one block of imports:
76110
77111```diff
78112- import * as csstree from 'css-tree'
79113- import * as csswhat from 'css-what'
80- import { selectAll, selectOne, is } from 'css-select'
114- import { is, selectAll, selectOne } from 'css-select'
81115- import * as csso from 'csso'
82+ import { csstree, cssWhat, csso } from '@stacksjs/ts-css'
83+ import { selectAll, selectOne, is } from '@stacksjs/ts-css'
116+ import { csstree, cssWhat as csswhat, csso, is, selectAll, selectOne } from '@stacksjs/ts-css'
84117```
85118
119Every method already used by SVGO and CSSO works without further changes:
120
121| Surface | Available |
122| -------------------------------- | --------- |
123| `csstree.parse(s, opts)` | ✅ — all `context` modes |
124| `csstree.walk(ast, cb \| { visit, enter, leave })` + `walk.skip` | ✅ |
125| `csstree.generate(node)` | ✅ |
126| `csstree.clone(node)` | ✅ |
127| `csstree.List` / `ListItem` | ✅ — same `forEach(data, item, list)` callback shape |
128| `cssWhat.parse(selector)` | ✅ |
129| `cssWhat.isTraversal(seg)` | ✅ |
130| `cssSelect.{selectAll,selectOne,is}` | ✅ |
131| `csso.{minify,minifyBlock}` | ✅ |
132| `csso.syntax.specificity(node)` | ✅ |
133
86134## CLI
87135
88136```bash
89137ts-css minify input.css > output.css
90ts-css parse input.css | jq # pretty-print AST
91ts-css format input.css # round-trip through parser/generator
138ts-css parse input.css # AST as JSON on stdout
139ts-css format input.css # round-trip through parser/generator
92140```
93141
94## Scope notes
142## What's not (yet) in scope
95143
96This is a pragmatic port — the full surface area of the original libraries
97that real consumers (CSSO, SVGO, lint tools) actually touch. Specifically:
144This is a pragmatic port — full surface area where it's load-bearing,
145trimmed where the original libs spend most of their bytes on
146edge cases real consumers don't hit:
98147
99- ❌ No CSS-spec-aware lexer (no `<color>` / `<length>` validation against
100 the syntax definitions). Add it back when an actual consumer needs it —
101 the AST already preserves enough information to do so.
102- ❌ No selector-rewriting CSS restructuring (csso's "merge rules across
103 selectors" pass). Marginal real-world gain over gzip.
148- ❌ **No CSS-spec-aware lexer.** `css-tree`'s value validator (e.g.
149 "is `red` a valid `<color>`?") is ~3000 lines of spec data and grammar
150 matchers. We don't ship it. If you need spec validation, the AST already
151 preserves enough to add it on top.
152- ❌ **No CSSO restructuring pass.** `csso` reorders rules across selectors
153 to fold compatible declarations. It's complex and gives marginal gain
154 beyond gzip. Declaration-level minification (numbers, colors, dedup)
155 ships fully.
104156
105157Everything else round-trips byte-equivalent or better than the originals.
106158
159## Project layout
160
161```
162src/
163 parse/ # tokenizer, parser, AST types, walker, generator, clone, List
164 what/ # selector parser/stringifier
165 select/ # selectAll/selectOne/is engine + pseudo-class compilers
166 optimize/ # minifier, value compressors, specificity
167```
168
169Each sub-module is exported as a separate entry point so you can import
170just what you need:
171
172```ts
173import { parse, walk } from '@stacksjs/ts-css/parse'
174import { selectAll } from '@stacksjs/ts-css/select'
175import { minify } from '@stacksjs/ts-css/optimize'
176```
177
178## Documentation
179
180Full docs: see [`./docs`](./docs) or run `bun run dev:docs` to start the
181local docs site.
182
183- [Getting started](./docs/intro.md)
184- [Parser API](./docs/api/parse.md)
185- [Selector parser API](./docs/api/what.md)
186- [Selector matcher API](./docs/api/select.md)
187- [Optimizer API](./docs/api/optimize.md)
188- [Migration guide](./docs/migration.md)
189
107190## License
108191
109MIT
192MIT — see [`LICENSE.md`](./LICENSE.md).
193
194The selector parsing strategy in `src/what` is structurally inspired by
195[`css-what`](https://github.com/fb55/css-what); the visitor & List shape in
196`src/parse` mirrors [`css-tree`](https://github.com/csstree/csstree); the
197minification plan in `src/optimize` follows
198[`csso`](https://github.com/css/csso). All four are MIT-licensed; this
199project is an independent reimplementation that preserves their public API
200surface.
docs/api/optimize.mdadded+107-0
Changes to docs/api/optimize.md
@@ -0,0 +1,107 @@
1# Optimizer API
2
3Replaces `csso`. Compact a CSS string, remove duplicate declarations,
4shorten colors, and compute selector specificity.
5
6```ts
7import { minify, minifyBlock, specificity, syntax } from '@stacksjs/ts-css'
8// or:
9import { /* … */ } from '@stacksjs/ts-css/optimize'
10```
11
12## `minify(source, options?)`
13
14Parse, compress, and re-stringify a full stylesheet.
15
16```ts
17minify(source: string, options?: MinifyOptions): { css: string, ast: CssNode }
18```
19
20```ts
21minify('.a { margin: 0px 0.5em 10.000px; color: #aabbcc }').css
22// → ".a{margin:0 .5em 10px;color:#abc}"
23```
24
25## `minifyBlock(source, options?)`
26
27Same, but parse `source` as a declaration list (the body of a `style="…"`
28attribute).
29
30```ts
31minifyBlock('color: red; color: blue').css
32// → "color:blue"
33```
34
35## `MinifyOptions`
36
37| Option | Default | Description |
38| ------------------- | ---------------- | ---------------------------------------------------------- |
39| `comments` | `'exclamation'` | `false` to strip all, `true` / `'exclamation'` to keep `/*!*/` comments, `'first-exclamation'` to keep just the first. |
40| `restructure` | `true` | (Reserved — currently a no-op; declaration-level minification only.) |
41| `forceMediaMerge` | `false` | (Reserved.) |
42| `usage` | `null` | Usage hints (`{tags, ids, classes, force}`) — accepted for API parity. |
43
44## What gets compressed
45
46Inside any `Value`, ts-css compresses:
47
48| Input | Output |
49| ------------------ | ---------- |
50| `0.5em` | `.5em` |
51| `10.000px` | `10px` |
52| `0px` / `0em` / … | `0` |
53| `#aabbcc` | `#abc` |
54| `#aabbccdd` | `#abcd` |
55| `rgb(255,0,0)` | `#f00` |
56| `url("/x.png")` | `url(/x.png)` (when safe) |
57
58In each `Block`, duplicate declarations are folded with **last wins**
59semantics (respecting `!important`):
60
61```css
62.a { color: red; color: blue } → .a { color: blue }
63.a { color: red !important;
64 color: blue } → .a { color: red !important }
65```
66
67Comments matching `options.comments` rule are stripped.
68
69## `specificity(node)`
70
71Compute the specificity of a `Selector` AST.
72
73```ts
74import { parse, syntax } from '@stacksjs/ts-css'
75
76const sel = parse('#a.b div', { context: 'selector' })
77syntax.specificity(sel) // [1, 1, 1]
78```
79
80Returns a `[a, b, c]` tuple per
81[selectors level 4](https://www.w3.org/TR/selectors-4/#specificity-rules):
82
83- `a` — id selectors
84- `b` — class / attribute / pseudo-class selectors
85- `c` — type / pseudo-element selectors
86
87`:is()`, `:not()`, `:has()`, `:matches()`, `:-moz-any()`, `:-webkit-any()`
88take the **maximum** specificity of their arguments.
89`:where()` always contributes `[0,0,0]`.
90
91The `syntax.specificity` re-export mirrors `csso.syntax.specificity` so
92existing call sites work without changes.
93
94## Compose your own pipeline
95
96If you want finer control, all the building blocks are exported:
97
98```ts
99import { compressTree, dedupeDeclarations, removeComments } from '@stacksjs/ts-css/optimize'
100import { generate, parse } from '@stacksjs/ts-css/parse'
101
102const ast = parse(input)
103removeComments(ast, { exclamation: true })
104compressTree(ast)
105dedupeDeclarations(ast)
106const out = generate(ast)
107```
docs/api/parse.mdadded+166-0
Changes to docs/api/parse.md
@@ -0,0 +1,166 @@
1# Parser API
2
3Replaces `css-tree`. Tokenizer, recursive-descent parser, walker,
4generator, deep clone, and a doubly-linked `List`.
5
6```ts
7import { clone, generate, List, parse, walk } from '@stacksjs/ts-css'
8// or, when you only need the parser:
9import { /* … */ } from '@stacksjs/ts-css/parse'
10```
11
12## `parse(source, options?)`
13
14Parse a CSS source string into an AST.
15
16```ts
17parse(source: string, options?: ParseOptions): CssNode
18```
19
20### Options
21
22| Option | Default | Description |
23| ----------------------- | -------------- | ------------------------------------------------------------------------ |
24| `context` | `'stylesheet'` | What grammar to parse against (see below). |
25| `parseValue` | `true` | When `false`, declaration values are stored as `Raw` nodes. |
26| `parseAtrulePrelude` | `true` | When `false`, at-rule preludes are stored as `Raw`. |
27| `parseRulePrelude` | `true` | When `false`, rule preludes (selector lists) are stored as `Raw`. |
28| `parseCustomProperty` | `false` | When `true`, custom property values are parsed (otherwise `Raw`). |
29| `positions` | `false` | Track source `loc` info on every node. |
30| `filename` | — | Source filename, used in `loc.source`. |
31| `onParseError` | — | Called when the parser substitutes a `Raw` node for an error. |
32
33### Parse contexts
34
35| `context` | Returns | Use for |
36| ------------------- | ------------------------------------ | ---------------------------------------------------- |
37| `'stylesheet'` | `StyleSheet` | Top-level CSS files (default) |
38| `'atrule'` | `Atrule \| StyleSheet` | A single `@rule …;` or `@rule { … }` |
39| `'atrulePrelude'` | `AtrulePrelude` | The `(min-width: 800px)` part of a media query |
40| `'rule'` | `Rule` | A single `selector { … }` block |
41| `'block'` | `Block` | Just the `{ decl; decl; }` body |
42| `'declarationList'` | `DeclarationList` | A `style="…"` attribute body |
43| `'declaration'` | `Declaration` | A single `prop: value;` line |
44| `'value'` | `Value` | Just the value side of a declaration |
45| `'selector'` | `Selector` | A single selector segment (`a > b`) |
46| `'selectorList'` | `SelectorList` | A comma-separated list (`a, b > c`) |
47
48## `walk(ast, visitor)`
49
50Recursively traverse the AST.
51
52```ts
53walk(ast: CssNode, visitor: WalkVisitor): symbol | undefined
54```
55
56`visitor` is either:
57- A function: `(node, item, list) => void | symbol`
58- An object: `{ visit?, enter?, leave?, reverse? }`
59
60Inside callbacks, `this` carries traversal context: `this.atrule`,
61`this.rule`, `this.selector`, `this.declaration`, etc.
62
63```ts
64walk(ast, {
65 visit: 'Declaration',
66 enter(decl) {
67 if (decl.property === 'color')
68 decl.value = parse('blue', { context: 'value' })
69 },
70})
71```
72
73### Sentinels
74
75| Constant | What it does |
76| ------------- | --------------------------------------------------------- |
77| `walk.skip` | Return from `enter` to skip the node's children. |
78| `walk.stop` | Return to halt the entire traversal. |
79
80## `generate(node)`
81
82Serialize an AST node back to a CSS string. Output is compact (no
83unnecessary whitespace) — comparable to `csstree.generate(node)` with
84no formatting options.
85
86```ts
87generate(node: CssNode): string
88```
89
90## `clone(node)`
91
92Deep-clone an AST node, preserving the `List` structure of children.
93
94```ts
95clone<T extends CssNode>(node: T): T
96```
97
98## `List<T>`
99
100Doubly linked list — every container node uses one for its `children`.
101
102```ts
103import { List } from '@stacksjs/ts-css'
104
105const list = new List<number>()
106list.appendData(1)
107list.appendData(2)
108list.toArray() // [1, 2]
109```
110
111### Mutation methods
112
113| Method | Description |
114| --------------------------- | ------------------------------------------------- |
115| `appendData(data)` | Append a new item built from `data`. |
116| `prependData(data)` | Prepend a new item. |
117| `insertData(data, before?)` | Insert before `before` (a `ListItem`), or at tail. |
118| `insert(item, before?)` | Insert an existing `ListItem`. |
119| `remove(item)` | Detach an item from the list. |
120| `replace(old, newItem)` | Replace `old` with `newItem` (or another `List`). |
121| `clear()` | Detach all items. |
122
123### Iteration
124
125| Method | Callback signature |
126| ------------------------------- | ----------------------------------------------- |
127| `forEach(fn)` | `(data, item, list)` — matches `css-tree`. |
128| `forEachRight(fn)` | Same, in reverse. |
129| `reduce(fn, init)` | `(acc, data, index, list)` |
130| `some(fn)` | `(data, index, list) => boolean` |
131| `map(fn)` | Returns a new `List`. |
132| `filter(fn)` | Returns a new `List`. |
133
134The `forEach` / `forEachRight` callbacks honor a cursor protocol so
135callbacks can safely insert/remove items mid-walk without breaking the
136iterator.
137
138## AST node reference
139
140The full discriminated union is `CssNode`. The most common variants:
141
142```ts
143interface StyleSheet { type: 'StyleSheet', children: List<CssNode> }
144interface Rule { type: 'Rule', prelude: SelectorList | Raw, block: Block }
145interface Atrule { type: 'Atrule', name: string, prelude: AtrulePrelude | Raw | null, block: Block | null }
146interface Block { type: 'Block', children: List<CssNode> }
147interface Declaration { type: 'Declaration', property: string, value: Value | Raw, important: boolean | string }
148interface Value { type: 'Value', children: List<CssNode> }
149interface Selector { type: 'Selector', children: List<CssNode> }
150// + identifiers, numbers, dimensions, hashes, urls, functions, …
151```
152
153See `src/parse/types.ts` for the complete type definitions.
154
155## Tokenizer (advanced)
156
157If you need raw token output, import `Tokenizer` directly:
158
159```ts
160import { Tokenizer, TokenType } from '@stacksjs/ts-css/parse'
161
162const t = new Tokenizer('.foo { color: red }')
163for (const tok of t.tokens) {
164 console.log(TokenType[tok.type], tok.start, tok.end)
165}
166```
docs/api/select.mdadded+123-0
Changes to docs/api/select.md
@@ -0,0 +1,123 @@
1# Selector matcher API
2
3Replaces `css-select`. Run any CSS selector against any tree-like data
4structure via an `Adapter`.
5
6```ts
7import { compile, is, selectAll, selectOne } from '@stacksjs/ts-css'
8// or:
9import { /* … */ } from '@stacksjs/ts-css/select'
10```
11
12## `selectAll(selector, root, options)`
13
14Return every element under `root` that matches `selector`.
15
16```ts
17selectAll<Node, ElementNode extends Node>(
18 selector: string | Selector[][] | CompiledQuery<ElementNode>,
19 root: Node | Node[],
20 options: Options<Node, ElementNode>,
21): ElementNode[]
22```
23
24## `selectOne(selector, root, options)`
25
26Return the first match (in document order), or `null`.
27
28```ts
29selectOne(...): ElementNode | null
30```
31
32## `is(node, selector, options)`
33
34Test whether `node` itself matches `selector`.
35
36```ts
37is(node, selector, options): boolean
38```
39
40## `compile(selector, options)`
41
42Compile a selector once and reuse the predicate against many trees.
43
44```ts
45const test = compile('p > span.foo', options)
46adapter.findAll(test, [root])
47```
48
49## `Adapter<Node, ElementNode>`
50
51The adapter is the tree-access contract. Every method has the same
52signature as `css-select`'s — if you already have one, it works as-is.
53
54| Method | Returns | Purpose |
55| ------------------------------------------------- | -------------------------------- | ----------------------------------------------- |
56| `isTag(node)` | `node is ElementNode` | Type guard for element-vs-text/comment. |
57| `getName(elem)` | `string` | Element tag name. |
58| `getAttributeValue(elem, name)` | `string \| undefined` | Attribute value (case-sensitive on `name`). |
59| `hasAttrib(elem, name)` | `boolean` | Whether `elem[name]` is set. |
60| `getChildren(node)` | `Node[]` | Direct children of `node`. |
61| `getParent(elem)` | `ElementNode \| null` | Parent element (or null for the root). |
62| `getSiblings(elem)` | `Node[]` | All siblings, including `elem`. |
63| `getText(node)` | `string` | Text content (used by some pseudos). |
64| `findAll(test, elems)` | `ElementNode[]` | DFS traversal. |
65| `findOne(test, elems)` | `ElementNode \| null` | DFS, first match. |
66| `existsOne(test, elems)` | `boolean` | DFS, just a check. |
67| `removeSubsets(nodes)` | `Node[]` | Remove descendants when their ancestor is also in `nodes`. |
68
69Optional state-aware hooks (always called for `:hover` / `:active` /
70`:visited`):
71
72| Method | Default | When you'd implement it |
73| --------------------- | ---------------- | --------------------------------------------- |
74| `isActive(elem)` | always `false` | DOM-aware match for `:active` |
75| `isHovered(elem)` | always `false` | DOM-aware match for `:hover` |
76| `isVisited(elem)` | always `false` | DOM-aware match for `:visited` |
77| `equals(a, b)` | `===` | Custom node equality (rare). |
78
79If your tree has no notion of focus/hover, leave these undefined — the
80matcher treats stateful pseudos as never-matching.
81
82## `Options`
83
84| Option | Default | Description |
85| ---------------------------- | ------- | -------------------------------------------------------------------- |
86| `adapter` | — | **Required.** The `Adapter` for your tree. |
87| `xmlMode` | `false` | Disable HTML quirks (case-insensitive tags / attrs). |
88| `lowerCaseAttributeNames` | `true` | Lowercase attribute names before matching. |
89| `lowerCaseTags` | `true` | Lowercase tag names. |
90| `cacheResults` | `true` | Cache compiled selectors keyed by selector string. |
91| `context` | — | Reference node(s) for `:scope`. |
92| `pseudos` | — | Map of custom pseudo-class implementations. |
93| `relativeSelector` | — | Match `:has` lazily. |
94| `quirksMode` | `false` | Match HTML quirks-mode attribute case rules. |
95
96## Supported selectors
97
98| Category | Examples |
99| --------------------- | --------------------------------------------------- |
100| Type / universal | `div`, `*`, `svg\|circle` (namespaces in xmlMode) |
101| Class / id | `.foo`, `#bar` |
102| Attribute | `[a]`, `[a=x]`, `[a~=x]`, `[a\|=x]`, `[a^=x]`, `[a$=x]`, `[a*=x]` (with `i` / `s` case flags) |
103| Combinators | `a b`, `a > b`, `a + b`, `a ~ b`, `a \|\| b` |
104| Structural pseudos | `:first-child`, `:last-child`, `:only-child`, `:first-of-type`, `:last-of-type`, `:only-of-type`, `:nth-child(...)`, `:nth-last-child(...)`, `:nth-of-type(...)`, `:nth-last-of-type(...)` |
105| Logical pseudos | `:is(...)`, `:not(...)`, `:where(...)`, `:has(...)`, `:matches(...)` |
106| Other pseudos | `:empty`, `:root`, `:scope` |
107| Stateful pseudos | `:hover`, `:active`, `:visited`, `:focus`, etc. — gated on adapter `is*` hooks. |
108
109## Custom pseudo-classes
110
111Add your own via `options.pseudos`:
112
113```ts
114selectAll(':my-thing', root, {
115 adapter,
116 pseudos: {
117 'my-thing': elem => adapter.getName(elem).startsWith('x-'),
118 },
119})
120```
121
122The value is either a predicate `(elem, arg?) => boolean` or a string
123selector that the matcher will compile.
docs/api/what.mdadded+104-0
Changes to docs/api/what.md
@@ -0,0 +1,104 @@
1# Selector parser API
2
3Replaces `css-what`. Pure-string-to-selector-AST parsing — separate from
4the matcher, so you can analyse selectors (specificity, rewrites, lint
5rules) without running them.
6
7```ts
8import { isTraversal, parseSelector, stringifySelector } from '@stacksjs/ts-css'
9// or:
10import { parse, stringify, isTraversal } from '@stacksjs/ts-css/what'
11```
12
13## `parse(selector, options?)`
14
15Parse a CSS selector string into a list-of-lists of selector segments.
16
17```ts
18parse(selector: string, options?: ParseOptions): Selector[][]
19```
20
21The outer array is **comma-separated selectors**; the inner arrays are
22**simple selectors and combinators** in source order.
23
24```ts
25import { parse } from '@stacksjs/ts-css/what'
26
27parse('div > p, .foo')
28// [
29// [{type:'tag',name:'div'}, {type:'child'}, {type:'tag',name:'p'}],
30// [{type:'attribute',name:'class',action:'element',value:'foo'}],
31// ]
32```
33
34### Options
35
36| Option | Default | Description |
37| ---------------------------- | ------- | -------------------------------------------------------- |
38| `xmlMode` | `false` | Disable HTML quirks (case-insensitive tags / attrs). |
39| `lowerCaseAttributeNames` | `true` | Lowercase attribute names (HTML default). |
40| `lowerCaseTags` | `true` | Lowercase tag names. |
41
42## `stringify(ast)`
43
44Inverse of `parse` — turn a selector AST back into a string.
45
46```ts
47stringify(ast: Selector[][]): string
48```
49
50```ts
51import { parse, stringify } from '@stacksjs/ts-css/what'
52
53const ast = parse('div > p:not(.x)')
54stringify(ast) // 'div > p:not(.x)'
55```
56
57## `isTraversal(token)`
58
59Returns `true` if `token` is a combinator (descendant / child / sibling /
60adjacent / parent / column-combinator) and not a simple selector.
61
62```ts
63isTraversal(token: Selector): boolean
64```
65
66## Segment types
67
68The `Selector` union contains:
69
70```ts
71type Selector =
72 | { type: 'tag', name: string, namespace: string | null }
73 | { type: 'universal', namespace: string | null }
74 | { type: 'attribute', name: string, action: AttributeAction, value: string, namespace: string | null, ignoreCase: boolean | 'quirks' | null }
75 | { type: 'pseudo', name: string, data: string | Selector[][] | null }
76 | { type: 'pseudo-element', name: string, data: string | null }
77 | { type: 'descendant' }
78 | { type: 'child' }
79 | { type: 'parent' }
80 | { type: 'sibling' }
81 | { type: 'adjacent' }
82 | { type: 'column-combinator' }
83```
84
85`AttributeAction` is one of:
86`'any'`, `'element'`, `'end'`, `'equals'`, `'exists'`, `'hyphen'`, `'not'`, `'start'`.
87
88| `[a]` form | `action` |
89| -------------------- | ------------ |
90| `[a]` | `'exists'` |
91| `[a="x"]` | `'equals'` |
92| `[a~="x"]` | `'element'` |
93| `[a\|="x"]` | `'hyphen'` |
94| `[a^="x"]` | `'start'` |
95| `[a$="x"]` | `'end'` |
96| `[a*="x"]` | `'any'` |
97| `[a!="x"]` | `'not'` |
98
99## Pseudo-class shape
100
101For pseudo-classes that take a selector list (`:is`, `:not`, `:where`,
102`:has`, `:matches`, `:-moz-any`, `:-webkit-any`), the `data` field is
103**already parsed** as a `Selector[][]`. For other pseudo-classes that take
104a string (e.g. `:nth-child(2n+1)`), `data` is the raw inner string.
docs/index.mdmodified+26-21
Changes to docs/index.md
@@ -1,33 +1,38 @@
11---
2# https://vitepress.dev/reference/default-theme-home-page
32layout: home
43
54hero:
6 name: "ts-starter"
7 text: "For a better local environment."
8 tagline: "Modern and smart reverse proxy."
9 image: /images/logo-white.png
5 name: "@stacksjs/ts-css"
6 text: "Pure-TypeScript CSS toolkit."
7 tagline: "Parser, walker, generator, selector engine, minifier — zero runtime deps."
108 actions:
119 - theme: brand
12 text: Get Started
10 text: Get started
1311 link: /intro
12 - theme: alt
13 text: Migration guide
14 link: /migration
1415 - theme: alt
1516 text: View on GitHub
16 link: https://github.com/stacksjs/rpx
17 link: https://github.com/stacksjs/ts-css
1718
1819features:
19 - title: "Minimal Starter Kit"
20 icon: "🔀"
21 details: "Bootstrap your next TypeScript project."
22 - title: "Documentation"
23 icon: "��"
24 details: "Easily document your idea/package/library."
25 - title: "CLI"
26 icon: "🛠"
27 details: "Build your own CLI."
28 - title: "Binary"
29 icon: "��"
30 details: "Automatically builds a binary for your project out of your CLI."
20 - title: "All-in-one CSS pipeline"
21 icon: "🧩"
22 details: "Replaces css-tree + css-select + css-what + csso. One install, no transitive deps."
23 - title: "Drop-in compatible"
24 icon: "��"
25 details: "Namespace re-exports keep existing call sites unchanged. Migrate by editing imports."
26 - title: "Selector engine"
27 icon: "🎯"
28 details: "Run any CSS selector against any tree. Adapter pattern preserved verbatim."
29 - title: "Minifier"
30 icon: "��"
31 details: "Number / color / unit compression, declaration dedup, comment cleanup, specificity calc."
32 - title: "Bun-first"
33 icon: "🥟"
34 details: "Built on Bun, runs on Node ≥18. Strict TypeScript with isolatedDeclarations."
35 - title: "Small surface, full coverage"
36 icon: "✂️"
37 details: "Pragmatic — every API real consumers (CSSO, SVGO, lint tools) actually use."
3138---
32
33<Home />
docs/install.mdmodified+42-84
Changes to docs/install.md
@@ -1,118 +1,76 @@
11# Install
22
3_This is just an example of the ts-starter docs._
3`@stacksjs/ts-css` is a single package. There are no transitive dependencies.
44
5Installing `rpx` is easy. Simply pull it in via your package manager of choice, or download the binary directly.
6
7## Package Managers
8
9Choose your package manager of choice:
5## Package managers
106
117::: code-group
128
13```sh [npm]
14npm install --save-dev @stacksjs/rpx
15# npm i -d @stacksjs/rpx
16
17# or, install globally via
18npm i -g @stacksjs/rpx
19```
20
219```sh [bun]
22bun install --dev @stacksjs/rpx
23# bun add --dev @stacksjs/rpx
24# bun i -d @stacksjs/rpx
10bun add @stacksjs/ts-css
11# or, dev dependency
12bun add --dev @stacksjs/ts-css
13```
2514
26# or, install globally via
27bun add --global @stacksjs/rpx
15```sh [npm]
16npm install @stacksjs/ts-css
17# or, dev dependency
18npm install --save-dev @stacksjs/ts-css
2819```
2920
3021```sh [pnpm]
31pnpm add --save-dev @stacksjs/rpx
32# pnpm i -d @stacksjs/rpx
33
34# or, install globally via
35pnpm add --global @stacksjs/rpx
22pnpm add @stacksjs/ts-css
23# or, dev dependency
24pnpm add --save-dev @stacksjs/ts-css
3625```
3726
3827```sh [yarn]
39yarn add --dev @stacksjs/rpx
40# yarn i -d @stacksjs/rpx
41
42# or, install globally via
43yarn global add @stacksjs/rpx
44```
45
46```sh [brew]
47brew install rpx # coming soon
48```
49
50```sh [pkgx]
51pkgx rpx # coming soon
28yarn add @stacksjs/ts-css
5229```
5330
5431:::
5532
56Read more about how to use it in the Usage section of the documentation.
57
58## Binaries
59
60Choose the binary that matches your platform and architecture:
61
62::: code-group
33## Local development with `bun link`
6334
64```sh [macOS (arm64)]
65# Download the binary
66curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-darwin-arm64 -o rpx
35If you're contributing to ts-css and want to test the changes against
36another project locally, register it once with `bun link`:
6737
68# Make it executable
69chmod +x rpx
38```sh
39# inside the ts-css repo
40bun link
7041
71# Move it to your PATH
72mv rpx /usr/local/bin/rpx
42# inside the consumer project
43bun link @stacksjs/ts-css
7344```
7445
75```sh [macOS (x64)]
76# Download the binary
77curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-darwin-x64 -o rpx
46That's how [`ts-svg`](https://github.com/stacksjs/ts-svg) consumes ts-css
47during development — its `package.json` carries
48`"@stacksjs/ts-css": "link:@stacksjs/ts-css"`.
7849
79# Make it executable
80chmod +x rpx
50## CLI binary (optional)
8151
82# Move it to your PATH
83mv rpx /usr/local/bin/rpx
84```
85
86```sh [Linux (arm64)]
87# Download the binary
88curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-linux-arm64 -o rpx
89
90# Make it executable
91chmod +x rpx
52The `ts-css` CLI ships with the package and is exposed via Bun's `bin`
53field. After installing, you can run:
9254
93# Move it to your PATH
94mv rpx /usr/local/bin/rpx
55```sh
56bunx ts-css minify input.css
9557```
9658
97```sh [Linux (x64)]
98# Download the binary
99curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-linux-x64 -o rpx
59For a standalone binary, build from source:
10060
101# Make it executable
102chmod +x rpx
103
104# Move it to your PATH
105mv rpx /usr/local/bin/rpx
61```sh
62bun run compile # builds bin/ts-css for your platform
63bun run compile:all # cross-compile for darwin/linux/windows
10664```
10765
108```sh [Windows (x64)]
109# Download the binary
110curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-windows-x64.exe -o rpx.exe
66## Verifying the install
67
68```ts
69import { minify, parse } from '@stacksjs/ts-css'
11170
112# Move it to your PATH (adjust the path as needed)
113move rpx.exe C:\Windows\System32\rpx.exe
71console.log(minify('.a { color: #aabbcc }').css)
72// → ".a{color:#abc}"
11473```
11574
116::: tip
117You can also find the `rpx` binaries in GitHub [releases](https://github.com/stacksjs/rpx/releases).
118:::
75If that prints the minified output, you're set. Continue to
76[**Getting started**](./intro.md).
docs/intro.mdmodified+69-63
Changes to docs/intro.md
@@ -1,93 +1,99 @@
1<p align="center"><img src="https://github.com/stacksjs/rpx/blob/main/.github/art/cover.jpg?raw=true" alt="Social Card of this repo"></p>
1# Getting started
22
3# A Better Developer Experience
3`@stacksjs/ts-css` is a pure-TypeScript CSS toolkit — a parser, walker,
4generator, selector engine, and minifier — all in one **zero-dependency**
5package. It's a drop-in replacement for the four-library quartet most CSS
6pipelines pull in:
47
5> A TypeScript Starter Kit that will help you bootstrap your next project without minimal opinion.
8| Replaces | Provides |
9| ------------- | ----------------------------------------------------- |
10| `css-tree` | Tokenizer, parser, walker, generator, clone, `List` |
11| `css-what` | Selector parser & stringifier |
12| `css-select` | `selectAll` / `selectOne` / `is` against any tree |
13| `csso` | Minifier + `syntax.specificity` |
614
7# bun-ts-starter
15If you've worked with any of those four libraries before, the migration
16is one block of `import` lines (see the [migration guide](./migration.md)).
817
9This is an opinionated TypeScript Starter kit to help kick-start development of your next Bun package.
10
11## Get Started
12
13It's rather simple to get your package development started:
18## Install
1419
1520```bash
16# you may use this GitHub template or the following command:
17bunx degit stacksjs/ts-starter my-pkg
18cd my-pkg
19
20 # if you don't have pnpm installed, run `npm i -g pnpm`
21bun i # install all deps
22bun run build # builds the library for production-ready use
23
24# after you have successfully committed, you may create a "release"
25bun run release # automates git commits, versioning, and changelog generations
21bun add @stacksjs/ts-css
22# or
23npm i @stacksjs/ts-css
2624```
2725
28_Check out the package.json scripts for more commands._
29
30### Developer Experience (DX)
31
32This Starter Kit comes pre-configured with the following:
33
34- [Powerful Build Process](https://github.com/oven-sh/bun) - via Bun
35- [Fully Typed APIs](https://www.typescriptlang.org/) - via TypeScript
36- [Documentation-ready](https://vitepress.dev/) - via VitePress
37- [CLI & Binary](https://www.npmjs.com/package/bunx) - via Bun & CAC
38- [Be a Good Commitizen](https://www.npmjs.com/package/git-cz) - pre-configured Commitizen & git-cz setup to simplify semantic git commits, versioning, and changelog generations
39- [Built With Testing In Mind](https://bun.sh/docs/cli/test) - pre-configured unit-testing powered by [Bun](https://bun.sh/docs/cli/test)
40- [Renovate](https://renovatebot.com/) - optimized & automated PR dependency updates
41- [ESLint](https://eslint.org/) - for code linting _(and formatting)_
42- [GitHub Actions](https://github.com/features/actions) - runs your CI _(fixes code style issues, tags releases & creates its changelogs, runs the test suite, etc.)_
43
44## Changelog
26## Your first parse
4527
46Please see our [releases](https://github.com/stacksjs/stacks/releases) page for more information on what has changed recently.
28```ts
29import { generate, parse, walk } from '@stacksjs/ts-css'
4730
48## Stargazers
31const ast = parse('.foo { color: red }')
4932
50[![Stargazers](https://starchart.cc/stacksjs/ts-starter.svg?variant=adaptive)](https://starchart.cc/stacksjs/ts-starter)
33walk(ast, (node) => {
34 if (node.type === 'Declaration')
35 console.log(node.property)
36})
37// → "color"
5138
52## Contributing
53
54Please review the [Contributing Guide](https://github.com/stacksjs/contributing) for details.
39console.log(generate(ast))
40// → ".foo{color:red}"
41```
5542
56## Community
43That's the full pipeline: parse → walk/transform → generate.
5744
58For help, discussion about best practices, or any other conversation that would benefit from being searchable:
45## Exploring the AST
5946
60[Discussions on GitHub](https://github.com/stacksjs/stacks/discussions)
47Pass a `context` to parse a fragment instead of a full stylesheet:
6148
62For casual chit-chat with others using this package:
49```ts
50parse('color: red', { context: 'declaration' })
51parse('color: red; font-size: 12px', { context: 'declarationList' })
52parse('.a > .b', { context: 'selector' })
53parse('@media (min-width: 800px)', { context: 'atrulePrelude' })
54```
6355
64[Join the Stacks Discord Server](https://discord.gg/stacksjs)
56The full set of contexts is in the [parser API reference](./api/parse.md).
6557
66## Postcardware
58## Selecting nodes
6759
68Two things are true: Stacks OSS will always stay open-source, and we do love to receive postcards from wherever Stacks is used! �� _We also publish them on our website. And thank you, Spatie_
60`ts-css` ships a tree-agnostic selector engine. Provide an `Adapter` for
61your tree and you can run any CSS selector against it:
6962
70Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094
63```ts
64import { selectAll } from '@stacksjs/ts-css'
7165
72## Sponsors
66const matches = selectAll('p > span.foo:not(.disabled)', root, {
67 adapter: myAdapter,
68 xmlMode: true,
69})
70```
7371
74We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.
72[Full selector matcher API →](./api/select.md)
7573
76- [JetBrains](https://www.jetbrains.com/)
77- [The Solana Foundation](https://solana.com/)
74## Minifying
7875
79## Credits
76```ts
77import { minify } from '@stacksjs/ts-css'
8078
81- [Chris Breuer](https://github.com/chrisbbreuer)
82- [All Contributors](https://github.com/stacksjs/rpx/graphs/contributors)
79minify('.a { margin: 0px; color: #aabbcc }').css
80// → ".a{margin:0;color:#abc}"
81```
8382
84## License
83[Full minifier API →](./api/optimize.md)
8584
86The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/ts-starter/tree/main/LICENSE.md) for more information.
85## CLI
8786
88Made with ��
87```bash
88ts-css minify input.css > output.css
89ts-css parse input.css | jq
90ts-css format input.css
91```
8992
90<!-- Badges -->
93## Where to next
9194
92<!-- [codecov-src]: https://img.shields.io/codecov/c/gh/stacksjs/rpx/main?style=flat-square
93[codecov-href]: https://codecov.io/gh/stacksjs/rpx -->
95- [**Parser** API](./api/parse.md) — `parse`, `walk`, `generate`, `clone`, `List`
96- [**Selector parser** API](./api/what.md) — `parse`, `stringify`, `isTraversal`
97- [**Selector matcher** API](./api/select.md) — `selectAll`, `selectOne`, `is`
98- [**Optimizer** API](./api/optimize.md) — `minify`, `minifyBlock`, `specificity`
99- [**Migration** guide](./migration.md) — moving off the four-library setup
docs/migration.mdadded+125-0
Changes to docs/migration.md
@@ -0,0 +1,125 @@
1# Migration guide
2
3Migrating from `css-tree` / `css-select` / `css-what` / `csso` to
4`@stacksjs/ts-css` is one block of `import` lines.
5
6## The diff
7
8```diff
9- import * as csstree from 'css-tree'
10- import * as csswhat from 'css-what'
11- import { is, selectAll, selectOne } from 'css-select'
12- import * as csso from 'csso'
13
14+ import {
15+ csstree, // namespace mirror of css-tree
16+ cssWhat as csswhat, // namespace mirror of css-what
17+ csso, // namespace mirror of csso
18+ is, selectAll, selectOne, // top-level css-select APIs
19+ } from '@stacksjs/ts-css'
20```
21
22That's it. Every method real consumers use is preserved.
23
24## Per-library compatibility
25
26### `css-tree`
27
28| Surface | Compat |
29| -------------------------------------------- | ------ |
30| `parse(source, opts)` — all `context` modes | ✅ |
31| `walk(ast, cb)` & `walk(ast, { visit, enter, leave })` | ✅ |
32| `walk.skip` / `walk.stop` | ✅ |
33| `this.atrule` / `this.rule` context inside callbacks | ✅ |
34| `generate(node)` | ✅ |
35| `clone(node)` | ✅ |
36| `List` class — `forEach(data, item, list)`, `insert`, `remove`, `replace`, `prepend`, `append`, `prependList`, `appendList`, `insertList`, `createItem`, `first`, `last`, `isEmpty` | ✅ |
37| Lexer (`syntax.lexer.match*`, `syntax.match*`) | ❌ — see below |
38
39### `css-what`
40
41| Surface | Compat |
42| -------------------- | ------ |
43| `parse(selector)` | ✅ |
44| `stringify(ast)` | ✅ |
45| `isTraversal(token)` | ✅ |
46| Selector segment shape (incl. `ignoreCase: 'quirks'`) | ✅ |
47
48### `css-select`
49
50| Surface | Compat |
51| ------------------------------------------- | ------ |
52| `selectAll(sel, root, opts)` | ✅ |
53| `selectOne(sel, root, opts)` | ✅ |
54| `is(node, sel, opts)` | ✅ |
55| `compile(sel, opts)` | ✅ |
56| `Adapter` interface | ✅ — same shape, including state hooks (`isActive`, etc.) |
57| `pseudos` extension | ✅ |
58| `:has`, `:is`, `:not`, `:where`, `:matches` | ✅ |
59| `:nth-child`, `:nth-last-child`, `:nth-of-type`, `:nth-last-of-type`, `:first-child`, `:last-child`, `:only-child`, `:first-of-type`, `:last-of-type`, `:only-of-type` | ✅ |
60| `:empty`, `:root`, `:scope` | ✅ |
61| Stateful pseudos via adapter hooks | ✅ |
62
63### `csso`
64
65| Surface | Compat |
66| -------------------------------------------- | ------ |
67| `minify(source, opts)` | ✅ |
68| `minifyBlock(source, opts)` | ✅ |
69| `syntax.specificity(node)` | ✅ |
70| Restructuring across rules / selectors | ❌ — declaration-level only |
71
72## What's intentionally not ported
73
74We dropped two large chunks of the original libs that real consumers
75rarely need:
76
77### `css-tree`'s lexer (`syntax.lexer`)
78
79`css-tree` ships ~3000 lines of CSS spec data that lets you ask "is the
80string `red 1px solid` a valid `<border>` shorthand?". This is great for
81linters and editors, but downstream consumers like CSSO, SVGO, and most
82formatters never touch it.
83
84The AST that ts-css produces is **rich enough** for you to add lexer
85behaviour back on top — every node carries its source slice and a typed
86discriminated union, so a value-type checker is a straightforward
87external library.
88
89### CSSO's restructuring pass
90
91`csso` does heroic work to merge declarations across rules:
92
93```css
94.a { color: red; padding: 4px }
95.b { color: red }
96.c { padding: 4px }
97```
98
99→ rewritten to share declarations between rules. Beyond gzip's reach
100this saves **single-digit percent**, costs ~1000 lines, and breaks subtly
101in edge cases (specificity, source-order dependencies, vendor-prefix
102collisions). ts-css does declaration-level minification (numbers,
103colors, dedup-within-rule) which catches the bulk of the bytes.
104
105If you have a use case where restructuring matters, file an issue —
106adding it back is straightforward, but we'd rather not ship dead code.
107
108## Real-world swap: `ts-svg`
109
110[`ts-svg`](https://github.com/stacksjs/ts-svg)'s SVGO port uses ts-css.
111The migration was 7 files and looked like:
112
113```diff
114- import * as csstree from 'css-tree'
115- import * as csswhat from 'css-what'
116- import { is, selectAll, selectOne } from 'css-select'
117- import { syntax } from 'csso'
118- import * as csso from 'csso'
119
120+ import { csstree, cssWhat as csswhat, csso, is, selectAll, selectOne } from '@stacksjs/ts-css'
121+ const { syntax } = csso
122```
123
124The full SVGO test suite (82 tests) passes after the swap with no
125behaviour changes, and removes 4 transitive dep trees from `node_modules`.
docs/usage.mdmodified+135-62
Changes to docs/usage.md
@@ -1,89 +1,162 @@
1# Get Started
1# Usage
22
3There are two ways of using this reverse proxy: _as a library or as a CLI._
3`@stacksjs/ts-css` is a library first, with a small CLI on top. Most
4consumers import it as a library; the CLI is a convenience wrapper for
5ad-hoc minification and AST inspection.
46
57## Library
68
7Given the npm package is installed:
9### Full pipeline: parse → walk → generate
810
911```ts
10import type { TlsConfig } from '@stacksjs/rpx'
11import { startProxy } from '@stacksjs/rpx'
12import { generate, parse, walk } from '@stacksjs/ts-css'
13
14const ast = parse(`
15 .foo {
16 color: red;
17 background: url("/assets/bg.png");
18 }
19`)
20
21walk(ast, {
22 visit: 'Url',
23 enter(url) {
24 console.log('asset:', url.value)
25 },
26})
1227
13export interface CleanupConfig {
14 hosts: boolean // clean up /etc/hosts, defaults to false
15 certs: boolean // clean up certificates, defaults to false
16}
28console.log(generate(ast))
29```
30
31### Selector matching against your tree
1732
18export interface ReverseProxyConfig {
19 from: string // domain to proxy from, defaults to localhost:3000
20 to: string // domain to proxy to, defaults to stacks.localhost
21 cleanUrls?: boolean // removes the .html extension from URLs, defaults to false
22 https: boolean | TlsConfig // automatically uses https, defaults to true, also redirects http to https
23 cleanup?: boolean | CleanupConfig // automatically cleans up /etc/hosts, defaults to false
24 verbose: boolean // log verbose output, defaults to false
33```ts
34import { selectAll } from '@stacksjs/ts-css'
35
36interface MyEl {
37 type: 'el'
38 name: string
39 attrs: Record<string, string>
40 children: MyEl[]
41 parent: MyEl | null
2542}
2643
27const config: ReverseProxyOptions = {
28 from: 'localhost:3000',
29 to: 'my-docs.localhost',
30 cleanUrls: true,
31 https: true,
32 cleanup: false,
44const adapter = {
45 isTag: (n: any): n is MyEl => n?.type === 'el',
46 getName: (e: MyEl) => e.name,
47 getParent: (e: MyEl) => e.parent,
48 getChildren: (e: MyEl) => e.children,
49 getSiblings: (e: MyEl) => e.parent?.children ?? [e],
50 getAttributeValue: (e: MyEl, n: string) => e.attrs[n],
51 hasAttrib: (e: MyEl, n: string) => n in e.attrs,
52 getText: () => '',
53 removeSubsets: (xs: any[]) => xs,
54 existsOne: (test: (e: MyEl) => boolean, xs: any[]): boolean =>
55 xs.some(x => adapter.isTag(x) && (test(x) || adapter.existsOne(test, x.children))),
56 findAll: (test: (e: MyEl) => boolean, xs: any[]): MyEl[] => {
57 const out: MyEl[] = []
58 for (const x of xs) {
59 if (adapter.isTag(x)) {
60 if (test(x)) out.push(x)
61 out.push(...adapter.findAll(test, x.children))
62 }
63 }
64 return out
65 },
66 findOne: (test: (e: MyEl) => boolean, xs: any[]): MyEl | null => {
67 for (const x of xs) {
68 if (adapter.isTag(x)) {
69 if (test(x)) return x
70 const r = adapter.findOne(test, x.children)
71 if (r) return r
72 }
73 }
74 return null
75 },
3376}
3477
35startProxy(config)
78const matches = selectAll('p > .foo:not(.disabled)', root, {
79 adapter,
80 xmlMode: true,
81})
3682```
3783
38In case you are trying to start multiple proxies, you may use this configuration:
84### Minify a stylesheet
3985
4086```ts
41// reverse-proxy.config.{ts,js}
42import type { ReverseProxyOptions } from '@stacksjs/rpx'
43import os from 'node:os'
44import path from 'node:path'
45
46const config: ReverseProxyOptions = {
47 https: { // https: true -> also works with sensible defaults
48 caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`),
49 certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`),
50 keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`),
51 },
87import { minify } from '@stacksjs/ts-css'
5288
53 cleanup: {
54 hosts: true,
55 certs: false,
56 },
89const { css } = minify(input, {
90 comments: 'exclamation', // keep `/*!*/` comments
91})
92```
5793
58 proxies: [
59 {
60 from: 'localhost:5173',
61 to: 'my-app.localhost',
62 cleanUrls: true,
63 },
64 {
65 from: 'localhost:5174',
66 to: 'my-api.local',
67 },
68 ],
69
70 verbose: true,
71}
94### Compute selector specificity
7295
73export default config
96```ts
97import { parse, syntax } from '@stacksjs/ts-css'
98
99const sel = parse('#a.b div', { context: 'selector' })
100syntax.specificity(sel) // [1, 1, 1]
74101```
75102
76103## CLI
77104
78```bash
79rpx --from localhost:3000 --to my-project.localhost
80rpx --from localhost:8080 --to my-project.test --keyPath ./key.pem --certPath ./cert.pem
81rpx --help
82rpx --version
105```sh
106ts-css <command> <file>
107```
108
109| Command | Output |
110| --------------- | ---------------------------------------------------- |
111| `minify <file>` | Minified CSS on stdout. |
112| `parse <file>` | The parsed AST as JSON on stdout. |
113| `format <file>` | Round-trip through parser/generator (deterministic). |
114| `version` | Print the installed CLI version. |
115
116### Examples
117
118```sh
119# Minify a stylesheet, write to disk
120ts-css minify src/app.css > dist/app.css
121
122# Inspect the AST of a snippet
123echo '.a { color: red }' > /tmp/snip.css
124ts-css parse /tmp/snip.css | jq '.children[0]'
125
126# Format-only round trip
127ts-css format src/app.css
83128```
84129
85## Testing
130### Flags
131
132`ts-css minify`:
133
134| Flag | Description |
135| ---------------- | ------------------------------------ |
136| `--no-comments` | Strip `/*!*/` comments too. |
137| `--block` | Treat input as a `style="…"` body. |
138
139`ts-css parse`:
140
141| Flag | Description |
142| -------------- | --------------------------------------------------- |
143| `--positions` | Track source-location info on every AST node. |
86144
87```bash
88bun test
145## Configuration
146
147You can drop a `css.config.ts` at the project root for defaults:
148
149```ts
150// css.config.ts
151import type { CSSOptions } from '@stacksjs/ts-css'
152
153const config: CSSOptions = {
154 floatPrecision: 3,
155 verbose: false,
156}
157
158export default config
89159```
160
161`ts-css` loads it via [`bunfig`](https://github.com/stacksjs/bunfig) — the
162same loader used across the Stacks toolchain.
src/optimize/compress/index.tsmodified+70-21
Changes to src/optimize/compress/index.ts
@@ -1,16 +1,63 @@
11/**
22 * Run all compressors over the AST in place.
3 *
4 * Identifier ↔ Hash conversions happen only when the surrounding
5 * declaration's property is known to accept a color (so `font-family: red`
6 * isn't treated as a color, and `color: red` is). The list+item args
7 * from the walker callback let us swap nodes in place without rebuilding
8 * the parent list.
39 */
410
5import type { CssNode } from '../../parse'
11import type { CssNode, HashNode, Identifier } from '../../parse'
612import { walk } from '../../parse'
713import { colorNameToHex, hexToShortName, shortenHex } from './color'
814import { compressDimension, compressNumber, compressPercentage } from './number'
9import { compressString } from './string'
1015import { compressUrl } from './url'
1116
17const COLOR_PROPERTIES: ReadonlySet<string> = new Set([
18 'color',
19 'background',
20 'background-color',
21 'border',
22 'border-color',
23 'border-top',
24 'border-top-color',
25 'border-right',
26 'border-right-color',
27 'border-bottom',
28 'border-bottom-color',
29 'border-left',
30 'border-left-color',
31 'border-block',
32 'border-block-color',
33 'border-block-start-color',
34 'border-block-end-color',
35 'border-inline',
36 'border-inline-color',
37 'border-inline-start-color',
38 'border-inline-end-color',
39 'outline',
40 'outline-color',
41 'caret-color',
42 'fill',
43 'stroke',
44 'flood-color',
45 'lighting-color',
46 'stop-color',
47 'column-rule',
48 'column-rule-color',
49 'text-decoration',
50 'text-decoration-color',
51 'text-emphasis',
52 'text-emphasis-color',
53 'text-shadow',
54 'box-shadow',
55 'accent-color',
56 'scrollbar-color',
57])
58
1259export function compressTree(ast: CssNode): void {
13 walk(ast, (node) => {
60 walk(ast, function compressVisitor(node, item, list) {
1461 switch (node.type) {
1562 case 'Number':
1663 node.value = compressNumber(node.value)
@@ -25,36 +72,38 @@ export function compressTree(ast: CssNode): void {
2572 return
2673 }
2774 case 'Hash': {
75 // Hex normalisation runs unconditionally — `#aabbcc` → `#abc`.
2876 const newName = shortenHex(`#${node.name}`).slice(1)
2977 node.name = newName
30 const short = hexToShortName(`#${newName}`)
31 if (short) {
32 // Replace this Hash by an Identifier in caller-managed list — done
33 // by a follow-up pass; here we just leave the shortened hex.
34 void short
78 // If a shorter named keyword exists for the resulting hex, swap
79 // the Hash out for an Identifier (only inside a color property).
80 if (item && list && this.declaration && COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) {
81 const short = hexToShortName(`#${newName}`)
82 if (short && short.length < newName.length + 1) {
83 const ident: Identifier = { type: 'Identifier', name: short, loc: node.loc }
84 list.replace(item, list.createItem(ident))
85 }
3586 }
3687 return
3788 }
3889 case 'Identifier': {
39 // Color name → hex (when shorter)
90 // Named color → short hex when the substitution is *strictly* shorter.
91 // `blue` (4) and `#00f` (4) tie — we keep the keyword (more readable
92 // and survives gzip the same).
93 if (!item || !list || !this.declaration)
94 return
95 if (!COLOR_PROPERTIES.has(this.declaration.property.toLowerCase()))
96 return
4097 const hex = colorNameToHex(node.name)
4198 if (hex && hex.length < node.name.length) {
42 // Replace identifier with a shortened hex by mutating in place is
43 // not possible (Identifier vs Hash differ in shape) — leave alone
44 // unless we're in a property where colors are expected. The csso
45 // library does this with full property/value type knowledge; we
46 // settle for the safer hex→name reverse direction.
47 void hex
99 const hashNode: HashNode = { type: 'Hash', name: hex.slice(1), loc: node.loc }
100 list.replace(item, list.createItem(hashNode))
48101 }
49102 return
50103 }
51 case 'String':
52 node.value = node.value
53 // re-rendered by generator using its own quoting
54 return
55104 case 'Url':
56 node.value = node.value
57 // generator already picks quoting via compressUrl shape — re-call:
105 // Generator picks quoting via compressUrl — strip the quotes
106 // we'd produce so the round-trip is stable.
58107 node.value = compressUrl(node.value).replace(/^"|"$/g, '')
59108 return
60109 }
src/optimize/specificity.tsmodified+93-9
Changes to src/optimize/specificity.ts
@@ -6,21 +6,45 @@
66 * b = class / attribute / pseudo-class selectors
77 * c = type / pseudo-element selectors
88 *
9 * Operates on css-tree-style `Selector` nodes (children list of simple
10 * selectors + combinators). Mirrors `csso.syntax.specificity` so SVGO's
11 * inlineStyles plugin can swap deps cleanly.
9 * Accepts either:
10 * - a css-tree-style `Selector` / `SelectorList` AST node (csso shape), OR
11 * - a `Selector[][]` returned by `cssWhat.parse(string)` — so callers
12 * can pipe `cssWhat.parse(...)` straight into `syntax.specificity(...)`.
1213 */
1314
1415import type { CssNode } from '../parse'
16import type { Selector as WhatSelector } from '../what'
1517
1618export type Specificity = [number, number, number]
1719
18export function specificity(node: CssNode): Specificity {
20export function specificity(input: CssNode | WhatSelector[][] | WhatSelector[] | string): Specificity {
1921 const result: Specificity = [0, 0, 0]
20 visit(node, result)
22
23 if (typeof input === 'string') {
24 // Lazy import to avoid a hard cycle.
25 const { parse: parseSelector } = require('../what') as typeof import('../what')
26 const ast = parseSelector(input)
27 visitWhat(ast, result)
28 return result
29 }
30
31 if (Array.isArray(input)) {
32 if (input.length === 0)
33 return result
34 // distinguish Selector[][] from Selector[]
35 if (Array.isArray(input[0]))
36 visitWhat(input as WhatSelector[][], result)
37 else
38 visitWhatGroup(input as WhatSelector[], result)
39 return result
40 }
41
42 visit(input, result)
2143 return result
2244}
2345
46// ---- css-tree shape ----
47
2448function visit(node: CssNode, result: Specificity): void {
2549 switch (node.type) {
2650 case 'IdSelector':
@@ -38,9 +62,6 @@ function visit(node: CssNode, result: Specificity): void {
3862 const name = node.name.toLowerCase()
3963 if (name === 'is' || name === 'matches' || name === '-moz-any' || name === '-webkit-any' || name === 'not' || name === 'has') {
4064 if (node.children) {
41 // The argument is either a SelectorList (multiple selectors) or a
42 // flat list of selector tokens. Either way, find each top-level
43 // Selector and take the max of their individual specificities.
4465 const selectors: CssNode[] = []
4566 for (const inner of node.children as Iterable<CssNode>) {
4667 if (inner.type === 'SelectorList' && 'children' in inner && inner.children) {
@@ -80,11 +101,74 @@ function visit(node: CssNode, result: Specificity): void {
80101 }
81102 return
82103 case 'NestingSelector':
83 // `&` adopts the host's specificity — approximate as 0
84104 return
85105 }
86106}
87107
108// ---- css-what shape (Selector[][] or Selector[]) ----
109//
110// css-what's `Selector` is one of:
111// { type: 'tag' | 'universal' | 'attribute' | 'pseudo' | 'pseudo-element'
112// | 'descendant' | 'child' | 'parent' | 'sibling' | 'adjacent' | 'column-combinator',
113// … }
114//
115// For specificity, *only* attribute/pseudo/tag matter — combinators are
116// ignored. `:is`/`:not`/`:has`/`:matches` take the max of their args.
117
118function visitWhat(groups: WhatSelector[][], result: Specificity): void {
119 // Each top-level group is one comma-separated selector. css-tree-style
120 // specificity is computed per-selector; for SelectorList (`a, b`) we
121 // sum across all groups (matches csso behaviour).
122 for (const g of groups)
123 visitWhatGroup(g, result)
124}
125
126function visitWhatGroup(tokens: WhatSelector[], result: Specificity): void {
127 for (const t of tokens) {
128 switch (t.type) {
129 case 'tag':
130 if (t.name !== '*')
131 result[2]++
132 break
133 case 'attribute':
134 // `.foo` and `#bar` are emitted as attribute selectors with name
135 // 'class'/'id' — count them like ClassSelector / IdSelector.
136 if (t.name === 'id' && t.action === 'equals')
137 result[0]++
138 else
139 result[1]++
140 break
141 case 'pseudo': {
142 const name = t.name.toLowerCase()
143 if (name === 'is' || name === 'matches' || name === 'not' || name === 'has' || name === '-moz-any' || name === '-webkit-any') {
144 // data is Selector[][] when parsed by css-what's selector-list pseudos
145 if (Array.isArray(t.data)) {
146 let max: Specificity = [0, 0, 0]
147 for (const inner of t.data as WhatSelector[][]) {
148 const s: Specificity = [0, 0, 0]
149 visitWhatGroup(inner, s)
150 if (compareSpec(s, max) > 0)
151 max = s
152 }
153 result[0] += max[0]
154 result[1] += max[1]
155 result[2] += max[2]
156 }
157 break
158 }
159 if (name === 'where')
160 break
161 result[1]++
162 break
163 }
164 case 'pseudo-element':
165 result[2]++
166 break
167 // combinators don't contribute to specificity
168 }
169 }
170}
171
88172function compareSpec(a: Specificity, b: Specificity): number {
89173 for (let i = 0; i < 3; i++) {
90174 if (a[i] !== b[i])
src/parse/list.tsmodified+28-20
Changes to src/parse/list.ts
@@ -17,30 +17,35 @@ function createItem<T>(data: T): ListItem<T> {
1717 return { prev: null, next: null, data }
1818}
1919
20const cursors: Array<{ list: CssList<any>, prev: ListItem<any> | null, next: ListItem<any> | null, cursor: any }> = []
20interface ListCursor<T> {
21 prev: ListItem<T> | null
22 next: ListItem<T> | null
23}
2124
2225export class CssList<T> {
2326 head: ListItem<T> | null = null
2427 tail: ListItem<T> | null = null
2528
2629 // ----- mutation cursors -----
27 // Walkers may iterate while declarations get inserted/removed mid-flight;
28 // an active cursor records the in-progress prev/next pointers so a removal
29 // that lands on the cursor's `next` slot can repoint it onto the survivor.
30 private allocateCursor(prev: ListItem<T> | null, next: ListItem<T> | null): any {
31 const cursor = { list: this, prev, next, cursor: cursors[cursors.length - 1] || null }
32 cursors.push(cursor)
30 // Each list owns its own cursor stack. Callbacks may insert/remove items
31 // mid-walk; the topmost active cursor records the in-progress prev/next
32 // pointers so a removal that lands on the cursor's `next` slot can be
33 // repointed onto the survivor. Per-list (not module-global) so nested
34 // walks of the same list don't corrupt each other.
35 private cursors: ListCursor<T>[] = []
36
37 private allocateCursor(prev: ListItem<T> | null, next: ListItem<T> | null): ListCursor<T> {
38 const cursor: ListCursor<T> = { prev, next }
39 this.cursors.push(cursor)
3340 return cursor
3441 }
3542
3643 private releaseCursor(): void {
37 cursors.pop()
44 this.cursors.pop()
3845 }
3946
4047 private updateCursors(prevOld: ListItem<T> | null, prevNew: ListItem<T> | null, nextOld: ListItem<T> | null, nextNew: ListItem<T> | null): void {
41 for (const c of cursors) {
42 if (c.list !== this)
43 continue
48 for (const c of this.cursors) {
4449 if (c.prev === prevOld)
4550 c.prev = prevNew
4651 if (c.next === nextOld)
@@ -100,28 +105,31 @@ export class CssList<T> {
100105 return this.toArray()
101106 }
102107
108 /**
109 * css-tree-style forEach: callback receives `(data, item, list)`.
110 * The `item` is the underlying `ListItem<T>`, not the index — call sites
111 * use it with `list.remove(item)` / `list.replace(item, ...)`.
112 */
103113 // eslint-disable-next-line pickier/no-unused-vars
104 forEach(fn: (item: T, index: number, list: CssList<T>) => void, thisArg?: any): void {
114 forEach(fn: (data: T, item: ListItem<T>, list: CssList<T>) => void, thisArg?: any): void {
105115 const cursor = this.allocateCursor(null, this.head)
106 let i = 0
107116 while (cursor.next !== null) {
108117 const item = cursor.next as ListItem<T>
109118 cursor.prev = item
110119 cursor.next = item.next
111 fn.call(thisArg, item.data, i++, this)
120 fn.call(thisArg, item.data, item, this)
112121 }
113122 this.releaseCursor()
114123 }
115124
116125 // eslint-disable-next-line pickier/no-unused-vars
117 forEachRight(fn: (item: T, index: number, list: CssList<T>) => void, thisArg?: any): void {
126 forEachRight(fn: (data: T, item: ListItem<T>, list: CssList<T>) => void, thisArg?: any): void {
118127 const cursor = this.allocateCursor(this.tail, null)
119 let i = this.toArray().length - 1
120128 while (cursor.prev !== null) {
121129 const item = cursor.prev as ListItem<T>
122130 cursor.next = item
123131 cursor.prev = item.prev
124 fn.call(thisArg, item.data, i--, this)
132 fn.call(thisArg, item.data, item, this)
125133 }
126134 this.releaseCursor()
127135 }
@@ -220,9 +228,9 @@ export class CssList<T> {
220228 return this.insert(createItem(data), null)
221229 }
222230
223 /** Insert `item` before `before`, or at tail if `before` is null. */
224 insert(item: ListItem<T>, before: ListItem<T> | null = null): this {
225 if (before !== null) {
231 /** Insert `item` before `before`, or at tail if `before` is null/undefined. */
232 insert(item: ListItem<T>, before: ListItem<T> | null | undefined = null): this {
233 if (before != null) {
226234 this.updateCursors(before.prev, item, before, item)
227235 if (before.prev === null) {
228236 if (this.head !== before)
src/parse/parser/index.tsmodified+64-14
Changes to src/parse/parser/index.ts
@@ -152,6 +152,24 @@ function newList<T>(): CssList<T> {
152152 return new CssList<T>()
153153}
154154
155/**
156 * Report a parse problem to the consumer. Always returns the supplied
157 * fallback node so call sites can `return reportError(…, raw)`. The
158 * `onParseError` hook lets consumers (linters, IDEs) surface
159 * the parser's recovery substitutions.
160 */
161function reportError(s: ParserState, message: string, at: Token, fallback: CssNode): CssNode {
162 if (s.onParseError) {
163 const { line, column } = s.tokenizer.locate(at.start)
164 const err = new SyntaxError(`${s.filename ?? '<input>'}:${line}:${column}: ${message}`) as SyntaxError & { line: number, column: number, offset: number }
165 err.line = line
166 err.column = column
167 err.offset = at.start
168 s.onParseError(err, fallback)
169 }
170 return fallback
171}
172
155173// ----- top-level dispatch -----
156174
157175export function parse(source: string, options: ParseOptions = {}): CssNode {
@@ -250,21 +268,35 @@ function parseAtrule(s: ParserState): Atrule | null {
250268 if (preludeStart !== peek(s)) {
251269 const raw = s.source.slice(preludeStart.start, preludeEndTok.end).trim()
252270 if (raw.length > 0) {
253 if (s.parseAtrulePrelude && (name === 'media' || name === 'supports' || name === 'import' || name === 'charset' || name === 'namespace' || name === 'keyframes' || name === '-webkit-keyframes' || name === '-moz-keyframes' || name === '-o-keyframes' || name === 'page' || name === 'font-face' || name === 'document' || name === 'layer' || name === 'container')) {
254 // Reparse the prelude tokens as an AtrulePrelude with mixed tokens.
255 const sub = makeState(raw, { positions: false })
256 const children = newList<CssNode>()
257 while (peekType(sub) !== TokenType.EOF) {
258 const tok = sub.tokens[sub.pos]!
259 if (tok.type === TokenType.WhiteSpace) {
260 sub.pos++
261 continue
271 if (s.parseAtrulePrelude) {
272 // Always attempt to parse — fall back to Raw if parsing produces
273 // nothing useful or throws. No more hardcoded at-rule whitelist:
274 // custom at-rules (`@property`, `@scope`, `@layer`, future specs)
275 // get a parsed prelude too.
276 try {
277 const sub = makeState(raw, { positions: false })
278 sub.onParseError = s.onParseError
279 const children = newList<CssNode>()
280 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 const node = parseValueChild(sub)
287 if (node)
288 children.appendData(node)
262289 }
263 const node = parseValueChild(sub)
264 if (node)
265 children.appendData(node)
290 if (children.isEmpty)
291 prelude = rawNode(raw, preludeStart, preludeEndTok, s)
292 else
293 prelude = { type: 'AtrulePrelude', children, loc: null }
294 }
295 catch (err) {
296 const fallback = rawNode(raw, preludeStart, preludeEndTok, s)
297 reportError(s, `Failed to parse @${name} prelude: ${(err as Error).message}`, preludeStart, fallback)
298 prelude = fallback
266299 }
267 prelude = { type: 'AtrulePrelude', children, loc: null }
268300 }
269301 else {
270302 prelude = rawNode(raw, preludeStart, preludeEndTok, s)
@@ -277,12 +309,28 @@ function parseAtrule(s: ParserState): Atrule | null {
277309 consume(s)
278310 }
279311 else if (peekType(s) === TokenType.LeftCurlyBracket) {
280 block = parseBlock(s, name === 'media' || name === 'supports' || name === 'document' || name === 'layer' || name === 'container')
312 block = parseBlock(s, AT_RULES_WITH_NESTED_RULES.has(name))
281313 }
282314
283315 return { type: 'Atrule', name, prelude, block, loc: loc(s, startTok, peek(s)) }
284316}
285317
318/**
319 * At-rules whose block contains nested *rules* (selector { … }), as
320 * opposed to direct declarations. Used to switch parser mode inside the
321 * block. Lives here (not on _collections) because it's a parser concern.
322 */
323const AT_RULES_WITH_NESTED_RULES: ReadonlySet<string> = new Set([
324 'media',
325 'supports',
326 'document',
327 'layer',
328 'container',
329 'scope',
330 'starting-style',
331 '-moz-document',
332])
333
286334function parseAtrulePreludeContext(s: ParserState): AtrulePrelude {
287335 const startTok = peek(s)
288336 const children = newList<CssNode>()
@@ -430,6 +478,7 @@ function parseDeclaration(s: ParserState): Declaration | null {
430478 }
431479 else {
432480 // skip junk to next ;
481 reportError(s, `Expected property name`, startTok, { type: 'Raw', value: '', loc: null })
433482 while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.Semicolon && peekType(s) !== TokenType.RightCurlyBracket)
434483 consume(s)
435484 return null
@@ -443,6 +492,7 @@ function parseDeclaration(s: ParserState): Declaration | null {
443492 skipWhitespaceOnly(s)
444493 if (peekType(s) !== TokenType.Colon) {
445494 // not a declaration — recover
495 reportError(s, `Expected ':' after property "${property}"`, peek(s), { type: 'Raw', value: '', loc: null })
446496 while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.Semicolon && peekType(s) !== TokenType.RightCurlyBracket)
447497 consume(s)
448498 return null
src/parse/tokenizer.tsmodified+59-0
Changes to src/parse/tokenizer.ts
@@ -507,6 +507,65 @@ export class Tokenizer {
507507}
508508
509509/** Decode a CSS-escaped name slice (e.g. `\\26 B` → `&B`). */
510/**
511 * Decode a CSS string literal slice to its actual character value.
512 * `start` / `end` exclude the surrounding quote characters.
513 *
514 * Handles:
515 * - hex escapes (`\26 ` → `&`)
516 * - `\"` / `\'` / `\\`
517 * - escaped newlines (CSS: backslash followed by newline is removed)
518 * - any other `\<char>` → `<char>`
519 */
520export function decodeString(source: string, start: number, end: number): string {
521 let out = ''
522 let i = start
523 while (i < end) {
524 const c = source.charCodeAt(i)
525 if (c !== 92 /* \\ */) {
526 out += source[i]!
527 i++
528 continue
529 }
530 // backslash at end-of-input
531 if (i + 1 >= end) {
532 i++
533 continue
534 }
535 // backslash + newline = continuation (removed entirely)
536 const next = source.charCodeAt(i + 1)
537 if (next === 10) {
538 i += 2
539 continue
540 }
541 if (next === 13) {
542 i += source.charCodeAt(i + 2) === 10 ? 3 : 2
543 continue
544 }
545 // hex escape
546 if (isHexDigit(next)) {
547 let j = i + 1
548 let hex = ''
549 while (hex.length < 6 && j < end && isHexDigit(source.charCodeAt(j))) {
550 hex += source[j]
551 j++
552 }
553 const code = Number.parseInt(hex, 16)
554 if (j < end && isWhitespace(source.charCodeAt(j)))
555 j++
556 out += code === 0 || (code >= 0xD800 && code <= 0xDFFF) || code > 0x10FFFF
557 ? String.fromCodePoint(REPLACEMENT)
558 : String.fromCodePoint(code)
559 i = j
560 continue
561 }
562 // escape of any other char (including \" \' \\) — keep the char
563 out += source[i + 1]
564 i += 2
565 }
566 return out
567}
568
510569export function decodeName(source: string, start: number, end: number): string {
511570 let out = ''
512571 let i = start
src/parse/walker.tsmodified+33-12
Changes to src/parse/walker.ts
@@ -25,17 +25,34 @@ export interface WalkContext {
2525
2626export type WalkVisit = CssNode['type']
2727
28export type WalkVisitor =
29 // eslint-disable-next-line pickier/no-unused-vars
30 | ((this: WalkContext, node: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol)
31 | {
32 visit?: WalkVisit
33 reverse?: boolean
34 // eslint-disable-next-line pickier/no-unused-vars
35 enter?: (this: WalkContext, node: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol
36 // eslint-disable-next-line pickier/no-unused-vars
37 leave?: (this: WalkContext, node: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol
38 }
28// Narrow CssNode by its `type` discriminant — used to type the
29// `visit: 'Rule'` filter so consumer callbacks receive a `Rule`, not the
30// full union.
31export type CssNodeOfType<T extends WalkVisit> = Extract<CssNode, { type: T }>
32
33// eslint-disable-next-line pickier/no-unused-vars
34type VisitorCallback<N> = (this: WalkContext, node: N, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol
35
36/**
37 * Object-form visitor. The `T extends WalkVisit` generic is inferred from
38 * the literal value of `visit`, so writing
39 *
40 * walk(ast, { visit: 'Rule', enter(node) { … } })
41 *
42 * narrows `node` to `Rule`. Without `visit`, callbacks receive the full
43 * `CssNode` union as `enter`/`leave` arguments.
44 */
45export interface WalkVisitorObject<T extends WalkVisit | undefined = undefined> {
46 visit?: T
47 reverse?: boolean
48 enter?: VisitorCallback<T extends WalkVisit ? CssNodeOfType<T> : CssNode>
49 leave?: VisitorCallback<T extends WalkVisit ? CssNodeOfType<T> : CssNode>
50}
51
52/** Plain-function visitor — runs against every node. */
53export type WalkVisitorFunction = VisitorCallback<CssNode>
54
55export type WalkVisitor = WalkVisitorFunction | WalkVisitorObject<any>
3956
4057function newContext(root: CssNode): WalkContext {
4158 return {
@@ -60,8 +77,12 @@ function newContext(root: CssNode): WalkContext {
6077 * `walk.skip` / `walk.stop` are exposed as static-symbol sentinels.
6178 */
6279export interface WalkFn {
80 // 1. visitor function — every node (no narrowing)
81 // eslint-disable-next-line pickier/no-unused-vars
82 (root: CssNode, visitor: WalkVisitorFunction): symbol | undefined
83 // 2. visitor object with `visit: '<NodeType>'` — narrowed callbacks
6384 // eslint-disable-next-line pickier/no-unused-vars
64 (root: CssNode, visitor: WalkVisitor): symbol | undefined
85 <T extends WalkVisit>(root: CssNode, visitor: WalkVisitorObject<T>): symbol | undefined
6586 skip: symbol
6687 stop: symbol
6788}
src/select/index.tsmodified+53-2
Changes to src/select/index.ts
@@ -5,6 +5,9 @@
55 * selectOne(selector, root, options) → ElementNode | null
66 * is(node, selector, options) → boolean
77 * compile(selector, options) → CompiledQuery
8 *
9 * Compiled-selector cache: the same selector string compiled with the
10 * same adapter + options is reused. Disable with `cacheResults: false`.
811 */
912
1013import type { Selector } from '../what'
@@ -18,6 +21,26 @@ function getAdapter<Node, ElementNode extends Node>(options: Options<Node, Eleme
1821 return options.adapter
1922}
2023
24// ----- compiled-selector cache -----
25// Keyed by Adapter (object identity) so different trees can't poison each
26// other's cache. The inner key is a deterministic string capturing the
27// selector + the options that affect compile output.
28const adapterCaches = new WeakMap<object, Map<string, CompiledQuery<any>>>()
29
30function cacheKey(selector: string, options: Options<any, any>): string {
31 // Only options that change compile output need to be in the key.
32 return `${options.xmlMode ? '1' : '0'}|${options.lowerCaseAttributeNames === false ? '0' : '1'}|${options.lowerCaseTags === false ? '0' : '1'}|${selector}`
33}
34
35function getCache(adapter: object): Map<string, CompiledQuery<any>> {
36 let cache = adapterCaches.get(adapter)
37 if (!cache) {
38 cache = new Map()
39 adapterCaches.set(adapter, cache)
40 }
41 return cache
42}
43
2144function compileSelectorString<Node, ElementNode extends Node>(
2245 selector: string,
2346 options: Options<Node, ElementNode>,
@@ -34,9 +57,37 @@ export function compile<Node, ElementNode extends Node>(
3457 selector: string | Selector[][],
3558 options: Options<Node, ElementNode>,
3659): CompiledQuery<ElementNode> {
37 if (typeof selector === 'string')
60 if (typeof selector !== 'string')
61 return compileGeneric(selector, options)
62
63 // Fast path: use the per-adapter cache unless the consumer opted out.
64 if (options.cacheResults === false || !options.adapter)
3865 return compileSelectorString(selector, options)
39 return compileGeneric(selector, options)
66
67 const cache = getCache(options.adapter as unknown as object)
68 const key = cacheKey(selector, options)
69 let compiled = cache.get(key)
70 if (compiled !== undefined)
71 return compiled as CompiledQuery<ElementNode>
72 compiled = compileSelectorString(selector, options)
73 cache.set(key, compiled)
74 return compiled as CompiledQuery<ElementNode>
75}
76
77/** Drop the cached compile of `selector` for `adapter`, or all entries
78 * if `selector` is omitted. */
79export function clearSelectorCache(adapter: object, selector?: string): void {
80 if (selector === undefined) {
81 adapterCaches.delete(adapter)
82 return
83 }
84 const cache = adapterCaches.get(adapter)
85 if (!cache)
86 return
87 for (const key of cache.keys()) {
88 if (key.endsWith(`|${selector}`))
89 cache.delete(key)
90 }
4091}
4192
4293export function selectAll<Node, ElementNode extends Node>(
test/optimize.test.tsmodified+33-0
Changes to test/optimize.test.ts
@@ -27,6 +27,30 @@ describe('minify', () => {
2727 it('shortens hex colors', () => {
2828 expect(minify('.a{color:#aabbcc}').css).toBe('.a{color:#abc}')
2929 })
30
31 it('shortens named colors when hex is strictly shorter', () => {
32 // black (5) → #000 (4): replace
33 expect(minify('.a{color:black}').css).toBe('.a{color:#000}')
34 // white (5) → #fff (4): replace
35 expect(minify('.a{color:white}').css).toBe('.a{color:#fff}')
36 })
37
38 it('keeps named colors that tie or beat hex', () => {
39 // blue (4) ties #00f (4) — keep keyword
40 expect(minify('.a{color:blue}').css).toBe('.a{color:blue}')
41 // red (3) beats #f00 (4) — keep keyword
42 expect(minify('.a{color:red}').css).toBe('.a{color:red}')
43 })
44
45 it('only swaps colors inside color properties', () => {
46 // font-family is not a color property — `red` stays
47 expect(minify('.a{font-family:red}').css).toBe('.a{font-family:red}')
48 })
49
50 it('reverses hex → name when name is strictly shorter', () => {
51 // #ff0000 (7) → red (3): replace
52 expect(minify('.a{color:#ff0000}').css).toBe('.a{color:red}')
53 })
3054})
3155
3256describe('minifyBlock', () => {
@@ -56,4 +80,13 @@ describe('syntax.specificity', () => {
5680 const sel = parse(':is(#a, .b)', { context: 'selector' })
5781 expect(syntax.specificity(sel)).toEqual([1, 0, 0])
5882 })
83 it('accepts a raw selector string', () => {
84 expect(syntax.specificity('#a.b div')).toEqual([1, 1, 1])
85 expect(syntax.specificity('p > span:not(#x)')).toEqual([1, 0, 2])
86 })
87 it('accepts cssWhat-style Selector[][] arrays', async () => {
88 const { parse: parseWhat } = await import('../src/what')
89 const ast = parseWhat('#a.b div')
90 expect(syntax.specificity(ast)).toEqual([1, 1, 1])
91 })
5992})
test/parse.test.tsmodified+66-0
Changes to test/parse.test.ts
@@ -38,6 +38,34 @@ describe('parse + generate round-trip', () => {
3838 expect(ast.children.first.name).toBe('media')
3939 })
4040
41 it('parses unknown at-rule preludes (no hardcoded whitelist)', () => {
42 const ast = parse('@my-custom rule (foo) { .a { color: red } }') as any
43 const at = ast.children.first
44 expect(at.type).toBe('Atrule')
45 expect(at.name).toBe('my-custom')
46 // Prelude should be parsed structurally (AtrulePrelude with children),
47 // not stored as a Raw blob.
48 expect(at.prelude.type).toBe('AtrulePrelude')
49 expect(at.prelude.children.toArray().length).toBeGreaterThan(0)
50 })
51
52 it('parses @scope, @starting-style, and @container with nested rules', () => {
53 for (const name of ['scope', 'starting-style', 'container']) {
54 const ast = parse(`@${name} (foo) { .x { color: blue } }`) as any
55 const at = ast.children.first
56 expect(at.type).toBe('Atrule')
57 expect(at.block.children.first.type).toBe('Rule')
58 }
59 })
60
61 it('calls onParseError for malformed declarations', () => {
62 const errors: string[] = []
63 parse('.a { invalid-no-colon; color: red; }', {
64 onParseError: (err) => { errors.push(err.message) },
65 })
66 expect(errors.length).toBeGreaterThan(0)
67 })
68
4169 it('parses url() function', () => {
4270 const ast = parse('.a{background:url("x.png")}') as any
4371 let foundUrl = false
@@ -65,6 +93,27 @@ describe('parse + generate round-trip', () => {
6593 expect(ast.children.toArray().length).toBe(2)
6694 })
6795
96 it('narrows visitor.enter via visit filter', () => {
97 const ast = parse('.a{color:red} .b{color:blue}')
98 const propsByRule: string[][] = []
99 walk(ast, {
100 visit: 'Rule',
101 enter(rule) {
102 // `rule` is narrowed to Rule here — TS should NOT see the wider union.
103 const props: string[] = []
104 walk(rule.block, {
105 visit: 'Declaration',
106 enter(decl) {
107 // `decl` narrowed to Declaration
108 props.push(decl.property)
109 },
110 })
111 propsByRule.push(props)
112 },
113 })
114 expect(propsByRule).toEqual([['color'], ['color']])
115 })
116
68117 it('exposes a doubly linked List', () => {
69118 const list = new List<number>()
70119 list.appendData(1)
@@ -74,4 +123,21 @@ describe('parse + generate round-trip', () => {
74123 list.shift()
75124 expect(list.toArray()).toEqual([2, 3])
76125 })
126
127 it('survives nested forEach with mid-walk removal', () => {
128 const list = new List<number>()
129 list.fromArray([1, 2, 3, 4, 5])
130 const seen: number[] = []
131 list.forEach((data, item) => {
132 seen.push(data)
133 // a nested walk over the SAME list — must not corrupt the outer cursor.
134 list.forEach((d) => { void d })
135 if (data === 2) {
136 // remove the current node mid-iteration
137 list.remove(item)
138 }
139 })
140 expect(seen).toEqual([1, 2, 3, 4, 5])
141 expect(list.toArray()).toEqual([1, 3, 4, 5])
142 })
77143})
test/select.test.tsmodified+25-0
Changes to test/select.test.ts
@@ -102,3 +102,28 @@ describe('selectOne / is', () => {
102102 expect(is(span, '.missing', opts)).toBe(false)
103103 })
104104})
105
106describe('compile cache', () => {
107 it('reuses compiled selectors with default options', async () => {
108 const { compile, clearSelectorCache } = await import('../src/select')
109 clearSelectorCache(adapter as any)
110 const a = compile('p > span.foo', opts)
111 const b = compile('p > span.foo', opts)
112 expect(a).toBe(b)
113 })
114
115 it('skips the cache when cacheResults is false', async () => {
116 const { compile } = await import('../src/select')
117 const noCache: typeof opts = { ...opts, cacheResults: false }
118 const a = compile('p > span', noCache)
119 const b = compile('p > span', noCache)
120 expect(a).not.toBe(b)
121 })
122
123 it('keys cache by xmlMode flag', async () => {
124 const { compile } = await import('../src/select')
125 const a = compile('p', opts)
126 const b = compile('p', { ...opts, xmlMode: false })
127 expect(a).not.toBe(b)
128 })
129})