diff --git a/.gitignore b/.gitignore index 94a3131..1eff9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.md !API.md !README.md +!MIGRATION-TYPED-NODES.md dist coverage .claude/settings.local.json \ No newline at end of file diff --git a/package.json b/package.json index 5e4b87d..323f10d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "./parse-anplusb": { "types": "./dist/parse-anplusb.d.ts", "import": "./dist/parse-anplusb.js" + }, + "./nodes": { + "types": "./dist/nodes/index.d.ts", + "import": "./dist/nodes/index.js" } }, "files": [ diff --git a/src/anplusb-parser.test.ts b/src/anplusb-parser.test.ts index 0150390..f55e04d 100644 --- a/src/anplusb-parser.test.ts +++ b/src/anplusb-parser.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect } from 'vitest' import { ANplusBParser } from './anplusb-parser' import { CSSDataArena, NODE_SELECTOR_NTH } from './arena' -import { CSSNode } from './css-node' +import { CSSNode, SelectorNthNode } from './css-node' // Helper to parse An+B expression -function parse_anplusb(expr: string): CSSNode | null { +function parse_anplusb(expr: string): SelectorNthNode | null { const arena = new CSSDataArena(64) const parser = new ANplusBParser(arena, expr) const nodeIndex = parser.parse_anplusb(0, expr.length) if (nodeIndex === null) return null - return new CSSNode(arena, expr, nodeIndex) + return CSSNode.from(arena, expr, nodeIndex) as SelectorNthNode } describe('ANplusBParser', () => { diff --git a/src/at-rule-prelude-parser.test.ts b/src/at-rule-prelude-parser.test.ts index 4ae9156..4355359 100644 --- a/src/at-rule-prelude-parser.test.ts +++ b/src/at-rule-prelude-parser.test.ts @@ -15,13 +15,19 @@ import { NODE_PRELUDE_IMPORT_LAYER, NODE_PRELUDE_IMPORT_SUPPORTS, } from './arena' +import { + AtRuleNode, + PreludeMediaFeatureNode, + PreludeImportLayerNode, + PreludeSupportsQueryNode, +} from './nodes' describe('At-Rule Prelude Parser', () => { describe('@media', () => { it('should parse media type', () => { const css = '@media screen { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('media') @@ -41,7 +47,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse media feature', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) @@ -51,17 +57,17 @@ describe('At-Rule Prelude Parser', () => { expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) // Feature should have content - const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) + const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) as PreludeMediaFeatureNode expect(feature?.value).toContain('min-width') }) it('should trim whitespace and comments from media features', () => { const css = '@media (/* comment */ min-width: 768px /* test */) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] const queryChildren = children[0].children - const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) + const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) as PreludeMediaFeatureNode expect(feature?.value).toBe('min-width: 768px') }) @@ -69,7 +75,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse complex media query with and operator', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) @@ -84,7 +90,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse multiple media features', () => { const css = '@media (min-width: 768px) and (max-width: 1024px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] const queryChildren = children[0].children @@ -95,7 +101,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse comma-separated media queries', () => { const css = '@media screen, print { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] // Should have 2 media query nodes @@ -108,7 +114,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse unnamed container query', () => { const css = '@container (min-width: 400px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('container') @@ -121,7 +127,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse named container query', () => { const css = '@container sidebar (min-width: 400px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children[0].type).toBe(NODE_PRELUDE_CONTAINER_QUERY) @@ -137,7 +143,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse single feature query', () => { const css = '@supports (display: flex) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('supports') @@ -145,7 +151,7 @@ describe('At-Rule Prelude Parser', () => { const children = atRule?.children || [] expect(children.some((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY)).toBe(true) - const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) + const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) as PreludeSupportsQueryNode expect(query?.value).toContain('display') expect(query?.value).toContain('flex') }) @@ -153,9 +159,9 @@ describe('At-Rule Prelude Parser', () => { it('should trim whitespace and comments from supports queries', () => { const css = '@supports (/* comment */ display: flex /* test */) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] - const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) + const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) as PreludeSupportsQueryNode expect(query?.value).toBe('display: flex') }) @@ -163,7 +169,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse complex supports query with operators', () => { const css = '@supports (display: flex) and (gap: 1rem) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] // Should have 2 queries and 1 operator @@ -179,7 +185,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse single layer name', () => { const css = '@layer base { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('layer') @@ -194,7 +200,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse comma-separated layer names', () => { const css = '@layer base, components, utilities;' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(3) @@ -214,7 +220,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse keyframe name', () => { const css = '@keyframes slidein { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('keyframes') @@ -231,7 +237,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse custom property name', () => { const css = '@property --my-color { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('property') @@ -248,7 +254,7 @@ describe('At-Rule Prelude Parser', () => { it('should have no prelude children', () => { const css = '@font-face { font-family: "MyFont"; }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('font-face') @@ -266,7 +272,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse preludes when enabled (default)', () => { const css = '@media screen { }' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.some((c) => c.type === NODE_PRELUDE_MEDIA_QUERY)).toBe(true) @@ -275,7 +281,7 @@ describe('At-Rule Prelude Parser', () => { it('should not parse preludes when disabled', () => { const css = '@media screen { }' const ast = parse(css, { parse_atrule_preludes: false }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.some((c) => c.type === NODE_PRELUDE_MEDIA_QUERY)).toBe(false) @@ -286,7 +292,7 @@ describe('At-Rule Prelude Parser', () => { it('should preserve prelude text in at-rule node', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode // The prelude text should still be accessible expect(atRule?.prelude).toBe('screen and (min-width: 768px)') @@ -297,7 +303,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse URL with url() function', () => { const css = '@import url("styles.css");' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBeGreaterThan(0) @@ -308,7 +314,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse URL with string', () => { const css = '@import "styles.css";' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBeGreaterThan(0) @@ -319,76 +325,76 @@ describe('At-Rule Prelude Parser', () => { it('should parse with anonymous layer', () => { const css = '@import url("styles.css") layer;' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) expect(children[1].text).toBe('layer') - expect(children[1].name).toBe('') + expect((children[1] as PreludeImportLayerNode).name).toBe('') }) it('should parse with anonymous LAYER', () => { const css = '@import url("styles.css") LAYER;' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) expect(children[1].text).toBe('LAYER') - expect(children[1].name).toBe('') + expect((children[1] as PreludeImportLayerNode).name).toBe('') }) it('should parse with named layer', () => { const css = '@import url("styles.css") layer(utilities);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) expect(children[1].text).toBe('layer(utilities)') - expect(children[1].name).toBe('utilities') + expect((children[1] as PreludeImportLayerNode).name).toBe('utilities') }) it('should trim whitespace from layer names', () => { const css = '@import url("styles.css") layer( utilities );' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].name).toBe('utilities') + expect((children[1] as PreludeImportLayerNode).name).toBe('utilities') }) it('should trim comments from layer names', () => { const css = '@import url("styles.css") layer(/* comment */utilities/* test */);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].name).toBe('utilities') + expect((children[1] as PreludeImportLayerNode).name).toBe('utilities') }) it('should trim whitespace and comments from dotted layer names', () => { const css = '@import url("foo.css") layer(/* test */named.nested );' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].name).toBe('named.nested') + expect((children[1] as PreludeImportLayerNode).name).toBe('named.nested') }) it('should parse with supports query', () => { const css = '@import url("styles.css") supports(display: grid);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) @@ -400,7 +406,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with media query', () => { const css = '@import url("styles.css") screen;' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) @@ -411,7 +417,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with media feature', () => { const css = '@import url("styles.css") (min-width: 768px);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) @@ -422,7 +428,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with combined media query', () => { const css = '@import url("styles.css") screen and (min-width: 768px);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) @@ -433,7 +439,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with layer and media query', () => { const css = '@import url("styles.css") layer(base) screen;' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(3) @@ -445,7 +451,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with layer and supports', () => { const css = '@import url("styles.css") layer(base) supports(display: grid);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(3) @@ -457,7 +463,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with supports and media query', () => { const css = '@import url("styles.css") supports(display: grid) screen;' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(3) @@ -469,7 +475,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with all features combined', () => { const css = '@import url("styles.css") layer(base) supports(display: grid) screen and (min-width: 768px);' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(4) @@ -482,7 +488,7 @@ describe('At-Rule Prelude Parser', () => { it('should parse with complex supports condition', () => { const css = '@import url("styles.css") supports((display: grid) and (gap: 1rem));' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode const children = atRule?.children || [] expect(children.length).toBe(2) @@ -494,7 +500,7 @@ describe('At-Rule Prelude Parser', () => { it('should preserve prelude text', () => { const css = '@import url("styles.css") layer(base) screen;' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child as AtRuleNode expect(atRule?.prelude).toBe('url("styles.css") layer(base) screen') }) diff --git a/src/column-tracking.test.ts b/src/column-tracking.test.ts index 4b3f5f9..c4e638d 100644 --- a/src/column-tracking.test.ts +++ b/src/column-tracking.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest' import { parse } from './parse' import { NODE_STYLE_RULE, NODE_DECLARATION, NODE_AT_RULE, NODE_SELECTOR_LIST } from './parser' +import { AtRuleNode } from './css-node' describe('Column Tracking', () => { test('should track column for single-line CSS', () => { @@ -69,7 +70,7 @@ describe('Column Tracking', () => { expect(atRule.column).toBe(1) // Get the block, then find the nested style rule - const block = atRule.block! + const block = (atRule as AtRuleNode).block! let nestedRule = block.first_child while (nestedRule && nestedRule.type !== NODE_STYLE_RULE) { nestedRule = nestedRule.next_sibling diff --git a/src/css-node-base.ts b/src/css-node-base.ts new file mode 100644 index 0000000..5097af7 --- /dev/null +++ b/src/css-node-base.ts @@ -0,0 +1,236 @@ +// CSSNode Base - Abstract base class for all type-specific node classes +import type { CSSDataArena } from './arena' +import type { AnyNode } from './types' +import { + NODE_STYLESHEET, + NODE_STYLE_RULE, + NODE_AT_RULE, + NODE_DECLARATION, + NODE_SELECTOR, + NODE_COMMENT, + NODE_BLOCK, + NODE_VALUE_KEYWORD, + NODE_VALUE_NUMBER, + NODE_VALUE_DIMENSION, + NODE_VALUE_STRING, + NODE_VALUE_COLOR, + NODE_VALUE_FUNCTION, + NODE_VALUE_OPERATOR, + NODE_SELECTOR_LIST, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_CLASS, + NODE_SELECTOR_ID, + NODE_SELECTOR_ATTRIBUTE, + NODE_SELECTOR_PSEUDO_CLASS, + NODE_SELECTOR_PSEUDO_ELEMENT, + NODE_SELECTOR_COMBINATOR, + NODE_SELECTOR_UNIVERSAL, + NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, + NODE_PRELUDE_MEDIA_QUERY, + NODE_PRELUDE_MEDIA_FEATURE, + NODE_PRELUDE_MEDIA_TYPE, + NODE_PRELUDE_CONTAINER_QUERY, + NODE_PRELUDE_SUPPORTS_QUERY, + NODE_PRELUDE_LAYER_NAME, + NODE_PRELUDE_IDENTIFIER, + NODE_PRELUDE_OPERATOR, + NODE_PRELUDE_IMPORT_URL, + NODE_PRELUDE_IMPORT_LAYER, + NODE_PRELUDE_IMPORT_SUPPORTS, + FLAG_HAS_ERROR, +} from './arena' + +// Node type constants (numeric for performance) +export type CSSNodeType = + | typeof NODE_STYLESHEET + | typeof NODE_STYLE_RULE + | typeof NODE_AT_RULE + | typeof NODE_DECLARATION + | typeof NODE_SELECTOR + | typeof NODE_COMMENT + | typeof NODE_BLOCK + | typeof NODE_VALUE_KEYWORD + | typeof NODE_VALUE_NUMBER + | typeof NODE_VALUE_DIMENSION + | typeof NODE_VALUE_STRING + | typeof NODE_VALUE_COLOR + | typeof NODE_VALUE_FUNCTION + | typeof NODE_VALUE_OPERATOR + | typeof NODE_SELECTOR_LIST + | typeof NODE_SELECTOR_TYPE + | typeof NODE_SELECTOR_CLASS + | typeof NODE_SELECTOR_ID + | typeof NODE_SELECTOR_ATTRIBUTE + | typeof NODE_SELECTOR_PSEUDO_CLASS + | typeof NODE_SELECTOR_PSEUDO_ELEMENT + | typeof NODE_SELECTOR_COMBINATOR + | typeof NODE_SELECTOR_UNIVERSAL + | typeof NODE_SELECTOR_NESTING + | typeof NODE_SELECTOR_NTH + | typeof NODE_SELECTOR_NTH_OF + | typeof NODE_SELECTOR_LANG + | typeof NODE_PRELUDE_MEDIA_QUERY + | typeof NODE_PRELUDE_MEDIA_FEATURE + | typeof NODE_PRELUDE_MEDIA_TYPE + | typeof NODE_PRELUDE_CONTAINER_QUERY + | typeof NODE_PRELUDE_SUPPORTS_QUERY + | typeof NODE_PRELUDE_LAYER_NAME + | typeof NODE_PRELUDE_IDENTIFIER + | typeof NODE_PRELUDE_OPERATOR + | typeof NODE_PRELUDE_IMPORT_URL + | typeof NODE_PRELUDE_IMPORT_LAYER + | typeof NODE_PRELUDE_IMPORT_SUPPORTS + +export abstract class CSSNode { + protected arena: CSSDataArena + protected source: string + protected index: number + + constructor(arena: CSSDataArena, source: string, index: number) { + this.arena = arena + this.source = source + this.index = index + } + + // Factory method to create type-specific node instances + // Subclasses will implement this to return the correct type + static from(arena: CSSDataArena, source: string, index: number): CSSNode { + throw new Error('from() must be implemented by concrete CSSNode class') + } + + // Get the node index (for internal use) + get_index(): number { + return this.index + } + + // Get node type as number (for performance) + get type(): CSSNodeType { + return this.arena.get_type(this.index) as CSSNodeType + } + + // Get the full text of this node from source + get text(): string { + let start = this.arena.get_start_offset(this.index) + let length = this.arena.get_length(this.index) + return this.source.substring(start, start + length) + } + + // Check if this node has an error + get has_error(): boolean { + return this.arena.has_flag(this.index, FLAG_HAS_ERROR) + } + + // Get start line number + get line(): number { + return this.arena.get_start_line(this.index) + } + + // Get start column number + get column(): number { + return this.arena.get_start_column(this.index) + } + + // Get start offset in source + get offset(): number { + return this.arena.get_start_offset(this.index) + } + + // Get length in source + get length(): number { + return this.arena.get_length(this.index) + } + + // --- Tree Traversal --- + + // Get first child node + // Returns type-specific node (StylesheetNode, DeclarationNode, etc.) + get first_child(): AnyNode | null { + let child_index = this.arena.get_first_child(this.index) + if (child_index === 0) return null + // Factory returns the correct type-specific node + return this.create_node_wrapper(child_index) + } + + // Get next sibling node + // Returns type-specific node (StylesheetNode, DeclarationNode, etc.) + get next_sibling(): AnyNode | null { + let sibling_index = this.arena.get_next_sibling(this.index) + if (sibling_index === 0) return null + // Factory returns the correct type-specific node + return this.create_node_wrapper(sibling_index) + } + + // Helper to create node wrappers - can be overridden by subclasses + protected create_node_wrapper(index: number): AnyNode { + // Create instance of the same class type + return new (this.constructor as any)(this.arena, this.source, index) + } + + get has_next(): boolean { + let sibling_index = this.arena.get_next_sibling(this.index) + return sibling_index !== 0 + } + + // Check if this node has children + get has_children(): boolean { + return this.arena.has_children(this.index) + } + + // Get all children as an array + // Returns array of type-specific nodes + get children(): AnyNode[] { + let result: AnyNode[] = [] + let child = this.first_child + while (child) { + result.push(child) + child = child.next_sibling + } + return result + } + + // Make CSSNode iterable over its children + // Yields type-specific nodes + *[Symbol.iterator](): Iterator { + let child = this.first_child + while (child) { + yield child + child = child.next_sibling + } + } + + // Default implementations for properties that only some node types have + // Subclasses can override these to provide specific behavior + + // Check if this node has a prelude (for at-rules) + // Default: false. AtRuleNode overrides this. + get has_prelude(): boolean { + return this.arena.get_value_length(this.index) > 0 + } + + // Check if this node has a block (for at-rules and style rules) + // Default: false. AtRuleNode and StyleRuleNode override this. + get has_block(): boolean { + return false + } + + // Check if this style rule has declarations (for style rules) + // Default: false. Only StyleRuleNode overrides this. + get has_declarations(): boolean { + return false + } + + // Check if this node has a vendor prefix + // Default: false. DeclarationNode and selector pseudo nodes override this. + get is_vendor_prefixed(): boolean { + return false + } + + // CamelCase alias for is_vendor_prefixed + get isVendorPrefixed(): boolean { + return this.is_vendor_prefixed + } + +} diff --git a/src/css-node.test.ts b/src/css-node.test.ts index b507dc7..aac459f 100644 --- a/src/css-node.test.ts +++ b/src/css-node.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest' import { Parser } from './parser' import { NODE_DECLARATION, NODE_SELECTOR_LIST, NODE_STYLE_RULE, NODE_AT_RULE } from './arena' +import { StyleRuleNode, AtRuleNode, PreludeMediaFeatureNode, DeclarationNode } from './nodes' describe('CSSNode', () => { describe('iteration', () => { @@ -9,7 +10,7 @@ describe('CSSNode', () => { const parser = new Parser(source, { parse_selectors: false, parse_values: false }) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode const block = rule.block! const types: number[] = [] @@ -36,7 +37,7 @@ describe('CSSNode', () => { const parser = new Parser(source, { parse_selectors: false, parse_values: false, parse_atrule_preludes: false }) const root = parser.parse() - const media = root.first_child! + const media = root.first_child as AtRuleNode const block = media.block! const children = Array.from(block) @@ -53,7 +54,7 @@ describe('CSSNode', () => { }) const root = parser.parse() - const importRule = root.first_child! + const importRule = root.first_child as AtRuleNode const children = [...importRule] expect(children).toHaveLength(0) @@ -65,7 +66,7 @@ describe('CSSNode', () => { const source = '@media (min-width: 768px) { body { color: red; } }' const parser = new Parser(source) const root = parser.parse() - const media = root.first_child! + const media = root.first_child as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) expect(media.has_prelude).toBe(true) @@ -76,7 +77,7 @@ describe('CSSNode', () => { const source = '@supports (display: grid) { .grid { display: grid; } }' const parser = new Parser(source) const root = parser.parse() - const supports = root.first_child! + const supports = root.first_child as AtRuleNode expect(supports.type).toBe(NODE_AT_RULE) expect(supports.has_prelude).toBe(true) @@ -87,7 +88,7 @@ describe('CSSNode', () => { const source = '@layer utilities { .btn { padding: 1rem; } }' const parser = new Parser(source) const root = parser.parse() - const layer = root.first_child! + const layer = root.first_child as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) expect(layer.has_prelude).toBe(true) @@ -98,7 +99,7 @@ describe('CSSNode', () => { const source = '@layer { .btn { padding: 1rem; } }' const parser = new Parser(source) const root = parser.parse() - const layer = root.first_child! + const layer = root.first_child as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) expect(layer.has_prelude).toBe(false) @@ -109,7 +110,7 @@ describe('CSSNode', () => { const source = '@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }' const parser = new Parser(source) const root = parser.parse() - const keyframes = root.first_child! + const keyframes = root.first_child as AtRuleNode expect(keyframes.type).toBe(NODE_AT_RULE) expect(keyframes.has_prelude).toBe(true) @@ -120,7 +121,7 @@ describe('CSSNode', () => { const source = '@font-face { font-family: "Custom"; src: url("font.woff2"); }' const parser = new Parser(source) const root = parser.parse() - const fontFace = root.first_child! + const fontFace = root.first_child as AtRuleNode expect(fontFace.type).toBe(NODE_AT_RULE) expect(fontFace.has_prelude).toBe(false) @@ -131,7 +132,7 @@ describe('CSSNode', () => { const source = '@page { margin: 1in; }' const parser = new Parser(source) const root = parser.parse() - const page = root.first_child! + const page = root.first_child as AtRuleNode expect(page.type).toBe(NODE_AT_RULE) expect(page.has_prelude).toBe(false) @@ -142,7 +143,7 @@ describe('CSSNode', () => { const source = '@import url("styles.css") layer(base) supports(display: flex);' const parser = new Parser(source) const root = parser.parse() - const importRule = root.first_child! + const importRule = root.first_child as AtRuleNode expect(importRule.type).toBe(NODE_AT_RULE) expect(importRule.has_prelude).toBe(true) @@ -153,7 +154,7 @@ describe('CSSNode', () => { const source = '@media (min-width: 768px) { body { color: red; } }' const parser = new Parser(source) const root = parser.parse() - const media = root.first_child! + const media = root.first_child as AtRuleNode // has_prelude should be faster than prelude !== null // because it doesn't allocate a string @@ -165,10 +166,10 @@ describe('CSSNode', () => { const source = 'body { color: red; }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode const selector = rule.first_child! const block = selector.next_sibling! - const declaration = block.first_child! + const declaration = block.first_child as DeclarationNode // Rules and selectors don't use value field expect(rule.has_prelude).toBe(false) @@ -186,7 +187,7 @@ describe('CSSNode', () => { const source = 'body { color: red; }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.has_block).toBe(true) @@ -196,7 +197,7 @@ describe('CSSNode', () => { const source = 'body { }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.has_block).toBe(true) @@ -206,7 +207,7 @@ describe('CSSNode', () => { const source = '@media (min-width: 768px) { body { color: red; } }' const parser = new Parser(source) const root = parser.parse() - const media = root.first_child! + const media = root.first_child as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) expect(media.has_block).toBe(true) @@ -216,7 +217,7 @@ describe('CSSNode', () => { const source = '@supports (display: grid) { .grid { display: grid; } }' const parser = new Parser(source) const root = parser.parse() - const supports = root.first_child! + const supports = root.first_child as AtRuleNode expect(supports.type).toBe(NODE_AT_RULE) expect(supports.has_block).toBe(true) @@ -226,7 +227,7 @@ describe('CSSNode', () => { const source = '@layer utilities { .btn { padding: 1rem; } }' const parser = new Parser(source) const root = parser.parse() - const layer = root.first_child! + const layer = root.first_child as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) expect(layer.has_block).toBe(true) @@ -236,7 +237,7 @@ describe('CSSNode', () => { const source = '@layer { .btn { padding: 1rem; } }' const parser = new Parser(source) const root = parser.parse() - const layer = root.first_child! + const layer = root.first_child as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) expect(layer.has_block).toBe(true) @@ -246,7 +247,7 @@ describe('CSSNode', () => { const source = '@font-face { font-family: "Custom"; src: url("font.woff2"); }' const parser = new Parser(source) const root = parser.parse() - const fontFace = root.first_child! + const fontFace = root.first_child as AtRuleNode expect(fontFace.type).toBe(NODE_AT_RULE) expect(fontFace.has_block).toBe(true) @@ -256,7 +257,7 @@ describe('CSSNode', () => { const source = '@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }' const parser = new Parser(source) const root = parser.parse() - const keyframes = root.first_child! + const keyframes = root.first_child as AtRuleNode expect(keyframes.type).toBe(NODE_AT_RULE) expect(keyframes.has_block).toBe(true) @@ -266,7 +267,7 @@ describe('CSSNode', () => { const source = '@import url("styles.css");' const parser = new Parser(source) const root = parser.parse() - const importRule = root.first_child! + const importRule = root.first_child as AtRuleNode expect(importRule.type).toBe(NODE_AT_RULE) expect(importRule.has_block).toBe(false) @@ -276,7 +277,7 @@ describe('CSSNode', () => { const source = '@import url("styles.css") layer(base) supports(display: flex);' const parser = new Parser(source) const root = parser.parse() - const importRule = root.first_child! + const importRule = root.first_child as AtRuleNode expect(importRule.type).toBe(NODE_AT_RULE) expect(importRule.has_block).toBe(false) @@ -291,7 +292,7 @@ describe('CSSNode', () => { ` const parser = new Parser(source) const root = parser.parse() - const importRule = root.first_child! + const importRule = root.first_child as AtRuleNode const layerRule = importRule.next_sibling! // @import has children (preludes) but no block @@ -307,7 +308,7 @@ describe('CSSNode', () => { const source = 'body { color: red; }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode const selector = rule.first_child! const declaration = selector.next_sibling! @@ -345,7 +346,7 @@ describe('CSSNode', () => { const source = 'body { color: red; margin: 0; }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.has_declarations).toBe(true) @@ -355,7 +356,7 @@ describe('CSSNode', () => { const source = 'body { }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.has_declarations).toBe(false) @@ -365,7 +366,7 @@ describe('CSSNode', () => { const source = 'body { .nested { color: red; } }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.has_declarations).toBe(false) @@ -375,7 +376,7 @@ describe('CSSNode', () => { const source = 'body { color: blue; .nested { margin: 0; } }' const parser = new Parser(source) const root = parser.parse() - const rule = root.first_child! + const rule = root.first_child as StyleRuleNode expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.has_declarations).toBe(true) @@ -385,7 +386,7 @@ describe('CSSNode', () => { const source = '@media screen { body { color: red; } }' const parser = new Parser(source) const root = parser.parse() - const media = root.first_child! + const media = root.first_child as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) expect(media.has_declarations).toBe(false) diff --git a/src/css-node.ts b/src/css-node.ts index 0cb04fa..938b2d5 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -1,382 +1,143 @@ // CSSNode - Ergonomic wrapper over arena node indices +// This is the concrete implementation that handles all node types +// Will be replaced by type-specific classes in future batches +import { CSSNode as CSSNodeBase } from './css-node-base' import type { CSSDataArena } from './arena' -import { - NODE_STYLESHEET, - NODE_STYLE_RULE, - NODE_AT_RULE, - NODE_DECLARATION, - NODE_SELECTOR, - NODE_COMMENT, - NODE_BLOCK, - NODE_VALUE_KEYWORD, - NODE_VALUE_NUMBER, - NODE_VALUE_DIMENSION, - NODE_VALUE_STRING, - NODE_VALUE_COLOR, - NODE_VALUE_FUNCTION, - NODE_VALUE_OPERATOR, - NODE_SELECTOR_LIST, - NODE_SELECTOR_TYPE, - NODE_SELECTOR_CLASS, - NODE_SELECTOR_ID, - NODE_SELECTOR_ATTRIBUTE, - NODE_SELECTOR_PSEUDO_CLASS, - NODE_SELECTOR_PSEUDO_ELEMENT, - NODE_SELECTOR_COMBINATOR, - NODE_SELECTOR_UNIVERSAL, - NODE_SELECTOR_NESTING, - NODE_SELECTOR_NTH, - NODE_SELECTOR_NTH_OF, - NODE_SELECTOR_LANG, - NODE_PRELUDE_MEDIA_QUERY, - NODE_PRELUDE_MEDIA_FEATURE, - NODE_PRELUDE_MEDIA_TYPE, - NODE_PRELUDE_CONTAINER_QUERY, - NODE_PRELUDE_SUPPORTS_QUERY, - NODE_PRELUDE_LAYER_NAME, - NODE_PRELUDE_IDENTIFIER, - NODE_PRELUDE_OPERATOR, - NODE_PRELUDE_IMPORT_URL, - NODE_PRELUDE_IMPORT_LAYER, - NODE_PRELUDE_IMPORT_SUPPORTS, - FLAG_IMPORTANT, - FLAG_HAS_ERROR, - FLAG_HAS_BLOCK, - FLAG_VENDOR_PREFIXED, - FLAG_HAS_DECLARATIONS, -} from './arena' - -import { parse_dimension } from './string-utils' - -// Node type constants (numeric for performance) -export type CSSNodeType = - | typeof NODE_STYLESHEET - | typeof NODE_STYLE_RULE - | typeof NODE_AT_RULE - | typeof NODE_DECLARATION - | typeof NODE_SELECTOR - | typeof NODE_COMMENT - | typeof NODE_BLOCK - | typeof NODE_VALUE_KEYWORD - | typeof NODE_VALUE_NUMBER - | typeof NODE_VALUE_DIMENSION - | typeof NODE_VALUE_STRING - | typeof NODE_VALUE_COLOR - | typeof NODE_VALUE_FUNCTION - | typeof NODE_VALUE_OPERATOR - | typeof NODE_SELECTOR_LIST - | typeof NODE_SELECTOR_TYPE - | typeof NODE_SELECTOR_CLASS - | typeof NODE_SELECTOR_ID - | typeof NODE_SELECTOR_ATTRIBUTE - | typeof NODE_SELECTOR_PSEUDO_CLASS - | typeof NODE_SELECTOR_PSEUDO_ELEMENT - | typeof NODE_SELECTOR_COMBINATOR - | typeof NODE_SELECTOR_UNIVERSAL - | typeof NODE_SELECTOR_NESTING - | typeof NODE_SELECTOR_NTH - | typeof NODE_SELECTOR_NTH_OF - | typeof NODE_SELECTOR_LANG - | typeof NODE_PRELUDE_MEDIA_QUERY - | typeof NODE_PRELUDE_MEDIA_FEATURE - | typeof NODE_PRELUDE_MEDIA_TYPE - | typeof NODE_PRELUDE_CONTAINER_QUERY - | typeof NODE_PRELUDE_SUPPORTS_QUERY - | typeof NODE_PRELUDE_LAYER_NAME - | typeof NODE_PRELUDE_IDENTIFIER - | typeof NODE_PRELUDE_OPERATOR - | typeof NODE_PRELUDE_IMPORT_URL - | typeof NODE_PRELUDE_IMPORT_LAYER - | typeof NODE_PRELUDE_IMPORT_SUPPORTS - -export class CSSNode { - private arena: CSSDataArena - private source: string - private index: number - - constructor(arena: CSSDataArena, source: string, index: number) { - this.arena = arena - this.source = source - this.index = index - } - - // Get the node index (for internal use) - get_index(): number { - return this.index - } - - // Get node type as number (for performance) - get type(): CSSNodeType { - return this.arena.get_type(this.index) as CSSNodeType - } - - // Get the full text of this node from source - get text(): string { - let start = this.arena.get_start_offset(this.index) - let length = this.arena.get_length(this.index) - return this.source.substring(start, start + length) - } - - // Get the "content" text (property name for declarations, at-rule name for at-rules, layer name for import layers) - get name(): string { - let start = this.arena.get_content_start(this.index) - let length = this.arena.get_content_length(this.index) - if (length === 0) return '' - return this.source.substring(start, start + length) - } - - // Alias for name (for declarations: "color" in "color: blue") - // More semantic than `name` for declaration nodes - get property(): string { - return this.name - } - - // Get the value text (for declarations: "blue" in "color: blue") - // For dimension/number nodes: returns the numeric value as a number - // For string nodes: returns the string content without quotes - get value(): string | number | null { - // For dimension and number nodes, parse and return as number - if (this.type === NODE_VALUE_DIMENSION || this.type === NODE_VALUE_NUMBER) { - return parse_dimension(this.text).value +import type { AnyNode } from './types' +import { NODE_STYLESHEET, NODE_COMMENT, NODE_BLOCK, NODE_DECLARATION, NODE_AT_RULE, NODE_STYLE_RULE, NODE_SELECTOR, NODE_VALUE_KEYWORD, NODE_VALUE_STRING, NODE_VALUE_COLOR, NODE_VALUE_OPERATOR, NODE_VALUE_NUMBER, NODE_VALUE_DIMENSION, NODE_VALUE_FUNCTION, NODE_SELECTOR_LIST, NODE_SELECTOR_TYPE, NODE_SELECTOR_UNIVERSAL, NODE_SELECTOR_NESTING, NODE_SELECTOR_COMBINATOR, NODE_SELECTOR_CLASS, NODE_SELECTOR_ID, NODE_SELECTOR_LANG, NODE_SELECTOR_ATTRIBUTE, NODE_SELECTOR_PSEUDO_CLASS, NODE_SELECTOR_PSEUDO_ELEMENT, NODE_SELECTOR_NTH, NODE_SELECTOR_NTH_OF, NODE_PRELUDE_MEDIA_QUERY, NODE_PRELUDE_MEDIA_FEATURE, NODE_PRELUDE_MEDIA_TYPE, NODE_PRELUDE_CONTAINER_QUERY, NODE_PRELUDE_SUPPORTS_QUERY, NODE_PRELUDE_LAYER_NAME, NODE_PRELUDE_IDENTIFIER, NODE_PRELUDE_OPERATOR, NODE_PRELUDE_IMPORT_URL, NODE_PRELUDE_IMPORT_LAYER, NODE_PRELUDE_IMPORT_SUPPORTS } from './arena' +import { StylesheetNode } from './nodes/stylesheet-node' +import { CommentNode } from './nodes/comment-node' +import { BlockNode } from './nodes/block-node' +import { DeclarationNode } from './nodes/declaration-node' +import { AtRuleNode } from './nodes/at-rule-node' +import { StyleRuleNode } from './nodes/style-rule-node' +import { SelectorNode } from './nodes/selector-node' +import { ValueKeywordNode, ValueStringNode, ValueColorNode, ValueOperatorNode, ValueNumberNode, ValueDimensionNode, ValueFunctionNode } from './nodes/value-nodes' +import { SelectorListNode, SelectorTypeNode, SelectorUniversalNode, SelectorNestingNode, SelectorCombinatorNode } from './nodes/selector-nodes-simple' +import { SelectorClassNode, SelectorIdNode, SelectorLangNode } from './nodes/selector-nodes-named' +import { SelectorAttributeNode } from './nodes/selector-attribute-node' +import { SelectorPseudoClassNode, SelectorPseudoElementNode } from './nodes/selector-pseudo-nodes' +import { SelectorNthNode, SelectorNthOfNode } from './nodes/selector-nth-nodes' +import { PreludeMediaQueryNode, PreludeMediaFeatureNode, PreludeMediaTypeNode } from './nodes/prelude-media-nodes' +import { PreludeContainerQueryNode, PreludeSupportsQueryNode, PreludeLayerNameNode, PreludeIdentifierNode, PreludeOperatorNode } from './nodes/prelude-container-supports-nodes' +import { PreludeImportUrlNode, PreludeImportLayerNode, PreludeImportSupportsNode } from './nodes/prelude-import-nodes' + +// Re-export CSSNodeType from base +export type { CSSNodeType } from './css-node-base' + +// Re-export type-specific node classes +export { StylesheetNode } from './nodes/stylesheet-node' +export { CommentNode } from './nodes/comment-node' +export { BlockNode } from './nodes/block-node' +export { DeclarationNode } from './nodes/declaration-node' +export { AtRuleNode } from './nodes/at-rule-node' +export { StyleRuleNode } from './nodes/style-rule-node' +export { SelectorNode } from './nodes/selector-node' +export { ValueKeywordNode, ValueStringNode, ValueColorNode, ValueOperatorNode, ValueNumberNode, ValueDimensionNode, ValueFunctionNode } from './nodes/value-nodes' +export { SelectorListNode, SelectorTypeNode, SelectorUniversalNode, SelectorNestingNode, SelectorCombinatorNode } from './nodes/selector-nodes-simple' +export { SelectorClassNode, SelectorIdNode, SelectorLangNode } from './nodes/selector-nodes-named' +export { SelectorAttributeNode } from './nodes/selector-attribute-node' +export { SelectorPseudoClassNode, SelectorPseudoElementNode } from './nodes/selector-pseudo-nodes' +export { SelectorNthNode, SelectorNthOfNode } from './nodes/selector-nth-nodes' +export { PreludeMediaQueryNode, PreludeMediaFeatureNode, PreludeMediaTypeNode } from './nodes/prelude-media-nodes' +export { PreludeContainerQueryNode, PreludeSupportsQueryNode, PreludeLayerNameNode, PreludeIdentifierNode, PreludeOperatorNode } from './nodes/prelude-container-supports-nodes' +export { PreludeImportUrlNode, PreludeImportLayerNode, PreludeImportSupportsNode } from './nodes/prelude-import-nodes' + +export class CSSNode extends CSSNodeBase { + // Implement factory method that returns type-specific node classes + // Gradually expanding to cover all node types + static override from(arena: CSSDataArena, source: string, index: number): AnyNode { + const type = arena.get_type(index) + + // Return type-specific nodes + switch (type) { + case NODE_STYLESHEET: + return new StylesheetNode(arena, source, index) + case NODE_COMMENT: + return new CommentNode(arena, source, index) + case NODE_BLOCK: + return new BlockNode(arena, source, index) + case NODE_DECLARATION: + return new DeclarationNode(arena, source, index) + case NODE_AT_RULE: + return new AtRuleNode(arena, source, index) + case NODE_STYLE_RULE: + return new StyleRuleNode(arena, source, index) + case NODE_SELECTOR: + return new SelectorNode(arena, source, index) + // Value nodes + case NODE_VALUE_KEYWORD: + return new ValueKeywordNode(arena, source, index) + case NODE_VALUE_STRING: + return new ValueStringNode(arena, source, index) + case NODE_VALUE_COLOR: + return new ValueColorNode(arena, source, index) + case NODE_VALUE_OPERATOR: + return new ValueOperatorNode(arena, source, index) + case NODE_VALUE_NUMBER: + return new ValueNumberNode(arena, source, index) + case NODE_VALUE_DIMENSION: + return new ValueDimensionNode(arena, source, index) + case NODE_VALUE_FUNCTION: + return new ValueFunctionNode(arena, source, index) + // Selector nodes + case NODE_SELECTOR_LIST: + return new SelectorListNode(arena, source, index) + case NODE_SELECTOR_TYPE: + return new SelectorTypeNode(arena, source, index) + case NODE_SELECTOR_UNIVERSAL: + return new SelectorUniversalNode(arena, source, index) + case NODE_SELECTOR_NESTING: + return new SelectorNestingNode(arena, source, index) + case NODE_SELECTOR_COMBINATOR: + return new SelectorCombinatorNode(arena, source, index) + case NODE_SELECTOR_CLASS: + return new SelectorClassNode(arena, source, index) + case NODE_SELECTOR_ID: + return new SelectorIdNode(arena, source, index) + case NODE_SELECTOR_LANG: + return new SelectorLangNode(arena, source, index) + case NODE_SELECTOR_ATTRIBUTE: + return new SelectorAttributeNode(arena, source, index) + case NODE_SELECTOR_PSEUDO_CLASS: + return new SelectorPseudoClassNode(arena, source, index) + case NODE_SELECTOR_PSEUDO_ELEMENT: + return new SelectorPseudoElementNode(arena, source, index) + case NODE_SELECTOR_NTH: + return new SelectorNthNode(arena, source, index) + case NODE_SELECTOR_NTH_OF: + return new SelectorNthOfNode(arena, source, index) + // Media prelude nodes + case NODE_PRELUDE_MEDIA_QUERY: + return new PreludeMediaQueryNode(arena, source, index) + case NODE_PRELUDE_MEDIA_FEATURE: + return new PreludeMediaFeatureNode(arena, source, index) + case NODE_PRELUDE_MEDIA_TYPE: + return new PreludeMediaTypeNode(arena, source, index) + case NODE_PRELUDE_CONTAINER_QUERY: + return new PreludeContainerQueryNode(arena, source, index) + case NODE_PRELUDE_SUPPORTS_QUERY: + return new PreludeSupportsQueryNode(arena, source, index) + case NODE_PRELUDE_LAYER_NAME: + return new PreludeLayerNameNode(arena, source, index) + case NODE_PRELUDE_IDENTIFIER: + return new PreludeIdentifierNode(arena, source, index) + case NODE_PRELUDE_OPERATOR: + return new PreludeOperatorNode(arena, source, index) + case NODE_PRELUDE_IMPORT_URL: + return new PreludeImportUrlNode(arena, source, index) + case NODE_PRELUDE_IMPORT_LAYER: + return new PreludeImportLayerNode(arena, source, index) + case NODE_PRELUDE_IMPORT_SUPPORTS: + return new PreludeImportSupportsNode(arena, source, index) + default: + // For all other types, return generic CSSNode + return new CSSNode(arena, source, index) as any } - - // For other nodes, return as string - let start = this.arena.get_value_start(this.index) - let length = this.arena.get_value_length(this.index) - if (length === 0) return null - return this.source.substring(start, start + length) } - // Get the prelude text (for at-rules: "(min-width: 768px)" in "@media (min-width: 768px)") - // This is an alias for `value` to make at-rule usage more semantic - get prelude(): string | null { - let val = this.value - return typeof val === 'string' ? val : null - } - - // Get the attribute operator (for attribute selectors: =, ~=, |=, ^=, $=, *=) - // Returns one of the ATTR_OPERATOR_* constants - get attr_operator(): number { - return this.arena.get_attr_operator(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 - return parse_dimension(this.text).unit - } - - // Check if this declaration has !important - get is_important(): boolean { - return this.arena.has_flag(this.index, FLAG_IMPORTANT) - } - - // Check if this has a vendor prefix (flag-based for performance) - get is_vendor_prefixed(): boolean { - return this.arena.has_flag(this.index, FLAG_VENDOR_PREFIXED) - } - - // Check if this node has an error - get has_error(): boolean { - return this.arena.has_flag(this.index, FLAG_HAS_ERROR) - } - - // Check if this at-rule has a prelude - get has_prelude(): boolean { - return this.arena.get_value_length(this.index) > 0 - } - - // Check if this rule has a block { } - get has_block(): boolean { - return this.arena.has_flag(this.index, FLAG_HAS_BLOCK) - } - - // Check if this style rule has declarations - get has_declarations(): boolean { - return this.arena.has_flag(this.index, FLAG_HAS_DECLARATIONS) - } - - // Get the block node (for style rules and at-rules with blocks) - get block(): CSSNode | null { - // For StyleRule: block is sibling after selector list - if (this.type === NODE_STYLE_RULE) { - let first = this.first_child - if (!first) return null - // Block is the sibling after selector list - let blockNode = first.next_sibling - if (blockNode && blockNode.type === NODE_BLOCK) { - return blockNode - } - return null - } - - // For AtRule: block is last child (after prelude nodes) - if (this.type === NODE_AT_RULE) { - // Find last child that is a block - let child = this.first_child - while (child) { - if (child.type === NODE_BLOCK && !child.next_sibling) { - return child - } - child = child.next_sibling - } - return null - } - - return null - } - - // Check if this block is empty (no declarations or rules, only comments allowed) - get is_empty(): boolean { - // Only valid on block nodes - if (this.type !== NODE_BLOCK) { - return false - } - - // Empty if no children, or all children are comments - let child = this.first_child - while (child) { - if (child.type !== NODE_COMMENT) { - return false - } - child = child.next_sibling - } - return true - } - - // --- Value Node Access (for declarations) --- - - // Get array of parsed value nodes (for declarations only) - get values(): CSSNode[] { - let result: CSSNode[] = [] - let child = this.first_child - while (child) { - result.push(child) - child = child.next_sibling - } - return result - } - - // Get count of value nodes - get value_count(): number { - let count = 0 - let child = this.first_child - while (child) { - count++ - child = child.next_sibling - } - return count - } - - // Get start line number - get line(): number { - return this.arena.get_start_line(this.index) - } - - // Get start column number - get column(): number { - return this.arena.get_start_column(this.index) - } - - // Get start offset in source - get offset(): number { - return this.arena.get_start_offset(this.index) - } - - // Get length in source - get length(): number { - return this.arena.get_length(this.index) - } - - // --- Tree Traversal --- - - // Get first child node - get first_child(): CSSNode | null { - let child_index = this.arena.get_first_child(this.index) - if (child_index === 0) return null - return new CSSNode(this.arena, this.source, child_index) - } - - // Get next sibling node - get next_sibling(): CSSNode | null { - let sibling_index = this.arena.get_next_sibling(this.index) - if (sibling_index === 0) return null - return new CSSNode(this.arena, this.source, sibling_index) - } - - get has_next(): boolean { - let sibling_index = this.arena.get_next_sibling(this.index) - return sibling_index !== 0 - } - - // Check if this node has children - get has_children(): boolean { - return this.arena.has_children(this.index) - } - - // Get all children as an array - get children(): CSSNode[] { - let result: CSSNode[] = [] - let child = this.first_child - while (child) { - result.push(child) - child = child.next_sibling - } - return result - } - - // Make CSSNode iterable over its children - *[Symbol.iterator](): Iterator { - let child = this.first_child - while (child) { - yield child - child = child.next_sibling - } - } - - // --- An+B Expression Helpers (for NODE_SELECTOR_NTH) --- - - // Get the 'a' coefficient from An+B expression (e.g., "2n" from "2n+1", "odd" from "odd") - get nth_a(): string | null { - if (this.type !== NODE_SELECTOR_NTH) return null - - let len = this.arena.get_content_length(this.index) - if (len === 0) return null - let start = this.arena.get_content_start(this.index) - return this.source.substring(start, start + len) - } - - // Get the 'b' coefficient from An+B expression (e.g., "1" from "2n+1") - get nth_b(): string | null { - if (this.type !== NODE_SELECTOR_NTH) return null - - let len = this.arena.get_value_length(this.index) - if (len === 0) return null - let start = this.arena.get_value_start(this.index) - let value = this.source.substring(start, start + len) - - // Check if there's a - sign before this position (handling "2n - 1" with spaces) - // Look backwards for a - or + sign, skipping whitespace - let check_pos = start - 1 - while (check_pos >= 0) { - let ch = this.source.charCodeAt(check_pos) - if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { - check_pos-- - continue - } - // Found non-whitespace - if (ch === 0x2d /* - */) { - // Prepend - to value - value = '-' + value - } - // Note: + signs are implicit, so we don't prepend them - break - } - - // Strip leading + if present in the token itself - if (value.charCodeAt(0) === 0x2b /* + */) { - return value.substring(1) - } - return value + // Override create_node_wrapper to use the factory + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) } } diff --git a/src/index.ts b/src/index.ts index 7d8e365..48f21f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { type ParserOptions } from './parser' // Types export { CSSNode, type CSSNodeType } from './css-node' +export type { AnyNode } from './types' export { ATTR_OPERATOR_NONE, diff --git a/src/node-types.ts b/src/node-types.ts new file mode 100644 index 0000000..6e745eb --- /dev/null +++ b/src/node-types.ts @@ -0,0 +1,221 @@ +// Node type guards and helpers +import type { CSSNode } from './css-node-base' +import { + NODE_STYLESHEET, + NODE_STYLE_RULE, + NODE_AT_RULE, + NODE_DECLARATION, + NODE_SELECTOR, + NODE_COMMENT, + NODE_BLOCK, + NODE_VALUE_KEYWORD, + NODE_VALUE_NUMBER, + NODE_VALUE_DIMENSION, + NODE_VALUE_STRING, + NODE_VALUE_COLOR, + NODE_VALUE_FUNCTION, + NODE_VALUE_OPERATOR, + NODE_SELECTOR_LIST, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_CLASS, + NODE_SELECTOR_ID, + NODE_SELECTOR_ATTRIBUTE, + NODE_SELECTOR_PSEUDO_CLASS, + NODE_SELECTOR_PSEUDO_ELEMENT, + NODE_SELECTOR_COMBINATOR, + NODE_SELECTOR_UNIVERSAL, + NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, + NODE_PRELUDE_MEDIA_QUERY, + NODE_PRELUDE_MEDIA_FEATURE, + NODE_PRELUDE_MEDIA_TYPE, + NODE_PRELUDE_CONTAINER_QUERY, + NODE_PRELUDE_SUPPORTS_QUERY, + NODE_PRELUDE_LAYER_NAME, + NODE_PRELUDE_IDENTIFIER, + NODE_PRELUDE_OPERATOR, + NODE_PRELUDE_IMPORT_URL, + NODE_PRELUDE_IMPORT_LAYER, + NODE_PRELUDE_IMPORT_SUPPORTS, + ATTR_OPERATOR_NONE, + ATTR_OPERATOR_EQUAL, + ATTR_OPERATOR_TILDE_EQUAL, + ATTR_OPERATOR_PIPE_EQUAL, + ATTR_OPERATOR_CARET_EQUAL, + ATTR_OPERATOR_DOLLAR_EQUAL, + ATTR_OPERATOR_STAR_EQUAL, +} from './arena' + +// Union type for all node types (will be expanded as type-specific classes are added) +export type AnyNode = CSSNode + +// Attribute operator string mapping +export const ATTR_OPERATOR_STRINGS: Record = { + [ATTR_OPERATOR_NONE]: '', + [ATTR_OPERATOR_EQUAL]: '=', + [ATTR_OPERATOR_TILDE_EQUAL]: '~=', + [ATTR_OPERATOR_PIPE_EQUAL]: '|=', + [ATTR_OPERATOR_CARET_EQUAL]: '^=', + [ATTR_OPERATOR_DOLLAR_EQUAL]: '$=', + [ATTR_OPERATOR_STAR_EQUAL]: '*=', +} + +// Type guards for all node types + +// Core structure nodes +export function isStylesheet(node: CSSNode): node is CSSNode { + return node.type === NODE_STYLESHEET +} + +export function isStyleRule(node: CSSNode): node is CSSNode { + return node.type === NODE_STYLE_RULE +} + +export function isAtRule(node: CSSNode): node is CSSNode { + return node.type === NODE_AT_RULE +} + +export function isDeclaration(node: CSSNode): node is CSSNode { + return node.type === NODE_DECLARATION +} + +export function isSelector(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR +} + +export function isComment(node: CSSNode): node is CSSNode { + return node.type === NODE_COMMENT +} + +export function isBlock(node: CSSNode): node is CSSNode { + return node.type === NODE_BLOCK +} + +// Value nodes +export function isValueKeyword(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_KEYWORD +} + +export function isValueNumber(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_NUMBER +} + +export function isValueDimension(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_DIMENSION +} + +export function isValueString(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_STRING +} + +export function isValueColor(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_COLOR +} + +export function isValueFunction(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_FUNCTION +} + +export function isValueOperator(node: CSSNode): node is CSSNode { + return node.type === NODE_VALUE_OPERATOR +} + +// Selector nodes +export function isSelectorList(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_LIST +} + +export function isSelectorType(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_TYPE +} + +export function isSelectorClass(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_CLASS +} + +export function isSelectorId(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_ID +} + +export function isSelectorAttribute(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_ATTRIBUTE +} + +export function isSelectorPseudoClass(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_PSEUDO_CLASS +} + +export function isSelectorPseudoElement(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_PSEUDO_ELEMENT +} + +export function isSelectorCombinator(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_COMBINATOR +} + +export function isSelectorUniversal(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_UNIVERSAL +} + +export function isSelectorNesting(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_NESTING +} + +export function isSelectorNth(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_NTH +} + +export function isSelectorNthOf(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_NTH_OF +} + +export function isSelectorLang(node: CSSNode): node is CSSNode { + return node.type === NODE_SELECTOR_LANG +} + +// Prelude nodes +export function isPreludeMediaQuery(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_MEDIA_QUERY +} + +export function isPreludeMediaFeature(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_MEDIA_FEATURE +} + +export function isPreludeMediaType(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_MEDIA_TYPE +} + +export function isPreludeContainerQuery(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_CONTAINER_QUERY +} + +export function isPreludeSupportsQuery(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_SUPPORTS_QUERY +} + +export function isPreludeLayerName(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_LAYER_NAME +} + +export function isPreludeIdentifier(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_IDENTIFIER +} + +export function isPreludeOperator(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_OPERATOR +} + +export function isPreludeImportUrl(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_IMPORT_URL +} + +export function isPreludeImportLayer(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_IMPORT_LAYER +} + +export function isPreludeImportSupports(node: CSSNode): node is CSSNode { + return node.type === NODE_PRELUDE_IMPORT_SUPPORTS +} diff --git a/src/nodes/at-rule-node.ts b/src/nodes/at-rule-node.ts new file mode 100644 index 0000000..f5f730f --- /dev/null +++ b/src/nodes/at-rule-node.ts @@ -0,0 +1,94 @@ +// AtRuleNode - CSS at-rule (@media, @import, @keyframes, etc.) +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { FLAG_HAS_BLOCK, NODE_BLOCK, NODE_AT_RULE } from '../arena' +import type { AnyNode } from '../types' + +// Forward declarations for child types +export type PreludeNode = AnyNode +export type BlockNode = AnyNode + +export class AtRuleNode extends CSSNodeBase { + override get type(): typeof NODE_AT_RULE { + return this.arena.get_type(this.index) as typeof NODE_AT_RULE + } + + // Get prelude nodes (children before the block, if any) + get prelude_nodes(): PreludeNode[] { + const nodes: PreludeNode[] = [] + let child = this.first_child + while (child) { + // Stop when we hit the block + if (child.type === 7 /* NODE_BLOCK */) { + break + } + nodes.push(child as PreludeNode) + child = child.next_sibling + } + return nodes + } + + // Override children with typed return + override get children(): (PreludeNode | BlockNode)[] { + return super.children as (PreludeNode | BlockNode)[] + } + + // Get the at-rule name (e.g., "media", "import", "keyframes") + get name(): string { + let start = this.arena.get_content_start(this.index) + let length = this.arena.get_content_length(this.index) + if (length === 0) return '' + return this.source.substring(start, start + length) + } + + // Get the prelude text (for at-rules: "(min-width: 768px)" in "@media (min-width: 768px)") + // This is an alias for `value` to make at-rule usage more semantic + get prelude(): string | null { + let start = this.arena.get_value_start(this.index) + let length = this.arena.get_value_length(this.index) + if (length === 0) return null + return this.source.substring(start, start + length) + } + + // Get the value text (raw value area, same as prelude for at-rules) + get value(): string | null { + return this.prelude + } + + // Check if this at-rule has a prelude + get hasPrelude(): boolean { + return this.arena.get_value_length(this.index) > 0 + } + + // Snake_case alias for hasPrelude (overrides base class) + override get has_prelude(): boolean { + return this.hasPrelude + } + + // Check if this rule has a block { } + get hasBlock(): boolean { + return this.arena.has_flag(this.index, FLAG_HAS_BLOCK) + } + + // Snake_case alias for hasBlock (overrides base class) + override get has_block(): boolean { + return this.hasBlock + } + + // Get the block node (for at-rules with blocks) + get block(): BlockNode | null { + // For AtRule: block is last child (after prelude nodes) + let child = this.first_child + while (child) { + if (child.type === NODE_BLOCK && !child.next_sibling) { + return child as BlockNode + } + child = child.next_sibling + } + return null + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/block-node.ts b/src/nodes/block-node.ts new file mode 100644 index 0000000..230b122 --- /dev/null +++ b/src/nodes/block-node.ts @@ -0,0 +1,45 @@ +// BlockNode - Block container for declarations and nested rules +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { NODE_COMMENT, NODE_BLOCK } from '../arena' +import type { AnyNode } from '../types' + +// Forward declarations for child types +export type DeclarationNode = AnyNode +export type StyleRuleNode = AnyNode +export type AtRuleNode = AnyNode +export type CommentNode = AnyNode + +export class BlockNode extends CSSNodeBase { + override get type(): typeof NODE_BLOCK { + return this.arena.get_type(this.index) as typeof NODE_BLOCK + } + + // Override children with typed return + // Blocks can contain declarations, style rules, at-rules, and comments + override get children(): (DeclarationNode | StyleRuleNode | AtRuleNode | CommentNode)[] { + return super.children as (DeclarationNode | StyleRuleNode | AtRuleNode | CommentNode)[] + } + + // Check if this block is empty (no declarations or rules, only comments allowed) + get isEmpty(): boolean { + // Empty if no children, or all children are comments + let child = this.first_child + while (child) { + if (child.type !== NODE_COMMENT) { + return false + } + child = child.next_sibling + } + return true + } + + // Snake_case alias for isEmpty + get is_empty(): boolean { + return this.isEmpty + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/comment-node.ts b/src/nodes/comment-node.ts new file mode 100644 index 0000000..1cfd649 --- /dev/null +++ b/src/nodes/comment-node.ts @@ -0,0 +1,18 @@ +// CommentNode - CSS comment +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { NODE_COMMENT } from '../arena' +import type { AnyNode } from '../types' + +export class CommentNode extends CSSNodeBase { + override get type(): typeof NODE_COMMENT { + return this.arena.get_type(this.index) as typeof NODE_COMMENT + } + + // No additional properties needed - comments are leaf nodes + // All functionality inherited from base CSSNode + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/declaration-node.ts b/src/nodes/declaration-node.ts new file mode 100644 index 0000000..7e372c9 --- /dev/null +++ b/src/nodes/declaration-node.ts @@ -0,0 +1,80 @@ +// DeclarationNode - CSS declaration (property: value) +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { FLAG_IMPORTANT, FLAG_VENDOR_PREFIXED, NODE_DECLARATION } from '../arena' +import type { AnyNode } from '../types' + +// Forward declarations for child types (value nodes) +export type ValueNode = AnyNode + +export class DeclarationNode extends CSSNodeBase { + override get type(): typeof NODE_DECLARATION { + return this.arena.get_type(this.index) as typeof NODE_DECLARATION + } + + // Get the property name (e.g., "color", "display") + get name(): string { + let start = this.arena.get_content_start(this.index) + let length = this.arena.get_content_length(this.index) + if (length === 0) return '' + return this.source.substring(start, start + length) + } + + // Property name (alias for name) + get property(): string { + return this.name + } + + // Get array of parsed value nodes + get values(): ValueNode[] { + return super.children as ValueNode[] + } + + // Override children with typed return + override get children(): ValueNode[] { + return super.children as ValueNode[] + } + + // Get the value text (for declarations: "blue" in "color: blue") + get value(): string | null { + let start = this.arena.get_value_start(this.index) + let length = this.arena.get_value_length(this.index) + if (length === 0) return null + return this.source.substring(start, start + length) + } + + // Check if this declaration has !important + get isImportant(): boolean { + return this.arena.has_flag(this.index, FLAG_IMPORTANT) + } + + // Snake_case alias for isImportant + get is_important(): boolean { + return this.isImportant + } + + // Check if this has a vendor prefix (flag-based for performance) + get isVendorPrefixed(): boolean { + return this.arena.has_flag(this.index, FLAG_VENDOR_PREFIXED) + } + + // Snake_case alias for isVendorPrefixed + get is_vendor_prefixed(): boolean { + return this.isVendorPrefixed + } + + // Get count of value nodes + get value_count(): number { + let count = 0 + let child = this.first_child + while (child) { + count++ + child = child.next_sibling + } + return count + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/index.ts b/src/nodes/index.ts new file mode 100644 index 0000000..8569bd1 --- /dev/null +++ b/src/nodes/index.ts @@ -0,0 +1,76 @@ +// Barrel export file for all type-specific node classes +// Provides convenient single import point for all node types + +// Core structure nodes +export { StylesheetNode } from './stylesheet-node' +export { CommentNode } from './comment-node' +export { BlockNode } from './block-node' +export { DeclarationNode } from './declaration-node' +export { AtRuleNode } from './at-rule-node' +export { StyleRuleNode } from './style-rule-node' +export { SelectorNode } from './selector-node' + +// Value nodes +export { + ValueKeywordNode, + ValueStringNode, + ValueColorNode, + ValueOperatorNode, + ValueNumberNode, + ValueDimensionNode, + ValueFunctionNode, +} from './value-nodes' + +// Simple selector nodes +export { + SelectorListNode, + SelectorTypeNode, + SelectorUniversalNode, + SelectorNestingNode, + SelectorCombinatorNode, +} from './selector-nodes-simple' + +// Named selector nodes +export { + SelectorClassNode, + SelectorIdNode, + SelectorLangNode, +} from './selector-nodes-named' + +// Attribute selector node +export { SelectorAttributeNode } from './selector-attribute-node' + +// Pseudo selector nodes +export { + SelectorPseudoClassNode, + SelectorPseudoElementNode, +} from './selector-pseudo-nodes' + +// Nth selector nodes +export { + SelectorNthNode, + SelectorNthOfNode, +} from './selector-nth-nodes' + +// Media prelude nodes +export { + PreludeMediaQueryNode, + PreludeMediaFeatureNode, + PreludeMediaTypeNode, +} from './prelude-media-nodes' + +// Container and supports prelude nodes +export { + PreludeContainerQueryNode, + PreludeSupportsQueryNode, + PreludeLayerNameNode, + PreludeIdentifierNode, + PreludeOperatorNode, +} from './prelude-container-supports-nodes' + +// Import prelude nodes +export { + PreludeImportUrlNode, + PreludeImportLayerNode, + PreludeImportSupportsNode, +} from './prelude-import-nodes' diff --git a/src/nodes/prelude-container-supports-nodes.ts b/src/nodes/prelude-container-supports-nodes.ts new file mode 100644 index 0000000..0edf4d6 --- /dev/null +++ b/src/nodes/prelude-container-supports-nodes.ts @@ -0,0 +1,148 @@ +// Container and Supports Prelude Node Classes +// Represents container query and supports query components +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import type { AnyNode } from '../types' +import { + NODE_PRELUDE_CONTAINER_QUERY, + NODE_PRELUDE_IDENTIFIER, + NODE_PRELUDE_LAYER_NAME, + NODE_PRELUDE_OPERATOR, + NODE_PRELUDE_SUPPORTS_QUERY, +} from '../arena' + +// Forward declarations for child types +export type PreludeComponentNode = AnyNode + +/** + * PreludeContainerQueryNode - Represents a container query + * Examples: + * - (min-width: 400px) + * - sidebar (min-width: 400px) + * - (orientation: portrait) + * - style(--custom-property: value) + */ +export class PreludeContainerQueryNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_CONTAINER_QUERY { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_CONTAINER_QUERY + } + // Override children to return query components + override get children(): PreludeComponentNode[] { + return super.children as PreludeComponentNode[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeSupportsQueryNode - Represents a supports query/condition + * Examples: + * - (display: flex) + * - (display: grid) and (gap: 1rem) + * - not (display: flex) + * - selector(:has(a)) + */ +export class PreludeSupportsQueryNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_SUPPORTS_QUERY { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_SUPPORTS_QUERY + } + + // Get the query value (content inside parentheses, trimmed) + // For (display: flex), returns "display: flex" + get value(): string { + let text = this.text + // Remove parentheses + if (text.startsWith('(') && text.endsWith(')')) { + text = text.slice(1, -1) + } + // Remove comments and normalize whitespace + text = text + .replace(/\/\*.*?\*\//g, '') + .replace(/\s+/g, ' ') + .trim() + return text + } + + // Override children to return query components + override get children(): PreludeComponentNode[] { + return super.children as PreludeComponentNode[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeLayerNameNode - Represents a layer name + * Examples: + * - base + * - components + * - utilities + * - theme.dark (dot notation) + */ +export class PreludeLayerNameNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_LAYER_NAME { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_LAYER_NAME + } + + get name() { + return this.text + } + + // Leaf node - the layer name is available via 'text' + + // Get the layer name parts (split by dots) + get parts(): string[] { + return this.name.split('.') + } + + // Check if this is a nested layer (has dots) + get is_nested(): boolean { + return this.name.includes('.') + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeIdentifierNode - Generic identifier in preludes + * Used for: + * - Keyframe names in @keyframes + * - Property names in @property + * - Container names in @container + * - Generic identifiers in various contexts + */ +export class PreludeIdentifierNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_IDENTIFIER { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_IDENTIFIER + } + + // Leaf node - the identifier is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeOperatorNode - Logical operator in preludes + * Examples: + * - and + * - or + * - not + */ +export class PreludeOperatorNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_OPERATOR { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_OPERATOR + } + // Leaf node - the operator is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/prelude-import-nodes.ts b/src/nodes/prelude-import-nodes.ts new file mode 100644 index 0000000..c0bfc23 --- /dev/null +++ b/src/nodes/prelude-import-nodes.ts @@ -0,0 +1,137 @@ +// Import Prelude Node Classes +// Represents components of @import at-rule preludes +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import type { AnyNode } from '../types' +import { NODE_PRELUDE_IMPORT_LAYER, NODE_PRELUDE_IMPORT_SUPPORTS, NODE_PRELUDE_IMPORT_URL } from '../arena' + +// Forward declarations for child types +export type ImportComponentNode = AnyNode + +/** + * PreludeImportUrlNode - Represents the URL in an @import statement + * Examples: + * - url("styles.css") + * - "styles.css" + * - url(https://example.com/styles.css) + */ +export class PreludeImportUrlNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_IMPORT_URL { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_IMPORT_URL + } + + // Get the URL value (without url() wrapper or quotes if present) + get url(): string { + const text = this.text.trim() + + // Handle url() wrapper + if (text.startsWith('url(') && text.endsWith(')')) { + const inner = text.slice(4, -1).trim() + // Remove quotes if present + if ((inner.startsWith('"') && inner.endsWith('"')) || (inner.startsWith("'") && inner.endsWith("'"))) { + return inner.slice(1, -1) + } + return inner + } + + // Handle quoted string + if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { + return text.slice(1, -1) + } + + return text + } + + // Check if the URL uses the url() function syntax + get uses_url_function(): boolean { + return this.text.trim().startsWith('url(') + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeImportLayerNode - Represents the layer() component in @import + * Examples: + * - layer + * - layer(utilities) + * - layer(theme.dark) + */ +export class PreludeImportLayerNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_IMPORT_LAYER { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_IMPORT_LAYER + } + + // Get the layer name (null if just "layer" without parentheses, empty string otherwise) + get layer_name(): string | null { + const text = this.text.trim() + + // Just "layer" keyword + if (text === 'layer' || text.toUpperCase() === 'LAYER') { + return null + } + + // layer(name) syntax + if (text.toLowerCase().startsWith('layer(') && text.endsWith(')')) { + let inner = text.slice(6, -1) + // Remove comments and normalize whitespace + inner = inner + .replace(/\/\*.*?\*\//g, '') + .replace(/\s+/g, ' ') + .trim() + return inner + } + + return null + } + + // Alias for layer_name that returns empty string instead of null + get name(): string { + return this.layer_name || '' + } + + // Check if this is an anonymous layer (just "layer" without a name) + get is_anonymous(): boolean { + return this.layer_name === null + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeImportSupportsNode - Represents the supports() component in @import + * Examples: + * - supports(display: flex) + * - supports(display: grid) + * - supports(selector(:has(a))) + */ +export class PreludeImportSupportsNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_IMPORT_SUPPORTS { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_IMPORT_SUPPORTS + } + + // Get the supports condition (content inside parentheses) + get condition(): string { + const text = this.text.trim() + + // supports(condition) syntax + if (text.startsWith('supports(') && text.endsWith(')')) { + return text.slice(9, -1).trim() + } + + return text + } + + // Override children for complex supports conditions + override get children(): AnyNode[] { + return super.children + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/prelude-media-nodes.ts b/src/nodes/prelude-media-nodes.ts new file mode 100644 index 0000000..bbb240c --- /dev/null +++ b/src/nodes/prelude-media-nodes.ts @@ -0,0 +1,136 @@ +// Media Prelude Node Classes +// Represents media query components in @media at-rules +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import type { AnyNode } from '../types' +import { NODE_PRELUDE_MEDIA_FEATURE, NODE_PRELUDE_MEDIA_QUERY, NODE_PRELUDE_MEDIA_TYPE } from '../arena' + +// Forward declarations for child types +export type MediaComponentNode = AnyNode + +/** + * PreludeMediaQueryNode - Represents a single media query + * Examples: + * - screen + * - (min-width: 768px) + * - screen and (min-width: 768px) + * - not print + * - only screen and (orientation: landscape) + */ +export class PreludeMediaQueryNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_MEDIA_QUERY { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_MEDIA_QUERY + } + + // Override children to return media query components + // Children can be media types, media features, and logical operators + override get children(): MediaComponentNode[] { + return super.children as MediaComponentNode[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeMediaFeatureNode - Represents a media feature + * Examples: + * - (min-width: 768px) + * - (orientation: portrait) + * - (color) + * - (width >= 600px) - range syntax + * - (400px <= width <= 800px) - range syntax + */ +export class PreludeMediaFeatureNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_MEDIA_FEATURE { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_MEDIA_FEATURE + } + + // Get the feature value (content inside parentheses, trimmed) + // For (min-width: 768px), returns "min-width: 768px" + get value(): string { + const text = this.text + // Remove parentheses + let inner = text.slice(1, -1) + // Remove comments and normalize whitespace + inner = inner + .replace(/\/\*.*?\*\//g, '') + .replace(/\s+/g, ' ') + .trim() + return inner + } + + // Get the feature name + // For (min-width: 768px), returns "min-width" + // For (orientation: portrait), returns "orientation" + get feature_name(): string { + const inner = this.value + + // Find the first colon or comparison operator + const colonIndex = inner.indexOf(':') + const geIndex = inner.indexOf('>=') + const leIndex = inner.indexOf('<=') + const gtIndex = inner.indexOf('>') + const ltIndex = inner.indexOf('<') + const eqIndex = inner.indexOf('=') + + // Find the first operator position + let opIndex = -1 + const indices = [colonIndex, geIndex, leIndex, gtIndex, ltIndex, eqIndex].filter((i) => i > 0) + if (indices.length > 0) { + opIndex = Math.min(...indices) + } + + if (opIndex > 0) { + return inner.slice(0, opIndex).trim() + } + + // No operator, just a feature name like (color) + return inner + } + + // Check if this is a boolean feature (no value) + // For (color), returns true + // For (min-width: 768px), returns false + get is_boolean(): boolean { + const text = this.text + return ( + !text.includes(':') && + !text.includes('>=') && + !text.includes('<=') && + !text.includes('>') && + !text.includes('<') && + !text.includes('=') + ) + } + + // Override children for range syntax values + override get children(): AnyNode[] { + return super.children + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * PreludeMediaTypeNode - Represents a media type + * Examples: + * - screen + * - print + * - all + * - speech + */ +export class PreludeMediaTypeNode extends CSSNodeBase { + override get type(): typeof NODE_PRELUDE_MEDIA_TYPE { + return this.arena.get_type(this.index) as typeof NODE_PRELUDE_MEDIA_TYPE + } + + // Leaf node - the media type is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/selector-attribute-node.ts b/src/nodes/selector-attribute-node.ts new file mode 100644 index 0000000..130155b --- /dev/null +++ b/src/nodes/selector-attribute-node.ts @@ -0,0 +1,127 @@ +// Attribute Selector Node Class +// Represents CSS attribute selectors +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { + ATTR_OPERATOR_NONE, + ATTR_OPERATOR_EQUAL, + ATTR_OPERATOR_TILDE_EQUAL, + ATTR_OPERATOR_PIPE_EQUAL, + ATTR_OPERATOR_CARET_EQUAL, + ATTR_OPERATOR_DOLLAR_EQUAL, + ATTR_OPERATOR_STAR_EQUAL, + NODE_SELECTOR_ATTRIBUTE, +} from '../arena' +import type { AnyNode } from '../types' + +// Mapping of operator constants to their string representation +const ATTR_OPERATOR_STRINGS: Record = { + [ATTR_OPERATOR_NONE]: '', + [ATTR_OPERATOR_EQUAL]: '=', + [ATTR_OPERATOR_TILDE_EQUAL]: '~=', + [ATTR_OPERATOR_PIPE_EQUAL]: '|=', + [ATTR_OPERATOR_CARET_EQUAL]: '^=', + [ATTR_OPERATOR_DOLLAR_EQUAL]: '$=', + [ATTR_OPERATOR_STAR_EQUAL]: '*=', +} + +/** + * SelectorAttributeNode - Attribute selector + * Examples: + * - [attr] - has attribute + * - [attr=value] - exact match + * - [attr~=value] - word match + * - [attr|=value] - prefix match + * - [attr^=value] - starts with + * - [attr$=value] - ends with + * - [attr*=value] - contains + * - [attr=value i] - case-insensitive + */ +export class SelectorAttributeNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_ATTRIBUTE { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_ATTRIBUTE + } + + // Get the attribute name + // For [data-id], returns "data-id" + get attribute_name(): string { + const text = this.text + // Remove [ and ] + const inner = text.slice(1, -1).trim() + + // Find where the operator starts (if any) + const operator = this.operator + if (operator) { + const opIndex = inner.indexOf(operator) + if (opIndex > 0) { + return inner.slice(0, opIndex).trim() + } + } + + // No operator, return the whole thing (minus case sensitivity flag) + // Check for 'i' or 's' flag at the end + const parts = inner.split(/\s+/) + if (parts.length > 1 && (parts[parts.length - 1] === 'i' || parts[parts.length - 1] === 's')) { + return parts.slice(0, -1).join(' ') + } + + return inner + } + + // Get the attribute operator (for attribute selectors: =, ~=, |=, ^=, $=, *=) + // Returns one of the ATTR_OPERATOR_* constants + get attr_operator(): number { + return this.arena.get_attr_operator(this.index) + } + + // Get the operator as a string + get operator(): string { + return ATTR_OPERATOR_STRINGS[this.attr_operator] || '' + } + + // Get the attribute value (if present) + // For [attr=value], returns "value" (with quotes if present) + // For [attr], returns null + get attribute_value(): string | null { + const text = this.text + const inner = text.slice(1, -1).trim() + const operator = this.operator + + if (!operator) { + return null + } + + const opIndex = inner.indexOf(operator) + if (opIndex < 0) { + return null + } + + // Get everything after the operator + let value = inner.slice(opIndex + operator.length).trim() + + // Remove case sensitivity flag if present + if (value.endsWith(' i') || value.endsWith(' s')) { + value = value.slice(0, -2).trim() + } + + return value + } + + // Check if the selector has a case sensitivity modifier + get has_case_modifier(): boolean { + const text = this.text + return text.endsWith(' i]') || text.endsWith(' s]') + } + + // Get the case sensitivity modifier ('i' for case-insensitive, 's' for case-sensitive) + get case_modifier(): string | null { + const text = this.text + if (text.endsWith(' i]')) return 'i' + if (text.endsWith(' s]')) return 's' + return null + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/selector-node.ts b/src/nodes/selector-node.ts new file mode 100644 index 0000000..6fc36ff --- /dev/null +++ b/src/nodes/selector-node.ts @@ -0,0 +1,25 @@ +// SelectorNode - Wrapper for individual selector +// Used for pseudo-class arguments like :is(), :where(), :has() +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { NODE_SELECTOR } from '../arena' +import type { AnyNode } from '../types' + +// Forward declarations for child types (selector components) +export type SelectorComponentNode = AnyNode + +export class SelectorNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR + } + + // Override children with typed return + // Selector contains selector components (type, class, id, pseudo, etc.) + override get children(): SelectorComponentNode[] { + return super.children as SelectorComponentNode[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/selector-nodes-named.ts b/src/nodes/selector-nodes-named.ts new file mode 100644 index 0000000..51790d1 --- /dev/null +++ b/src/nodes/selector-nodes-named.ts @@ -0,0 +1,67 @@ +// Named Selector Node Classes +// These selectors have specific names/identifiers +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { NODE_SELECTOR_CLASS, NODE_SELECTOR_ID, NODE_SELECTOR_LANG } from '../arena' +import type { AnyNode } from '../types' + +/** + * SelectorClassNode - Class selector + * Examples: .container, .btn-primary, .nav-item + */ +export class SelectorClassNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_CLASS { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_CLASS + } + + // Leaf node + + // Get the class name (without the leading dot) + get name(): string { + const text = this.text + return text.startsWith('.') ? text.slice(1) : text + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorIdNode - ID selector + * Examples: #header, #main-content, #footer + */ +export class SelectorIdNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_ID { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_ID + } + + // Leaf node + + // Get the ID name (without the leading hash) + get name(): string { + const text = this.text + return text.startsWith('#') ? text.slice(1) : text + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorLangNode - Language identifier for :lang() pseudo-class + * Examples: en, fr, de, zh-CN + */ +export class SelectorLangNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_LANG { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_LANG + } + + // Leaf node - the language code + // The language code is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/selector-nodes-simple.ts b/src/nodes/selector-nodes-simple.ts new file mode 100644 index 0000000..ee598d7 --- /dev/null +++ b/src/nodes/selector-nodes-simple.ts @@ -0,0 +1,103 @@ +// Simple Selector Node Classes +// These are the basic building blocks of CSS selectors +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { + NODE_SELECTOR_LIST, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_UNIVERSAL, + NODE_SELECTOR_NESTING, + NODE_SELECTOR_COMBINATOR, +} from '../arena' +import type { AnyNode } from '../types' + +// Forward declaration for selector component types +export type SelectorComponentNode = AnyNode + +/** + * SelectorListNode - Comma-separated list of selectors + * Examples: "div, span", "h1, h2, h3", ".class1, .class2" + * This is always the first child of a StyleRule + */ +export class SelectorListNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_LIST { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_LIST + } + + // Override children to return selector components + override get children(): SelectorComponentNode[] { + return super.children as SelectorComponentNode[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorTypeNode - Type/element selector + * Examples: div, span, p, h1, article + */ +export class SelectorTypeNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_TYPE { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_TYPE + } + + // Leaf node - no additional properties + // The element name is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorUniversalNode - Universal selector + * Example: * + */ +export class SelectorUniversalNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_UNIVERSAL { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_UNIVERSAL + } + + // Leaf node - always represents "*" + // The text is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorNestingNode - Nesting selector (CSS Nesting) + * Example: & + */ +export class SelectorNestingNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_NESTING { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_NESTING + } + + // Leaf node - always represents "&" + // The text is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorCombinatorNode - Combinator between selectors + * Examples: " " (descendant), ">" (child), "+" (adjacent sibling), "~" (general sibling) + */ +export class SelectorCombinatorNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_COMBINATOR { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_COMBINATOR + } + + // Leaf node - the combinator symbol + // The combinator is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/selector-nth-nodes.ts b/src/nodes/selector-nth-nodes.ts new file mode 100644 index 0000000..162a4a3 --- /dev/null +++ b/src/nodes/selector-nth-nodes.ts @@ -0,0 +1,186 @@ +// Nth Selector Node Classes +// Represents An+B expressions in pseudo-class selectors +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import type { AnyNode } from '../types' +import { NODE_SELECTOR_NTH, NODE_SELECTOR_NTH_OF } from '../arena' + +// Forward declaration for selector types +export type SelectorComponentNode = AnyNode + +/** + * SelectorNthNode - An+B expression + * Examples: + * - 2n+1 (odd positions) + * - 2n (even positions) + * - odd + * - even + * - 3n+2 + * - -n+5 + * - 5 (just a number) + * + * Used in :nth-child(), :nth-last-child(), :nth-of-type(), :nth-last-of-type() + */ +export class SelectorNthNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_NTH { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_NTH + } + + // Get the 'a' coefficient from An+B expression (e.g., "2n" from "2n+1", "odd" from "odd") + get nth_a(): string | null { + let len = this.arena.get_content_length(this.index) + if (len === 0) return null + let start = this.arena.get_content_start(this.index) + return this.source.substring(start, start + len) + } + + // Get the 'b' coefficient from An+B expression (e.g., "1" from "2n+1") + get nth_b(): string | null { + let len = this.arena.get_value_length(this.index) + if (len === 0) return null + let start = this.arena.get_value_start(this.index) + let value = this.source.substring(start, start + len) + + // Check if there's a - sign before this position (handling "2n - 1" with spaces) + // Look backwards for a - or + sign, skipping whitespace + let check_pos = start - 1 + while (check_pos >= 0) { + let ch = this.source.charCodeAt(check_pos) + if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { + check_pos-- + continue + } + // Found non-whitespace + if (ch === 0x2d /* - */) { + // Prepend - to value + value = '-' + value + } + // Note: + signs are implicit, so we don't prepend them + break + } + + // Strip leading + if present in the token itself + if (value.charCodeAt(0) === 0x2b /* + */) { + return value.substring(1) + } + return value + } + + // Get the 'a' coefficient from An+B + // For "2n+1", returns "2n" + // For "odd", returns "odd" + // For "5", returns null (no 'n' part) + get a(): string | null { + return this.nth_a + } + + // Get the 'b' coefficient from An+B + // For "2n+1", returns "+1" + // For "2n-3", returns "-3" + // For "5", returns "5" + get b(): string | null { + return this.nth_b + } + + // Check if this is just a simple number (no 'n') + get is_number_only(): boolean { + return this.nth_a === null && this.nth_b !== null + } + + // Check if this is "odd" or "even" keyword + get is_keyword(): boolean { + const a = this.nth_a + return a === 'odd' || a === 'even' + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorNthOfNode - An+B expression with "of " syntax + * Examples: + * - 2n+1 of .class + * - odd of [attr] + * - 3 of li + * + * Used in :nth-child(An+B of selector) and :nth-last-child(An+B of selector) + * The selector part is a child node + */ +export class SelectorNthOfNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_NTH_OF { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_NTH_OF + } + + // Get the 'a' coefficient from An+B expression (e.g., "2n" from "2n+1", "odd" from "odd") + get nth_a(): string | null { + let len = this.arena.get_content_length(this.index) + if (len === 0) return null + let start = this.arena.get_content_start(this.index) + return this.source.substring(start, start + len) + } + + // Get the 'b' coefficient from An+B expression (e.g., "1" from "2n+1") + get nth_b(): string | null { + let len = this.arena.get_value_length(this.index) + if (len === 0) return null + let start = this.arena.get_value_start(this.index) + let value = this.source.substring(start, start + len) + + // Check if there's a - sign before this position (handling "2n - 1" with spaces) + // Look backwards for a - or + sign, skipping whitespace + let check_pos = start - 1 + while (check_pos >= 0) { + let ch = this.source.charCodeAt(check_pos) + if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { + check_pos-- + continue + } + // Found non-whitespace + if (ch === 0x2d /* - */) { + // Prepend - to value + value = '-' + value + } + // Note: + signs are implicit, so we don't prepend them + break + } + + // Strip leading + if present in the token itself + if (value.charCodeAt(0) === 0x2b /* + */) { + return value.substring(1) + } + return value + } + + // Get the 'a' coefficient from An+B + get a(): string | null { + return this.nth_a + } + + // Get the 'b' coefficient from An+B + get b(): string | null { + return this.nth_b + } + + // Check if this is just a simple number (no 'n') + get is_number_only(): boolean { + return this.nth_a === null && this.nth_b !== null + } + + // Check if this is "odd" or "even" keyword + get is_keyword(): boolean { + const a = this.nth_a + return a === 'odd' || a === 'even' + } + + // Override children to return the selector after "of" + // For "2n+1 of .class", children would contain the selector nodes + override get children(): SelectorComponentNode[] { + return super.children as SelectorComponentNode[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/selector-pseudo-nodes.ts b/src/nodes/selector-pseudo-nodes.ts new file mode 100644 index 0000000..e6a00e7 --- /dev/null +++ b/src/nodes/selector-pseudo-nodes.ts @@ -0,0 +1,96 @@ +// Pseudo Selector Node Classes +// Represents pseudo-classes and pseudo-elements +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { FLAG_VENDOR_PREFIXED, NODE_SELECTOR_PSEUDO_CLASS, NODE_SELECTOR_PSEUDO_ELEMENT } from '../arena' +import type { AnyNode } from '../types' + +// Forward declaration for child types +export type SelectorComponentNode = AnyNode + +/** + * SelectorPseudoClassNode - Pseudo-class selector + * Examples: + * - :hover, :focus, :active + * - :first-child, :last-child + * - :nth-child(2n+1), :nth-of-type(3) + * - :is(selector), :where(selector), :has(selector), :not(selector) + */ +export class SelectorPseudoClassNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_PSEUDO_CLASS { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_PSEUDO_CLASS + } + + // Get the pseudo-class name (without the leading colon) + // For :hover, returns "hover" + // For :nth-child(2n+1), returns "nth-child" + get name(): string { + const text = this.text + // Remove leading colon + const withoutColon = text.startsWith(':') ? text.slice(1) : text + + // If it has parentheses, get name before the opening paren + const parenIndex = withoutColon.indexOf('(') + if (parenIndex > 0) { + return withoutColon.slice(0, parenIndex) + } + + return withoutColon + } + + // Check if the pseudo-class has arguments + get has_arguments(): boolean { + return this.has_children || this.text.includes('(') + } + + // Override children to return selector components + // For functional pseudo-classes like :is(), :where(), :has(), :not() + override get children(): SelectorComponentNode[] { + return super.children as SelectorComponentNode[] + } + + // Check if this has a vendor prefix (flag-based for performance) + get is_vendor_prefixed(): boolean { + return this.arena.has_flag(this.index, FLAG_VENDOR_PREFIXED) + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * SelectorPseudoElementNode - Pseudo-element selector + * Examples: + * - ::before, ::after + * - ::first-line, ::first-letter + * - ::marker, ::placeholder + * - ::selection + */ +export class SelectorPseudoElementNode extends CSSNodeBase { + override get type(): typeof NODE_SELECTOR_PSEUDO_ELEMENT { + return this.arena.get_type(this.index) as typeof NODE_SELECTOR_PSEUDO_ELEMENT + } + + // Get the pseudo-element name (without the leading double colon) + // For ::before, returns "before" + // Also handles single colon syntax (:before) for backwards compatibility + get name(): string { + const text = this.text + // Remove leading :: or : + if (text.startsWith('::')) { + return text.slice(2) + } else if (text.startsWith(':')) { + return text.slice(1) + } + return text + } + + get is_vendor_prefixed(): boolean { + return this.arena.has_flag(this.index, FLAG_VENDOR_PREFIXED) + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/style-rule-node.ts b/src/nodes/style-rule-node.ts new file mode 100644 index 0000000..22b775c --- /dev/null +++ b/src/nodes/style-rule-node.ts @@ -0,0 +1,68 @@ +// StyleRuleNode - CSS style rule with selector and declarations +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { FLAG_HAS_BLOCK, FLAG_HAS_DECLARATIONS, NODE_BLOCK, NODE_STYLE_RULE } from '../arena' +import type { AnyNode } from '../types' + +// Forward declarations for child types +export type SelectorListNode = AnyNode +export type BlockNode = AnyNode + +export class StyleRuleNode extends CSSNodeBase { + override get type(): typeof NODE_STYLE_RULE { + return this.arena.get_type(this.index) as typeof NODE_STYLE_RULE + } + + // Get selector list (always first child of style rule) + get selector_list(): SelectorListNode | null { + const first = this.first_child + if (!first) return null + // First child should be selector list + if (first.type === 20 /* NODE_SELECTOR_LIST */) { + return first as SelectorListNode + } + return null + } + + // Override children with typed return + // StyleRule has [SelectorListNode, BlockNode?] + override get children(): (SelectorListNode | BlockNode)[] { + return super.children as (SelectorListNode | BlockNode)[] + } + + // Check if this rule has a block { } + get hasBlock(): boolean { + return this.arena.has_flag(this.index, FLAG_HAS_BLOCK) + } + + // Snake_case alias for hasBlock (overrides base class) + override get has_block(): boolean { + return this.hasBlock + } + + // Check if this style rule has declarations + get hasDeclarations(): boolean { + return this.arena.has_flag(this.index, FLAG_HAS_DECLARATIONS) + } + + // Snake_case alias for hasDeclarations (overrides base class) + override get has_declarations(): boolean { + return this.hasDeclarations + } + + // Get the block node (sibling after selector list) + get block(): BlockNode | null { + let first = this.first_child + if (!first) return null + // Block is the sibling after selector list + let blockNode = first.next_sibling + if (blockNode && blockNode.type === NODE_BLOCK) { + return blockNode as BlockNode + } + return null + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/stylesheet-node.ts b/src/nodes/stylesheet-node.ts new file mode 100644 index 0000000..d82ac18 --- /dev/null +++ b/src/nodes/stylesheet-node.ts @@ -0,0 +1,32 @@ +// StylesheetNode - Root node of the CSS AST +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { NODE_STYLESHEET } from '../arena' +import type { CSSDataArena } from '../arena' +import type { AnyNode } from '../types' + +// Forward declarations for child types (will be implemented in future batches) +// For now, these are all AnyNode, but will become specific types later +export type StyleRuleNode = AnyNode +export type AtRuleNode = AnyNode +export type CommentNode = AnyNode + +export class StylesheetNode extends CSSNodeBase { + constructor(arena: CSSDataArena, source: string, index: number) { + super(arena, source, index) + } + + override get type(): typeof NODE_STYLESHEET { + return this.arena.get_type(this.index) as typeof NODE_STYLESHEET + } + + // Override children with typed return + // Stylesheet can contain style rules, at-rules, and comments + override get children(): (StyleRuleNode | AtRuleNode | CommentNode)[] { + return super.children as (StyleRuleNode | AtRuleNode | CommentNode)[] + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/nodes/value-nodes.ts b/src/nodes/value-nodes.ts new file mode 100644 index 0000000..0742add --- /dev/null +++ b/src/nodes/value-nodes.ts @@ -0,0 +1,187 @@ +// Value Node Classes - For declaration values +// These nodes represent parsed values in CSS declarations +import { CSSNode as CSSNodeBase } from '../css-node-base' +import { CSSNode } from '../css-node' +import { + NODE_VALUE_KEYWORD, + NODE_VALUE_NUMBER, + NODE_VALUE_DIMENSION, + NODE_VALUE_STRING, + NODE_VALUE_COLOR, + NODE_VALUE_FUNCTION, + NODE_VALUE_OPERATOR, +} from '../arena' +import type { AnyNode } from '../types' + +/** + * ValueKeywordNode - Represents a keyword value (identifier) + * Examples: red, auto, inherit, initial, flex, block + */ +export class ValueKeywordNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_KEYWORD { + return this.arena.get_type(this.index) as typeof NODE_VALUE_KEYWORD + } + + // Keyword nodes are leaf nodes with no additional properties + // The keyword text is available via the inherited 'text' property + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * ValueStringNode - Represents a quoted string value + * Examples: "hello", 'world', "path/to/file.css" + */ +export class ValueStringNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_STRING { + return this.arena.get_type(this.index) as typeof NODE_VALUE_STRING + } + + // String nodes are leaf nodes + // The full string (including quotes) is available via 'text' + + // Get the string content without quotes + get value(): string { + const text = this.text + // Remove surrounding quotes (first and last character) + if (text.length >= 2 && (text[0] === '"' || text[0] === "'")) { + return text.slice(1, -1) + } + return text + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * ValueColorNode - Represents a hex color value + * Examples: #fff, #ff0000, #rgba + */ +export class ValueColorNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_COLOR { + return this.arena.get_type(this.index) as typeof NODE_VALUE_COLOR + } + + // Color nodes are leaf nodes + // The hex color (including #) is available via 'text' + + // Get the color value without the # prefix + get hex(): string { + const text = this.text + return text.startsWith('#') ? text.slice(1) : text + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * ValueOperatorNode - Represents an operator in a value + * Examples: +, -, *, /, comma (,) + */ +export class ValueOperatorNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_OPERATOR { + return this.arena.get_type(this.index) as typeof NODE_VALUE_OPERATOR + } + + // Operator nodes are leaf nodes + // The operator symbol is available via 'text' + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * ValueNumberNode - Represents a numeric value + * Examples: 42, 3.14, -5, .5 + */ +export class ValueNumberNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_NUMBER { + return this.arena.get_type(this.index) as typeof NODE_VALUE_NUMBER + } + + // Number nodes are leaf nodes + + // Get the numeric value + get value(): number { + return parseFloat(this.text) + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * ValueDimensionNode - Represents a number with a unit + * Examples: 10px, 2em, 50%, 1.5rem, 90deg + */ +export class ValueDimensionNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_DIMENSION { + return this.arena.get_type(this.index) as typeof NODE_VALUE_DIMENSION + } + + // Dimension nodes are leaf nodes + + // Get the numeric value (without the unit) + get value(): number { + // Parse the number from the beginning of the text + return parseFloat(this.text) + } + + // Get the unit string + get unit(): string { + const text = this.text + // Find where the number ends and unit begins + let i = 0 + // Skip optional leading sign + if (text[i] === '+' || text[i] === '-') i++ + // Skip digits and decimal point + while (i < text.length) { + const c = text[i] + if (c >= '0' && c <= '9' || c === '.') { + i++ + } else { + break + } + } + return text.slice(i) + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} + +/** + * ValueFunctionNode - Represents a function call in a value + * Examples: calc(100% - 20px), var(--color), rgb(255, 0, 0), url("image.png") + */ +export class ValueFunctionNode extends CSSNodeBase { + override get type(): typeof NODE_VALUE_FUNCTION { + return this.arena.get_type(this.index) as typeof NODE_VALUE_FUNCTION + } + + // Function nodes can have children (function arguments) + + // Get the function name (without parentheses) + get name(): string { + return this.text.slice(0, this.text.indexOf('(')) + } + + // Override children to return typed value nodes + // Function arguments are value nodes + override get children(): AnyNode[] { + return super.children + } + + protected override create_node_wrapper(index: number): AnyNode { + return CSSNode.from(this.arena, this.source, index) + } +} diff --git a/src/parse.test.ts b/src/parse.test.ts index b03be53..9fae034 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest' import { parse } from './parse' import { NODE_STYLESHEET, NODE_STYLE_RULE, NODE_DECLARATION, NODE_AT_RULE } from './arena' +import { AtRuleNode, DeclarationNode } from './css-node' describe('parse()', () => { test('should parse CSS and return CSSNode', () => { @@ -30,7 +31,7 @@ describe('parse()', () => { const result = parse('@media (min-width: 768px) { body { color: blue; } }') expect(result.type).toBe(NODE_STYLESHEET) - const media = result.first_child! + const media = result.first_child! as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) expect(media.name).toBe('media') }) @@ -42,9 +43,9 @@ describe('parse()', () => { const [_selector, block] = rule.children const [decl1, decl2] = block.children expect(decl1.type).toBe(NODE_DECLARATION) - expect(decl1.name).toBe('color') + expect((decl1 as DeclarationNode).name).toBe('color') expect(decl2.type).toBe(NODE_DECLARATION) - expect(decl2.name).toBe('margin') + expect((decl2 as DeclarationNode).name).toBe('margin') }) test('should accept parser options', () => { @@ -59,7 +60,7 @@ describe('parse()', () => { const rule = result.first_child! const [_selector, block] = rule.children - const decl = block.first_child! + const decl = block.first_child! as DeclarationNode expect(decl.name).toBe('color') expect(decl.value).toBe('red') // With parse_values, should have value children @@ -69,7 +70,7 @@ describe('parse()', () => { test('should parse with parse_atrule_preludes enabled', () => { const result = parse('@media (min-width: 768px) { }', { parse_atrule_preludes: true }) - const media = result.first_child! + const media = result.first_child! as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) expect(media.name).toBe('media') // With parse_atrule_preludes, should have prelude children diff --git a/src/parse.ts b/src/parse.ts index 1f75e3f..564d781 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,14 +1,14 @@ import { Parser } from './parser' import type { ParserOptions } from './parser' -import type { CSSNode } from './css-node' +import type { StylesheetNode } from './css-node' /** * Parse CSS and return an AST * @param source - The CSS source code to parse * @param options - Parser options - * @returns The root CSSNode of the AST + * @returns The root StylesheetNode of the AST */ -export function parse(source: string, options?: ParserOptions): CSSNode { +export function parse(source: string, options?: ParserOptions): StylesheetNode { const parser = new Parser(source, options) - return parser.parse() + return parser.parse() as StylesheetNode } diff --git a/src/parser-options.test.ts b/src/parser-options.test.ts index 16350bf..da14068 100644 --- a/src/parser-options.test.ts +++ b/src/parser-options.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { Parser } from './parser' import { NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_VALUE_KEYWORD } from './arena' +import { DeclarationNode } from './css-node' describe('Parser Options', () => { const css = 'body { color: red; }' @@ -58,7 +59,7 @@ describe('Parser Options', () => { // Declaration should exist but have no value children const block = selector?.next_sibling - const declaration = block?.first_child + const declaration = block?.first_child as DeclarationNode expect(declaration).not.toBeNull() expect(declaration?.type).toBe(NODE_DECLARATION) expect(declaration?.property).toBe('color') @@ -72,7 +73,7 @@ describe('Parser Options', () => { const rule = root.first_child const selector = rule?.first_child const block = selector?.next_sibling - const declaration = block?.first_child + const declaration = block?.first_child as DeclarationNode expect(declaration?.property).toBe('margin') expect(declaration?.value).toBe('10px 20px') @@ -85,7 +86,7 @@ describe('Parser Options', () => { const rule = root.first_child const selector = rule?.first_child const block = selector?.next_sibling - const declaration = block?.first_child + const declaration = block?.first_child as DeclarationNode expect(declaration?.property).toBe('color') expect(declaration?.value).toBe('rgb(255, 0, 0)') @@ -150,7 +151,7 @@ describe('Parser Options', () => { // Declaration should have no value children const block = selector?.next_sibling - const declaration = block?.first_child + const declaration = block?.first_child as DeclarationNode expect(declaration?.type).toBe(NODE_DECLARATION) expect(declaration?.property).toBe('color') expect(declaration?.value).toBe('red') @@ -173,12 +174,12 @@ describe('Parser Options', () => { expect(selector?.has_children).toBe(false) const block = selector?.next_sibling - const decl1 = block?.first_child + const decl1 = block?.first_child as DeclarationNode expect(decl1?.property).toBe('margin') expect(decl1?.value).toBe('10px 20px') expect(decl1?.has_children).toBe(false) - const decl2 = decl1?.next_sibling + const decl2 = decl1?.next_sibling as DeclarationNode expect(decl2?.property).toBe('color') expect(decl2?.value).toBe('rgb(255, 0, 0)') expect(decl2?.has_children).toBe(false) @@ -204,8 +205,8 @@ describe('Parser Options', () => { let decl = block?.first_child const properties: string[] = [] while (decl) { - if (decl.property) { - properties.push(decl.property) + if ((decl as DeclarationNode).property) { + properties.push((decl as DeclarationNode).property) } decl = decl.next_sibling } diff --git a/src/parser.test.ts b/src/parser.test.ts index 5c8cdf9..ba59a2a 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -15,6 +15,7 @@ import { } from './parser' import { parse } from './parse' import { ATTR_OPERATOR_PIPE_EQUAL } from './arena' +import { DeclarationNode, AtRuleNode, SelectorAttributeNode, SelectorPseudoClassNode, SelectorPseudoElementNode, StyleRuleNode } from './css-node' describe('Parser', () => { describe('basic parsing', () => { @@ -140,11 +141,11 @@ describe('Parser', () => { const selectorlist = rule.first_child! const selector = selectorlist.first_child! expect(selector.type).toBe(NODE_SELECTOR) - const s = selector.children[0] + const s = selector.children[0] as SelectorAttributeNode expect(s.type).toBe(NODE_SELECTOR_ATTRIBUTE) expect(s.attr_operator).toEqual(ATTR_OPERATOR_PIPE_EQUAL) - expect(s.name).toBe('root') - expect(s.value).toBe('"test"') + expect(s.attribute_name).toBe('root') + expect(s.attribute_value).toBe('"test"') }) }) @@ -156,7 +157,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(false) @@ -169,7 +170,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode // Property name stored in the 'name' property expect(declaration.name).toBe('color') @@ -196,7 +197,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(true) @@ -209,7 +210,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(true) @@ -222,7 +223,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(true) @@ -235,7 +236,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) }) @@ -247,7 +248,7 @@ describe('Parser', () => { const rule = root.first_child! const [_selector, block] = rule.children - const declaration = block.first_child! + const declaration = block.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.name).toBe('background') @@ -261,7 +262,7 @@ describe('Parser', () => { const parser = new Parser(source, { parse_atrule_preludes: false }) const root = parser.parse() - const atRule = root.first_child! + const atRule = root.first_child! as AtRuleNode expect(atRule.type).toBe(NODE_AT_RULE) expect(atRule.name).toBe('import') expect(atRule.has_children).toBe(false) @@ -272,7 +273,7 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const atRule = root.first_child! + const atRule = root.first_child! as AtRuleNode expect(atRule.type).toBe(NODE_AT_RULE) expect(atRule.name).toBe('namespace') }) @@ -284,9 +285,9 @@ describe('Parser', () => { const parser = new Parser(source, { parse_atrule_preludes: false }) const root = parser.parse() - const media = root.first_child! + const media = root.first_child! as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('MEDIA') + expect((media as AtRuleNode).name).toBe('MEDIA') expect(media.has_children).toBe(true) // Should parse as conditional (containing rules) const block = media.block! @@ -299,13 +300,13 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const fontFace = root.first_child! + const fontFace = root.first_child! as AtRuleNode expect(fontFace.type).toBe(NODE_AT_RULE) expect(fontFace.name).toBe('Font-Face') expect(fontFace.has_children).toBe(true) // Should parse as declaration at-rule (containing declarations) const block = fontFace.block! - const decl = block.first_child! + const decl = block.first_child! as DeclarationNode expect(decl.type).toBe(NODE_DECLARATION) }) @@ -314,7 +315,7 @@ describe('Parser', () => { const parser = new Parser(source, { parse_atrule_preludes: false }) const root = parser.parse() - const supports = root.first_child! + const supports = root.first_child! as AtRuleNode expect(supports.type).toBe(NODE_AT_RULE) expect(supports.name).toBe('SUPPORTS') expect(supports.has_children).toBe(true) @@ -327,9 +328,9 @@ describe('Parser', () => { const parser = new Parser(source, { parse_atrule_preludes: false }) const root = parser.parse() - const media = root.first_child! + const media = root.first_child! as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') + expect((media as AtRuleNode).name).toBe('media') expect(media.has_children).toBe(true) const block = media.block! @@ -342,9 +343,9 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const layer = root.first_child! + const layer = root.first_child! as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) - expect(layer.name).toBe('layer') + expect((layer as AtRuleNode).name).toBe('layer') expect(layer.has_children).toBe(true) }) @@ -353,9 +354,9 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const layer = root.first_child! + const layer = root.first_child! as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) - expect(layer.name).toBe('layer') + expect((layer as AtRuleNode).name).toBe('layer') expect(layer.has_children).toBe(true) }) @@ -364,7 +365,7 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const supports = root.first_child! + const supports = root.first_child! as AtRuleNode expect(supports.type).toBe(NODE_AT_RULE) expect(supports.name).toBe('supports') expect(supports.has_children).toBe(true) @@ -375,9 +376,9 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const container = root.first_child! + const container = root.first_child! as AtRuleNode expect(container.type).toBe(NODE_AT_RULE) - expect(container.name).toBe('container') + expect((container as AtRuleNode).name).toBe('container') expect(container.has_children).toBe(true) }) }) @@ -388,7 +389,7 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const fontFace = root.first_child! + const fontFace = root.first_child! as AtRuleNode expect(fontFace.type).toBe(NODE_AT_RULE) expect(fontFace.name).toBe('font-face') expect(fontFace.has_children).toBe(true) @@ -405,12 +406,12 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const page = root.first_child! + const page = root.first_child! as AtRuleNode expect(page.type).toBe(NODE_AT_RULE) expect(page.name).toBe('page') const block = page.block! - const decl = block.first_child! + const decl = block.first_child! as DeclarationNode expect(decl.type).toBe(NODE_DECLARATION) }) @@ -419,12 +420,12 @@ describe('Parser', () => { const parser = new Parser(source) const root = parser.parse() - const counterStyle = root.first_child! + const counterStyle = root.first_child! as AtRuleNode expect(counterStyle.type).toBe(NODE_AT_RULE) expect(counterStyle.name).toBe('counter-style') const block = counterStyle.block! - const decl = block.first_child! + const decl = block.first_child! as DeclarationNode expect(decl.type).toBe(NODE_DECLARATION) }) }) @@ -435,13 +436,13 @@ describe('Parser', () => { const parser = new Parser(source, { parse_atrule_preludes: false }) const root = parser.parse() - const supports = root.first_child! + const supports = root.first_child! as AtRuleNode expect(supports.name).toBe('supports') const supports_block = supports.block! - const media = supports_block.first_child! + const media = supports_block.first_child! as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') + expect((media as AtRuleNode).name).toBe('media') const media_block = media.block! const rule = media_block.first_child! @@ -456,9 +457,9 @@ describe('Parser', () => { const root = parser.parse() const [import1, layer, media] = root.children - expect(import1.name).toBe('import') - expect(layer.name).toBe('layer') - expect(media.name).toBe('media') + expect((import1 as AtRuleNode).name).toBe('import') + expect((layer as AtRuleNode).name).toBe('layer') + expect((media as AtRuleNode).name).toBe('media') }) }) }) @@ -475,7 +476,7 @@ describe('Parser', () => { let [_selector, block] = parent.children let [decl, nested_rule] = block.children expect(decl.type).toBe(NODE_DECLARATION) - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(nested_rule.type).toBe(NODE_STYLE_RULE) let nested_selector = nested_rule.first_child! @@ -525,9 +526,9 @@ describe('Parser', () => { expect(c.type).toBe(NODE_STYLE_RULE) let [_selector_c, block_c] = c.children - let decl = block_c.first_child! + let decl = block_c.first_child! as DeclarationNode expect(decl.type).toBe(NODE_DECLARATION) - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') }) test('should parse nested @media inside rule', () => { @@ -541,10 +542,10 @@ describe('Parser', () => { expect(decl.type).toBe(NODE_DECLARATION) expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') + expect((media as AtRuleNode).name).toBe('media') - let media_block = media.block! - let nested_decl = media_block.first_child! + let media_block = (media as AtRuleNode).block! + let nested_decl = media_block.first_child! as DeclarationNode expect(nested_decl.type).toBe(NODE_DECLARATION) expect(nested_decl.name).toBe('padding') }) @@ -594,12 +595,12 @@ describe('Parser', () => { let [decl1, title, decl2, body] = block.children expect(decl1.type).toBe(NODE_DECLARATION) - expect(decl1.name).toBe('color') + expect((decl1 as DeclarationNode).name).toBe('color') expect(title.type).toBe(NODE_STYLE_RULE) expect(decl2.type).toBe(NODE_DECLARATION) - expect(decl2.name).toBe('padding') + expect((decl2 as DeclarationNode).name).toBe('padding') expect(body.type).toBe(NODE_STYLE_RULE) }) @@ -611,7 +612,7 @@ describe('Parser', () => { let parser = new Parser(source, { parse_atrule_preludes: false }) let root = parser.parse() - let keyframes = root.first_child! + let keyframes = root.first_child! as AtRuleNode expect(keyframes.type).toBe(NODE_AT_RULE) expect(keyframes.name).toBe('keyframes') @@ -632,7 +633,7 @@ describe('Parser', () => { let parser = new Parser(source, { parse_atrule_preludes: false }) let root = parser.parse() - let keyframes = root.first_child! + let keyframes = root.first_child! as AtRuleNode let block = keyframes.block! let [rule0, rule50, rule100] = block.children @@ -649,7 +650,7 @@ describe('Parser', () => { let parser = new Parser(source, { parse_atrule_preludes: false }) let root = parser.parse() - let keyframes = root.first_child! + let keyframes = root.first_child! as AtRuleNode let block = keyframes.block! let [rule1, _rule2] = block.children @@ -666,16 +667,16 @@ describe('Parser', () => { let parent = root.first_child! let [_selector, block] = parent.children - let nest = block.first_child! + let nest = block.first_child! as AtRuleNode expect(nest.type).toBe(NODE_AT_RULE) expect(nest.name).toBe('nest') expect(nest.has_children).toBe(true) let nest_block = nest.block! - let decl = nest_block.first_child! + let decl = nest_block.first_child! as DeclarationNode expect(decl.type).toBe(NODE_DECLARATION) - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') }) test('should parse @nest with complex selector', () => { @@ -685,7 +686,7 @@ describe('Parser', () => { let a = root.first_child! let [_selector, block] = a.children - let nest = block.first_child! + let nest = block.first_child! as AtRuleNode expect(nest.type).toBe(NODE_AT_RULE) expect(nest.name).toBe('nest') @@ -729,7 +730,7 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode expect(decl.type).toBe(NODE_DECLARATION) }) @@ -799,8 +800,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-webkit-transform') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('-webkit-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -811,8 +812,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-moz-transform') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('-moz-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -823,8 +824,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-ms-transform') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('-ms-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -835,8 +836,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-o-transform') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('-o-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -847,8 +848,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('transform') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('transform') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -859,8 +860,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('background-color') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('background-color') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -871,8 +872,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('--primary-color') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('--primary-color') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -885,14 +886,14 @@ describe('Parser', () => { let [_selector, block] = rule.children let [webkit, moz, standard] = block.children - expect(webkit.name).toBe('-webkit-transform') - expect(webkit.is_vendor_prefixed).toBe(true) + expect((webkit as DeclarationNode).name).toBe('-webkit-transform') + expect((webkit as DeclarationNode).is_vendor_prefixed).toBe(true) - expect(moz.name).toBe('-moz-transform') - expect(moz.is_vendor_prefixed).toBe(true) + expect((moz as DeclarationNode).name).toBe('-moz-transform') + expect((moz as DeclarationNode).is_vendor_prefixed).toBe(true) - expect(standard.name).toBe('transform') - expect(standard.is_vendor_prefixed).toBe(false) + expect((standard as DeclarationNode).name).toBe('transform') + expect((standard as DeclarationNode).is_vendor_prefixed).toBe(false) }) test('should detect vendor prefix for complex property names', () => { @@ -902,8 +903,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-webkit-border-top-left-radius') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('-webkit-border-top-left-radius') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -915,8 +916,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('border-radius') + let decl = block.first_child! as DeclarationNode + expect((decl as DeclarationNode).name).toBe('border-radius') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -929,7 +930,7 @@ describe('Parser', () => { let rule = root.first_child! let selector = rule.first_child! // Selectors have text but checking is_vendor_prefixed should be safe - expect(selector.is_vendor_prefixed).toBe(false) + expect((selector as any).is_vendor_prefixed).toBe(false) }) }) @@ -946,9 +947,9 @@ describe('Parser', () => { expect(selector.has_children).toBe(true) // Navigate: selector -> type selector (input) -> pseudo-class (next sibling) let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! + let pseudoClass = typeSelector.next_sibling! as SelectorPseudoClassNode expect(pseudoClass.name).toBe('-webkit-autofill') - expect(pseudoClass.is_vendor_prefixed).toBe(true) + expect((pseudoClass as any).is_vendor_prefixed).toBe(true) }) test('should detect -moz- vendor prefix in pseudo-class', () => { @@ -960,9 +961,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! + let pseudoClass = typeSelector.next_sibling! as SelectorPseudoClassNode expect(pseudoClass.name).toBe('-moz-focusring') - expect(pseudoClass.is_vendor_prefixed).toBe(true) + expect((pseudoClass as any).is_vendor_prefixed).toBe(true) }) test('should detect -ms- vendor prefix in pseudo-class', () => { @@ -974,9 +975,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! + let pseudoClass = typeSelector.next_sibling! as SelectorPseudoClassNode expect(pseudoClass.name).toBe('-ms-input-placeholder') - expect(pseudoClass.is_vendor_prefixed).toBe(true) + expect((pseudoClass as any).is_vendor_prefixed).toBe(true) }) test('should detect -webkit- vendor prefix in pseudo-element', () => { @@ -988,9 +989,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! + let pseudoElement = typeSelector.next_sibling! as SelectorPseudoElementNode expect(pseudoElement.name).toBe('-webkit-scrollbar') - expect(pseudoElement.is_vendor_prefixed).toBe(true) + expect((pseudoElement as any).is_vendor_prefixed).toBe(true) }) test('should detect -moz- vendor prefix in pseudo-element', () => { @@ -1002,9 +1003,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! + let pseudoElement = typeSelector.next_sibling! as SelectorPseudoElementNode expect(pseudoElement.name).toBe('-moz-selection') - expect(pseudoElement.is_vendor_prefixed).toBe(true) + expect((pseudoElement as any).is_vendor_prefixed).toBe(true) }) test('should detect -webkit- vendor prefix in pseudo-element with multiple parts', () => { @@ -1016,9 +1017,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! + let pseudoElement = typeSelector.next_sibling! as SelectorPseudoElementNode expect(pseudoElement.name).toBe('-webkit-input-placeholder') - expect(pseudoElement.is_vendor_prefixed).toBe(true) + expect((pseudoElement as any).is_vendor_prefixed).toBe(true) }) test('should detect -webkit- vendor prefix in pseudo-class function', () => { @@ -1030,9 +1031,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! + let pseudoClass = typeSelector.next_sibling! as SelectorPseudoClassNode expect(pseudoClass.name).toBe('-webkit-any') - expect(pseudoClass.is_vendor_prefixed).toBe(true) + expect((pseudoClass as any).is_vendor_prefixed).toBe(true) }) test('should not detect vendor prefix for standard pseudo-classes', () => { @@ -1044,9 +1045,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! + let pseudoClass = typeSelector.next_sibling! as SelectorPseudoClassNode expect(pseudoClass.name).toBe('hover') - expect(pseudoClass.is_vendor_prefixed).toBe(false) + expect((pseudoClass as any).is_vendor_prefixed).toBe(false) }) test('should not detect vendor prefix for standard pseudo-elements', () => { @@ -1058,9 +1059,9 @@ describe('Parser', () => { let selectorList = rule.first_child! let selector = selectorList.first_child! // NODE_SELECTOR wrapper let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! + let pseudoElement = typeSelector.next_sibling! as SelectorPseudoElementNode expect(pseudoElement.name).toBe('before') - expect(pseudoElement.is_vendor_prefixed).toBe(false) + expect((pseudoElement as any).is_vendor_prefixed).toBe(false) }) test('should detect vendor prefix with multiple vendor-prefixed pseudo-elements', () => { @@ -1074,22 +1075,22 @@ describe('Parser', () => { let selector1 = selectorList1.first_child! // NODE_SELECTOR wrapper let typeSelector1 = selector1.first_child! let pseudo1 = typeSelector1.next_sibling! - expect(pseudo1.name).toBe('-webkit-scrollbar') - expect(pseudo1.is_vendor_prefixed).toBe(true) + expect((pseudo1 as SelectorPseudoElementNode).name).toBe('-webkit-scrollbar') + expect((pseudo1 as any).is_vendor_prefixed).toBe(true) let selectorList2 = rule2.first_child! let selector2 = selectorList2.first_child! // NODE_SELECTOR wrapper let typeSelector2 = selector2.first_child! let pseudo2 = typeSelector2.next_sibling! - expect(pseudo2.name).toBe('-webkit-scrollbar-thumb') - expect(pseudo2.is_vendor_prefixed).toBe(true) + expect((pseudo2 as SelectorPseudoElementNode).name).toBe('-webkit-scrollbar-thumb') + expect((pseudo2 as any).is_vendor_prefixed).toBe(true) let selectorList3 = rule3.first_child! let selector3 = selectorList3.first_child! // NODE_SELECTOR wrapper let typeSelector3 = selector3.first_child! let pseudo3 = typeSelector3.next_sibling! - expect(pseudo3.name).toBe('after') - expect(pseudo3.is_vendor_prefixed).toBe(false) + expect((pseudo3 as SelectorPseudoElementNode).name).toBe('after') + expect((pseudo3 as any).is_vendor_prefixed).toBe(false) }) test('should detect vendor prefix in complex selector', () => { @@ -1102,14 +1103,14 @@ describe('Parser', () => { let selector = selectorList.first_child! // NODE_SELECTOR wrapper // Navigate through compound selector: input (type) -> -webkit-autofill (pseudo) -> :focus (pseudo) let typeSelector = selector.first_child! - let webkitPseudo = typeSelector.next_sibling! + let webkitPseudo = typeSelector.next_sibling! as SelectorPseudoClassNode expect(webkitPseudo.name).toBe('-webkit-autofill') - expect(webkitPseudo.is_vendor_prefixed).toBe(true) + expect((webkitPseudo as any).is_vendor_prefixed).toBe(true) // Check the :focus pseudo-class is not vendor prefixed - let focusPseudo = webkitPseudo.next_sibling! + let focusPseudo = webkitPseudo.next_sibling! as SelectorPseudoClassNode expect(focusPseudo.name).toBe('focus') - expect(focusPseudo.is_vendor_prefixed).toBe(false) + expect((focusPseudo as any).is_vendor_prefixed).toBe(false) }) }) @@ -1183,9 +1184,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children let [decl1, decl2, decl3] = block.children - expect(decl1.name).toBe('-webkit-transform') - expect(decl2.name).toBe('-moz-transform') - expect(decl3.name).toBe('transform') + expect((decl1 as DeclarationNode).name).toBe('-webkit-transform') + expect((decl2 as DeclarationNode).name).toBe('-moz-transform') + expect((decl3 as DeclarationNode).name).toBe('transform') }) test('should parse complex selector list', () => { @@ -1212,14 +1213,14 @@ describe('Parser', () => { let parser = new Parser(source, { parse_atrule_preludes: false }) let root = parser.parse() - let supports = root.first_child! + let supports = root.first_child! as AtRuleNode let supports_block = supports.block! - let media = supports_block.first_child! - let media_block = media.block! - let layer = media_block.first_child! + let media = supports_block.first_child! as AtRuleNode + let media_block = (media as AtRuleNode).block! + let layer = media_block.first_child! as AtRuleNode expect(supports.name).toBe('supports') - expect(media.name).toBe('media') - expect(layer.name).toBe('layer') + expect((media as AtRuleNode).name).toBe('media') + expect((layer as AtRuleNode).name).toBe('layer') }) test('should parse CSS with calc() and other functions', () => { @@ -1230,8 +1231,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children let [width_decl, bg_decl] = block.children - expect(width_decl.name).toBe('width') - expect(bg_decl.name).toBe('background') + expect((width_decl as DeclarationNode).name).toBe('width') + expect((bg_decl as DeclarationNode).name).toBe('background') }) test('should parse custom properties', () => { @@ -1273,12 +1274,12 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let block = rule.block! + let block = (rule as StyleRuleNode).block! expect(block.children.length).toBeGreaterThan(1) // Check at least first declaration has important flag let declarations = block.children.filter((c) => c.type === NODE_DECLARATION) expect(declarations.length).toBeGreaterThan(0) - expect(declarations[0].is_important).toBe(true) + expect((declarations[0] as DeclarationNode).is_important).toBe(true) }) }) @@ -1366,8 +1367,8 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children let [decl1, decl2] = block.children - expect(decl1.name).toBe('color') - expect(decl2.name).toBe('margin') + expect((decl1 as DeclarationNode).name).toBe('color') + expect((decl2 as DeclarationNode).name).toBe('margin') }) }) @@ -1379,7 +1380,7 @@ describe('Parser', () => { let [charset, _body] = root.children expect(charset.type).toBe(NODE_AT_RULE) - expect(charset.name).toBe('charset') + expect((charset as AtRuleNode).name).toBe('charset') }) test('should parse @import with media query', () => { @@ -1387,7 +1388,7 @@ describe('Parser', () => { let parser = new Parser(source) let root = parser.parse() - let import_rule = root.first_child! + let import_rule = root.first_child! as AtRuleNode expect(import_rule.type).toBe(NODE_AT_RULE) expect(import_rule.name).toBe('import') }) @@ -1406,7 +1407,7 @@ describe('Parser', () => { let parser = new Parser(source) let root = parser.parse() - let font_face = root.first_child! + let font_face = root.first_child! as AtRuleNode expect(font_face.name).toBe('font-face') let block = font_face.block! expect(block.children.length).toBeGreaterThan(3) @@ -1417,7 +1418,7 @@ describe('Parser', () => { let parser = new Parser(source, { parse_atrule_preludes: false }) let root = parser.parse() - let keyframes = root.first_child! + let keyframes = root.first_child! as AtRuleNode let block = keyframes.block! expect(block.children.length).toBe(3) }) @@ -1427,7 +1428,7 @@ describe('Parser', () => { let parser = new Parser(source) let root = parser.parse() - let counter = root.first_child! + let counter = root.first_child! as AtRuleNode expect(counter.name).toBe('counter-style') let block = counter.block! expect(block.children.length).toBeGreaterThan(1) @@ -1438,7 +1439,7 @@ describe('Parser', () => { let parser = new Parser(source) let root = parser.parse() - let property = root.first_child! + let property = root.first_child! as AtRuleNode expect(property.name).toBe('property') }) }) @@ -1499,9 +1500,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(decl.value).toBe('blue') }) @@ -1512,9 +1513,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('padding') + expect((decl as DeclarationNode).name).toBe('padding') expect(decl.value).toBe('1rem 2rem 3rem 4rem') }) @@ -1525,9 +1526,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('background') + expect((decl as DeclarationNode).name).toBe('background') expect(decl.value).toBe('linear-gradient(to bottom, red, blue)') }) @@ -1538,9 +1539,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('width') + expect((decl as DeclarationNode).name).toBe('width') expect(decl.value).toBe('calc(100% - 2rem)') }) @@ -1551,9 +1552,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(decl.value).toBe('blue') expect(decl.is_important).toBe(true) }) @@ -1565,9 +1566,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(decl.value).toBe('blue') }) @@ -1578,9 +1579,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('--brand-color') + expect((decl as DeclarationNode).name).toBe('--brand-color') expect(decl.value).toBe('rgb(0% 10% 50% / 0.5)') }) @@ -1591,9 +1592,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(decl.value).toBe('var(--primary-color)') }) @@ -1604,9 +1605,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('transform') + expect((decl as DeclarationNode).name).toBe('transform') expect(decl.value).toBe('translate(calc(50% - 1rem), 0)') }) @@ -1617,9 +1618,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(decl.value).toBe('blue') }) @@ -1630,9 +1631,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('color') + expect((decl as DeclarationNode).name).toBe('color') expect(decl.value).toBe(null) }) @@ -1643,9 +1644,9 @@ describe('Parser', () => { let rule = root.first_child! let [_selector, block] = rule.children - let decl = block.first_child! + let decl = block.first_child! as DeclarationNode - expect(decl.name).toBe('background') + expect((decl as DeclarationNode).name).toBe('background') expect(decl.value).toBe('url("image.png")') }) }) @@ -1658,8 +1659,8 @@ describe('Parser', () => { let atrule = root.first_child! expect(atrule.type).toBe(NODE_AT_RULE) - expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('(min-width: 768px)') + expect((atrule as AtRuleNode).name).toBe('media') + expect((atrule as AtRuleNode).prelude).toBe('(min-width: 768px)') }) test('should extract complex media query prelude', () => { @@ -1668,8 +1669,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('screen and (min-width: 768px) and (max-width: 1024px)') + expect((atrule as AtRuleNode).name).toBe('media') + expect((atrule as AtRuleNode).prelude).toBe('screen and (min-width: 768px) and (max-width: 1024px)') }) test('should extract container query prelude', () => { @@ -1678,8 +1679,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('container') - expect(atrule.prelude).toBe('(width >= 200px)') + expect((atrule as AtRuleNode).name).toBe('container') + expect((atrule as AtRuleNode).prelude).toBe('(width >= 200px)') }) test('should extract supports query prelude', () => { @@ -1688,8 +1689,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('supports') - expect(atrule.prelude).toBe('(display: grid)') + expect((atrule as AtRuleNode).name).toBe('supports') + expect((atrule as AtRuleNode).prelude).toBe('(display: grid)') }) test('should extract import prelude', () => { @@ -1698,8 +1699,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('import') - expect(atrule.prelude).toBe('url("styles.css")') + expect((atrule as AtRuleNode).name).toBe('import') + expect((atrule as AtRuleNode).prelude).toBe('url("styles.css")') }) test('should handle at-rule without prelude', () => { @@ -1708,8 +1709,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('font-face') - expect(atrule.prelude).toBe(null) + expect((atrule as AtRuleNode).name).toBe('font-face') + expect((atrule as AtRuleNode).prelude).toBe(null) }) test('should extract layer prelude', () => { @@ -1718,8 +1719,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('layer') - expect(atrule.prelude).toBe('utilities') + expect((atrule as AtRuleNode).name).toBe('layer') + expect((atrule as AtRuleNode).prelude).toBe('utilities') }) test('should extract keyframes prelude', () => { @@ -1728,8 +1729,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('keyframes') - expect(atrule.prelude).toBe('slide-in') + expect((atrule as AtRuleNode).name).toBe('keyframes') + expect((atrule as AtRuleNode).prelude).toBe('slide-in') }) test('should handle prelude with extra whitespace', () => { @@ -1738,8 +1739,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('(min-width: 768px)') + expect((atrule as AtRuleNode).name).toBe('media') + expect((atrule as AtRuleNode).prelude).toBe('(min-width: 768px)') }) test('should extract charset prelude', () => { @@ -1748,8 +1749,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('charset') - expect(atrule.prelude).toBe('"UTF-8"') + expect((atrule as AtRuleNode).name).toBe('charset') + expect((atrule as AtRuleNode).prelude).toBe('"UTF-8"') }) test('should extract namespace prelude', () => { @@ -1758,8 +1759,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.name).toBe('namespace') - expect(atrule.prelude).toBe('svg url(http://www.w3.org/2000/svg)') + expect((atrule as AtRuleNode).name).toBe('namespace') + expect((atrule as AtRuleNode).prelude).toBe('svg url(http://www.w3.org/2000/svg)') }) test('should value and prelude be aliases for at-rules', () => { @@ -1768,8 +1769,8 @@ describe('Parser', () => { let root = parser.parse() let atrule = root.first_child! - expect(atrule.value).toBe(atrule.prelude) - expect(atrule.value).toBe('(min-width: 768px)') + expect((atrule as AtRuleNode).value).toBe((atrule as AtRuleNode).prelude) + expect((atrule as AtRuleNode).value).toBe('(min-width: 768px)') }) }) @@ -1777,22 +1778,22 @@ describe('Parser', () => { let css = `@layer test { a {} }` let sheet = parse(css) let atrule = sheet?.first_child - let rule = atrule?.block?.first_child + let rule = (atrule as AtRuleNode)?.block?.first_child test('atrule should have block', () => { expect(sheet.type).toBe(NODE_STYLESHEET) expect(atrule!.type).toBe(NODE_AT_RULE) - expect(atrule?.block?.type).toBe(NODE_BLOCK) + expect((atrule as AtRuleNode)?.block?.type).toBe(NODE_BLOCK) }) test('block children should be stylerule', () => { - expect(atrule!.block).not.toBeNull() + expect((atrule as AtRuleNode)!.block).not.toBeNull() expect(rule!.type).toBe(NODE_STYLE_RULE) expect(rule!.text).toBe('a {}') }) test('rule should have selectorlist + block', () => { - expect(rule!.block).not.toBeNull() + expect((rule as StyleRuleNode)!.block).not.toBeNull() expect(rule?.has_block).toBeTruthy() expect(rule?.has_declarations).toBeFalsy() expect(rule?.first_child!.type).toBe(NODE_SELECTOR_LIST) @@ -1811,20 +1812,20 @@ describe('Parser', () => { test('empty at-rule block should have empty text', () => { const parser = new Parser('@layer test {}') const root = parser.parse() - const atRule = root.first_child! + const atRule = root.first_child! as AtRuleNode expect(atRule.has_block).toBe(true) - expect(atRule.block!.text).toBe('') + expect((atRule as AtRuleNode).block!.text).toBe('') expect(atRule.text).toBe('@layer test {}') // at-rule includes braces }) test('at-rule block with content should exclude braces', () => { const parser = new Parser('@layer test { .foo { color: red; } }') const root = parser.parse() - const atRule = root.first_child! + const atRule = root.first_child! as AtRuleNode expect(atRule.has_block).toBe(true) - expect(atRule.block!.text).toBe(' .foo { color: red; } ') + expect((atRule as AtRuleNode).block!.text).toBe(' .foo { color: red; } ') expect(atRule.text).toBe('@layer test { .foo { color: red; } }') // at-rule includes braces }) @@ -1834,7 +1835,7 @@ describe('Parser', () => { const styleRule = root.first_child! expect(styleRule.has_block).toBe(true) - expect(styleRule.block!.text).toBe('') + expect((styleRule as StyleRuleNode).block!.text).toBe('') expect(styleRule.text).toBe('body {}') // style rule includes braces }) @@ -1844,7 +1845,7 @@ describe('Parser', () => { const styleRule = root.first_child! expect(styleRule.has_block).toBe(true) - expect(styleRule.block!.text).toBe(' color: red; ') + expect((styleRule as StyleRuleNode).block!.text).toBe(' color: red; ') expect(styleRule.text).toBe('body { color: red; }') // style rule includes braces }) @@ -1852,9 +1853,9 @@ describe('Parser', () => { const parser = new Parser('.parent { .child { margin: 0; } }') const root = parser.parse() const parent = root.first_child! - const parentBlock = parent.block! + const parentBlock = (parent as StyleRuleNode).block! const child = parentBlock.first_child! - const childBlock = child.block! + const childBlock = (child as StyleRuleNode).block! expect(parentBlock.text).toBe(' .child { margin: 0; } ') expect(childBlock.text).toBe(' margin: 0; ') @@ -1863,9 +1864,9 @@ describe('Parser', () => { test('at-rule with multiple declarations should exclude braces', () => { const parser = new Parser('@font-face { font-family: "Test"; src: url(test.woff); }') const root = parser.parse() - const atRule = root.first_child! + const atRule = root.first_child! as AtRuleNode - expect(atRule.block!.text).toBe(' font-family: "Test"; src: url(test.woff); ') + expect((atRule as AtRuleNode).block!.text).toBe(' font-family: "Test"; src: url(test.woff); ') }) test('media query with nested rules should exclude braces', () => { @@ -1873,7 +1874,7 @@ describe('Parser', () => { const root = parser.parse() const mediaRule = root.first_child! - expect(mediaRule.block!.text).toBe(' body { color: blue; } ') + expect((mediaRule as AtRuleNode).block!.text).toBe(' body { color: blue; } ') }) test('block with no whitespace should be empty', () => { @@ -1881,7 +1882,7 @@ describe('Parser', () => { const root = parser.parse() const styleRule = root.first_child! - expect(styleRule.block!.text).toBe('') + expect((styleRule as StyleRuleNode).block!.text).toBe('') }) test('block with only whitespace should preserve whitespace', () => { @@ -1889,7 +1890,7 @@ describe('Parser', () => { const root = parser.parse() const styleRule = root.first_child! - expect(styleRule.block!.text).toBe(' \n\t ') + expect((styleRule as StyleRuleNode).block!.text).toBe(' \n\t ') }) }) @@ -1898,11 +1899,11 @@ describe('Parser', () => { let css = `@container (width > 0) { div { color: red; } }` let ast = parse(css) - const container = ast.first_child! + const container = ast.first_child! as AtRuleNode expect(container.type).toBe(NODE_AT_RULE) - expect(container.name).toBe('container') + expect((container as AtRuleNode).name).toBe('container') - const containerBlock = container.block! + const containerBlock = (container as AtRuleNode).block! const rule = containerBlock.first_child! expect(rule.type).toBe(NODE_STYLE_RULE) }) @@ -1911,8 +1912,8 @@ describe('Parser', () => { let css = `@container (width > 0) { ul:has(li) { color: red; } }` let ast = parse(css) - const container = ast.first_child! - const containerBlock = container.block! + const container = ast.first_child! as AtRuleNode + const containerBlock = (container as AtRuleNode).block! const rule = containerBlock.first_child! expect(rule.type).toBe(NODE_STYLE_RULE) }) @@ -1937,21 +1938,21 @@ describe('Parser', () => { expect(ast.has_children).toBe(true) // First child: @layer what - const layer = ast.first_child! + const layer = ast.first_child! as AtRuleNode expect(layer.type).toBe(NODE_AT_RULE) - expect(layer.name).toBe('layer') + expect((layer as AtRuleNode).name).toBe('layer') expect(layer.prelude).toBe('what') expect(layer.has_block).toBe(true) // Inside @layer: @container (width > 0) const container = layer.block!.first_child! expect(container.type).toBe(NODE_AT_RULE) - expect(container.name).toBe('container') - expect(container.prelude).toBe('(width > 0)') + expect((container as AtRuleNode).name).toBe('container') + expect((container as AtRuleNode).prelude).toBe('(width > 0)') expect(container.has_block).toBe(true) // Inside @container: ul:has(:nth-child(1 of li)) - const ulRule = container.block!.first_child! + const ulRule = (container as AtRuleNode).block!.first_child! expect(ulRule.type).toBe(NODE_STYLE_RULE) expect(ulRule.has_block).toBe(true) @@ -1967,7 +1968,7 @@ describe('Parser', () => { expect(selectorParts[0].text).toBe('ul') // Inside ul rule: @media (height > 0) - const media = ulRule.block!.first_child! + const media = (ulRule as StyleRuleNode).block!.first_child! as AtRuleNode expect(media.type).toBe(NODE_AT_RULE) expect(media.name).toBe('media') expect(media.prelude).toBe('(height > 0)') @@ -1989,7 +1990,7 @@ describe('Parser', () => { expect(nestingParts[0].text).toBe('&') // Inside &:hover: --is: this declaration - const declaration = nestingRule.block!.first_child! + const declaration = (nestingRule as StyleRuleNode).block!.first_child! as DeclarationNode expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.property).toBe('--is') expect(declaration.value).toBe('this') diff --git a/src/parser.ts b/src/parser.ts index e6d3819..8c208e1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,7 +14,7 @@ import { FLAG_VENDOR_PREFIXED, FLAG_HAS_DECLARATIONS, } from './arena' -import { CSSNode } from './css-node' +import { CSSNode, StylesheetNode } from './css-node' import { ValueParser } from './value-parser' import { SelectorParser } from './selector-parser' import { AtRulePreludeParser } from './at-rule-prelude-parser' @@ -100,7 +100,7 @@ export class Parser { } // Parse the entire stylesheet and return the root CSSNode - parse(): CSSNode { + parse(): StylesheetNode { // Start by getting the first token this.next_token() @@ -124,7 +124,7 @@ export class Parser { } // Return wrapped node - return new CSSNode(this.arena, this.source, stylesheet) + return CSSNode.from(this.arena, this.source, stylesheet) as StylesheetNode } // Parse a rule (style rule or at-rule) diff --git a/src/selector-parser.test.ts b/src/selector-parser.test.ts index efa49cf..3def8c4 100644 --- a/src/selector-parser.test.ts +++ b/src/selector-parser.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { SelectorParser } from './selector-parser' import { CSSDataArena } from './arena' +import { SelectorNthNode } from './css-node' import { NODE_SELECTOR, NODE_SELECTOR_LIST, @@ -965,8 +966,8 @@ describe('SelectorParser', () => { expect(nth_child.children).toHaveLength(1) const anplusb = nth_child.first_child! expect(anplusb.type).toBe(NODE_SELECTOR_NTH) - expect(anplusb.nth_a).toBe(null) // No 'a' coefficient, just 'b' - expect(anplusb.nth_b).toBe('1') + expect((anplusb as SelectorNthNode).nth_a).toBe(null) // No 'a' coefficient, just 'b' + expect((anplusb as SelectorNthNode).nth_b).toBe('1') }) it('should parse :nth-child(2n+1)', () => { @@ -982,8 +983,8 @@ describe('SelectorParser', () => { expect(nth_child.children).toHaveLength(1) const anplusb = nth_child.first_child! expect(anplusb.type).toBe(NODE_SELECTOR_NTH) - expect(anplusb.nth_a).toBe('2n') - expect(anplusb.nth_b).toBe('1') + expect((anplusb as SelectorNthNode).nth_a).toBe('2n') + expect((anplusb as SelectorNthNode).nth_b).toBe('1') expect(anplusb.text).toBe('2n+1') }) @@ -1006,8 +1007,8 @@ describe('SelectorParser', () => { expect(nth_of.children).toHaveLength(2) const anplusb = nth_of.first_child! expect(anplusb.type).toBe(NODE_SELECTOR_NTH) - expect(anplusb.nth_a).toBe('2n') - expect(anplusb.nth_b).toBe(null) + expect((anplusb as SelectorNthNode).nth_a).toBe('2n') + expect((anplusb as SelectorNthNode).nth_b).toBe(null) // Second child is the selector list const selectorList = nth_of.children[1] diff --git a/src/stylerule-structure.test.ts b/src/stylerule-structure.test.ts index a31b833..7fc8ae8 100644 --- a/src/stylerule-structure.test.ts +++ b/src/stylerule-structure.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest' import { Parser } from './parser' import { NODE_STYLE_RULE, NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_AT_RULE } from './arena' +import { BlockNode } from './css-node' describe('StyleRule Structure', () => { test('should have selector list as first child, followed by declarations', () => { @@ -231,7 +232,7 @@ describe('StyleRule Structure', () => { // Rule should have selector list + empty block const block = rule.first_child!.next_sibling expect(block).not.toBeNull() - expect(block!.is_empty).toBe(true) + expect((block as BlockNode)!.is_empty).toBe(true) }) test('block children should be correctly linked via next_sibling with declarations only', () => { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..24ab014 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,118 @@ +// Type definitions for the CSS parser +// Union type of all possible node types returned by tree traversal + +// Use type-only imports to avoid circular dependencies +import type { StylesheetNode } from './nodes/stylesheet-node' +import type { StyleRuleNode } from './nodes/style-rule-node' +import type { AtRuleNode } from './nodes/at-rule-node' +import type { DeclarationNode } from './nodes/declaration-node' +import type { SelectorNode } from './nodes/selector-node' +import type { CommentNode } from './nodes/comment-node' +import type { BlockNode } from './nodes/block-node' + +// Value nodes +import type { + ValueKeywordNode, + ValueNumberNode, + ValueDimensionNode, + ValueStringNode, + ValueColorNode, + ValueFunctionNode, + ValueOperatorNode, +} from './nodes/value-nodes' + +// Selector nodes +import type { + SelectorListNode, + SelectorTypeNode, + SelectorUniversalNode, + SelectorNestingNode, + SelectorCombinatorNode, +} from './nodes/selector-nodes-simple' + +import type { + SelectorClassNode, + SelectorIdNode, + SelectorLangNode, +} from './nodes/selector-nodes-named' + +import type { SelectorAttributeNode } from './nodes/selector-attribute-node' + +import type { + SelectorPseudoClassNode, + SelectorPseudoElementNode, +} from './nodes/selector-pseudo-nodes' + +import type { + SelectorNthNode, + SelectorNthOfNode, +} from './nodes/selector-nth-nodes' + +// Prelude nodes +import type { + PreludeMediaQueryNode, + PreludeMediaFeatureNode, + PreludeMediaTypeNode, +} from './nodes/prelude-media-nodes' + +import type { + PreludeContainerQueryNode, + PreludeSupportsQueryNode, + PreludeLayerNameNode, + PreludeIdentifierNode, + PreludeOperatorNode, +} from './nodes/prelude-container-supports-nodes' + +import type { + PreludeImportUrlNode, + PreludeImportLayerNode, + PreludeImportSupportsNode, +} from './nodes/prelude-import-nodes' + +/** + * Union type of all possible CSS node types + * Used for tree traversal return types (first_child, next_sibling, children, etc.) + */ +export type AnyNode = + // Core structure nodes (7) + | StylesheetNode + | StyleRuleNode + | AtRuleNode + | DeclarationNode + | SelectorNode + | CommentNode + | BlockNode + // Value nodes (7) + | ValueKeywordNode + | ValueNumberNode + | ValueDimensionNode + | ValueStringNode + | ValueColorNode + | ValueFunctionNode + | ValueOperatorNode + // Selector nodes (13) + | SelectorListNode + | SelectorTypeNode + | SelectorClassNode + | SelectorIdNode + | SelectorAttributeNode + | SelectorPseudoClassNode + | SelectorPseudoElementNode + | SelectorCombinatorNode + | SelectorUniversalNode + | SelectorNestingNode + | SelectorNthNode + | SelectorNthOfNode + | SelectorLangNode + // Prelude nodes (11) + | PreludeMediaQueryNode + | PreludeMediaFeatureNode + | PreludeMediaTypeNode + | PreludeContainerQueryNode + | PreludeSupportsQueryNode + | PreludeLayerNameNode + | PreludeIdentifierNode + | PreludeOperatorNode + | PreludeImportUrlNode + | PreludeImportLayerNode + | PreludeImportSupportsNode diff --git a/src/value-parser.test.ts b/src/value-parser.test.ts index a324641..85460b9 100644 --- a/src/value-parser.test.ts +++ b/src/value-parser.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { Parser } from './parser' +import { DeclarationNode, ValueDimensionNode, ValueFunctionNode } from './css-node' import { NODE_VALUE_KEYWORD, NODE_VALUE_NUMBER, @@ -16,7 +17,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: red; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child // selector → block → declaration + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined as DeclarationNode // selector → block → declaration expect(decl?.value).toBe('red') expect(decl?.values).toHaveLength(1) @@ -28,7 +29,7 @@ describe('ValueParser', () => { const parser = new Parser('body { opacity: 0.5; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('0.5') expect(decl?.values).toHaveLength(1) @@ -40,35 +41,35 @@ describe('ValueParser', () => { const parser = new Parser('body { width: 100px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('100px') expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) expect(decl?.values[0].text).toBe('100px') - expect(decl?.values[0].value).toBe(100) - expect(decl?.values[0].unit).toBe('px') + expect((decl?.values[0] as ValueDimensionNode)?.value).toBe(100) + expect((decl?.values[0] as ValueDimensionNode)?.unit).toBe('px') }) it('should parse px dimension values', () => { const parser = new Parser('body { font-size: 3em; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('3em') expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) expect(decl?.values[0].text).toBe('3em') - expect(decl?.values[0].value).toBe(3) - expect(decl?.values[0].unit).toBe('em') + expect((decl?.values[0] as ValueDimensionNode)?.value).toBe(3) + expect((decl?.values[0] as ValueDimensionNode)?.unit).toBe('em') }) it('should parse percentage values', () => { const parser = new Parser('body { width: 50%; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('50%') expect(decl?.values).toHaveLength(1) @@ -80,7 +81,7 @@ describe('ValueParser', () => { const parser = new Parser('body { content: "hello"; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('"hello"') expect(decl?.values).toHaveLength(1) @@ -92,7 +93,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: #ff0000; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('#ff0000') expect(decl?.values).toHaveLength(1) @@ -106,7 +107,7 @@ describe('ValueParser', () => { const parser = new Parser('body { font-family: Arial, sans-serif; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(3) expect(decl?.values[0].type).toBe(NODE_VALUE_KEYWORD) @@ -121,7 +122,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: 10px 20px 30px 40px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(4) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -138,7 +139,7 @@ describe('ValueParser', () => { const parser = new Parser('body { border: 1px solid red; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(3) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -155,11 +156,11 @@ describe('ValueParser', () => { const parser = new Parser('body { color: rgb(255, 0, 0); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('rgb') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('rgb') expect(decl?.values[0].text).toBe('rgb(255, 0, 0)') }) @@ -167,7 +168,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: rgb(255, 0, 0); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined const func = decl?.values[0] expect(func?.children).toHaveLength(5) @@ -187,11 +188,11 @@ describe('ValueParser', () => { 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 decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('calc') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('calc') expect(decl?.values[0].children).toHaveLength(3) expect(decl?.values[0].children[0].type).toBe(NODE_VALUE_DIMENSION) expect(decl?.values[0].children[0].text).toBe('100%') @@ -205,11 +206,11 @@ describe('ValueParser', () => { 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 decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('var') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('var') expect(decl?.values[0].children).toHaveLength(1) expect(decl?.values[0].children[0].type).toBe(NODE_VALUE_KEYWORD) expect(decl?.values[0].children[0].text).toBe('--primary-color') @@ -219,11 +220,11 @@ describe('ValueParser', () => { const parser = new Parser('body { background: url("image.png"); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('url') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('url') expect(decl?.values[0].children).toHaveLength(1) expect(decl?.values[0].children[0].type).toBe(NODE_VALUE_STRING) expect(decl?.values[0].children[0].text).toBe('"image.png"') @@ -235,11 +236,11 @@ describe('ValueParser', () => { const parser = new Parser('body { background: url("bg.png") no-repeat center center / cover; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values.length).toBeGreaterThan(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('url') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('url') expect(decl?.values[1].type).toBe(NODE_VALUE_KEYWORD) expect(decl?.values[1].text).toBe('no-repeat') }) @@ -248,27 +249,27 @@ describe('ValueParser', () => { const parser = new Parser('body { transform: translateX(10px) rotate(45deg); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(2) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('translateX') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('translateX') expect(decl?.values[1].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[1].name).toBe('rotate') + expect((decl?.values[1] as ValueFunctionNode)?.name).toBe('rotate') }) it('should parse filter value', () => { const parser = new Parser('body { filter: blur(5px) brightness(1.2); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(2) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[0].name).toBe('blur') + expect((decl?.values[0] as ValueFunctionNode)?.name).toBe('blur') expect(decl?.values[0].children[0].text).toBe('5px') expect(decl?.values[1].type).toBe(NODE_VALUE_FUNCTION) - expect(decl?.values[1].name).toBe('brightness') + expect((decl?.values[1] as ValueFunctionNode)?.name).toBe('brightness') expect(decl?.values[1].children[0].text).toBe('1.2') }) }) @@ -278,7 +279,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: ; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBeNull() expect(decl?.values).toHaveLength(0) @@ -288,7 +289,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: red !important; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.value).toBe('red') expect(decl?.values).toHaveLength(1) @@ -301,7 +302,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: -10px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -312,7 +313,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: 0px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -323,7 +324,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: 0; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_NUMBER) @@ -336,7 +337,7 @@ describe('ValueParser', () => { const parser = new Parser('body { font-family: Arial, sans-serif; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined expect(decl?.values[1].type).toBe(NODE_VALUE_OPERATOR) expect(decl?.values[1].text).toBe(',') @@ -346,7 +347,7 @@ describe('ValueParser', () => { 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 decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined const func = decl?.values[0] expect(func?.children[1].type).toBe(NODE_VALUE_OPERATOR) @@ -357,7 +358,7 @@ describe('ValueParser', () => { const parser = new Parser('body { width: calc(1px + 2px * 3px / 4px - 5px); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + const decl = rule?.first_child?.next_sibling?.first_child as DeclarationNode | undefined const func = decl?.values[0] const operators = func?.children.filter((n) => n.type === NODE_VALUE_OPERATOR) diff --git a/src/walk.test.ts b/src/walk.test.ts index aa3bb33..0d3bd45 100644 --- a/src/walk.test.ts +++ b/src/walk.test.ts @@ -12,6 +12,7 @@ import { NODE_VALUE_DIMENSION, } from './parser' import { walk, walk_enter_leave } from './walk' +import { DeclarationNode } from './css-node' describe('walk', () => { it('should visit single node', () => { @@ -138,7 +139,7 @@ describe('walk', () => { walk(root, (node) => { if (node.type === NODE_DECLARATION) { - const name = node.name + const name = (node as DeclarationNode).name if (name) properties.push(name) } }) diff --git a/src/walk.ts b/src/walk.ts index 4a055f3..6c617d5 100644 --- a/src/walk.ts +++ b/src/walk.ts @@ -1,14 +1,33 @@ // AST walker - depth-first traversal -import type { CSSNode } from './css-node' +import type { AnyNode } from './types' -type WalkCallback = (node: CSSNode, depth: number) => void +type WalkCallback = (node: AnyNode, depth: number) => void /** * Walk the AST in depth-first order, calling the callback for each node + * * @param node - The root node to start walking from * @param callback - Function to call for each node visited. Receives the node and its depth (0 for root) + * + * @example + * // Find all declarations + * import { parse, DeclarationNode } from '@projectwallace/css-parser' + * const ast = parse('div { color: red; }') + * walk(ast, (node) => { + * if (node instanceof DeclarationNode) { + * console.log(node.property, node.valueText) + * } + * }) + * + * @example + * // Count nodes by type + * const counts = new Map() + * walk(ast, (node) => { + * const typename = node.constructor.name + * counts.set(typename, (counts.get(typename) || 0) + 1) + * }) */ -export function walk(node: CSSNode, callback: WalkCallback, depth = 0): void { +export function walk(node: AnyNode, callback: WalkCallback, depth = 0): void { // Call callback for current node callback(node, depth) @@ -22,7 +41,7 @@ export function walk(node: CSSNode, callback: WalkCallback, depth = 0): void { const NOOP = function () {} -type WalkEnterLeaveCallback = (node: CSSNode) => void +type WalkEnterLeaveCallback = (node: AnyNode) => void interface WalkEnterLeaveOptions { enter?: WalkEnterLeaveCallback @@ -31,10 +50,27 @@ interface WalkEnterLeaveOptions { /** * Walk the AST in depth-first order, calling enter before visiting children and leave after + * * @param node - The root node to start walking from * @param options - Object with optional enter and leave callback functions + * + * @example + * // Transform the AST + * import { parse, DeclarationNode } from '@projectwallace/css-parser' + * const ast = parse('div { color: red; }') + * const declarations = [] + * walk_enter_leave(ast, { + * enter(node) { + * if (node instanceof DeclarationNode) { + * declarations.push(node) + * } + * }, + * leave(node) { + * // Cleanup after processing node and its children + * } + * }) */ -export function walk_enter_leave(node: CSSNode, { enter = NOOP, leave = NOOP }: WalkEnterLeaveOptions = {}) { +export function walk_enter_leave(node: AnyNode, { enter = NOOP, leave = NOOP }: WalkEnterLeaveOptions = {}) { // Call enter callback before processing children enter(node) diff --git a/vite.config.ts b/vite.config.ts index 8580d21..0c76aeb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'parse-selector': './src/parse-selector.ts', 'parse-atrule-prelude': './src/parse-atrule-prelude.ts', 'parse-anplusb': './src/parse-anplusb.ts', + 'nodes/index': './src/nodes/index.ts', }, formats: ['es'], },