Skip to content

Commit 724563b

Browse files
authored
feat: add convenience property getters for pseudoclass selectors (#27)
1 parent f8a6517 commit 724563b

File tree

5 files changed

+275
-21
lines changed

5 files changed

+275
-21
lines changed

API.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ function parse(source: string, options?: ParserOptions): CSSNode
5353
- `next_sibling` - Next sibling node or `null`
5454
- `children` - Array of all child nodes
5555
- `values` - Array of value nodes (for declarations)
56+
- `selector_list` - Selector list from pseudo-classes like `:is()`, `:not()`, `:has()`, `:where()`, or `:nth-child(of)`
57+
- `nth` - An+B formula node from `:nth-child(of)` wrapper (for NODE_SELECTOR_NTH_OF nodes)
58+
- `selector` - Selector list from `:nth-child(of)` wrapper (for NODE_SELECTOR_NTH_OF nodes)
59+
- `nth_a` - The 'a' coefficient from An+B expressions like `2n` from `:nth-child(2n+1)`
60+
- `nth_b` - The 'b' coefficient from An+B expressions like `+1` from `:nth-child(2n+1)`
5661
5762
### Example 1: Basic Parsing
5863
@@ -295,6 +300,68 @@ if (node.type_name === 'declaration') {
295300
}
296301
```
297302

303+
### Example 9: Accessing Nested Selectors in Pseudo-Classes
304+
305+
Convenience properties simplify access to nested selector data:
306+
307+
```typescript
308+
import { parse_selector, NODE_SELECTOR_LIST, NODE_SELECTOR_NTH } from '@projectwallace/css-parser'
309+
310+
// Simple pseudo-classes with selectors
311+
const isSelector = parse_selector(':is(.foo, #bar)')
312+
const pseudo = isSelector.first_child?.first_child
313+
314+
// Direct access to selector list
315+
console.log(pseudo.selector_list.text) // ".foo, #bar"
316+
console.log(pseudo.selector_list.type === NODE_SELECTOR_LIST) // true
317+
318+
// Complex pseudo-classes with An+B notation
319+
const nthSelector = parse_selector(':nth-child(2n+1 of .foo)')
320+
const nthPseudo = nthSelector.first_child?.first_child
321+
const nthOf = nthPseudo.first_child // NODE_SELECTOR_NTH_OF
322+
323+
// Direct access to formula
324+
console.log(nthOf.nth.type === NODE_SELECTOR_NTH) // true
325+
console.log(nthOf.nth.nth_a) // "2n"
326+
console.log(nthOf.nth.nth_b) // "+1"
327+
328+
// Direct access to selector list from :nth-child(of)
329+
console.log(nthOf.selector.text) // ".foo"
330+
331+
// Or use the unified helper on the pseudo-class
332+
console.log(nthPseudo.selector_list.text) // ".foo"
333+
```
334+
335+
**Before (nested loops required):**
336+
337+
```typescript
338+
// Had to manually traverse to find selector list
339+
let child = pseudo.first_child
340+
while (child) {
341+
if (child.type === NODE_SELECTOR_NTH_OF) {
342+
let inner = child.first_child
343+
while (inner) {
344+
if (inner.type === NODE_SELECTOR_LIST) {
345+
processSelectors(inner)
346+
break
347+
}
348+
inner = inner.next_sibling
349+
}
350+
break
351+
}
352+
child = child.next_sibling
353+
}
354+
```
355+
356+
**After (direct property access):**
357+
358+
```typescript
359+
// Simple and clear
360+
if (pseudo.selector_list) {
361+
processSelectors(pseudo.selector_list)
362+
}
363+
```
364+
298365
---
299366

300367
## `parse_selector(source)`

src/css-node.test.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { describe, test, expect } from 'vitest'
22
import { Parser } from './parse'
3-
import { NODE_DECLARATION, NODE_STYLE_RULE, NODE_AT_RULE } from './arena'
3+
import { parse_selector } from './parse-selector'
4+
import {
5+
NODE_DECLARATION,
6+
NODE_STYLE_RULE,
7+
NODE_AT_RULE,
8+
NODE_SELECTOR_NTH,
9+
NODE_SELECTOR_NTH_OF,
10+
NODE_SELECTOR_LIST,
11+
NODE_SELECTOR_PSEUDO_CLASS,
12+
} from './arena'
413

514
describe('CSSNode', () => {
615
describe('iteration', () => {
@@ -618,4 +627,147 @@ describe('CSSNode', () => {
618627
expect(prelude.type_name).toBe('media-query')
619628
})
620629
})
630+
631+
describe('Pseudo-class convenience properties', () => {
632+
describe('nth_of helpers (NODE_SELECTOR_NTH_OF)', () => {
633+
test('nth property returns An+B formula node', () => {
634+
const result = parse_selector(':nth-child(2n+1 of .foo)')
635+
const selector = result.first_child
636+
const pseudo = selector?.first_child // Get pseudo-class
637+
const nthOf = pseudo?.first_child // NODE_SELECTOR_NTH_OF
638+
639+
expect(nthOf?.nth).not.toBeNull()
640+
expect(nthOf?.nth?.type).toBe(NODE_SELECTOR_NTH)
641+
expect(nthOf?.nth?.nth_a).toBe('2n')
642+
expect(nthOf?.nth?.nth_b).toBe('+1')
643+
})
644+
645+
test('selector property returns selector list', () => {
646+
const result = parse_selector(':nth-child(2n of .foo, #bar)')
647+
const selector = result.first_child
648+
const pseudo = selector?.first_child
649+
const nthOf = pseudo?.first_child
650+
651+
expect(nthOf?.selector).not.toBeNull()
652+
expect(nthOf?.selector?.type).toBe(NODE_SELECTOR_LIST)
653+
expect(nthOf?.selector?.text).toBe('.foo, #bar')
654+
})
655+
656+
test('returns null for wrong node types', () => {
657+
const result = parse_selector('.foo')
658+
const selector = result.first_child
659+
const classNode = selector?.first_child
660+
661+
expect(classNode?.nth).toBeNull()
662+
expect(classNode?.selector).toBeNull()
663+
})
664+
665+
test('works with :nth-last-child', () => {
666+
const result = parse_selector(':nth-last-child(odd of .item)')
667+
const selector = result.first_child
668+
const pseudo = selector?.first_child
669+
const nthOf = pseudo?.first_child
670+
671+
expect(nthOf?.nth).not.toBeNull()
672+
expect(nthOf?.nth?.nth_a).toBe('odd')
673+
expect(nthOf?.selector).not.toBeNull()
674+
expect(nthOf?.selector?.text).toBe('.item')
675+
})
676+
677+
test('works with :nth-of-type', () => {
678+
const result = parse_selector(':nth-of-type(3n of .special)')
679+
const selector = result.first_child
680+
const pseudo = selector?.first_child
681+
const nthOf = pseudo?.first_child
682+
683+
expect(nthOf?.nth).not.toBeNull()
684+
expect(nthOf?.nth?.nth_a).toBe('3n')
685+
expect(nthOf?.selector?.text).toBe('.special')
686+
})
687+
688+
test('works with :nth-last-of-type', () => {
689+
const result = parse_selector(':nth-last-of-type(even of div)')
690+
const selector = result.first_child
691+
const pseudo = selector?.first_child
692+
const nthOf = pseudo?.first_child
693+
694+
expect(nthOf?.nth?.nth_a).toBe('even')
695+
expect(nthOf?.selector?.text).toBe('div')
696+
})
697+
})
698+
699+
describe('selector_list helper (NODE_SELECTOR_PSEUDO_CLASS)', () => {
700+
test('returns selector list for :is()', () => {
701+
const result = parse_selector(':is(.foo, #bar)')
702+
const selector = result.first_child
703+
const pseudo = selector?.first_child
704+
705+
expect(pseudo?.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
706+
expect(pseudo?.selector_list).not.toBeNull()
707+
expect(pseudo?.selector_list?.type).toBe(NODE_SELECTOR_LIST)
708+
expect(pseudo?.selector_list?.text).toBe('.foo, #bar')
709+
})
710+
711+
test('returns selector list for :nth-child(of)', () => {
712+
const result = parse_selector(':nth-child(2n of .foo)')
713+
const selector = result.first_child
714+
const pseudo = selector?.first_child
715+
716+
expect(pseudo?.selector_list).not.toBeNull()
717+
expect(pseudo?.selector_list?.text).toBe('.foo')
718+
})
719+
720+
test('returns null for pseudo-classes without selectors', () => {
721+
const result = parse_selector(':hover')
722+
const selector = result.first_child
723+
const pseudo = selector?.first_child
724+
725+
expect(pseudo?.selector_list).toBeNull()
726+
})
727+
728+
test('returns null for :nth-child without "of"', () => {
729+
const result = parse_selector(':nth-child(2n)')
730+
const selector = result.first_child
731+
const pseudo = selector?.first_child
732+
733+
expect(pseudo?.selector_list).toBeNull()
734+
})
735+
736+
test('works with :not()', () => {
737+
const result = parse_selector(':not(.excluded)')
738+
const selector = result.first_child
739+
const pseudo = selector?.first_child
740+
741+
expect(pseudo?.selector_list).not.toBeNull()
742+
expect(pseudo?.selector_list?.text).toBe('.excluded')
743+
})
744+
745+
test('works with :has()', () => {
746+
const result = parse_selector(':has(> .child)')
747+
const selector = result.first_child
748+
const pseudo = selector?.first_child
749+
750+
expect(pseudo?.selector_list).not.toBeNull()
751+
expect(pseudo?.selector_list?.text).toBe('> .child')
752+
})
753+
754+
test('works with :where()', () => {
755+
const result = parse_selector(':where(article, section)')
756+
const selector = result.first_child
757+
const pseudo = selector?.first_child
758+
759+
expect(pseudo?.selector_list).not.toBeNull()
760+
expect(pseudo?.selector_list?.text).toBe('article, section')
761+
})
762+
763+
test('complex :nth-child with multiple selectors', () => {
764+
const result = parse_selector(':nth-child(3n+2 of .item, .element, #special)')
765+
const selector = result.first_child
766+
const pseudo = selector?.first_child
767+
768+
expect(pseudo?.selector_list).not.toBeNull()
769+
expect(pseudo?.selector_list?.text).toBe('.item, .element, #special')
770+
})
771+
})
772+
})
621773
})

src/css-node.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ export class CSSNode {
413413
return this.source.substring(start, start + len)
414414
}
415415

416-
// Get the 'b' coefficient from An+B expression (e.g., "1" from "2n+1")
416+
// Get the 'b' coefficient from An+B expression (e.g., "+1" from "2n+1")
417417
get nth_b(): string | null {
418418
if (this.type !== NODE_SELECTOR_NTH) return null
419419

@@ -422,7 +422,7 @@ export class CSSNode {
422422
let start = this.arena.get_value_start(this.index)
423423
let value = this.source.substring(start, start + len)
424424

425-
// Check if there's a - sign before this position (handling "2n - 1" with spaces)
425+
// Check if there's a - or + sign before this position (handling "2n - 1" or "2n + 1" with spaces)
426426
// Look backwards for a - or + sign, skipping whitespace
427427
let check_pos = start - 1
428428
while (check_pos >= 0) {
@@ -432,18 +432,53 @@ export class CSSNode {
432432
continue
433433
}
434434
// Found non-whitespace
435-
if (ch === CHAR_MINUS_HYPHEN /* - */) {
436-
// Prepend - to value
435+
if (ch === CHAR_MINUS_HYPHEN) {
437436
value = '-' + value
437+
} else if (ch === CHAR_PLUS) {
438+
value = '+' + value
438439
}
439-
// Note: + signs are implicit, so we don't prepend them
440440
break
441441
}
442442

443-
// Strip leading + if present in the token itself
444-
if (value.charCodeAt(0) === CHAR_PLUS) {
445-
return value.substring(1)
446-
}
447443
return value
448444
}
445+
446+
// --- Pseudo-Class Nth-Of Helpers (for NODE_SELECTOR_NTH_OF) ---
447+
448+
// Get the An+B formula node from :nth-child(2n+1 of .foo)
449+
get nth(): CSSNode | null {
450+
if (this.type !== NODE_SELECTOR_NTH_OF) return null
451+
return this.first_child // First child is always NODE_SELECTOR_NTH
452+
}
453+
454+
// Get the selector list from :nth-child(2n+1 of .foo)
455+
get selector(): CSSNode | null {
456+
if (this.type !== NODE_SELECTOR_NTH_OF) return null
457+
let first = this.first_child
458+
return first ? first.next_sibling : null // Second child is NODE_SELECTOR_LIST
459+
}
460+
461+
// --- Pseudo-Class Selector List Helper ---
462+
463+
// Get selector list from pseudo-class functions
464+
// Works for :is(.a), :not(.b), :has(.c), :where(.d), :nth-child(2n of .e)
465+
get selector_list(): CSSNode | null {
466+
if (this.type !== NODE_SELECTOR_PSEUDO_CLASS) return null
467+
468+
let child = this.first_child
469+
if (!child) return null
470+
471+
// For simple cases (:is, :not, :where, :has), first_child is the selector list
472+
if (child.type === NODE_SELECTOR_LIST) {
473+
return child
474+
}
475+
476+
// For :nth-child(of) cases, need to look inside NODE_SELECTOR_NTH_OF
477+
if (child.type === NODE_SELECTOR_NTH_OF) {
478+
// Use the convenience getter we just added
479+
return child.selector
480+
}
481+
482+
return null
483+
}
449484
}

0 commit comments

Comments
 (0)