Skip to content

Commit ddebfd4

Browse files
authored
feat: add attribute selector flag property (#17)
1 parent 7bea331 commit ddebfd4

File tree

6 files changed

+202
-1
lines changed

6 files changed

+202
-1
lines changed

API.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,45 @@ import {
436436
- `NODE_PRELUDE_IMPORT_URL` (38) - Import URL
437437
- `NODE_PRELUDE_IMPORT_LAYER` (39) - Import layer
438438
- `NODE_PRELUDE_IMPORT_SUPPORTS` (40) - Import supports condition
439+
440+
## Attribute Selector Constants
441+
442+
### Attribute Selector Operators
443+
444+
Use these constants with the `node.attr_operator` property to identify the operator in attribute selectors:
445+
446+
- `ATTR_OPERATOR_NONE` (0) - No operator (e.g., `[disabled]`)
447+
- `ATTR_OPERATOR_EQUAL` (1) - Exact match (e.g., `[type="text"]`)
448+
- `ATTR_OPERATOR_TILDE_EQUAL` (2) - Whitespace-separated list contains (e.g., `[class~="active"]`)
449+
- `ATTR_OPERATOR_PIPE_EQUAL` (3) - Starts with or is followed by hyphen (e.g., `[lang|="en"]`)
450+
- `ATTR_OPERATOR_CARET_EQUAL` (4) - Starts with (e.g., `[href^="https"]`)
451+
- `ATTR_OPERATOR_DOLLAR_EQUAL` (5) - Ends with (e.g., `[href$=".pdf"]`)
452+
- `ATTR_OPERATOR_STAR_EQUAL` (6) - Contains substring (e.g., `[href*="example"]`)
453+
454+
### Attribute Selector Flags
455+
456+
Use these constants with the `node.attr_flags` property to identify case sensitivity flags in attribute selectors:
457+
458+
- `ATTR_FLAG_NONE` (0) - No flag specified (default case sensitivity)
459+
- `ATTR_FLAG_CASE_INSENSITIVE` (1) - Case-insensitive matching (e.g., `[type="text" i]`)
460+
- `ATTR_FLAG_CASE_SENSITIVE` (2) - Case-sensitive matching (e.g., `[type="text" s]`)
461+
462+
#### Example
463+
464+
```javascript
465+
import {
466+
parse_selector,
467+
NODE_SELECTOR_ATTRIBUTE,
468+
ATTR_OPERATOR_EQUAL,
469+
ATTR_FLAG_CASE_INSENSITIVE
470+
} from '@projectwallace/css-parser'
471+
472+
const ast = parse_selector('[type="text" i]')
473+
474+
for (let node of ast) {
475+
if (node.type === NODE_SELECTOR_ATTRIBUTE) {
476+
console.log(node.attr_operator === ATTR_OPERATOR_EQUAL) // true
477+
console.log(node.attr_flags === ATTR_FLAG_CASE_INSENSITIVE) // true
478+
}
479+
}
480+
```

src/arena.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export const ATTR_OPERATOR_CARET_EQUAL = 4 // [attr^=value]
8585
export const ATTR_OPERATOR_DOLLAR_EQUAL = 5 // [attr$=value]
8686
export const ATTR_OPERATOR_STAR_EQUAL = 6 // [attr*=value]
8787

88+
// Attribute selector flag constants (stored in 1 byte at offset 3)
89+
export const ATTR_FLAG_NONE = 0 // No flag
90+
export const ATTR_FLAG_CASE_INSENSITIVE = 1 // [attr=value i]
91+
export const ATTR_FLAG_CASE_SENSITIVE = 2 // [attr=value s]
92+
8893
export class CSSDataArena {
8994
private buffer: ArrayBuffer
9095
private view: DataView
@@ -168,6 +173,11 @@ export class CSSDataArena {
168173
return this.view.getUint8(this.node_offset(node_index) + 2)
169174
}
170175

176+
// Read attribute flags (for NODE_SELECTOR_ATTRIBUTE)
177+
get_attr_flags(node_index: number): number {
178+
return this.view.getUint8(this.node_offset(node_index) + 3)
179+
}
180+
171181
// Read first child index (0 = no children)
172182
get_first_child(node_index: number): number {
173183
return this.view.getUint32(this.node_offset(node_index) + 20, true)
@@ -240,6 +250,11 @@ export class CSSDataArena {
240250
this.view.setUint8(this.node_offset(node_index) + 2, operator)
241251
}
242252

253+
// Write attribute flags (for NODE_SELECTOR_ATTRIBUTE)
254+
set_attr_flags(node_index: number, flags: number): void {
255+
this.view.setUint8(this.node_offset(node_index) + 3, flags)
256+
}
257+
243258
// Write first child index
244259
set_first_child(node_index: number, childIndex: number): void {
245260
this.view.setUint32(this.node_offset(node_index) + 20, childIndex, true)

src/css-node.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ export class CSSNode {
161161
return this.arena.get_attr_operator(this.index)
162162
}
163163

164+
// Get the attribute flags (for attribute selectors: i, s)
165+
// Returns one of the ATTR_FLAG_* constants
166+
get attr_flags(): number {
167+
return this.arena.get_attr_flags(this.index)
168+
}
169+
164170
// Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%")
165171
get unit(): string | null {
166172
if (this.type !== NODE_VALUE_DIMENSION) return null

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export {
2323
ATTR_OPERATOR_CARET_EQUAL,
2424
ATTR_OPERATOR_DOLLAR_EQUAL,
2525
ATTR_OPERATOR_STAR_EQUAL,
26+
ATTR_FLAG_NONE,
27+
ATTR_FLAG_CASE_INSENSITIVE,
28+
ATTR_FLAG_CASE_SENSITIVE,
2629
} from './arena'
2730

2831
// Constants

src/parse-selector.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, test } from 'vitest'
22
import { SelectorParser, parse_selector } from './parse-selector'
3-
import { CSSDataArena } from './arena'
3+
import { ATTR_OPERATOR_EQUAL, CSSDataArena } from './arena'
44
import {
55
NODE_SELECTOR,
66
NODE_SELECTOR_LIST,
@@ -16,6 +16,9 @@ import {
1616
NODE_SELECTOR_NTH,
1717
NODE_SELECTOR_NTH_OF,
1818
NODE_SELECTOR_LANG,
19+
ATTR_FLAG_NONE,
20+
ATTR_FLAG_CASE_INSENSITIVE,
21+
ATTR_FLAG_CASE_SENSITIVE,
1922
} from './arena'
2023

2124
// Tests using the exported parse_selector() function
@@ -520,6 +523,114 @@ describe('SelectorParser', () => {
520523
// Content now stores just the attribute name
521524
expect(getNodeContent(arena, source, child)).toBe('data-test')
522525
})
526+
527+
it('should parse attribute with case-insensitive flag', () => {
528+
const { arena, rootNode, source } = parseSelectorInternal('[type="text" i]')
529+
530+
expect(rootNode).not.toBeNull()
531+
if (!rootNode) return
532+
533+
const selectorWrapper = arena.get_first_child(rootNode)
534+
const child = arena.get_first_child(selectorWrapper)
535+
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
536+
expect(getNodeText(arena, source, child)).toBe('[type="text" i]')
537+
expect(getNodeContent(arena, source, child)).toBe('type')
538+
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
539+
})
540+
541+
it('should parse attribute with case-insensitive flag', () => {
542+
const root = parse_selector('[type="text" i]')
543+
544+
expect(root).not.toBeNull()
545+
if (!root) return
546+
547+
expect(root.type).toBe(NODE_SELECTOR_LIST)
548+
let selector = root.first_child!
549+
expect(selector.type).toBe(NODE_SELECTOR)
550+
let attr = selector.first_child!
551+
expect(attr.type).toBe(NODE_SELECTOR_ATTRIBUTE)
552+
expect(attr.attr_flags).toBe(ATTR_FLAG_CASE_INSENSITIVE)
553+
expect(attr.attr_operator).toBe(ATTR_OPERATOR_EQUAL)
554+
})
555+
556+
it('should parse attribute with case-sensitive flag', () => {
557+
const { arena, rootNode, source } = parseSelectorInternal('[type="text" s]')
558+
559+
expect(rootNode).not.toBeNull()
560+
if (!rootNode) return
561+
562+
const selectorWrapper = arena.get_first_child(rootNode)
563+
const child = arena.get_first_child(selectorWrapper)
564+
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
565+
expect(getNodeText(arena, source, child)).toBe('[type="text" s]')
566+
expect(getNodeContent(arena, source, child)).toBe('type')
567+
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_SENSITIVE)
568+
})
569+
570+
it('should parse attribute with uppercase case-insensitive flag', () => {
571+
const { arena, rootNode, source } = parseSelectorInternal('[type="text" I]')
572+
573+
expect(rootNode).not.toBeNull()
574+
if (!rootNode) return
575+
576+
const selectorWrapper = arena.get_first_child(rootNode)
577+
const child = arena.get_first_child(selectorWrapper)
578+
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
579+
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
580+
})
581+
582+
it('should parse attribute with whitespace before flag', () => {
583+
const { arena, rootNode, source } = parseSelectorInternal('[type="text" i]')
584+
585+
expect(rootNode).not.toBeNull()
586+
if (!rootNode) return
587+
588+
const selectorWrapper = arena.get_first_child(rootNode)
589+
const child = arena.get_first_child(selectorWrapper)
590+
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
591+
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
592+
})
593+
594+
it('should parse attribute without flag', () => {
595+
const { arena, rootNode, source } = parseSelectorInternal('[type="text"]')
596+
597+
expect(rootNode).not.toBeNull()
598+
if (!rootNode) return
599+
600+
const selectorWrapper = arena.get_first_child(rootNode)
601+
const child = arena.get_first_child(selectorWrapper)
602+
expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE)
603+
expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_NONE)
604+
})
605+
606+
it('should handle flag with various operators', () => {
607+
// Test with ^= operator
608+
const test1 = parseSelectorInternal('[class^="btn" i]')
609+
if (!test1.rootNode) throw new Error('Expected rootNode')
610+
const wrapper1 = test1.arena.get_first_child(test1.rootNode)
611+
if (!wrapper1) throw new Error('Expected wrapper1')
612+
const child1 = test1.arena.get_first_child(wrapper1)
613+
if (!child1) throw new Error('Expected child1')
614+
expect(test1.arena.get_attr_flags(child1)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
615+
616+
// Test with $= operator
617+
const test2 = parseSelectorInternal('[class$="btn" s]')
618+
if (!test2.rootNode) throw new Error('Expected rootNode')
619+
const wrapper2 = test2.arena.get_first_child(test2.rootNode)
620+
if (!wrapper2) throw new Error('Expected wrapper2')
621+
const child2 = test2.arena.get_first_child(wrapper2)
622+
if (!child2) throw new Error('Expected child2')
623+
expect(test2.arena.get_attr_flags(child2)).toBe(ATTR_FLAG_CASE_SENSITIVE)
624+
625+
// Test with ~= operator
626+
const test3 = parseSelectorInternal('[class~="active" i]')
627+
if (!test3.rootNode) throw new Error('Expected rootNode')
628+
const wrapper3 = test3.arena.get_first_child(test3.rootNode)
629+
if (!wrapper3) throw new Error('Expected wrapper3')
630+
const child3 = test3.arena.get_first_child(wrapper3)
631+
if (!child3) throw new Error('Expected child3')
632+
expect(test3.arena.get_attr_flags(child3)).toBe(ATTR_FLAG_CASE_INSENSITIVE)
633+
})
523634
})
524635

525636
describe('Combinators', () => {

src/parse-selector.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {
2323
ATTR_OPERATOR_CARET_EQUAL,
2424
ATTR_OPERATOR_DOLLAR_EQUAL,
2525
ATTR_OPERATOR_STAR_EQUAL,
26+
ATTR_FLAG_NONE,
27+
ATTR_FLAG_CASE_INSENSITIVE,
28+
ATTR_FLAG_CASE_SENSITIVE,
2629
} from './arena'
2730
import {
2831
TOKEN_IDENT,
@@ -497,6 +500,7 @@ export class SelectorParser {
497500
if (pos >= end) {
498501
// No operator, just [attr]
499502
this.arena.set_attr_operator(node, ATTR_OPERATOR_NONE)
503+
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
500504
return
501505
}
502506

@@ -530,6 +534,7 @@ export class SelectorParser {
530534
} else {
531535
// No valid operator
532536
this.arena.set_attr_operator(node, ATTR_OPERATOR_NONE)
537+
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
533538
return
534539
}
535540

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

539544
if (pos >= end) {
540545
// No value after operator
546+
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
541547
return
542548
}
543549

@@ -582,6 +588,24 @@ export class SelectorParser {
582588
this.arena.set_value_start(node, value_start)
583589
this.arena.set_value_length(node, value_end - value_start)
584590
}
591+
592+
// Check for attribute flags (i or s) after the value
593+
// Skip whitespace and comments after value
594+
pos = skip_whitespace_and_comments_forward(this.source, value_end, end)
595+
596+
if (pos < end) {
597+
let flag_ch = this.source.charCodeAt(pos)
598+
// Check for 'i' (case-insensitive) or 's' (case-sensitive)
599+
if (flag_ch === 0x69 /* i */ || flag_ch === 0x49 /* I */) {
600+
this.arena.set_attr_flags(node, ATTR_FLAG_CASE_INSENSITIVE)
601+
} else if (flag_ch === 0x73 /* s */ || flag_ch === 0x53 /* S */) {
602+
this.arena.set_attr_flags(node, ATTR_FLAG_CASE_SENSITIVE)
603+
} else {
604+
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
605+
}
606+
} else {
607+
this.arena.set_attr_flags(node, ATTR_FLAG_NONE)
608+
}
585609
}
586610

587611
// Parse pseudo-class or pseudo-element (:hover, ::before)

0 commit comments

Comments
 (0)