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
42 changes: 42 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,45 @@ import {
- `NODE_PRELUDE_IMPORT_URL` (38) - Import URL
- `NODE_PRELUDE_IMPORT_LAYER` (39) - Import layer
- `NODE_PRELUDE_IMPORT_SUPPORTS` (40) - Import supports condition

## Attribute Selector Constants

### Attribute Selector Operators

Use these constants with the `node.attr_operator` property to identify the operator in attribute selectors:

- `ATTR_OPERATOR_NONE` (0) - No operator (e.g., `[disabled]`)
- `ATTR_OPERATOR_EQUAL` (1) - Exact match (e.g., `[type="text"]`)
- `ATTR_OPERATOR_TILDE_EQUAL` (2) - Whitespace-separated list contains (e.g., `[class~="active"]`)
- `ATTR_OPERATOR_PIPE_EQUAL` (3) - Starts with or is followed by hyphen (e.g., `[lang|="en"]`)
- `ATTR_OPERATOR_CARET_EQUAL` (4) - Starts with (e.g., `[href^="https"]`)
- `ATTR_OPERATOR_DOLLAR_EQUAL` (5) - Ends with (e.g., `[href$=".pdf"]`)
- `ATTR_OPERATOR_STAR_EQUAL` (6) - Contains substring (e.g., `[href*="example"]`)

### Attribute Selector Flags

Use these constants with the `node.attr_flags` property to identify case sensitivity flags in attribute selectors:

- `ATTR_FLAG_NONE` (0) - No flag specified (default case sensitivity)
- `ATTR_FLAG_CASE_INSENSITIVE` (1) - Case-insensitive matching (e.g., `[type="text" i]`)
- `ATTR_FLAG_CASE_SENSITIVE` (2) - Case-sensitive matching (e.g., `[type="text" s]`)

#### Example

```javascript
import {
parse_selector,
NODE_SELECTOR_ATTRIBUTE,
ATTR_OPERATOR_EQUAL,
ATTR_FLAG_CASE_INSENSITIVE
} from '@projectwallace/css-parser'

const ast = parse_selector('[type="text" i]')

for (let node of ast) {
if (node.type === NODE_SELECTOR_ATTRIBUTE) {
console.log(node.attr_operator === ATTR_OPERATOR_EQUAL) // true
console.log(node.attr_flags === ATTR_FLAG_CASE_INSENSITIVE) // true
}
}
```
15 changes: 15 additions & 0 deletions src/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export const ATTR_OPERATOR_CARET_EQUAL = 4 // [attr^=value]
export const ATTR_OPERATOR_DOLLAR_EQUAL = 5 // [attr$=value]
export const ATTR_OPERATOR_STAR_EQUAL = 6 // [attr*=value]

// Attribute selector flag constants (stored in 1 byte at offset 3)
export const ATTR_FLAG_NONE = 0 // No flag
export const ATTR_FLAG_CASE_INSENSITIVE = 1 // [attr=value i]
export const ATTR_FLAG_CASE_SENSITIVE = 2 // [attr=value s]

export class CSSDataArena {
private buffer: ArrayBuffer
private view: DataView
Expand Down Expand Up @@ -168,6 +173,11 @@ export class CSSDataArena {
return this.view.getUint8(this.node_offset(node_index) + 2)
}

// Read attribute flags (for NODE_SELECTOR_ATTRIBUTE)
get_attr_flags(node_index: number): number {
return this.view.getUint8(this.node_offset(node_index) + 3)
}

// Read first child index (0 = no children)
get_first_child(node_index: number): number {
return this.view.getUint32(this.node_offset(node_index) + 20, true)
Expand Down Expand Up @@ -240,6 +250,11 @@ export class CSSDataArena {
this.view.setUint8(this.node_offset(node_index) + 2, operator)
}

// Write attribute flags (for NODE_SELECTOR_ATTRIBUTE)
set_attr_flags(node_index: number, flags: number): void {
this.view.setUint8(this.node_offset(node_index) + 3, flags)
}

// Write first child index
set_first_child(node_index: number, childIndex: number): void {
this.view.setUint32(this.node_offset(node_index) + 20, childIndex, true)
Expand Down
6 changes: 6 additions & 0 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ export class CSSNode {
return this.arena.get_attr_operator(this.index)
}

// Get the attribute flags (for attribute selectors: i, s)
// Returns one of the ATTR_FLAG_* constants
get attr_flags(): number {
return this.arena.get_attr_flags(this.index)
}

// Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%")
get unit(): string | null {
if (this.type !== NODE_VALUE_DIMENSION) return null
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export {
ATTR_OPERATOR_CARET_EQUAL,
ATTR_OPERATOR_DOLLAR_EQUAL,
ATTR_OPERATOR_STAR_EQUAL,
ATTR_FLAG_NONE,
ATTR_FLAG_CASE_INSENSITIVE,
ATTR_FLAG_CASE_SENSITIVE,
} from './arena'

// Constants
Expand Down
113 changes: 112 additions & 1 deletion src/parse-selector.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, test } from 'vitest'
import { SelectorParser, parse_selector } from './parse-selector'
import { CSSDataArena } from './arena'
import { ATTR_OPERATOR_EQUAL, CSSDataArena } from './arena'
import {
NODE_SELECTOR,
NODE_SELECTOR_LIST,
Expand All @@ -16,6 +16,9 @@ import {
NODE_SELECTOR_NTH,
NODE_SELECTOR_NTH_OF,
NODE_SELECTOR_LANG,
ATTR_FLAG_NONE,
ATTR_FLAG_CASE_INSENSITIVE,
ATTR_FLAG_CASE_SENSITIVE,
} from './arena'

// Tests using the exported parse_selector() function
Expand Down Expand Up @@ -520,6 +523,114 @@ describe('SelectorParser', () => {
// Content now stores just the attribute name
expect(getNodeContent(arena, source, child)).toBe('data-test')
})

it('should parse attribute with case-insensitive flag', () => {
const { arena, rootNode, source } = parseSelectorInternal('[type="text" i]')

expect(rootNode).not.toBeNull()
if (!rootNode) return

const selectorWrapper = arena.get_first_child(rootNode)
const child = arena.get_first_child(selectorWrapper)
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
expect(getNodeText(arena, source, child)).toBe('[type="text" i]')
expect(getNodeContent(arena, source, child)).toBe('type')
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
})

it('should parse attribute with case-insensitive flag', () => {
const root = parse_selector('[type="text" i]')

expect(root).not.toBeNull()
if (!root) return

expect(root.type).toBe(NODE_SELECTOR_LIST)
let selector = root.first_child!
expect(selector.type).toBe(NODE_SELECTOR)
let attr = selector.first_child!
expect(attr.type).toBe(NODE_SELECTOR_ATTRIBUTE)
expect(attr.attr_flags).toBe(ATTR_FLAG_CASE_INSENSITIVE)
expect(attr.attr_operator).toBe(ATTR_OPERATOR_EQUAL)
})

it('should parse attribute with case-sensitive flag', () => {
const { arena, rootNode, source } = parseSelectorInternal('[type="text" s]')

expect(rootNode).not.toBeNull()
if (!rootNode) return

const selectorWrapper = arena.get_first_child(rootNode)
const child = arena.get_first_child(selectorWrapper)
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
expect(getNodeText(arena, source, child)).toBe('[type="text" s]')
expect(getNodeContent(arena, source, child)).toBe('type')
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_SENSITIVE)
})

it('should parse attribute with uppercase case-insensitive flag', () => {
const { arena, rootNode, source } = parseSelectorInternal('[type="text" I]')

expect(rootNode).not.toBeNull()
if (!rootNode) return

const selectorWrapper = arena.get_first_child(rootNode)
const child = arena.get_first_child(selectorWrapper)
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
})

it('should parse attribute with whitespace before flag', () => {
const { arena, rootNode, source } = parseSelectorInternal('[type="text" i]')

expect(rootNode).not.toBeNull()
if (!rootNode) return

const selectorWrapper = arena.get_first_child(rootNode)
const child = arena.get_first_child(selectorWrapper)
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
})

it('should parse attribute without flag', () => {
const { arena, rootNode, source } = parseSelectorInternal('[type="text"]')

expect(rootNode).not.toBeNull()
if (!rootNode) return

const selectorWrapper = arena.get_first_child(rootNode)
const child = arena.get_first_child(selectorWrapper)
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_NONE)
})

it('should handle flag with various operators', () => {
// Test with ^= operator
const test1 = parseSelectorInternal('[class^="btn" i]')
if (!test1.rootNode) throw new Error('Expected rootNode')
const wrapper1 = test1.arena.get_first_child(test1.rootNode)
if (!wrapper1) throw new Error('Expected wrapper1')
const child1 = test1.arena.get_first_child(wrapper1)
if (!child1) throw new Error('Expected child1')
expect(test1.arena.get_attr_flags(child1)).toBe(ATTR_FLAG_CASE_INSENSITIVE)

// Test with $= operator
const test2 = parseSelectorInternal('[class$="btn" s]')
if (!test2.rootNode) throw new Error('Expected rootNode')
const wrapper2 = test2.arena.get_first_child(test2.rootNode)
if (!wrapper2) throw new Error('Expected wrapper2')
const child2 = test2.arena.get_first_child(wrapper2)
if (!child2) throw new Error('Expected child2')
expect(test2.arena.get_attr_flags(child2)).toBe(ATTR_FLAG_CASE_SENSITIVE)

// Test with ~= operator
const test3 = parseSelectorInternal('[class~="active" i]')
if (!test3.rootNode) throw new Error('Expected rootNode')
const wrapper3 = test3.arena.get_first_child(test3.rootNode)
if (!wrapper3) throw new Error('Expected wrapper3')
const child3 = test3.arena.get_first_child(wrapper3)
if (!child3) throw new Error('Expected child3')
expect(test3.arena.get_attr_flags(child3)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
})
})

describe('Combinators', () => {
Expand Down
24 changes: 24 additions & 0 deletions src/parse-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import {
ATTR_OPERATOR_CARET_EQUAL,
ATTR_OPERATOR_DOLLAR_EQUAL,
ATTR_OPERATOR_STAR_EQUAL,
ATTR_FLAG_NONE,
ATTR_FLAG_CASE_INSENSITIVE,
ATTR_FLAG_CASE_SENSITIVE,
} from './arena'
import {
TOKEN_IDENT,
Expand Down Expand Up @@ -497,6 +500,7 @@ export class SelectorParser {
if (pos >= end) {
// No operator, just [attr]
this.arena.set_attr_operator(node, ATTR_OPERATOR_NONE)
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
return
}

Expand Down Expand Up @@ -530,6 +534,7 @@ export class SelectorParser {
} else {
// No valid operator
this.arena.set_attr_operator(node, ATTR_OPERATOR_NONE)
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
return
}

Expand All @@ -538,6 +543,7 @@ export class SelectorParser {

if (pos >= end) {
// No value after operator
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
return
}

Expand Down Expand Up @@ -582,6 +588,24 @@ export class SelectorParser {
this.arena.set_value_start(node, value_start)
this.arena.set_value_length(node, value_end - value_start)
}

// Check for attribute flags (i or s) after the value
// Skip whitespace and comments after value
pos = skip_whitespace_and_comments_forward(this.source, value_end, end)

if (pos < end) {
let flag_ch = this.source.charCodeAt(pos)
// Check for 'i' (case-insensitive) or 's' (case-sensitive)
if (flag_ch === 0x69 /* i */ || flag_ch === 0x49 /* I */) {
this.arena.set_attr_flags(node, ATTR_FLAG_CASE_INSENSITIVE)
} else if (flag_ch === 0x73 /* s */ || flag_ch === 0x53 /* S */) {
this.arena.set_attr_flags(node, ATTR_FLAG_CASE_SENSITIVE)
} else {
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
}
} else {
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
}
}

// Parse pseudo-class or pseudo-element (:hover, ::before)
Expand Down
Loading