diff --git a/src/arena.ts b/src/arena.ts
index efdbc98..6bf52b0 100644
--- a/src/arena.ts
+++ b/src/arena.ts
@@ -39,6 +39,7 @@ export const NODE_VALUE_STRING = 13 // quoted string: "hello", 'world'
export const NODE_VALUE_COLOR = 14 // hex color: #fff, #ff0000
export const NODE_VALUE_FUNCTION = 15 // function: calc(), var(), url()
export const NODE_VALUE_OPERATOR = 16 // operator: +, -, *, /, comma
+export const NODE_VALUE_PARENTHESIS = 17 // parenthesized expression: (100% - 50px)
// Selector node type constants (for detailed selector parsing)
export const NODE_SELECTOR_LIST = 20 // comma-separated selectors
diff --git a/src/css-node.ts b/src/css-node.ts
index 52618a8..5649454 100644
--- a/src/css-node.ts
+++ b/src/css-node.ts
@@ -15,6 +15,7 @@ import {
NODE_VALUE_COLOR,
NODE_VALUE_FUNCTION,
NODE_VALUE_OPERATOR,
+ NODE_VALUE_PARENTHESIS,
NODE_SELECTOR_LIST,
NODE_SELECTOR_TYPE,
NODE_SELECTOR_CLASS,
@@ -66,6 +67,7 @@ export type CSSNodeType =
| typeof NODE_VALUE_COLOR
| typeof NODE_VALUE_FUNCTION
| typeof NODE_VALUE_OPERATOR
+ | typeof NODE_VALUE_PARENTHESIS
| typeof NODE_SELECTOR_LIST
| typeof NODE_SELECTOR_TYPE
| typeof NODE_SELECTOR_CLASS
diff --git a/src/index.ts b/src/index.ts
index 5ee3b7e..32184eb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -43,6 +43,7 @@ export {
NODE_VALUE_COLOR,
NODE_VALUE_FUNCTION,
NODE_VALUE_OPERATOR,
+ NODE_VALUE_PARENTHESIS,
NODE_SELECTOR_LIST,
NODE_SELECTOR_TYPE,
NODE_SELECTOR_CLASS,
diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts
index 4a27545..45f6c6c 100644
--- a/src/parse-value.test.ts
+++ b/src/parse-value.test.ts
@@ -8,6 +8,7 @@ import {
NODE_VALUE_COLOR,
NODE_VALUE_FUNCTION,
NODE_VALUE_OPERATOR,
+ NODE_VALUE_PARENTHESIS,
} from './arena'
describe('ValueParser', () => {
@@ -215,7 +216,7 @@ describe('ValueParser', () => {
expect(decl?.values[0].children[0].text).toBe('--primary-color')
})
- it('should parse url() function', () => {
+ it('should parse url() function with quoted string', () => {
const parser = new Parser('body { background: url("image.png"); }')
const root = parser.parse()
const rule = root.first_child
@@ -228,6 +229,90 @@ describe('ValueParser', () => {
expect(decl?.values[0].children[0].type).toBe(NODE_VALUE_STRING)
expect(decl?.values[0].children[0].text).toBe('"image.png"')
})
+
+ it('should parse url() function with unquoted URL containing dots', () => {
+ const parser = new Parser('body { cursor: url(mycursor.cur); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('url')
+
+ // URL function should not parse children - content is available via node.value
+ expect(func?.has_children).toBe(false)
+ expect(func?.text).toBe('url(mycursor.cur)')
+ expect(func?.value).toBe('mycursor.cur')
+ })
+
+ it('should parse src() function with unquoted URL', () => {
+ const parser = new Parser('body { content: src(myfont.woff2); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('src')
+ expect(func?.has_children).toBe(false)
+ expect(func?.text).toBe('src(myfont.woff2)')
+ expect(func?.value).toBe('myfont.woff2')
+ })
+
+ it('should parse url() with base64 data URL', () => {
+ const parser = new Parser('body { background: url(data:image/png;base64,iVBORw0KGg); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('url')
+ expect(func?.has_children).toBe(false)
+ expect(func?.value).toBe('data:image/png;base64,iVBORw0KGg')
+ })
+
+ it('should parse url() with inline SVG', () => {
+ const parser = new Parser('body { background: url(data:image/svg+xml,); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('url')
+ expect(func?.has_children).toBe(false)
+ expect(func?.value).toBe('data:image/svg+xml,')
+ })
+
+ it('should provide node.value for other functions like calc()', () => {
+ const parser = new Parser('body { width: calc(100% - 20px); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('calc')
+ expect(func?.text).toBe('calc(100% - 20px)')
+ expect(func?.value).toBe('100% - 20px')
+ expect(func?.has_children).toBe(true) // calc() parses its children
+ })
+
+ it('should provide node.value for var() function', () => {
+ const parser = new Parser('body { color: var(--primary-color); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('var')
+ expect(func?.text).toBe('var(--primary-color)')
+ expect(func?.value).toBe('--primary-color')
+ expect(func?.has_children).toBe(true) // var() parses its children
+ })
})
describe('Complex values', () => {
@@ -368,4 +453,79 @@ describe('ValueParser', () => {
expect(operators?.[3].text).toBe('-')
})
})
+
+ describe('Parentheses', () => {
+ it('should parse parenthesized expressions in calc()', () => {
+ const parser = new Parser('body { width: calc((100% - 50px) / 2); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('calc')
+ expect(func?.children).toHaveLength(3)
+
+ // First child should be a parenthesis node
+ expect(func?.children[0].type).toBe(NODE_VALUE_PARENTHESIS)
+ expect(func?.children[0].text).toBe('(100% - 50px)')
+
+ // Check parenthesis content
+ const parenNode = func?.children[0]
+ expect(parenNode?.children).toHaveLength(3)
+ expect(parenNode?.children[0].type).toBe(NODE_VALUE_DIMENSION)
+ expect(parenNode?.children[0].text).toBe('100%')
+ expect(parenNode?.children[1].type).toBe(NODE_VALUE_OPERATOR)
+ expect(parenNode?.children[1].text).toBe('-')
+ expect(parenNode?.children[2].type).toBe(NODE_VALUE_DIMENSION)
+ expect(parenNode?.children[2].text).toBe('50px')
+
+ // Second child should be division operator
+ expect(func?.children[1].type).toBe(NODE_VALUE_OPERATOR)
+ expect(func?.children[1].text).toBe('/')
+
+ // Third child should be number
+ expect(func?.children[2].type).toBe(NODE_VALUE_NUMBER)
+ expect(func?.children[2].text).toBe('2')
+ })
+
+ it('should parse complex nested parentheses', () => {
+ const parser = new Parser('body { width: calc(((100% - var(--x)) / 12 * 6) + (-1 * var(--y))); }')
+ const root = parser.parse()
+ const rule = root.first_child
+ const decl = rule?.first_child?.next_sibling?.first_child
+ const func = decl?.values[0]
+
+ expect(func?.type).toBe(NODE_VALUE_FUNCTION)
+ expect(func?.name).toBe('calc')
+
+ // The calc function should have 3 children: parenthesis + operator + parenthesis
+ expect(func?.children).toHaveLength(3)
+ expect(func?.children[0].type).toBe(NODE_VALUE_PARENTHESIS)
+ expect(func?.children[0].text).toBe('((100% - var(--x)) / 12 * 6)')
+ expect(func?.children[1].type).toBe(NODE_VALUE_OPERATOR)
+ expect(func?.children[1].text).toBe('+')
+ expect(func?.children[2].type).toBe(NODE_VALUE_PARENTHESIS)
+ expect(func?.children[2].text).toBe('(-1 * var(--y))')
+
+ // Check first parenthesis has nested parenthesis and preserves structure
+ const firstParen = func?.children[0]
+ expect(firstParen?.children).toHaveLength(5) // paren + / + 12 + * + 6
+ expect(firstParen?.children[0].type).toBe(NODE_VALUE_PARENTHESIS)
+ expect(firstParen?.children[0].text).toBe('(100% - var(--x))')
+
+ // Check nested parenthesis has function
+ const nestedParen = firstParen?.children[0]
+ expect(nestedParen?.children[2].type).toBe(NODE_VALUE_FUNCTION)
+ expect(nestedParen?.children[2].name).toBe('var')
+
+ // Check second parenthesis has content
+ const secondParen = func?.children[2]
+ expect(secondParen?.children).toHaveLength(3) // -1 * var(--y)
+ expect(secondParen?.children[0].type).toBe(NODE_VALUE_NUMBER)
+ expect(secondParen?.children[0].text).toBe('-1')
+ expect(secondParen?.children[2].type).toBe(NODE_VALUE_FUNCTION)
+ expect(secondParen?.children[2].name).toBe('var')
+ })
+ })
})
diff --git a/src/parse-value.ts b/src/parse-value.ts
index 65f56a0..1d3a431 100644
--- a/src/parse-value.ts
+++ b/src/parse-value.ts
@@ -9,6 +9,7 @@ import {
NODE_VALUE_COLOR,
NODE_VALUE_FUNCTION,
NODE_VALUE_OPERATOR,
+ NODE_VALUE_PARENTHESIS,
} from './arena'
import {
TOKEN_IDENT,
@@ -125,6 +126,9 @@ export class ValueParser {
case TOKEN_COMMA:
return this.create_node(NODE_VALUE_OPERATOR, start, end)
+ case TOKEN_LEFT_PAREN:
+ return this.parse_parenthesis_node(start, end)
+
default:
// Unknown token type, skip it
return null
@@ -167,10 +171,80 @@ export class ValueParser {
this.arena.set_content_start(node, start)
this.arena.set_content_length(node, name_end - start)
+ // Get function name to check for special handling
+ let func_name = this.source.substring(start, name_end).toLowerCase()
+
+ // Special handling for url() and src() functions with unquoted content:
+ // Don't parse contents to preserve URLs with dots, base64, inline SVGs, etc.
+ // Users can extract the full URL from the function's text property
+ // Note: Quoted urls like url("...") or url('...') parse normally
+ if (func_name === 'url' || func_name === 'src') {
+ // Peek at the next token to see if it's a string
+ // If it's a string, parse normally. Otherwise, skip parsing children.
+ let save_pos = this.lexer.save_position()
+ this.lexer.next_token_fast(false)
+
+ // Skip whitespace
+ while (this.is_whitespace_token() && this.lexer.pos < this.value_end) {
+ this.lexer.next_token_fast(false)
+ }
+
+ let first_token_type = this.lexer.token_type
+
+ // Restore lexer position
+ this.lexer.restore_position(save_pos)
+
+ // If the first non-whitespace token is a string, parse normally
+ if (first_token_type === TOKEN_STRING) {
+ // Fall through to normal parsing below
+ } else {
+ // Unquoted URL - don't parse children
+ // Note: We can't rely on value_end because URLs may contain semicolons
+ // that confuse the declaration parser (e.g., data:image/png;base64,...)
+ // So we consume tokens until we find the matching ')' regardless of value_end
+ let paren_depth = 1
+ let func_end = end
+ let content_start = end // Position after 'url('
+ let content_end = end
+
+ // Just consume tokens until we find the matching ')'
+ // Don't create child nodes
+ while (paren_depth > 0) {
+ this.lexer.next_token_fast(false)
+
+ let token_type = this.lexer.token_type
+ if (token_type === TOKEN_EOF) break
+
+ // Track parentheses depth
+ if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) {
+ paren_depth++
+ } else if (token_type === TOKEN_RIGHT_PAREN) {
+ paren_depth--
+ if (paren_depth === 0) {
+ content_end = this.lexer.token_start // Position of ')'
+ func_end = this.lexer.token_end
+ break
+ }
+ }
+ }
+
+ // Set function total length (includes opening and closing parens)
+ this.arena.set_length(node, func_end - start)
+
+ // Set value to the content between parentheses (accessible via node.value)
+ this.arena.set_value_start(node, content_start)
+ this.arena.set_value_length(node, content_end - content_start)
+
+ return node
+ }
+ }
+
// Parse function arguments (everything until matching ')')
let args: number[] = []
let paren_depth = 1
let func_end = end
+ let content_start = end // Position after function name and '('
+ let content_end = end
while (this.lexer.pos < this.value_end && paren_depth > 0) {
this.lexer.next_token_fast(false)
@@ -185,6 +259,7 @@ export class ValueParser {
} else if (token_type === TOKEN_RIGHT_PAREN) {
paren_depth--
if (paren_depth === 0) {
+ content_end = this.lexer.token_start // Position of ')'
func_end = this.lexer.token_end
break
}
@@ -203,6 +278,10 @@ export class ValueParser {
// Set function total length
this.arena.set_length(node, func_end - start)
+ // Set value to the content between parentheses (accessible via node.value)
+ this.arena.set_value_start(node, content_start)
+ this.arena.set_value_length(node, content_end - content_start)
+
// Link arguments as children
if (args.length > 0) {
this.arena.set_first_child(node, args[0])
@@ -216,6 +295,64 @@ export class ValueParser {
return node
}
+
+ private parse_parenthesis_node(start: number, end: number): number {
+ // Create parenthesis node
+ let node = this.arena.create_node()
+ this.arena.set_type(node, NODE_VALUE_PARENTHESIS)
+ this.arena.set_start_offset(node, start)
+
+ // Parse parenthesized content (everything until matching ')')
+ let children: number[] = []
+ let paren_depth = 1
+ let paren_end = end
+
+ while (this.lexer.pos < this.value_end && paren_depth > 0) {
+ this.lexer.next_token_fast(false)
+
+ let token_type = this.lexer.token_type
+ if (token_type === TOKEN_EOF) break
+ if (this.lexer.token_start >= this.value_end) break
+
+ // Check for closing paren BEFORE parsing child nodes
+ // This is important because child nodes (like nested parentheses or functions)
+ // will consume tokens including closing parens
+ if (token_type === TOKEN_RIGHT_PAREN) {
+ paren_depth--
+ if (paren_depth === 0) {
+ paren_end = this.lexer.token_end
+ break
+ }
+ }
+
+ // Skip whitespace
+ if (this.is_whitespace_token()) continue
+
+ // Parse child node
+ // Note: We don't track paren_depth for LEFT_PAREN or TOKEN_FUNCTION here
+ // because parse_value_node() will recursively handle them
+ let child_node = this.parse_value_node()
+ if (child_node !== null) {
+ children.push(child_node)
+ }
+ }
+
+ // Set parenthesis total length (includes opening and closing parens)
+ this.arena.set_length(node, paren_end - start)
+
+ // Link children as siblings
+ if (children.length > 0) {
+ this.arena.set_first_child(node, children[0])
+ this.arena.set_last_child(node, children[children.length - 1])
+
+ // Chain children as siblings
+ for (let i = 0; i < children.length - 1; i++) {
+ this.arena.set_next_sibling(children[i], children[i + 1])
+ }
+ }
+
+ return node
+ }
}
/**
diff --git a/src/parse.ts b/src/parse.ts
index ef33d0f..097c6da 100644
--- a/src/parse.ts
+++ b/src/parse.ts
@@ -4,7 +4,6 @@ import {
CSSDataArena,
NODE_STYLESHEET,
NODE_STYLE_RULE,
- NODE_SELECTOR,
NODE_SELECTOR_LIST,
NODE_DECLARATION,
NODE_AT_RULE,
@@ -590,6 +589,7 @@ export {
NODE_VALUE_COLOR,
NODE_VALUE_FUNCTION,
NODE_VALUE_OPERATOR,
+ NODE_VALUE_PARENTHESIS,
NODE_SELECTOR_LIST,
NODE_SELECTOR_TYPE,
NODE_SELECTOR_CLASS,