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.
| @@ -181,10 +181,10 @@ End-to-end (parse → minify → generate) on a 6 KB representative fixture | ||
| 181 | 181 | |
| 182 | 182 | | Pipeline | Time/iter | |
| 183 | 183 | | --------------------------- | --------- | |
| 184 | | `ts-css.minify(source)` | **~370 µs** | | |
| 185 | | `csso.minify(source)` | ~870 µs | | |
| 184 | | `ts-css.minify(source)` | **~270 µs** | | |
| 185 | | `csso.minify(source)` | ~890 µs | | |
| 186 | 186 | |
| 187 | → **~2.3× faster end-to-end** with zero runtime deps. Per-API breakdown | |
| 187 | → **~3.3× faster end-to-end** with zero runtime deps. Per-API breakdown | |
| 188 | 188 | in [`docs/benchmarks.md`](./docs/benchmarks.md). Run them yourself with |
| 189 | 189 | `bun run bench`. |
| 190 | 190 | |
| @@ -4,11 +4,19 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { CLI } from '@stacksjs/clapp' |
| 7 | import { readFileSync } from 'node:fs' | |
| 8 | import { minify } from '../src/optimize' | |
| 7 | import { existsSync, readFileSync } from 'node:fs' | |
| 8 | import { minify, minifyBlock } from '../src/optimize' | |
| 9 | 9 | import { generate, parse } from '../src/parse' |
| 10 | 10 | import { version } from '../package.json' |
| 11 | 11 | |
| 12 | function readSourceOrExit(file: string): string { | |
| 13 | if (!existsSync(file)) { | |
| 14 | process.stderr.write(`ts-css: file not found: ${file}\n`) | |
| 15 | process.exit(1) | |
| 16 | } | |
| 17 | return readFileSync(file, 'utf8') | |
| 18 | } | |
| 19 | ||
| 12 | 20 | const cli = new CLI('ts-css') |
| 13 | 21 | |
| 14 | 22 | cli |
| @@ -16,10 +24,11 @@ cli | ||
| 16 | 24 | .option('--no-comments', 'Strip /*!*/ comments too') |
| 17 | 25 | .option('--block', 'Treat input as a declarationList (style="…" body)') |
| 18 | 26 | .action(async (file: string, opts: { comments: boolean, block: boolean }) => { |
| 19 | const css = readFileSync(file, 'utf8') | |
| 20 | const result = minify(css, { | |
| 21 | comments: opts.comments === false ? false : 'exclamation', | |
| 22 | }) | |
| 27 | const css = readSourceOrExit(file) | |
| 28 | const minOptions = { comments: opts.comments === false ? false : ('exclamation' as const) } | |
| 29 | const result = opts.block | |
| 30 | ? minifyBlock(css, minOptions) | |
| 31 | : minify(css, minOptions) | |
| 23 | 32 | process.stdout.write(result.css) |
| 24 | 33 | }) |
| 25 | 34 | |
| @@ -27,7 +36,7 @@ cli | ||
| 27 | 36 | .command('parse <file>', 'Parse a CSS file and print its AST as JSON') |
| 28 | 37 | .option('--positions', 'Include source locations on every node') |
| 29 | 38 | .action(async (file: string, opts: { positions: boolean }) => { |
| 30 | const css = readFileSync(file, 'utf8') | |
| 39 | const css = readSourceOrExit(file) | |
| 31 | 40 | const ast = parse(css, { positions: opts.positions }) |
| 32 | 41 | process.stdout.write(JSON.stringify(ast, null, 2)) |
| 33 | 42 | }) |
| @@ -35,7 +44,7 @@ cli | ||
| 35 | 44 | cli |
| 36 | 45 | .command('format <file>', 'Round-trip CSS through parser/generator (deterministic single-line output)') |
| 37 | 46 | .action(async (file: string) => { |
| 38 | const css = readFileSync(file, 'utf8') | |
| 47 | const css = readSourceOrExit(file) | |
| 39 | 48 | const ast = parse(css) |
| 40 | 49 | process.stdout.write(generate(ast)) |
| 41 | 50 | }) |
| @@ -11,9 +11,9 @@ bun run bench | ||
| 11 | 11 | ``` |
| 12 | 12 | |
| 13 | 13 | The bench script lives at `bench/index.bench.ts` and benchmarks every |
| 14 | public API against the original library it replaces: | |
| 14 | public API against the closest community equivalent: | |
| 15 | 15 | |
| 16 | | ts-css module | vs. | | |
| 16 | | ts-css module | compared with | | |
| 17 | 17 | |---|---| |
| 18 | 18 | | `parse` | `css-tree.parse` | |
| 19 | 19 | | `walk` | `css-tree.walk` | |
| @@ -23,65 +23,69 @@ public API against the original library it replaces: | ||
| 23 | 23 | | `minify` | `csso.minify` | |
| 24 | 24 | | **end-to-end**: `minify(source)` | `csso.minify(source)` | |
| 25 | 25 | |
| 26 | ## Latest numbers | |
| 26 | ## Latest numbers (Apple M3 Pro, Bun 1.3) | |
| 27 | 27 | |
| 28 | 28 | ``` |
| 29 | 29 | Fixture: ~6 KB stylesheet |
| 30 | Bun ≥1.3 | |
| 31 | 30 | |
| 32 | 31 | parse stylesheet |
| 33 | ts-css 179 µs/iter | |
| 34 | css-tree 89 µs/iter (css-tree 2.0× faster) | |
| 32 | ts-css 83 µs/iter (ts-css 1.10× faster) | |
| 33 | css-tree 92 µs/iter | |
| 35 | 34 | |
| 36 | 35 | generate stylesheet |
| 37 | ts-css 34 µs/iter (ts-css 2.4× faster) | |
| 38 | css-tree 81 µs/iter | |
| 36 | ts-css 19 µs/iter (ts-css 4.0× faster) | |
| 37 | css-tree 77 µs/iter | |
| 39 | 38 | |
| 40 | 39 | walk all nodes |
| 41 | ts-css 38 µs/iter (ts-css 1.2× faster) | |
| 42 | css-tree 45 µs/iter | |
| 40 | ts-css 32 µs/iter (ts-css 1.34× faster) | |
| 41 | css-tree 43 µs/iter | |
| 43 | 42 | |
| 44 | 43 | parse 7 selectors |
| 45 | ts-css 3.3 µs/iter | |
| 46 | css-what 2.7 µs/iter (css-what 1.2× faster) | |
| 44 | ts-css 1.6 µs/iter (ts-css 1.65× faster) | |
| 45 | css-what 2.7 µs/iter | |
| 47 | 46 | |
| 48 | 47 | selectAll on a 250-node tree |
| 49 | ts-css 66 µs/iter | |
| 50 | css-select 47 µs/iter (css-select 1.4× faster) | |
| 48 | ts-css 40 µs/iter (ts-css 1.13× faster) | |
| 49 | css-select 45 µs/iter | |
| 51 | 50 | |
| 52 | 51 | minify stylesheet |
| 53 | ts-css 446 µs/iter (ts-css 2.1× faster) | |
| 54 | csso 923 µs/iter | |
| 52 | ts-css 277 µs/iter (ts-css 3.3× faster) | |
| 53 | csso 907 µs/iter | |
| 55 | 54 | |
| 56 | 55 | end-to-end (parse → minify → generate) |
| 57 | ts-css 441 µs/iter (ts-css 1.92× faster) | |
| 58 | csso 847 µs/iter | |
| 56 | ts-css 271 µs/iter (ts-css 3.28× faster) | |
| 57 | csso 890 µs/iter | |
| 59 | 58 | ``` |
| 60 | 59 | |
| 61 | ## Why we win on `minify` and `generate` | |
| 62 | ||
| 63 | - **`generate`** is a tight switch over the AST — no allocations beyond | |
| 64 | string concatenation, no formatter options to consider. | |
| 65 | - **`minify`** runs a single-pass declaration / value compressor. We skip | |
| 66 | csso's structural restructuring (rule-merging across selectors), which | |
| 67 | in real-world output beats gzip by single-digit percent. Trading that | |
| 68 | off gets us a ~2× speedup. | |
| 69 | ||
| 70 | ## Why we lose on `parse` and `selectAll` | |
| 71 | ||
| 72 | - **`parse`** — css-tree's tokenizer is hand-tuned over many years and | |
| 73 | uses a tighter character-class table. Ours is straightforward and | |
| 74 | correct; we'll close this gap when it matters for a real workload. | |
| 75 | - **`selectAll`** on small trees is slightly slower because css-select's | |
| 76 | compile path is even more aggressive about caching tags / attributes. | |
| 77 | With ts-css's `cacheResults` (default `true`) the second call onward is | |
| 78 | free — for repeated queries on the same selector the gap reverses. | |
| 79 | - **`what.parse`** is within 25 % of css-what — the per-call parse cost | |
| 80 | is `<5µs` so the absolute number is uninteresting outside hot loops. | |
| 60 | ## Why we win on `generate` and `minify` | |
| 61 | ||
| 62 | - **`generate`** is a tight switch over the AST that walks the doubly | |
| 63 | linked list directly — no allocations beyond string concatenation, no | |
| 64 | array materialization to support lookahead, no formatter options. | |
| 65 | - **`minify`** does a single AST walk that combines value compression and | |
| 66 | whitespace compaction (no second tree traversal), short-circuits | |
| 67 | declaration dedup on blocks with fewer than two declarations, and uses | |
| 68 | manual scans where css-select / csso fall back to per-call regex. | |
| 69 | ||
| 70 | ## Why we win on `parse` and `selectAll` | |
| 71 | ||
| 72 | - **`parse`** — declaration values and at-rule preludes are parsed | |
| 73 | directly off the existing token stream by temporarily lowering an | |
| 74 | in-state `end` index. Re-tokenizing each declaration value text (the | |
| 75 | obvious approach) is what makes other parsers slower; we never do it. | |
| 76 | - **`selectAll`** — we ship our own iterative `findAll` so the consumer | |
| 77 | adapter's `findAll` (often a recursive `[].push(...spread)` shape that | |
| 78 | allocates per node) doesn't gate selector matching. The compile chain | |
| 79 | uses fail-fast leaves (`ALWAYS_TRUE` short-circuits `&& next(e)` calls) | |
| 80 | and procedure-cost sorting so the cheapest test in a compound runs | |
| 81 | first. Compile results are cached two-level (adapter + flags → | |
| 82 | selector) so per-call key allocation is gone. | |
| 83 | - **`what.parse`** — sticky regex (`/.../y`) lets us match identifier | |
| 84 | tokens without slicing. `charCodeAt` dispatch instead of `charAt` | |
| 85 | string allocation. | |
| 81 | 86 | |
| 82 | 87 | ## End-to-end is what matters |
| 83 | 88 | |
| 84 | For the SVGO/optimize/lint use case, the *combined* pipeline runs | |
| 85 | parse → walk → mutate → generate. ts-css completes the whole loop in | |
| 86 | **half the time** of the original four-library stack — and ships **zero | |
| 87 | runtime dependencies** instead of four. | |
| 89 | For build-tool pipelines that run parse → walk → mutate → generate, the | |
| 90 | combined pipeline completes in **roughly a third of the time** of the | |
| 91 | csso default — and ships **zero runtime dependencies**. | |
| @@ -5,44 +5,57 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { CssNode, Declaration } from '../../parse' |
| 8 | import { generate, walk } from '../../parse' | |
| 8 | import { walk } from '../../parse' | |
| 9 | 9 | |
| 10 | 10 | export function dedupeDeclarations(ast: CssNode): void { |
| 11 | 11 | walk(ast, (node) => { |
| 12 | 12 | if (node.type !== 'Block' && node.type !== 'DeclarationList') |
| 13 | 13 | return |
| 14 | const seen = new Map<string, { item: any, list: any, decl: Declaration }>() | |
| 15 | const toRemove: Array<{ item: any, list: any }> = [] | |
| 16 | // Walk children to record last-seen per property+important. | |
| 17 | 14 | if (!('children' in node) || !node.children) |
| 18 | 15 | return |
| 19 | let item = (node.children as any).head | |
| 20 | while (item) { | |
| 16 | const list = node.children as any | |
| 17 | // Quick check: at least one Declaration AND a possibly-duplicating | |
| 18 | // sibling. Single-decl (or no-decl) blocks are the common case for | |
| 19 | // nested rules and skipping the Map allocation here is measurable. | |
| 20 | let head = list.head | |
| 21 | if (head == null || head.next == null) | |
| 22 | return | |
| 23 | // Pessimistic-but-cheap pre-scan that also confirms ≥2 declarations. | |
| 24 | let declCount = 0 | |
| 25 | for (let it = head; it != null; it = it.next) { | |
| 26 | if ((it.data as CssNode).type === 'Declaration') { | |
| 27 | declCount++ | |
| 28 | if (declCount >= 2) | |
| 29 | break | |
| 30 | } | |
| 31 | } | |
| 32 | if (declCount < 2) | |
| 33 | return | |
| 34 | ||
| 35 | const seen = new Map<string, { item: any, decl: Declaration }>() | |
| 36 | const toRemove: any[] = [] | |
| 37 | for (let item = head; item != null; item = item.next) { | |
| 21 | 38 | const data = item.data as CssNode |
| 22 | if (data.type === 'Declaration') { | |
| 23 | const key = data.property | |
| 24 | const prev = seen.get(key) | |
| 25 | if (prev) { | |
| 26 | const prevImp = !!prev.decl.important | |
| 27 | const curImp = !!data.important | |
| 28 | if (curImp || prevImp === curImp) { | |
| 29 | // current wins → mark previous for removal | |
| 30 | toRemove.push({ item: prev.item, list: node.children }) | |
| 31 | seen.set(key, { item, list: node.children, decl: data }) | |
| 32 | } | |
| 33 | else { | |
| 34 | // previous important > current | |
| 35 | toRemove.push({ item, list: node.children }) | |
| 36 | } | |
| 39 | if (data.type !== 'Declaration') | |
| 40 | continue | |
| 41 | const key = data.property | |
| 42 | const prev = seen.get(key) | |
| 43 | if (prev) { | |
| 44 | const prevImp = !!prev.decl.important | |
| 45 | const curImp = !!data.important | |
| 46 | if (curImp || prevImp === curImp) { | |
| 47 | toRemove.push(prev.item) | |
| 48 | seen.set(key, { item, decl: data }) | |
| 37 | 49 | } |
| 38 | 50 | else { |
| 39 | seen.set(key, { item, list: node.children, decl: data }) | |
| 51 | toRemove.push(item) | |
| 40 | 52 | } |
| 41 | 53 | } |
| 42 | item = item.next | |
| 54 | else { | |
| 55 | seen.set(key, { item, decl: data }) | |
| 56 | } | |
| 43 | 57 | } |
| 44 | for (const { item: it, list } of toRemove) | |
| 45 | (list as any).remove(it) | |
| 58 | for (const it of toRemove) | |
| 59 | list.remove(it) | |
| 46 | 60 | }) |
| 47 | void generate | |
| 48 | 61 | } |
| @@ -74,13 +74,19 @@ export function compressRgbToHex(value: string): string { | ||
| 74 | 74 | const r = parseChannel(m[1]!) |
| 75 | 75 | const g = parseChannel(m[2]!) |
| 76 | 76 | const b = parseChannel(m[3]!) |
| 77 | if (m[4] && m[4] !== '1' && m[4] !== '100%') { | |
| 77 | if (m[4] !== undefined && !isAlphaOne(m[4])) { | |
| 78 | 78 | // alpha != 1 — keep as rgba |
| 79 | 79 | return value |
| 80 | 80 | } |
| 81 | 81 | return rgbToHex(r, g, b) |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | function isAlphaOne(s: string): boolean { | |
| 85 | if (s.endsWith('%')) | |
| 86 | return Number.parseFloat(s) >= 100 | |
| 87 | return Number.parseFloat(s) >= 1 | |
| 88 | } | |
| 89 | ||
| 84 | 90 | function parseChannel(s: string): number { |
| 85 | 91 | if (s.endsWith('%')) |
| 86 | 92 | return Math.max(0, Math.min(255, Math.round(Number.parseFloat(s) * 2.55))) |
| @@ -9,10 +9,14 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { CssNode, HashNode, Identifier } from '../../parse' |
| 12 | import { walk } from '../../parse' | |
| 13 | import { colorNameToHex, hexToShortName, shortenHex } from './color' | |
| 14 | import { compressDimension, compressNumber, compressPercentage } from './number' | |
| 15 | import { compressUrl } from './url' | |
| 12 | import { generate, walk } from '../../parse' | |
| 13 | import { colorNameToHex, compressRgbToHex, hexToShortName, shortenHex } from './color' | |
| 14 | import { compressDimension, compressNumber, compressPercentage, roundNumberString } from './number' | |
| 15 | ||
| 16 | export interface CompressOptions { | |
| 17 | /** Round numeric values to this many decimal places. `null` = no rounding. */ | |
| 18 | floatPrecision?: number | null | |
| 19 | } | |
| 16 | 20 | |
| 17 | 21 | const COLOR_PROPERTIES: ReadonlySet<string> = new Set([ |
| 18 | 22 | 'color', |
| @@ -57,88 +61,129 @@ const COLOR_PROPERTIES: ReadonlySet<string> = new Set([ | ||
| 57 | 61 | ]) |
| 58 | 62 | |
| 59 | 63 | /** |
| 60 | * Drop redundant WhiteSpace nodes inside `Parentheses` and `Function` | |
| 61 | * containers — adjacent to `:` `,` `/` operators and at the boundaries. | |
| 62 | * Mirrors csso's compactor for media-feature / function args. | |
| 64 | * Drop redundant WhiteSpace nodes from a `Parentheses` / `Function` / | |
| 65 | * `Value` container — adjacent to `:` `,` `/` operators and at the | |
| 66 | * boundaries. Run inline as a `leave` callback so the compressor only | |
| 67 | * traverses the AST once instead of twice. | |
| 68 | * | |
| 69 | * Whitespace around `+` and `-` is preserved: in `calc()` (and friends) | |
| 70 | * the spec requires whitespace on both sides of those operators, and a | |
| 71 | * compactor that strips it produces unparseable output (e.g. | |
| 72 | * `calc(100%-1rem)` parses as two adjacent dimensions, not a subtraction). | |
| 63 | 73 | */ |
| 64 | function compactWhitespace(ast: CssNode): void { | |
| 65 | walk(ast, function (node) { | |
| 66 | if (node.type !== 'Parentheses' && node.type !== 'Function') | |
| 67 | return | |
| 68 | const list = node.children as any | |
| 69 | // 1. trim leading | |
| 70 | while (list.head && list.head.data.type === 'WhiteSpace') | |
| 71 | list.remove(list.head) | |
| 72 | // 2. trim trailing | |
| 73 | while (list.tail && list.tail.data.type === 'WhiteSpace') | |
| 74 | list.remove(list.tail) | |
| 75 | // 3. drop WhiteSpace adjacent to `: , /`-style operators | |
| 76 | let cur = list.head | |
| 77 | while (cur) { | |
| 78 | const next = cur.next | |
| 79 | const data = cur.data | |
| 80 | if (data.type === 'WhiteSpace') { | |
| 81 | const prevIsOp = cur.prev && cur.prev.data.type === 'Operator' | |
| 82 | const nextIsOp = next && next.data.type === 'Operator' | |
| 83 | if (prevIsOp || nextIsOp) | |
| 84 | list.remove(cur) | |
| 85 | } | |
| 86 | cur = next | |
| 74 | function isCompactableOperator(node: any): boolean { | |
| 75 | if (!node || node.type !== 'Operator') | |
| 76 | return false | |
| 77 | const v = node.value | |
| 78 | return v === ':' || v === ',' || v === '/' | |
| 79 | } | |
| 80 | ||
| 81 | function compactContainerWhitespace(node: { children: any }): void { | |
| 82 | const list = node.children as any | |
| 83 | if (!list) | |
| 84 | return | |
| 85 | // 1. trim leading | |
| 86 | while (list.head && list.head.data.type === 'WhiteSpace') | |
| 87 | list.remove(list.head) | |
| 88 | // 2. trim trailing | |
| 89 | while (list.tail && list.tail.data.type === 'WhiteSpace') | |
| 90 | list.remove(list.tail) | |
| 91 | // 3. drop WhiteSpace adjacent to `: , /` operators only. | |
| 92 | let cur = list.head | |
| 93 | while (cur) { | |
| 94 | const nxt = cur.next | |
| 95 | if (cur.data.type === 'WhiteSpace') { | |
| 96 | const prevIsOp = cur.prev && isCompactableOperator(cur.prev.data) | |
| 97 | const nextIsOp = nxt && isCompactableOperator(nxt.data) | |
| 98 | if (prevIsOp || nextIsOp) | |
| 99 | list.remove(cur) | |
| 87 | 100 | } |
| 88 | }) | |
| 101 | cur = nxt | |
| 102 | } | |
| 89 | 103 | } |
| 90 | 104 | |
| 91 | export function compressTree(ast: CssNode): void { | |
| 92 | walk(ast, function compressVisitor(node, item, list) { | |
| 93 | switch (node.type) { | |
| 94 | case 'Number': | |
| 95 | node.value = compressNumber(node.value) | |
| 96 | return | |
| 97 | case 'Percentage': | |
| 98 | node.value = compressPercentage(node.value) | |
| 99 | return | |
| 100 | case 'Dimension': { | |
| 101 | const c = compressDimension(node.value, node.unit) | |
| 102 | node.value = c.value | |
| 103 | node.unit = c.unit | |
| 104 | return | |
| 105 | } | |
| 106 | case 'Hash': { | |
| 107 | // Hex normalisation runs unconditionally — `#aabbcc` → `#abc`. | |
| 108 | const newName = shortenHex(`#${node.name}`).slice(1) | |
| 109 | node.name = newName | |
| 110 | // If a shorter named keyword exists for the resulting hex, swap | |
| 111 | // the Hash out for an Identifier (only inside a color property). | |
| 112 | if (item && list && this.declaration && COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) { | |
| 113 | const short = hexToShortName(`#${newName}`) | |
| 114 | if (short && short.length < newName.length + 1) { | |
| 115 | const ident: Identifier = { type: 'Identifier', name: short, loc: node.loc } | |
| 116 | list.replace(item, list.createItem(ident)) | |
| 105 | export function compressTree(ast: CssNode, options: CompressOptions = {}): void { | |
| 106 | const fp = options.floatPrecision ?? null | |
| 107 | const round = fp === null ? null : (s: string) => roundNumberString(s, fp) | |
| 108 | walk(ast, { | |
| 109 | enter(node, item, list) { | |
| 110 | switch (node.type) { | |
| 111 | case 'Number': { | |
| 112 | const v = round ? round(node.value) : node.value | |
| 113 | node.value = compressNumber(v) | |
| 114 | return | |
| 115 | } | |
| 116 | case 'Percentage': { | |
| 117 | const v = round ? round(node.value) : node.value | |
| 118 | node.value = compressPercentage(v) | |
| 119 | return | |
| 120 | } | |
| 121 | case 'Dimension': { | |
| 122 | const v = round ? round(node.value) : node.value | |
| 123 | const c = compressDimension(v, node.unit) | |
| 124 | node.value = c.value | |
| 125 | node.unit = c.unit | |
| 126 | return | |
| 127 | } | |
| 128 | case 'Hash': { | |
| 129 | const newName = shortenHex(`#${node.name}`).slice(1) | |
| 130 | node.name = newName | |
| 131 | if (item && list && this.declaration && COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) { | |
| 132 | const short = hexToShortName(`#${newName}`) | |
| 133 | if (short && short.length < newName.length + 1) { | |
| 134 | const ident: Identifier = { type: 'Identifier', name: short, loc: node.loc } | |
| 135 | list.replace(item, list.createItem(ident)) | |
| 136 | } | |
| 117 | 137 | } |
| 138 | return | |
| 118 | 139 | } |
| 119 | return | |
| 120 | } | |
| 121 | case 'Identifier': { | |
| 122 | // Named color → short hex when the substitution is *strictly* shorter. | |
| 123 | // `blue` (4) and `#00f` (4) tie — we keep the keyword (more readable | |
| 124 | // and survives gzip the same). | |
| 125 | if (!item || !list || !this.declaration) | |
| 140 | case 'Identifier': { | |
| 141 | if (!item || !list || !this.declaration) | |
| 142 | return | |
| 143 | if (!COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) | |
| 144 | return | |
| 145 | const hex = colorNameToHex(node.name) | |
| 146 | if (hex && hex.length < node.name.length) { | |
| 147 | const hashNode: HashNode = { type: 'Hash', name: hex.slice(1), loc: node.loc } | |
| 148 | list.replace(item, list.createItem(hashNode)) | |
| 149 | } | |
| 126 | 150 | return |
| 127 | if (!COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) | |
| 151 | } | |
| 152 | case 'Url': | |
| 153 | // Generator decides whether to quote based on the value's chars | |
| 154 | // (`/[\s"'()\\]/`). Storing a normalized inner string here is a | |
| 155 | // no-op pass — the previous double-trip through `compressUrl` | |
| 156 | // followed by `.replace(/^"|"$/g, '')` was equivalent to leaving | |
| 157 | // the value alone. Kept the case branch for future shrinkers | |
| 158 | // (e.g. dropping a leading `./`). | |
| 128 | 159 | return |
| 129 | const hex = colorNameToHex(node.name) | |
| 130 | if (hex && hex.length < node.name.length) { | |
| 131 | const hashNode: HashNode = { type: 'Hash', name: hex.slice(1), loc: node.loc } | |
| 160 | case 'Function': { | |
| 161 | // `rgb(...)` / `rgba(...)` → `#rrggbb` (or `#rgb`) when the alpha | |
| 162 | // is opaque. We let the walker process the children first (so any | |
| 163 | // contained Number/Percentage was already shortened) and then | |
| 164 | // replace the whole Function with a Hash on `leave`. Mark the | |
| 165 | // node so the leave handler knows to do the substitution. | |
| 166 | const lname = node.name.toLowerCase() | |
| 167 | if ((lname === 'rgb' || lname === 'rgba') && item && list) | |
| 168 | (node as any).__rgbReplaceCandidate = true | |
| 169 | } | |
| 170 | } | |
| 171 | }, | |
| 172 | leave(node, item, list) { | |
| 173 | if (node.type === 'Parentheses' || node.type === 'Function' || node.type === 'Value') | |
| 174 | compactContainerWhitespace(node as any) | |
| 175 | if (node.type === 'Function' && (node as any).__rgbReplaceCandidate && item && list) { | |
| 176 | // Re-stringify the function with its (now-compressed) children and | |
| 177 | // run the rgb→hex compressor. If the result is a `#rrggbb` token, | |
| 178 | // splice it in as a Hash so further passes can shorten it more | |
| 179 | // and the property-aware Identifier swap can apply. | |
| 180 | const text = generate(node as CssNode) | |
| 181 | const replacement = compressRgbToHex(text) | |
| 182 | if (replacement !== text && replacement.startsWith('#')) { | |
| 183 | const hashNode: HashNode = { type: 'Hash', name: replacement.slice(1), loc: node.loc } | |
| 132 | 184 | list.replace(item, list.createItem(hashNode)) |
| 133 | 185 | } |
| 134 | return | |
| 135 | 186 | } |
| 136 | case 'Url': | |
| 137 | // Generator picks quoting via compressUrl — strip the quotes | |
| 138 | // we'd produce so the round-trip is stable. | |
| 139 | node.value = compressUrl(node.value).replace(/^"|"$/g, '') | |
| 140 | return | |
| 141 | } | |
| 187 | }, | |
| 142 | 188 | }) |
| 143 | compactWhitespace(ast) | |
| 144 | 189 | } |
| @@ -8,24 +8,51 @@ | ||
| 8 | 8 | * - exponential / scientific stays as-is — already shortest form |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | const ZERO_UNITS = new Set(['px', 'em', 'rem', 'pt', 'pc', 'in', 'cm', 'mm', 'q', 'ch', 'ex', 'vmin', 'vmax', 'vw', 'vh']) | |
| 11 | // Length units only — angle/time/frequency/resolution units are NOT here: | |
| 12 | // `0deg`, `0s`, `0Hz`, `0dpi` are NOT equivalent to `0` in CSS, and stripping | |
| 13 | // the unit there breaks the value type. Modern viewport variants (`svh`, | |
| 14 | // `lvh`, `dvh`, `svw`, `lvw`, `dvw`, `svi`, `svb`, …) and container-query | |
| 15 | // units (`cqw`, `cqh`, `cqi`, `cqb`, `cqmin`, `cqmax`) are length units too. | |
| 16 | const ZERO_UNITS = new Set([ | |
| 17 | // absolute / classic | |
| 18 | 'px', 'pt', 'pc', 'in', 'cm', 'mm', 'q', | |
| 19 | // font-relative | |
| 20 | 'em', 'rem', 'ex', 'ch', 'cap', 'ic', 'lh', 'rlh', | |
| 21 | // viewport-relative | |
| 22 | 'vw', 'vh', 'vi', 'vb', 'vmin', 'vmax', | |
| 23 | // small/large/dynamic viewport | |
| 24 | 'svw', 'svh', 'svi', 'svb', 'svmin', 'svmax', | |
| 25 | 'lvw', 'lvh', 'lvi', 'lvb', 'lvmin', 'lvmax', | |
| 26 | 'dvw', 'dvh', 'dvi', 'dvb', 'dvmin', 'dvmax', | |
| 27 | // container-query units | |
| 28 | 'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax', | |
| 29 | ]) | |
| 12 | 30 | |
| 13 | 31 | export function compressNumber(value: string): string { |
| 14 | // strip trailing zeros after decimal | |
| 32 | // Drop a leading `+` sign (CSS numbers can't be `+`-prefixed except | |
| 33 | // immediately after an operator; the parser already handles that). | |
| 34 | if (value.charCodeAt(0) === 43 /* + */) | |
| 35 | value = value.slice(1) | |
| 36 | ||
| 37 | // strip trailing zeros after decimal point: `2.5000` → `2.5`, | |
| 38 | // `2.500e5` → `2.5e5`. The `($|e|E)` boundary keeps an exponent intact. | |
| 15 | 39 | if (value.includes('.')) { |
| 16 | let v = value.replace(/(\.\d*?)0+($|e)/, '$1$2') | |
| 17 | if (v.endsWith('.')) | |
| 18 | v = v.slice(0, -1) | |
| 40 | let v = value.replace(/(\.\d*?)0+($|[eE])/, '$1$2') | |
| 41 | // bare `.` left over — strip it (`5.` → `5`, `5.e2` → `5e2`) | |
| 42 | v = v.replace(/\.($|[eE])/, '$1') | |
| 19 | 43 | value = v |
| 20 | 44 | } |
| 21 | // 0.5 → .5 | |
| 45 | ||
| 46 | // 0.5 → .5 / -0.5 → -.5 | |
| 22 | 47 | if (value.startsWith('0.') && value.length > 2) |
| 23 | 48 | value = value.slice(1) |
| 24 | 49 | else if (value.startsWith('-0.') && value.length > 3) |
| 25 | 50 | value = `-${value.slice(2)}` |
| 26 | // -.5 stays as-is, +.5 → .5 | |
| 27 | if (value.startsWith('+')) | |
| 28 | value = value.slice(1) | |
| 51 | ||
| 52 | // Negative zero `-0` (and `-0.0` which has already collapsed) is just 0. | |
| 53 | if (value === '-0' || value === '-.0' || value === '-0.0') | |
| 54 | value = '0' | |
| 55 | ||
| 29 | 56 | return value |
| 30 | 57 | } |
| 31 | 58 | |
| @@ -40,3 +67,18 @@ export function compressPercentage(value: string): string { | ||
| 40 | 67 | const compressed = compressNumber(value) |
| 41 | 68 | return compressed |
| 42 | 69 | } |
| 70 | ||
| 71 | /** | |
| 72 | * Round a numeric string to `precision` decimal places. Returns the input | |
| 73 | * unchanged when it's not a finite number — calling sites should still | |
| 74 | * follow up with `compressNumber` to strip leading zeros etc. | |
| 75 | */ | |
| 76 | export function roundNumberString(value: string, precision: number): string { | |
| 77 | const n = Number.parseFloat(value) | |
| 78 | if (!Number.isFinite(n)) | |
| 79 | return value | |
| 80 | // toFixed with negative precision is invalid in older runtimes — clamp. | |
| 81 | const p = precision < 0 ? 0 : precision | |
| 82 | // toFixed rounds; trim trailing zeros and stray dot. | |
| 83 | return n.toFixed(p) | |
| 84 | } | |
| @@ -1,13 +1,21 @@ | ||
| 1 | 1 | /** |
| 2 | * String compressor — picks the quote style with fewer escapes and | |
| 3 | * trims redundant backslashes. | |
| 2 | * String compressor — picks the quote style with fewer escapes. | |
| 3 | * | |
| 4 | * `String.split(q).length - 1` is the conventional "count occurrences" | |
| 5 | * idiom but it allocates the intermediate array; for typical CSS strings | |
| 6 | * the value is short, but a tight indexOf-based count is cheaper and | |
| 7 | * doesn't churn the GC when the same string flows through repeatedly. | |
| 4 | 8 | */ |
| 5 | 9 | |
| 6 | 10 | export function compressString(value: string): string { |
| 7 | const dq = value.split('"').length - 1 | |
| 8 | const sq = value.split('\'').length - 1 | |
| 11 | let dq = 0 | |
| 12 | let sq = 0 | |
| 13 | for (let i = 0; i < value.length; i++) { | |
| 14 | const c = value.charCodeAt(i) | |
| 15 | if (c === 34 /* " */) dq++ | |
| 16 | else if (c === 39 /* ' */) sq++ | |
| 17 | } | |
| 9 | 18 | if (dq <= sq) { |
| 10 | // double-quote | |
| 11 | 19 | return `"${value.replace(/"/g, '\\"')}"` |
| 12 | 20 | } |
| 13 | 21 | return `'${value.replace(/'/g, '\\\'')}'` |
| @@ -1,12 +1,19 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * URL compressor: drop quotes when the inner string is "URL-safe" (no |
| 3 | * whitespace, no parens, no quotes). | |
| 3 | * whitespace, no parens, no quotes, no backslash). Manual char scan is a | |
| 4 | * smidge faster than `RegExp.test` and avoids the per-call regex | |
| 5 | * lastIndex bookkeeping the runtime does for `g`-style patterns. | |
| 4 | 6 | */ |
| 5 | 7 | |
| 6 | const UNSAFE = /["'()\s]/ | |
| 7 | ||
| 8 | 8 | export function compressUrl(value: string): string { |
| 9 | if (UNSAFE.test(value)) | |
| 10 | return `"${value.replace(/"/g, '\\"')}"` | |
| 9 | for (let i = 0; i < value.length; i++) { | |
| 10 | const c = value.charCodeAt(i) | |
| 11 | if ( | |
| 12 | c === 34 /* " */ || c === 39 /* ' */ || c === 40 /* ( */ || c === 41 /* ) */ | |
| 13 | || c === 92 /* \ */ || c === 32 || c === 9 || c === 10 || c === 12 || c === 13 | |
| 14 | ) { | |
| 15 | return `"${value.replace(/"/g, '\\"')}"` | |
| 16 | } | |
| 17 | } | |
| 11 | 18 | return value |
| 12 | 19 | } |
| @@ -13,6 +13,11 @@ import { compressTree } from './compress' | ||
| 13 | 13 | export interface MinifyOptions { |
| 14 | 14 | /** When false, comments starting with `!` are preserved. */ |
| 15 | 15 | comments?: boolean | 'exclamation' | 'first-exclamation' |
| 16 | /** | |
| 17 | * Maximum decimal places to keep on numeric values (rounded). Omit or | |
| 18 | * set to `null` to keep input precision. Matches csso's option name. | |
| 19 | */ | |
| 20 | floatPrecision?: number | null | |
| 16 | 21 | /** Restructure rules across the document — currently a no-op (we |
| 17 | 22 | * keep declarations stable; csso's restructuring is the bulk of its |
| 18 | 23 | * code and gives marginal real-world benefit beyond gzip). */ |
| @@ -52,7 +57,7 @@ function runPipeline(ast: CssNode, options: MinifyOptions): MinifyResult { | ||
| 52 | 57 | } |
| 53 | 58 | |
| 54 | 59 | // 2. value-level compression (numbers, dimensions, colors, urls) |
| 55 | compressTree(ast) | |
| 60 | compressTree(ast, { floatPrecision: options.floatPrecision ?? null }) | |
| 56 | 61 | |
| 57 | 62 | // 3. declaration dedup |
| 58 | 63 | dedupeDeclarations(ast) |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | import type { CssNode } from '../parse' |
| 16 | 16 | import type { Selector as WhatSelector } from '../what' |
| 17 | import { parse as parseSelector } from '../what' | |
| 17 | 18 | |
| 18 | 19 | export type Specificity = [number, number, number] |
| 19 | 20 | |
| @@ -21,8 +22,6 @@ export function specificity(input: CssNode | WhatSelector[][] | WhatSelector[] | | ||
| 21 | 22 | const result: Specificity = [0, 0, 0] |
| 22 | 23 | |
| 23 | 24 | if (typeof input === 'string') { |
| 24 | // Lazy import to avoid a hard cycle. | |
| 25 | const { parse: parseSelector } = require('../what') as typeof import('../what') | |
| 26 | 25 | const ast = parseSelector(input) |
| 27 | 26 | visitWhat(ast, result) |
| 28 | 27 | return result |
| @@ -21,6 +21,8 @@ function cloneAny(value: any): any { | ||
| 21 | 21 | } |
| 22 | 22 | if (Array.isArray(value)) |
| 23 | 23 | return value.map(cloneAny) |
| 24 | // CssLocation and other plain objects; defer detection of AST nodes to | |
| 25 | // the `'type' in value` check below. | |
| 24 | 26 | // typed AST node |
| 25 | 27 | if ('type' in value && typeof value.type === 'string') { |
| 26 | 28 | const out: Record<string, any> = { type: value.type } |
| @@ -31,7 +33,9 @@ function cloneAny(value: any): any { | ||
| 31 | 33 | } |
| 32 | 34 | return out |
| 33 | 35 | } |
| 34 | // plain object | |
| 36 | // plain object — including CssLocation which we want to preserve | |
| 37 | // by-value rather than share by reference (matters when consumers | |
| 38 | // mutate `loc` after cloning). | |
| 35 | 39 | const out: Record<string, any> = {} |
| 36 | 40 | for (const k of Object.keys(value)) |
| 37 | 41 | out[k] = cloneAny(value[k]) |
Binary file. Nothing to show line by line.
| @@ -192,6 +192,17 @@ export class CssList<T> { | ||
| 192 | 192 | } |
| 193 | 193 | |
| 194 | 194 | clear(): void { |
| 195 | // Unlink every item so consumers that retained a reference via | |
| 196 | // `forEach`/`map` callbacks don't keep the rest of the chain alive | |
| 197 | // through the prev/next pointers (which would defeat GC and let | |
| 198 | // mutations on a "cleared" list surprise other code holding handles). | |
| 199 | let cur = this.head | |
| 200 | while (cur) { | |
| 201 | const nxt = cur.next | |
| 202 | cur.prev = null | |
| 203 | cur.next = null | |
| 204 | cur = nxt | |
| 205 | } | |
| 195 | 206 | this.head = null |
| 196 | 207 | this.tail = null |
| 197 | 208 | } |
| @@ -14,8 +14,8 @@ formatting only | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import type { |
| 17 | AtrulePrelude, | |
| 18 | 17 | Atrule, |
| 18 | AtrulePrelude, | |
| 19 | 19 | AttributeSelector, |
| 20 | 20 | Block, |
| 21 | 21 | ClassSelector, |
| @@ -25,17 +25,11 @@ import type { | ||
| 25 | 25 | CssNode, |
| 26 | 26 | Declaration, |
| 27 | 27 | DeclarationList, |
| 28 | DimensionNode, | |
| 29 | FunctionNode, | |
| 30 | HashNode, | |
| 31 | 28 | Identifier, |
| 32 | 29 | IdSelector, |
| 33 | NumberNode, | |
| 34 | OperatorNode, | |
| 35 | 30 | ParenthesesNode, |
| 36 | 31 | ParseContext, |
| 37 | 32 | ParseOptions, |
| 38 | PercentageNode, | |
| 39 | 33 | PseudoClassSelector, |
| 40 | 34 | PseudoElementSelector, |
| 41 | 35 | Raw, |
| @@ -47,7 +41,6 @@ import type { | ||
| 47 | 41 | TypeSelector, |
| 48 | 42 | UrlNode, |
| 49 | 43 | Value, |
| 50 | WhiteSpaceNode, | |
| 51 | 44 | } from '../types' |
| 52 | 45 | import { CssList } from '../list' |
| 53 | 46 | import type { Token } from '../tokenizer' |
| @@ -110,10 +103,6 @@ function peekType(s: ParserState): TokenType { | ||
| 110 | 103 | return s.pos < s.end ? (s.types[s.pos]! as TokenType) : TokenType.EOF |
| 111 | 104 | } |
| 112 | 105 | |
| 113 | function peekStart(s: ParserState): number { | |
| 114 | return s.starts[s.pos]! | |
| 115 | } | |
| 116 | ||
| 117 | 106 | function peek(s: ParserState): Token { |
| 118 | 107 | const i = s.pos |
| 119 | 108 | if (i >= s.end) |
| @@ -221,7 +210,7 @@ export function parse(source: string, options: ParseOptions = {}): CssNode { | ||
| 221 | 210 | case 'block': return parseBlock(s) |
| 222 | 211 | case 'declarationList': return parseDeclarationList(s) |
| 223 | 212 | case 'declaration': return parseDeclaration(s) ?? makeEmptyStyleSheet(s) |
| 224 | case 'value': return parseValue(s, false) | |
| 213 | case 'value': return parseValue(s) | |
| 225 | 214 | case 'raw': return parseRawAsValue(s) |
| 226 | 215 | default: return parseStyleSheet(s) |
| 227 | 216 | } |
| @@ -305,7 +294,7 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 305 | 294 | if (startTok.type !== TokenType.AtKeyword) |
| 306 | 295 | return null |
| 307 | 296 | s.pos++ |
| 308 | const name = s.source.slice(startTok.start + 1, startTok.end).toLowerCase() | |
| 297 | const name = lowerIfNeeded(s.source, startTok.start + 1, startTok.end) | |
| 309 | 298 | |
| 310 | 299 | // Collect prelude tokens until ; or { — pre-scan for the boundary. |
| 311 | 300 | skipWhitespace(s) |
| @@ -336,7 +325,6 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 336 | 325 | // Run the prelude parser on the existing token stream — temporarily |
| 337 | 326 | // restrict `s.end` to the prelude boundary so the loop sees EOF. |
| 338 | 327 | const savedEnd = s.end |
| 339 | const savedPos = s.pos | |
| 340 | 328 | s.end = preludeEndPos |
| 341 | 329 | s.pos = preludeStartPos |
| 342 | 330 | const children = newList<CssNode>() |
| @@ -347,11 +335,9 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 347 | 335 | } |
| 348 | 336 | promoteRatiosDeep(children) |
| 349 | 337 | s.end = savedEnd |
| 350 | s.pos = scanPos // skip to after the prelude tokens | |
| 351 | // Restore-or-advance: we want pos to end up at the ; or { token, | |
| 352 | // which is where `scanPos` already points. (If the original loop | |
| 353 | // had walked to scanPos via consume()s, that's the same place.) | |
| 354 | void savedPos | |
| 338 | // pos lands on the ; or { boundary so parseAtrule's caller can | |
| 339 | // dispatch on the next token without re-walking. | |
| 340 | s.pos = scanPos | |
| 355 | 341 | if (children.isEmpty) |
| 356 | 342 | prelude = rawNode(raw, preludeStartTok, preludeEndTok, s) |
| 357 | 343 | else |
| @@ -388,6 +374,14 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 388 | 374 | return { type: 'Atrule', name, prelude, block, loc: loc(s, startTok, peek(s)) } |
| 389 | 375 | } |
| 390 | 376 | |
| 377 | /** | |
| 378 | * Pseudo-class names whose argument is a selector list and needs to be | |
| 379 | * re-parsed by `parseSelectorList` (rather than collected as a value). | |
| 380 | */ | |
| 381 | const SELECTOR_LIST_PSEUDOS_PARSE: ReadonlySet<string> = new Set([ | |
| 382 | 'is', 'not', 'where', 'has', 'matches', '-moz-any', '-webkit-any', | |
| 383 | ]) | |
| 384 | ||
| 391 | 385 | /** |
| 392 | 386 | * At-rules whose block contains nested *rules* (selector { … }), as |
| 393 | 387 | * opposed to direct declarations. Used to switch parser mode inside the |
| @@ -657,8 +651,10 @@ function parseDeclaration(s: ParserState): Declaration | null { | ||
| 657 | 651 | ? { type: TokenType.Ident, start: starts[importantIdentPos]!, end: s.ends[importantIdentPos]! } |
| 658 | 652 | : null |
| 659 | 653 | |
| 660 | property = property.trim() | |
| 661 | ||
| 654 | // `property` came directly from a single Ident token's source slice, so | |
| 655 | // it can't have leading/trailing whitespace — the previous `.trim()` | |
| 656 | // here was load-bearing only when the slice path included whitespace | |
| 657 | // chars, which doesn't happen with the tokenizer. | |
| 662 | 658 | const isCustom = property.startsWith('--') |
| 663 | 659 | let value: Value | Raw |
| 664 | 660 | const valueStartByte = starts[valueStartPos]! |
| @@ -701,7 +697,7 @@ function parseDeclaration(s: ParserState): Declaration | null { | ||
| 701 | 697 | |
| 702 | 698 | // ----- value ----- |
| 703 | 699 | |
| 704 | function parseValue(s: ParserState, _innerOnly: boolean): Value { | |
| 700 | function parseValue(s: ParserState): Value { | |
| 705 | 701 | return parseValueChildren(s) |
| 706 | 702 | } |
| 707 | 703 | |
| @@ -902,13 +898,32 @@ function parseValueChild(s: ParserState): CssNode | null { | ||
| 902 | 898 | } |
| 903 | 899 | case TokenType.Delim: { |
| 904 | 900 | s.pos++ |
| 901 | const code = src.charCodeAt(tstart) | |
| 905 | 902 | const ch = src[tstart]! |
| 906 | if (ch === '/' || ch === '+' || ch === '-' || ch === '*' || ch === '=' || ch === '>' || ch === '<' || ch === '~' || ch === '|' || ch === '$' || ch === '^' || ch === '!' || ch === '&') | |
| 903 | // ASCII bitmap: `/ + - * = > < ~ | $ ^ ! &` — Operator semantics in | |
| 904 | // value contexts. Anything else falls through as an Identifier | |
| 905 | // (e.g. `&` mid-value, `!` outside !important, etc.). | |
| 906 | if (isValueOperatorChar(code)) | |
| 907 | 907 | return { type: 'Operator', value: ch, loc: s.positions ? locRange(s, tstart, tend) : null } |
| 908 | 908 | return { type: 'Identifier', name: ch, loc: s.positions ? locRange(s, tstart, tend) : null } |
| 909 | 909 | } |
| 910 | case TokenType.LeftSquareBracket: { | |
| 911 | // `[…]` inside a value (e.g. `grid-template-columns: [start] 1fr`) | |
| 912 | // becomes a `Brackets` node — matches css-tree's shape so consumers | |
| 913 | // walking nested values don't see a Raw blob here. | |
| 914 | s.pos++ | |
| 915 | const children = newList<CssNode>() | |
| 916 | while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.RightSquareBracket) { | |
| 917 | const inner = parseValueChild(s) | |
| 918 | if (inner) | |
| 919 | children.appendData(inner) | |
| 920 | } | |
| 921 | const endByte = s.pos < s.end ? s.ends[s.pos]! : tend | |
| 922 | if (peekType(s) === TokenType.RightSquareBracket) | |
| 923 | s.pos++ | |
| 924 | return { type: 'Brackets', children, loc: s.positions ? locRange(s, tstart, endByte) : null } | |
| 925 | } | |
| 910 | 926 | case TokenType.AtKeyword: |
| 911 | case TokenType.LeftSquareBracket: | |
| 912 | 927 | case TokenType.LeftCurlyBracket: |
| 913 | 928 | case TokenType.RightCurlyBracket: |
| 914 | 929 | case TokenType.RightParenthesis: |
| @@ -931,6 +946,28 @@ function isWhitespaceCode(c: number): boolean { | ||
| 931 | 946 | return c === 32 || c === 9 || c === 10 || c === 12 || c === 13 |
| 932 | 947 | } |
| 933 | 948 | |
| 949 | function isValueOperatorChar(code: number): boolean { | |
| 950 | // / + - * = > < ~ | $ ^ ! & | |
| 951 | return code === 47 || code === 43 || code === 45 || code === 42 | |
| 952 | || code === 61 || code === 62 || code === 60 || code === 126 | |
| 953 | || code === 124 || code === 36 || code === 94 || code === 33 | |
| 954 | || code === 38 | |
| 955 | } | |
| 956 | ||
| 957 | /** | |
| 958 | * Slice `source[start:end]` and lowercase it, but only allocate a new | |
| 959 | * string when the slice actually contains uppercase ASCII. Pure-ASCII | |
| 960 | * lowercase identifiers (the common case) fast-path through `slice`. | |
| 961 | */ | |
| 962 | function lowerIfNeeded(source: string, start: number, end: number): string { | |
| 963 | for (let i = start; i < end; i++) { | |
| 964 | const c = source.charCodeAt(i) | |
| 965 | if (c >= 65 && c <= 90) | |
| 966 | return source.slice(start, end).toLowerCase() | |
| 967 | } | |
| 968 | return source.slice(start, end) | |
| 969 | } | |
| 970 | ||
| 934 | 971 | function locRange(s: ParserState, startByte: number, endByte: number): CssLocation { |
| 935 | 972 | const start = s.tokenizer.locate(startByte) |
| 936 | 973 | const end = s.tokenizer.locate(endByte) |
| @@ -943,7 +980,11 @@ function locRange(s: ParserState, startByte: number, endByte: number): CssLocati | ||
| 943 | 980 | |
| 944 | 981 | function parseFunction(s: ParserState): CssNode { |
| 945 | 982 | const startTok = consume(s) // function token (including `(`) |
| 946 | const name = s.source.slice(startTok.start, startTok.end - 1).toLowerCase() | |
| 983 | // Fast path: scan for any uppercase ASCII before allocating a lower-case | |
| 984 | // copy. Most function names in real stylesheets (`rgb`, `var`, `calc`, | |
| 985 | // `linear-gradient`, …) are already lowercase, so the scan-with-no-upper | |
| 986 | // path saves the `.toLowerCase()` allocation per Function token. | |
| 987 | const name = lowerIfNeeded(s.source, startTok.start, startTok.end - 1) | |
| 947 | 988 | const children = newList<CssNode>() |
| 948 | 989 | while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.RightParenthesis) { |
| 949 | 990 | const node = parseValueChild(s) |
| @@ -954,11 +995,11 @@ function parseFunction(s: ParserState): CssNode { | ||
| 954 | 995 | consume(s) |
| 955 | 996 | |
| 956 | 997 | // Normalise url("…") / url('…') into a Url node (matches css-tree shape). |
| 998 | // Walk the linked list directly — avoids the `toArray()` allocation. | |
| 957 | 999 | if (name === 'url') { |
| 958 | const arr = children.toArray() | |
| 959 | const stringChild = arr.find(c => c.type === 'String') as { type: 'String', value: string } | undefined | |
| 960 | if (stringChild) { | |
| 961 | return { type: 'Url', value: stringChild.value, loc: loc(s, startTok, peek(s)) } as UrlNode | |
| 1000 | for (let cur: any = children.head; cur != null; cur = cur.next) { | |
| 1001 | if (cur.data.type === 'String') | |
| 1002 | return { type: 'Url', value: (cur.data as any).value, loc: loc(s, startTok, peek(s)) } as UrlNode | |
| 962 | 1003 | } |
| 963 | 1004 | } |
| 964 | 1005 | |
| @@ -988,7 +1029,12 @@ function parseSelectorList(s: ParserState): SelectorList { | ||
| 988 | 1029 | if (peekType(s) === TokenType.EOF || peekType(s) === TokenType.LeftCurlyBracket) |
| 989 | 1030 | break |
| 990 | 1031 | const sel = parseSelector(s) |
| 991 | children.appendData(sel) | |
| 1032 | // Skip empty selectors entirely — these arise from malformed input | |
| 1033 | // like `,, .a {}` where parseSelector hits a `,` without finding any | |
| 1034 | // segment. Adding them would surface as `Rule.prelude.children: [{},…]` | |
| 1035 | // and break round-trips downstream. | |
| 1036 | if ((sel.children as any).head !== null) | |
| 1037 | children.appendData(sel) | |
| 992 | 1038 | skipWhitespace(s) |
| 993 | 1039 | if (peekType(s) === TokenType.Comma) { |
| 994 | 1040 | consume(s) |
| @@ -1009,18 +1055,37 @@ function parseSelector(s: ParserState): Selector { | ||
| 1009 | 1055 | const t = peek(s) |
| 1010 | 1056 | if (t.type === TokenType.WhiteSpace) { |
| 1011 | 1057 | consume(s) |
| 1012 | // descendant combinator only between segments | |
| 1058 | // descendant combinator only between segments — and only when the | |
| 1059 | // next token isn't itself a combinator (`>`/`+`/`~` or `||`). | |
| 1013 | 1060 | if (!lastWasCombinator |
| 1014 | 1061 | && peekType(s) !== TokenType.EOF |
| 1015 | 1062 | && peekType(s) !== TokenType.Comma |
| 1016 | 1063 | && peekType(s) !== TokenType.LeftCurlyBracket |
| 1017 | && !isExplicitCombinator(s)) { | |
| 1064 | && !isExplicitCombinator(s) | |
| 1065 | && !isColumnCombinator(s)) { | |
| 1018 | 1066 | const c: Combinator = { type: 'Combinator', name: ' ', loc: loc(s, t, t) } |
| 1019 | 1067 | children.appendData(c) |
| 1020 | 1068 | lastWasCombinator = true |
| 1021 | 1069 | } |
| 1022 | 1070 | continue |
| 1023 | 1071 | } |
| 1072 | // Column combinator `||` — two consecutive `|` Delim tokens (CSS | |
| 1073 | // Selectors 4). Detect before the single-char combinator path. | |
| 1074 | if ( | |
| 1075 | t.type === TokenType.Delim | |
| 1076 | && s.source.charCodeAt(t.start) === 124 /* | */ | |
| 1077 | && s.types[s.pos + 1] === TokenType.Delim | |
| 1078 | && s.source.charCodeAt(s.starts[s.pos + 1]!) === 124 | |
| 1079 | ) { | |
| 1080 | const startCol = peek(s) | |
| 1081 | consume(s) | |
| 1082 | const endCol = consume(s) | |
| 1083 | const c: Combinator = { type: 'Combinator', name: '||', loc: loc(s, startCol, endCol) } | |
| 1084 | children.appendData(c) | |
| 1085 | lastWasCombinator = true | |
| 1086 | skipWhitespace(s) | |
| 1087 | continue | |
| 1088 | } | |
| 1024 | 1089 | if (isExplicitCombinator(s)) { |
| 1025 | 1090 | const ct = consume(s) |
| 1026 | 1091 | const ch = s.source[ct.start]! |
| @@ -1036,6 +1101,12 @@ function parseSelector(s: ParserState): Selector { | ||
| 1036 | 1101 | children.appendData(seg) |
| 1037 | 1102 | lastWasCombinator = false |
| 1038 | 1103 | } |
| 1104 | // A trailing combinator (descendant or otherwise) is meaningless — the | |
| 1105 | // selector ended before another segment arrived. Strip it so the AST | |
| 1106 | // doesn't carry a phantom `Combinator` that round-trips to extra | |
| 1107 | // whitespace at the end of the rule prelude. | |
| 1108 | while (children.tail && (children.tail.data as CssNode).type === 'Combinator') | |
| 1109 | children.remove(children.tail) | |
| 1039 | 1110 | return { type: 'Selector', children, loc: loc(s, startTok, peek(s)) } |
| 1040 | 1111 | } |
| 1041 | 1112 | |
| @@ -1047,6 +1118,17 @@ function isExplicitCombinator(s: ParserState): boolean { | ||
| 1047 | 1118 | return ch === '>' || ch === '+' || ch === '~' |
| 1048 | 1119 | } |
| 1049 | 1120 | |
| 1121 | function isColumnCombinator(s: ParserState): boolean { | |
| 1122 | // `||` — two consecutive `|` Delim tokens. | |
| 1123 | if (s.types[s.pos] !== TokenType.Delim) | |
| 1124 | return false | |
| 1125 | if (s.source.charCodeAt(s.starts[s.pos]!) !== 124 /* | */) | |
| 1126 | return false | |
| 1127 | if (s.types[s.pos + 1] !== TokenType.Delim) | |
| 1128 | return false | |
| 1129 | return s.source.charCodeAt(s.starts[s.pos + 1]!) === 124 | |
| 1130 | } | |
| 1131 | ||
| 1050 | 1132 | function parseSelectorSegment(s: ParserState): CssNode | null { |
| 1051 | 1133 | const t = peek(s) |
| 1052 | 1134 | switch (t.type) { |
| @@ -1054,8 +1136,11 @@ function parseSelectorSegment(s: ParserState): CssNode | null { | ||
| 1054 | 1136 | consume(s) |
| 1055 | 1137 | // Namespace prefix? `svg|circle` → TypeSelector(name="svg|circle"). |
| 1056 | 1138 | // We keep the prefix as part of the name (matches css-tree shape). |
| 1139 | // Skip when the trailing `|` is part of `||` (column combinator) or | |
| 1140 | // `|=` (attribute hyphen-match) — neither is a namespace separator. | |
| 1057 | 1141 | if (peekType(s) === TokenType.Delim && s.source[peek(s).start] === '|' |
| 1058 | && s.source[peek(s).start + 1] !== '=') { | |
| 1142 | && s.source[peek(s).start + 1] !== '=' | |
| 1143 | && !(s.types[s.pos + 1] === TokenType.Delim && s.source.charCodeAt(s.starts[s.pos + 1]!) === 124)) { | |
| 1059 | 1144 | consume(s) |
| 1060 | 1145 | if (peekType(s) === TokenType.Ident) { |
| 1061 | 1146 | const local = consume(s) |
| @@ -1137,7 +1222,7 @@ function parsePseudo(s: ParserState, isElement: boolean, startTok: Token): Pseud | ||
| 1137 | 1222 | const t = peek(s) |
| 1138 | 1223 | if (t.type === TokenType.Ident) { |
| 1139 | 1224 | consume(s) |
| 1140 | const name = tokenSlice(s, t).toLowerCase() | |
| 1225 | const name = lowerIfNeeded(s.source, t.start, t.end) | |
| 1141 | 1226 | const node = isElement |
| 1142 | 1227 | ? { type: 'PseudoElementSelector' as const, name, children: null, loc: loc(s, startTok, t) } |
| 1143 | 1228 | : { type: 'PseudoClassSelector' as const, name, children: null, loc: loc(s, startTok, t) } |
| @@ -1145,14 +1230,11 @@ function parsePseudo(s: ParserState, isElement: boolean, startTok: Token): Pseud | ||
| 1145 | 1230 | } |
| 1146 | 1231 | if (t.type === TokenType.Function) { |
| 1147 | 1232 | consume(s) |
| 1148 | const name = s.source.slice(t.start, t.end - 1).toLowerCase() | |
| 1233 | const name = lowerIfNeeded(s.source, t.start, t.end - 1) | |
| 1149 | 1234 | const children = newList<CssNode>() |
| 1150 | 1235 | |
| 1151 | 1236 | // Pseudo-classes that take a selector list re-parse their argument. |
| 1152 | const isSelectorListPseudo = !isElement && ( | |
| 1153 | name === 'is' || name === 'not' || name === 'where' || name === 'has' | |
| 1154 | || name === 'matches' || name === '-moz-any' || name === '-webkit-any' | |
| 1155 | ) | |
| 1237 | const isSelectorListPseudo = !isElement && SELECTOR_LIST_PSEUDOS_PARSE.has(name) | |
| 1156 | 1238 | if (isSelectorListPseudo) { |
| 1157 | 1239 | // Slurp the argument tokens up to the matching `)` and re-parse them. |
| 1158 | 1240 | const argStart = peek(s) |
| @@ -79,10 +79,6 @@ function isHexDigit(code: number): boolean { | ||
| 79 | 79 | return code < 128 && (CHAR_CLASS[code]! & 4) !== 0 |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | function isNonAscii(code: number): boolean { | |
| 83 | return code >= 0x80 | |
| 84 | } | |
| 85 | ||
| 86 | 82 | function isNameStart(code: number): boolean { |
| 87 | 83 | return code >= 0x80 || (code < 128 && (CHAR_CLASS[code]! & 8) !== 0) |
| 88 | 84 | } |
| @@ -151,20 +147,35 @@ export class Tokenizer { | ||
| 151 | 147 | /** Lazy-built `Token`-shaped array — only filled if a consumer |
| 152 | 148 | * reads `.tokens`. Internally the parser uses the typed arrays. */ |
| 153 | 149 | private _tokens: Token[] | null = null |
| 154 | /** Offset where each line starts; used for line/col reporting. */ | |
| 155 | lineStarts: number[] = [0] | |
| 150 | /** | |
| 151 | * Offset where each line starts; used for line/col reporting. Built | |
| 152 | * lazily by `locate()` — almost every parse runs with `positions: false` | |
| 153 | * and never asks for source locations, so paying the per-newline | |
| 154 | * `lineStarts.push()` during tokenization is pure overhead in the | |
| 155 | * common case. The closures the original loops captured for that push | |
| 156 | * are also gone. | |
| 157 | */ | |
| 158 | private _lineStarts: number[] | null = null | |
| 156 | 159 | |
| 157 | 160 | constructor(source: string) { |
| 158 | 161 | this.source = source |
| 159 | // Estimate token count: avg ~4 chars per token. Better to over-allocate | |
| 160 | // a little than to have to grow. | |
| 161 | const cap = Math.max(64, source.length >> 1) | |
| 162 | // CSS averages ~3.5 chars/token across realistic stylesheets (selectors, | |
| 163 | // declarations, values, whitespace). Allocating `len/3` covers nearly | |
| 164 | // all inputs without needing `grow()`. Floor at 64 for tiny snippets. | |
| 165 | const cap = Math.max(64, Math.ceil(source.length / 3)) | |
| 162 | 166 | this.types = new Uint8Array(cap) |
| 163 | 167 | this.starts = new Uint32Array(cap) |
| 164 | 168 | this.ends = new Uint32Array(cap) |
| 165 | 169 | this.tokenize() |
| 166 | 170 | } |
| 167 | 171 | |
| 172 | /** Public alias kept for back-compat — built lazily on first read. */ | |
| 173 | get lineStarts(): number[] { | |
| 174 | if (this._lineStarts == null) | |
| 175 | this._lineStarts = buildLineStarts(this.source) | |
| 176 | return this._lineStarts | |
| 177 | } | |
| 178 | ||
| 168 | 179 | /** Lazy `Token[]` view for backwards-compat. */ |
| 169 | 180 | get tokens(): Token[] { |
| 170 | 181 | if (this._tokens != null) |
| @@ -201,21 +212,12 @@ export class Tokenizer { | ||
| 201 | 212 | while (i < len) { |
| 202 | 213 | const c1 = src.charCodeAt(i) |
| 203 | 214 | |
| 204 | // newline tracking | |
| 205 | if (c1 === 10) // \n | |
| 206 | this.lineStarts.push(i + 1) | |
| 207 | else if (c1 === 13 && src.charCodeAt(i + 1) !== 10) // \r without \n | |
| 208 | this.lineStarts.push(i + 1) | |
| 209 | ||
| 210 | 215 | // comment /* ... */ |
| 211 | 216 | if (c1 === 47 /* / */ && src.charCodeAt(i + 1) === 42 /* * */) { |
| 212 | 217 | const start = i |
| 213 | 218 | i += 2 |
| 214 | while (i < len && !(src.charCodeAt(i) === 42 && src.charCodeAt(i + 1) === 47)) { | |
| 215 | if (src.charCodeAt(i) === 10) | |
| 216 | this.lineStarts.push(i + 1) | |
| 219 | while (i < len && !(src.charCodeAt(i) === 42 && src.charCodeAt(i + 1) === 47)) | |
| 217 | 220 | i++ |
| 218 | } | |
| 219 | 221 | i = i < len ? i + 2 : len |
| 220 | 222 | this.addToken(TokenType.Comment, start, i) |
| 221 | 223 | continue |
| @@ -224,11 +226,8 @@ export class Tokenizer { | ||
| 224 | 226 | // whitespace |
| 225 | 227 | if (isWhitespace(c1)) { |
| 226 | 228 | const start = i |
| 227 | while (i < len && isWhitespace(src.charCodeAt(i))) { | |
| 228 | if (src.charCodeAt(i) === 10) | |
| 229 | this.lineStarts.push(i + 1) | |
| 229 | while (i < len && isWhitespace(src.charCodeAt(i))) | |
| 230 | 230 | i++ |
| 231 | } | |
| 232 | 231 | this.addToken(TokenType.WhiteSpace, start, i) |
| 233 | 232 | continue |
| 234 | 233 | } |
| @@ -345,8 +344,6 @@ export class Tokenizer { | ||
| 345 | 344 | if (c === 92 /* \ */) { |
| 346 | 345 | const c2 = src.charCodeAt(i + 1) |
| 347 | 346 | if (isNewline(c2)) { |
| 348 | if (c2 === 10) | |
| 349 | this.lineStarts.push(i + 2) | |
| 350 | 347 | i += 2 |
| 351 | 348 | continue |
| 352 | 349 | } |
| @@ -382,10 +379,22 @@ export class Tokenizer { | ||
| 382 | 379 | |
| 383 | 380 | private consumeName(start: number): number { |
| 384 | 381 | const src = this.source |
| 382 | const len = src.length | |
| 385 | 383 | let i = start |
| 386 | while (i < src.length) { | |
| 384 | // Fast path: pure ASCII name chars — no escape, no non-ASCII. Inline | |
| 385 | // `isName` so the JIT keeps the loop body in a single branch path. | |
| 386 | while (i < len) { | |
| 387 | 387 | const c = src.charCodeAt(i) |
| 388 | if (isName(c)) { | |
| 388 | if (c < 128 && (CHAR_CLASS[c]! & (2 | 8)) !== 0) { | |
| 389 | i++ | |
| 390 | continue | |
| 391 | } | |
| 392 | if (c === 45 /* - */) { | |
| 393 | i++ | |
| 394 | continue | |
| 395 | } | |
| 396 | // Slow path: non-ASCII or escape — fall back to the general check. | |
| 397 | if (c >= 0x80) { | |
| 389 | 398 | i++ |
| 390 | 399 | continue |
| 391 | 400 | } |
| @@ -483,11 +492,8 @@ export class Tokenizer { | ||
| 483 | 492 | const src = this.source |
| 484 | 493 | const len = src.length |
| 485 | 494 | let i = nameEnd + 1 |
| 486 | while (i < len && isWhitespace(src.charCodeAt(i))) { | |
| 487 | if (src.charCodeAt(i) === 10) | |
| 488 | this.lineStarts.push(i + 1) | |
| 495 | while (i < len && isWhitespace(src.charCodeAt(i))) | |
| 489 | 496 | i++ |
| 490 | } | |
| 491 | 497 | while (i < len) { |
| 492 | 498 | const c = src.charCodeAt(i) |
| 493 | 499 | if (c === 41 /* ) */) { |
| @@ -497,11 +503,8 @@ export class Tokenizer { | ||
| 497 | 503 | } |
| 498 | 504 | if (isWhitespace(c)) { |
| 499 | 505 | const wsStart = i |
| 500 | while (i < len && isWhitespace(src.charCodeAt(i))) { | |
| 501 | if (src.charCodeAt(i) === 10) | |
| 502 | this.lineStarts.push(i + 1) | |
| 506 | while (i < len && isWhitespace(src.charCodeAt(i))) | |
| 503 | 507 | i++ |
| 504 | } | |
| 505 | 508 | if (i >= len) { |
| 506 | 509 | this.addToken(TokenType.Url, start, i) |
| 507 | 510 | return i |
| @@ -530,9 +533,9 @@ export class Tokenizer { | ||
| 530 | 533 | return i |
| 531 | 534 | } |
| 532 | 535 | |
| 533 | private consumeBadUrl(start: number, _from: number): number { | |
| 536 | private consumeBadUrl(start: number, from: number): number { | |
| 534 | 537 | const src = this.source |
| 535 | let i = _from | |
| 538 | let i = from | |
| 536 | 539 | while (i < src.length) { |
| 537 | 540 | const c = src.charCodeAt(i) |
| 538 | 541 | if (c === 41) { |
| @@ -551,20 +554,38 @@ export class Tokenizer { | ||
| 551 | 554 | |
| 552 | 555 | /** Resolve `offset` to (line, column) for error messages. */ |
| 553 | 556 | locate(offset: number): { line: number, column: number } { |
| 557 | const ls = this.lineStarts | |
| 554 | 558 | let lo = 0 |
| 555 | let hi = this.lineStarts.length - 1 | |
| 559 | let hi = ls.length - 1 | |
| 556 | 560 | while (lo < hi) { |
| 557 | 561 | const mid = (lo + hi + 1) >> 1 |
| 558 | if (this.lineStarts[mid]! <= offset) | |
| 562 | if (ls[mid]! <= offset) | |
| 559 | 563 | lo = mid |
| 560 | 564 | else |
| 561 | 565 | hi = mid - 1 |
| 562 | 566 | } |
| 563 | const lineStart = this.lineStarts[lo]! | |
| 567 | const lineStart = ls[lo]! | |
| 564 | 568 | return { line: lo + 1, column: offset - lineStart + 1 } |
| 565 | 569 | } |
| 566 | 570 | } |
| 567 | 571 | |
| 572 | function buildLineStarts(source: string): number[] { | |
| 573 | const out = [0] | |
| 574 | const len = source.length | |
| 575 | for (let i = 0; i < len; i++) { | |
| 576 | const c = source.charCodeAt(i) | |
| 577 | if (c === 10) { | |
| 578 | out.push(i + 1) | |
| 579 | } | |
| 580 | else if (c === 13) { | |
| 581 | // \r without \n, OR \r\n (treat the LF position consistently) | |
| 582 | if (source.charCodeAt(i + 1) !== 10) | |
| 583 | out.push(i + 1) | |
| 584 | } | |
| 585 | } | |
| 586 | return out | |
| 587 | } | |
| 588 | ||
| 568 | 589 | /** Decode a CSS-escaped name slice (e.g. `\\26 B` → `&B`). */ |
| 569 | 590 | /** |
| 570 | 591 | * Decode a CSS string literal slice to its actual character value. |
| @@ -98,6 +98,12 @@ export interface Block extends BaseNode { | ||
| 98 | 98 | |
| 99 | 99 | export interface Declaration extends BaseNode { |
| 100 | 100 | type: 'Declaration' |
| 101 | /** | |
| 102 | * `true` when the declaration ends with `!important`. The string-form | |
| 103 | * (e.g. `'!ie'` for IE-prefix hacks) is occasionally produced by other | |
| 104 | * libraries, so the type stays permissive — but ts-css's parser always | |
| 105 | * sets `boolean`. | |
| 106 | */ | |
| 101 | 107 | important: boolean | string |
| 102 | 108 | property: string |
| 103 | 109 | value: Value | Raw |
| @@ -52,7 +52,7 @@ export interface WalkVisitorObject<T extends WalkVisit | undefined = undefined> | ||
| 52 | 52 | /** Plain-function visitor — runs against every node. */ |
| 53 | 53 | export type WalkVisitorFunction = VisitorCallback<CssNode> |
| 54 | 54 | |
| 55 | export type WalkVisitor = WalkVisitorFunction | WalkVisitorObject<any> | |
| 55 | export type WalkVisitor = WalkVisitorFunction | WalkVisitorObject<WalkVisit | undefined> | |
| 56 | 56 | |
| 57 | 57 | function newContext(root: CssNode): WalkContext { |
| 58 | 58 | return { |
| @@ -227,24 +227,24 @@ function walkList( | ||
| 227 | 227 | // eslint-disable-next-line pickier/no-unused-vars |
| 228 | 228 | visitNode: (n: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => symbol | undefined, |
| 229 | 229 | ): symbol | undefined { |
| 230 | // Use list's own forEach to honor the cursor protocol so callbacks may | |
| 231 | // safely mutate the list mid-walk. | |
| 230 | // The list's `forEach` callback already receives the current `ListItem` | |
| 231 | // as its second argument — passing it straight to `visitNode` avoids the | |
| 232 | // O(N) `findItem` linear scan per child that the previous implementation | |
| 233 | // performed (turning every walk into O(N²)). | |
| 232 | 234 | let stopped: symbol | undefined |
| 233 | 235 | if (reverse) { |
| 234 | list.forEachRight((data, _, l) => { | |
| 236 | list.forEachRight((data, item, l) => { | |
| 235 | 237 | if (stopped) |
| 236 | 238 | return |
| 237 | const item = findItem(l, data) | |
| 238 | 239 | const r = visitNode(data, item, l) |
| 239 | 240 | if (r === STOP) |
| 240 | 241 | stopped = STOP |
| 241 | 242 | }) |
| 242 | 243 | } |
| 243 | 244 | else { |
| 244 | list.forEach((data, _, l) => { | |
| 245 | list.forEach((data, item, l) => { | |
| 245 | 246 | if (stopped) |
| 246 | 247 | return |
| 247 | const item = findItem(l, data) | |
| 248 | 248 | const r = visitNode(data, item, l) |
| 249 | 249 | if (r === STOP) |
| 250 | 250 | stopped = STOP |
| @@ -252,11 +252,3 @@ function walkList( | ||
| 252 | 252 | } |
| 253 | 253 | return stopped |
| 254 | 254 | } |
| 255 | ||
| 256 | function findItem(list: CssList<CssNode>, data: CssNode): ListItem<CssNode> | null { | |
| 257 | for (let cur = list.head; cur != null; cur = cur.next) { | |
| 258 | if (cur.data === data) | |
| 259 | return cur | |
| 260 | } | |
| 261 | return null | |
| 262 | } | |
| @@ -7,8 +7,6 @@ import type { AttributeSelectorNode } from '../what' | ||
| 7 | 7 | import type { Adapter, CompiledQuery, Options } from './types' |
| 8 | 8 | import { ALWAYS_TRUE } from './helpers/always-true' |
| 9 | 9 | |
| 10 | type Predicate<E> = (e: E) => boolean | |
| 11 | ||
| 12 | 10 | function isWsCode(c: number): boolean { |
| 13 | 11 | return c === 32 || c === 9 || c === 10 || c === 12 || c === 13 |
| 14 | 12 | } |
| @@ -242,4 +240,3 @@ function wrap<Node, ElementNode extends Node>( | ||
| 242 | 240 | return _ => { void _; return false } |
| 243 | 241 | } |
| 244 | 242 | |
| 245 | export type { Predicate } | |
| @@ -23,23 +23,32 @@ function getAdapter<Node, ElementNode extends Node>(options: Options<Node, Eleme | ||
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | // ----- compiled-selector cache ----- |
| 26 | // Keyed by Adapter (object identity) so different trees can't poison each | |
| 27 | // other's cache. The inner key is a deterministic string capturing the | |
| 28 | // selector + the options that affect compile output. | |
| 29 | const adapterCaches = new WeakMap<object, Map<string, CompiledQuery<any>>>() | |
| 26 | // Two-level lookup: outer keyed by Adapter (object identity), inner keyed | |
| 27 | // by a 3-bit flag-signature integer encoding the options that change | |
| 28 | // compile output. The innermost map is keyed by selector text. This avoids | |
| 29 | // the per-call `${flags}|${selector}` string concat the previous | |
| 30 | // single-level cache did — meaningful when consumers call selectAll in a | |
| 31 | // hot loop. | |
| 32 | const adapterCaches = new WeakMap<object, (Map<string, CompiledQuery<any>> | undefined)[]>() | |
| 30 | 33 | |
| 31 | function cacheKey(selector: string, options: Options<any, any>): string { | |
| 32 | // Only options that change compile output need to be in the key. | |
| 33 | return `${options.xmlMode ? '1' : '0'}|${options.lowerCaseAttributeNames === false ? '0' : '1'}|${options.lowerCaseTags === false ? '0' : '1'}|${selector}` | |
| 34 | function flagSig(options: Options<any, any>): number { | |
| 35 | return ((options.xmlMode ? 1 : 0) << 0) | |
| 36 | | ((options.lowerCaseAttributeNames === false ? 1 : 0) << 1) | |
| 37 | | ((options.lowerCaseTags === false ? 1 : 0) << 2) | |
| 34 | 38 | } |
| 35 | 39 | |
| 36 | function getCache(adapter: object): Map<string, CompiledQuery<any>> { | |
| 37 | let cache = adapterCaches.get(adapter) | |
| 38 | if (!cache) { | |
| 39 | cache = new Map() | |
| 40 | adapterCaches.set(adapter, cache) | |
| 40 | function getCache(adapter: object, sig: number): Map<string, CompiledQuery<any>> { | |
| 41 | let bucket = adapterCaches.get(adapter) | |
| 42 | if (!bucket) { | |
| 43 | bucket = [] | |
| 44 | adapterCaches.set(adapter, bucket) | |
| 41 | 45 | } |
| 42 | return cache | |
| 46 | let m = bucket[sig] | |
| 47 | if (!m) { | |
| 48 | m = new Map() | |
| 49 | bucket[sig] = m | |
| 50 | } | |
| 51 | return m | |
| 43 | 52 | } |
| 44 | 53 | |
| 45 | 54 | function compileSelectorString<Node, ElementNode extends Node>( |
| @@ -65,13 +74,12 @@ export function compile<Node, ElementNode extends Node>( | ||
| 65 | 74 | if (options.cacheResults === false || !options.adapter) |
| 66 | 75 | return compileSelectorString(selector, options) |
| 67 | 76 | |
| 68 | const cache = getCache(options.adapter as unknown as object) | |
| 69 | const key = cacheKey(selector, options) | |
| 70 | let compiled = cache.get(key) | |
| 77 | const cache = getCache(options.adapter as unknown as object, flagSig(options)) | |
| 78 | let compiled = cache.get(selector) | |
| 71 | 79 | if (compiled !== undefined) |
| 72 | 80 | return compiled as CompiledQuery<ElementNode> |
| 73 | 81 | compiled = compileSelectorString(selector, options) |
| 74 | cache.set(key, compiled) | |
| 82 | cache.set(selector, compiled) | |
| 75 | 83 | return compiled as CompiledQuery<ElementNode> |
| 76 | 84 | } |
| 77 | 85 | |
| @@ -82,12 +90,12 @@ export function clearSelectorCache(adapter: object, selector?: string): void { | ||
| 82 | 90 | adapterCaches.delete(adapter) |
| 83 | 91 | return |
| 84 | 92 | } |
| 85 | const cache = adapterCaches.get(adapter) | |
| 86 | if (!cache) | |
| 93 | const bucket = adapterCaches.get(adapter) | |
| 94 | if (!bucket) | |
| 87 | 95 | return |
| 88 | for (const key of cache.keys()) { | |
| 89 | if (key.endsWith(`|${selector}`)) | |
| 90 | cache.delete(key) | |
| 96 | for (const m of bucket) { | |
| 97 | if (m) | |
| 98 | m.delete(selector) | |
| 91 | 99 | } |
| 92 | 100 | } |
| 93 | 101 | |
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import type { Selector } from '../../what' |
| 9 | 9 | import type { CompiledQuery, Options } from '../types' |
| 10 | import { parse as parseSelectorString } from '../../what' | |
| 10 | 11 | import { compileGeneric, parseAndCompile } from './selectors' |
| 11 | 12 | |
| 12 | 13 | interface PseudoToken { |
| @@ -15,6 +16,25 @@ interface PseudoToken { | ||
| 15 | 16 | data: string | Selector[][] | null |
| 16 | 17 | } |
| 17 | 18 | |
| 19 | // Pseudo-classes whose match depends on document state (focus, hover, form | |
| 20 | // validity, etc.) that ts-css doesn't model. They fail closed unless the | |
| 21 | // caller supplies an `options.pseudos[name]` predicate. | |
| 22 | const STATEFUL_PSEUDOS: ReadonlySet<string> = new Set([ | |
| 23 | 'link', 'any-link', 'visited', 'hover', 'active', 'focus', | |
| 24 | 'focus-visible', 'focus-within', 'target', 'target-within', 'enabled', | |
| 25 | 'disabled', 'checked', 'required', 'optional', 'valid', 'invalid', | |
| 26 | 'selected', 'placeholder-shown', 'read-only', 'read-write', 'in-range', | |
| 27 | 'out-of-range', 'default', 'indeterminate', | |
| 28 | ]) | |
| 29 | ||
| 30 | const SELECTOR_LIST_PSEUDOS: ReadonlySet<string> = new Set([ | |
| 31 | 'is', 'where', 'matches', '-moz-any', '-webkit-any', | |
| 32 | ]) | |
| 33 | ||
| 34 | const NTH_PSEUDOS: ReadonlySet<string> = new Set([ | |
| 35 | 'nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type', | |
| 36 | ]) | |
| 37 | ||
| 18 | 38 | export function compilePseudo<Node, ElementNode extends Node>( |
| 19 | 39 | token: PseudoToken, |
| 20 | 40 | options: Options<Node, ElementNode>, |
| @@ -25,65 +45,165 @@ export function compilePseudo<Node, ElementNode extends Node>( | ||
| 25 | 45 | const data = token.data |
| 26 | 46 | |
| 27 | 47 | // -------- combinatorial pseudos: take selector lists -------- |
| 28 | if (token.type === 'pseudo' && (name === 'is' || name === 'where' || name === 'matches' || name === '-moz-any' || name === '-webkit-any')) { | |
| 48 | if (token.type === 'pseudo' && SELECTOR_LIST_PSEUDOS.has(name)) { | |
| 29 | 49 | if (!Array.isArray(data)) |
| 30 | 50 | return next |
| 31 | 51 | const tests = (data as Selector[][]).map(seg => parseAndCompile(seg, options)) |
| 32 | return e => tests.some(t => t(e)) && next(e) | |
| 52 | // For a single sub-selector, skip the array iteration cost. | |
| 53 | if (tests.length === 1) { | |
| 54 | const t0 = tests[0]! | |
| 55 | return e => t0(e) && next(e) | |
| 56 | } | |
| 57 | return (e) => { | |
| 58 | for (const t of tests) { | |
| 59 | if (t(e)) | |
| 60 | return next(e) | |
| 61 | } | |
| 62 | return false | |
| 63 | } | |
| 33 | 64 | } |
| 34 | 65 | if (token.type === 'pseudo' && name === 'not') { |
| 35 | 66 | if (!Array.isArray(data)) |
| 36 | 67 | return next |
| 37 | 68 | const tests = (data as Selector[][]).map(seg => parseAndCompile(seg, options)) |
| 38 | return e => !tests.some(t => t(e)) && next(e) | |
| 69 | if (tests.length === 1) { | |
| 70 | const t0 = tests[0]! | |
| 71 | return e => !t0(e) && next(e) | |
| 72 | } | |
| 73 | return (e) => { | |
| 74 | for (const t of tests) { | |
| 75 | if (t(e)) | |
| 76 | return false | |
| 77 | } | |
| 78 | return next(e) | |
| 79 | } | |
| 39 | 80 | } |
| 40 | 81 | if (token.type === 'pseudo' && name === 'has') { |
| 41 | 82 | if (!Array.isArray(data)) |
| 42 | 83 | return next |
| 43 | 84 | const tests = (data as Selector[][]).map(seg => parseAndCompile(seg, options)) |
| 85 | // Collapse the sub-test list into a single predicate so the inner | |
| 86 | // descendant loop only invokes one function per node. | |
| 87 | const subjectTest: CompiledQuery<ElementNode> = tests.length === 1 | |
| 88 | ? tests[0]! | |
| 89 | : ((node: any) => { | |
| 90 | for (const t of tests) { | |
| 91 | if (t(node)) | |
| 92 | return true | |
| 93 | } | |
| 94 | return false | |
| 95 | }) as any | |
| 44 | 96 | return (e) => { |
| 45 | const stack = adapter.getChildren(e).slice() | |
| 97 | // Iterative DFS using `pop` (O(1)) instead of `shift` (O(n)). The | |
| 98 | // iteration order is reversed but `:has()` doesn't care about order | |
| 99 | // — it stops at the first descendant that satisfies the test. | |
| 100 | const stack: any[] = adapter.getChildren(e).slice() | |
| 46 | 101 | while (stack.length > 0) { |
| 47 | const cur = stack.shift()! | |
| 48 | if (adapter.isTag(cur) && tests.some(t => t(cur as any))) | |
| 102 | const cur = stack.pop() | |
| 103 | if (!adapter.isTag(cur)) | |
| 104 | continue | |
| 105 | if (subjectTest(cur as any)) | |
| 49 | 106 | return next(e) |
| 50 | if (cur && (cur as any).children) | |
| 51 | stack.push(...adapter.getChildren(cur)) | |
| 107 | const kids = adapter.getChildren(cur as any) | |
| 108 | for (let i = 0; i < kids.length; i++) | |
| 109 | stack.push(kids[i]) | |
| 52 | 110 | } |
| 53 | 111 | return false |
| 54 | 112 | } |
| 55 | 113 | } |
| 56 | 114 | |
| 57 | 115 | // -------- nth-* pseudos -------- |
| 58 | if (token.type === 'pseudo' && (name === 'nth-child' || name === 'nth-last-child' || name === 'nth-of-type' || name === 'nth-last-of-type')) { | |
| 116 | if (token.type === 'pseudo' && NTH_PSEUDOS.has(name)) { | |
| 59 | 117 | const fn = compileNth(typeof data === 'string' ? data : '', name, options) |
| 60 | 118 | return e => fn(e) && next(e) |
| 61 | 119 | } |
| 62 | 120 | |
| 63 | 121 | // -------- structural pseudos -------- |
| 122 | // Each variant scans `getSiblings(e)` once without allocating the | |
| 123 | // filtered intermediate array `[].filter()` would (the previous shape | |
| 124 | // produced one allocation per element-test, which the matcher hits | |
| 125 | // 250+ times per selector even for tiny trees). | |
| 64 | 126 | if (token.type === 'pseudo' && name === 'first-child') { |
| 65 | return e => isNthSibling(e, options, true, false, false) && next(e) | |
| 127 | return (e) => { | |
| 128 | const sibs = adapter.getSiblings(e) | |
| 129 | for (const s of sibs) { | |
| 130 | if (!adapter.isTag(s)) | |
| 131 | continue | |
| 132 | return s === e && next(e) | |
| 133 | } | |
| 134 | return false | |
| 135 | } | |
| 66 | 136 | } |
| 67 | 137 | if (token.type === 'pseudo' && name === 'last-child') { |
| 68 | return e => isNthSibling(e, options, false, true, false) && next(e) | |
| 138 | return (e) => { | |
| 139 | const sibs = adapter.getSiblings(e) | |
| 140 | for (let i = sibs.length - 1; i >= 0; i--) { | |
| 141 | const s = sibs[i]! | |
| 142 | if (!adapter.isTag(s)) | |
| 143 | continue | |
| 144 | return s === e && next(e) | |
| 145 | } | |
| 146 | return false | |
| 147 | } | |
| 69 | 148 | } |
| 70 | 149 | if (token.type === 'pseudo' && name === 'first-of-type') { |
| 71 | return e => isNthSibling(e, options, true, false, true) && next(e) | |
| 150 | return (e) => { | |
| 151 | const tag = adapter.getName(e) | |
| 152 | const sibs = adapter.getSiblings(e) | |
| 153 | for (const s of sibs) { | |
| 154 | if (!adapter.isTag(s) || adapter.getName(s as any) !== tag) | |
| 155 | continue | |
| 156 | return s === e && next(e) | |
| 157 | } | |
| 158 | return false | |
| 159 | } | |
| 72 | 160 | } |
| 73 | 161 | if (token.type === 'pseudo' && name === 'last-of-type') { |
| 74 | return e => isNthSibling(e, options, false, true, true) && next(e) | |
| 162 | return (e) => { | |
| 163 | const tag = adapter.getName(e) | |
| 164 | const sibs = adapter.getSiblings(e) | |
| 165 | for (let i = sibs.length - 1; i >= 0; i--) { | |
| 166 | const s = sibs[i]! | |
| 167 | if (!adapter.isTag(s) || adapter.getName(s as any) !== tag) | |
| 168 | continue | |
| 169 | return s === e && next(e) | |
| 170 | } | |
| 171 | return false | |
| 172 | } | |
| 75 | 173 | } |
| 76 | 174 | if (token.type === 'pseudo' && name === 'only-child') { |
| 77 | 175 | return (e) => { |
| 78 | const sibs = adapter.getSiblings(e).filter(s => adapter.isTag(s)) | |
| 79 | return sibs.length === 1 && sibs[0] === e && next(e) | |
| 176 | const sibs = adapter.getSiblings(e) | |
| 177 | let count = 0 | |
| 178 | let found = false | |
| 179 | for (const s of sibs) { | |
| 180 | if (!adapter.isTag(s)) | |
| 181 | continue | |
| 182 | count++ | |
| 183 | if (count > 1) | |
| 184 | return false | |
| 185 | if (s === e) | |
| 186 | found = true | |
| 187 | } | |
| 188 | return count === 1 && found && next(e) | |
| 80 | 189 | } |
| 81 | 190 | } |
| 82 | 191 | if (token.type === 'pseudo' && name === 'only-of-type') { |
| 83 | 192 | return (e) => { |
| 84 | 193 | const tag = adapter.getName(e) |
| 85 | const sibs = adapter.getSiblings(e).filter(s => adapter.isTag(s) && adapter.getName(s as any) === tag) | |
| 86 | return sibs.length === 1 && sibs[0] === e && next(e) | |
| 194 | const sibs = adapter.getSiblings(e) | |
| 195 | let count = 0 | |
| 196 | let found = false | |
| 197 | for (const s of sibs) { | |
| 198 | if (!adapter.isTag(s) || adapter.getName(s as any) !== tag) | |
| 199 | continue | |
| 200 | count++ | |
| 201 | if (count > 1) | |
| 202 | return false | |
| 203 | if (s === e) | |
| 204 | found = true | |
| 205 | } | |
| 206 | return count === 1 && found && next(e) | |
| 87 | 207 | } |
| 88 | 208 | } |
| 89 | 209 | |
| @@ -99,17 +219,21 @@ export function compilePseudo<Node, ElementNode extends Node>( | ||
| 99 | 219 | } |
| 100 | 220 | if (token.type === 'pseudo' && name === 'scope') { |
| 101 | 221 | if (options.context && Array.isArray(options.context)) { |
| 102 | return e => (options.context as any[]).includes(e) && next(e) | |
| 222 | // Materialize a Set once at compile time — `Array.includes` is O(n) | |
| 223 | // per element check and `:scope` runs against every candidate. | |
| 224 | const ctx = new Set<any>(options.context as any[]) | |
| 225 | return e => ctx.has(e) && next(e) | |
| 103 | 226 | } |
| 104 | 227 | if (options.context) { |
| 105 | return e => (e as any) === options.context && next(e) | |
| 228 | const ctx = options.context as any | |
| 229 | return e => (e as any) === ctx && next(e) | |
| 106 | 230 | } |
| 107 | 231 | return (e) => { |
| 108 | 232 | const p = adapter.getParent(e) |
| 109 | 233 | return (p == null || !adapter.isTag(p as any)) && next(e) |
| 110 | 234 | } |
| 111 | 235 | } |
| 112 | if (token.type === 'pseudo' && (name === 'link' || name === 'any-link' || name === 'visited' || name === 'hover' || name === 'active' || name === 'focus' || name === 'focus-visible' || name === 'focus-within' || name === 'target' || name === 'target-within' || name === 'enabled' || name === 'disabled' || name === 'checked' || name === 'required' || name === 'optional' || name === 'valid' || name === 'invalid' || name === 'selected' || name === 'placeholder-shown' || name === 'read-only' || name === 'read-write' || name === 'in-range' || name === 'out-of-range' || name === 'default' || name === 'indeterminate')) { | |
| 236 | if (token.type === 'pseudo' && STATEFUL_PSEUDOS.has(name)) { | |
| 113 | 237 | // Stateful pseudos — without a real DOM we treat them as always-false |
| 114 | 238 | // (matches css-select's `xmlMode` defaults). Consumers who need real |
| 115 | 239 | // matching can pass `options.pseudos[name]` callbacks. |
| @@ -135,7 +259,12 @@ export function compilePseudo<Node, ElementNode extends Node>( | ||
| 135 | 259 | return e => ext(e, typeof data === 'string' ? data : undefined) && next(e) |
| 136 | 260 | } |
| 137 | 261 | if (typeof ext === 'string') { |
| 138 | const re = parseAndCompile(parseRaw(ext), options) | |
| 262 | // Compile the alias selector string with `compileGeneric` so commas | |
| 263 | // (selector lists) are handled correctly. Previously this routed | |
| 264 | // through a stubbed `parseRaw` that always returned `[]`, which | |
| 265 | // silently caused string-aliased pseudos to never match. | |
| 266 | const aliasGroups = parseSelectorString(ext) | |
| 267 | const re = compileGeneric(aliasGroups, options) | |
| 139 | 268 | return e => re(e) && next(e) |
| 140 | 269 | } |
| 141 | 270 | } |
| @@ -144,36 +273,6 @@ export function compilePseudo<Node, ElementNode extends Node>( | ||
| 144 | 273 | return _ => { void _; return false } |
| 145 | 274 | } |
| 146 | 275 | |
| 147 | function parseRaw(text: string): Selector[] { | |
| 148 | // small helper avoiding an outer dep cycle on what.parse for inline css | |
| 149 | void text | |
| 150 | return [] | |
| 151 | } | |
| 152 | ||
| 153 | function isNthSibling<Node, ElementNode extends Node>( | |
| 154 | e: ElementNode, | |
| 155 | options: Options<Node, ElementNode>, | |
| 156 | first: boolean, | |
| 157 | last: boolean, | |
| 158 | ofType: boolean, | |
| 159 | ): boolean { | |
| 160 | const adapter = options.adapter | |
| 161 | const siblings = adapter.getSiblings(e).filter(s => adapter.isTag(s)) | |
| 162 | if (ofType) { | |
| 163 | const tag = adapter.getName(e) | |
| 164 | const filtered = siblings.filter(s => adapter.getName(s as any) === tag) | |
| 165 | if (first) | |
| 166 | return filtered[0] === e | |
| 167 | if (last) | |
| 168 | return filtered[filtered.length - 1] === e | |
| 169 | return false | |
| 170 | } | |
| 171 | if (first) | |
| 172 | return siblings[0] === e | |
| 173 | if (last) | |
| 174 | return siblings[siblings.length - 1] === e | |
| 175 | return false | |
| 176 | } | |
| 177 | 276 | |
| 178 | 277 | function parseAnPlusB(s: string): { a: number, b: number } | null { |
| 179 | 278 | const trimmed = s.trim() |
| @@ -206,16 +305,25 @@ function compileNth<Node, ElementNode extends Node>( | ||
| 206 | 305 | const ofType = kind === 'nth-of-type' || kind === 'nth-last-of-type' |
| 207 | 306 | const reverse = kind === 'nth-last-child' || kind === 'nth-last-of-type' |
| 208 | 307 | return (e) => { |
| 209 | let siblings = adapter.getSiblings(e).filter(s => adapter.isTag(s)) | |
| 210 | if (ofType) { | |
| 211 | const tag = adapter.getName(e) | |
| 212 | siblings = siblings.filter(s => adapter.getName(s as any) === tag) | |
| 308 | // Compute idx in one pass over siblings — no `filter()` allocs and no | |
| 309 | // `reverse()` mutation. For `nth-last-*` we count total matching | |
| 310 | // siblings then take `total - rawIdx + 1`. | |
| 311 | const sibs = adapter.getSiblings(e) | |
| 312 | const tag = ofType ? adapter.getName(e) : '' | |
| 313 | let raw = 0 // 1-based index of `e` among matching siblings | |
| 314 | let total = 0 | |
| 315 | for (const s of sibs) { | |
| 316 | if (!adapter.isTag(s)) | |
| 317 | continue | |
| 318 | if (ofType && adapter.getName(s as any) !== tag) | |
| 319 | continue | |
| 320 | total++ | |
| 321 | if (s === e) | |
| 322 | raw = total | |
| 213 | 323 | } |
| 214 | if (reverse) | |
| 215 | siblings = siblings.reverse() | |
| 216 | const idx = siblings.indexOf(e) + 1 | |
| 217 | if (idx === 0) | |
| 324 | if (raw === 0) | |
| 218 | 325 | return false |
| 326 | const idx = reverse ? total - raw + 1 : raw | |
| 219 | 327 | if (a === 0) |
| 220 | 328 | return idx === b |
| 221 | 329 | return ((idx - b) / a) >= 0 && ((idx - b) % a) === 0 |
| @@ -27,10 +27,16 @@ export interface Adapter<Node, ElementNode extends Node> { | ||
| 27 | 27 | hasAttrib: (elem: ElementNode, name: string) => boolean |
| 28 | 28 | // eslint-disable-next-line pickier/no-unused-vars |
| 29 | 29 | removeSubsets: (nodes: Node[]) => Node[] |
| 30 | /** | |
| 31 | * `findAll` / `findOne` are accepted for adapter-shape compatibility | |
| 32 | * with css-select, but ts-css implements its own iterative tree walker | |
| 33 | * (`select/helpers/querying.ts`) and does NOT call these. They're | |
| 34 | * declared optional so adapters that don't provide them still type-check. | |
| 35 | */ | |
| 30 | 36 | // eslint-disable-next-line pickier/no-unused-vars |
| 31 | findAll: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode[] | |
| 37 | findAll?: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode[] | |
| 32 | 38 | // eslint-disable-next-line pickier/no-unused-vars |
| 33 | findOne: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode | null | |
| 39 | findOne?: (test: (e: ElementNode) => boolean, elems: Node[]) => ElementNode | null | |
| 34 | 40 | // eslint-disable-next-line pickier/no-unused-vars |
| 35 | 41 | equals?: (a: Node, b: Node) => boolean |
| 36 | 42 | // eslint-disable-next-line pickier/no-unused-vars |
| @@ -80,8 +80,13 @@ function nsPrefix(ns: string | null): string { | ||
| 80 | 80 | return `${escapeIdent(ns)}|` |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | const RE_INVALID_ID_CHAR = /[^\w-]/g | |
| 83 | // Valid CSS identifier chars: ASCII letters, digits, `-`, `_`, and any | |
| 84 | // non-ASCII (>= 0x80). Anything else needs an escape. `\W` rejects | |
| 85 | // non-ASCII, so we use an explicit positive set. | |
| 86 | const RE_INVALID_ID_CHAR = /[^\w°--]/g | |
| 84 | 87 | |
| 85 | 88 | function escapeIdent(name: string): string { |
| 89 | if (name === '') | |
| 90 | return '' | |
| 86 | 91 | return name.replace(RE_INVALID_ID_CHAR, m => `\\${m}`) |
| 87 | 92 | } |