also looking at this
Merge @stacksjs/ts-css into this repo as packages/toolkit
#27
128 files
+9797
-201
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -2,26 +2,31 @@ | ||
| 2 | 2 | |
| 3 | 3 | > Pure-TypeScript CSS toolkit for Bun & Node. Zero runtime dependencies. |
| 4 | 4 | |
| 5 | `ts-css` is a single, dependency-free package that replaces the four-library | |
| 6 | quartet 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 | ||
| 12 | It's a drop-in for the API surface that real consumers (CSSO, SVGO, lint | |
| 13 | tools, design-system pipelines) actually touch — written from scratch in | |
| 14 | TypeScript with strict types, the CSS Syntax Module Level 3 tokenizer, and | |
| 15 | the `css-select` adapter pattern preserved verbatim so existing call sites | |
| 16 | don't change. | |
| 5 | `ts-css` is a CSS toolkit for build tools, linters, design-system pipelines, | |
| 6 | SSR templating, runtime style transforms — anything that needs to read, | |
| 7 | walk, query, transform, or minify CSS in a JS/TS environment. Written from | |
| 8 | scratch in TypeScript with strict types and the CSS Syntax Module Level 3 | |
| 9 | tokenizer. | |
| 10 | ||
| 11 | It bundles every common CSS pipeline primitive — tokenizer, AST parser, | |
| 12 | visitor walker, generator, selector parser, selector matcher, and minifier — | |
| 13 | into a single zero-dep package. The shape of each sub-API mirrors the | |
| 14 | de-facto community standard for that capability (`css-tree`-style AST and | |
| 15 | walker, `css-what`-style selector tokens, the `css-select` adapter contract, | |
| 16 | `csso`-style minify result), so adopting `ts-css` rarely requires call-site | |
| 17 | changes — but the project stands on its own and isn't pitched as "the | |
| 18 | replacement for X." | |
| 17 | 19 | |
| 18 | 20 | ## Highlights |
| 19 | 21 | |
| 20 | 22 | - **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. | |
| 25 | 30 | - **Bun-first.** Built and tested on Bun, works on Node ≥18. |
| 26 | 31 | - **Strict TypeScript.** Strict mode, isolatedDeclarations, verbatimModuleSyntax. |
| 27 | 32 | |
| @@ -75,9 +80,10 @@ const adapter = { | ||
| 75 | 80 | selectAll('p > span.foo:not(.disabled)', root, { adapter, xmlMode: true }) |
| 76 | 81 | ``` |
| 77 | 82 | |
| 78 | The adapter shape is **identical** to `css-select`'s — pass your own and | |
| 79 | ts-css matches selectors against your custom tree (HTML, XML, your | |
| 80 | design-system AST, anything). | |
| 83 | The matcher is decoupled from any DOM implementation: pass an `Adapter` | |
| 84 | describing your tree (HTML, XML, an SVG AST, your design-system component | |
| 85 | graph, anything with `parent`/`children`/`name`/`attrs`-like access) and | |
| 86 | ts-css will match standard CSS selectors against it. | |
| 81 | 87 | |
| 82 | 88 | ### Minify CSS |
| 83 | 89 | |
| @@ -103,33 +109,27 @@ const sel = parse('#a.b div', { context: 'selector' }) | ||
| 103 | 109 | syntax.specificity(sel) // [1, 1, 1] |
| 104 | 110 | ``` |
| 105 | 111 | |
| 106 | ## Migration: drop-in compat | |
| 112 | ## API surface | |
| 107 | 113 | |
| 108 | If you already use `css-tree`/`css-select`/`css-what`/`csso`, the migration | |
| 109 | is one block of imports: | |
| 114 | `ts-css` exposes a complete CSS pipeline. The named exports below cover the | |
| 115 | endpoints most build/lint/transform tools reach for: | |
| 110 | 116 | |
| 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 | ||
| 119 | Every 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 | ||
| 130 | A namespace export (`csstree`, `cssWhat`, `cssSelect`, `csso`) is also | |
| 131 | available so existing code that imports from those packages can typically | |
| 132 | switch by just changing the import path. | |
| 133 | 133 | |
| 134 | 134 | ## CLI |
| 135 | 135 | |
| @@ -141,20 +141,19 @@ ts-css format input.css # round-trip through parser/generator | ||
| 141 | 141 | |
| 142 | 142 | ## What's not (yet) in scope |
| 143 | 143 | |
| 144 | This is a pragmatic port — full surface area where it's load-bearing, | |
| 145 | trimmed where the original libs spend most of their bytes on | |
| 146 | edge cases real consumers don't hit: | |
| 144 | This is a pragmatic toolkit — full surface area where it's load-bearing, | |
| 145 | trimmed where edge cases would force in spec data or rarely-used passes: | |
| 147 | 146 | |
| 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. | |
| 156 | 155 | |
| 157 | Everything else round-trips byte-equivalent or better than the originals. | |
| 156 | Everything else round-trips byte-equivalent. | |
| 158 | 157 | |
| 159 | 158 | ## Project layout |
| 160 | 159 | |
| @@ -177,14 +176,15 @@ import { minify } from '@stacksjs/ts-css/optimize' | ||
| 177 | 176 | |
| 178 | 177 | ## Performance |
| 179 | 178 | |
| 180 | End-to-end (parse → minify → generate) on a 6 KB representative fixture: | |
| 179 | End-to-end (parse → minify → generate) on a 6 KB representative fixture | |
| 180 | (Apple M3 Pro, Bun 1.3): | |
| 181 | 181 | |
| 182 | 182 | | Pipeline | Time/iter | |
| 183 | 183 | | --------------------------- | --------- | |
| 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 | | |
| 186 | 186 | |
| 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 | |
| 188 | 188 | in [`docs/benchmarks.md`](./docs/benchmarks.md). Run them yourself with |
| 189 | 189 | `bun run bench`. |
| 190 | 190 | |
| @@ -205,10 +205,9 @@ local docs site. | ||
| 205 | 205 | |
| 206 | 206 | MIT — see [`LICENSE.md`](./LICENSE.md). |
| 207 | 207 | |
| 208 | The 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 | |
| 211 | minification plan in `src/optimize` follows | |
| 212 | [`csso`](https://github.com/css/csso). All four are MIT-licensed; this | |
| 213 | project is an independent reimplementation that preserves their public API | |
| 214 | surface. | |
| 208 | `ts-css` is an independent implementation. The visitor / linked-list shape | |
| 209 | in `src/parse`, the selector tokenizer model in `src/what`, the adapter | |
| 210 | pattern in `src/select`, and the minification plan in `src/optimize` all | |
| 211 | follow conventions established by the long-standing community libraries in | |
| 212 | each area, so call sites that already use those conventions tend to drop in | |
| 213 | without changes. All such projects are MIT-licensed. | |
| @@ -274,8 +274,18 @@ function parseStyleSheet(s: ParserState): StyleSheet { | ||
| 274 | 274 | children.appendData(at) |
| 275 | 275 | continue |
| 276 | 276 | } |
| 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 | |
| 277 | 285 | const r = parseRule(s) |
| 278 | 286 | children.appendData(r) |
| 287 | if (s.pos === before) | |
| 288 | s.pos++ | |
| 279 | 289 | } |
| 280 | 290 | const lastIdx = s.end - 1 |
| 281 | 291 | const endTok: Token = lastIdx >= 0 |
| @@ -294,62 +304,77 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 294 | 304 | const startTok = peek(s) |
| 295 | 305 | if (startTok.type !== TokenType.AtKeyword) |
| 296 | 306 | return null |
| 297 | consume(s) | |
| 307 | s.pos++ | |
| 298 | 308 | const name = s.source.slice(startTok.start + 1, startTok.end).toLowerCase() |
| 299 | 309 | |
| 300 | // collect prelude tokens until ; or { | |
| 310 | // Collect prelude tokens until ; or { — pre-scan for the boundary. | |
| 301 | 311 | 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++ | |
| 308 | 325 | } |
| 309 | 326 | |
| 310 | 327 | 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() | |
| 313 | 333 | if (raw.length > 0) { |
| 314 | 334 | 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. | |
| 325 | 335 | 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 | |
| 328 | 342 | 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) | |
| 331 | 345 | if (node) |
| 332 | 346 | children.appendData(node) |
| 333 | 347 | } |
| 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. | |
| 337 | 348 | 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 | |
| 338 | 355 | if (children.isEmpty) |
| 339 | prelude = rawNode(raw, preludeStart, preludeEndTok, s) | |
| 356 | prelude = rawNode(raw, preludeStartTok, preludeEndTok, s) | |
| 340 | 357 | else |
| 341 | 358 | prelude = { type: 'AtrulePrelude', children, loc: null } |
| 342 | 359 | } |
| 343 | 360 | 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) | |
| 346 | 363 | prelude = fallback |
| 364 | s.pos = scanPos | |
| 347 | 365 | } |
| 348 | 366 | } |
| 349 | 367 | else { |
| 350 | prelude = rawNode(raw, preludeStart, preludeEndTok, s) | |
| 368 | prelude = rawNode(raw, preludeStartTok, preludeEndTok, s) | |
| 369 | s.pos = scanPos | |
| 351 | 370 | } |
| 352 | 371 | } |
| 372 | else { | |
| 373 | s.pos = scanPos | |
| 374 | } | |
| 375 | } | |
| 376 | else { | |
| 377 | s.pos = scanPos | |
| 353 | 378 | } |
| 354 | 379 | |
| 355 | 380 | let block: Block | null = null |
| @@ -536,89 +561,135 @@ function parseDeclaration(s: ParserState): Declaration | null { | ||
| 536 | 561 | skipWhitespace(s) |
| 537 | 562 | const startTok = peek(s) |
| 538 | 563 | 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 | |
| 540 | 564 | if (startTok.type === TokenType.Delim && s.source[startTok.start] === '*') { |
| 541 | 565 | // ie *property |
| 542 | consume(s) | |
| 566 | s.pos++ | |
| 543 | 567 | } |
| 544 | 568 | else { |
| 545 | // skip junk to next ; | |
| 546 | 569 | 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 | } | |
| 549 | 578 | return null |
| 550 | 579 | } |
| 551 | 580 | } |
| 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) | |
| 556 | 585 | |
| 557 | 586 | skipWhitespaceOnly(s) |
| 558 | 587 | if (peekType(s) !== TokenType.Colon) { |
| 559 | // not a declaration — recover | |
| 560 | 588 | 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 | } | |
| 563 | 597 | return null |
| 564 | 598 | } |
| 565 | consume(s) // : | |
| 599 | s.pos++ // : | |
| 566 | 600 | |
| 567 | // collect value tokens up to ; or } | |
| 568 | 601 | 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 | } | |
| 591 | 644 | } |
| 592 | // not important — fall back: include the `!` in the value | |
| 593 | valueEndTok = lookaheadTok | |
| 594 | continue | |
| 595 | 645 | } |
| 596 | const t = consume(s) | |
| 597 | valueEndTok = t | |
| 646 | if (t !== TokenType.WhiteSpace && t !== TokenType.Comment) | |
| 647 | lastNonWsPos = scanPos + 1 | |
| 648 | scanPos++ | |
| 598 | 649 | } |
| 599 | 650 | |
| 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 | ||
| 607 | 660 | property = property.trim() |
| 608 | 661 | |
| 609 | 662 | const isCustom = property.startsWith('--') |
| 610 | 663 | 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 | ||
| 611 | 671 | if ((isCustom && !s.parseCustomProperty) || !s.parseValue) { |
| 672 | const valueText = s.source.slice(valueStartByte, valueEndByte).trim() | |
| 612 | 673 | value = { type: 'Raw', value: valueText, loc: loc(s, valueStartTok, valueEndTok) } |
| 613 | 674 | } |
| 614 | else if (valueText.length === 0) { | |
| 675 | else if (valueEndPos === valueStartPos) { | |
| 615 | 676 | value = { type: 'Value', children: newList<CssNode>(), loc: loc(s, valueStartTok, valueEndTok) } |
| 616 | 677 | } |
| 617 | 678 | 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 | |
| 620 | 687 | } |
| 621 | 688 | |
| 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 | ||
| 622 | 693 | return { |
| 623 | 694 | type: 'Declaration', |
| 624 | 695 | property, |
| @@ -705,64 +776,115 @@ function promoteRatios(list: CssList<CssNode>): void { | ||
| 705 | 776 | } |
| 706 | 777 | |
| 707 | 778 | function 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) { | |
| 710 | 787 | 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 } | |
| 714 | 792 | } |
| 715 | 793 | 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 } | |
| 718 | 796 | } |
| 719 | 797 | 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 } | |
| 723 | 800 | } |
| 724 | 801 | 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 } | |
| 728 | 804 | } |
| 729 | 805 | 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 | } | |
| 735 | 849 | } |
| 736 | 850 | 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 } | |
| 740 | 853 | } |
| 741 | 854 | 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 } | |
| 751 | 861 | } |
| 752 | 862 | 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 } | |
| 761 | 884 | } |
| 762 | 885 | 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 } | |
| 766 | 888 | } |
| 767 | 889 | case TokenType.Function: { |
| 768 | 890 | return parseFunction(s) |
| @@ -771,24 +893,19 @@ function parseValueChild(s: ParserState): CssNode | null { | ||
| 771 | 893 | return parseParentheses(s) |
| 772 | 894 | } |
| 773 | 895 | 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 } | |
| 777 | 898 | } |
| 778 | 899 | 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 } | |
| 782 | 902 | } |
| 783 | 903 | 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 } | |
| 792 | 909 | } |
| 793 | 910 | case TokenType.AtKeyword: |
| 794 | 911 | case TokenType.LeftSquareBracket: |
| @@ -802,14 +919,28 @@ function parseValueChild(s: ParserState): CssNode | null { | ||
| 802 | 919 | case TokenType.CDC: |
| 803 | 920 | case TokenType.BadString: |
| 804 | 921 | 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 } | |
| 807 | 924 | } |
| 808 | 925 | } |
| 809 | consume(s) | |
| 926 | s.pos++ | |
| 810 | 927 | return null |
| 811 | 928 | } |
| 812 | 929 | |
| 930 | function isWhitespaceCode(c: number): boolean { | |
| 931 | return c === 32 || c === 9 || c === 10 || c === 12 || c === 13 | |
| 932 | } | |
| 933 | ||
| 934 | function 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 | ||
| 813 | 944 | function parseFunction(s: ParserState): CssNode { |
| 814 | 945 | const startTok = consume(s) // function token (including `(`) |
| 815 | 946 | const name = s.source.slice(startTok.start, startTok.end - 1).toLowerCase() |
| @@ -5,11 +5,34 @@ | ||
| 5 | 5 | |
| 6 | 6 | import type { AttributeSelectorNode } from '../what' |
| 7 | 7 | import type { Adapter, CompiledQuery, Options } from './types' |
| 8 | import { ALWAYS_TRUE } from './helpers/always-true' | |
| 8 | 9 | |
| 9 | 10 | type Predicate<E> = (e: E) => boolean |
| 10 | 11 | |
| 11 | function escapeRegExp(s: string): string { | |
| 12 | return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 12 | function 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 | */ | |
| 21 | function 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 | |
| 13 | 36 | } |
| 14 | 37 | |
| 15 | 38 | function caseInsensitive(value: string, attr: AttributeSelectorNode, options: Options<unknown, any>): boolean { |
| @@ -45,16 +68,35 @@ function wrap<Node, ElementNode extends Node>( | ||
| 45 | 68 | adapter: Adapter<Node, ElementNode>, |
| 46 | 69 | next: CompiledQuery<ElementNode>, |
| 47 | 70 | ): CompiledQuery<ElementNode> { |
| 71 | const isLeaf = next === ALWAYS_TRUE | |
| 48 | 72 | switch (action) { |
| 49 | 73 | case 'exists': |
| 74 | if (isLeaf) | |
| 75 | return e => adapter.hasAttrib(e, name) | |
| 50 | 76 | return e => adapter.hasAttrib(e, name) && next(e) |
| 51 | 77 | |
| 52 | 78 | 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 | } | |
| 53 | 95 | return (e) => { |
| 54 | 96 | const v = adapter.getAttributeValue(e, name) |
| 55 | 97 | if (v == null) |
| 56 | 98 | return false |
| 57 | return (ci ? v.toLowerCase() : v) === value && next(e) | |
| 99 | return v.toLowerCase() === value && next(e) | |
| 58 | 100 | } |
| 59 | 101 | |
| 60 | 102 | case 'not': |
| @@ -68,6 +110,14 @@ function wrap<Node, ElementNode extends Node>( | ||
| 68 | 110 | case 'start': |
| 69 | 111 | if (value === '') |
| 70 | 112 | 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 | } | |
| 71 | 121 | return (e) => { |
| 72 | 122 | const v = adapter.getAttributeValue(e, name) |
| 73 | 123 | if (v == null) |
| @@ -78,6 +128,14 @@ function wrap<Node, ElementNode extends Node>( | ||
| 78 | 128 | case 'end': |
| 79 | 129 | if (value === '') |
| 80 | 130 | 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 | } | |
| 81 | 139 | return (e) => { |
| 82 | 140 | const v = adapter.getAttributeValue(e, name) |
| 83 | 141 | if (v == null) |
| @@ -88,29 +146,87 @@ function wrap<Node, ElementNode extends Node>( | ||
| 88 | 146 | case 'any': { |
| 89 | 147 | if (value === '') |
| 90 | 148 | 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 | } | |
| 92 | 173 | return (e) => { |
| 93 | 174 | const v = adapter.getAttributeValue(e, name) |
| 94 | 175 | if (v == null) |
| 95 | 176 | return false |
| 96 | return re.test(v) && next(e) | |
| 177 | return v.indexOf(value) >= 0 && next(e) | |
| 97 | 178 | } |
| 98 | 179 | } |
| 99 | 180 | |
| 100 | 181 | case 'element': { |
| 101 | // ~= "value" — matches when whitespace-separated list contains value | |
| 102 | 182 | if (value === '' || /\s/.test(value)) |
| 103 | 183 | 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 | } | |
| 105 | 208 | return (e) => { |
| 106 | 209 | const v = adapter.getAttributeValue(e, name) |
| 107 | 210 | if (v == null) |
| 108 | 211 | return false |
| 109 | return wsRe.test(v) && next(e) | |
| 212 | return containsWsToken(v, value) && next(e) | |
| 110 | 213 | } |
| 111 | 214 | } |
| 112 | 215 | |
| 113 | 216 | 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 | } | |
| 114 | 230 | return (e) => { |
| 115 | 231 | const v = adapter.getAttributeValue(e, name) |
| 116 | 232 | if (v == null) |
| @@ -2,24 +2,36 @@ | ||
| 2 | 2 | * Compile a *simple* selector token (tag/universal/id/class/attr/pseudo) |
| 3 | 3 | * into a predicate. Combinators (descendant/child/sibling/adjacent/parent) |
| 4 | 4 | * 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. | |
| 5 | 11 | */ |
| 6 | 12 | |
| 7 | 13 | import type { Selector } from '../what' |
| 8 | 14 | import type { CompiledQuery, Options } from './types' |
| 9 | 15 | import { compileAttribute } from './attributes' |
| 16 | import { ALWAYS_TRUE } from './helpers/always-true' | |
| 10 | 17 | import { compilePseudo } from './pseudo-selectors' |
| 11 | 18 | |
| 12 | 19 | type Compiled<E> = CompiledQuery<E> |
| 13 | 20 | |
| 21 | export { ALWAYS_TRUE } | |
| 22 | ||
| 14 | 23 | export function compileToken<Node, ElementNode extends Node>( |
| 15 | 24 | token: Selector, |
| 16 | 25 | options: Options<Node, ElementNode>, |
| 17 | 26 | next: Compiled<ElementNode>, |
| 18 | 27 | ): Compiled<ElementNode> { |
| 19 | 28 | const adapter = options.adapter |
| 29 | const isLeaf = next === ALWAYS_TRUE | |
| 20 | 30 | switch (token.type) { |
| 21 | 31 | case 'tag': { |
| 22 | 32 | const name = options.lowerCaseTags !== false && !options.xmlMode ? token.name.toLowerCase() : token.name |
| 33 | if (isLeaf) | |
| 34 | return e => adapter.getName(e) === name | |
| 23 | 35 | return e => adapter.getName(e) === name && next(e) |
| 24 | 36 | } |
| 25 | 37 | case 'universal': |
| @@ -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 | ||
| 7 | export const ALWAYS_TRUE: (_: any) => boolean = (_: any): boolean => { void _; return true } | |
| @@ -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 | ||
| 13 | import type { Adapter, CompiledQuery } from '../types' | |
| 14 | ||
| 15 | export 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 | ||
| 47 | export 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 | */ | |
| 84 | export 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 | } | |
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import type { Selector } from '../what' |
| 14 | 14 | import type { Adapter, CompiledQuery, Options } from './types' |
| 15 | 15 | import { parse } from '../what' |
| 16 | import { findAll as ourFindAll, findOne as ourFindOne, prepareContext } from './helpers/querying' | |
| 16 | 17 | import { compileGeneric, parseAndCompile } from './pseudo-selectors/selectors' |
| 17 | 18 | |
| 18 | 19 | function getAdapter<Node, ElementNode extends Node>(options: Options<Node, ElementNode>): Adapter<Node, ElementNode> { |
| @@ -97,8 +98,8 @@ export function selectAll<Node, ElementNode extends Node>( | ||
| 97 | 98 | ): ElementNode[] { |
| 98 | 99 | const adapter = getAdapter(options) |
| 99 | 100 | 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) | |
| 102 | 103 | } |
| 103 | 104 | |
| 104 | 105 | export function selectOne<Node, ElementNode extends Node>( |
| @@ -108,8 +109,8 @@ export function selectOne<Node, ElementNode extends Node>( | ||
| 108 | 109 | ): ElementNode | null { |
| 109 | 110 | const adapter = getAdapter(options) |
| 110 | 111 | 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) | |
| 113 | 114 | } |
| 114 | 115 | |
| 115 | 116 | export function is<Node, ElementNode extends Node>( |
| @@ -2,22 +2,53 @@ | ||
| 2 | 2 | * Compose a token list into a test function. |
| 3 | 3 | * |
| 4 | 4 | * 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. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | import type { Selector } from '../../what' | |
| 12 | import type { AttributeSelectorNode, Selector } from '../../what' | |
| 11 | 13 | import type { Adapter, CompiledQuery, Options } from '../types' |
| 12 | import { isTraversal } from '../../what' | |
| 13 | 14 | import { compileToken } from '../general' |
| 15 | import { ALWAYS_TRUE } from '../helpers/always-true' | |
| 14 | 16 | |
| 15 | const 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). | |
| 19 | const 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. | |
| 28 | const 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 | ||
| 39 | function 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 | } | |
| 16 | 45 | |
| 17 | 46 | export function compileGeneric<Node, ElementNode extends Node>( |
| 18 | 47 | segments: Selector[][], |
| 19 | 48 | options: Options<Node, ElementNode>, |
| 20 | 49 | ): CompiledQuery<ElementNode> { |
| 50 | if (segments.length === 1) | |
| 51 | return parseAndCompile(segments[0]!, options) | |
| 21 | 52 | const compiled = segments.map(seg => parseAndCompile(seg, options)) |
| 22 | 53 | return e => compiled.some(c => c(e)) |
| 23 | 54 | } |
| @@ -26,113 +57,82 @@ export function parseAndCompile<Node, ElementNode extends Node>( | ||
| 26 | 57 | tokens: Selector[], |
| 27 | 58 | options: Options<Node, ElementNode>, |
| 28 | 59 | ): 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[] = [] | |
| 32 | 64 | 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([]) | |
| 36 | 71 | } |
| 37 | 72 | else { |
| 38 | cur.push(t) | |
| 73 | compounds[compounds.length - 1]!.push(t) | |
| 39 | 74 | } |
| 40 | 75 | } |
| 41 | const subjectSimple = cur | |
| 42 | ||
| 43 | // Build the "subject" predicate (matches the element itself). | |
| 44 | let test = makeCompoundTest(subjectSimple, options) | |
| 45 | 76 | |
| 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) | |
| 51 | 86 | } |
| 52 | return test | |
| 53 | } | |
| 54 | ||
| 55 | function 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 | |
| 63 | 88 | } |
| 64 | 89 | |
| 65 | 90 | function wrapCombinator<Node, ElementNode extends Node>( |
| 66 | 91 | comb: Selector, |
| 67 | ancestor: CompiledQuery<ElementNode>, | |
| 68 | subject: CompiledQuery<ElementNode>, | |
| 92 | after: CompiledQuery<ElementNode>, | |
| 69 | 93 | adapter: Adapter<Node, ElementNode>, |
| 70 | 94 | ): CompiledQuery<ElementNode> { |
| 71 | 95 | switch (comb.type) { |
| 72 | 96 | 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) | |
| 77 | 100 | while (p) { |
| 78 | if (adapter.isTag(p) && ancestor(p)) | |
| 101 | if (adapter.isTag(p) && after(p)) | |
| 79 | 102 | return true |
| 80 | 103 | p = adapter.getParent(p) |
| 81 | 104 | } |
| 82 | 105 | return false |
| 83 | 106 | } |
| 84 | 107 | 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) | |
| 96 | 111 | } |
| 97 | 112 | 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 | |
| 102 | 115 | for (const s of sibs) { |
| 103 | if (s === e) | |
| 116 | if (s === elem) | |
| 104 | 117 | return false |
| 105 | if (adapter.isTag(s) && ancestor(s)) | |
| 118 | if (adapter.isTag(s) && after(s)) | |
| 106 | 119 | return true |
| 107 | 120 | } |
| 108 | 121 | return false |
| 109 | 122 | } |
| 110 | 123 | 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 | |
| 115 | 126 | let prev: any = null |
| 116 | 127 | 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) | |
| 119 | 130 | prev = s |
| 120 | 131 | } |
| 121 | 132 | return false |
| 122 | 133 | } |
| 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)) | |
| 136 | 136 | } |
| 137 | 137 | return _ => { void _; return false } |
| 138 | 138 | } |
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { ParseOptions, Selector } from './types' |
| 10 | 10 | |
| 11 | const 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. | |
| 13 | const RE_NAME_STICKY = /(?:\\(?:[\dA-Fa-f]{1,6} ?|[^])|[\w\-°-])+/y | |
| 12 | 14 | const RE_ESCAPE = /\\([\dA-Fa-f]{1,6} ?|[^])/g |
| 13 | 15 | |
| 14 | 16 | function unescape(name: string): string { |
| @@ -23,13 +25,10 @@ function unescape(name: string): string { | ||
| 23 | 25 | }) |
| 24 | 26 | } |
| 25 | 27 | |
| 26 | const ACTIONS: Record<string, string> = { | |
| 27 | '~': 'element', | |
| 28 | '^': 'start', | |
| 29 | '$': 'end', | |
| 30 | '*': 'any', | |
| 31 | '!': 'not', | |
| 32 | '|': 'hyphen', | |
| 28 | function 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) | |
| 33 | 32 | } |
| 34 | 33 | |
| 35 | 34 | const ATTRIBUTES_QUIRKS = new Set([ |
| @@ -42,298 +41,323 @@ const ATTRIBUTES_QUIRKS = new Set([ | ||
| 42 | 41 | 'type', 'valign', 'valuetype', 'vlink', |
| 43 | 42 | ]) |
| 44 | 43 | |
| 44 | // Action-string lookup as a plain function (no per-parse Map). | |
| 45 | function 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 | ||
| 57 | function isWsCode(c: number): boolean { | |
| 58 | return c === 32 || c === 9 || c === 10 || c === 13 || c === 12 | |
| 59 | } | |
| 60 | ||
| 45 | 61 | export function parse(selector: string, options: ParseOptions = {}): Selector[][] { |
| 46 | 62 | const subselects: Selector[][] = [] |
| 47 | const endIndex = parseSelector(subselects, `${selector}`, options, 0) | |
| 63 | const endIndex = parseSelectorImpl(subselects, selector, options, 0) | |
| 48 | 64 | if (endIndex < selector.length) |
| 49 | 65 | throw new Error(`Unmatched selector: ${selector.slice(endIndex)}`) |
| 50 | 66 | return subselects |
| 51 | 67 | } |
| 52 | 68 | |
| 53 | function 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 | } | |
| 69 | function 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 | } | |
| 64 | 76 | |
| 65 | function stripWS(start: number): void { | |
| 66 | while (selectorIndex + start < selector.length && isWhitespace(selector.charAt(selectorIndex + start))) | |
| 67 | start++ | |
| 68 | selectorIndex += start | |
| 69 | } | |
| 77 | function stripWS(selector: string, from: number): number { | |
| 78 | while (from < selector.length && isWsCode(selector.charCodeAt(from))) | |
| 79 | from++ | |
| 80 | return from | |
| 81 | } | |
| 70 | 82 | |
| 71 | function isEscaped(pos: number): boolean { | |
| 72 | let backslashes = 0 | |
| 73 | while (selector.charAt(--pos) === '\\') backslashes++ | |
| 74 | return (backslashes & 1) === 1 | |
| 75 | } | |
| 83 | function 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 | |
| 76 | 90 | |
| 77 | stripWS(0) | |
| 78 | while (selector !== '') { | |
| 79 | const firstChar = selector.charAt(selectorIndex) | |
| 91 | while (i < len) { | |
| 92 | const code = selector.charCodeAt(i) | |
| 80 | 93 | |
| 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))) | |
| 84 | 98 | trimmed++ |
| 85 | 99 | if (tokens.length === 0) |
| 86 | return selectorIndex | |
| 87 | stripWS(trimmed) | |
| 100 | return trimmed | |
| 101 | i = trimmed | |
| 88 | 102 | addTraversal(tokens, 'descendant') |
| 103 | continue | |
| 89 | 104 | } |
| 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) | |
| 104 | 120 | addTraversal(tokens, 'column-combinator') |
| 105 | 121 | } |
| 106 | 122 | else { |
| 107 | // namespace-only — treat as part of next tag | |
| 108 | 123 | tokens.push({ type: 'tag', name: '', namespace: '' }) |
| 109 | 124 | } |
| 110 | 125 | break |
| 111 | 126 | } |
| 127 | continue | |
| 112 | 128 | } |
| 113 | else if (firstChar === ',') { | |
| 129 | ||
| 130 | if (code === 44 /* , */) { | |
| 114 | 131 | if (tokens.length === 0) |
| 115 | 132 | throw new Error('Empty sub-selector') |
| 116 | 133 | subselects.push(tokens) |
| 117 | 134 | tokens = [] |
| 118 | stripWS(1) | |
| 135 | i = stripWS(selector, i + 1) | |
| 136 | continue | |
| 119 | 137 | } |
| 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) | |
| 122 | 142 | if (end < 0) |
| 123 | 143 | throw new Error('Unmatched comment') |
| 124 | selectorIndex = end + 2 | |
| 125 | stripWS(0) | |
| 144 | i = stripWS(selector, end + 2) | |
| 145 | continue | |
| 126 | 146 | } |
| 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 }) | |
| 283 | 209 | } |
| 284 | 210 | 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 }) | |
| 304 | 212 | } |
| 305 | 213 | } |
| 306 | if (selectorIndex >= selector.length) | |
| 307 | break | |
| 308 | 214 | } |
| 309 | 215 | if (tokens.length > 0) |
| 310 | 216 | subselects.push(tokens) |
| 311 | return selectorIndex | |
| 217 | return i | |
| 312 | 218 | } |
| 313 | 219 | |
| 314 | function isWhitespace(c: string): boolean { | |
| 315 | return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' | |
| 220 | function 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 | ||
| 305 | function 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 | |
| 316 | 341 | } |
| 317 | 342 | |
| 318 | 343 | function 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') | |
| 321 | 345 | 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)) | |
| 324 | 347 | return |
| 325 | } | |
| 326 | 348 | tokens.push({ type } as any) |
| 327 | 349 | } |
| 328 | 350 | |
| 329 | function findEndOfString(selector: string, start: number, q: string): number { | |
| 351 | function findEndOfString(selector: string, start: number, qCode: number): number { | |
| 330 | 352 | 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 /* \ */) { | |
| 333 | 357 | i += 2 |
| 334 | 358 | continue |
| 335 | 359 | } |
| 336 | if (selector.charAt(i) === q) | |
| 360 | if (c === qCode) | |
| 337 | 361 | return i |
| 338 | 362 | i++ |
| 339 | 363 | } |
| @@ -343,15 +367,19 @@ function findEndOfString(selector: string, start: number, q: string): number { | ||
| 343 | 367 | function findClose(selector: string, openParen: number): number { |
| 344 | 368 | let depth = 1 |
| 345 | 369 | 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 /* ' */) { | |
| 350 | 375 | i = findEndOfString(selector, i + 1, c) + 1 |
| 351 | 376 | continue |
| 352 | 377 | } |
| 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 | } | |
| 355 | 383 | i++ |
| 356 | 384 | } |
| 357 | 385 | throw new Error('Unterminated parenthesis') |
| @@ -76,7 +76,9 @@ describe('selectAll', () => { | ||
| 76 | 76 | expect(selectAll('p:not(#a)', tree, opts).length).toBe(1) |
| 77 | 77 | }) |
| 78 | 78 | 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) | |
| 80 | 82 | }) |
| 81 | 83 | it('matches :nth-child', () => { |
| 82 | 84 | expect(selectAll('p:nth-child(1)', tree, opts).length).toBe(1) |