Skip to content

Commit 93dd619

Browse files
authored
breaking: parse media feature names and values into structured nodes (#95)
Add granular media feature parsing to match CSSTree's approach: - Add `name` property to MEDIA_FEATURE nodes (accessible via `feature.name`) - Parse feature values into typed child nodes (DIMENSION, IDENTIFIER, etc.) - Add FEATURE_RANGE node type (value 39) for range syntax support - Support modern range syntax: `(50px <= width <= 100px)`, `(width >= 400px)` Implementation: - Extract feature name and store in content fields - Parse value portion into typed AST nodes as children - Detect comparison operators (<=, >=, <, >, =) for range syntax - Add helper methods: parse_feature_value(), parse_value_token(), parse_feature_range() BREAKING CHANGE: The `.value` property on MEDIA_FEATURE nodes no longer returns the full feature string (e.g., "min-width: 768px"). Use `.name` to access the feature name and `.children` to access parsed values. Before: ```js feature.value // "min-width: 768px" After: feature.name // "min-width" feature.children[0].type // DIMENSION feature.children[0].text // "768px"
1 parent 27df82c commit 93dd619

File tree

6 files changed

+300
-10
lines changed

6 files changed

+300
-10
lines changed

src/arena.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const CONTAINER_QUERY = 35 // container query: sidebar (min-width: 400px)
8686
export const SUPPORTS_QUERY = 36 // supports query: (display: flex)
8787
export const LAYER_NAME = 37 // layer name: base, components
8888
export const PRELUDE_OPERATOR = 38 // logical operator: and, or, not
89+
export const FEATURE_RANGE = 39 // Range syntax: (50px <= width <= 100px)
8990

9091
// Flag constants (bit-packed in 1 byte)
9192
export const FLAG_IMPORTANT = 1 << 0 // Has !important

src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
SUPPORTS_QUERY,
3939
LAYER_NAME,
4040
PRELUDE_OPERATOR,
41+
FEATURE_RANGE,
4142
FLAG_IMPORTANT,
4243
ATTR_OPERATOR_NONE,
4344
ATTR_OPERATOR_EQUAL,
@@ -88,6 +89,7 @@ export {
8889
SUPPORTS_QUERY,
8990
LAYER_NAME,
9091
PRELUDE_OPERATOR,
92+
FEATURE_RANGE,
9193
FLAG_IMPORTANT,
9294
ATTR_OPERATOR_NONE,
9395
ATTR_OPERATOR_EQUAL,
@@ -143,4 +145,5 @@ export const NODE_TYPES = {
143145
SUPPORTS_QUERY,
144146
LAYER_NAME,
145147
PRELUDE_OPERATOR,
148+
FEATURE_RANGE,
146149
} as const

src/css-node.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
SUPPORTS_QUERY,
3838
LAYER_NAME,
3939
PRELUDE_OPERATOR,
40+
FEATURE_RANGE,
4041
FLAG_IMPORTANT,
4142
FLAG_HAS_ERROR,
4243
FLAG_HAS_BLOCK,
@@ -86,6 +87,7 @@ export const TYPE_NAMES = {
8687
[SUPPORTS_QUERY]: 'SupportsQuery',
8788
[LAYER_NAME]: 'Layer',
8889
[PRELUDE_OPERATOR]: 'Operator',
90+
[FEATURE_RANGE]: 'MediaFeatureRange',
8991
} as const
9092

9193
export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown'
@@ -128,6 +130,7 @@ export type CSSNodeType =
128130
| typeof SUPPORTS_QUERY
129131
| typeof LAYER_NAME
130132
| typeof PRELUDE_OPERATOR
133+
| typeof FEATURE_RANGE
131134

132135
// Options for cloning nodes
133136
export interface CloneOptions {

src/parse-atrule-prelude.test.ts

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
PRELUDE_OPERATOR,
1515
URL,
1616
FUNCTION,
17+
DIMENSION,
18+
FEATURE_RANGE,
1719
} from './arena'
1820

1921
describe('At-Rule Prelude Nodes', () => {
@@ -425,7 +427,7 @@ describe('At-Rule Prelude Nodes', () => {
425427

426428
// Feature should have content
427429
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
428-
expect(feature?.value).toContain('min-width')
430+
expect(feature?.name).toBe('min-width')
429431
})
430432

431433
it('should trim whitespace and comments from media features', () => {
@@ -436,7 +438,7 @@ describe('At-Rule Prelude Nodes', () => {
436438
const queryChildren = children[0].children
437439
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
438440

439-
expect(feature?.value).toBe('min-width: 768px')
441+
expect(feature?.name).toBe('min-width')
440442
})
441443

442444
it('should parse complex media query with and operator', () => {
@@ -465,6 +467,128 @@ describe('At-Rule Prelude Nodes', () => {
465467
expect(features.length).toBe(2)
466468
})
467469

470+
it('should extract feature name from standard feature', () => {
471+
const css = '@media (orientation: portrait) { }'
472+
const ast = parse(css)
473+
const atRule = ast.first_child
474+
const queryChildren = atRule?.children[0].children || []
475+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
476+
477+
expect(feature?.name).toBe('orientation')
478+
expect(feature?.children.length).toBe(1)
479+
expect(feature?.children[0].type).toBe(IDENTIFIER)
480+
})
481+
482+
it('should extract feature name from boolean feature', () => {
483+
const css = '@media (hover) { }'
484+
const ast = parse(css)
485+
const atRule = ast.first_child
486+
const queryChildren = atRule?.children[0].children || []
487+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
488+
489+
expect(feature?.name).toBe('hover')
490+
})
491+
492+
it('should parse feature values as typed children', () => {
493+
const css = '@media (min-width: 768px) { }'
494+
const ast = parse(css)
495+
const atRule = ast.first_child
496+
const queryChildren = atRule?.children[0].children || []
497+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
498+
499+
expect(feature?.name).toBe('min-width')
500+
expect(feature?.children.length).toBe(1)
501+
expect(feature?.children[0].type).toBe(DIMENSION)
502+
})
503+
504+
it('should parse identifier value as child', () => {
505+
const css = '@media (orientation: portrait) { }'
506+
const ast = parse(css)
507+
const atRule = ast.first_child
508+
const queryChildren = atRule?.children[0].children || []
509+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
510+
511+
expect(feature?.children.length).toBe(1)
512+
expect(feature?.children[0].type).toBe(IDENTIFIER)
513+
expect(feature?.children[0].text).toBe('portrait')
514+
})
515+
516+
it('should have no children for boolean features', () => {
517+
const css = '@media (hover) { }'
518+
const ast = parse(css)
519+
const atRule = ast.first_child
520+
const queryChildren = atRule?.children[0].children || []
521+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE)
522+
523+
expect(feature?.children.length).toBe(0)
524+
})
525+
526+
it('should parse range syntax with single comparison', () => {
527+
const css = '@media (width >= 400px) { }'
528+
const ast = parse(css)
529+
const atRule = ast.first_child
530+
const queryChildren = atRule?.children[0].children || []
531+
const range = queryChildren.find((c) => c.type === FEATURE_RANGE)
532+
533+
expect(range?.type).toBe(FEATURE_RANGE)
534+
expect(range?.name).toBe('width')
535+
expect(range?.children.length).toBe(2) // dimension + operator
536+
537+
// Verify child types
538+
expect(range?.children[0].type).toBe(PRELUDE_OPERATOR) // >=
539+
expect(range?.children[1].type).toBe(DIMENSION) // 400px
540+
})
541+
542+
it('should parse range syntax with double comparison', () => {
543+
const css = '@media (50px <= width <= 100px) { }'
544+
const ast = parse(css)
545+
const atRule = ast.first_child
546+
const queryChildren = atRule?.children[0].children || []
547+
const range = queryChildren.find((c) => c.type === FEATURE_RANGE)
548+
549+
expect(range?.type).toBe(FEATURE_RANGE)
550+
expect(range?.name).toBe('width')
551+
expect(range?.children.length).toBe(4) // dim, op, op, dim
552+
553+
// Verify child types
554+
expect(range?.children[0].type).toBe(DIMENSION) // 50px
555+
expect(range?.children[1].type).toBe(PRELUDE_OPERATOR) // <=
556+
expect(range?.children[2].type).toBe(PRELUDE_OPERATOR) // <=
557+
expect(range?.children[3].type).toBe(DIMENSION) // 100px
558+
})
559+
560+
it('should parse range syntax with less-than', () => {
561+
const css = '@media (400px < width) { }'
562+
const ast = parse(css)
563+
const atRule = ast.first_child
564+
const queryChildren = atRule?.children[0].children || []
565+
const range = queryChildren.find((c) => c.type === FEATURE_RANGE)
566+
567+
expect(range?.type).toBe(FEATURE_RANGE)
568+
expect(range?.name).toBe('width')
569+
expect(range?.children.length).toBe(2)
570+
571+
// Verify child types
572+
expect(range?.children[0].type).toBe(DIMENSION) // 400px
573+
expect(range?.children[1].type).toBe(PRELUDE_OPERATOR) // <
574+
})
575+
576+
it('should parse range syntax with equals', () => {
577+
const css = '@media (width = 500px) { }'
578+
const ast = parse(css)
579+
const atRule = ast.first_child
580+
const queryChildren = atRule?.children[0].children || []
581+
const range = queryChildren.find((c) => c.type === FEATURE_RANGE)
582+
583+
expect(range?.type).toBe(FEATURE_RANGE)
584+
expect(range?.name).toBe('width')
585+
expect(range?.children.length).toBe(2)
586+
587+
// Verify child types
588+
expect(range?.children[0].type).toBe(PRELUDE_OPERATOR) // =
589+
expect(range?.children[1].type).toBe(DIMENSION) // 500px
590+
})
591+
468592
it('should parse comma-separated media queries', () => {
469593
const css = '@media screen, print { }'
470594
const ast = parse(css)
@@ -474,6 +598,11 @@ describe('At-Rule Prelude Nodes', () => {
474598
// Should have 2 media query nodes
475599
const queries = children.filter((c) => c.type === MEDIA_QUERY)
476600
expect(queries.length).toBe(2)
601+
const [screen, print] = queries
602+
expect(screen.type_name).toBe('MediaQuery')
603+
expect(screen.text).toBe('screen')
604+
expect(print.type_name).toBe('MediaQuery')
605+
expect(print.text).toBe('print')
477606
})
478607
})
479608

0 commit comments

Comments
 (0)