diff --git a/API.md b/API.md index 015b385..a4ffb74 100644 --- a/API.md +++ b/API.md @@ -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 @@ -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)` diff --git a/src/css-node.test.ts b/src/css-node.test.ts index 7cdf9c5..0fbe53d 100644 --- a/src/css-node.test.ts +++ b/src/css-node.test.ts @@ -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', () => { @@ -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') + }) + }) + }) }) diff --git a/src/css-node.ts b/src/css-node.ts index 8ff6abc..4a7cc6c 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -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 @@ -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) { @@ -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 + } } diff --git a/src/parse-anplusb.test.ts b/src/parse-anplusb.test.ts index 459d581..213c0bd 100644 --- a/src/parse-anplusb.test.ts +++ b/src/parse-anplusb.test.ts @@ -127,28 +127,28 @@ describe('ANplusBParser', () => { const node = parse_anplusb('2n+1')! expect(node).not.toBeNull() expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('1') + expect(node.nth_b).toBe('+1') }) it('should parse 3n+5', () => { const node = parse_anplusb('3n+5')! expect(node).not.toBeNull() expect(node.nth_a).toBe('3n') - expect(node.nth_b).toBe('5') + expect(node.nth_b).toBe('+5') }) it('should parse n+0', () => { const node = parse_anplusb('n+0')! expect(node).not.toBeNull() expect(node.nth_a).toBe('n') - expect(node.nth_b).toBe('0') + expect(node.nth_b).toBe('+0') }) it('should parse -n+3', () => { const node = parse_anplusb('-n+3')! expect(node).not.toBeNull() expect(node.nth_a).toBe('-n') - expect(node.nth_b).toBe('3') + expect(node.nth_b).toBe('+3') }) }) @@ -194,7 +194,7 @@ describe('ANplusBParser', () => { const node = parse_anplusb('2n + 1')! expect(node).not.toBeNull() expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('1') + expect(node.nth_b).toBe('+1') }) it('should parse 2n - 1 with spaces', () => { @@ -208,21 +208,21 @@ describe('ANplusBParser', () => { const node = parse_anplusb('n + 5')! expect(node).not.toBeNull() expect(node.nth_a).toBe('n') - expect(node.nth_b).toBe('5') + expect(node.nth_b).toBe('+5') }) it('should handle leading whitespace', () => { const node = parse_anplusb(' 2n+1')! expect(node).not.toBeNull() expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('1') + expect(node.nth_b).toBe('+1') }) it('should handle trailing whitespace', () => { const node = parse_anplusb('2n+1 ')! expect(node).not.toBeNull() expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('1') + expect(node.nth_b).toBe('+1') }) }) @@ -231,14 +231,14 @@ describe('ANplusBParser', () => { const node = parse_anplusb('+0n+0')! expect(node).not.toBeNull() expect(node.nth_a).toBe('+0n') - expect(node.nth_b).toBe('0') + expect(node.nth_b).toBe('+0') }) it('should parse large coefficients', () => { const node = parse_anplusb('100n+50')! expect(node).not.toBeNull() expect(node.nth_a).toBe('100n') - expect(node.nth_b).toBe('50') + expect(node.nth_b).toBe('+50') }) }) }) diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index b55fe83..d76f5b7 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -1394,7 +1394,7 @@ describe('SelectorParser', () => { const anplusb = nth_child.first_child! expect(anplusb.type).toBe(NODE_SELECTOR_NTH) expect(anplusb.nth_a).toBe('2n') - expect(anplusb.nth_b).toBe('1') + expect(anplusb.nth_b).toBe('+1') expect(anplusb.text).toBe('2n+1') })