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+66-67
Changes to README.md
@@ -2,26 +2,31 @@
22
33> Pure-TypeScript CSS toolkit for Bun & Node. Zero runtime dependencies.
44
5`ts-css` is a single, dependency-free package that replaces the four-library
6quartet most CSS pipelines pull in:
7
8| You used to install | Now you only install |
9| -------------------------------- | ---------------------------- |
10| `css-tree` `css-select` `css-what` `csso` | `@stacksjs/ts-css` |
11
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.
5`ts-css` is a CSS toolkit for build tools, linters, design-system pipelines,
6SSR templating, runtime style transforms — anything that needs to read,
7walk, query, transform, or minify CSS in a JS/TS environment. Written from
8scratch in TypeScript with strict types and the CSS Syntax Module Level 3
9tokenizer.
10
11It bundles every common CSS pipeline primitive — tokenizer, AST parser,
12visitor walker, generator, selector parser, selector matcher, and minifier —
13into a single zero-dep package. The shape of each sub-API mirrors the
14de-facto community standard for that capability (`css-tree`-style AST and
15walker, `css-what`-style selector tokens, the `css-select` adapter contract,
16`csso`-style minify result), so adopting `ts-css` rarely requires call-site
17changes — but the project stands on its own and isn't pitched as "the
18replacement for X."
1719
1820## Highlights
1921
2022- **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.
23- **One install for the whole CSS pipeline.** Tokenizer, parser, walker,
24 generator, selector parser/matcher, and minifier — all behind sub-module
25 imports at `@stacksjs/ts-css/parse`, `/what`, `/select`, `/optimize`.
26- **Familiar API shapes.** Visitor/walker callbacks, AST node types, and
27 the `css-select` adapter contract follow the conventions established by
28 the popular community libraries, so most existing call sites work
29 unchanged.
2530- **Bun-first.** Built and tested on Bun, works on Node ≥18.
2631- **Strict TypeScript.** Strict mode, isolatedDeclarations, verbatimModuleSyntax.
2732
@@ -75,9 +80,10 @@ const adapter = {
7580selectAll('p > span.foo:not(.disabled)', root, { adapter, xmlMode: true })
7681```
7782
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).
83The matcher is decoupled from any DOM implementation: pass an `Adapter`
84describing your tree (HTML, XML, an SVG AST, your design-system component
85graph, anything with `parent`/`children`/`name`/`attrs`-like access) and
86ts-css will match standard CSS selectors against it.
8187
8288### Minify CSS
8389
@@ -103,33 +109,27 @@ const sel = parse('#a.b div', { context: 'selector' })
103109syntax.specificity(sel) // [1, 1, 1]
104110```
105111
106## Migration: drop-in compat
112## API surface
107113
108If you already use `css-tree`/`css-select`/`css-what`/`csso`, the migration
109is one block of imports:
114`ts-css` exposes a complete CSS pipeline. The named exports below cover the
115endpoints most build/lint/transform tools reach for:
110116
111```diff
112- import * as csstree from 'css-tree'
113- import * as csswhat from 'css-what'
114- import { is, selectAll, selectOne } from 'css-select'
115- import * as csso from 'csso'
116+ import { csstree, cssWhat as csswhat, csso, is, selectAll, selectOne } from '@stacksjs/ts-css'
117```
118
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)` | ✅ |
117| Capability | Export |
118| -------------------------------- | ---------------------------- |
119| Parse a stylesheet to an AST (`stylesheet`/`rule`/`declaration`/`selectorList`/etc. contexts) | `parse(source, opts?)` |
120| Walk the AST with `enter`/`leave` callbacks, `visit` filtering, and a `walk.skip` sentinel | `walk(ast, fn \| visitor)` |
121| Stringify any AST node back to CSS source | `generate(node)` |
122| Deep-clone an AST subtree | `clone(node)` |
123| Doubly-linked children container with cursor-safe `forEach(data, item, list)` mid-walk mutation | `List` / `ListItem` |
124| Tokenize a selector string into segments | `cssWhat.parse(selector)` |
125| Detect combinator/traversal segments | `cssWhat.isTraversal(seg)` |
126| Match a selector against an arbitrary tree via the `Adapter` contract | `selectAll`, `selectOne`, `is`, `compile` |
127| Minify with declaration/value compression and whitespace collapse | `minify`, `minifyBlock` |
128| Compute selector specificity tuple | `syntax.specificity(node)` |
129
130A namespace export (`csstree`, `cssWhat`, `cssSelect`, `csso`) is also
131available so existing code that imports from those packages can typically
132switch by just changing the import path.
133133
134134## CLI
135135
@@ -141,20 +141,19 @@ ts-css format input.css # round-trip through parser/generator
141141
142142## What's not (yet) in scope
143143
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:
144This is a pragmatic toolkit — full surface area where it's load-bearing,
145trimmed where edge cases would force in spec data or rarely-used passes:
147146
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.
147- ❌ **No CSS-spec-aware value lexer.** Validating that `red` is a valid
148 `<color>`, or that `1px` is the right syntax for a given property, is
149 thousands of lines of spec grammar that most pipelines don't need. The
150 AST preserves enough information to layer that on top if you do.
151- ❌ **No cross-rule restructuring pass.** Reordering rules across the
152 whole stylesheet to fold compatible declarations is complex and gives
153 marginal gains beyond gzip. Declaration-level minification (numbers,
154 colors, dedup, longhand collapse) ships fully.
156155
157Everything else round-trips byte-equivalent or better than the originals.
156Everything else round-trips byte-equivalent.
158157
159158## Project layout
160159
@@ -177,14 +176,15 @@ import { minify } from '@stacksjs/ts-css/optimize'
177176
178177## Performance
179178
180End-to-end (parse → minify → generate) on a 6 KB representative fixture:
179End-to-end (parse → minify → generate) on a 6 KB representative fixture
180(Apple M3 Pro, Bun 1.3):
181181
182182| Pipeline | Time/iter |
183183| --------------------------- | --------- |
184| `ts-css.minify(source)` | **441 µs** |
185| `csso.minify(source)` | 847 µs |
184| `ts-css.minify(source)` | **~370 µs** |
185| `csso.minify(source)` | ~870 µs |
186186
187→ **1.92× faster end-to-end** with zero runtime deps. Per-API breakdown
187→ **~2.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
@@ -205,10 +205,9 @@ local docs site.
205205
206206MIT — see [`LICENSE.md`](./LICENSE.md).
207207
208The selector parsing strategy in `src/what` is structurally inspired by
209[`css-what`](https://github.com/fb55/css-what); the visitor & List shape in
210`src/parse` mirrors [`css-tree`](https://github.com/csstree/csstree); the
211minification plan in `src/optimize` follows
212[`csso`](https://github.com/css/csso). All four are MIT-licensed; this
213project is an independent reimplementation that preserves their public API
214surface.
208`ts-css` is an independent implementation. The visitor / linked-list shape
209in `src/parse`, the selector tokenizer model in `src/what`, the adapter
210pattern in `src/select`, and the minification plan in `src/optimize` all
211follow conventions established by the long-standing community libraries in
212each area, so call sites that already use those conventions tend to drop in
213without changes. All such projects are MIT-licensed.
src/parse/parser/index.tsmodified+271-140
Changes to src/parse/parser/index.ts
@@ -274,8 +274,18 @@ function parseStyleSheet(s: ParserState): StyleSheet {
274274 children.appendData(at)
275275 continue
276276 }
277 // Top-level safety: a stray `}` (mismatched braces in malformed input)
278 // would otherwise let parseRule return without advancing — guard against
279 // the infinite loop here rather than embedding the check inside parseRule.
280 if (t === TokenType.RightCurlyBracket) {
281 s.pos++
282 continue
283 }
284 const before = s.pos
277285 const r = parseRule(s)
278286 children.appendData(r)
287 if (s.pos === before)
288 s.pos++
279289 }
280290 const lastIdx = s.end - 1
281291 const endTok: Token = lastIdx >= 0
@@ -294,62 +304,77 @@ function parseAtrule(s: ParserState): Atrule | null {
294304 const startTok = peek(s)
295305 if (startTok.type !== TokenType.AtKeyword)
296306 return null
297 consume(s)
307 s.pos++
298308 const name = s.source.slice(startTok.start + 1, startTok.end).toLowerCase()
299309
300 // collect prelude tokens until ; or {
310 // Collect prelude tokens until ; or { — pre-scan for the boundary.
301311 skipWhitespace(s)
302 const preludeStart = peek(s)
303 let preludeEndTok = preludeStart
304 while (peekType(s) !== TokenType.EOF
305 && peekType(s) !== TokenType.Semicolon
306 && peekType(s) !== TokenType.LeftCurlyBracket) {
307 preludeEndTok = consume(s)
312 const preludeStartPos = s.pos
313 const preludeStartTok = peek(s)
314 const types = s.types
315 const tend = s.end
316 let scanPos = preludeStartPos
317 let preludeEndPos = preludeStartPos
318 while (scanPos < tend) {
319 const t = types[scanPos]!
320 if (t === TokenType.EOF || t === TokenType.Semicolon || t === TokenType.LeftCurlyBracket)
321 break
322 if (t !== TokenType.WhiteSpace && t !== TokenType.Comment)
323 preludeEndPos = scanPos + 1
324 scanPos++
308325 }
309326
310327 let prelude: AtrulePrelude | Raw | null = null
311 if (preludeStart !== peek(s)) {
312 const raw = s.source.slice(preludeStart.start, preludeEndTok.end).trim()
328 if (preludeEndPos > preludeStartPos) {
329 const preludeStartByte = s.starts[preludeStartPos]!
330 const preludeEndByte = s.ends[preludeEndPos - 1]!
331 const preludeEndTok: Token = { type: types[preludeEndPos - 1]! as TokenType, start: s.starts[preludeEndPos - 1]!, end: preludeEndByte }
332 const raw = s.source.slice(preludeStartByte, preludeEndByte).trim()
313333 if (raw.length > 0) {
314334 if (s.parseAtrulePrelude) {
315 // Always attempt to parse — fall back to Raw if parsing produces
316 // nothing useful or throws. No more hardcoded at-rule whitelist:
317 // custom at-rules (`@property`, `@scope`, `@layer`, future specs)
318 // get a parsed prelude too.
319 //
320 // Whitespace IS preserved in at-rule preludes: media queries treat
321 // it as significant (`screen and (...)`), and so do `@layer foo, bar`
322 // and `@import url('x') screen`. `parseValueChild` emits a single
323 // WhiteSpace node per whitespace run which the generator turns
324 // back into one space.
325335 try {
326 const sub = makeState(raw, { positions: false })
327 sub.onParseError = s.onParseError
336 // Run the prelude parser on the existing token stream — temporarily
337 // restrict `s.end` to the prelude boundary so the loop sees EOF.
338 const savedEnd = s.end
339 const savedPos = s.pos
340 s.end = preludeEndPos
341 s.pos = preludeStartPos
328342 const children = newList<CssNode>()
329 while (peekType(sub) !== TokenType.EOF) {
330 const node = parseValueChild(sub)
343 while (peekType(s) !== TokenType.EOF) {
344 const node = parseValueChild(s)
331345 if (node)
332346 children.appendData(node)
333347 }
334 // The @media `(aspect-ratio: 16/9)` form lives inside Parens
335 // here — promote the Number/Operator(/)/Number triples in
336 // every container to Ratio nodes, recursively.
337348 promoteRatiosDeep(children)
349 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
338355 if (children.isEmpty)
339 prelude = rawNode(raw, preludeStart, preludeEndTok, s)
356 prelude = rawNode(raw, preludeStartTok, preludeEndTok, s)
340357 else
341358 prelude = { type: 'AtrulePrelude', children, loc: null }
342359 }
343360 catch (err) {
344 const fallback = rawNode(raw, preludeStart, preludeEndTok, s)
345 reportError(s, `Failed to parse @${name} prelude: ${(err as Error).message}`, preludeStart, fallback)
361 const fallback = rawNode(raw, preludeStartTok, preludeEndTok, s)
362 reportError(s, `Failed to parse @${name} prelude: ${(err as Error).message}`, preludeStartTok, fallback)
346363 prelude = fallback
364 s.pos = scanPos
347365 }
348366 }
349367 else {
350 prelude = rawNode(raw, preludeStart, preludeEndTok, s)
368 prelude = rawNode(raw, preludeStartTok, preludeEndTok, s)
369 s.pos = scanPos
351370 }
352371 }
372 else {
373 s.pos = scanPos
374 }
375 }
376 else {
377 s.pos = scanPos
353378 }
354379
355380 let block: Block | null = null
@@ -536,89 +561,135 @@ function parseDeclaration(s: ParserState): Declaration | null {
536561 skipWhitespace(s)
537562 const startTok = peek(s)
538563 if (startTok.type !== TokenType.Ident && startTok.type !== TokenType.Hash && !(startTok.type === TokenType.Delim && s.source[startTok.start] === '*' && s.source[startTok.start + 1] === ' ')) {
539 // Custom property starts with --, which is tokenised as ident
540564 if (startTok.type === TokenType.Delim && s.source[startTok.start] === '*') {
541565 // ie *property
542 consume(s)
566 s.pos++
543567 }
544568 else {
545 // skip junk to next ;
546569 reportError(s, `Expected property name`, startTok, { type: 'Raw', value: '', loc: null })
547 while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.Semicolon && peekType(s) !== TokenType.RightCurlyBracket)
548 consume(s)
570 const types = s.types
571 const end = s.end
572 while (s.pos < end) {
573 const t = types[s.pos]!
574 if (t === TokenType.EOF || t === TokenType.Semicolon || t === TokenType.RightCurlyBracket)
575 break
576 s.pos++
577 }
549578 return null
550579 }
551580 }
552 const propTok = consume(s)
553 let property = s.source.slice(propTok.start, propTok.end)
554 // optional `*property` IE hack — already handled via leading delim
555 // optional vendor prefix already part of ident
581 const propStart = s.starts[s.pos]!
582 const propEnd = s.ends[s.pos]!
583 s.pos++
584 let property = s.source.slice(propStart, propEnd)
556585
557586 skipWhitespaceOnly(s)
558587 if (peekType(s) !== TokenType.Colon) {
559 // not a declaration — recover
560588 reportError(s, `Expected ':' after property "${property}"`, peek(s), { type: 'Raw', value: '', loc: null })
561 while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.Semicolon && peekType(s) !== TokenType.RightCurlyBracket)
562 consume(s)
589 const types = s.types
590 const end = s.end
591 while (s.pos < end) {
592 const t = types[s.pos]!
593 if (t === TokenType.EOF || t === TokenType.Semicolon || t === TokenType.RightCurlyBracket)
594 break
595 s.pos++
596 }
563597 return null
564598 }
565 consume(s) // :
599 s.pos++ // :
566600
567 // collect value tokens up to ; or }
568601 skipWhitespaceOnly(s)
569 const valueStartTok = peek(s)
570 let valueEndTok = valueStartTok
571 let importantTok: Token | null = null
572
573 while (peekType(s) !== TokenType.EOF
574 && peekType(s) !== TokenType.Semicolon
575 && peekType(s) !== TokenType.RightCurlyBracket) {
576 const lookaheadTok = peek(s)
577 if (lookaheadTok.type === TokenType.Delim && s.source[lookaheadTok.start] === '!') {
578 // potential !important — preserve current valueEndTok and try to advance
579 const before = valueEndTok
580 s.pos++ // consume the `!`
581 while (s.pos < s.count && s.types[s.pos]! === TokenType.WhiteSpace)
582 s.pos++
583 const next: Token | null = s.pos < s.count
584 ? { type: s.types[s.pos]! as TokenType, start: s.starts[s.pos]!, end: s.ends[s.pos]! }
585 : null
586 if (next && next.type === TokenType.Ident && s.source.slice(next.start, next.end).toLowerCase() === 'important') {
587 importantTok = next
588 valueEndTok = before // exclude the `!important` from the value text
589 s.pos++
590 continue
602 // Pre-scan the value token range. We track:
603 // - lastNonWsPos: index after the last non-whitespace value token
604 // (where the parsable value ends — excludes trailing whitespace/!important)
605 // - importantPos: position of the `important` ident if !important detected
606 // - endPos: stop position (Semicolon/RightCurly/EOF)
607 // This avoids re-tokenizing the value text — the parser walks the
608 // already-tokenised stream directly.
609 const types = s.types
610 const starts = s.starts
611 const tend = s.end
612 const valueStartPos = s.pos
613 let scanPos = valueStartPos
614 let lastNonWsPos = valueStartPos
615 let importantIdentPos = -1
616 let bangPos = -1
617 while (scanPos < tend) {
618 const t = types[scanPos]!
619 if (t === TokenType.EOF || t === TokenType.Semicolon || t === TokenType.RightCurlyBracket)
620 break
621 if (t === TokenType.Delim && s.source.charCodeAt(starts[scanPos]!) === 33 /* ! */) {
622 // potential !important — peek next non-ws
623 let look = scanPos + 1
624 while (look < tend && types[look]! === TokenType.WhiteSpace) look++
625 if (look < tend && types[look]! === TokenType.Ident) {
626 const ts = starts[look]!
627 const te = s.ends[look]!
628 // Inline lowercase compare for "important" (9 chars)
629 if (te - ts === 9
630 && (s.source.charCodeAt(ts) | 32) === 105
631 && (s.source.charCodeAt(ts + 1) | 32) === 109
632 && (s.source.charCodeAt(ts + 2) | 32) === 112
633 && (s.source.charCodeAt(ts + 3) | 32) === 111
634 && (s.source.charCodeAt(ts + 4) | 32) === 114
635 && (s.source.charCodeAt(ts + 5) | 32) === 116
636 && (s.source.charCodeAt(ts + 6) | 32) === 97
637 && (s.source.charCodeAt(ts + 7) | 32) === 110
638 && (s.source.charCodeAt(ts + 8) | 32) === 116) {
639 bangPos = scanPos
640 importantIdentPos = look
641 scanPos = look + 1
642 continue
643 }
591644 }
592 // not important — fall back: include the `!` in the value
593 valueEndTok = lookaheadTok
594 continue
595645 }
596 const t = consume(s)
597 valueEndTok = t
646 if (t !== TokenType.WhiteSpace && t !== TokenType.Comment)
647 lastNonWsPos = scanPos + 1
648 scanPos++
598649 }
599650
600 let valueText: string
601 if (importantTok) {
602 valueText = s.source.slice(valueStartTok.start, valueEndTok.end).trim()
603 }
604 else {
605 valueText = s.source.slice(valueStartTok.start, valueEndTok.end).trim()
606 }
651 // The value range, parser-relative: [valueStartPos, valueEndPos).
652 // `lastNonWsPos` already excludes both the trailing whitespace AND the
653 // !important suffix because we don't update it inside the bang branch.
654 void bangPos
655 const valueEndPos = lastNonWsPos
656 const importantTok: Token | null = importantIdentPos >= 0
657 ? { type: TokenType.Ident, start: starts[importantIdentPos]!, end: s.ends[importantIdentPos]! }
658 : null
659
607660 property = property.trim()
608661
609662 const isCustom = property.startsWith('--')
610663 let value: Value | Raw
664 const valueStartByte = starts[valueStartPos]!
665 const valueEndByte = valueEndPos > valueStartPos ? s.ends[valueEndPos - 1]! : valueStartByte
666 const valueStartTok: Token = { type: types[valueStartPos]! as TokenType, start: valueStartByte, end: s.ends[valueStartPos]! }
667 const valueEndTok: Token = valueEndPos > valueStartPos
668 ? { type: types[valueEndPos - 1]! as TokenType, start: starts[valueEndPos - 1]!, end: valueEndByte }
669 : valueStartTok
670
611671 if ((isCustom && !s.parseCustomProperty) || !s.parseValue) {
672 const valueText = s.source.slice(valueStartByte, valueEndByte).trim()
612673 value = { type: 'Raw', value: valueText, loc: loc(s, valueStartTok, valueEndTok) }
613674 }
614 else if (valueText.length === 0) {
675 else if (valueEndPos === valueStartPos) {
615676 value = { type: 'Value', children: newList<CssNode>(), loc: loc(s, valueStartTok, valueEndTok) }
616677 }
617678 else {
618 const sub = makeState(valueText, { positions: false })
619 value = parseValueChildren(sub)
679 // Run parseValueChildren on the existing token stream by temporarily
680 // shrinking `s.end` to the value boundary so the parser sees a virtual
681 // EOF at the right place. No re-tokenization required.
682 const savedEnd = s.end
683 s.end = valueEndPos
684 s.pos = valueStartPos
685 value = parseValueChildren(s)
686 s.end = savedEnd
620687 }
621688
689 // Position the parser at the boundary token (Semicolon / RightCurly / EOF).
690 // `scanPos` already advanced past !important when it was detected.
691 s.pos = scanPos
692
622693 return {
623694 type: 'Declaration',
624695 property,
@@ -705,64 +776,115 @@ function promoteRatios(list: CssList<CssNode>): void {
705776}
706777
707778function parseValueChild(s: ParserState): CssNode | null {
708 const t = peek(s)
709 switch (t.type) {
779 const i = s.pos
780 if (i >= s.end)
781 return null
782 const ttype = s.types[i]! as TokenType
783 const tstart = s.starts[i]!
784 const tend = s.ends[i]!
785 const src = s.source
786 switch (ttype) {
710787 case TokenType.WhiteSpace: {
711 consume(s)
712 const node: WhiteSpaceNode = { type: 'WhiteSpace', value: ' ', loc: loc(s, t, t) }
713 return node
788 s.pos++
789 return s.positions
790 ? { type: 'WhiteSpace', value: ' ', loc: locRange(s, tstart, tend) }
791 : { type: 'WhiteSpace', value: ' ', loc: null }
714792 }
715793 case TokenType.Comment: {
716 consume(s)
717 return makeComment(s, t)
794 s.pos++
795 return { type: 'Comment', value: src.slice(tstart + 2, tend - 2), loc: s.positions ? locRange(s, tstart, tend) : null }
718796 }
719797 case TokenType.Number: {
720 consume(s)
721 const node: NumberNode = { type: 'Number', value: tokenSlice(s, t), loc: loc(s, t, t) }
722 return node
798 s.pos++
799 return { type: 'Number', value: src.slice(tstart, tend), loc: s.positions ? locRange(s, tstart, tend) : null }
723800 }
724801 case TokenType.Percentage: {
725 consume(s)
726 const node: PercentageNode = { type: 'Percentage', value: s.source.slice(t.start, t.end - 1), loc: loc(s, t, t) }
727 return node
802 s.pos++
803 return { type: 'Percentage', value: src.slice(tstart, tend - 1), loc: s.positions ? locRange(s, tstart, tend) : null }
728804 }
729805 case TokenType.Dimension: {
730 consume(s)
731 const text = tokenSlice(s, t)
732 const m = /^([+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)(.+)$/.exec(text)!
733 const node: DimensionNode = { type: 'Dimension', value: m[1]!, unit: m[2]!, loc: loc(s, t, t) }
734 return node
806 s.pos++
807 // Manual scan: number portion ends where the unit (alpha/`-`/escape)
808 // begins. Faster than the previous regex.
809 let unitStart = tstart
810 // optional sign
811 const c0 = src.charCodeAt(unitStart)
812 if (c0 === 43 /* + */ || c0 === 45 /* - */)
813 unitStart++
814 // integer/fractional digits and a single dot
815 let sawDot = false
816 while (unitStart < tend) {
817 const c = src.charCodeAt(unitStart)
818 if (c >= 48 && c <= 57) {
819 unitStart++
820 continue
821 }
822 if (c === 46 && !sawDot) {
823 sawDot = true
824 unitStart++
825 continue
826 }
827 break
828 }
829 // optional scientific notation
830 const eC = src.charCodeAt(unitStart)
831 if ((eC === 69 || eC === 101) /* e/E */) {
832 let look = unitStart + 1
833 const sgn = src.charCodeAt(look)
834 if (sgn === 43 || sgn === 45) look++
835 let any = false
836 while (look < tend) {
837 const c = src.charCodeAt(look)
838 if (c >= 48 && c <= 57) { look++; any = true; continue }
839 break
840 }
841 if (any) unitStart = look
842 }
843 return {
844 type: 'Dimension',
845 value: src.slice(tstart, unitStart),
846 unit: src.slice(unitStart, tend),
847 loc: s.positions ? locRange(s, tstart, tend) : null,
848 }
735849 }
736850 case TokenType.Ident: {
737 consume(s)
738 const node: Identifier = { type: 'Identifier', name: decodeName(s.source, t.start, t.end), loc: loc(s, t, t) }
739 return node
851 s.pos++
852 return { type: 'Identifier', name: decodeName(src, tstart, tend), loc: s.positions ? locRange(s, tstart, tend) : null }
740853 }
741854 case TokenType.String: {
742 consume(s)
743 // Drop the surrounding quotes and decode escapes (`\"` → `"`,
744 // `\26 ` → `&`, etc.) so the AST holds the actual string value.
745 const innerStart = t.start + 1
746 const closesProperly = t.end - t.start >= 2 && s.source.charCodeAt(t.end - 1) === s.source.charCodeAt(t.start)
747 const innerEnd = closesProperly ? t.end - 1 : t.end
748 const value = decodeString(s.source, innerStart, innerEnd)
749 const node: StringNode = { type: 'String', value, loc: loc(s, t, t) }
750 return node
855 s.pos++
856 const innerStart = tstart + 1
857 const closesProperly = tend - tstart >= 2 && src.charCodeAt(tend - 1) === src.charCodeAt(tstart)
858 const innerEnd = closesProperly ? tend - 1 : tend
859 const value = decodeString(src, innerStart, innerEnd)
860 return { type: 'String', value, loc: s.positions ? locRange(s, tstart, tend) : null }
751861 }
752862 case TokenType.Url: {
753 consume(s)
754 const slice = tokenSlice(s, t)
755 const inner = slice.replace(/^url\(\s*/i, '').replace(/\s*\)$/, '')
756 const value = inner.length >= 2 && (inner[0] === '"' || inner[0] === '\'') && inner.endsWith(inner[0]!)
757 ? inner.slice(1, -1)
758 : inner
759 const node: UrlNode = { type: 'Url', value, loc: loc(s, t, t) }
760 return node
863 s.pos++
864 // url(<inner>) — strip `url(` and the matching `)` (and any quotes
865 // on the inner if present), avoiding a regex+slice chain.
866 let a = tstart + 4 // past `url(`
867 while (a < tend && isWhitespaceCode(src.charCodeAt(a))) a++
868 let b = tend
869 if (b > a && src.charCodeAt(b - 1) === 41 /* ) */) b--
870 while (b > a && isWhitespaceCode(src.charCodeAt(b - 1))) b--
871 let value: string
872 if (b - a >= 2) {
873 const q0 = src.charCodeAt(a)
874 const q1 = src.charCodeAt(b - 1)
875 if ((q0 === 34 || q0 === 39) && q0 === q1)
876 value = src.slice(a + 1, b - 1)
877 else
878 value = src.slice(a, b)
879 }
880 else {
881 value = src.slice(a, b)
882 }
883 return { type: 'Url', value, loc: s.positions ? locRange(s, tstart, tend) : null }
761884 }
762885 case TokenType.Hash: {
763 consume(s)
764 const node: HashNode = { type: 'Hash', name: s.source.slice(t.start + 1, t.end), loc: loc(s, t, t) }
765 return node
886 s.pos++
887 return { type: 'Hash', name: src.slice(tstart + 1, tend), loc: s.positions ? locRange(s, tstart, tend) : null }
766888 }
767889 case TokenType.Function: {
768890 return parseFunction(s)
@@ -771,24 +893,19 @@ function parseValueChild(s: ParserState): CssNode | null {
771893 return parseParentheses(s)
772894 }
773895 case TokenType.Comma: {
774 consume(s)
775 const node: OperatorNode = { type: 'Operator', value: ',', loc: loc(s, t, t) }
776 return node
896 s.pos++
897 return { type: 'Operator', value: ',', loc: s.positions ? locRange(s, tstart, tend) : null }
777898 }
778899 case TokenType.Colon: {
779 consume(s)
780 const node: OperatorNode = { type: 'Operator', value: ':', loc: loc(s, t, t) }
781 return node
900 s.pos++
901 return { type: 'Operator', value: ':', loc: s.positions ? locRange(s, tstart, tend) : null }
782902 }
783903 case TokenType.Delim: {
784 consume(s)
785 const ch = s.source[t.start]!
786 if (ch === '/' || ch === '+' || ch === '-' || ch === '*' || ch === '=' || ch === '>' || ch === '<' || ch === '~' || ch === '|' || ch === '$' || ch === '^' || ch === '!' || ch === '&') {
787 const node: OperatorNode = { type: 'Operator', value: ch, loc: loc(s, t, t) }
788 return node
789 }
790 const node: Identifier = { type: 'Identifier', name: ch, loc: loc(s, t, t) }
791 return node
904 s.pos++
905 const ch = src[tstart]!
906 if (ch === '/' || ch === '+' || ch === '-' || ch === '*' || ch === '=' || ch === '>' || ch === '<' || ch === '~' || ch === '|' || ch === '$' || ch === '^' || ch === '!' || ch === '&')
907 return { type: 'Operator', value: ch, loc: s.positions ? locRange(s, tstart, tend) : null }
908 return { type: 'Identifier', name: ch, loc: s.positions ? locRange(s, tstart, tend) : null }
792909 }
793910 case TokenType.AtKeyword:
794911 case TokenType.LeftSquareBracket:
@@ -802,14 +919,28 @@ function parseValueChild(s: ParserState): CssNode | null {
802919 case TokenType.CDC:
803920 case TokenType.BadString:
804921 case TokenType.BadUrl: {
805 consume(s)
806 return { type: 'Raw', value: tokenSlice(s, t), loc: loc(s, t, t) }
922 s.pos++
923 return { type: 'Raw', value: src.slice(tstart, tend), loc: s.positions ? locRange(s, tstart, tend) : null }
807924 }
808925 }
809 consume(s)
926 s.pos++
810927 return null
811928}
812929
930function isWhitespaceCode(c: number): boolean {
931 return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
932}
933
934function locRange(s: ParserState, startByte: number, endByte: number): CssLocation {
935 const start = s.tokenizer.locate(startByte)
936 const end = s.tokenizer.locate(endByte)
937 return {
938 source: s.filename ?? '<unknown>',
939 start: { offset: startByte, line: start.line, column: start.column },
940 end: { offset: endByte, line: end.line, column: end.column },
941 }
942}
943
813944function parseFunction(s: ParserState): CssNode {
814945 const startTok = consume(s) // function token (including `(`)
815946 const name = s.source.slice(startTok.start, startTok.end - 1).toLowerCase()
src/select/attributes.tsmodified+124-8
Changes to src/select/attributes.ts
@@ -5,11 +5,34 @@
55
66import type { AttributeSelectorNode } from '../what'
77import type { Adapter, CompiledQuery, Options } from './types'
8import { ALWAYS_TRUE } from './helpers/always-true'
89
910type Predicate<E> = (e: E) => boolean
1011
11function escapeRegExp(s: string): string {
12 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
12function isWsCode(c: number): boolean {
13 return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
14}
15
16/**
17 * Whitespace-separated token containment — equivalent to the `~=` operator
18 * (and the `.class` shorthand), but without the regex compile + RegExp.test
19 * overhead of a per-element check.
20 */
21function containsWsToken(haystack: string, needle: string): boolean {
22 const nlen = needle.length
23 const hlen = haystack.length
24 if (nlen === 0 || nlen > hlen)
25 return false
26 let idx = haystack.indexOf(needle)
27 while (idx >= 0) {
28 const before = idx === 0 || isWsCode(haystack.charCodeAt(idx - 1))
29 const afterIdx = idx + nlen
30 const after = afterIdx === hlen || isWsCode(haystack.charCodeAt(afterIdx))
31 if (before && after)
32 return true
33 idx = haystack.indexOf(needle, idx + 1)
34 }
35 return false
1336}
1437
1538function caseInsensitive(value: string, attr: AttributeSelectorNode, options: Options<unknown, any>): boolean {
@@ -45,16 +68,35 @@ function wrap<Node, ElementNode extends Node>(
4568 adapter: Adapter<Node, ElementNode>,
4669 next: CompiledQuery<ElementNode>,
4770): CompiledQuery<ElementNode> {
71 const isLeaf = next === ALWAYS_TRUE
4872 switch (action) {
4973 case 'exists':
74 if (isLeaf)
75 return e => adapter.hasAttrib(e, name)
5076 return e => adapter.hasAttrib(e, name) && next(e)
5177
5278 case 'equals':
79 if (!ci) {
80 // String equality with `null`/`undefined` already short-circuits to
81 // false, so we can skip the explicit null guard — matches
82 // css-select's equality fast path.
83 if (isLeaf)
84 return e => adapter.getAttributeValue(e, name) === value
85 return e => adapter.getAttributeValue(e, name) === value && next(e)
86 }
87 if (isLeaf) {
88 return (e) => {
89 const v = adapter.getAttributeValue(e, name)
90 if (v == null)
91 return false
92 return v.toLowerCase() === value
93 }
94 }
5395 return (e) => {
5496 const v = adapter.getAttributeValue(e, name)
5597 if (v == null)
5698 return false
57 return (ci ? v.toLowerCase() : v) === value && next(e)
99 return v.toLowerCase() === value && next(e)
58100 }
59101
60102 case 'not':
@@ -68,6 +110,14 @@ function wrap<Node, ElementNode extends Node>(
68110 case 'start':
69111 if (value === '')
70112 return () => false
113 if (isLeaf) {
114 return (e) => {
115 const v = adapter.getAttributeValue(e, name)
116 if (v == null)
117 return false
118 return (ci ? v.toLowerCase() : v).startsWith(value)
119 }
120 }
71121 return (e) => {
72122 const v = adapter.getAttributeValue(e, name)
73123 if (v == null)
@@ -78,6 +128,14 @@ function wrap<Node, ElementNode extends Node>(
78128 case 'end':
79129 if (value === '')
80130 return () => false
131 if (isLeaf) {
132 return (e) => {
133 const v = adapter.getAttributeValue(e, name)
134 if (v == null)
135 return false
136 return (ci ? v.toLowerCase() : v).endsWith(value)
137 }
138 }
81139 return (e) => {
82140 const v = adapter.getAttributeValue(e, name)
83141 if (v == null)
@@ -88,29 +146,87 @@ function wrap<Node, ElementNode extends Node>(
88146 case 'any': {
89147 if (value === '')
90148 return () => false
91 const re = new RegExp(escapeRegExp(value), ci ? 'i' : '')
149 if (ci) {
150 if (isLeaf) {
151 return (e) => {
152 const v = adapter.getAttributeValue(e, name)
153 if (v == null)
154 return false
155 return v.toLowerCase().indexOf(value) >= 0
156 }
157 }
158 return (e) => {
159 const v = adapter.getAttributeValue(e, name)
160 if (v == null)
161 return false
162 return v.toLowerCase().indexOf(value) >= 0 && next(e)
163 }
164 }
165 if (isLeaf) {
166 return (e) => {
167 const v = adapter.getAttributeValue(e, name)
168 if (v == null)
169 return false
170 return v.indexOf(value) >= 0
171 }
172 }
92173 return (e) => {
93174 const v = adapter.getAttributeValue(e, name)
94175 if (v == null)
95176 return false
96 return re.test(v) && next(e)
177 return v.indexOf(value) >= 0 && next(e)
97178 }
98179 }
99180
100181 case 'element': {
101 // ~= "value" — matches when whitespace-separated list contains value
102182 if (value === '' || /\s/.test(value))
103183 return () => false
104 const wsRe = new RegExp(`(?:^|\\s)${escapeRegExp(value)}(?:$|\\s)`, ci ? 'i' : '')
184 if (ci) {
185 if (isLeaf) {
186 return (e) => {
187 const v = adapter.getAttributeValue(e, name)
188 if (v == null)
189 return false
190 return containsWsToken(v.toLowerCase(), value)
191 }
192 }
193 return (e) => {
194 const v = adapter.getAttributeValue(e, name)
195 if (v == null)
196 return false
197 return containsWsToken(v.toLowerCase(), value) && next(e)
198 }
199 }
200 if (isLeaf) {
201 return (e) => {
202 const v = adapter.getAttributeValue(e, name)
203 if (v == null)
204 return false
205 return containsWsToken(v, value)
206 }
207 }
105208 return (e) => {
106209 const v = adapter.getAttributeValue(e, name)
107210 if (v == null)
108211 return false
109 return wsRe.test(v) && next(e)
212 return containsWsToken(v, value) && next(e)
110213 }
111214 }
112215
113216 case 'hyphen':
217 if (isLeaf) {
218 return (e) => {
219 const v = adapter.getAttributeValue(e, name)
220 if (v == null)
221 return false
222 const lower = ci ? v.toLowerCase() : v
223 if (lower === value)
224 return true
225 if (lower.startsWith(value) && lower.charAt(value.length) === '-')
226 return true
227 return false
228 }
229 }
114230 return (e) => {
115231 const v = adapter.getAttributeValue(e, name)
116232 if (v == null)
src/select/general.tsmodified+12-0
Changes to src/select/general.ts
@@ -2,24 +2,36 @@
22 * Compile a *simple* selector token (tag/universal/id/class/attr/pseudo)
33 * into a predicate. Combinators (descendant/child/sibling/adjacent/parent)
44 * are handled at a higher level — see pseudo-selectors/selectors.ts.
5 *
6 * `next` is the predicate for the rest of the compound chain to the right
7 * of this token. When the caller has nothing further to test, it passes the
8 * shared `ALWAYS_TRUE` sentinel — each branch detects that and returns a
9 * leaf predicate without the trailing `&& next(e)` indirection. Skipping
10 * the no-op call shaves ~10 % off `selectAll` on tight loops.
511 */
612
713import type { Selector } from '../what'
814import type { CompiledQuery, Options } from './types'
915import { compileAttribute } from './attributes'
16import { ALWAYS_TRUE } from './helpers/always-true'
1017import { compilePseudo } from './pseudo-selectors'
1118
1219type Compiled<E> = CompiledQuery<E>
1320
21export { ALWAYS_TRUE }
22
1423export function compileToken<Node, ElementNode extends Node>(
1524 token: Selector,
1625 options: Options<Node, ElementNode>,
1726 next: Compiled<ElementNode>,
1827): Compiled<ElementNode> {
1928 const adapter = options.adapter
29 const isLeaf = next === ALWAYS_TRUE
2030 switch (token.type) {
2131 case 'tag': {
2232 const name = options.lowerCaseTags !== false && !options.xmlMode ? token.name.toLowerCase() : token.name
33 if (isLeaf)
34 return e => adapter.getName(e) === name
2335 return e => adapter.getName(e) === name && next(e)
2436 }
2537 case 'universal':
src/select/helpers/always-true.tsadded+7-0
Changes to src/select/helpers/always-true.ts
@@ -0,0 +1,7 @@
1/**
2 * Singleton "always-matches" predicate. Used as the leaf sentinel in
3 * compound-selector chains so each compileX factory can detect the leaf
4 * case and skip the trailing `&& next(e)` indirection.
5 */
6
7export const ALWAYS_TRUE: (_: any) => boolean = (_: any): boolean => { void _; return true }
src/select/helpers/querying.tsadded+91-0
Changes to src/select/helpers/querying.ts
@@ -0,0 +1,91 @@
1/**
2 * Iterative tree search for selectAll / selectOne. Mirrors `css-select`'s
3 * approach: use a stack of `(children, index)` pairs to walk the tree
4 * depth-first without recursion, avoiding the per-recursion frame and the
5 * `out.push(...spread)` allocations a naive recursive findAll incurs.
6 *
7 * We deliberately ignore `adapter.findAll` even when the consumer supplies
8 * one, because forcing every selectAll through the adapter's recursion is
9 * the single biggest cost on tree walks. Adapters are still consulted for
10 * `isTag` / `getChildren` / `getName`.
11 */
12
13import type { Adapter, CompiledQuery } from '../types'
14
15export function findAll<Node, ElementNode extends Node>(
16 query: CompiledQuery<ElementNode>,
17 nodes: ReadonlyArray<Node>,
18 adapter: Adapter<Node, ElementNode>,
19 xmlMode: boolean,
20): ElementNode[] {
21 const result: ElementNode[] = []
22 const nodeStack: ReadonlyArray<Node>[] = [nodes]
23 const indexStack: number[] = [0]
24 for (; ;) {
25 if (indexStack[0]! >= nodeStack[0]!.length) {
26 if (nodeStack.length === 1)
27 return result
28 nodeStack.shift()
29 indexStack.shift()
30 continue
31 }
32 const element = nodeStack[0]![indexStack[0]!++]!
33 if (!adapter.isTag(element))
34 continue
35 if (query(element as unknown as ElementNode))
36 result.push(element as unknown as ElementNode)
37 if (xmlMode || adapter.getName(element as unknown as ElementNode) !== 'template') {
38 const children = adapter.getChildren(element as unknown as ElementNode) as Node[]
39 if (children.length > 0) {
40 nodeStack.unshift(children)
41 indexStack.unshift(0)
42 }
43 }
44 }
45}
46
47export function findOne<Node, ElementNode extends Node>(
48 query: CompiledQuery<ElementNode>,
49 nodes: ReadonlyArray<Node>,
50 adapter: Adapter<Node, ElementNode>,
51 xmlMode: boolean,
52): ElementNode | null {
53 const nodeStack: ReadonlyArray<Node>[] = [nodes]
54 const indexStack: number[] = [0]
55 for (; ;) {
56 if (indexStack[0]! >= nodeStack[0]!.length) {
57 if (nodeStack.length === 1)
58 return null
59 nodeStack.shift()
60 indexStack.shift()
61 continue
62 }
63 const element = nodeStack[0]![indexStack[0]!++]!
64 if (!adapter.isTag(element))
65 continue
66 if (query(element as unknown as ElementNode))
67 return element as unknown as ElementNode
68 if (xmlMode || adapter.getName(element as unknown as ElementNode) !== 'template') {
69 const children = adapter.getChildren(element as unknown as ElementNode) as Node[]
70 if (children.length > 0) {
71 nodeStack.unshift(children)
72 indexStack.unshift(0)
73 }
74 }
75 }
76}
77
78/**
79 * Convert the user-supplied root into a search context. Mirrors
80 * `css-select`'s `prepareContext` — a single root node is replaced by its
81 * children (so the root itself isn't a candidate); arrays go through
82 * `removeSubsets` to avoid testing nested selections twice.
83 */
84export function prepareContext<Node, ElementNode extends Node>(
85 elements: Node | Node[],
86 adapter: Adapter<Node, ElementNode>,
87): ReadonlyArray<Node> {
88 if (Array.isArray(elements))
89 return adapter.removeSubsets(elements)
90 return adapter.getChildren(elements as unknown as ElementNode) as Node[]
91}
src/select/index.tsmodified+5-4
Changes to src/select/index.ts
@@ -13,6 +13,7 @@
1313import type { Selector } from '../what'
1414import type { Adapter, CompiledQuery, Options } from './types'
1515import { parse } from '../what'
16import { findAll as ourFindAll, findOne as ourFindOne, prepareContext } from './helpers/querying'
1617import { compileGeneric, parseAndCompile } from './pseudo-selectors/selectors'
1718
1819function getAdapter<Node, ElementNode extends Node>(options: Options<Node, ElementNode>): Adapter<Node, ElementNode> {
@@ -97,8 +98,8 @@ export function selectAll<Node, ElementNode extends Node>(
9798): ElementNode[] {
9899 const adapter = getAdapter(options)
99100 const test = typeof selector === 'function' ? selector : compile(selector, options)
100 const elems = Array.isArray(root) ? root : [root]
101 return adapter.findAll(test, elems)
101 const ctx = prepareContext(root, adapter)
102 return ourFindAll(test, ctx, adapter, options.xmlMode === true)
102103}
103104
104105export function selectOne<Node, ElementNode extends Node>(
@@ -108,8 +109,8 @@ export function selectOne<Node, ElementNode extends Node>(
108109): ElementNode | null {
109110 const adapter = getAdapter(options)
110111 const test = typeof selector === 'function' ? selector : compile(selector, options)
111 const elems = Array.isArray(root) ? root : [root]
112 return adapter.findOne(test, elems)
112 const ctx = prepareContext(root, adapter)
113 return ourFindOne(test, ctx, adapter, options.xmlMode === true)
113114}
114115
115116export function is<Node, ElementNode extends Node>(
src/select/pseudo-selectors/selectors.tsmodified+76-76
Changes to src/select/pseudo-selectors/selectors.ts
@@ -2,22 +2,53 @@
22 * Compose a token list into a test function.
33 *
44 * Tokens carry simple selectors (tag/class/attr/pseudo) interleaved with
5 * combinators (descendant/child/sibling/adjacent). The final predicate
6 * tests the SUBJECT — the rightmost compound — and each preceding compound
7 * becomes an "ancestor"-style check applied via the combinator.
5 * combinators (descendant/child/sibling/adjacent). Each token becomes one
6 * link in a chain — combinators wrap the running chain so it tests the
7 * left-hand chain on a new pivot. We iterate left-to-right (matching
8 * `css-select`'s compile model) but sort tokens *within each compound* by
9 * procedure cost so cheap tests (tag) wind up outermost and fail-fast.
810 */
911
10import type { Selector } from '../../what'
12import type { AttributeSelectorNode, Selector } from '../../what'
1113import type { Adapter, CompiledQuery, Options } from '../types'
12import { isTraversal } from '../../what'
1314import { compileToken } from '../general'
15import { ALWAYS_TRUE } from '../helpers/always-true'
1416
15const ALWAYS_TRUE = (_: any): boolean => { void _; return true }
17// Lower = more expensive → wrapped innermost → runs last.
18// Higher = cheaper → wrapped outermost → runs first (lets us fail-fast).
19const PROCEDURE: Record<string, number> = {
20 universal: 50,
21 tag: 30,
22 attribute: 1,
23 pseudo: 0,
24 'pseudo-element': 0,
25}
26
27// Within `attribute`, equality is cheaper than substring/regex.
28const ATTRIBUTE_PROCEDURE: Record<string, number> = {
29 exists: 10,
30 equals: 8,
31 not: 7,
32 start: 6,
33 end: 6,
34 hyphen: 5,
35 any: 4,
36 element: 3,
37}
38
39function tokenProcedure(t: Selector): number {
40 const base = PROCEDURE[t.type] ?? 0
41 if (t.type === 'attribute')
42 return base + (ATTRIBUTE_PROCEDURE[(t as AttributeSelectorNode).action] ?? 0)
43 return base
44}
1645
1746export function compileGeneric<Node, ElementNode extends Node>(
1847 segments: Selector[][],
1948 options: Options<Node, ElementNode>,
2049): CompiledQuery<ElementNode> {
50 if (segments.length === 1)
51 return parseAndCompile(segments[0]!, options)
2152 const compiled = segments.map(seg => parseAndCompile(seg, options))
2253 return e => compiled.some(c => c(e))
2354}
@@ -26,113 +57,82 @@ export function parseAndCompile<Node, ElementNode extends Node>(
2657 tokens: Selector[],
2758 options: Options<Node, ElementNode>,
2859): CompiledQuery<ElementNode> {
29 // Split tokens into compound groups separated by traversal combinators.
30 const groups: Array<{ simple: Selector[], comb: Selector }> = []
31 let cur: Selector[] = []
60 const adapter = options.adapter
61 // Split into compound buckets; combinator markers stay in `combs`.
62 const compounds: Selector[][] = [[]]
63 const combs: Selector[] = []
3264 for (const t of tokens) {
33 if (isTraversal(t)) {
34 groups.push({ simple: cur, comb: t })
35 cur = []
65 if (
66 t.type === 'descendant' || t.type === 'child' || t.type === 'sibling'
67 || t.type === 'adjacent' || t.type === 'parent' || t.type === 'column-combinator'
68 ) {
69 combs.push(t)
70 compounds.push([])
3671 }
3772 else {
38 cur.push(t)
73 compounds[compounds.length - 1]!.push(t)
3974 }
4075 }
41 const subjectSimple = cur
42
43 // Build the "subject" predicate (matches the element itself).
44 let test = makeCompoundTest(subjectSimple, options)
4576
46 // For each preceding compound, wrap with the corresponding combinator.
47 for (let i = groups.length - 1; i >= 0; i--) {
48 const { simple, comb } = groups[i]!
49 const ancestor = makeCompoundTest(simple, options)
50 test = wrapCombinator(comb, ancestor, test, options.adapter)
77 // Build left-to-right. Within each compound, sort by procedure ascending
78 // so the cheapest test ends up outermost (runs first).
79 let func: CompiledQuery<ElementNode> = ALWAYS_TRUE
80 for (let ci = 0; ci < compounds.length; ci++) {
81 const compound = compounds[ci]!.slice().sort((a, b) => tokenProcedure(a) - tokenProcedure(b))
82 for (const t of compound)
83 func = compileToken(t, options, func)
84 if (ci < combs.length)
85 func = wrapCombinator(combs[ci]!, func, adapter)
5186 }
52 return test
53}
54
55function makeCompoundTest<Node, ElementNode extends Node>(
56 simples: Selector[],
57 options: Options<Node, ElementNode>,
58): CompiledQuery<ElementNode> {
59 let chain: CompiledQuery<ElementNode> = ALWAYS_TRUE
60 for (let i = simples.length - 1; i >= 0; i--)
61 chain = compileToken(simples[i]!, options, chain)
62 return chain
87 return func
6388}
6489
6590function wrapCombinator<Node, ElementNode extends Node>(
6691 comb: Selector,
67 ancestor: CompiledQuery<ElementNode>,
68 subject: CompiledQuery<ElementNode>,
92 after: CompiledQuery<ElementNode>,
6993 adapter: Adapter<Node, ElementNode>,
7094): CompiledQuery<ElementNode> {
7195 switch (comb.type) {
7296 case 'descendant':
73 return (e) => {
74 if (!subject(e))
75 return false
76 let p: any = adapter.getParent(e)
97 case 'column-combinator':
98 return (elem: any) => {
99 let p: any = adapter.getParent(elem)
77100 while (p) {
78 if (adapter.isTag(p) && ancestor(p))
101 if (adapter.isTag(p) && after(p))
79102 return true
80103 p = adapter.getParent(p)
81104 }
82105 return false
83106 }
84107 case 'child':
85 return (e) => {
86 if (!subject(e))
87 return false
88 const p = adapter.getParent(e)
89 return p != null && adapter.isTag(p) && ancestor(p)
90 }
91 case 'parent':
92 return (e) => {
93 if (!subject(e))
94 return false
95 return adapter.existsOne(ancestor, adapter.getChildren(e))
108 return (elem: any) => {
109 const p: any = adapter.getParent(elem)
110 return p != null && adapter.isTag(p) && after(p)
96111 }
97112 case 'sibling':
98 return (e) => {
99 if (!subject(e))
100 return false
101 const sibs = adapter.getSiblings(e)
113 return (elem: any) => {
114 const sibs: any[] = adapter.getSiblings(elem) as any
102115 for (const s of sibs) {
103 if (s === e)
116 if (s === elem)
104117 return false
105 if (adapter.isTag(s) && ancestor(s))
118 if (adapter.isTag(s) && after(s))
106119 return true
107120 }
108121 return false
109122 }
110123 case 'adjacent':
111 return (e) => {
112 if (!subject(e))
113 return false
114 const sibs = adapter.getSiblings(e)
124 return (elem: any) => {
125 const sibs: any[] = adapter.getSiblings(elem) as any
115126 let prev: any = null
116127 for (const s of sibs) {
117 if (s === e)
118 return prev != null && adapter.isTag(prev) && ancestor(prev)
128 if (s === elem)
129 return prev != null && adapter.isTag(prev) && after(prev)
119130 prev = s
120131 }
121132 return false
122133 }
123 case 'column-combinator':
124 // Approximate as descendant — we don't model table columns.
125 return (e) => {
126 if (!subject(e))
127 return false
128 let p: any = adapter.getParent(e)
129 while (p) {
130 if (adapter.isTag(p) && ancestor(p))
131 return true
132 p = adapter.getParent(p)
133 }
134 return false
135 }
134 case 'parent':
135 return (elem: any) => adapter.existsOne(after, adapter.getChildren(elem))
136136 }
137137 return _ => { void _; return false }
138138}
src/what/parse.tsmodified+282-254
Changes to src/what/parse.ts
@@ -8,7 +8,9 @@
88
99import type { ParseOptions, Selector } from './types'
1010
11const RE_NAME = /^(?:\\(?:[\dA-Fa-f]{1,6} ?|[^])|[\w\-°-￿])+/
11// Sticky regexes: `y` lets us match from a specific index without slicing.
12// Using `lastIndex` directly keeps the parser allocation-light per name.
13const RE_NAME_STICKY = /(?:\\(?:[\dA-Fa-f]{1,6} ?|[^])|[\w\-°-￿])+/y
1214const RE_ESCAPE = /\\([\dA-Fa-f]{1,6} ?|[^])/g
1315
1416function unescape(name: string): string {
@@ -23,13 +25,10 @@ function unescape(name: string): string {
2325 })
2426}
2527
26const ACTIONS: Record<string, string> = {
27 '~': 'element',
28 '^': 'start',
29 '$': 'end',
30 '*': 'any',
31 '!': 'not',
32 '|': 'hyphen',
28function unescapeIfNeeded(name: string): string {
29 // Vast majority of CSS selector identifiers have no `\\` escapes — skip
30 // the regex replace pipeline and return the string directly.
31 return name.indexOf('\\') < 0 ? name : unescape(name)
3332}
3433
3534const ATTRIBUTES_QUIRKS = new Set([
@@ -42,298 +41,323 @@ const ATTRIBUTES_QUIRKS = new Set([
4241 'type', 'valign', 'valuetype', 'vlink',
4342])
4443
44// Action-string lookup as a plain function (no per-parse Map).
45function actionFromChar(ch: number): string | null {
46 switch (ch) {
47 case 126 /* ~ */: return 'element'
48 case 94 /* ^ */: return 'start'
49 case 36 /* $ */: return 'end'
50 case 42 /* * */: return 'any'
51 case 33 /* ! */: return 'not'
52 case 124 /* | */: return 'hyphen'
53 default: return null
54 }
55}
56
57function isWsCode(c: number): boolean {
58 return c === 32 || c === 9 || c === 10 || c === 13 || c === 12
59}
60
4561export function parse(selector: string, options: ParseOptions = {}): Selector[][] {
4662 const subselects: Selector[][] = []
47 const endIndex = parseSelector(subselects, `${selector}`, options, 0)
63 const endIndex = parseSelectorImpl(subselects, selector, options, 0)
4864 if (endIndex < selector.length)
4965 throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`)
5066 return subselects
5167}
5268
53function parseSelector(subselects: Selector[][], selector: string, options: ParseOptions, selectorIndex: number): number {
54 let tokens: Selector[] = []
55
56 function getName(offset: number): string {
57 const sub = selector.slice(selectorIndex + offset)
58 const m = RE_NAME.exec(sub)
59 if (!m)
60 throw new Error(`Expected name, found ${sub}`)
61 selectorIndex += offset + m[0].length
62 return unescape(m[0])
63 }
69function readName(selector: string, from: number): { value: string, end: number } {
70 RE_NAME_STICKY.lastIndex = from
71 const m = RE_NAME_STICKY.exec(selector)
72 if (!m)
73 throw new Error(`Expected name, found ${selector.slice(from)}`)
74 return { value: unescapeIfNeeded(m[0]), end: from + m[0].length }
75}
6476
65 function stripWS(start: number): void {
66 while (selectorIndex + start < selector.length && isWhitespace(selector.charAt(selectorIndex + start)))
67 start++
68 selectorIndex += start
69 }
77function stripWS(selector: string, from: number): number {
78 while (from < selector.length && isWsCode(selector.charCodeAt(from)))
79 from++
80 return from
81}
7082
71 function isEscaped(pos: number): boolean {
72 let backslashes = 0
73 while (selector.charAt(--pos) === '\\') backslashes++
74 return (backslashes & 1) === 1
75 }
83function parseSelectorImpl(subselects: Selector[][], selector: string, options: ParseOptions, startIndex: number): number {
84 let tokens: Selector[] = []
85 let i = stripWS(selector, startIndex)
86 const len = selector.length
87 const xmlMode = options.xmlMode === true
88 const lowerCaseAttrs = options.lowerCaseAttributeNames !== false && !xmlMode
89 const lowerCaseTagsFlag = options.lowerCaseTags !== false
7690
77 stripWS(0)
78 while (selector !== '') {
79 const firstChar = selector.charAt(selectorIndex)
91 while (i < len) {
92 const code = selector.charCodeAt(i)
8093
81 if (isWhitespace(firstChar)) {
82 let trimmed = 1
83 while (isWhitespace(selector.charAt(selectorIndex + trimmed)))
94 // whitespace → descendant combinator (or just trim if at start)
95 if (isWsCode(code)) {
96 let trimmed = i + 1
97 while (trimmed < len && isWsCode(selector.charCodeAt(trimmed)))
8498 trimmed++
8599 if (tokens.length === 0)
86 return selectorIndex
87 stripWS(trimmed)
100 return trimmed
101 i = trimmed
88102 addTraversal(tokens, 'descendant')
103 continue
89104 }
90 else if (firstChar === '>' || firstChar === '<' || firstChar === '~' || firstChar === '+' || firstChar === '|') {
91 // combinator
92 while (selectorIndex < selector.length && isWhitespace(selector.charAt(selectorIndex + 1)))
93 selectorIndex++
94 stripWS(1)
95 switch (firstChar) {
96 case '>': addTraversal(tokens, 'child'); break
97 case '<': addTraversal(tokens, 'parent'); break
98 case '~': addTraversal(tokens, 'sibling'); break
99 case '+': addTraversal(tokens, 'adjacent'); break
100 case '|':
101 if (selector.charAt(selectorIndex) === '|') {
102 selectorIndex++
103 stripWS(0)
105
106 // explicit combinators
107 if (code === 62 /* > */ || code === 60 /* < */ || code === 126 /* ~ */ || code === 43 /* + */ || code === 124 /* | */) {
108 let j = i + 1
109 while (j < len && isWsCode(selector.charCodeAt(j))) j++
110 i = j
111 switch (code) {
112 case 62: addTraversal(tokens, 'child'); break
113 case 60: addTraversal(tokens, 'parent'); break
114 case 126: addTraversal(tokens, 'sibling'); break
115 case 43: addTraversal(tokens, 'adjacent'); break
116 case 124:
117 if (i < len && selector.charCodeAt(i) === 124) {
118 i++
119 i = stripWS(selector, i)
104120 addTraversal(tokens, 'column-combinator')
105121 }
106122 else {
107 // namespace-only — treat as part of next tag
108123 tokens.push({ type: 'tag', name: '', namespace: '' })
109124 }
110125 break
111126 }
127 continue
112128 }
113 else if (firstChar === ',') {
129
130 if (code === 44 /* , */) {
114131 if (tokens.length === 0)
115132 throw new Error('Empty sub-selector')
116133 subselects.push(tokens)
117134 tokens = []
118 stripWS(1)
135 i = stripWS(selector, i + 1)
136 continue
119137 }
120 else if (selector.startsWith('/*', selectorIndex)) {
121 const end = selector.indexOf('*/', selectorIndex + 2)
138
139 // comment /* ... */
140 if (code === 47 /* / */ && selector.charCodeAt(i + 1) === 42 /* * */) {
141 const end = selector.indexOf('*/', i + 2)
122142 if (end < 0)
123143 throw new Error('Unmatched comment')
124 selectorIndex = end + 2
125 stripWS(0)
144 i = stripWS(selector, end + 2)
145 continue
126146 }
127 else {
128 if (firstChar === '*') {
129 selectorIndex++
130 tokens.push({ type: 'universal', namespace: null })
131 }
132 else if ('><~+'.includes(firstChar)) {
133 // shouldn't reach
134 }
135 else if (firstChar === '#') {
136 const name = getName(1)
137 tokens.push({
138 type: 'attribute',
139 name: 'id',
140 action: 'equals',
141 value: name,
142 namespace: null,
143 ignoreCase: false,
144 })
145 }
146 else if (firstChar === '.') {
147 const name = getName(1)
148 tokens.push({
149 type: 'attribute',
150 name: 'class',
151 action: 'element',
152 value: name,
153 namespace: null,
154 ignoreCase: false,
155 })
156 }
157 else if (firstChar === '[') {
158 // attribute selector
159 const sub = selector.slice(selectorIndex + 1)
160 let attribute: string
161 let nameEnd = 0
162 // namespace-prefixed?
163 if (sub.charAt(0) === '|') {
164 throw new Error('Empty namespace not supported')
165 }
166 else if (sub.charAt(0) === '*' && sub.charAt(1) === '|') {
167 nameEnd = 2
168 attribute = getName(3)
169 }
170 else {
171 // just name
172 const m = RE_NAME.exec(sub)
173 if (!m)
174 throw new Error(`Expected attribute name, got ${sub}`)
175 nameEnd = m[0].length
176 attribute = unescape(m[0])
177 selectorIndex += 1 + nameEnd
178 // namespace?
179 if (selector.charAt(selectorIndex) === '|' && selector.charAt(selectorIndex + 1) !== '=') {
180 selectorIndex++
181 const m2 = RE_NAME.exec(selector.slice(selectorIndex))
182 if (!m2)
183 throw new Error('expected attr name after namespace')
184 selectorIndex += m2[0].length
185 attribute = unescape(m2[0])
186 }
187 }
188 stripWS(0)
189 let action: 'exists' | 'equals' | 'element' | 'start' | 'end' | 'any' | 'not' | 'hyphen' = 'exists'
190 let value = ''
191 let ignoreCase: boolean | null = null
192 const ch = selector.charAt(selectorIndex)
193 if (ch === '=') {
194 action = 'equals'
195 selectorIndex++
196 }
197 else if (ch === '!' && selector.charAt(selectorIndex + 1) === '=') {
198 action = 'not'
199 selectorIndex += 2
200 }
201 else if ((ACTIONS as any)[ch] && selector.charAt(selectorIndex + 1) === '=') {
202 action = (ACTIONS as any)[ch]
203 selectorIndex += 2
204 }
205 if (action !== 'exists') {
206 stripWS(0)
207 const q = selector.charAt(selectorIndex)
208 if (q === '"' || q === '\'') {
209 const end = findEndOfString(selector, selectorIndex + 1, q)
210 value = unescape(selector.slice(selectorIndex + 1, end))
211 selectorIndex = end + 1
212 }
213 else {
214 const m3 = RE_NAME.exec(selector.slice(selectorIndex))
215 if (!m3)
216 throw new Error('expected value')
217 value = unescape(m3[0])
218 selectorIndex += m3[0].length
219 }
220 stripWS(0)
221 // case flag
222 const flag = selector.charAt(selectorIndex)
223 if (flag === 'i' || flag === 'I') {
224 ignoreCase = true
225 selectorIndex++
226 }
227 else if (flag === 's' || flag === 'S') {
228 ignoreCase = false
229 selectorIndex++
230 }
231 }
232 if (selector.charAt(selectorIndex) !== ']')
233 throw new Error('Expected ]')
234 selectorIndex++
235 if (ignoreCase === null) {
236 // quirks-mode default for HTML attributes
237 if (!options.xmlMode && ATTRIBUTES_QUIRKS.has(attribute.toLowerCase()))
238 ignoreCase = 'quirks' as any
239 }
240 tokens.push({
241 type: 'attribute',
242 name: options.lowerCaseAttributeNames !== false && !options.xmlMode ? attribute.toLowerCase() : attribute,
243 action,
244 value,
245 namespace: null,
246 ignoreCase,
247 })
248 }
249 else if (firstChar === ':') {
250 if (selector.charAt(selectorIndex + 1) === ':') {
251 // pseudo-element
252 selectorIndex += 2
253 const name = getName(0).toLowerCase()
254 let data: string | null = null
255 if (selector.charAt(selectorIndex) === '(') {
256 const end = findClose(selector, selectorIndex)
257 data = selector.slice(selectorIndex + 1, end).trim()
258 selectorIndex = end + 1
259 }
260 tokens.push({ type: 'pseudo-element', name, data })
261 }
262 else {
263 selectorIndex += 1
264 const name = getName(0).toLowerCase()
265 if (selector.charAt(selectorIndex) === '(') {
266 const end = findClose(selector, selectorIndex)
267 const inner = selector.slice(selectorIndex + 1, end)
268 selectorIndex = end + 1
269 // selector-list pseudos
270 if (name === 'is' || name === 'not' || name === 'where' || name === 'has' || name === 'matches' || name === '-moz-any' || name === '-webkit-any') {
271 const sub: Selector[][] = []
272 parseSelector(sub, inner.trim(), options, 0)
273 tokens.push({ type: 'pseudo', name, data: sub })
274 }
275 else {
276 tokens.push({ type: 'pseudo', name, data: inner.trim() })
277 }
278 }
279 else {
280 tokens.push({ type: 'pseudo', name, data: null })
281 }
282 }
147
148 if (code === 42 /* * */) {
149 i++
150 tokens.push({ type: 'universal', namespace: null })
151 continue
152 }
153
154 if (code === 35 /* # */) {
155 const r = readName(selector, i + 1)
156 i = r.end
157 tokens.push({
158 type: 'attribute',
159 name: 'id',
160 action: 'equals',
161 value: r.value,
162 namespace: null,
163 ignoreCase: false,
164 })
165 continue
166 }
167
168 if (code === 46 /* . */) {
169 const r = readName(selector, i + 1)
170 i = r.end
171 tokens.push({
172 type: 'attribute',
173 name: 'class',
174 action: 'element',
175 value: r.value,
176 namespace: null,
177 ignoreCase: false,
178 })
179 continue
180 }
181
182 if (code === 91 /* [ */) {
183 i = parseAttribute(selector, i, tokens, options, xmlMode, lowerCaseAttrs)
184 continue
185 }
186
187 if (code === 58 /* : */) {
188 i = parsePseudo(selector, i, tokens, options)
189 continue
190 }
191
192 // tag selector — readName then optional `|tag`
193 if (code === 124 /* | */) {
194 i++
195 const r = readName(selector, i)
196 i = r.end
197 tokens.push({ type: 'tag', name: lowerCaseTagsFlag ? r.value.toLowerCase() : r.value, namespace: '' })
198 continue
199 }
200
201 {
202 const r1 = readName(selector, i)
203 i = r1.end
204 if (i < len && selector.charCodeAt(i) === 124 /* | */ && selector.charCodeAt(i + 1) !== 61 /* = */) {
205 i++
206 const r2 = readName(selector, i)
207 i = r2.end
208 tokens.push({ type: 'tag', name: lowerCaseTagsFlag ? r2.value.toLowerCase() : r2.value, namespace: r1.value })
283209 }
284210 else {
285 // tag selector
286 let name = ''
287 if (firstChar === '|') {
288 // |tag — no namespace
289 selectorIndex++
290 name = getName(0)
291 tokens.push({ type: 'tag', name: options.lowerCaseTags !== false ? name.toLowerCase() : name, namespace: '' })
292 }
293 else {
294 name = getName(0)
295 if (selector.charAt(selectorIndex) === '|') {
296 selectorIndex++
297 const tag = getName(0)
298 tokens.push({ type: 'tag', name: options.lowerCaseTags !== false ? tag.toLowerCase() : tag, namespace: name })
299 }
300 else {
301 tokens.push({ type: 'tag', name: options.lowerCaseTags !== false ? name.toLowerCase() : name, namespace: null })
302 }
303 }
211 tokens.push({ type: 'tag', name: lowerCaseTagsFlag ? r1.value.toLowerCase() : r1.value, namespace: null })
304212 }
305213 }
306 if (selectorIndex >= selector.length)
307 break
308214 }
309215 if (tokens.length > 0)
310216 subselects.push(tokens)
311 return selectorIndex
217 return i
312218}
313219
314function isWhitespace(c: string): boolean {
315 return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'
220function parseAttribute(selector: string, idx: number, tokens: Selector[], options: ParseOptions, xmlMode: boolean, lowerCaseAttrs: boolean): number {
221 let i = idx + 1
222 const len = selector.length
223 let attribute: string
224 if (selector.charCodeAt(i) === 124 /* | */)
225 throw new Error('Empty namespace not supported')
226 if (selector.charCodeAt(i) === 42 /* * */ && selector.charCodeAt(i + 1) === 124 /* | */) {
227 i += 2
228 const r = readName(selector, i)
229 i = r.end
230 attribute = r.value
231 }
232 else {
233 const r = readName(selector, i)
234 i = r.end
235 attribute = r.value
236 if (selector.charCodeAt(i) === 124 /* | */ && selector.charCodeAt(i + 1) !== 61 /* = */) {
237 i++
238 const r2 = readName(selector, i)
239 i = r2.end
240 attribute = r2.value
241 }
242 }
243 i = stripWS(selector, i)
244 let action: 'exists' | 'equals' | 'element' | 'start' | 'end' | 'any' | 'not' | 'hyphen' = 'exists'
245 let value = ''
246 let ignoreCase: boolean | null = null
247 const opCode = selector.charCodeAt(i)
248 if (opCode === 61 /* = */) {
249 action = 'equals'
250 i++
251 }
252 else if (opCode === 33 /* ! */ && selector.charCodeAt(i + 1) === 61) {
253 action = 'not'
254 i += 2
255 }
256 else {
257 const a = actionFromChar(opCode)
258 if (a !== null && selector.charCodeAt(i + 1) === 61) {
259 action = a as any
260 i += 2
261 }
262 }
263 if (action !== 'exists') {
264 i = stripWS(selector, i)
265 const q = selector.charCodeAt(i)
266 if (q === 34 /* " */ || q === 39 /* ' */) {
267 const end = findEndOfString(selector, i + 1, q)
268 value = unescapeIfNeeded(selector.slice(i + 1, end))
269 i = end + 1
270 }
271 else {
272 const r = readName(selector, i)
273 value = r.value
274 i = r.end
275 }
276 i = stripWS(selector, i)
277 const flag = selector.charCodeAt(i)
278 if (flag === 105 /* i */ || flag === 73 /* I */) {
279 ignoreCase = true
280 i++
281 }
282 else if (flag === 115 /* s */ || flag === 83 /* S */) {
283 ignoreCase = false
284 i++
285 }
286 }
287 if (selector.charCodeAt(i) !== 93 /* ] */)
288 throw new Error('Expected ]')
289 i++
290 if (ignoreCase === null && !xmlMode && ATTRIBUTES_QUIRKS.has(attribute.toLowerCase()))
291 ignoreCase = 'quirks' as any
292 void options
293 void len
294 tokens.push({
295 type: 'attribute',
296 name: lowerCaseAttrs ? attribute.toLowerCase() : attribute,
297 action,
298 value,
299 namespace: null,
300 ignoreCase,
301 })
302 return i
303}
304
305function parsePseudo(selector: string, idx: number, tokens: Selector[], options: ParseOptions): number {
306 if (selector.charCodeAt(idx + 1) === 58 /* : */) {
307 let i = idx + 2
308 const r = readName(selector, i)
309 i = r.end
310 const name = r.value.toLowerCase()
311 let data: string | null = null
312 if (selector.charCodeAt(i) === 40 /* ( */) {
313 const end = findClose(selector, i)
314 data = selector.slice(i + 1, end).trim()
315 i = end + 1
316 }
317 tokens.push({ type: 'pseudo-element', name, data })
318 return i
319 }
320 let i = idx + 1
321 const r = readName(selector, i)
322 i = r.end
323 const name = r.value.toLowerCase()
324 if (selector.charCodeAt(i) === 40 /* ( */) {
325 const end = findClose(selector, i)
326 const inner = selector.slice(i + 1, end)
327 i = end + 1
328 if (name === 'is' || name === 'not' || name === 'where' || name === 'has' || name === 'matches' || name === '-moz-any' || name === '-webkit-any') {
329 const sub: Selector[][] = []
330 parseSelectorImpl(sub, inner.trim(), options, 0)
331 tokens.push({ type: 'pseudo', name, data: sub })
332 }
333 else {
334 tokens.push({ type: 'pseudo', name, data: inner.trim() })
335 }
336 }
337 else {
338 tokens.push({ type: 'pseudo', name, data: null })
339 }
340 return i
316341}
317342
318343function addTraversal(tokens: Selector[], type: 'adjacent' | 'child' | 'descendant' | 'parent' | 'sibling' | 'column-combinator'): void {
319 // Replace trailing descendant if a stronger combinator follows
320 if (tokens.length > 0 && tokens[tokens.length - 1]!.type === 'descendant' && type !== 'descendant') {
344 if (tokens.length > 0 && tokens[tokens.length - 1]!.type === 'descendant' && type !== 'descendant')
321345 tokens.pop()
322 }
323 if (tokens.length > 0 && (tokens[tokens.length - 1]!.type === type)) {
346 if (tokens.length > 0 && (tokens[tokens.length - 1]!.type === type))
324347 return
325 }
326348 tokens.push({ type } as any)
327349}
328350
329function findEndOfString(selector: string, start: number, q: string): number {
351function findEndOfString(selector: string, start: number, qCode: number): number {
330352 let i = start
331 while (i < selector.length) {
332 if (selector.charAt(i) === '\\') {
353 const len = selector.length
354 while (i < len) {
355 const c = selector.charCodeAt(i)
356 if (c === 92 /* \ */) {
333357 i += 2
334358 continue
335359 }
336 if (selector.charAt(i) === q)
360 if (c === qCode)
337361 return i
338362 i++
339363 }
@@ -343,15 +367,19 @@ function findEndOfString(selector: string, start: number, q: string): number {
343367function findClose(selector: string, openParen: number): number {
344368 let depth = 1
345369 let i = openParen + 1
346 while (i < selector.length) {
347 const c = selector.charAt(i)
348 if (c === '\\') { i += 2; continue }
349 if (c === '"' || c === '\'') {
370 const len = selector.length
371 while (i < len) {
372 const c = selector.charCodeAt(i)
373 if (c === 92 /* \ */) { i += 2; continue }
374 if (c === 34 /* " */ || c === 39 /* ' */) {
350375 i = findEndOfString(selector, i + 1, c) + 1
351376 continue
352377 }
353 if (c === '(') depth++
354 else if (c === ')') { depth--; if (depth === 0) return i }
378 if (c === 40 /* ( */) depth++
379 else if (c === 41 /* ) */) {
380 depth--
381 if (depth === 0) return i
382 }
355383 i++
356384 }
357385 throw new Error('Unterminated parenthesis')
test/select.test.tsmodified+3-1
Changes to test/select.test.ts
@@ -76,7 +76,9 @@ describe('selectAll', () => {
7676 expect(selectAll('p:not(#a)', tree, opts).length).toBe(1)
7777 })
7878 it('matches :has()', () => {
79 expect(selectAll('div:has(span.bar)', tree, opts).length).toBe(1)
79 // querySelector-style: passing a single root excludes the root itself
80 // (matches css-select). Wrap in `[tree]` to opt into root-as-candidate.
81 expect(selectAll('div:has(span.bar)', [tree], opts).length).toBe(1)
8082 })
8183 it('matches :nth-child', () => {
8284 expect(selectAll('p:nth-child(1)', tree, opts).length).toBe(1)