diff --git a/API.md b/API.md index ae0361b..ea5f5d0 100644 --- a/API.md +++ b/API.md @@ -63,6 +63,7 @@ function parse(source: string, options?: ParserOptions): CSSNode - `all_compounds` - Array of compound arrays split by combinators (for NODE_SELECTOR) - `is_compound` - Whether selector has no combinators (for NODE_SELECTOR) - `first_compound_text` - Text of first compound selector (for NODE_SELECTOR) +- `clone(options?)` - Clone node as a mutable plain object with children as arrays ### Example 1: Basic Parsing @@ -281,7 +282,7 @@ const ast = parse('.foo { color: red; }') // Using type_name directly on nodes for (let node of ast) { - console.log(`${node.type_name}: ${node.text}`) + console.log(`${node.type_name}: ${node.text}`) } // Output: // style_rule: .foo { color: red; } @@ -301,7 +302,7 @@ console.log(TYPE_NAMES[NODE_DECLARATION]) // 'declaration' // Compare strings instead of numeric constants if (node.type_name === 'declaration') { - console.log(`Property: ${node.property}, Value: ${node.value}`) + console.log(`Property: ${node.property}, Value: ${node.value}`) } ``` @@ -327,14 +328,14 @@ const nthOf = nthPseudo.first_child // NODE_SELECTOR_NTH_OF // Direct access to formula console.log(nthOf.nth.type === NODE_SELECTOR_NTH) // true -console.log(nthOf.nth.nth_a) // "2n" -console.log(nthOf.nth.nth_b) // "+1" +console.log(nthOf.nth.nth_a) // "2n" +console.log(nthOf.nth.nth_b) // "+1" // Direct access to selector list from :nth-child(of) -console.log(nthOf.selector.text) // ".foo" +console.log(nthOf.selector.text) // ".foo" // Or use the unified helper on the pseudo-class -console.log(nthPseudo.selector_list.text) // ".foo" +console.log(nthPseudo.selector_list.text) // ".foo" ``` **Before (nested loops required):** @@ -343,18 +344,18 @@ console.log(nthPseudo.selector_list.text) // ".foo" // Had to manually traverse to find selector list let child = pseudo.first_child while (child) { - if (child.type === NODE_SELECTOR_NTH_OF) { - let inner = child.first_child - while (inner) { - if (inner.type === NODE_SELECTOR_LIST) { - processSelectors(inner) - break - } - inner = inner.next_sibling - } - break - } - child = child.next_sibling + if (child.type === NODE_SELECTOR_NTH_OF) { + let inner = child.first_child + while (inner) { + if (inner.type === NODE_SELECTOR_LIST) { + processSelectors(inner) + break + } + inner = inner.next_sibling + } + break + } + child = child.next_sibling } ``` @@ -363,7 +364,7 @@ while (child) { ```typescript // Simple and clear if (pseudo.selector_list) { - processSelectors(pseudo.selector_list) + processSelectors(pseudo.selector_list) } ``` @@ -380,9 +381,9 @@ const selector = root.first_child // Hot path: Calculate specificity (zero allocations) let [id, cls, type] = [0, 0, 0] for (let part of selector.compound_parts()) { - if (part.type === NODE_SELECTOR_ID) id++ - else if (part.type === NODE_SELECTOR_CLASS) cls++ - else if (part.type === NODE_SELECTOR_TYPE) type++ + if (part.type === NODE_SELECTOR_ID) id++ + else if (part.type === NODE_SELECTOR_CLASS) cls++ + else if (part.type === NODE_SELECTOR_TYPE) type++ } console.log('Specificity:', [id, cls, type]) // [1, 1, 1] @@ -398,7 +399,7 @@ console.log('Compounds:', all.length) // 3 // [[div, .container, #app], [p, .text], [span]] for (let compound of all) { - console.log('Compound:', compound.map(n => n.text).join('')) + console.log('Compound:', compound.map((n) => n.text).join('')) } // Output: // Compound: div.container#app @@ -416,12 +417,12 @@ console.log('First text:', selector.first_compound_text) // "div.container#app" const compoundParts = [] let selectorPart = selector.first_child while (selectorPart) { - if (selectorPart.type === NODE_SELECTOR_COMBINATOR) break - compoundParts.push(selectorPart) - selectorPart = selectorPart.next_sibling + if (selectorPart.type === NODE_SELECTOR_COMBINATOR) break + compoundParts.push(selectorPart) + selectorPart = selectorPart.next_sibling } // Then... REPARSING! ❌ -const text = compoundParts.map(n => n.text).join('') +const text = compoundParts.map((n) => n.text).join('') const result = parse_selector(text) // Expensive! ``` @@ -434,11 +435,69 @@ for (let part of selector.compound_parts()) { ... } // Zero allocations ``` **Performance Benefits**: + - `compound_parts()` iterator: 0 allocations, lazy evaluation - `first_compound`: Small array allocation (~40-200 bytes typical) - **10-20x faster** than reparsing approach - All operations O(n) where n = number of child nodes +### Example 11: Node Cloning + +Convert arena-backed immutable nodes into mutable plain JavaScript objects for manipulation: + +```typescript +import { parse } from '@projectwallace/css-parser' + +const ast = parse('div { margin: 10px 20px; padding: 5px; }') +const rule = ast.first_child +const block = rule.block +const marginDecl = block.first_child + +// Shallow clone (no children) +const shallow = marginDecl.clone({ deep: false }) +console.log(shallow.type) // NODE_DECLARATION +console.log(shallow.type_name) // "declaration" +console.log(shallow.property) // "margin" +console.log(shallow.children) // [] (empty array) + +// Deep clone (includes all children) +const deep = marginDecl.clone({ deep: true }) +console.log(deep.children.length) // 2 (dimension nodes) +console.log(deep.children[0].value) // 10 +console.log(deep.children[0].unit) // "px" +console.log(deep.children[1].value) // 20 + +// Clone with location information +const withLocation = marginDecl.clone({ locations: true }) +console.log(withLocation.line) // 1 +console.log(withLocation.column) // 6 +console.log(withLocation.offset) // 6 + +// Cloned objects are mutable +const clone = marginDecl.clone() +clone.value = '0' +clone.children.push({ type: 99, text: 'test', children: [] }) +// Original node unchanged ✅ +``` + +**Use Cases**: + +- Convert nodes to plain objects for modification +- Create synthetic AST nodes for tools +- Extract and manipulate selector parts +- Build custom transformations + +**Options**: + +- `deep?: boolean` (default: `true`) - Recursively clone children +- `locations?: boolean` (default: `false`) - Include line/column/offset/length + +**Return Type**: Plain object with: + +- All node properties extracted (including `type_name`) +- `children` as array (no linked lists) +- Mutable - can be freely modified + --- ## `parse_selector(source)` @@ -626,10 +685,12 @@ For formatters and tools that need to reconstruct CSS, the parser distinguishes - `:lang(en)` → `has_children = true` (function syntax with content) The `has_children` property on pseudo-class and pseudo-element nodes returns `true` if: + 1. The node has actual child nodes (parsed content), OR 2. The node uses function syntax (has parentheses), indicated by the `FLAG_HAS_PARENS` flag This allows formatters to correctly reconstruct selectors: + - `:hover` → no parentheses needed - `:lang()` → parentheses needed (even though empty) @@ -674,19 +735,14 @@ Use these constants with the `node.attr_flags` property to identify case sensiti #### Example ```javascript -import { - parse_selector, - NODE_SELECTOR_ATTRIBUTE, - ATTR_OPERATOR_EQUAL, - ATTR_FLAG_CASE_INSENSITIVE -} from '@projectwallace/css-parser' +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 - } + 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/css-node.test.ts b/src/css-node.test.ts index cb2c87c..ae9bac8 100644 --- a/src/css-node.test.ts +++ b/src/css-node.test.ts @@ -1,14 +1,17 @@ import { describe, test, expect } from 'vitest' -import { Parser } from './parse' +import { Parser, parse } from './parse' import { parse_selector } from './parse-selector' import { NODE_DECLARATION, NODE_STYLE_RULE, NODE_AT_RULE, NODE_SELECTOR_NTH, - NODE_SELECTOR_NTH_OF, NODE_SELECTOR_LIST, NODE_SELECTOR_PSEUDO_CLASS, + NODE_VALUE_DIMENSION, + NODE_VALUE_NUMBER, + NODE_VALUE_FUNCTION, + NODE_SELECTOR_ATTRIBUTE, } from './arena' describe('CSSNode', () => { @@ -1041,4 +1044,230 @@ describe('CSSNode', () => { }) }) }) + + describe('Node cloning', () => { + describe('clone() method', () => { + test('creates plain object with core properties', () => { + const ast = parse('div { color: red; }', { parse_values: false, parse_selectors: false }) + const rule = ast.first_child! + const block = rule.block! + const decl = block.first_child! + + const clone = decl.clone({ deep: false }) + + expect(clone.type).toBe(NODE_DECLARATION) + expect(clone.type_name).toBe('declaration') + expect(clone.text).toBe('color: red;') + expect(clone.name).toBe('color') + expect(clone.property).toBe('color') + expect(clone.value).toBe('red') + expect(clone.children).toEqual([]) + }) + + test('shallow clone has empty children array', () => { + const ast = parse('div { margin: 10px 20px; }') + const decl = ast.first_child!.block!.first_child! + + const shallow = decl.clone({ deep: false }) + + expect(shallow.children).toEqual([]) + expect(shallow.type).toBe(NODE_DECLARATION) + }) + + test('deep clone includes children as array', () => { + const ast = parse('div { margin: 10px 20px; }') + const decl = ast.first_child!.block!.first_child! + + const deep = decl.clone() + + expect(deep.children.length).toBe(2) + expect(deep.children[0].type).toBe(NODE_VALUE_DIMENSION) + expect(deep.children[0].value).toBe(10) + expect(deep.children[0].unit).toBe('px') + expect(deep.children[1].value).toBe(20) + expect(deep.children[1].unit).toBe('px') + }) + + test('collects multiple children correctly', () => { + const ast = parse('div { margin: 10px 20px 30px 40px; }') + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone() + + expect(clone.children.length).toBe(4) + expect(clone.children[0].value).toBe(10) + expect(clone.children[1].value).toBe(20) + expect(clone.children[2].value).toBe(30) + expect(clone.children[3].value).toBe(40) + }) + + test('handles nested children', () => { + const ast = parse('div { margin: calc(10px + 20px); }') + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone() + + expect(clone.children.length).toBe(1) + expect(clone.children[0].type).toBe(NODE_VALUE_FUNCTION) + expect(clone.children[0].name).toBe('calc') + // Function should have nested children + expect(clone.children[0].children.length).toBeGreaterThan(0) + }) + }) + + describe('Type-specific properties', () => { + test('extracts declaration properties', () => { + const ast = parse('div { color: red !important; }', { parse_values: false }) + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone({ deep: false }) + + expect(clone.type).toBe(NODE_DECLARATION) + expect(clone.type_name).toBe('declaration') + expect(clone.property).toBe('color') + expect(clone.name).toBe('color') + expect(clone.value).toBe('red') + expect(clone.is_important).toBe(true) + }) + + test('extracts at-rule properties', () => { + const ast = parse('@media screen { }', { parse_atrule_preludes: false }) + const atrule = ast.first_child! + + const clone = atrule.clone({ deep: false }) + + expect(clone.type).toBe(NODE_AT_RULE) + expect(clone.type_name).toBe('atrule') + expect(clone.name).toBe('media') + expect(clone.prelude).toBe('screen') + }) + + test('extracts dimension value with unit', () => { + const ast = parse('div { width: 100px; }') + const decl = ast.first_child!.block!.first_child! + const dimension = decl.first_child! + + const clone = dimension.clone({ deep: false }) + + expect(clone.type).toBe(NODE_VALUE_DIMENSION) + expect(clone.type_name).toBe('dimension') + expect(clone.value).toBe(100) + expect(clone.unit).toBe('px') + }) + + test('extracts number value', () => { + const ast = parse('div { opacity: 0.5; }') + const decl = ast.first_child!.block!.first_child! + const number = decl.first_child! + + const clone = number.clone({ deep: false }) + + expect(clone.type).toBe(NODE_VALUE_NUMBER) + expect(clone.value).toBe(0.5) + expect(clone.unit).toBeUndefined() + }) + + test('extracts selector attribute properties', () => { + const ast = parse_selector('[data-foo="bar"]') + const selector = ast.first_child! + const attribute = selector.first_child! + + const clone = attribute.clone({ deep: false }) + + expect(clone.type).toBe(NODE_SELECTOR_ATTRIBUTE) + expect(clone.type_name).toBe('attribute-selector') + expect(clone.attr_operator).toBeDefined() + expect(clone.attr_flags).toBeDefined() + }) + + test('extracts nth selector properties', () => { + const ast = parse_selector(':nth-child(2n+1)') + const selector = ast.first_child! + const pseudo = selector.first_child! + const nth = pseudo.first_child! + + const clone = nth.clone({ deep: false }) + + expect(clone.type).toBe(NODE_SELECTOR_NTH) + expect(clone.nth_a).toBe('2n') + expect(clone.nth_b).toBe('+1') + }) + }) + + describe('Flags', () => { + test('includes is_important flag when true', () => { + const ast = parse('div { color: red !important; }', { parse_values: false }) + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone() + + expect(clone.is_important).toBe(true) + }) + + test('is_important is false', () => { + const ast = parse('div { color: red; }') + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone() + + expect(clone.is_important).toBe(false) + }) + + test('omits is_important when not a declaration', () => { + const ast = parse('div { color: red; }') + const rule = ast.first_child! + + const clone = rule.clone() + + expect(clone.is_important).toBeUndefined() + }) + + test('includes is_vendor_prefixed flag', () => { + const ast = parse('div { -webkit-transform: rotate(45deg); }', { parse_values: false }) + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone() + + expect(clone.is_vendor_prefixed).toBe(true) + }) + }) + + describe('Location information', () => { + test('omits location by default', () => { + const ast = parse('div { color: red; }', { parse_values: false }) + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone() + + expect(clone.line).toBeUndefined() + expect(clone.column).toBeUndefined() + expect(clone.offset).toBeUndefined() + expect(clone.length).toBeUndefined() + }) + + test('includes location when requested', () => { + const ast = parse('div { color: red; }', { parse_values: false }) + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone({ locations: true }) + + expect(clone.line).toBeDefined() + expect(clone.column).toBeDefined() + expect(clone.offset).toBeDefined() + expect(clone.length).toBeDefined() + }) + + test('includes location in deep cloned children', () => { + const ast = parse('div { margin: 10px 20px; }') + const decl = ast.first_child!.block!.first_child! + + const clone = decl.clone({ locations: true }) + + expect(clone.children[0].line).toBeDefined() + expect(clone.children[0].column).toBeDefined() + expect(clone.children[1].line).toBeDefined() + expect(clone.children[1].column).toBeDefined() + }) + }) + }) }) diff --git a/src/css-node.ts b/src/css-node.ts index 6b110be..aaabe56 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -136,6 +136,53 @@ export type CSSNodeType = | typeof NODE_PRELUDE_IMPORT_LAYER | typeof NODE_PRELUDE_IMPORT_SUPPORTS +// Options for cloning nodes +export interface CloneOptions { + /** + * Recursively clone all children + * @default true + */ + deep?: boolean + /** + * Include location information (line, column, offset, length) + * @default false + */ + locations?: boolean +} + +// Plain object representation of a CSSNode +export type PlainCSSNode = { + // Core properties (always present) + type: number + type_name: string + text: string + children: PlainCSSNode[] + + // Optional properties (only when meaningful) + name?: string + property?: string + value?: string | number | null + unit?: string + prelude?: string + + // Flags (only when true) + is_important?: boolean + is_vendor_prefixed?: boolean + has_error?: boolean + + // Selector-specific + attr_operator?: number + attr_flags?: number + nth_a?: string | null + nth_b?: string | null + + // Location (only when locations: true) + line?: number + column?: number + offset?: number + length?: number +} + export class CSSNode { private arena: CSSDataArena private source: string @@ -572,4 +619,86 @@ export class CSSNode { if (start === -1) return '' return this.source.substring(start, end) } + + // --- Node Cloning --- + + /** + * Clone this node as a mutable plain JavaScript object + * + * Extracts all properties from the arena into a plain object with children as an array. + * The resulting object can be freely modified. + * + * @param options - Cloning configuration + * @param options.deep - Recursively clone children (default: true) + * @param options.locations - Include line/column/offset/length (default: false) + * @returns Plain object with children as array + * + * @example + * const ast = parse('div { color: red; }') + * const decl = ast.first_child.block.first_child + * const plain = decl.clone() + * + * // Access children as array + * plain.children.length + * plain.children[0] + * plain.children.push(newChild) + */ + clone(options: CloneOptions = {}): PlainCSSNode { + const { deep = true, locations = false } = options + + // 1. Create plain object with base properties + let plain: any = { + type: this.type, + type_name: this.type_name, + text: this.text, + children: [], + } + + // 2. Extract type-specific properties (only if meaningful) + if (this.name) plain.name = this.name + if (this.type === NODE_DECLARATION) plain.property = this.name + + // 3. Handle value types + if (this.value !== undefined && this.value !== null) { + plain.value = this.value + if (this.unit) plain.unit = this.unit + } + + // 4. Extract prelude for at-rules + if (this.type === NODE_AT_RULE && this.prelude) { + plain.prelude = this.prelude + } + + // 5. Extract flags + if (this.type === NODE_DECLARATION) plain.is_important = this.is_important + plain.is_vendor_prefixed = this.is_vendor_prefixed + plain.has_error = this.has_error + + // 6. Extract selector-specific properties + if (this.type === NODE_SELECTOR_ATTRIBUTE) { + plain.attr_operator = this.attr_operator + plain.attr_flags = this.attr_flags + } + if (this.type === NODE_SELECTOR_NTH || this.type === NODE_SELECTOR_NTH_OF) { + plain.nth_a = this.nth_a + plain.nth_b = this.nth_b + } + + // 7. Include location if requested + if (locations) { + plain.line = this.line + plain.column = this.column + plain.offset = this.offset + plain.length = this.length + } + + // 8. Deep clone children - just push to array! + if (deep) { + for (let child of this.children) { + plain.children.push(child.clone({ deep: true, locations })) + } + } + + return plain + } } diff --git a/src/index.ts b/src/index.ts index 3d1f530..b71ae1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ export { walk, traverse } from './walk' export { type ParserOptions } from './parse' // Types -export { CSSNode, type CSSNodeType, TYPE_NAMES } from './css-node' +export { CSSNode, type CSSNodeType, TYPE_NAMES, type CloneOptions, type PlainCSSNode } from './css-node' export type { LexerPosition } from './lexer' export {