Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ function parse(source: string, options?: ParserOptions): CSSNode
- `next_sibling` - Next sibling node or `null`
- `children` - Array of all child nodes
- `values` - Array of value nodes (for declarations)
- `selector_list` - Selector list from pseudo-classes like `:is()`, `:not()`, `:has()`, `:where()`, or `:nth-child(of)`
- `nth` - An+B formula node from `:nth-child(of)` wrapper (for NODE_SELECTOR_NTH_OF nodes)
- `selector` - Selector list from `:nth-child(of)` wrapper (for NODE_SELECTOR_NTH_OF nodes)
- `nth_a` - The 'a' coefficient from An+B expressions like `2n` from `:nth-child(2n+1)`
- `nth_b` - The 'b' coefficient from An+B expressions like `+1` from `:nth-child(2n+1)`

### Example 1: Basic Parsing

Expand Down Expand Up @@ -295,6 +300,68 @@ if (node.type_name === 'declaration') {
}
```

### Example 9: Accessing Nested Selectors in Pseudo-Classes

Convenience properties simplify access to nested selector data:

```typescript
import { parse_selector, NODE_SELECTOR_LIST, NODE_SELECTOR_NTH } from '@projectwallace/css-parser'

// Simple pseudo-classes with selectors
const isSelector = parse_selector(':is(.foo, #bar)')
const pseudo = isSelector.first_child?.first_child

// Direct access to selector list
console.log(pseudo.selector_list.text) // ".foo, #bar"
console.log(pseudo.selector_list.type === NODE_SELECTOR_LIST) // true

// Complex pseudo-classes with An+B notation
const nthSelector = parse_selector(':nth-child(2n+1 of .foo)')
const nthPseudo = nthSelector.first_child?.first_child
const nthOf = nthPseudo.first_child // NODE_SELECTOR_NTH_OF

// Direct access to formula
console.log(nthOf.nth.type === NODE_SELECTOR_NTH) // true
console.log(nthOf.nth.nth_a) // "2n"
console.log(nthOf.nth.nth_b) // "+1"

// Direct access to selector list from :nth-child(of)
console.log(nthOf.selector.text) // ".foo"

// Or use the unified helper on the pseudo-class
console.log(nthPseudo.selector_list.text) // ".foo"
```

**Before (nested loops required):**

```typescript
// Had to manually traverse to find selector list
let child = pseudo.first_child
while (child) {
if (child.type === NODE_SELECTOR_NTH_OF) {
let inner = child.first_child
while (inner) {
if (inner.type === NODE_SELECTOR_LIST) {
processSelectors(inner)
break
}
inner = inner.next_sibling
}
break
}
child = child.next_sibling
}
```

**After (direct property access):**

```typescript
// Simple and clear
if (pseudo.selector_list) {
processSelectors(pseudo.selector_list)
}
```

---

## `parse_selector(source)`
Expand Down
154 changes: 153 additions & 1 deletion src/css-node.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { describe, test, expect } from 'vitest'
import { Parser } from './parse'
import { NODE_DECLARATION, NODE_STYLE_RULE, NODE_AT_RULE } from './arena'
import { parse_selector } from './parse-selector'
import {
NODE_DECLARATION,
NODE_STYLE_RULE,
NODE_AT_RULE,
NODE_SELECTOR_NTH,
NODE_SELECTOR_NTH_OF,
NODE_SELECTOR_LIST,
NODE_SELECTOR_PSEUDO_CLASS,
} from './arena'

describe('CSSNode', () => {
describe('iteration', () => {
Expand Down Expand Up @@ -618,4 +627,147 @@ describe('CSSNode', () => {
expect(prelude.type_name).toBe('media-query')
})
})

describe('Pseudo-class convenience properties', () => {
describe('nth_of helpers (NODE_SELECTOR_NTH_OF)', () => {
test('nth property returns An+B formula node', () => {
const result = parse_selector(':nth-child(2n+1 of .foo)')
const selector = result.first_child
const pseudo = selector?.first_child // Get pseudo-class
const nthOf = pseudo?.first_child // NODE_SELECTOR_NTH_OF

expect(nthOf?.nth).not.toBeNull()
expect(nthOf?.nth?.type).toBe(NODE_SELECTOR_NTH)
expect(nthOf?.nth?.nth_a).toBe('2n')
expect(nthOf?.nth?.nth_b).toBe('+1')
})

test('selector property returns selector list', () => {
const result = parse_selector(':nth-child(2n of .foo, #bar)')
const selector = result.first_child
const pseudo = selector?.first_child
const nthOf = pseudo?.first_child

expect(nthOf?.selector).not.toBeNull()
expect(nthOf?.selector?.type).toBe(NODE_SELECTOR_LIST)
expect(nthOf?.selector?.text).toBe('.foo, #bar')
})

test('returns null for wrong node types', () => {
const result = parse_selector('.foo')
const selector = result.first_child
const classNode = selector?.first_child

expect(classNode?.nth).toBeNull()
expect(classNode?.selector).toBeNull()
})

test('works with :nth-last-child', () => {
const result = parse_selector(':nth-last-child(odd of .item)')
const selector = result.first_child
const pseudo = selector?.first_child
const nthOf = pseudo?.first_child

expect(nthOf?.nth).not.toBeNull()
expect(nthOf?.nth?.nth_a).toBe('odd')
expect(nthOf?.selector).not.toBeNull()
expect(nthOf?.selector?.text).toBe('.item')
})

test('works with :nth-of-type', () => {
const result = parse_selector(':nth-of-type(3n of .special)')
const selector = result.first_child
const pseudo = selector?.first_child
const nthOf = pseudo?.first_child

expect(nthOf?.nth).not.toBeNull()
expect(nthOf?.nth?.nth_a).toBe('3n')
expect(nthOf?.selector?.text).toBe('.special')
})

test('works with :nth-last-of-type', () => {
const result = parse_selector(':nth-last-of-type(even of div)')
const selector = result.first_child
const pseudo = selector?.first_child
const nthOf = pseudo?.first_child

expect(nthOf?.nth?.nth_a).toBe('even')
expect(nthOf?.selector?.text).toBe('div')
})
})

describe('selector_list helper (NODE_SELECTOR_PSEUDO_CLASS)', () => {
test('returns selector list for :is()', () => {
const result = parse_selector(':is(.foo, #bar)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudo?.selector_list).not.toBeNull()
expect(pseudo?.selector_list?.type).toBe(NODE_SELECTOR_LIST)
expect(pseudo?.selector_list?.text).toBe('.foo, #bar')
})

test('returns selector list for :nth-child(of)', () => {
const result = parse_selector(':nth-child(2n of .foo)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).not.toBeNull()
expect(pseudo?.selector_list?.text).toBe('.foo')
})

test('returns null for pseudo-classes without selectors', () => {
const result = parse_selector(':hover')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).toBeNull()
})

test('returns null for :nth-child without "of"', () => {
const result = parse_selector(':nth-child(2n)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).toBeNull()
})

test('works with :not()', () => {
const result = parse_selector(':not(.excluded)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).not.toBeNull()
expect(pseudo?.selector_list?.text).toBe('.excluded')
})

test('works with :has()', () => {
const result = parse_selector(':has(> .child)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).not.toBeNull()
expect(pseudo?.selector_list?.text).toBe('> .child')
})

test('works with :where()', () => {
const result = parse_selector(':where(article, section)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).not.toBeNull()
expect(pseudo?.selector_list?.text).toBe('article, section')
})

test('complex :nth-child with multiple selectors', () => {
const result = parse_selector(':nth-child(3n+2 of .item, .element, #special)')
const selector = result.first_child
const pseudo = selector?.first_child

expect(pseudo?.selector_list).not.toBeNull()
expect(pseudo?.selector_list?.text).toBe('.item, .element, #special')
})
})
})
})
53 changes: 44 additions & 9 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ export class CSSNode {
return this.source.substring(start, start + len)
}

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

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

// Check if there's a - sign before this position (handling "2n - 1" with spaces)
// Check if there's a - or + sign before this position (handling "2n - 1" or "2n + 1" with spaces)
// Look backwards for a - or + sign, skipping whitespace
let check_pos = start - 1
while (check_pos >= 0) {
Expand All @@ -432,18 +432,53 @@ export class CSSNode {
continue
}
// Found non-whitespace
if (ch === CHAR_MINUS_HYPHEN /* - */) {
// Prepend - to value
if (ch === CHAR_MINUS_HYPHEN) {
value = '-' + value
} else if (ch === CHAR_PLUS) {
value = '+' + value
}
// Note: + signs are implicit, so we don't prepend them
break
}

// Strip leading + if present in the token itself
if (value.charCodeAt(0) === CHAR_PLUS) {
return value.substring(1)
}
return value
}

// --- Pseudo-Class Nth-Of Helpers (for NODE_SELECTOR_NTH_OF) ---

// Get the An+B formula node from :nth-child(2n+1 of .foo)
get nth(): CSSNode | null {
if (this.type !== NODE_SELECTOR_NTH_OF) return null
return this.first_child // First child is always NODE_SELECTOR_NTH
}

// Get the selector list from :nth-child(2n+1 of .foo)
get selector(): CSSNode | null {
if (this.type !== NODE_SELECTOR_NTH_OF) return null
let first = this.first_child
return first ? first.next_sibling : null // Second child is NODE_SELECTOR_LIST
}

// --- Pseudo-Class Selector List Helper ---

// Get selector list from pseudo-class functions
// Works for :is(.a), :not(.b), :has(.c), :where(.d), :nth-child(2n of .e)
get selector_list(): CSSNode | null {
if (this.type !== NODE_SELECTOR_PSEUDO_CLASS) return null

let child = this.first_child
if (!child) return null

// For simple cases (:is, :not, :where, :has), first_child is the selector list
if (child.type === NODE_SELECTOR_LIST) {
return child
}

// For :nth-child(of) cases, need to look inside NODE_SELECTOR_NTH_OF
if (child.type === NODE_SELECTOR_NTH_OF) {
// Use the convenience getter we just added
return child.selector
}

return null
}
}
Loading
Loading