@wix/zero-config-implementation 1.66.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -422,6 +422,12 @@ export declare interface CSSParserAPI {
422
422
  * @param defaultValue - The initial value string of the custom property
423
423
  */
424
424
  getVarPropertyType: (varName: string, defaultValue: string) => string | undefined;
425
+ /**
426
+ * Returns every `:global(.modifier)` design-state class found in the CSS, paired
427
+ * with any native pseudo-class on the same base element within the same rule.
428
+ * Modifiers with no paired pseudo are custom (non-native) state triggers.
429
+ */
430
+ getStateClasses: () => StateClassRule[];
425
431
  }
426
432
 
427
433
  export declare interface CssPropertiesData {
@@ -1577,6 +1583,8 @@ PropsTwo extends ErrorProps,
1577
1583
  declare type MethodOptions<PluginArg extends Plugin> =
1578
1584
  ExternalPluginOptions<PluginArg>
1579
1585
 
1586
+ declare type NativePseudoClass = 'hover' | 'focus' | 'disabled' | 'invalid';
1587
+
1580
1588
  /**
1581
1589
  * Normalize each error in the `errors` option to `Error` instances
1582
1590
  */
@@ -2644,6 +2652,24 @@ CauseArg extends Cause,
2644
2652
  > = MainInstanceOptions<AggregateErrorsArg, CauseArg> &
2645
2653
  PluginsOptions<PluginsArg, ChildProps>
2646
2654
 
2655
+ /**
2656
+ * A `:global(.modifier)` design-state class found on a base element selector.
2657
+ * Produced by walking each CSS rule and pairing the global modifier with any
2658
+ * native pseudo-class present on the same base within that rule.
2659
+ */
2660
+ declare interface StateClassRule {
2661
+ /** DOM-matchable base selector (e.g. `.root`), used to map the modifier to an element. */
2662
+ baseSelector: string;
2663
+ /** Global modifier class name from `:global(.X)`, without the leading dot. */
2664
+ modifier: string;
2665
+ /**
2666
+ * Native pseudo-class paired with this modifier on the same base within the same
2667
+ * rule. When present, the modifier is the editor-trigger class for a native state;
2668
+ * when absent, the modifier denotes a custom (non-native) design state.
2669
+ */
2670
+ pseudoClass?: NativePseudoClass;
2671
+ }
2672
+
2647
2673
  /**
2648
2674
  * Unbound static method of a plugin
2649
2675
  */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as t, D as e, E as o, I as n, N as c, P as l, R as i, a as p, V as E, b as m, c as x, d as f, e as d, f as u, h as C, i as I, j as k, k as y, l as A, m as D, n as P, o as R, p as h, q as B, r as M, s as T, t as b, w } from "./index-BEdi2EUy.js";
1
+ import { B as t, D as e, E as o, I as n, N as c, P as l, R as i, a as p, V as E, b as m, c as x, d as f, e as d, f as u, h as C, i as I, j as k, k as y, l as A, m as D, n as P, o as R, p as h, q as B, r as M, s as T, t as b, w } from "./index-Ps7eUbv5.js";
2
2
  import "react";
3
3
  export {
4
4
  t as BaseError,
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.66.0",
7
+ "version": "1.67.0",
8
8
  "description": "Core library for extracting component manifests from JS and CSS files",
9
9
  "type": "module",
10
10
  "main": "dist/index.js",
@@ -81,5 +81,5 @@
81
81
  ]
82
82
  }
83
83
  },
84
- "falconPackageHash": "2a3fd8ab08ce40ac87e3bf2f07ad6928e67ddb1262856b12f593624b"
84
+ "falconPackageHash": "98b2efba108de51cc5bc3678f7b32253ba1a26e9b875964b6a8e725e"
85
85
  }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ExtractedElement } from '../information-extractors/react'
3
+ import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
4
+
5
+ function elementWithClass(classAttribute: string): ExtractedElement {
6
+ return { attributes: { class: classAttribute } } as unknown as ExtractedElement
7
+ }
8
+
9
+ function classTrigger(baseSelector: string, modifier: string, isCssModule = false): CustomClassTrigger {
10
+ return { baseSelector, modifier, isCssModule }
11
+ }
12
+
13
+ describe('buildCustomStatesBlock', () => {
14
+ it('emits a custom-class state with className only (no props, no pseudoClass)', () => {
15
+ const states = buildCustomStatesBlock({
16
+ element: elementWithClass('custom-states'),
17
+ classTriggers: [classTrigger('.custom-states', 'custom-states--featured')],
18
+ })
19
+
20
+ expect(states).toEqual({ featured: { displayName: 'Featured', className: 'custom-states--featured' } })
21
+ })
22
+
23
+ it('derives a multi-word state name from the modifier and capital-cases the displayName', () => {
24
+ const states = buildCustomStatesBlock({
25
+ element: elementWithClass('widget'),
26
+ classTriggers: [classTrigger('.widget', 'widget--in-progress')],
27
+ })
28
+
29
+ expect(states?.['in-progress']).toEqual({
30
+ displayName: 'In Progress',
31
+ className: 'widget--in-progress',
32
+ })
33
+ })
34
+
35
+ it('does not apply a block-base class trigger to a BEM child element (plain CSS, exact match)', () => {
36
+ const states = buildCustomStatesBlock({
37
+ element: elementWithClass('custom-states__label'),
38
+ classTriggers: [classTrigger('.custom-states', 'custom-states--featured')],
39
+ })
40
+
41
+ expect(states).toBeUndefined()
42
+ })
43
+
44
+ it('matches a base class rendered as a double-underscore CSS-module hash (`root__HASH`)', () => {
45
+ // Vite emits `[name]__[hash]` for `styles.root`; the hash carries uppercase, so it is the
46
+ // base element — not a BEM child. (Regression: this used to be dropped as a child.)
47
+ const states = buildCustomStatesBlock({
48
+ element: elementWithClass('root__IWjkn wixui-test-comp-card test-comp-card'),
49
+ classTriggers: [classTrigger('.root', 'test-comp-card--featured', true)],
50
+ })
51
+
52
+ expect(states).toEqual({ featured: { displayName: 'Featured', className: 'test-comp-card--featured' } })
53
+ })
54
+
55
+ it('still excludes a genuine BEM child (`root__label`) from a block-base trigger in a module', () => {
56
+ const states = buildCustomStatesBlock({
57
+ element: elementWithClass('root__label'),
58
+ classTriggers: [classTrigger('.root', 'test-comp-card--featured', true)],
59
+ })
60
+
61
+ expect(states).toBeUndefined()
62
+ })
63
+
64
+ it('returns undefined when no class trigger targets the element', () => {
65
+ const states = buildCustomStatesBlock({
66
+ element: elementWithClass('custom-states'),
67
+ classTriggers: [],
68
+ })
69
+
70
+ expect(states).toBeUndefined()
71
+ })
72
+ })
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Builds the custom (non-native) `states` entries for an element.
3
+ *
4
+ * The custom-class trigger feeds this: a `:global(.modifier)` class on the element
5
+ * with no paired native pseudo (CSS extractor `getStateClasses`). It emits the
6
+ * modifier as the state's editor-trigger `className`.
7
+ *
8
+ * Native states are built separately and take precedence on any key collision.
9
+ *
10
+ * Note: the prop trigger (`State<>` markers → `props` overrides) is intentionally
11
+ * not handled here yet — it is deferred to a follow-up PR, gated on the upstream
12
+ * marker-type agreement.
13
+ */
14
+
15
+ import type { States } from '@wix/react-component-schema'
16
+ import type { StateClassRule } from '../information-extractors/css/types'
17
+ import type { ExtractedElement } from '../information-extractors/react'
18
+ import { isGlobalSemanticClass, stateNameFromModifier } from '../utils/css-class'
19
+ import { formatDisplayName } from './utils'
20
+
21
+ /** A custom-class trigger tagged with whether its source file is a CSS module. */
22
+ export type CustomClassTrigger = StateClassRule & { isCssModule: boolean }
23
+
24
+ interface BuildCustomStatesParams {
25
+ element: ExtractedElement
26
+ /** Custom-class triggers (modifiers with no paired native pseudo) across all CSS files. */
27
+ classTriggers: CustomClassTrigger[]
28
+ }
29
+
30
+ export function buildCustomStatesBlock({ element, classTriggers }: BuildCustomStatesParams): States | undefined {
31
+ const states: States = {}
32
+
33
+ // Custom-class triggers that target this element.
34
+ for (const rule of classTriggers) {
35
+ if (!elementMatchesBase(element, rule.baseSelector, rule.isCssModule)) continue
36
+ const stateName = stateNameFromModifier(rule.modifier)
37
+ states[stateName] = {
38
+ displayName: formatDisplayName(stateName),
39
+ className: rule.modifier,
40
+ }
41
+ }
42
+
43
+ return Object.keys(states).length > 0 ? states : undefined
44
+ }
45
+
46
+ /**
47
+ * Checks whether an element's rendered class list matches a simple single-class
48
+ * base selector (e.g. `.root`, `.custom-states`).
49
+ *
50
+ * Exact class match always counts. For CSS-module files the Vite hash patterns also
51
+ * count: `localName_HASH`, `_localName_HASH`, and the double-underscore `localName__HASH`
52
+ * form. The catch is that `localName__HASH` is syntactically identical to a BEM child
53
+ * class (`localName__child`) — which denotes a *different* element and must NOT match the
54
+ * block's base selector. We disambiguate by shape: a real BEM child is an all-lowercase
55
+ * semantic name (`isGlobalSemanticClass`), whereas a module hash carries uppercase / other
56
+ * non-kebab characters. So only genuine BEM children are excluded; hashed base classes match.
57
+ */
58
+ function elementMatchesBase(element: ExtractedElement, baseSelector: string, isCssModule: boolean): boolean {
59
+ const match = baseSelector.match(/^\.([\w-]+)$/)
60
+ if (!match) return false
61
+
62
+ const localName = match[1]
63
+ const classList = element.attributes.class?.split(/\s+/).filter(Boolean) ?? []
64
+ return classList.some((className) => {
65
+ if (className === localName) return true
66
+ if (!isCssModule) return false
67
+ // Genuine BEM child (`localName__<lowercase-element>`) → a different element, exclude it.
68
+ // A double-underscore module hash (`localName__<hash>`) is NOT semantic, so it falls through.
69
+ const isBemChild =
70
+ (className.startsWith(`${localName}__`) || className.startsWith(`_${localName}__`)) &&
71
+ isGlobalSemanticClass(className.replace(/^_/, ''))
72
+ if (isBemChild) return false
73
+ return className.startsWith(`${localName}_`) || className.startsWith(`_${localName}_`)
74
+ })
75
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  EditorElement,
7
7
  EditorReactComponent,
8
8
  ElementItem,
9
+ States,
9
10
  } from '@wix/react-component-schema'
10
11
  import { CSS_PROPERTIES, ELEMENTS } from '@wix/react-component-schema'
11
12
  import { camelCase } from 'case-anything'
@@ -21,10 +22,32 @@ import type {
21
22
  import { getDefaultDisplayForTag, resolveDisplayValue } from '../information-extractors/react'
22
23
  import { findPreferredSemanticClass } from '../utils/css-class'
23
24
  import { resolveLonghand } from './css-longhand-resolver'
25
+ import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
24
26
  import { buildDataItem } from './data-item-builder'
25
27
  import { buildStatesBlock } from './states-builder'
26
28
  import { formatDisplayName } from './utils'
27
29
 
30
+ /**
31
+ * Merges native and custom state blocks. Native states take precedence on any
32
+ * key collision (e.g. an author wrapping `isDisabled` in `State<>` does not
33
+ * shadow the inferred native `disabled` state).
34
+ */
35
+ function mergeStates(native: States | undefined, custom: States | undefined): States | undefined {
36
+ if (!native && !custom) return undefined
37
+ const merged: States = { ...custom, ...native }
38
+ return Object.keys(merged).length > 0 ? merged : undefined
39
+ }
40
+
41
+ /** Collects custom-class state triggers (modifiers with no paired native pseudo) across all CSS files. */
42
+ function collectCustomClassTriggers(component: ComponentInfoWithCss): CustomClassTrigger[] {
43
+ return component.css.flatMap((cssInfo) =>
44
+ cssInfo.api
45
+ .getStateClasses()
46
+ .filter((rule) => rule.pseudoClass === undefined)
47
+ .map((rule) => ({ ...rule, isCssModule: cssInfo.isCssModule })),
48
+ )
49
+ }
50
+
28
51
  // Props to exclude from data items (same as in element-data.ts)
29
52
  const EXCLUDED_PROPS = new Set(['id', 'className', 'elementProps', 'wix'])
30
53
 
@@ -43,8 +66,18 @@ function buildEditorElement(
43
66
  const childElements = rootElement?.children ?? []
44
67
  const rootCustomProps = rootElement ? (nearestCommonAncestorCustomProps.get(rootElement.traceId) ?? {}) : {}
45
68
 
69
+ const customClassTriggers = collectCustomClassTriggers(component)
70
+
71
+ // Native states need a semantic block name to synthesize `<block>--<state>`; custom
72
+ // class triggers don't (they carry the className verbatim and match the element
73
+ // directly), so they're built even when the element resolves no semantic class.
46
74
  const rootSemanticClass = getSemanticBlockName(rootElement)
47
- const rootStates = rootElement && rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass) : undefined
75
+ const rootStates = rootElement
76
+ ? mergeStates(
77
+ rootSemanticClass ? buildStatesBlock(rootElement, rootSemanticClass) : undefined,
78
+ buildCustomStatesBlock({ element: rootElement, classTriggers: customClassTriggers }),
79
+ )
80
+ : undefined
48
81
 
49
82
  return {
50
83
  selector: buildSelector(rootElement),
@@ -53,6 +86,7 @@ function buildEditorElement(
53
86
  elements: buildElements(
54
87
  childElements,
55
88
  nearestCommonAncestorCustomProps,
89
+ customClassTriggers,
56
90
  component.innerElementProps,
57
91
  component.propUsages,
58
92
  ),
@@ -100,6 +134,7 @@ function buildData(
100
134
  function buildElements(
101
135
  elements: ExtractedElement[],
102
136
  nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
137
+ customClassTriggers: CustomClassTrigger[],
103
138
  innerElementProps?: CoupledComponentInfo['innerElementProps'],
104
139
  propUsages?: TrackingStores['propUsages'],
105
140
  ): Record<string, ElementItem> {
@@ -112,7 +147,10 @@ function buildElements(
112
147
  const cssCustomProps = nearestCommonAncestorCustomProps.get(element.traceId) ?? {}
113
148
 
114
149
  const semanticClass = getSemanticBlockName(element)
115
- const states = semanticClass ? buildStatesBlock(element, semanticClass) : undefined
150
+ const states = mergeStates(
151
+ semanticClass ? buildStatesBlock(element, semanticClass) : undefined,
152
+ buildCustomStatesBlock({ element, classTriggers: customClassTriggers }),
153
+ )
116
154
 
117
155
  result[element.name] = {
118
156
  elementType: ELEMENTS.ELEMENT_TYPE.inlineElement,
@@ -130,7 +168,13 @@ function buildElements(
130
168
  // Recursively build nested elements
131
169
  elements:
132
170
  element.children.length > 0
133
- ? buildElements(element.children, nearestCommonAncestorCustomProps, innerElementProps, propUsages)
171
+ ? buildElements(
172
+ element.children,
173
+ nearestCommonAncestorCustomProps,
174
+ customClassTriggers,
175
+ innerElementProps,
176
+ propUsages,
177
+ )
134
178
  : undefined,
135
179
  },
136
180
  }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseCss } from './parse'
3
+
4
+ describe('parseCss — getStateClasses', () => {
5
+ it('extracts a custom-class modifier with no paired pseudo', () => {
6
+ const stateClasses = parseCss(`
7
+ .root:global(.custom-states--featured) {
8
+ border: 2px solid orange;
9
+ }
10
+ `).getStateClasses()
11
+
12
+ expect(stateClasses).toEqual([
13
+ { baseSelector: '.root', modifier: 'custom-states--featured', pseudoClass: undefined },
14
+ ])
15
+ })
16
+
17
+ it('pairs a global modifier with a native pseudo on the same base in the same rule', () => {
18
+ const stateClasses = parseCss(`
19
+ .root:global(.toggle--hover),
20
+ .root:hover {
21
+ filter: brightness(1.05);
22
+ }
23
+ `).getStateClasses()
24
+
25
+ expect(stateClasses).toEqual([{ baseSelector: '.root', modifier: 'toggle--hover', pseudoClass: 'hover' }])
26
+ })
27
+
28
+ it('maps :focus-visible and :focus-within to the focus state', () => {
29
+ const stateClasses = parseCss(`
30
+ .root:global(.toggle--focus),
31
+ .root:focus-within {
32
+ outline: 2px solid;
33
+ }
34
+ `).getStateClasses()
35
+
36
+ expect(stateClasses).toEqual([{ baseSelector: '.root', modifier: 'toggle--focus', pseudoClass: 'focus' }])
37
+ })
38
+
39
+ it('keeps modifiers on distinct base elements separate', () => {
40
+ const stateClasses = parseCss(`
41
+ .root:global(.card--featured) { color: red; }
42
+ .label:global(.card__label--featured) { font-weight: 700; }
43
+ `).getStateClasses()
44
+
45
+ expect(stateClasses).toEqual([
46
+ { baseSelector: '.root', modifier: 'card--featured', pseudoClass: undefined },
47
+ { baseSelector: '.label', modifier: 'card__label--featured', pseudoClass: undefined },
48
+ ])
49
+ })
50
+
51
+ it('treats a global base class with a global modifier as base + modifier (BEM extension)', () => {
52
+ const stateClasses = parseCss(`
53
+ :global(.card):global(.card--featured) {
54
+ border: 2px solid orange;
55
+ }
56
+ `).getStateClasses()
57
+
58
+ expect(stateClasses).toEqual([{ baseSelector: '.card', modifier: 'card--featured', pseudoClass: undefined }])
59
+ })
60
+
61
+ it('resolves a global base for an inner BEM element (`__`) carrying a modifier', () => {
62
+ const stateClasses = parseCss(`
63
+ :global(.card__label):global(.card__label--highlight) {
64
+ font-weight: 700;
65
+ }
66
+ `).getStateClasses()
67
+
68
+ expect(stateClasses).toEqual([
69
+ { baseSelector: '.card__label', modifier: 'card__label--highlight', pseudoClass: undefined },
70
+ ])
71
+ })
72
+
73
+ it('pairs a native pseudo with a modifier when the base element is global', () => {
74
+ const stateClasses = parseCss(`
75
+ :global(.card):global(.card--hover),
76
+ :global(.card):hover {
77
+ filter: brightness(1.05);
78
+ }
79
+ `).getStateClasses()
80
+
81
+ expect(stateClasses).toEqual([{ baseSelector: '.card', modifier: 'card--hover', pseudoClass: 'hover' }])
82
+ })
83
+
84
+ it('anchors a modifier to the compound it sits on, ignoring a descendant combinator', () => {
85
+ // SCSS `.header { &:global(.card__title--selected) { .label { … } } }` compiles to a
86
+ // descendant selector; the modifier belongs to `.header`, not `.header .label`.
87
+ const stateClasses = parseCss(`
88
+ .header:global(.card__title--selected) .label {
89
+ text-decoration: underline;
90
+ }
91
+ `).getStateClasses()
92
+
93
+ expect(stateClasses).toEqual([
94
+ { baseSelector: '.header', modifier: 'card__title--selected', pseudoClass: undefined },
95
+ ])
96
+ })
97
+
98
+ it('pairs only the same-named modifier with a pseudo when a base carries both native and custom modifiers', () => {
99
+ // One rule, descendant-anchored: `--hover` pairs with `:hover`; `--selected` stays custom.
100
+ const stateClasses = parseCss(`
101
+ .header:hover .label,
102
+ .header:global(.card__title--hover) .label,
103
+ .header:global(.card__title--selected) .label {
104
+ text-decoration: underline;
105
+ }
106
+ `).getStateClasses()
107
+
108
+ expect(stateClasses).toEqual([
109
+ { baseSelector: '.header', modifier: 'card__title--hover', pseudoClass: 'hover' },
110
+ { baseSelector: '.header', modifier: 'card__title--selected', pseudoClass: undefined },
111
+ ])
112
+ })
113
+
114
+ it('ignores rules with no global modifier', () => {
115
+ const stateClasses = parseCss(`
116
+ .root:hover { cursor: pointer; }
117
+ .root { color: black; }
118
+ `).getStateClasses()
119
+
120
+ expect(stateClasses).toEqual([])
121
+ })
122
+ })
@@ -2,7 +2,8 @@ import { CSS_PROPERTIES } from '@wix/react-component-schema'
2
2
  import { camelCase } from 'case-anything'
3
3
  import { generate, lexer, parse, walk } from 'css-tree'
4
4
  import type { Atrule, CssNode, Declaration, FunctionNode, Rule, Selector } from 'css-tree'
5
- import type { CSSParserAPI, CSSProperty } from './types'
5
+ import { stateNameFromModifier } from '../../utils/css-class'
6
+ import type { CSSParserAPI, CSSProperty, NativePseudoClass, StateClassRule } from './types'
6
7
 
7
8
  type WalkContext = {
8
9
  atrule?: Atrule | null
@@ -20,8 +21,13 @@ function isInsideKeyframes(currentContext: WalkContext): boolean {
20
21
  export function parseCss(cssString: string): CSSParserAPI {
21
22
  const parsedSelectors = new Map<string, Selector>()
22
23
  const { propertiesMap: allProperties, varUsagesByProperty } = parseAllProperties(cssString, parsedSelectors)
24
+ const stateClasses = parseStateClasses(cssString)
23
25
 
24
26
  return {
27
+ getStateClasses(): StateClassRule[] {
28
+ return stateClasses
29
+ },
30
+
25
31
  getPropertiesForSelector(selector: string): CSSProperty[] {
26
32
  return allProperties.get(selector) ?? []
27
33
  },
@@ -198,6 +204,176 @@ function extractVarNames(valueNode: CssNode): string[] {
198
204
  return varNames
199
205
  }
200
206
 
207
+ // Native pseudo-classes mapped to their design-state key. `:focus-visible` and
208
+ // `:focus-within` both collapse to `focus` (the schema has a single focus state).
209
+ const NATIVE_PSEUDO_BY_NAME: Record<string, NativePseudoClass> = {
210
+ hover: 'hover',
211
+ focus: 'focus',
212
+ 'focus-visible': 'focus',
213
+ 'focus-within': 'focus',
214
+ disabled: 'disabled',
215
+ invalid: 'invalid',
216
+ }
217
+
218
+ const GLOBAL_MODIFIER_PATTERN = /^:global\(\s*\.([\w-]+)\s*\)$/
219
+
220
+ interface AnalyzedCompound {
221
+ /** DOM-matchable base of the compound the modifier/pseudo is attached to, e.g. `.root`. */
222
+ base: string
223
+ /** Native pseudo-class on this compound, if any. */
224
+ pseudoClass?: NativePseudoClass
225
+ /** Global modifier class names extracted from `:global(.X)` parts on this compound. */
226
+ modifiers: string[]
227
+ }
228
+
229
+ type SelectorPart = { kind: 'local'; selector: string } | { kind: 'global'; className: string }
230
+
231
+ /**
232
+ * Decides whether a `:global(.X)` class on a selector is a state *modifier* (vs. the
233
+ * base element it modifies):
234
+ * - If the selector has a local (CSS-module) class, that local class is always the
235
+ * base, so every global class is a modifier — the current, common form
236
+ * (`.root:global(.toggle--hover)`).
237
+ * - Otherwise the element itself is authored as a global class, and the base is the
238
+ * BEM root: a global class is a modifier only when it `--`-extends another global
239
+ * class on the selector (`:global(.card):global(.card--featured)` → base `.card`,
240
+ * modifier `card--featured`). A lone `:global(.card):hover` keeps `.card` as base.
241
+ */
242
+ function isModifierClass(className: string, hasLocalBase: boolean, globalClassNames: string[]): boolean {
243
+ if (hasLocalBase) return true
244
+ return globalClassNames.some((other) => other !== className && className.startsWith(`${other}--`))
245
+ }
246
+
247
+ /**
248
+ * Analyzes a single compound selector (no combinators) into its DOM-matchable base,
249
+ * a native pseudo-class (if present), and any `:global(.modifier)` class names. Returns
250
+ * null for a compound carrying a pseudo-element (unmatchable) or with no base.
251
+ *
252
+ * The element a modifier is applied to may itself be authored as a global class, so
253
+ * the base/modifier split is resolved across all of the compound's classes rather
254
+ * than assuming the base is local — see `isModifierClass`.
255
+ */
256
+ function analyzeCompound(componentNodes: CssNode[]): AnalyzedCompound | null {
257
+ let pseudoClass: NativePseudoClass | undefined
258
+ const parts: SelectorPart[] = []
259
+
260
+ for (const component of componentNodes) {
261
+ if (component.type === 'PseudoElementSelector') return null
262
+ if (component.type === 'PseudoClassSelector') {
263
+ if (component.name === 'global') {
264
+ const match = generate(component).match(GLOBAL_MODIFIER_PATTERN)
265
+ if (match) parts.push({ kind: 'global', className: match[1] })
266
+ continue
267
+ }
268
+ pseudoClass ??= NATIVE_PSEUDO_BY_NAME[component.name]
269
+ continue
270
+ }
271
+ parts.push({ kind: 'local', selector: generate(component) })
272
+ }
273
+
274
+ const hasLocalBase = parts.some((part) => part.kind === 'local')
275
+ const globalClassNames = parts.flatMap((part) => (part.kind === 'global' ? [part.className] : []))
276
+
277
+ const baseParts: string[] = []
278
+ const modifiers: string[] = []
279
+ for (const part of parts) {
280
+ if (part.kind === 'local') {
281
+ baseParts.push(part.selector)
282
+ } else if (isModifierClass(part.className, hasLocalBase, globalClassNames)) {
283
+ modifiers.push(part.className)
284
+ } else {
285
+ baseParts.push(`.${part.className}`)
286
+ }
287
+ }
288
+
289
+ const base = baseParts.join('')
290
+ if (!base) return null
291
+
292
+ return { base, pseudoClass, modifiers }
293
+ }
294
+
295
+ /**
296
+ * Splits a selector into compound selectors at combinators and analyzes each. A
297
+ * `:global(.modifier)` (or native pseudo) belongs to the compound it is attached to,
298
+ * so ancestor/descendant compounds in the same selector are ignored for the base —
299
+ * e.g. `.header:global(.x--selected) .label` resolves to base `.header`, not
300
+ * `.header .label`. Returns one entry per compound that carries a modifier or pseudo.
301
+ */
302
+ function analyzeStateSelector(selector: Selector): AnalyzedCompound[] {
303
+ const compounds: CssNode[][] = [[]]
304
+ for (const component of selector.children) {
305
+ if (component.type === 'Combinator') {
306
+ compounds.push([])
307
+ } else {
308
+ compounds[compounds.length - 1].push(component)
309
+ }
310
+ }
311
+
312
+ const results: AnalyzedCompound[] = []
313
+ for (const compoundNodes of compounds) {
314
+ const analyzed = analyzeCompound(compoundNodes)
315
+ if (analyzed && (analyzed.modifiers.length > 0 || analyzed.pseudoClass)) {
316
+ results.push(analyzed)
317
+ }
318
+ }
319
+ return results
320
+ }
321
+
322
+ /**
323
+ * Walks every CSS rule and extracts `:global(.modifier)` design-state classes,
324
+ * pairing each modifier with any native pseudo-class found on the same base within
325
+ * the same rule. This realizes the contract that a selector matching both a pseudo
326
+ * and a global modifier marks a native state's editor-trigger class, while a global
327
+ * modifier with no paired pseudo marks a custom state.
328
+ */
329
+ function parseStateClasses(cssString: string): StateClassRule[] {
330
+ const results: StateClassRule[] = []
331
+
332
+ try {
333
+ const ast = parse(cssString)
334
+
335
+ walk(ast, {
336
+ visit: 'Rule',
337
+ enter(rule: Rule) {
338
+ if (rule.prelude.type !== 'SelectorList') return
339
+
340
+ const pseudoByBase = new Map<string, NativePseudoClass>()
341
+ const modifiersByBase = new Map<string, Set<string>>()
342
+
343
+ for (const selectorNode of rule.prelude.children) {
344
+ if (selectorNode.type !== 'Selector') continue
345
+ for (const analyzed of analyzeStateSelector(selectorNode)) {
346
+ if (analyzed.pseudoClass && !pseudoByBase.has(analyzed.base)) {
347
+ pseudoByBase.set(analyzed.base, analyzed.pseudoClass)
348
+ }
349
+ if (analyzed.modifiers.length > 0) {
350
+ const modifiers = modifiersByBase.get(analyzed.base) ?? new Set<string>()
351
+ for (const modifier of analyzed.modifiers) modifiers.add(modifier)
352
+ modifiersByBase.set(analyzed.base, modifiers)
353
+ }
354
+ }
355
+ }
356
+
357
+ for (const [baseSelector, modifiers] of modifiersByBase) {
358
+ const basePseudo = pseudoByBase.get(baseSelector)
359
+ for (const modifier of modifiers) {
360
+ // A modifier is the editor-trigger class for a NATIVE state only when its
361
+ // own state name matches the base's pseudo (`x--hover` ↔ `:hover`). A custom
362
+ // modifier sharing a base with a native pseudo (`x--selected` next to `:hover`)
363
+ // stays custom — it must not inherit the unrelated pseudo.
364
+ const pseudoClass = basePseudo && stateNameFromModifier(modifier) === basePseudo ? basePseudo : undefined
365
+ results.push({ baseSelector, modifier, pseudoClass })
366
+ }
367
+ }
368
+ },
369
+ })
370
+ } catch (error) {
371
+ console.error('CSS state-class parsing error:', error)
372
+ }
373
+
374
+ return results
375
+ }
376
+
201
377
  /**
202
378
  * Builds a DOM-queryable selector by filtering out pseudo-classes and pseudo-elements.
203
379
  * Returns null if the selector contains pseudo-elements (unmatchable against real DOM).
@@ -15,6 +15,26 @@ export interface MatchedCssData {
15
15
  customProperties: Record<string, string>
16
16
  }
17
17
 
18
+ export type NativePseudoClass = 'hover' | 'focus' | 'disabled' | 'invalid'
19
+
20
+ /**
21
+ * A `:global(.modifier)` design-state class found on a base element selector.
22
+ * Produced by walking each CSS rule and pairing the global modifier with any
23
+ * native pseudo-class present on the same base within that rule.
24
+ */
25
+ export interface StateClassRule {
26
+ /** DOM-matchable base selector (e.g. `.root`), used to map the modifier to an element. */
27
+ baseSelector: string
28
+ /** Global modifier class name from `:global(.X)`, without the leading dot. */
29
+ modifier: string
30
+ /**
31
+ * Native pseudo-class paired with this modifier on the same base within the same
32
+ * rule. When present, the modifier is the editor-trigger class for a native state;
33
+ * when absent, the modifier denotes a custom (non-native) design state.
34
+ */
35
+ pseudoClass?: NativePseudoClass
36
+ }
37
+
18
38
  /**
19
39
  * API returned by parseCss function for querying parsed CSS
20
40
  */
@@ -66,4 +86,11 @@ export interface CSSParserAPI {
66
86
  * @param defaultValue - The initial value string of the custom property
67
87
  */
68
88
  getVarPropertyType: (varName: string, defaultValue: string) => string | undefined
89
+
90
+ /**
91
+ * Returns every `:global(.modifier)` design-state class found in the CSS, paired
92
+ * with any native pseudo-class on the same base element within the same rule.
93
+ * Modifiers with no paired pseudo are custom (non-native) state triggers.
94
+ */
95
+ getStateClasses: () => StateClassRule[]
69
96
  }