From 78039f717da7faa7997c1167e58a3ed69542e617 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 2 Dec 2025 16:19:07 +0100 Subject: [PATCH 1/2] feat: add attribute selector flag property --- API.md | 42 +++++++++++++++++ src/arena.ts | 15 ++++++ src/css-node.ts | 6 +++ src/index.ts | 3 ++ src/parse-selector.test.ts | 96 ++++++++++++++++++++++++++++++++++++++ src/parse-selector.ts | 24 ++++++++++ 6 files changed, 186 insertions(+) diff --git a/API.md b/API.md index 3f779ea..1ed4930 100644 --- a/API.md +++ b/API.md @@ -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 + } +} +``` diff --git a/src/arena.ts b/src/arena.ts index 96e1ec2..a5ebb8c 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -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 @@ -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) @@ -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) diff --git a/src/css-node.ts b/src/css-node.ts index bdb005c..b4f2750 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 7d50968..2aa617c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index e5dea82..c7bbf54 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -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 @@ -520,6 +523,99 @@ 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-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', () => { diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 60e94dc..09907eb 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -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, @@ -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 } @@ -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 } @@ -538,6 +543,7 @@ export class SelectorParser { if (pos >= end) { // No value after operator + this.arena.set_attr_flags(node, ATTR_FLAG_NONE) return } @@ -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) From dba98e4a819fc3871930c1c1dd7c8bdf6d756064 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 2 Dec 2025 22:29:06 +0100 Subject: [PATCH 2/2] add better test --- src/parse-selector.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index c7bbf54..c3b0227 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -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, @@ -538,6 +538,21 @@ describe('SelectorParser', () => { 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]')