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
40 changes: 32 additions & 8 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- `parse`
- `parse_selector`
- `parse_selector_list`
- `parse_atrule_prelude`
- `walk`
- `tokenize`
Expand Down Expand Up @@ -356,15 +357,15 @@ import { parse_selector, SELECTOR_LIST, NTH_SELECTOR } from '@projectwallace/css

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

// Direct access to selector list
console.log(pseudo.selector_list.text) // ".foo, #bar"
console.log(pseudo.selector_list.type === 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 nthPseudo = nthSelector.first_child
const nthOf = nthPseudo.first_child // NTH_OF_SELECTOR

// Direct access to formula
Expand Down Expand Up @@ -471,10 +472,10 @@ clone.children.push({ type: 99, text: 'test', children: [] })

## `parse_selector(source)`

Parse a CSS selector string into a detailed AST.
Parse a single CSS selector string into a detailed AST. Returns the first selector directly (skipping the list wrapper). If the source contains multiple comma-separated selectors, only the first is returned.

```typescript
function parse_selector(source: string): CSSNode
function parse_selector(source: string): Selector
```

**Example:**
Expand All @@ -484,9 +485,9 @@ import { parse_selector } from '@projectwallace/css-parser'

const selector = parse_selector('div.class > p#id::before')

console.log(selector.type) // SELECTOR_LIST
console.log(selector.type) // SELECTOR
// Iterate over selector components
for (const part of selector.first_child) {
for (const part of selector.children) {
console.log(part.type, part.text)
}
// TYPE_SELECTOR "div"
Expand All @@ -499,6 +500,29 @@ for (const part of selector.first_child) {

---

## `parse_selector_list(source)`

Parse a CSS selector string into a detailed AST, returning the full `SelectorList` node. Use this when the source may contain multiple comma-separated selectors.

```typescript
function parse_selector_list(source: string): SelectorList
```

**Example:**

```typescript
import { parse_selector_list } from '@projectwallace/css-parser'

const list = parse_selector_list('h1, h2, h3')

console.log(list.type) // SELECTOR_LIST
for (const selector of list.children) {
console.log(selector.text) // "h1", "h2", "h3"
}
```

---

## `parse_declaration(source)`

Parse a CSS declaration string into a detailed AST.
Expand Down Expand Up @@ -888,12 +912,12 @@ import { parse_selector } from '@projectwallace/css-parser'

// Function syntax (with parentheses) - even if empty
const ast1 = parse_selector(':lang()')
const pseudoClass1 = ast1.first_child.first_child
const pseudoClass1 = ast1.first_child
console.log(pseudoClass1.has_children) // true - indicates function syntax

// Regular pseudo-class (no parentheses)
const ast2 = parse_selector(':hover')
const pseudoClass2 = ast2.first_child.first_child
const pseudoClass2 = ast2.first_child
console.log(pseudoClass2.has_children) // false - no function syntax
```

Expand Down
86 changes: 34 additions & 52 deletions src/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, test, expect } from 'vitest'
import { parse } from './parse'
import { parse_selector } from './parse-selector'
import { parse_selector, parse_selector_list } from './parse-selector'
import {
DECLARATION,
STYLE_RULE,
Expand Down Expand Up @@ -648,10 +648,9 @@ describe('CSSNode', () => {
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 as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null // Get pseudo-class
const nthOf = pseudo?.first_child as unknown as NthOfSelector | null // NODE_SELECTOR_NTH_OF
const selector = parse_selector(':nth-child(2n+1 of .foo)')
const pseudo = selector.first_child as PseudoClassSelector | null
const nthOf = pseudo?.first_child as unknown as NthOfSelector | null

expect(nthOf?.nth).not.toBeUndefined()
expect(nthOf?.nth?.type).toBe(NTH_SELECTOR)
Expand All @@ -660,9 +659,8 @@ describe('CSSNode', () => {
})

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

expect(nthOf?.selector).not.toBeUndefined()
Expand All @@ -671,18 +669,16 @@ describe('CSSNode', () => {
})

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

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

test('works with :nth-last-child', () => {
const result = parse_selector(':nth-last-child(odd of .item)')
const selector = result.first_child as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null
const selector = parse_selector(':nth-last-child(odd of .item)')
const pseudo = selector.first_child as PseudoClassSelector | null
const nthOf = pseudo?.first_child as unknown as NthOfSelector | null

expect(nthOf?.nth).not.toBeUndefined()
Expand All @@ -692,9 +688,8 @@ describe('CSSNode', () => {
})

test('works with :nth-of-type', () => {
const result = parse_selector(':nth-of-type(3n of .special)')
const selector = result.first_child as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null
const selector = parse_selector(':nth-of-type(3n of .special)')
const pseudo = selector.first_child as PseudoClassSelector | null
const nthOf = pseudo?.first_child as unknown as NthOfSelector | null

expect(nthOf?.nth).not.toBeUndefined()
Expand All @@ -703,9 +698,8 @@ describe('CSSNode', () => {
})

test('works with :nth-last-of-type', () => {
const result = parse_selector(':nth-last-of-type(even of div)')
const selector = result.first_child as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null
const selector = parse_selector(':nth-last-of-type(even of div)')
const pseudo = selector.first_child as PseudoClassSelector | null
const nthOf = pseudo?.first_child as unknown as NthOfSelector | null

expect(nthOf?.nth?.nth_a).toBe('even')
Expand All @@ -715,9 +709,8 @@ describe('CSSNode', () => {

describe('functional pseudo-class children', () => {
test('first_child is selector list for :is()', () => {
const result = parse_selector(':is(.foo, #bar)')
const selector = result.first_child as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null
const selector = parse_selector(':is(.foo, #bar)')
const pseudo = selector.first_child as PseudoClassSelector | null

expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR)
expect(pseudo?.first_child).not.toBeNull()
Expand All @@ -726,62 +719,55 @@ describe('CSSNode', () => {
})

test('first_child is NthOfSelector for :nth-child(of)', () => {
const result = parse_selector(':nth-child(2n of .foo)')
const selector = result.first_child as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null
const selector = parse_selector(':nth-child(2n of .foo)')
const pseudo = selector.first_child as PseudoClassSelector | null
const nthOf = pseudo?.first_child as unknown as NthOfSelector | null

expect(nthOf?.selector).not.toBeNull()
expect(nthOf?.selector?.text).toBe('.foo')
})

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

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

test('first_child is NthSelector (no NthOfSelector) for :nth-child without "of"', () => {
const result = parse_selector(':nth-child(2n)')
const selector = result.first_child as Selector | null
const pseudo = selector?.first_child as PseudoClassSelector | null
const selector = parse_selector(':nth-child(2n)')
const pseudo = selector.first_child as PseudoClassSelector | null

expect(pseudo?.first_child?.type).toBe(NTH_SELECTOR)
})

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

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

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

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

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

expect(pseudo?.first_child).not.toBeNull()
expect(pseudo?.first_child?.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 as Selector
const pseudo = selector?.first_child as PseudoClassSelector
const selector = parse_selector(':nth-child(3n+2 of .item, .element, #special)')
const pseudo = selector.first_child as PseudoClassSelector
const nthOf = pseudo?.first_child as unknown as NthOfSelector

expect(nthOf?.selector).not.toBeNull()
Expand Down Expand Up @@ -918,8 +904,7 @@ describe('CSSNode', () => {
})

test('extracts selector attribute properties', () => {
const ast = parse_selector('[data-foo="bar"]')
const selector = ast.first_child!
const selector = parse_selector('[data-foo="bar"]')
const attribute = selector.first_child!

const clone = attribute.clone({ deep: false })
Expand All @@ -942,8 +927,7 @@ describe('CSSNode', () => {
]

for (const { selector, expected } of operators) {
const ast = parse_selector(selector)
const attribute = ast.first_child!.first_child!
const attribute = parse_selector(selector).first_child!
const clone = attribute.clone({ deep: false })

expect(clone.attr_operator).toBe(expected)
Expand All @@ -958,17 +942,15 @@ describe('CSSNode', () => {
]

for (const { selector, expected } of flags) {
const ast = parse_selector(selector)
const attribute = ast.first_child!.first_child!
const attribute = parse_selector(selector).first_child!
const clone = attribute.clone({ deep: false })

expect(clone.attr_flags).toBe(expected)
}
})

test('extracts nth selector properties', () => {
const ast = parse_selector(':nth-child(2n+1)')
const selector = ast.first_child!
const selector = parse_selector(':nth-child(2n+1)')
const pseudo = selector.first_child!
const nth = pseudo.first_child!

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

// Function-based API (recommended)
export { parse } from './parse'
export { parse_selector } from './parse-selector'
export { parse_selector, parse_selector_list } from './parse-selector'
export { parse_atrule_prelude } from './parse-atrule-prelude'
export { parse_declaration } from './parse-declaration'
export { parse_value } from './parse-value'
Expand Down
25 changes: 9 additions & 16 deletions src/node-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, test, expect, expectTypeOf } from 'vitest'
import { parse } from './parse'
import { parse_declaration } from './parse-declaration'
import { parse_selector } from './parse-selector'
import { parse_selector, parse_selector_list } from './parse-selector'
import {
is_stylesheet,
is_rule,
Expand Down Expand Up @@ -84,7 +84,7 @@ describe('type predicates — runtime', () => {
})

test('is_selector identifies selector nodes', () => {
const root = parse_selector('a, b')
const root = parse_selector_list('a, b')
// root is SELECTOR_LIST; first child is SELECTOR
expect(is_selector_list(root)).toBe(true)
expect(is_selector(root.first_child!)).toBe(true)
Expand Down Expand Up @@ -112,8 +112,7 @@ describe('type predicates — runtime', () => {
})

test('is_attribute_selector identifies attribute selectors', () => {
const root = parse_selector('[href]')
const attr = root.first_child!.first_child! // SelectorList > Selector > AttributeSelector
const attr = parse_selector('[href]').first_child! // Selector > AttributeSelector
expect(is_attribute_selector(attr)).toBe(true)
})

Expand Down Expand Up @@ -218,8 +217,7 @@ describe('type narrowing — compile-time', () => {
})

test('is_attribute_selector narrows attr_operator and attr_flags to string | null', () => {
const root = parse_selector('[href]')
const attr = root.first_child!.first_child!
const attr = parse_selector('[href]').first_child!
if (is_attribute_selector(attr)) {
expectTypeOf(attr).toExtend<AttributeSelector>()
expectTypeOf(attr.attr_operator).toEqualTypeOf<string | null>()
Expand Down Expand Up @@ -265,8 +263,8 @@ describe('type narrowing — compile-time', () => {
})

test('type_name "Combinator" narrows to Combinator', () => {
// SelectorList > Selector > TypeSelector > Combinator (next sibling)
const combinator = parse_selector('a > b').first_child.first_child!.next_sibling! as AnyNode
// Selector > TypeSelector > Combinator (next sibling)
const combinator = parse_selector('a > b').first_child!.next_sibling! as AnyNode
if (combinator.type_name === 'Combinator') {
expectTypeOf(combinator).toExtend<Combinator>()
expectTypeOf(combinator.name).toEqualTypeOf<string>()
Expand Down Expand Up @@ -358,9 +356,7 @@ describe('type narrowing — compile-time', () => {

describe('selector subtypes', () => {
test('is_pseudo_class_selector narrows name to string', () => {
const root = parse_selector(':hover')
const sel = root.first_child // Selector
const pseudo = sel.first_child // PseudoClassSelector
const pseudo = parse_selector(':hover').first_child // PseudoClassSelector
if (is_pseudo_class_selector(pseudo)) {
expectTypeOf(pseudo).toExtend<PseudoClassSelector>()
expectTypeOf(pseudo.name).toEqualTypeOf<string>()
Expand All @@ -369,9 +365,7 @@ describe('selector subtypes', () => {
})

test('is_pseudo_element_selector narrows name to string', () => {
const root = parse_selector('::before')
const sel = root.first_child!
const pseudo = sel.first_child!
const pseudo = parse_selector('::before').first_child!
if (is_pseudo_element_selector(pseudo)) {
expectTypeOf(pseudo).toExtend<PseudoElementSelector>()
expectTypeOf(pseudo.name).toEqualTypeOf<string>()
Expand All @@ -380,8 +374,7 @@ describe('selector subtypes', () => {
})

test('is_nth_selector preserves nth_a and nth_b types', () => {
const root = parse_selector(':nth-child(2n+1)')
const pseudo = root.first_child!.first_child! // PseudoClassSelector
const pseudo = parse_selector(':nth-child(2n+1)').first_child! // PseudoClassSelector
const nth = pseudo.first_child! // NthSelector inside
if (is_nth_selector(nth)) {
expectTypeOf(nth).toExtend<NthSelector>()
Expand Down
Loading