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,