From f182c862899decc72608415f61e8cfeb79bade7f Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 3 Jan 2026 17:24:06 +0100 Subject: [PATCH] breaking: add intermedia `AtrulePrelude` node type --- src/api.test.ts | 42 +++- src/arena.ts | 1 + src/constants.ts | 3 + src/css-node.ts | 24 +- src/parse-atrule-prelude.test.ts | 375 ++++++++++++++++--------------- src/parse.test.ts | 44 ++-- src/parse.ts | 40 +++- 7 files changed, 297 insertions(+), 232 deletions(-) diff --git a/src/api.test.ts b/src/api.test.ts index 0a9a358..1f6c65b 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -76,7 +76,28 @@ describe('CSSNode', () => { expect(media.type).toBe(AT_RULE) expect(media.has_prelude).toBe(true) - expect(media.prelude).toBe('(min-width: 768px)') + expect(media.prelude).not.toBeNull() + expect(media.prelude?.text).toBe('(min-width: 768px)') + }) + + test('prelude node can be walked to access structured children', () => { + const source = '@media screen and (min-width: 768px) { }' + const root = parse(source) + const media = root.first_child! + const prelude = media.prelude! + + // Prelude is a wrapper node containing the structured children + expect(prelude).not.toBeNull() + expect(prelude.has_children).toBe(true) + + // Can iterate over prelude children (media queries) + const children = [...prelude] + expect(children.length).toBeGreaterThan(0) + + // Can access structured nodes within prelude + const mediaQuery = prelude.first_child! + expect(mediaQuery.type_name).toBe('MediaQuery') + expect(mediaQuery.text).toBe('screen and (min-width: 768px)') }) test('should return true for @supports with prelude', () => { @@ -86,7 +107,8 @@ describe('CSSNode', () => { expect(supports.type).toBe(AT_RULE) expect(supports.has_prelude).toBe(true) - expect(supports.prelude).toBe('(display: grid)') + expect(supports.prelude).not.toBeNull() + expect(supports.prelude?.text).toBe('(display: grid)') }) test('should return true for @layer with name', () => { @@ -96,7 +118,8 @@ describe('CSSNode', () => { expect(layer.type).toBe(AT_RULE) expect(layer.has_prelude).toBe(true) - expect(layer.prelude).toBe('utilities') + expect(layer.prelude).not.toBeNull() + expect(layer.prelude?.text).toBe('utilities') }) test('should return false for @layer without name', () => { @@ -116,7 +139,8 @@ describe('CSSNode', () => { expect(keyframes.type).toBe(AT_RULE) expect(keyframes.has_prelude).toBe(true) - expect(keyframes.prelude).toBe('fadeIn') + expect(keyframes.prelude).not.toBeNull() + expect(keyframes.prelude?.text).toBe('fadeIn') }) test('should return false for @font-face without prelude', () => { @@ -573,9 +597,10 @@ describe('CSSNode', () => { const source = '@media screen and (min-width: 768px) { body { color: red; } }' const root = parse(source) const media = root.first_child! - const prelude = media.first_child! + const preludeWrapper = media.prelude! + const mediaQuery = preludeWrapper.first_child! - expect(prelude.type_name).toBe('MediaQuery') + expect(mediaQuery.type_name).toBe('MediaQuery') }) }) @@ -1082,7 +1107,7 @@ describe('CSSNode', () => { }) test('extracts at-rule properties', () => { - const ast = parse('@media screen { }', { parse_atrule_preludes: false }) + const ast = parse('@media screen { }') const atrule = ast.first_child! const clone = atrule.clone({ deep: false }) @@ -1090,7 +1115,8 @@ describe('CSSNode', () => { expect(clone.type).toBe(AT_RULE) expect(clone.type_name).toBe('Atrule') expect(clone.name).toBe('media') - expect(clone.prelude).toBe('screen') + // Prelude is now a child node, not extracted as property + expect(clone.children).toEqual([]) }) test('extracts dimension value with unit', () => { diff --git a/src/arena.ts b/src/arena.ts index 147be85..14f5a10 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -88,6 +88,7 @@ export const SUPPORTS_QUERY = 36 // supports query: (display: flex) export const LAYER_NAME = 37 // layer name: base, components export const PRELUDE_OPERATOR = 38 // logical operator: and, or, not export const FEATURE_RANGE = 39 // Range syntax: (50px <= width <= 100px) +export const AT_RULE_PRELUDE = 40 // Wrapper for at-rule prelude children // Flag constants (bit-packed in 1 byte) export const FLAG_IMPORTANT = 1 << 0 // Has !important diff --git a/src/constants.ts b/src/constants.ts index 5f4090f..173ba64 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,6 +40,7 @@ import { LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, + AT_RULE_PRELUDE, FLAG_IMPORTANT, ATTR_OPERATOR_NONE, ATTR_OPERATOR_EQUAL, @@ -92,6 +93,7 @@ export { LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, + AT_RULE_PRELUDE, FLAG_IMPORTANT, ATTR_OPERATOR_NONE, ATTR_OPERATOR_EQUAL, @@ -149,4 +151,5 @@ export const NODE_TYPES = { LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, + AT_RULE_PRELUDE, } as const diff --git a/src/css-node.ts b/src/css-node.ts index f3858aa..cea4d5a 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -39,6 +39,7 @@ import { LAYER_NAME, PRELUDE_OPERATOR, FEATURE_RANGE, + AT_RULE_PRELUDE, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -90,6 +91,7 @@ export const TYPE_NAMES = { [LAYER_NAME]: 'Layer', [PRELUDE_OPERATOR]: 'Operator', [FEATURE_RANGE]: 'MediaFeatureRange', + [AT_RULE_PRELUDE]: 'AtrulePrelude', } as const export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown' @@ -134,6 +136,7 @@ export type CSSNodeType = | typeof LAYER_NAME | typeof PRELUDE_OPERATOR | typeof FEATURE_RANGE + | typeof AT_RULE_PRELUDE // Options for cloning nodes export interface CloneOptions { @@ -304,12 +307,16 @@ export class CSSNode { } /** - * 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 the prelude node (for at-rules: structured prelude with media queries, layer names, etc.) + * Returns the AT_RULE_PRELUDE wrapper node containing prelude children, or null if no prelude */ - get prelude(): string | null { - let val = this.value - return typeof val === 'string' ? val : null + get prelude(): CSSNode | null { + if (this.type !== AT_RULE) return null + let first = this.first_child + if (first && first.type === AT_RULE_PRELUDE) { + return first + } + return null } /** @@ -734,10 +741,9 @@ export class CSSNode { if (this.unit) plain.unit = this.unit } - // 4. Extract prelude for at-rules - if (this.type === AT_RULE && this.prelude) { - plain.prelude = this.prelude - } + // 4. At-rule preludes are now child nodes (AT_RULE_PRELUDE wrapper) + // They will be cloned as part of children in deep clones + // No special extraction needed - breaking change from string to CSSNode // 5. Extract flags if (this.type === DECLARATION) { diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 0a7a1fd..3c5e736 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -24,7 +24,8 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const prelude = atRule.prelude! + const mediaQuery = prelude.first_child! expect(mediaQuery.type).toBe(MEDIA_QUERY) expect(mediaQuery.start).toBe(7) @@ -36,7 +37,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! expect(mediaQuery.type).toBe(MEDIA_QUERY) expect(mediaQuery.start).toBe(7) @@ -48,7 +49,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! expect(mediaQuery.type).toBe(MEDIA_QUERY) expect(mediaQuery.start).toBe(7) @@ -62,7 +63,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const mediaType = mediaQuery.first_child! expect(mediaType.type).toBe(MEDIA_TYPE) @@ -77,7 +78,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const mediaFeature = mediaQuery.first_child! expect(mediaFeature.type).toBe(MEDIA_FEATURE) @@ -92,7 +93,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@container (min-width: 400px) { }' const ast = parse(css) const atRule = ast.first_child! - const containerQuery = atRule.first_child! + const containerQuery = atRule.prelude!.first_child! expect(containerQuery.type).toBe(CONTAINER_QUERY) expect(containerQuery.start).toBe(11) @@ -104,7 +105,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@container sidebar (min-width: 400px) { }' const ast = parse(css) const atRule = ast.first_child! - const containerQuery = atRule.first_child! + const containerQuery = atRule.prelude!.first_child! expect(containerQuery.type).toBe(CONTAINER_QUERY) expect(containerQuery.start).toBe(11) @@ -118,7 +119,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@supports (display: flex) { }' const ast = parse(css) const atRule = ast.first_child! - const supportsQuery = atRule.first_child! + const supportsQuery = atRule.prelude!.first_child! expect(supportsQuery.type).toBe(SUPPORTS_QUERY) expect(supportsQuery.start).toBe(10) @@ -132,7 +133,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@layer utilities { }' const ast = parse(css) const atRule = ast.first_child! - const layerName = atRule.first_child! + const layerName = atRule.prelude!.first_child! expect(layerName.type).toBe(LAYER_NAME) expect(layerName.start).toBe(7) @@ -146,7 +147,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@keyframes slidein { }' const ast = parse(css) const atRule = ast.first_child! - const identifier = atRule.first_child! + const identifier = atRule.prelude!.first_child! expect(identifier.type).toBe(IDENTIFIER) expect(identifier.start).toBe(11) @@ -158,7 +159,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@property --my-color { }' const ast = parse(css) const atRule = ast.first_child! - const identifier = atRule.first_child! + const identifier = atRule.prelude!.first_child! expect(identifier.type).toBe(IDENTIFIER) expect(identifier.start).toBe(10) @@ -172,7 +173,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const operator = mediaQuery.children[1] expect(operator.type).toBe(PRELUDE_OPERATOR) @@ -187,7 +188,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@import url("styles.css");' const ast = parse(css) const atRule = ast.first_child! - const url = atRule.first_child! + const url = atRule.prelude!.first_child! expect(url.type).toBe(URL) expect(url.start).toBe(8) @@ -199,7 +200,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@import "styles.css";' const ast = parse(css) const atRule = ast.first_child! - const url = atRule.first_child! + const url = atRule.prelude!.first_child! expect(url.type).toBe(URL) expect(url.start).toBe(8) @@ -214,7 +215,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! expect(mediaQuery.type).toBe(MEDIA_QUERY) }) @@ -223,7 +224,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const mediaType = mediaQuery.first_child! expect(mediaType.type).toBe(MEDIA_TYPE) @@ -233,7 +234,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const mediaFeature = mediaQuery.first_child! expect(mediaFeature.type).toBe(MEDIA_FEATURE) @@ -243,7 +244,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@container (min-width: 400px) { }' const ast = parse(css) const atRule = ast.first_child! - const containerQuery = atRule.first_child! + const containerQuery = atRule.prelude!.first_child! expect(containerQuery.type).toBe(CONTAINER_QUERY) }) @@ -252,7 +253,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@supports (display: flex) { }' const ast = parse(css) const atRule = ast.first_child! - const supportsQuery = atRule.first_child! + const supportsQuery = atRule.prelude!.first_child! expect(supportsQuery.type).toBe(SUPPORTS_QUERY) }) @@ -261,7 +262,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@layer utilities { }' const ast = parse(css) const atRule = ast.first_child! - const layerName = atRule.first_child! + const layerName = atRule.prelude!.first_child! expect(layerName.type).toBe(LAYER_NAME) }) @@ -270,7 +271,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@keyframes slidein { }' const ast = parse(css) const atRule = ast.first_child! - const identifier = atRule.first_child! + const identifier = atRule.prelude!.first_child! expect(identifier.type).toBe(IDENTIFIER) }) @@ -279,7 +280,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@property --my-color { }' const ast = parse(css) const atRule = ast.first_child! - const identifier = atRule.first_child! + const identifier = atRule.prelude!.first_child! expect(identifier.type).toBe(IDENTIFIER) }) @@ -288,7 +289,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const operator = mediaQuery.children[1] expect(operator.type).toBe(PRELUDE_OPERATOR) @@ -298,7 +299,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@import url("styles.css");' const ast = parse(css) const atRule = ast.first_child! - const url = atRule.first_child! + const url = atRule.prelude!.first_child! expect(url.type).toBe(URL) }) @@ -309,7 +310,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! expect(mediaQuery.type_name).toBe('MediaQuery') }) @@ -318,7 +319,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const mediaType = mediaQuery.first_child! expect(mediaType.type_name).toBe('MediaType') @@ -328,7 +329,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const mediaFeature = mediaQuery.first_child! expect(mediaFeature.type_name).toBe('Feature') @@ -338,7 +339,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@container (min-width: 400px) { }' const ast = parse(css) const atRule = ast.first_child! - const containerQuery = atRule.first_child! + const containerQuery = atRule.prelude!.first_child! expect(containerQuery.type_name).toBe('ContainerQuery') }) @@ -347,7 +348,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@supports (display: flex) { }' const ast = parse(css) const atRule = ast.first_child! - const supportsQuery = atRule.first_child! + const supportsQuery = atRule.prelude!.first_child! expect(supportsQuery.type_name).toBe('SupportsQuery') }) @@ -356,7 +357,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@layer utilities { }' const ast = parse(css) const atRule = ast.first_child! - const layerName = atRule.first_child! + const layerName = atRule.prelude!.first_child! expect(layerName.type_name).toBe('Layer') }) @@ -365,7 +366,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@keyframes slidein { }' const ast = parse(css) const atRule = ast.first_child! - const identifier = atRule.first_child! + const identifier = atRule.prelude!.first_child! expect(identifier.type_name).toBe('Identifier') }) @@ -374,7 +375,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) const atRule = ast.first_child! - const mediaQuery = atRule.first_child! + const mediaQuery = atRule.prelude!.first_child! const operator = mediaQuery.children[1] expect(operator.type_name).toBe('Operator') @@ -384,7 +385,7 @@ describe('At-Rule Prelude Nodes', () => { const css = '@import url("styles.css");' const ast = parse(css) const atRule = ast.first_child! - const url = atRule.first_child! + const url = atRule.prelude!.first_child! expect(url.type_name).toBe('Url') }) @@ -395,13 +396,13 @@ describe('At-Rule Prelude Nodes', () => { it('should parse media type', () => { const css = '@media screen { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('media') // Should have prelude children - const children = atRule?.children || [] + const children = atRule.prelude?.children || [] expect(children.length).toBeGreaterThan(0) // First child should be a media query @@ -415,8 +416,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse media feature', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[0].type).toBe(MEDIA_QUERY) @@ -432,8 +433,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const queryChildren = children[0].children const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) @@ -443,8 +444,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[0].type).toBe(MEDIA_QUERY) @@ -458,8 +459,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const queryChildren = children[0].children const features = queryChildren.filter((c) => c.type === MEDIA_FEATURE) @@ -469,8 +470,8 @@ describe('At-Rule Prelude Nodes', () => { it('should extract feature name from standard feature', () => { const css = '@media (orientation: portrait) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) expect(feature?.name).toBe('orientation') @@ -481,8 +482,8 @@ describe('At-Rule Prelude Nodes', () => { it('should extract feature name from boolean feature', () => { const css = '@media (hover) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) expect(feature?.name).toBe('hover') @@ -491,8 +492,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse feature values as typed children', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) expect(feature?.name).toBe('min-width') @@ -503,8 +504,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse identifier value as child', () => { const css = '@media (orientation: portrait) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) expect(feature?.children.length).toBe(1) @@ -515,8 +516,8 @@ describe('At-Rule Prelude Nodes', () => { it('should have no children for boolean features', () => { const css = '@media (hover) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) expect(feature?.children.length).toBe(0) @@ -525,8 +526,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse range syntax with single comparison', () => { const css = '@media (width >= 400px) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const range = queryChildren.find((c) => c.type === FEATURE_RANGE) expect(range?.type).toBe(FEATURE_RANGE) @@ -541,8 +542,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse range syntax with double comparison', () => { const css = '@media (50px <= width <= 100px) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const range = queryChildren.find((c) => c.type === FEATURE_RANGE) expect(range?.type).toBe(FEATURE_RANGE) @@ -559,8 +560,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse range syntax with less-than', () => { const css = '@media (400px < width) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const range = queryChildren.find((c) => c.type === FEATURE_RANGE) expect(range?.type).toBe(FEATURE_RANGE) @@ -575,8 +576,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse range syntax with equals', () => { const css = '@media (width = 500px) { }' const ast = parse(css) - const atRule = ast.first_child - const queryChildren = atRule?.children[0].children || [] + const atRule = ast.first_child! + const queryChildren = atRule.prelude?.children[0].children || [] const range = queryChildren.find((c) => c.type === FEATURE_RANGE) expect(range?.type).toBe(FEATURE_RANGE) @@ -591,8 +592,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse comma-separated media queries', () => { const css = '@media screen, print { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] // Should have 2 media query nodes const queries = children.filter((c) => c.type === MEDIA_QUERY) @@ -609,12 +610,12 @@ describe('At-Rule Prelude Nodes', () => { 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! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('container') - const children = atRule?.children || [] + const children = atRule.prelude?.children || [] expect(children.length).toBeGreaterThan(0) expect(children[0].type).toBe(CONTAINER_QUERY) }) @@ -622,8 +623,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse named container query', () => { const css = '@container sidebar (min-width: 400px) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[0].type).toBe(CONTAINER_QUERY) @@ -636,8 +637,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse style container query', () => { const css = '@container style(--custom: 1) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[0].type).toBe(CONTAINER_QUERY) @@ -650,8 +651,8 @@ describe('At-Rule Prelude Nodes', () => { it('should parse named style container query', () => { const css = '@container mytest style(--custom: 1) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[0].type).toBe(CONTAINER_QUERY) @@ -671,8 +672,8 @@ describe('At-Rule Prelude Nodes', () => { /* */ }` const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[0].type).toBe(CONTAINER_QUERY) const container = children[0] @@ -702,12 +703,12 @@ describe('At-Rule Prelude Nodes', () => { 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! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('supports') - const children = atRule?.children || [] + const children = atRule.prelude?.children || [] expect(children.some((c) => c.type === SUPPORTS_QUERY)).toBe(true) const query = children.find((c) => c.type === SUPPORTS_QUERY) @@ -717,8 +718,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const query = children.find((c) => c.type === SUPPORTS_QUERY) expect(query?.value).toBe('display: flex') @@ -727,8 +728,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] // Should have 2 queries and 1 operator const queries = children.filter((c) => c.type === SUPPORTS_QUERY) @@ -743,13 +744,13 @@ describe('At-Rule Prelude Nodes', () => { it('should parse single layer name', () => { const css = '@layer base { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('layer') // Filter out block node to get only prelude children - const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] + const children = atRule.prelude?.children.filter((c) => c.type !== BLOCK) || [] expect(children.length).toBe(1) expect(children[0].type).toBe(LAYER_NAME) expect(children[0].type_name).toBe('Layer') @@ -760,9 +761,9 @@ describe('At-Rule Prelude Nodes', () => { 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! - const children = atRule?.children || [] + const children = atRule.prelude?.children || [] expect(children.length).toBe(3) expect(children[0].type).toBe(LAYER_NAME) @@ -783,13 +784,13 @@ describe('At-Rule Prelude Nodes', () => { it('should parse keyframe name', () => { const css = '@keyframes slidein { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('keyframes') // Filter out block node to get only prelude children - const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] + const children = atRule.prelude?.children.filter((c) => c.type !== BLOCK) || [] expect(children.length).toBe(1) expect(children[0].type).toBe(IDENTIFIER) expect(children[0].text).toBe('slidein') @@ -800,13 +801,13 @@ describe('At-Rule Prelude Nodes', () => { 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! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('property') // Filter out block node to get only prelude children - const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] + const children = atRule.prelude?.children.filter((c) => c.type !== BLOCK) || [] expect(children.length).toBe(1) expect(children[0].type).toBe(IDENTIFIER) expect(children[0].text).toBe('--my-color') @@ -817,13 +818,13 @@ describe('At-Rule Prelude Nodes', () => { 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! expect(atRule?.type).toBe(AT_RULE) expect(atRule?.name).toBe('font-face') // @font-face has no prelude, children should be declarations - const children = atRule?.children || [] + const children = atRule.prelude?.children || [] if (children.length > 0) { // If parse_values is enabled, there might be declaration children expect(children[0].type).not.toBe(IDENTIFIER) @@ -835,8 +836,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.some((c) => c.type === MEDIA_QUERY)).toBe(true) }) @@ -844,8 +845,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.some((c) => c.type === MEDIA_QUERY)).toBe(false) }) @@ -855,10 +856,10 @@ describe('At-Rule Prelude Nodes', () => { 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! // The prelude text should still be accessible - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + expect(atRule?.prelude?.text).toBe('screen and (min-width: 768px)') }) }) @@ -866,8 +867,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBeGreaterThan(0) expect(children[0].type).toBe(URL) @@ -877,8 +878,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBeGreaterThan(0) expect(children[0].type).toBe(URL) @@ -888,8 +889,8 @@ describe('At-Rule Prelude Nodes', () => { it('should have .value property for URL with quoted url() function', () => { const css = '@import url("example.com");' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const url = atRule?.children[0] + const atRule = ast.first_child! + const url = atRule.prelude?.children[0] expect(url?.type).toBe(URL) expect(url?.text).toBe('url("example.com")') @@ -900,8 +901,8 @@ describe('At-Rule Prelude Nodes', () => { it('should have .value property for URL with quoted string', () => { const css = '@import "example.com";' const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const url = atRule?.children[0] + const atRule = ast.first_child! + const url = atRule.prelude?.children[0] expect(url?.type).toBe(URL) expect(url?.text).toBe('"example.com"') @@ -911,8 +912,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -924,8 +925,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -937,8 +938,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -950,8 +951,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[1].type).toBe(LAYER_NAME) expect(children[1].name).toBe('utilities') @@ -960,8 +961,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[1].type).toBe(LAYER_NAME) expect(children[1].name).toBe('utilities') @@ -970,8 +971,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children[1].type).toBe(LAYER_NAME) expect(children[1].name).toBe('named.nested') @@ -980,8 +981,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -992,8 +993,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -1003,8 +1004,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -1015,8 +1016,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -1026,8 +1027,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(3) expect(children[0].type).toBe(URL) @@ -1041,8 +1042,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(3) expect(children[0].type).toBe(URL) @@ -1058,8 +1059,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(3) expect(children[0].type).toBe(URL) @@ -1070,8 +1071,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(4) expect(children[0].type).toBe(URL) @@ -1083,8 +1084,8 @@ describe('At-Rule Prelude Nodes', () => { 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 children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] expect(children.length).toBe(2) expect(children[0].type).toBe(URL) @@ -1096,9 +1097,9 @@ describe('At-Rule Prelude Nodes', () => { 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! - expect(atRule?.prelude).toBe('url("styles.css") layer(base) screen') + expect(atRule?.prelude?.text).toBe('url("styles.css") layer(base) screen') }) }) @@ -1107,117 +1108,117 @@ describe('At-Rule Prelude Nodes', () => { test('@media prelude length should match text', () => { const css = '@media screen { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('screen') + expect(atRule?.prelude?.text).toBe('screen') expect(atRule?.prelude?.length).toBe(6) }) test('@media with feature prelude length', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('(min-width: 768px)') + expect(atRule?.prelude?.text).toBe('(min-width: 768px)') expect(atRule?.prelude?.length).toBe(18) }) test('@media complex prelude length', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + expect(atRule?.prelude?.text).toBe('screen and (min-width: 768px)') expect(atRule?.prelude?.length).toBe(29) }) test('@container prelude length', () => { const css = '@container (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('(min-width: 768px)') + expect(atRule?.prelude?.text).toBe('(min-width: 768px)') expect(atRule?.prelude?.length).toBe(18) }) test('@container with name prelude length', () => { const css = '@container sidebar (min-width: 400px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('sidebar (min-width: 400px)') + expect(atRule?.prelude?.text).toBe('sidebar (min-width: 400px)') expect(atRule?.prelude?.length).toBe(26) }) test('@supports prelude length', () => { const css = '@supports (display: flex) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('(display: flex)') + expect(atRule?.prelude?.text).toBe('(display: flex)') expect(atRule?.prelude?.length).toBe(15) }) test('@supports complex prelude length', () => { const css = '@supports (display: flex) and (color: red) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('(display: flex) and (color: red)') + expect(atRule?.prelude?.text).toBe('(display: flex) and (color: red)') expect(atRule?.prelude?.length).toBe(32) }) test('@layer single name prelude length', () => { const css = '@layer utilities { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('utilities') + expect(atRule?.prelude?.text).toBe('utilities') expect(atRule?.prelude?.length).toBe(9) }) test('@layer multiple names prelude length', () => { const css = '@layer base, components, utilities { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('base, components, utilities') + expect(atRule?.prelude?.text).toBe('base, components, utilities') expect(atRule?.prelude?.length).toBe(27) }) test('@import url prelude length', () => { const css = '@import url("styles.css") screen;' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('url("styles.css") screen') + expect(atRule?.prelude?.text).toBe('url("styles.css") screen') expect(atRule?.prelude?.length).toBe(24) }) test('@import with layer prelude length', () => { const css = '@import "styles.css" layer(utilities);' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('"styles.css" layer(utilities)') + expect(atRule?.prelude?.text).toBe('"styles.css" layer(utilities)') expect(atRule?.prelude?.length).toBe(29) }) test('@import with supports prelude length', () => { const css = '@import url("styles.css") supports(display: flex);' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('url("styles.css") supports(display: flex)') + expect(atRule?.prelude?.text).toBe('url("styles.css") supports(display: flex)') expect(atRule?.prelude?.length).toBe(41) }) test('@import complex prelude length', () => { const css = '@import url("a.css") layer(utilities) supports(display: flex) screen;' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('url("a.css") layer(utilities) supports(display: flex) screen') + expect(atRule?.prelude?.text).toBe('url("a.css") layer(utilities) supports(display: flex) screen') expect(atRule?.prelude?.length).toBe(60) }) }) @@ -1226,8 +1227,8 @@ describe('At-Rule Prelude Nodes', () => { test('media query node text length', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] // First child should be media query const mediaQuery = children[0] @@ -1239,8 +1240,8 @@ describe('At-Rule Prelude Nodes', () => { test('media type node text length', () => { const css = '@media screen { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const mediaQuery = children[0] const queryChildren = mediaQuery?.children || [] @@ -1252,8 +1253,8 @@ describe('At-Rule Prelude Nodes', () => { test('media feature node text length', () => { const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const mediaQuery = children[0] const queryChildren = mediaQuery?.children || [] @@ -1265,8 +1266,8 @@ describe('At-Rule Prelude Nodes', () => { test('container query node text length', () => { const css = '@container sidebar (min-width: 400px) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const containerQuery = children.find((c) => c.type === CONTAINER_QUERY) expect(containerQuery?.text).toBe('sidebar (min-width: 400px)') @@ -1276,8 +1277,8 @@ describe('At-Rule Prelude Nodes', () => { test('supports query node text length', () => { const css = '@supports (display: flex) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const supportsQuery = children.find((c) => c.type === SUPPORTS_QUERY) expect(supportsQuery?.text).toBe('(display: flex)') @@ -1287,8 +1288,8 @@ describe('At-Rule Prelude Nodes', () => { test('layer name node text length', () => { const css = '@layer utilities { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const layerName = children.find((c) => c.type === LAYER_NAME) expect(layerName?.text).toBe('utilities') @@ -1298,8 +1299,8 @@ describe('At-Rule Prelude Nodes', () => { test('import url node text length', () => { const css = '@import url("styles.css") screen;' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const importUrl = children.find((c) => c.type === URL) expect(importUrl?.text).toBe('url("styles.css")') @@ -1309,8 +1310,8 @@ describe('At-Rule Prelude Nodes', () => { test('import layer node text length', () => { const css = '@import "styles.css" layer(utilities);' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const importLayer = children.find((c) => c.type === LAYER_NAME) expect(importLayer?.text).toBe('layer(utilities)') @@ -1320,8 +1321,8 @@ describe('At-Rule Prelude Nodes', () => { test('import supports node text length', () => { const css = '@import url("a.css") supports(display: flex);' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const importSupports = children.find((c) => c.type === SUPPORTS_QUERY) expect(importSupports?.text).toBe('supports(display: flex)') @@ -1331,8 +1332,8 @@ describe('At-Rule Prelude Nodes', () => { test('operator node text length', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + const atRule = ast.first_child! + const children = atRule.prelude?.children || [] const mediaQuery = children[0] const queryChildren = mediaQuery?.children || [] @@ -1346,28 +1347,28 @@ describe('At-Rule Prelude Nodes', () => { test('@media with extra whitespace prelude length', () => { const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! // Whitespace is trimmed from start/end but preserved internally - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + expect(atRule?.prelude?.text).toBe('screen and (min-width: 768px)') expect(atRule?.prelude?.length).toBe(33) }) test('@layer with whitespace around commas', () => { const css = '@layer base , components , utilities { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('base , components , utilities') + expect(atRule?.prelude?.text).toBe('base , components , utilities') expect(atRule?.prelude?.length).toBe(29) }) test('@import with newlines prelude length', () => { const css = '@import url("styles.css")\n screen;' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! - expect(atRule?.prelude).toBe('url("styles.css")\n screen') + expect(atRule?.prelude?.text).toBe('url("styles.css")\n screen') expect(atRule?.prelude?.length).toBe(26) }) }) diff --git a/src/parse.test.ts b/src/parse.test.ts index 3f9b289..e7c0e64 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -984,7 +984,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.type).toBe(AT_RULE) expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('(min-width: 768px)') + expect(atrule.prelude?.text).toBe('(min-width: 768px)') }) test('complex media query prelude', () => { @@ -993,7 +993,7 @@ describe('Core Nodes', () => { 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.prelude?.text).toBe('screen and (min-width: 768px) and (max-width: 1024px)') }) test('container query prelude', () => { @@ -1002,7 +1002,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('container') - expect(atrule.prelude).toBe('(width >= 200px)') + expect(atrule.prelude?.text).toBe('(width >= 200px)') }) test('supports query prelude', () => { @@ -1011,7 +1011,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('supports') - expect(atrule.prelude).toBe('(display: grid)') + expect(atrule.prelude?.text).toBe('(display: grid)') }) test('import prelude', () => { @@ -1020,7 +1020,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('import') - expect(atrule.prelude).toBe('url("styles.css")') + expect(atrule.prelude?.text).toBe('url("styles.css")') }) test('at-rule without prelude', () => { @@ -1038,7 +1038,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('layer') - expect(atrule.prelude).toBe('utilities') + expect(atrule.prelude?.text).toBe('utilities') }) test('keyframes prelude', () => { @@ -1047,7 +1047,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('keyframes') - expect(atrule.prelude).toBe('slide-in') + expect(atrule.prelude?.text).toBe('slide-in') }) test('prelude with extra whitespace', () => { @@ -1056,7 +1056,7 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('(min-width: 768px)') + expect(atrule.prelude?.text).toBe('(min-width: 768px)') }) test('charset prelude', () => { @@ -1065,7 +1065,9 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('charset') - expect(atrule.prelude).toBe('"UTF-8"') + // @charset doesn't have structured prelude parsing, use .value + expect(atrule.value).toBe('"UTF-8"') + expect(atrule.prelude).toBeNull() }) test('namespace prelude', () => { @@ -1074,16 +1076,20 @@ describe('Core Nodes', () => { let atrule = root.first_child! expect(atrule.name).toBe('namespace') - expect(atrule.prelude).toBe('svg url(http://www.w3.org/2000/svg)') + // @namespace doesn't have structured prelude parsing, use .value + expect(atrule.value).toBe('svg url(http://www.w3.org/2000/svg)') + expect(atrule.prelude).toBeNull() }) - test('value and prelude should be aliases for at-rules', () => { + test('value string matches prelude text for at-rules', () => { let source = '@media (min-width: 768px) { }' let root = parse(source) let atrule = root.first_child! - expect(atrule.value).toBe(atrule.prelude) + // value returns the raw string from arena, prelude returns the wrapper node + expect(typeof atrule.value).toBe('string') expect(atrule.value).toBe('(min-width: 768px)') + expect(atrule.prelude?.text).toBe('(min-width: 768px)') }) test('at-rule prelude line tracking', () => { @@ -1093,10 +1099,10 @@ describe('Core Nodes', () => { let [_rule1, atRule] = root.children expect(atRule.line).toBe(3) - // Check that prelude nodes inherit the correct line - let preludeNode = atRule.first_child - expect(preludeNode).toBeTruthy() - expect(preludeNode!.line).toBe(3) // Should be line 3, not line 1 + // Check that prelude wrapper inherits the correct line + let preludeWrapper = atRule.prelude + expect(preludeWrapper).toBeTruthy() + expect(preludeWrapper!.line).toBe(3) // Should be line 3, not line 1 }) }) @@ -2461,13 +2467,13 @@ describe('Core Nodes', () => { const layer = ast.first_child! expect(layer.type).toBe(AT_RULE) expect(layer.name).toBe('layer') - expect(layer.prelude).toBe('what') + expect(layer.prelude?.text).toBe('what') expect(layer.has_block).toBe(true) const container = layer.block!.first_child! expect(container.type).toBe(AT_RULE) expect(container.name).toBe('container') - expect(container.prelude).toBe('(width > 0)') + expect(container.prelude?.text).toBe('(width > 0)') expect(container.has_block).toBe(true) const ulRule = container.block!.first_child! @@ -2486,7 +2492,7 @@ describe('Core Nodes', () => { const media = ulRule.block!.first_child! expect(media.type).toBe(AT_RULE) expect(media.name).toBe('media') - expect(media.prelude).toBe('(height > 0)') + expect(media.prelude?.text).toBe('(height > 0)') expect(media.has_block).toBe(true) const nestingRule = media.block!.first_child! diff --git a/src/parse.ts b/src/parse.ts index a9b8bc7..ef44102 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,6 +1,6 @@ // CSS Parser - Builds AST using the arena import { Lexer } from './tokenize' -import { CSSDataArena, STYLESHEET, STYLE_RULE, SELECTOR_LIST, AT_RULE, BLOCK, FLAG_HAS_BLOCK, FLAG_HAS_DECLARATIONS } from './arena' +import { CSSDataArena, STYLESHEET, STYLE_RULE, SELECTOR_LIST, AT_RULE, BLOCK, AT_RULE_PRELUDE, FLAG_HAS_BLOCK, FLAG_HAS_DECLARATIONS } from './arena' import { CSSNode } from './css-node' import { SelectorParser } from './parse-selector' import { AtRulePreludeParser } from './parse-atrule-prelude' @@ -329,14 +329,20 @@ export class Parser { // Store prelude position (trimmed) let trimmed = trim_boundaries(this.source, prelude_start, prelude_end) - let prelude_nodes: number[] = [] + let prelude_wrapper: number | null = null if (trimmed) { this.arena.set_value_start_delta(at_rule, trimmed[0] - at_rule_start) this.arena.set_value_length(at_rule, trimmed[1] - trimmed[0]) // Parse prelude if enabled if (this.prelude_parser) { - prelude_nodes = this.prelude_parser.parse_prelude(at_rule_name, trimmed[0], trimmed[1], at_rule_line, at_rule_column) + let prelude_nodes = this.prelude_parser.parse_prelude(at_rule_name, trimmed[0], trimmed[1], at_rule_line, at_rule_column) + + // Wrap prelude nodes in an AT_RULE_PRELUDE wrapper + if (prelude_nodes.length > 0) { + prelude_wrapper = this.arena.create_node(AT_RULE_PRELUDE, trimmed[0], trimmed[1] - trimmed[0], at_rule_line, at_rule_column) + this.arena.append_children(prelude_wrapper, prelude_nodes) + } } } @@ -442,17 +448,33 @@ export class Parser { // Link block children this.arena.append_children(block_node, block_children) - // Add block to at-rule children - prelude_nodes.push(block_node) + // Build at-rule children: [prelude_wrapper?, block] + let at_rule_children: number[] = [] + if (prelude_wrapper !== null) { + at_rule_children.push(prelude_wrapper) + } + at_rule_children.push(block_node) + + // Set at-rule length and link children + this.arena.set_length(at_rule, last_end - at_rule_start) + this.arena.append_children(at_rule, at_rule_children) } else if (this.peek_type() === TOKEN_SEMICOLON) { // Statement at-rule (like @import, @namespace) last_end = this.lexer.token_end this.next_token() // consume ';' - } - // Set at-rule length and link children (prelude nodes + optional block) - this.arena.set_length(at_rule, last_end - at_rule_start) - this.arena.append_children(at_rule, prelude_nodes) + // Set at-rule length and link children (prelude wrapper only, no block) + this.arena.set_length(at_rule, last_end - at_rule_start) + if (prelude_wrapper !== null) { + this.arena.append_children(at_rule, [prelude_wrapper]) + } + } else { + // No block or semicolon (error recovery) + this.arena.set_length(at_rule, last_end - at_rule_start) + if (prelude_wrapper !== null) { + this.arena.append_children(at_rule, [prelude_wrapper]) + } + } return at_rule }