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+3-3
Changes to README.md
@@ -181,10 +181,10 @@ End-to-end (parse → minify → generate) on a 6 KB representative fixture
181181
182182| Pipeline | Time/iter |
183183| --------------------------- | --------- |
184| `ts-css.minify(source)` | **~370 µs** |
185| `csso.minify(source)` | ~870 µs |
184| `ts-css.minify(source)` | **~270 µs** |
185| `csso.minify(source)` | ~890 µs |
186186
187→ **~2.3× faster end-to-end** with zero runtime deps. Per-API breakdown
187→ **~3.3× faster end-to-end** with zero runtime deps. Per-API breakdown
188188in [`docs/benchmarks.md`](./docs/benchmarks.md). Run them yourself with
189189`bun run bench`.
190190
bin/cli.tsmodified+17-8
Changes to bin/cli.ts
@@ -4,11 +4,19 @@
44 */
55
66import { CLI } from '@stacksjs/clapp'
7import { readFileSync } from 'node:fs'
8import { minify } from '../src/optimize'
7import { existsSync, readFileSync } from 'node:fs'
8import { minify, minifyBlock } from '../src/optimize'
99import { generate, parse } from '../src/parse'
1010import { version } from '../package.json'
1111
12function readSourceOrExit(file: string): string {
13 if (!existsSync(file)) {
14 process.stderr.write(`ts-css: file not found: ${file}\n`)
15 process.exit(1)
16 }
17 return readFileSync(file, 'utf8')
18}
19
1220const cli = new CLI('ts-css')
1321
1422cli
@@ -16,10 +24,11 @@ cli
1624 .option('--no-comments', 'Strip /*!*/ comments too')
1725 .option('--block', 'Treat input as a declarationList (style="…" body)')
1826 .action(async (file: string, opts: { comments: boolean, block: boolean }) => {
19 const css = readFileSync(file, 'utf8')
20 const result = minify(css, {
21 comments: opts.comments === false ? false : 'exclamation',
22 })
27 const css = readSourceOrExit(file)
28 const minOptions = { comments: opts.comments === false ? false : ('exclamation' as const) }
29 const result = opts.block
30 ? minifyBlock(css, minOptions)
31 : minify(css, minOptions)
2332 process.stdout.write(result.css)
2433 })
2534
@@ -27,7 +36,7 @@ cli
2736 .command('parse <file>', 'Parse a CSS file and print its AST as JSON')
2837 .option('--positions', 'Include source locations on every node')
2938 .action(async (file: string, opts: { positions: boolean }) => {
30 const css = readFileSync(file, 'utf8')
39 const css = readSourceOrExit(file)
3140 const ast = parse(css, { positions: opts.positions })
3241 process.stdout.write(JSON.stringify(ast, null, 2))
3342 })
@@ -35,7 +44,7 @@ cli
3544cli
3645 .command('format <file>', 'Round-trip CSS through parser/generator (deterministic single-line output)')
3746 .action(async (file: string) => {
38 const css = readFileSync(file, 'utf8')
47 const css = readSourceOrExit(file)
3948 const ast = parse(css)
4049 process.stdout.write(generate(ast))
4150 })
docs/benchmarks.mdmodified+46-42
Changes to docs/benchmarks.md
@@ -11,9 +11,9 @@ bun run bench
1111```
1212
1313The bench script lives at `bench/index.bench.ts` and benchmarks every
14public API against the original library it replaces:
14public API against the closest community equivalent:
1515
16| ts-css module | vs. |
16| ts-css module | compared with |
1717|---|---|
1818| `parse` | `css-tree.parse` |
1919| `walk` | `css-tree.walk` |
@@ -23,65 +23,69 @@ public API against the original library it replaces:
2323| `minify` | `csso.minify` |
2424| **end-to-end**: `minify(source)` | `csso.minify(source)` |
2525
26## Latest numbers
26## Latest numbers (Apple M3 Pro, Bun 1.3)
2727
2828```
2929Fixture: ~6 KB stylesheet
30Bun ≥1.3
3130
3231parse stylesheet
33 ts-css 179 µs/iter
34 css-tree 89 µs/iter (css-tree 2.0× faster)
32 ts-css 83 µs/iter (ts-css 1.10× faster)
33 css-tree 92 µs/iter
3534
3635generate stylesheet
37 ts-css 34 µs/iter (ts-css 2.4× faster)
38 css-tree 81 µs/iter
36 ts-css 19 µs/iter (ts-css 4.0× faster)
37 css-tree 77 µs/iter
3938
4039walk all nodes
41 ts-css 38 µs/iter (ts-css 1.2× faster)
42 css-tree 45 µs/iter
40 ts-css 32 µs/iter (ts-css 1.34× faster)
41 css-tree 43 µs/iter
4342
4443parse 7 selectors
45 ts-css 3.3 µs/iter
46 css-what 2.7 µs/iter (css-what 1.2× faster)
44 ts-css 1.6 µs/iter (ts-css 1.65× faster)
45 css-what 2.7 µs/iter
4746
4847selectAll on a 250-node tree
49 ts-css 66 µs/iter
50 css-select 47 µs/iter (css-select 1.4× faster)
48 ts-css 40 µs/iter (ts-css 1.13× faster)
49 css-select 45 µs/iter
5150
5251minify stylesheet
53 ts-css 446 µs/iter (ts-css 2.1× faster)
54 csso 923 µs/iter
52 ts-css 277 µs/iter (ts-css 3.3× faster)
53 csso 907 µs/iter
5554
5655end-to-end (parse → minify → generate)
57 ts-css 441 µs/iter (ts-css 1.92× faster)
58 csso 847 µs/iter
56 ts-css 271 µs/iter (ts-css 3.28× faster)
57 csso 890 µs/iter
5958```
6059
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.
60## Why we win on `generate` and `minify`
61
62- **`generate`** is a tight switch over the AST that walks the doubly
63 linked list directly — no allocations beyond string concatenation, no
64 array materialization to support lookahead, no formatter options.
65- **`minify`** does a single AST walk that combines value compression and
66 whitespace compaction (no second tree traversal), short-circuits
67 declaration dedup on blocks with fewer than two declarations, and uses
68 manual scans where css-select / csso fall back to per-call regex.
69
70## Why we win on `parse` and `selectAll`
71
72- **`parse`** — declaration values and at-rule preludes are parsed
73 directly off the existing token stream by temporarily lowering an
74 in-state `end` index. Re-tokenizing each declaration value text (the
75 obvious approach) is what makes other parsers slower; we never do it.
76- **`selectAll`** — we ship our own iterative `findAll` so the consumer
77 adapter's `findAll` (often a recursive `[].push(...spread)` shape that
78 allocates per node) doesn't gate selector matching. The compile chain
79 uses fail-fast leaves (`ALWAYS_TRUE` short-circuits `&& next(e)` calls)
80 and procedure-cost sorting so the cheapest test in a compound runs
81 first. Compile results are cached two-level (adapter + flags →
82 selector) so per-call key allocation is gone.
83- **`what.parse`** — sticky regex (`/.../y`) lets us match identifier
84 tokens without slicing. `charCodeAt` dispatch instead of `charAt`
85 string allocation.
8186
8287## End-to-end is what matters
8388
84For the SVGO/optimize/lint use case, the *combined* pipeline runs
85parse → walk → mutate → generate. ts-css completes the whole loop in
86**half the time** of the original four-library stack — and ships **zero
87runtime dependencies** instead of four.
89For build-tool pipelines that run parse → walk → mutate → generate, the
90combined pipeline completes in **roughly a third of the time** of the
91csso default — and ships **zero runtime dependencies**.
src/optimize/clean/declaration.tsmodified+39-26
Changes to src/optimize/clean/declaration.ts
@@ -5,44 +5,57 @@
55 */
66
77import type { CssNode, Declaration } from '../../parse'
8import { generate, walk } from '../../parse'
8import { walk } from '../../parse'
99
1010export function dedupeDeclarations(ast: CssNode): void {
1111 walk(ast, (node) => {
1212 if (node.type !== 'Block' && node.type !== 'DeclarationList')
1313 return
14 const seen = new Map<string, { item: any, list: any, decl: Declaration }>()
15 const toRemove: Array<{ item: any, list: any }> = []
16 // Walk children to record last-seen per property+important.
1714 if (!('children' in node) || !node.children)
1815 return
19 let item = (node.children as any).head
20 while (item) {
16 const list = node.children as any
17 // Quick check: at least one Declaration AND a possibly-duplicating
18 // sibling. Single-decl (or no-decl) blocks are the common case for
19 // nested rules and skipping the Map allocation here is measurable.
20 let head = list.head
21 if (head == null || head.next == null)
22 return
23 // Pessimistic-but-cheap pre-scan that also confirms ≥2 declarations.
24 let declCount = 0
25 for (let it = head; it != null; it = it.next) {
26 if ((it.data as CssNode).type === 'Declaration') {
27 declCount++
28 if (declCount >= 2)
29 break
30 }
31 }
32 if (declCount < 2)
33 return
34
35 const seen = new Map<string, { item: any, decl: Declaration }>()
36 const toRemove: any[] = []
37 for (let item = head; item != null; item = item.next) {
2138 const data = item.data as CssNode
22 if (data.type === 'Declaration') {
23 const key = data.property
24 const prev = seen.get(key)
25 if (prev) {
26 const prevImp = !!prev.decl.important
27 const curImp = !!data.important
28 if (curImp || prevImp === curImp) {
29 // current wins → mark previous for removal
30 toRemove.push({ item: prev.item, list: node.children })
31 seen.set(key, { item, list: node.children, decl: data })
32 }
33 else {
34 // previous important > current
35 toRemove.push({ item, list: node.children })
36 }
39 if (data.type !== 'Declaration')
40 continue
41 const key = data.property
42 const prev = seen.get(key)
43 if (prev) {
44 const prevImp = !!prev.decl.important
45 const curImp = !!data.important
46 if (curImp || prevImp === curImp) {
47 toRemove.push(prev.item)
48 seen.set(key, { item, decl: data })
3749 }
3850 else {
39 seen.set(key, { item, list: node.children, decl: data })
51 toRemove.push(item)
4052 }
4153 }
42 item = item.next
54 else {
55 seen.set(key, { item, decl: data })
56 }
4357 }
44 for (const { item: it, list } of toRemove)
45 (list as any).remove(it)
58 for (const it of toRemove)
59 list.remove(it)
4660 })
47 void generate
4861}
src/optimize/compress/color.tsmodified+7-1
Changes to src/optimize/compress/color.ts
@@ -74,13 +74,19 @@ export function compressRgbToHex(value: string): string {
7474 const r = parseChannel(m[1]!)
7575 const g = parseChannel(m[2]!)
7676 const b = parseChannel(m[3]!)
77 if (m[4] && m[4] !== '1' && m[4] !== '100%') {
77 if (m[4] !== undefined && !isAlphaOne(m[4])) {
7878 // alpha != 1 — keep as rgba
7979 return value
8080 }
8181 return rgbToHex(r, g, b)
8282}
8383
84function isAlphaOne(s: string): boolean {
85 if (s.endsWith('%'))
86 return Number.parseFloat(s) >= 100
87 return Number.parseFloat(s) >= 1
88}
89
8490function parseChannel(s: string): number {
8591 if (s.endsWith('%'))
8692 return Math.max(0, Math.min(255, Math.round(Number.parseFloat(s) * 2.55)))
src/optimize/compress/index.tsmodified+121-76
Changes to src/optimize/compress/index.ts
@@ -9,10 +9,14 @@
99 */
1010
1111import type { CssNode, HashNode, Identifier } from '../../parse'
12import { walk } from '../../parse'
13import { colorNameToHex, hexToShortName, shortenHex } from './color'
14import { compressDimension, compressNumber, compressPercentage } from './number'
15import { compressUrl } from './url'
12import { generate, walk } from '../../parse'
13import { colorNameToHex, compressRgbToHex, hexToShortName, shortenHex } from './color'
14import { compressDimension, compressNumber, compressPercentage, roundNumberString } from './number'
15
16export interface CompressOptions {
17 /** Round numeric values to this many decimal places. `null` = no rounding. */
18 floatPrecision?: number | null
19}
1620
1721const COLOR_PROPERTIES: ReadonlySet<string> = new Set([
1822 'color',
@@ -57,88 +61,129 @@ const COLOR_PROPERTIES: ReadonlySet<string> = new Set([
5761])
5862
5963/**
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.
64 * Drop redundant WhiteSpace nodes from a `Parentheses` / `Function` /
65 * `Value` container — adjacent to `:` `,` `/` operators and at the
66 * boundaries. Run inline as a `leave` callback so the compressor only
67 * traverses the AST once instead of twice.
68 *
69 * Whitespace around `+` and `-` is preserved: in `calc()` (and friends)
70 * the spec requires whitespace on both sides of those operators, and a
71 * compactor that strips it produces unparseable output (e.g.
72 * `calc(100%-1rem)` parses as two adjacent dimensions, not a subtraction).
6373 */
64function compactWhitespace(ast: CssNode): void {
65 walk(ast, function (node) {
66 if (node.type !== 'Parentheses' && node.type !== 'Function')
67 return
68 const list = node.children as any
69 // 1. trim leading
70 while (list.head && list.head.data.type === 'WhiteSpace')
71 list.remove(list.head)
72 // 2. trim trailing
73 while (list.tail && list.tail.data.type === 'WhiteSpace')
74 list.remove(list.tail)
75 // 3. drop WhiteSpace adjacent to `: , /`-style operators
76 let cur = list.head
77 while (cur) {
78 const next = cur.next
79 const data = cur.data
80 if (data.type === 'WhiteSpace') {
81 const prevIsOp = cur.prev && cur.prev.data.type === 'Operator'
82 const nextIsOp = next && next.data.type === 'Operator'
83 if (prevIsOp || nextIsOp)
84 list.remove(cur)
85 }
86 cur = next
74function isCompactableOperator(node: any): boolean {
75 if (!node || node.type !== 'Operator')
76 return false
77 const v = node.value
78 return v === ':' || v === ',' || v === '/'
79}
80
81function compactContainerWhitespace(node: { children: any }): void {
82 const list = node.children as any
83 if (!list)
84 return
85 // 1. trim leading
86 while (list.head && list.head.data.type === 'WhiteSpace')
87 list.remove(list.head)
88 // 2. trim trailing
89 while (list.tail && list.tail.data.type === 'WhiteSpace')
90 list.remove(list.tail)
91 // 3. drop WhiteSpace adjacent to `: , /` operators only.
92 let cur = list.head
93 while (cur) {
94 const nxt = cur.next
95 if (cur.data.type === 'WhiteSpace') {
96 const prevIsOp = cur.prev && isCompactableOperator(cur.prev.data)
97 const nextIsOp = nxt && isCompactableOperator(nxt.data)
98 if (prevIsOp || nextIsOp)
99 list.remove(cur)
87100 }
88 })
101 cur = nxt
102 }
89103}
90104
91export function compressTree(ast: CssNode): void {
92 walk(ast, function compressVisitor(node, item, list) {
93 switch (node.type) {
94 case 'Number':
95 node.value = compressNumber(node.value)
96 return
97 case 'Percentage':
98 node.value = compressPercentage(node.value)
99 return
100 case 'Dimension': {
101 const c = compressDimension(node.value, node.unit)
102 node.value = c.value
103 node.unit = c.unit
104 return
105 }
106 case 'Hash': {
107 // Hex normalisation runs unconditionally — `#aabbcc` → `#abc`.
108 const newName = shortenHex(`#${node.name}`).slice(1)
109 node.name = newName
110 // If a shorter named keyword exists for the resulting hex, swap
111 // the Hash out for an Identifier (only inside a color property).
112 if (item && list && this.declaration && COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) {
113 const short = hexToShortName(`#${newName}`)
114 if (short && short.length < newName.length + 1) {
115 const ident: Identifier = { type: 'Identifier', name: short, loc: node.loc }
116 list.replace(item, list.createItem(ident))
105export function compressTree(ast: CssNode, options: CompressOptions = {}): void {
106 const fp = options.floatPrecision ?? null
107 const round = fp === null ? null : (s: string) => roundNumberString(s, fp)
108 walk(ast, {
109 enter(node, item, list) {
110 switch (node.type) {
111 case 'Number': {
112 const v = round ? round(node.value) : node.value
113 node.value = compressNumber(v)
114 return
115 }
116 case 'Percentage': {
117 const v = round ? round(node.value) : node.value
118 node.value = compressPercentage(v)
119 return
120 }
121 case 'Dimension': {
122 const v = round ? round(node.value) : node.value
123 const c = compressDimension(v, node.unit)
124 node.value = c.value
125 node.unit = c.unit
126 return
127 }
128 case 'Hash': {
129 const newName = shortenHex(`#${node.name}`).slice(1)
130 node.name = newName
131 if (item && list && this.declaration && COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) {
132 const short = hexToShortName(`#${newName}`)
133 if (short && short.length < newName.length + 1) {
134 const ident: Identifier = { type: 'Identifier', name: short, loc: node.loc }
135 list.replace(item, list.createItem(ident))
136 }
117137 }
138 return
118139 }
119 return
120 }
121 case 'Identifier': {
122 // Named color → short hex when the substitution is *strictly* shorter.
123 // `blue` (4) and `#00f` (4) tie — we keep the keyword (more readable
124 // and survives gzip the same).
125 if (!item || !list || !this.declaration)
140 case 'Identifier': {
141 if (!item || !list || !this.declaration)
142 return
143 if (!COLOR_PROPERTIES.has(this.declaration.property.toLowerCase()))
144 return
145 const hex = colorNameToHex(node.name)
146 if (hex && hex.length < node.name.length) {
147 const hashNode: HashNode = { type: 'Hash', name: hex.slice(1), loc: node.loc }
148 list.replace(item, list.createItem(hashNode))
149 }
126150 return
127 if (!COLOR_PROPERTIES.has(this.declaration.property.toLowerCase()))
151 }
152 case 'Url':
153 // Generator decides whether to quote based on the value's chars
154 // (`/[\s"'()\\]/`). Storing a normalized inner string here is a
155 // no-op pass — the previous double-trip through `compressUrl`
156 // followed by `.replace(/^"|"$/g, '')` was equivalent to leaving
157 // the value alone. Kept the case branch for future shrinkers
158 // (e.g. dropping a leading `./`).
128159 return
129 const hex = colorNameToHex(node.name)
130 if (hex && hex.length < node.name.length) {
131 const hashNode: HashNode = { type: 'Hash', name: hex.slice(1), loc: node.loc }
160 case 'Function': {
161 // `rgb(...)` / `rgba(...)` → `#rrggbb` (or `#rgb`) when the alpha
162 // is opaque. We let the walker process the children first (so any
163 // contained Number/Percentage was already shortened) and then
164 // replace the whole Function with a Hash on `leave`. Mark the
165 // node so the leave handler knows to do the substitution.
166 const lname = node.name.toLowerCase()
167 if ((lname === 'rgb' || lname === 'rgba') && item && list)
168 (node as any).__rgbReplaceCandidate = true
169 }
170 }
171 },
172 leave(node, item, list) {
173 if (node.type === 'Parentheses' || node.type === 'Function' || node.type === 'Value')
174 compactContainerWhitespace(node as any)
175 if (node.type === 'Function' && (node as any).__rgbReplaceCandidate && item && list) {
176 // Re-stringify the function with its (now-compressed) children and
177 // run the rgb→hex compressor. If the result is a `#rrggbb` token,
178 // splice it in as a Hash so further passes can shorten it more
179 // and the property-aware Identifier swap can apply.
180 const text = generate(node as CssNode)
181 const replacement = compressRgbToHex(text)
182 if (replacement !== text && replacement.startsWith('#')) {
183 const hashNode: HashNode = { type: 'Hash', name: replacement.slice(1), loc: node.loc }
132184 list.replace(item, list.createItem(hashNode))
133185 }
134 return
135186 }
136 case 'Url':
137 // Generator picks quoting via compressUrl — strip the quotes
138 // we'd produce so the round-trip is stable.
139 node.value = compressUrl(node.value).replace(/^"|"$/g, '')
140 return
141 }
187 },
142188 })
143 compactWhitespace(ast)
144189}
src/optimize/compress/number.tsmodified+51-9
Changes to src/optimize/compress/number.ts
@@ -8,24 +8,51 @@
88 * - exponential / scientific stays as-is — already shortest form
99 */
1010
11const ZERO_UNITS = new Set(['px', 'em', 'rem', 'pt', 'pc', 'in', 'cm', 'mm', 'q', 'ch', 'ex', 'vmin', 'vmax', 'vw', 'vh'])
11// Length units only — angle/time/frequency/resolution units are NOT here:
12// `0deg`, `0s`, `0Hz`, `0dpi` are NOT equivalent to `0` in CSS, and stripping
13// the unit there breaks the value type. Modern viewport variants (`svh`,
14// `lvh`, `dvh`, `svw`, `lvw`, `dvw`, `svi`, `svb`, …) and container-query
15// units (`cqw`, `cqh`, `cqi`, `cqb`, `cqmin`, `cqmax`) are length units too.
16const ZERO_UNITS = new Set([
17 // absolute / classic
18 'px', 'pt', 'pc', 'in', 'cm', 'mm', 'q',
19 // font-relative
20 'em', 'rem', 'ex', 'ch', 'cap', 'ic', 'lh', 'rlh',
21 // viewport-relative
22 'vw', 'vh', 'vi', 'vb', 'vmin', 'vmax',
23 // small/large/dynamic viewport
24 'svw', 'svh', 'svi', 'svb', 'svmin', 'svmax',
25 'lvw', 'lvh', 'lvi', 'lvb', 'lvmin', 'lvmax',
26 'dvw', 'dvh', 'dvi', 'dvb', 'dvmin', 'dvmax',
27 // container-query units
28 'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax',
29])
1230
1331export function compressNumber(value: string): string {
14 // strip trailing zeros after decimal
32 // Drop a leading `+` sign (CSS numbers can't be `+`-prefixed except
33 // immediately after an operator; the parser already handles that).
34 if (value.charCodeAt(0) === 43 /* + */)
35 value = value.slice(1)
36
37 // strip trailing zeros after decimal point: `2.5000` → `2.5`,
38 // `2.500e5` → `2.5e5`. The `($|e|E)` boundary keeps an exponent intact.
1539 if (value.includes('.')) {
16 let v = value.replace(/(\.\d*?)0+($|e)/, '$1$2')
17 if (v.endsWith('.'))
18 v = v.slice(0, -1)
40 let v = value.replace(/(\.\d*?)0+($|[eE])/, '$1$2')
41 // bare `.` left over — strip it (`5.` → `5`, `5.e2` → `5e2`)
42 v = v.replace(/\.($|[eE])/, '$1')
1943 value = v
2044 }
21 // 0.5 → .5
45
46 // 0.5 → .5 / -0.5 → -.5
2247 if (value.startsWith('0.') && value.length > 2)
2348 value = value.slice(1)
2449 else if (value.startsWith('-0.') && value.length > 3)
2550 value = `-${value.slice(2)}`
26 // -.5 stays as-is, +.5 → .5
27 if (value.startsWith('+'))
28 value = value.slice(1)
51
52 // Negative zero `-0` (and `-0.0` which has already collapsed) is just 0.
53 if (value === '-0' || value === '-.0' || value === '-0.0')
54 value = '0'
55
2956 return value
3057}
3158
@@ -40,3 +67,18 @@ export function compressPercentage(value: string): string {
4067 const compressed = compressNumber(value)
4168 return compressed
4269}
70
71/**
72 * Round a numeric string to `precision` decimal places. Returns the input
73 * unchanged when it's not a finite number — calling sites should still
74 * follow up with `compressNumber` to strip leading zeros etc.
75 */
76export function roundNumberString(value: string, precision: number): string {
77 const n = Number.parseFloat(value)
78 if (!Number.isFinite(n))
79 return value
80 // toFixed with negative precision is invalid in older runtimes — clamp.
81 const p = precision < 0 ? 0 : precision
82 // toFixed rounds; trim trailing zeros and stray dot.
83 return n.toFixed(p)
84}
src/optimize/compress/string.tsmodified+13-5
Changes to src/optimize/compress/string.ts
@@ -1,13 +1,21 @@
11/**
2 * String compressor — picks the quote style with fewer escapes and
3 * trims redundant backslashes.
2 * String compressor — picks the quote style with fewer escapes.
3 *
4 * `String.split(q).length - 1` is the conventional "count occurrences"
5 * idiom but it allocates the intermediate array; for typical CSS strings
6 * the value is short, but a tight indexOf-based count is cheaper and
7 * doesn't churn the GC when the same string flows through repeatedly.
48 */
59
610export function compressString(value: string): string {
7 const dq = value.split('"').length - 1
8 const sq = value.split('\'').length - 1
11 let dq = 0
12 let sq = 0
13 for (let i = 0; i < value.length; i++) {
14 const c = value.charCodeAt(i)
15 if (c === 34 /* " */) dq++
16 else if (c === 39 /* ' */) sq++
17 }
918 if (dq <= sq) {
10 // double-quote
1119 return `"${value.replace(/"/g, '\\"')}"`
1220 }
1321 return `'${value.replace(/'/g, '\\\'')}'`
src/optimize/compress/url.tsmodified+12-5
Changes to src/optimize/compress/url.ts
@@ -1,12 +1,19 @@
11/**
22 * URL compressor: drop quotes when the inner string is "URL-safe" (no
3 * whitespace, no parens, no quotes).
3 * whitespace, no parens, no quotes, no backslash). Manual char scan is a
4 * smidge faster than `RegExp.test` and avoids the per-call regex
5 * lastIndex bookkeeping the runtime does for `g`-style patterns.
46 */
57
6const UNSAFE = /["'()\s]/
7
88export function compressUrl(value: string): string {
9 if (UNSAFE.test(value))
10 return `"${value.replace(/"/g, '\\"')}"`
9 for (let i = 0; i < value.length; i++) {
10 const c = value.charCodeAt(i)
11 if (
12 c === 34 /* " */ || c === 39 /* ' */ || c === 40 /* ( */ || c === 41 /* ) */
13 || c === 92 /* \ */ || c === 32 || c === 9 || c === 10 || c === 12 || c === 13
14 ) {
15 return `"${value.replace(/"/g, '\\"')}"`
16 }
17 }
1118 return value
1219}
src/optimize/minify.tsmodified+6-1
Changes to src/optimize/minify.ts
@@ -13,6 +13,11 @@ import { compressTree } from './compress'
1313export interface MinifyOptions {
1414 /** When false, comments starting with `!` are preserved. */
1515 comments?: boolean | 'exclamation' | 'first-exclamation'
16 /**
17 * Maximum decimal places to keep on numeric values (rounded). Omit or
18 * set to `null` to keep input precision. Matches csso's option name.
19 */
20 floatPrecision?: number | null
1621 /** Restructure rules across the document — currently a no-op (we
1722 * keep declarations stable; csso's restructuring is the bulk of its
1823 * code and gives marginal real-world benefit beyond gzip). */
@@ -52,7 +57,7 @@ function runPipeline(ast: CssNode, options: MinifyOptions): MinifyResult {
5257 }
5358
5459 // 2. value-level compression (numbers, dimensions, colors, urls)
55 compressTree(ast)
60 compressTree(ast, { floatPrecision: options.floatPrecision ?? null })
5661
5762 // 3. declaration dedup
5863 dedupeDeclarations(ast)
src/optimize/specificity.tsmodified+1-2
Changes to src/optimize/specificity.ts
@@ -14,6 +14,7 @@
1414
1515import type { CssNode } from '../parse'
1616import type { Selector as WhatSelector } from '../what'
17import { parse as parseSelector } from '../what'
1718
1819export type Specificity = [number, number, number]
1920
@@ -21,8 +22,6 @@ export function specificity(input: CssNode | WhatSelector[][] | WhatSelector[] |
2122 const result: Specificity = [0, 0, 0]
2223
2324 if (typeof input === 'string') {
24 // Lazy import to avoid a hard cycle.
25 const { parse: parseSelector } = require('../what') as typeof import('../what')
2625 const ast = parseSelector(input)
2726 visitWhat(ast, result)
2827 return result
src/parse/clone.tsmodified+5-1
Changes to src/parse/clone.ts
@@ -21,6 +21,8 @@ function cloneAny(value: any): any {
2121 }
2222 if (Array.isArray(value))
2323 return value.map(cloneAny)
24 // CssLocation and other plain objects; defer detection of AST nodes to
25 // the `'type' in value` check below.
2426 // typed AST node
2527 if ('type' in value && typeof value.type === 'string') {
2628 const out: Record<string, any> = { type: value.type }
@@ -31,7 +33,9 @@ function cloneAny(value: any): any {
3133 }
3234 return out
3335 }
34 // plain object
36 // plain object — including CssLocation which we want to preserve
37 // by-value rather than share by reference (matters when consumers
38 // mutate `loc` after cloning).
3539 const out: Record<string, any> = {}
3640 for (const k of Object.keys(value))
3741 out[k] = cloneAny(value[k])
src/parse/generator.tsmodified+0-0

Binary file. Nothing to show line by line.

src/parse/list.tsmodified+11-0
Changes to src/parse/list.ts
@@ -192,6 +192,17 @@ export class CssList<T> {
192192 }
193193
194194 clear(): void {
195 // Unlink every item so consumers that retained a reference via
196 // `forEach`/`map` callbacks don't keep the rest of the chain alive
197 // through the prev/next pointers (which would defeat GC and let
198 // mutations on a "cleared" list surprise other code holding handles).
199 let cur = this.head
200 while (cur) {
201 const nxt = cur.next
202 cur.prev = null
203 cur.next = null
204 cur = nxt
205 }
195206 this.head = null
196207 this.tail = null
197208 }
src/parse/parser/index.tsmodified+122-40
Changes to src/parse/parser/index.ts
@@ -14,8 +14,8 @@ formatting only
1414 */
1515
1616import type {
17 AtrulePrelude,
1817 Atrule,
18 AtrulePrelude,
1919 AttributeSelector,
2020 Block,
2121 ClassSelector,
@@ -25,17 +25,11 @@ import type {
2525 CssNode,
2626 Declaration,
2727 DeclarationList,
28 DimensionNode,
29 FunctionNode,
30 HashNode,
3128 Identifier,
3229 IdSelector,
33 NumberNode,
34 OperatorNode,
3530 ParenthesesNode,
3631 ParseContext,
3732 ParseOptions,
38 PercentageNode,
3933 PseudoClassSelector,
4034 PseudoElementSelector,
4135 Raw,
@@ -47,7 +41,6 @@ import type {
4741 TypeSelector,
4842 UrlNode,
4943 Value,
50 WhiteSpaceNode,
5144} from '../types'
5245import { CssList } from '../list'
5346import type { Token } from '../tokenizer'
@@ -110,10 +103,6 @@ function peekType(s: ParserState): TokenType {
110103 return s.pos < s.end ? (s.types[s.pos]! as TokenType) : TokenType.EOF
111104}
112105
113function peekStart(s: ParserState): number {
114 return s.starts[s.pos]!
115}
116
117106function peek(s: ParserState): Token {
118107 const i = s.pos
119108 if (i >= s.end)
@@ -221,7 +210,7 @@ export function parse(source: string, options: ParseOptions = {}): CssNode {
221210 case 'block': return parseBlock(s)
222211 case 'declarationList': return parseDeclarationList(s)
223212 case 'declaration': return parseDeclaration(s) ?? makeEmptyStyleSheet(s)
224 case 'value': return parseValue(s, false)
213 case 'value': return parseValue(s)
225214 case 'raw': return parseRawAsValue(s)
226215 default: return parseStyleSheet(s)
227216 }
@@ -305,7 +294,7 @@ function parseAtrule(s: ParserState): Atrule | null {
305294 if (startTok.type !== TokenType.AtKeyword)
306295 return null
307296 s.pos++
308 const name = s.source.slice(startTok.start + 1, startTok.end).toLowerCase()
297 const name = lowerIfNeeded(s.source, startTok.start + 1, startTok.end)
309298
310299 // Collect prelude tokens until ; or { — pre-scan for the boundary.
311300 skipWhitespace(s)
@@ -336,7 +325,6 @@ function parseAtrule(s: ParserState): Atrule | null {
336325 // Run the prelude parser on the existing token stream — temporarily
337326 // restrict `s.end` to the prelude boundary so the loop sees EOF.
338327 const savedEnd = s.end
339 const savedPos = s.pos
340328 s.end = preludeEndPos
341329 s.pos = preludeStartPos
342330 const children = newList<CssNode>()
@@ -347,11 +335,9 @@ function parseAtrule(s: ParserState): Atrule | null {
347335 }
348336 promoteRatiosDeep(children)
349337 s.end = savedEnd
350 s.pos = scanPos // skip to after the prelude tokens
351 // Restore-or-advance: we want pos to end up at the ; or { token,
352 // which is where `scanPos` already points. (If the original loop
353 // had walked to scanPos via consume()s, that's the same place.)
354 void savedPos
338 // pos lands on the ; or { boundary so parseAtrule's caller can
339 // dispatch on the next token without re-walking.
340 s.pos = scanPos
355341 if (children.isEmpty)
356342 prelude = rawNode(raw, preludeStartTok, preludeEndTok, s)
357343 else
@@ -388,6 +374,14 @@ function parseAtrule(s: ParserState): Atrule | null {
388374 return { type: 'Atrule', name, prelude, block, loc: loc(s, startTok, peek(s)) }
389375}
390376
377/**
378 * Pseudo-class names whose argument is a selector list and needs to be
379 * re-parsed by `parseSelectorList` (rather than collected as a value).
380 */
381const SELECTOR_LIST_PSEUDOS_PARSE: ReadonlySet<string> = new Set([
382 'is', 'not', 'where', 'has', 'matches', '-moz-any', '-webkit-any',
383])
384
391385/**
392386 * At-rules whose block contains nested *rules* (selector { … }), as
393387 * opposed to direct declarations. Used to switch parser mode inside the
@@ -657,8 +651,10 @@ function parseDeclaration(s: ParserState): Declaration | null {
657651 ? { type: TokenType.Ident, start: starts[importantIdentPos]!, end: s.ends[importantIdentPos]! }
658652 : null
659653
660 property = property.trim()
661
654 // `property` came directly from a single Ident token's source slice, so
655 // it can't have leading/trailing whitespace — the previous `.trim()`
656 // here was load-bearing only when the slice path included whitespace
657 // chars, which doesn't happen with the tokenizer.
662658 const isCustom = property.startsWith('--')
663659 let value: Value | Raw
664660 const valueStartByte = starts[valueStartPos]!
@@ -701,7 +697,7 @@ function parseDeclaration(s: ParserState): Declaration | null {
701697
702698// ----- value -----
703699
704function parseValue(s: ParserState, _innerOnly: boolean): Value {
700function parseValue(s: ParserState): Value {
705701 return parseValueChildren(s)
706702}
707703
@@ -902,13 +898,32 @@ function parseValueChild(s: ParserState): CssNode | null {
902898 }
903899 case TokenType.Delim: {
904900 s.pos++
901 const code = src.charCodeAt(tstart)
905902 const ch = src[tstart]!
906 if (ch === '/' || ch === '+' || ch === '-' || ch === '*' || ch === '=' || ch === '>' || ch === '<' || ch === '~' || ch === '|' || ch === '$' || ch === '^' || ch === '!' || ch === '&')
903 // ASCII bitmap: `/ + - * = > < ~ | $ ^ ! &` — Operator semantics in
904 // value contexts. Anything else falls through as an Identifier
905 // (e.g. `&` mid-value, `!` outside !important, etc.).
906 if (isValueOperatorChar(code))
907907 return { type: 'Operator', value: ch, loc: s.positions ? locRange(s, tstart, tend) : null }
908908 return { type: 'Identifier', name: ch, loc: s.positions ? locRange(s, tstart, tend) : null }
909909 }
910 case TokenType.LeftSquareBracket: {
911 // `[…]` inside a value (e.g. `grid-template-columns: [start] 1fr`)
912 // becomes a `Brackets` node — matches css-tree's shape so consumers
913 // walking nested values don't see a Raw blob here.
914 s.pos++
915 const children = newList<CssNode>()
916 while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.RightSquareBracket) {
917 const inner = parseValueChild(s)
918 if (inner)
919 children.appendData(inner)
920 }
921 const endByte = s.pos < s.end ? s.ends[s.pos]! : tend
922 if (peekType(s) === TokenType.RightSquareBracket)
923 s.pos++
924 return { type: 'Brackets', children, loc: s.positions ? locRange(s, tstart, endByte) : null }
925 }
910926 case TokenType.AtKeyword:
911 case TokenType.LeftSquareBracket:
912927 case TokenType.LeftCurlyBracket:
913928 case TokenType.RightCurlyBracket:
914929 case TokenType.RightParenthesis:
@@ -931,6 +946,28 @@ function isWhitespaceCode(c: number): boolean {
931946 return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
932947}
933948
949function isValueOperatorChar(code: number): boolean {
950 // / + - * = > < ~ | $ ^ ! &
951 return code === 47 || code === 43 || code === 45 || code === 42
952 || code === 61 || code === 62 || code === 60 || code === 126
953 || code === 124 || code === 36 || code === 94 || code === 33
954 || code === 38
955}
956
957/**
958 * Slice `source[start:end]` and lowercase it, but only allocate a new
959 * string when the slice actually contains uppercase ASCII. Pure-ASCII
960 * lowercase identifiers (the common case) fast-path through `slice`.
961 */
962function lowerIfNeeded(source: string, start: number, end: number): string {
963 for (let i = start; i < end; i++) {
964 const c = source.charCodeAt(i)
965 if (c >= 65 && c <= 90)
966 return source.slice(start, end).toLowerCase()
967 }
968 return source.slice(start, end)
969}
970
934971function locRange(s: ParserState, startByte: number, endByte: number): CssLocation {
935972 const start = s.tokenizer.locate(startByte)
936973 const end = s.tokenizer.locate(endByte)
@@ -943,7 +980,11 @@ function locRange(s: ParserState, startByte: number, endByte: number): CssLocati
943980
944981function parseFunction(s: ParserState): CssNode {
945982 const startTok = consume(s) // function token (including `(`)
946 const name = s.source.slice(startTok.start, startTok.end - 1).toLowerCase()
983 // Fast path: scan for any uppercase ASCII before allocating a lower-case
984 // copy. Most function names in real stylesheets (`rgb`, `var`, `calc`,
985 // `linear-gradient`, …) are already lowercase, so the scan-with-no-upper
986 // path saves the `.toLowerCase()` allocation per Function token.
987 const name = lowerIfNeeded(s.source, startTok.start, startTok.end - 1)
947988 const children = newList<CssNode>()
948989 while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.RightParenthesis) {
949990 const node = parseValueChild(s)
@@ -954,11 +995,11 @@ function parseFunction(s: ParserState): CssNode {
954995 consume(s)
955996
956997 // Normalise url("…") / url('…') into a Url node (matches css-tree shape).
998 // Walk the linked list directly — avoids the `toArray()` allocation.
957999 if (name === 'url') {
958 const arr = children.toArray()
959 const stringChild = arr.find(c => c.type === 'String') as { type: 'String', value: string } | undefined
960 if (stringChild) {
961 return { type: 'Url', value: stringChild.value, loc: loc(s, startTok, peek(s)) } as UrlNode
1000 for (let cur: any = children.head; cur != null; cur = cur.next) {
1001 if (cur.data.type === 'String')
1002 return { type: 'Url', value: (cur.data as any).value, loc: loc(s, startTok, peek(s)) } as UrlNode
9621003 }
9631004 }
9641005
@@ -988,7 +1029,12 @@ function parseSelectorList(s: ParserState): SelectorList {
9881029 if (peekType(s) === TokenType.EOF || peekType(s) === TokenType.LeftCurlyBracket)
9891030 break
9901031 const sel = parseSelector(s)
991 children.appendData(sel)
1032 // Skip empty selectors entirely — these arise from malformed input
1033 // like `,, .a {}` where parseSelector hits a `,` without finding any
1034 // segment. Adding them would surface as `Rule.prelude.children: [{},…]`
1035 // and break round-trips downstream.
1036 if ((sel.children as any).head !== null)
1037 children.appendData(sel)
9921038 skipWhitespace(s)
9931039 if (peekType(s) === TokenType.Comma) {
9941040 consume(s)
@@ -1009,18 +1055,37 @@ function parseSelector(s: ParserState): Selector {
10091055 const t = peek(s)
10101056 if (t.type === TokenType.WhiteSpace) {
10111057 consume(s)
1012 // descendant combinator only between segments
1058 // descendant combinator only between segments — and only when the
1059 // next token isn't itself a combinator (`>`/`+`/`~` or `||`).
10131060 if (!lastWasCombinator
10141061 && peekType(s) !== TokenType.EOF
10151062 && peekType(s) !== TokenType.Comma
10161063 && peekType(s) !== TokenType.LeftCurlyBracket
1017 && !isExplicitCombinator(s)) {
1064 && !isExplicitCombinator(s)
1065 && !isColumnCombinator(s)) {
10181066 const c: Combinator = { type: 'Combinator', name: ' ', loc: loc(s, t, t) }
10191067 children.appendData(c)
10201068 lastWasCombinator = true
10211069 }
10221070 continue
10231071 }
1072 // Column combinator `||` — two consecutive `|` Delim tokens (CSS
1073 // Selectors 4). Detect before the single-char combinator path.
1074 if (
1075 t.type === TokenType.Delim
1076 && s.source.charCodeAt(t.start) === 124 /* | */
1077 && s.types[s.pos + 1] === TokenType.Delim
1078 && s.source.charCodeAt(s.starts[s.pos + 1]!) === 124
1079 ) {
1080 const startCol = peek(s)
1081 consume(s)
1082 const endCol = consume(s)
1083 const c: Combinator = { type: 'Combinator', name: '||', loc: loc(s, startCol, endCol) }
1084 children.appendData(c)
1085 lastWasCombinator = true
1086 skipWhitespace(s)
1087 continue
1088 }
10241089 if (isExplicitCombinator(s)) {
10251090 const ct = consume(s)
10261091 const ch = s.source[ct.start]!
@@ -1036,6 +1101,12 @@ function parseSelector(s: ParserState): Selector {
10361101 children.appendData(seg)
10371102 lastWasCombinator = false
10381103 }
1104 // A trailing combinator (descendant or otherwise) is meaningless — the
1105 // selector ended before another segment arrived. Strip it so the AST
1106 // doesn't carry a phantom `Combinator` that round-trips to extra
1107 // whitespace at the end of the rule prelude.
1108 while (children.tail && (children.tail.data as CssNode).type === 'Combinator')
1109 children.remove(children.tail)
10391110 return { type: 'Selector', children, loc: loc(s, startTok, peek(s)) }
10401111}
10411112
@@ -1047,6 +1118,17 @@ function isExplicitCombinator(s: ParserState): boolean {
10471118 return ch === '>' || ch === '+' || ch === '~'
10481119}
10491120
1121function isColumnCombinator(s: ParserState): boolean {
1122 // `||` — two consecutive `|` Delim tokens.
1123 if (s.types[s.pos] !== TokenType.Delim)
1124 return false
1125 if (s.source.charCodeAt(s.starts[s.pos]!) !== 124 /* | */)
1126 return false
1127 if (s.types[s.pos + 1] !== TokenType.Delim)
1128 return false
1129 return s.source.charCodeAt(s.starts[s.pos + 1]!) === 124
1130}
1131
10501132function parseSelectorSegment(s: ParserState): CssNode | null {
10511133 const t = peek(s)
10521134 switch (t.type) {
@@ -1054,8 +1136,11 @@ function parseSelectorSegment(s: ParserState): CssNode | null {
10541136 consume(s)
10551137 // Namespace prefix? `svg|circle` → TypeSelector(name="svg|circle").
10561138 // We keep the prefix as part of the name (matches css-tree shape).
1139 // Skip when the trailing `|` is part of `||` (column combinator) or
1140 // `|=` (attribute hyphen-match) — neither is a namespace separator.
10571141 if (peekType(s) === TokenType.Delim && s.source[peek(s).start] === '|'
1058 && s.source[peek(s).start + 1] !== '=') {
1142 && s.source[peek(s).start + 1] !== '='
1143 && !(s.types[s.pos + 1] === TokenType.Delim && s.source.charCodeAt(s.starts[s.pos + 1]!) === 124)) {
10591144 consume(s)
10601145 if (peekType(s) === TokenType.Ident) {
10611146 const local = consume(s)
@@ -1137,7 +1222,7 @@ function parsePseudo(s: ParserState, isElement: boolean, startTok: Token): Pseud
11371222 const t = peek(s)
11381223 if (t.type === TokenType.Ident) {
11391224 consume(s)
1140 const name = tokenSlice(s, t).toLowerCase()
1225 const name = lowerIfNeeded(s.source, t.start, t.end)
11411226 const node = isElement
11421227 ? { type: 'PseudoElementSelector' as const, name, children: null, loc: loc(s, startTok, t) }
11431228 : { type: 'PseudoClassSelector' as const, name, children: null, loc: loc(s, startTok, t) }
@@ -1145,14 +1230,11 @@ function parsePseudo(s: ParserState, isElement: boolean, startTok: Token): Pseud
11451230 }
11461231 if (t.type === TokenType.Function) {
11471232 consume(s)
1148 const name = s.source.slice(t.start, t.end - 1).toLowerCase()
1233 const name = lowerIfNeeded(s.source, t.start, t.end - 1)
11491234 const children = newList<CssNode>()
11501235
11511236 // Pseudo-classes that take a selector list re-parse their argument.
1152 const isSelectorListPseudo = !isElement && (
1153 name === 'is' || name === 'not' || name === 'where' || name === 'has'
1154 || name === 'matches' || name === '-moz-any' || name === '-webkit-any'
1155 )
1237 const isSelectorListPseudo = !isElement && SELECTOR_LIST_PSEUDOS_PARSE.has(name)
11561238 if (isSelectorListPseudo) {
11571239 // Slurp the argument tokens up to the matching `)` and re-parse them.
11581240 const argStart = peek(s)
src/parse/tokenizer.tsmodified+61-40
Changes to src/parse/tokenizer.ts
@@ -79,10 +79,6 @@ function isHexDigit(code: number): boolean {
7979 return code < 128 && (CHAR_CLASS[code]! & 4) !== 0
8080}
8181
82function isNonAscii(code: number): boolean {
83 return code >= 0x80
84}
85
8682function isNameStart(code: number): boolean {
8783 return code >= 0x80 || (code < 128 && (CHAR_CLASS[code]! & 8) !== 0)
8884}
@@ -151,20 +147,35 @@ export class Tokenizer {
151147 /** Lazy-built `Token`-shaped array — only filled if a consumer
152148 * reads `.tokens`. Internally the parser uses the typed arrays. */
153149 private _tokens: Token[] | null = null
154 /** Offset where each line starts; used for line/col reporting. */
155 lineStarts: number[] = [0]
150 /**
151 * Offset where each line starts; used for line/col reporting. Built
152 * lazily by `locate()` — almost every parse runs with `positions: false`
153 * and never asks for source locations, so paying the per-newline
154 * `lineStarts.push()` during tokenization is pure overhead in the
155 * common case. The closures the original loops captured for that push
156 * are also gone.
157 */
158 private _lineStarts: number[] | null = null
156159
157160 constructor(source: string) {
158161 this.source = source
159 // Estimate token count: avg ~4 chars per token. Better to over-allocate
160 // a little than to have to grow.
161 const cap = Math.max(64, source.length >> 1)
162 // CSS averages ~3.5 chars/token across realistic stylesheets (selectors,
163 // declarations, values, whitespace). Allocating `len/3` covers nearly
164 // all inputs without needing `grow()`. Floor at 64 for tiny snippets.
165 const cap = Math.max(64, Math.ceil(source.length / 3))
162166 this.types = new Uint8Array(cap)
163167 this.starts = new Uint32Array(cap)
164168 this.ends = new Uint32Array(cap)
165169 this.tokenize()
166170 }
167171
172 /** Public alias kept for back-compat — built lazily on first read. */
173 get lineStarts(): number[] {
174 if (this._lineStarts == null)
175 this._lineStarts = buildLineStarts(this.source)
176 return this._lineStarts
177 }
178
168179 /** Lazy `Token[]` view for backwards-compat. */
169180 get tokens(): Token[] {
170181 if (this._tokens != null)
@@ -201,21 +212,12 @@ export class Tokenizer {
201212 while (i < len) {
202213 const c1 = src.charCodeAt(i)
203214
204 // newline tracking
205 if (c1 === 10) // \n
206 this.lineStarts.push(i + 1)
207 else if (c1 === 13 && src.charCodeAt(i + 1) !== 10) // \r without \n
208 this.lineStarts.push(i + 1)
209
210215 // comment /* ... */
211216 if (c1 === 47 /* / */ && src.charCodeAt(i + 1) === 42 /* * */) {
212217 const start = i
213218 i += 2
214 while (i < len && !(src.charCodeAt(i) === 42 && src.charCodeAt(i + 1) === 47)) {
215 if (src.charCodeAt(i) === 10)
216 this.lineStarts.push(i + 1)
219 while (i < len && !(src.charCodeAt(i) === 42 && src.charCodeAt(i + 1) === 47))
217220 i++
218 }
219221 i = i < len ? i + 2 : len
220222 this.addToken(TokenType.Comment, start, i)
221223 continue
@@ -224,11 +226,8 @@ export class Tokenizer {
224226 // whitespace
225227 if (isWhitespace(c1)) {
226228 const start = i
227 while (i < len && isWhitespace(src.charCodeAt(i))) {
228 if (src.charCodeAt(i) === 10)
229 this.lineStarts.push(i + 1)
229 while (i < len && isWhitespace(src.charCodeAt(i)))
230230 i++
231 }
232231 this.addToken(TokenType.WhiteSpace, start, i)
233232 continue
234233 }
@@ -345,8 +344,6 @@ export class Tokenizer {
345344 if (c === 92 /* \ */) {
346345 const c2 = src.charCodeAt(i + 1)
347346 if (isNewline(c2)) {
348 if (c2 === 10)
349 this.lineStarts.push(i + 2)
350347 i += 2
351348 continue
352349 }
@@ -382,10 +379,22 @@ export class Tokenizer {
382379
383380 private consumeName(start: number): number {
384381 const src = this.source
382 const len = src.length
385383 let i = start
386 while (i < src.length) {
384 // Fast path: pure ASCII name chars — no escape, no non-ASCII. Inline
385 // `isName` so the JIT keeps the loop body in a single branch path.
386 while (i < len) {
387387 const c = src.charCodeAt(i)
388 if (isName(c)) {
388 if (c < 128 && (CHAR_CLASS[c]! & (2 | 8)) !== 0) {
389 i++
390 continue
391 }
392 if (c === 45 /* - */) {
393 i++
394 continue
395 }
396 // Slow path: non-ASCII or escape — fall back to the general check.
397 if (c >= 0x80) {
389398 i++
390399 continue
391400 }
@@ -483,11 +492,8 @@ export class Tokenizer {
483492 const src = this.source
484493 const len = src.length
485494 let i = nameEnd + 1
486 while (i < len && isWhitespace(src.charCodeAt(i))) {
487 if (src.charCodeAt(i) === 10)
488 this.lineStarts.push(i + 1)
495 while (i < len && isWhitespace(src.charCodeAt(i)))
489496 i++
490 }
491497 while (i < len) {
492498 const c = src.charCodeAt(i)
493499 if (c === 41 /* ) */) {
@@ -497,11 +503,8 @@ export class Tokenizer {
497503 }
498504 if (isWhitespace(c)) {
499505 const wsStart = i
500 while (i < len && isWhitespace(src.charCodeAt(i))) {
501 if (src.charCodeAt(i) === 10)
502 this.lineStarts.push(i + 1)
506 while (i < len && isWhitespace(src.charCodeAt(i)))
503507 i++
504 }
505508 if (i >= len) {
506509 this.addToken(TokenType.Url, start, i)
507510 return i
@@ -530,9 +533,9 @@ export class Tokenizer {
530533 return i
531534 }
532535
533 private consumeBadUrl(start: number, _from: number): number {
536 private consumeBadUrl(start: number, from: number): number {
534537 const src = this.source
535 let i = _from
538 let i = from
536539 while (i < src.length) {
537540 const c = src.charCodeAt(i)
538541 if (c === 41) {
@@ -551,20 +554,38 @@ export class Tokenizer {
551554
552555 /** Resolve `offset` to (line, column) for error messages. */
553556 locate(offset: number): { line: number, column: number } {
557 const ls = this.lineStarts
554558 let lo = 0
555 let hi = this.lineStarts.length - 1
559 let hi = ls.length - 1
556560 while (lo < hi) {
557561 const mid = (lo + hi + 1) >> 1
558 if (this.lineStarts[mid]! <= offset)
562 if (ls[mid]! <= offset)
559563 lo = mid
560564 else
561565 hi = mid - 1
562566 }
563 const lineStart = this.lineStarts[lo]!
567 const lineStart = ls[lo]!
564568 return { line: lo + 1, column: offset - lineStart + 1 }
565569 }
566570}
567571
572function buildLineStarts(source: string): number[] {
573 const out = [0]
574 const len = source.length
575 for (let i = 0; i < len; i++) {
576 const c = source.charCodeAt(i)
577 if (c === 10) {
578 out.push(i + 1)
579 }
580 else if (c === 13) {
581 // \r without \n, OR \r\n (treat the LF position consistently)
582 if (source.charCodeAt(i + 1) !== 10)
583 out.push(i + 1)
584 }
585 }
586 return out
587}
588
568589/** Decode a CSS-escaped name slice (e.g. `\\26 B` → `&B`). */
569590/**
570591 * Decode a CSS string literal slice to its actual character value.
src/parse/types.tsmodified+6-0
Changes to src/parse/types.ts
@@ -98,6 +98,12 @@ export interface Block extends BaseNode {
9898
9999export interface Declaration extends BaseNode {
100100 type: 'Declaration'
101 /**
102 * `true` when the declaration ends with `!important`. The string-form
103 * (e.g. `'!ie'` for IE-prefix hacks) is occasionally produced by other
104 * libraries, so the type stays permissive — but ts-css's parser always
105 * sets `boolean`.
106 */
101107 important: boolean | string
102108 property: string
103109 value: Value | Raw
src/parse/walker.tsmodified+7-15
Changes to src/parse/walker.ts
@@ -52,7 +52,7 @@ export interface WalkVisitorObject<T extends WalkVisit | undefined = undefined>
5252/** Plain-function visitor — runs against every node. */
5353export type WalkVisitorFunction = VisitorCallback<CssNode>
5454
55export type WalkVisitor = WalkVisitorFunction | WalkVisitorObject<any>
55export type WalkVisitor = WalkVisitorFunction | WalkVisitorObject<WalkVisit | undefined>
5656
5757function newContext(root: CssNode): WalkContext {
5858 return {
@@ -227,24 +227,24 @@ function walkList(
227227 // eslint-disable-next-line pickier/no-unused-vars
228228 visitNode: (n: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => symbol | undefined,
229229): symbol | undefined {
230 // Use list's own forEach to honor the cursor protocol so callbacks may
231 // safely mutate the list mid-walk.
230 // The list's `forEach` callback already receives the current `ListItem`
231 // as its second argument — passing it straight to `visitNode` avoids the
232 // O(N) `findItem` linear scan per child that the previous implementation
233 // performed (turning every walk into O(N²)).
232234 let stopped: symbol | undefined
233235 if (reverse) {
234 list.forEachRight((data, _, l) => {
236 list.forEachRight((data, item, l) => {
235237 if (stopped)
236238 return
237 const item = findItem(l, data)
238239 const r = visitNode(data, item, l)
239240 if (r === STOP)
240241 stopped = STOP
241242 })
242243 }
243244 else {
244 list.forEach((data, _, l) => {
245 list.forEach((data, item, l) => {
245246 if (stopped)
246247 return
247 const item = findItem(l, data)
248248 const r = visitNode(data, item, l)
249249 if (r === STOP)
250250 stopped = STOP
@@ -252,11 +252,3 @@ function walkList(
252252 }
253253 return stopped
254254}
255
256function findItem(list: CssList<CssNode>, data: CssNode): ListItem<CssNode> | null {
257 for (let cur = list.head; cur != null; cur = cur.next) {
258 if (cur.data === data)
259 return cur
260 }
261 return null
262}
src/select/attributes.tsmodified+0-3
Changes to src/select/attributes.ts
@@ -7,8 +7,6 @@ import type { AttributeSelectorNode } from '../what'
77import type { Adapter, CompiledQuery, Options } from './types'
88import { ALWAYS_TRUE } from './helpers/always-true'
99
10type Predicate<E> = (e: E) => boolean
11
1210function isWsCode(c: number): boolean {
1311 return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
1412}
@@ -242,4 +240,3 @@ function wrap<Node, ElementNode extends Node>(
242240 return _ => { void _; return false }
243241}
244242
245export type { Predicate }
src/select/index.tsmodified+30-22
Changes to src/select/index.ts
@@ -23,23 +23,32 @@ function getAdapter<Node, ElementNode extends Node>(options: Options<Node, Eleme
2323}
2424
2525// ----- compiled-selector cache -----
26// Keyed by Adapter (object identity) so different trees can't poison each
27// other's cache. The inner key is a deterministic string capturing the
28// selector + the options that affect compile output.
29const adapterCaches = new WeakMap<object, Map<string, CompiledQuery<any>>>()
26// Two-level lookup: outer keyed by Adapter (object identity), inner keyed
27// by a 3-bit flag-signature integer encoding the options that change
28// compile output. The innermost map is keyed by selector text. This avoids
29// the per-call `${flags}|${selector}` string concat the previous
30// single-level cache did — meaningful when consumers call selectAll in a
31// hot loop.
32const adapterCaches = new WeakMap<object, (Map<string, CompiledQuery<any>> | undefined)[]>()
3033
31function cacheKey(selector: string, options: Options<any, any>): string {
32 // Only options that change compile output need to be in the key.
33 return `${options.xmlMode ? '1' : '0'}|${options.lowerCaseAttributeNames === false ? '0' : '1'}|${options.lowerCaseTags === false ? '0' : '1'}|${selector}`
34function flagSig(options: Options<any, any>): number {
35 return ((options.xmlMode ? 1 : 0) << 0)
36 | ((options.lowerCaseAttributeNames === false ? 1 : 0) << 1)
37 | ((options.lowerCaseTags === false ? 1 : 0) << 2)
3438}
3539
36function getCache(adapter: object): Map<string, CompiledQuery<any>> {
37 let cache = adapterCaches.get(adapter)
38 if (!cache) {
39 cache = new Map()
40 adapterCaches.set(adapter, cache)
40function getCache(adapter: object, sig: number): Map<string, CompiledQuery<any>> {
41 let bucket = adapterCaches.get(adapter)
42 if (!bucket) {
43 bucket = []
44 adapterCaches.set(adapter, bucket)
4145 }
42 return cache
46 let m = bucket[sig]
47 if (!m) {
48 m = new Map()
49 bucket[sig] = m
50 }
51 return m
4352}
4453
4554function compileSelectorString<Node, ElementNode extends Node>(
@@ -65,13 +74,12 @@ export function compile<Node, ElementNode extends Node>(
6574 if (options.cacheResults === false || !options.adapter)
6675 return compileSelectorString(selector, options)
6776
68 const cache = getCache(options.adapter as unknown as object)
69 const key = cacheKey(selector, options)
70 let compiled = cache.get(key)
77 const cache = getCache(options.adapter as unknown as object, flagSig(options))
78 let compiled = cache.get(selector)
7179 if (compiled !== undefined)
7280 return compiled as CompiledQuery<ElementNode>
7381 compiled = compileSelectorString(selector, options)
74 cache.set(key, compiled)
82 cache.set(selector, compiled)
7583 return compiled as CompiledQuery<ElementNode>
7684}
7785
@@ -82,12 +90,12 @@ export function clearSelectorCache(adapter: object, selector?: string): void {
8290 adapterCaches.delete(adapter)
8391 return
8492 }
85 const cache = adapterCaches.get(adapter)
86 if (!cache)
93 const bucket = adapterCaches.get(adapter)
94 if (!bucket)
8795 return
88 for (const key of cache.keys()) {
89 if (key.endsWith(`|${selector}`))
90 cache.delete(key)
96 for (const m of bucket) {
97 if (m)
98 m.delete(selector)
9199 }
92100}
93101
src/select/pseudo-selectors/index.tsmodified+167-59
Changes to src/select/pseudo-selectors/index.ts
@@ -7,6 +7,7 @@
77
88import type { Selector } from '../../what'
99import type { CompiledQuery, Options } from '../types'
10import { parse as parseSelectorString } from '../../what'
1011import { compileGeneric, parseAndCompile } from './selectors'
1112
1213interface PseudoToken {
@@ -15,6 +16,25 @@ interface PseudoToken {
1516 data: string | Selector[][] | null
1617}
1718
19// Pseudo-classes whose match depends on document state (focus, hover, form
20// validity, etc.) that ts-css doesn't model. They fail closed unless the
21// caller supplies an `options.pseudos[name]` predicate.
22const STATEFUL_PSEUDOS: ReadonlySet<string> = new Set([
23 'link', 'any-link', 'visited', 'hover', 'active', 'focus',
24 'focus-visible', 'focus-within', 'target', 'target-within', 'enabled',
25 'disabled', 'checked', 'required', 'optional', 'valid', 'invalid',
26 'selected', 'placeholder-shown', 'read-only', 'read-write', 'in-range',
27 'out-of-range', 'default', 'indeterminate',
28])
29
30const SELECTOR_LIST_PSEUDOS: ReadonlySet<string> = new Set([
31 'is', 'where', 'matches', '-moz-any', '-webkit-any',
32])
33
34const NTH_PSEUDOS: ReadonlySet<string> = new Set([
35 'nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type',
36])
37
1838export function compilePseudo<Node, ElementNode extends Node>(
1939 token: PseudoToken,
2040 options: Options<Node, ElementNode>,
@@ -25,65 +45,165 @@ export function compilePseudo<Node, ElementNode extends Node>(
2545 const data = token.data
2646
2747 // -------- combinatorial pseudos: take selector lists --------
28 if (token.type === 'pseudo' && (name === 'is' || name === 'where' || name === 'matches' || name === '-moz-any' || name === '-webkit-any')) {
48 if (token.type === 'pseudo' && SELECTOR_LIST_PSEUDOS.has(name)) {
2949 if (!Array.isArray(data))
3050 return next
3151 const tests = (data as Selector[][]).map(seg => parseAndCompile(seg, options))
32 return e => tests.some(t => t(e)) && next(e)
52 // For a single sub-selector, skip the array iteration cost.
53 if (tests.length === 1) {
54 const t0 = tests[0]!
55 return e => t0(e) && next(e)
56 }
57 return (e) => {
58 for (const t of tests) {
59 if (t(e))
60 return next(e)
61 }
62 return false
63 }
3364 }
3465 if (token.type === 'pseudo' && name === 'not') {
3566 if (!Array.isArray(data))
3667 return next
3768 const tests = (data as Selector[][]).map(seg => parseAndCompile(seg, options))
38 return e => !tests.some(t => t(e)) && next(e)
69 if (tests.length === 1) {
70 const t0 = tests[0]!
71 return e => !t0(e) && next(e)
72 }
73 return (e) => {
74 for (const t of tests) {
75 if (t(e))
76 return false
77 }
78 return next(e)
79 }
3980 }
4081 if (token.type === 'pseudo' && name === 'has') {
4182 if (!Array.isArray(data))
4283 return next
4384 const tests = (data as Selector[][]).map(seg => parseAndCompile(seg, options))
85 // Collapse the sub-test list into a single predicate so the inner
86 // descendant loop only invokes one function per node.
87 const subjectTest: CompiledQuery<ElementNode> = tests.length === 1
88 ? tests[0]!
89 : ((node: any) => {
90 for (const t of tests) {
91 if (t(node))
92 return true
93 }
94 return false
95 }) as any
4496 return (e) => {
45 const stack = adapter.getChildren(e).slice()
97 // Iterative DFS using `pop` (O(1)) instead of `shift` (O(n)). The
98 // iteration order is reversed but `:has()` doesn't care about order
99 // — it stops at the first descendant that satisfies the test.
100 const stack: any[] = adapter.getChildren(e).slice()
46101 while (stack.length > 0) {
47 const cur = stack.shift()!
48 if (adapter.isTag(cur) && tests.some(t => t(cur as any)))
102 const cur = stack.pop()
103 if (!adapter.isTag(cur))
104 continue
105 if (subjectTest(cur as any))
49106 return next(e)
50 if (cur && (cur as any).children)
51 stack.push(...adapter.getChildren(cur))
107 const kids = adapter.getChildren(cur as any)
108 for (let i = 0; i < kids.length; i++)
109 stack.push(kids[i])
52110 }
53111 return false
54112 }
55113 }
56114
57115 // -------- nth-* pseudos --------
58 if (token.type === 'pseudo' && (name === 'nth-child' || name === 'nth-last-child' || name === 'nth-of-type' || name === 'nth-last-of-type')) {
116 if (token.type === 'pseudo' && NTH_PSEUDOS.has(name)) {
59117 const fn = compileNth(typeof data === 'string' ? data : '', name, options)
60118 return e => fn(e) && next(e)
61119 }
62120
63121 // -------- structural pseudos --------
122 // Each variant scans `getSiblings(e)` once without allocating the
123 // filtered intermediate array `[].filter()` would (the previous shape
124 // produced one allocation per element-test, which the matcher hits
125 // 250+ times per selector even for tiny trees).
64126 if (token.type === 'pseudo' && name === 'first-child') {
65 return e => isNthSibling(e, options, true, false, false) && next(e)
127 return (e) => {
128 const sibs = adapter.getSiblings(e)
129 for (const s of sibs) {
130 if (!adapter.isTag(s))
131 continue
132 return s === e && next(e)
133 }
134 return false
135 }
66136 }
67137 if (token.type === 'pseudo' && name === 'last-child') {
68 return e => isNthSibling(e, options, false, true, false) && next(e)
138 return (e) => {
139 const sibs = adapter.getSiblings(e)
140 for (let i = sibs.length - 1; i >= 0; i--) {
141 const s = sibs[i]!
142 if (!adapter.isTag(s))
143 continue
144 return s === e && next(e)
145 }
146 return false
147 }
69148 }
70149 if (token.type === 'pseudo' && name === 'first-of-type') {
71 return e => isNthSibling(e, options, true, false, true) && next(e)
150 return (e) => {
151 const tag = adapter.getName(e)
152 const sibs = adapter.getSiblings(e)
153 for (const s of sibs) {
154 if (!adapter.isTag(s) || adapter.getName(s as any) !== tag)
155 continue
156 return s === e && next(e)
157 }
158 return false
159 }
72160 }
73161 if (token.type === 'pseudo' && name === 'last-of-type') {
74 return e => isNthSibling(e, options, false, true, true) && next(e)
162 return (e) => {
163 const tag = adapter.getName(e)
164 const sibs = adapter.getSiblings(e)
165 for (let i = sibs.length - 1; i >= 0; i--) {
166 const s = sibs[i]!
167 if (!adapter.isTag(s) || adapter.getName(s as any) !== tag)
168 continue
169 return s === e && next(e)
170 }
171 return false
172 }
75173 }
76174 if (token.type === 'pseudo' && name === 'only-child') {
77175 return (e) => {
78 const sibs = adapter.getSiblings(e).filter(s => adapter.isTag(s))
79 return sibs.length === 1 && sibs[0] === e && next(e)
176 const sibs = adapter.getSiblings(e)
177 let count = 0
178 let found = false
179 for (const s of sibs) {
180 if (!adapter.isTag(s))
181 continue
182 count++
183 if (count > 1)
184 return false
185 if (s === e)
186 found = true
187 }
188 return count === 1 && found && next(e)
80189 }
81190 }
82191 if (token.type === 'pseudo' && name === 'only-of-type') {
83192 return (e) => {
84193 const tag = adapter.getName(e)
85 const sibs = adapter.getSiblings(e).filter(s => adapter.isTag(s) && adapter.getName(s as any) === tag)
86 return sibs.length === 1 && sibs[0] === e && next(e)
194 const sibs = adapter.getSiblings(e)
195 let count = 0
196 let found = false
197 for (const s of sibs) {
198 if (!adapter.isTag(s) || adapter.getName(s as any) !== tag)
199 continue
200 count++
201 if (count > 1)
202 return false
203 if (s === e)
204 found = true
205 }
206 return count === 1 && found && next(e)
87207 }
88208 }
89209
@@ -99,17 +219,21 @@ export function compilePseudo<Node, ElementNode extends Node>(
99219 }
100220 if (token.type === 'pseudo' && name === 'scope') {
101221 if (options.context && Array.isArray(options.context)) {
102 return e => (options.context as any[]).includes(e) && next(e)
222 // Materialize a Set once at compile time — `Array.includes` is O(n)
223 // per element check and `:scope` runs against every candidate.
224 const ctx = new Set<any>(options.context as any[])
225 return e => ctx.has(e) && next(e)
103226 }
104227 if (options.context) {
105 return e => (e as any) === options.context && next(e)
228 const ctx = options.context as any
229 return e => (e as any) === ctx && next(e)
106230 }
107231 return (e) => {
108232 const p = adapter.getParent(e)
109233 return (p == null || !adapter.isTag(p as any)) && next(e)
110234 }
111235 }
112 if (token.type === 'pseudo' && (name === 'link' || name === 'any-link' || name === 'visited' || name === 'hover' || name === 'active' || name === 'focus' || name === 'focus-visible' || name === 'focus-within' || name === 'target' || name === 'target-within' || name === 'enabled' || name === 'disabled' || name === 'checked' || name === 'required' || name === 'optional' || name === 'valid' || name === 'invalid' || name === 'selected' || name === 'placeholder-shown' || name === 'read-only' || name === 'read-write' || name === 'in-range' || name === 'out-of-range' || name === 'default' || name === 'indeterminate')) {
236 if (token.type === 'pseudo' && STATEFUL_PSEUDOS.has(name)) {
113237 // Stateful pseudos — without a real DOM we treat them as always-false
114238 // (matches css-select's `xmlMode` defaults). Consumers who need real
115239 // matching can pass `options.pseudos[name]` callbacks.
@@ -135,7 +259,12 @@ export function compilePseudo<Node, ElementNode extends Node>(
135259 return e => ext(e, typeof data === 'string' ? data : undefined) && next(e)
136260 }
137261 if (typeof ext === 'string') {
138 const re = parseAndCompile(parseRaw(ext), options)
262 // Compile the alias selector string with `compileGeneric` so commas
263 // (selector lists) are handled correctly. Previously this routed
264 // through a stubbed `parseRaw` that always returned `[]`, which
265 // silently caused string-aliased pseudos to never match.
266 const aliasGroups = parseSelectorString(ext)
267 const re = compileGeneric(aliasGroups, options)
139268 return e => re(e) && next(e)
140269 }
141270 }
@@ -144,36 +273,6 @@ export function compilePseudo<Node, ElementNode extends Node>(
144273 return _ => { void _; return false }
145274}
146275
147function parseRaw(text: string): Selector[] {
148 // small helper avoiding an outer dep cycle on what.parse for inline css
149 void text
150 return []
151}
152
153function isNthSibling<Node, ElementNode extends Node>(
154 e: ElementNode,
155 options: Options<Node, ElementNode>,
156 first: boolean,
157 last: boolean,
158 ofType: boolean,
159): boolean {
160 const adapter = options.adapter
161 const siblings = adapter.getSiblings(e).filter(s => adapter.isTag(s))
162 if (ofType) {
163 const tag = adapter.getName(e)
164 const filtered = siblings.filter(s => adapter.getName(s as any) === tag)
165 if (first)
166 return filtered[0] === e
167 if (last)
168 return filtered[filtered.length - 1] === e
169 return false
170 }
171 if (first)
172 return siblings[0] === e
173 if (last)
174 return siblings[siblings.length - 1] === e
175 return false
176}
177276
178277function parseAnPlusB(s: string): { a: number, b: number } | null {
179278 const trimmed = s.trim()
@@ -206,16 +305,25 @@ function compileNth<Node, ElementNode extends Node>(
206305 const ofType = kind === 'nth-of-type' || kind === 'nth-last-of-type'
207306 const reverse = kind === 'nth-last-child' || kind === 'nth-last-of-type'
208307 return (e) => {
209 let siblings = adapter.getSiblings(e).filter(s => adapter.isTag(s))
210 if (ofType) {
211 const tag = adapter.getName(e)
212 siblings = siblings.filter(s => adapter.getName(s as any) === tag)
308 // Compute idx in one pass over siblings — no `filter()` allocs and no
309 // `reverse()` mutation. For `nth-last-*` we count total matching
310 // siblings then take `total - rawIdx + 1`.
311 const sibs = adapter.getSiblings(e)
312 const tag = ofType ? adapter.getName(e) : ''
313 let raw = 0 // 1-based index of `e` among matching siblings
314 let total = 0
315 for (const s of sibs) {
316 if (!adapter.isTag(s))
317 continue
318 if (ofType && adapter.getName(s as any) !== tag)
319 continue
320 total++
321 if (s === e)
322 raw = total
213323 }
214 if (reverse)
215 siblings = siblings.reverse()
216 const idx = siblings.indexOf(e) + 1
217 if (idx === 0)
324 if (raw === 0)
218325 return false
326 const idx = reverse ? total - raw + 1 : raw
219327 if (a === 0)
220328 return idx === b
221329 return ((idx - b) / a) >= 0 && ((idx - b) % a) === 0
src/select/types.tsmodified+8-2
Changes to src/select/types.ts
@@ -27,10 +27,16 @@ export interface Adapter<Node, ElementNode extends Node> {
2727 hasAttrib: (elem: ElementNode, name: string) => boolean
2828 // eslint-disable-next-line pickier/no-unused-vars
2929 removeSubsets: (nodes: Node[]) => Node[]
30 /**
31 * `findAll` / `findOne` are accepted for adapter-shape compatibility
32 * with css-select, but ts-css implements its own iterative tree walker
33 * (`select/helpers/querying.ts`) and does NOT call these. They're
34 * declared optional so adapters that don't provide them still type-check.
35 */
3036 // eslint-disable-next-line pickier/no-unused-vars
31 findAll: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode[]
37 findAll?: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode[]
3238 // eslint-disable-next-line pickier/no-unused-vars
33 findOne: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode | null
39 findOne?: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode | null
3440 // eslint-disable-next-line pickier/no-unused-vars
3541 equals?: (a: Node, b: Node) => boolean
3642 // eslint-disable-next-line pickier/no-unused-vars
src/what/stringify.tsmodified+6-1
Changes to src/what/stringify.ts
@@ -80,8 +80,13 @@ function nsPrefix(ns: string | null): string {
8080 return `${escapeIdent(ns)}|`
8181}
8282
83const RE_INVALID_ID_CHAR = /[^\w-]/g
83// Valid CSS identifier chars: ASCII letters, digits, `-`, `_`, and any
84// non-ASCII (>= 0x80). Anything else needs an escape. `\W` rejects
85// non-ASCII, so we use an explicit positive set.
86const RE_INVALID_ID_CHAR = /[^\w°-￿-]/g
8487
8588function escapeIdent(name: string): string {
89 if (name === '')
90 return ''
8691 return name.replace(RE_INVALID_ID_CHAR, m => `\\${m}`)
8792}