@wix/zero-config-implementation 1.105.0 → 1.107.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.
@@ -9,7 +9,7 @@ import type {
9
9
  CssDataType,
10
10
  NativePseudoClass,
11
11
  RegisteredCustomProperty,
12
- RegisteredPropertyRange,
12
+ SelectorStateScope,
13
13
  StateClassRule,
14
14
  } from './types'
15
15
 
@@ -29,6 +29,7 @@ function isInsideKeyframes(currentContext: WalkContext): boolean {
29
29
  export function parseCss(cssString: string): CSSParserAPI {
30
30
  const parsedSelectors = new Map<string, Selector>()
31
31
  const { propertiesMap: allProperties, varUsagesByProperty } = parseAllProperties(cssString, parsedSelectors)
32
+ const stateScopeBySelector = new Map<string, SelectorStateScope>()
32
33
  const stateClasses = parseStateClasses(cssString)
33
34
  const registeredProperties = parseRegisteredProperties(cssString)
34
35
 
@@ -85,6 +86,15 @@ export function parseCss(cssString: string): CSSParserAPI {
85
86
  return getSelectorSpecificity(parsed)
86
87
  },
87
88
 
89
+ getSelectorStateScope(selector: string): SelectorStateScope {
90
+ const cachedScope = stateScopeBySelector.get(selector)
91
+ if (cachedScope) return cachedScope
92
+ const parsed = parsedSelectors.get(selector)
93
+ const scope = parsed ? classifySelectorStateScope(parsed) : REGULAR_SCOPE
94
+ stateScopeBySelector.set(selector, scope)
95
+ return scope
96
+ },
97
+
88
98
  getVarPropertyType(varName: string): string | undefined {
89
99
  // A `@property` registration is the declared type and wins over usage inference.
90
100
  const normalizedVarName = varName.startsWith('--') ? varName : `--${varName}`
@@ -145,6 +155,10 @@ function parseAllProperties(
145
155
 
146
156
  const selectorStrings: string[] = []
147
157
 
158
+ if (this.atrule && !isInsideKeyframes(this)) {
159
+ for (const property of properties) property.inAtRule = true
160
+ }
161
+
148
162
  if (isInsideKeyframes(this)) {
149
163
  const keyframeName = this.atrule?.prelude ? generate(this.atrule.prelude).trim() : ''
150
164
  if (!keyframeName) return
@@ -522,19 +536,6 @@ function extractAtruleDescriptors(block: CssNode): Map<string, string> {
522
536
  return descriptors
523
537
  }
524
538
 
525
- /** Picks the range descriptors out of the block; css-tree collects them like any other declaration. */
526
- function extractRangeDescriptors(descriptors: Map<string, string>): RegisteredPropertyRange | undefined {
527
- const min = descriptors.get('--min')
528
- const max = descriptors.get('--max')
529
- const step = descriptors.get('--step')
530
- if (min === undefined && max === undefined && step === undefined) return undefined
531
- return {
532
- ...(min !== undefined && { min }),
533
- ...(max !== undefined && { max }),
534
- ...(step !== undefined && { step }),
535
- }
536
- }
537
-
538
539
  /**
539
540
  * Walks the stylesheet for `@property` at-rules and builds a map of registered custom
540
541
  * properties keyed by the `--`-prefixed variable name.
@@ -562,13 +563,11 @@ function parseRegisteredProperties(cssString: string): Map<string, RegisteredCus
562
563
  if (!resolved) return
563
564
  const { cssPropertyType, enumOptions } = resolved
564
565
  const initialValue = descriptors.get('initial-value')
565
- const range = extractRangeDescriptors(descriptors)
566
566
 
567
567
  registered.set(varName, {
568
568
  cssPropertyType,
569
569
  ...(enumOptions && { enumOptions }),
570
570
  ...(initialValue !== undefined && { defaultValue: initialValue }),
571
- ...(range && { range }),
572
571
  })
573
572
  },
574
573
  })
@@ -664,14 +663,8 @@ function analyzeCompound(componentNodes: CssNode[]): AnalyzedCompound | null {
664
663
  return { base, pseudoClass, modifiers }
665
664
  }
666
665
 
667
- /**
668
- * Splits a selector into compound selectors at combinators and analyzes each. A
669
- * `:global(.modifier)` (or native pseudo) belongs to the compound it is attached to,
670
- * so ancestor/descendant compounds in the same selector are ignored for the base —
671
- * e.g. `.header:global(.x--selected) .label` resolves to base `.header`, not
672
- * `.header .label`. Returns one entry per compound that carries a modifier or pseudo.
673
- */
674
- function analyzeStateSelector(selector: Selector): AnalyzedCompound[] {
666
+ /** Splits a selector into its compound selectors (the parts between combinators), in order. */
667
+ function splitIntoCompounds(selector: Selector): CssNode[][] {
675
668
  const compounds: CssNode[][] = [[]]
676
669
  for (const component of selector.children) {
677
670
  if (component.type === 'Combinator') {
@@ -680,6 +673,18 @@ function analyzeStateSelector(selector: Selector): AnalyzedCompound[] {
680
673
  compounds[compounds.length - 1].push(component)
681
674
  }
682
675
  }
676
+ return compounds
677
+ }
678
+
679
+ /**
680
+ * Splits a selector into compound selectors at combinators and analyzes each. A
681
+ * `:global(.modifier)` (or native pseudo) belongs to the compound it is attached to,
682
+ * so ancestor/descendant compounds in the same selector are ignored for the base —
683
+ * e.g. `.header:global(.x--selected) .label` resolves to base `.header`, not
684
+ * `.header .label`. Returns one entry per compound that carries a modifier or pseudo.
685
+ */
686
+ function analyzeStateSelector(selector: Selector): AnalyzedCompound[] {
687
+ const compounds = splitIntoCompounds(selector)
683
688
 
684
689
  const results: AnalyzedCompound[] = []
685
690
  for (const compoundNodes of compounds) {
@@ -747,6 +752,90 @@ function parseStateClasses(cssString: string): StateClassRule[] {
747
752
  return results
748
753
  }
749
754
 
755
+ // Dynamic pseudo-classes mapped to the design-state name a selector styles. Broader than
756
+ // NATIVE_PSEUDO_BY_NAME: `:active`/`:checked` aren't native editor states, but a rule using
757
+ // them still styles a state (e.g. the custom `active` state of `.root:global(.x--active), .root:active`)
758
+ // and must not leak into the regular default. These names follow the pseudo-class, so they do
759
+ // not line up with prop-derived custom states (`isActive` → `is-active`). `:focus-within` (a
760
+ // descendant has focus) also maps to `focus`: the schema has a single focus state per element.
761
+ const STATE_NAME_BY_PSEUDO: Record<string, string> = {
762
+ hover: 'hover',
763
+ active: 'active',
764
+ focus: 'focus',
765
+ 'focus-visible': 'focus',
766
+ 'focus-within': 'focus',
767
+ disabled: 'disabled',
768
+ invalid: 'invalid',
769
+ checked: 'checked',
770
+ }
771
+
772
+ // Pseudo-classes whose selector-list argument matches the element itself, so a state
773
+ // condition inside them (`:is(:hover, :focus)`, `:is(:global(.x--selected))`) makes the
774
+ // rule state-dependent.
775
+ const SELECTOR_LIST_PSEUDO_NAMES = new Set(['is', 'where', 'has', 'matches'])
776
+
777
+ const REGULAR_SCOPE: SelectorStateScope = { kind: 'regular' }
778
+ const UNMAPPED_STATE_SCOPE: SelectorStateScope = { kind: 'unmappedState' }
779
+
780
+ /** True when a node contains a state pseudo-class or a `:global(.modifier)` state class. */
781
+ function containsStateCondition(node: CssNode): boolean {
782
+ let found = false
783
+ walk(node, (childNode) => {
784
+ if (
785
+ childNode.type === 'PseudoClassSelector' &&
786
+ (STATE_NAME_BY_PSEUDO[childNode.name] !== undefined || childNode.name === 'global')
787
+ ) {
788
+ found = true
789
+ }
790
+ })
791
+ return found
792
+ }
793
+
794
+ /**
795
+ * Collects the design-state names a single compound selector is conditioned on: its
796
+ * `:global(.modifier)` classes (split from the base exactly as `parseStateClasses` does)
797
+ * and its dynamic pseudo-classes. Returns null when a state condition is nested in a
798
+ * selector-list pseudo (`:is(:hover)`, `:is(:global(.x--selected))`), which cannot be
799
+ * attributed to a single state.
800
+ */
801
+ function collectCompoundStateNames(compoundNodes: CssNode[]): Set<string> | null {
802
+ const stateNames = new Set<string>()
803
+ for (const modifier of analyzeCompound(compoundNodes)?.modifiers ?? []) {
804
+ stateNames.add(stateNameFromModifier(modifier))
805
+ }
806
+ for (const component of compoundNodes) {
807
+ if (component.type !== 'PseudoClassSelector') continue
808
+ const pseudoStateName = STATE_NAME_BY_PSEUDO[component.name]
809
+ if (pseudoStateName) {
810
+ stateNames.add(pseudoStateName)
811
+ } else if (SELECTOR_LIST_PSEUDO_NAMES.has(component.name) && containsStateCondition(component)) {
812
+ return null
813
+ }
814
+ }
815
+ return stateNames
816
+ }
817
+
818
+ /**
819
+ * Classifies which appearance of its subject (last) compound a selector styles. A state is
820
+ * attributed only when the subject alone carries exactly one state name; a state on an
821
+ * ancestor compound, several distinct states, or a nested state pseudo is `unmappedState`.
822
+ * `:not(...)` is ignored — `:not([dir])` and `:not(:disabled)` hold in the regular appearance.
823
+ */
824
+ function classifySelectorStateScope(selector: Selector): SelectorStateScope {
825
+ const compounds = splitIntoCompounds(selector)
826
+
827
+ const subjectCompound = compounds[compounds.length - 1]
828
+ for (const ancestorCompound of compounds.slice(0, -1)) {
829
+ const ancestorStateNames = collectCompoundStateNames(ancestorCompound)
830
+ if (!ancestorStateNames || ancestorStateNames.size > 0) return UNMAPPED_STATE_SCOPE
831
+ }
832
+
833
+ const subjectStateNames = collectCompoundStateNames(subjectCompound)
834
+ if (!subjectStateNames || subjectStateNames.size > 1) return UNMAPPED_STATE_SCOPE
835
+ const [stateName] = subjectStateNames
836
+ return stateName ? { kind: 'state', stateName } : REGULAR_SCOPE
837
+ }
838
+
750
839
  /**
751
840
  * Builds a DOM-queryable selector by filtering out pseudo-classes and pseudo-elements.
752
841
  * Returns null if the selector contains pseudo-elements (unmatchable against real DOM).
@@ -50,9 +50,9 @@ export function matchCssSelectors(
50
50
  ): { elements: ExtractedElement[]; varUsedByTraceId: Map<string, Set<string>> } {
51
51
  const $ = cheerio.load(html)
52
52
 
53
- // Build traceId -> matches map
54
- const matchesByTraceId = new Map<string, CssSelectorMatch[]>()
55
- const customPropsByTraceId = new Map<string, Record<string, string>>()
53
+ // Build traceId -> matches map, split by the appearance (regular / per-state) each selector styles
54
+ const regularByTraceId = new Map<string, ScopedCssData>()
55
+ const stateByTraceId = new Map<string, Map<string, ScopedCssData>>()
56
56
  const varUsedByTraceId = new Map<string, Set<string>>()
57
57
 
58
58
  for (const cssInfo of cssInfos) {
@@ -66,6 +66,8 @@ export function matchCssSelectors(
66
66
  const domSelector = cssInfo.api.getDomSelector(selector)
67
67
  if (!domSelector) continue
68
68
 
69
+ const stateScope = cssInfo.api.getSelectorStateScope(selector)
70
+
69
71
  try {
70
72
  const selectorSpecificity = cssInfo.api.getSelectorSpecificity(selector) ?? [0, 0, 0]
71
73
 
@@ -76,39 +78,46 @@ export function matchCssSelectors(
76
78
  // Use reduce to separate regular and custom properties in one pass
77
79
  const { regular, custom } = properties.reduce<{
78
80
  regular: CSSProperty[]
79
- custom: Record<string, string>
81
+ custom: CSSProperty[]
80
82
  }>(
81
83
  (accumulator, prop) => {
82
84
  if (prop.name.startsWith('--')) {
83
- accumulator.custom[prop.name] = prop.value
85
+ accumulator.custom.push(prop)
84
86
  } else {
85
87
  accumulator.regular.push(prop)
86
88
  }
87
89
  return accumulator
88
90
  },
89
- { regular: [], custom: {} },
91
+ { regular: [], custom: [] },
90
92
  )
91
93
 
92
- if (regular.length > 0) {
93
- const existing = matchesByTraceId.get(traceId) ?? []
94
- existing.push({ selector, properties: regular, specificity: selectorSpecificity })
95
- matchesByTraceId.set(traceId, existing)
96
-
97
- // Track which CSS custom properties are used (via var()) by this element
98
- for (const regularProp of regular) {
99
- for (const varName of regularProp.varRefs ?? []) {
100
- const traceIdSet = varUsedByTraceId.get(varName) ?? new Set()
101
- traceIdSet.add(traceId)
102
- varUsedByTraceId.set(varName, traceIdSet)
103
- }
94
+ // Track which CSS custom properties are used (via var()) by this element — in every
95
+ // scope, so a variable read only under `:hover` is still placed on its element.
96
+ for (const regularProp of regular) {
97
+ for (const varName of regularProp.varRefs ?? []) {
98
+ const traceIdSet = varUsedByTraceId.get(varName) ?? new Set()
99
+ traceIdSet.add(traceId)
100
+ varUsedByTraceId.set(varName, traceIdSet)
104
101
  }
105
102
  }
106
103
 
107
- if (Object.keys(custom).length > 0) {
108
- const existing = customPropsByTraceId.get(traceId) ?? {}
109
- Object.assign(existing, custom)
110
- customPropsByTraceId.set(traceId, existing)
104
+ // State-dependent rules that can't be attributed to one state of this element
105
+ // style neither its regular appearance nor any of its states.
106
+ if (stateScope.kind === 'unmappedState') return
107
+
108
+ const scopedData =
109
+ stateScope.kind === 'state'
110
+ ? getOrCreate(
111
+ getOrCreate(stateByTraceId, traceId, () => new Map()),
112
+ stateScope.stateName,
113
+ createScopedData,
114
+ )
115
+ : getOrCreate(regularByTraceId, traceId, createScopedData)
116
+
117
+ if (regular.length > 0) {
118
+ scopedData.matches.push({ selector, properties: regular, specificity: selectorSpecificity })
111
119
  }
120
+ for (const customProp of custom) assignCustomProperty(scopedData, customProp)
112
121
  })
113
122
  } catch {
114
123
  // Invalid selector for Cheerio, skip
@@ -117,26 +126,72 @@ export function matchCssSelectors(
117
126
  }
118
127
 
119
128
  return {
120
- elements: enrichElements(elements, matchesByTraceId, customPropsByTraceId),
129
+ elements: enrichElements(elements, regularByTraceId, stateByTraceId),
121
130
  varUsedByTraceId,
122
131
  }
123
132
  }
124
133
 
134
+ interface ScopedCssData {
135
+ matches: CssSelectorMatch[]
136
+ customProperties: Record<string, string>
137
+ /** Custom properties declared outside any at-rule — a conditional (`@media`) value never overrides them. */
138
+ topLevelCustomPropertyNames: Set<string>
139
+ }
140
+
141
+ function createScopedData(): ScopedCssData {
142
+ return { matches: [], customProperties: {}, topLevelCustomPropertyNames: new Set() }
143
+ }
144
+
145
+ /** Later declaration wins, except that an at-rule declaration never overrides a top-level one. */
146
+ function assignCustomProperty(scopedData: ScopedCssData, customProp: CSSProperty): void {
147
+ if (customProp.inAtRule && scopedData.topLevelCustomPropertyNames.has(customProp.name)) return
148
+ if (!customProp.inAtRule) scopedData.topLevelCustomPropertyNames.add(customProp.name)
149
+ scopedData.customProperties[customProp.name] = customProp.value
150
+ }
151
+
152
+ function getOrCreate<Key, Value>(map: Map<Key, Value>, key: Key, create: () => Value): Value {
153
+ const existing = map.get(key)
154
+ if (existing !== undefined) return existing
155
+ const created = create()
156
+ map.set(key, created)
157
+ return created
158
+ }
159
+
160
+ function buildStateData(
161
+ stateData: Map<string, ScopedCssData> | undefined,
162
+ ): Pick<MatchedCssData, 'stateMatches' | 'stateCustomProperties'> {
163
+ if (!stateData || stateData.size === 0) return {}
164
+ const stateMatches: Record<string, CssSelectorMatch[]> = {}
165
+ const stateCustomProperties: Record<string, Record<string, string>> = {}
166
+ for (const [stateName, scopedData] of stateData) {
167
+ if (scopedData.matches.length > 0) stateMatches[stateName] = scopedData.matches
168
+ if (Object.keys(scopedData.customProperties).length > 0) {
169
+ stateCustomProperties[stateName] = scopedData.customProperties
170
+ }
171
+ }
172
+ return {
173
+ ...(Object.keys(stateMatches).length > 0 && { stateMatches }),
174
+ ...(Object.keys(stateCustomProperties).length > 0 && { stateCustomProperties }),
175
+ }
176
+ }
177
+
125
178
  function enrichElements(
126
179
  elements: ExtractedElement[],
127
- matchesByTraceId: Map<string, CssSelectorMatch[]>,
128
- customPropsByTraceId: Map<string, Record<string, string>>,
180
+ regularByTraceId: Map<string, ScopedCssData>,
181
+ stateByTraceId: Map<string, Map<string, ScopedCssData>>,
129
182
  ): ExtractedElement[] {
130
183
  return elements.map((element) => {
184
+ const regularData = regularByTraceId.get(element.traceId)
131
185
  const data: MatchedCssData = {
132
- matches: matchesByTraceId.get(element.traceId) ?? [],
133
- customProperties: customPropsByTraceId.get(element.traceId) ?? {},
186
+ matches: regularData?.matches ?? [],
187
+ customProperties: regularData?.customProperties ?? {},
188
+ ...buildStateData(stateByTraceId.get(element.traceId)),
134
189
  }
135
190
  element.extractorData.set('css-matcher', data)
136
191
 
137
192
  return {
138
193
  ...element,
139
- children: enrichElements(element.children, matchesByTraceId, customPropsByTraceId),
194
+ children: enrichElements(element.children, regularByTraceId, stateByTraceId),
140
195
  }
141
196
  })
142
197
  }
@@ -15,6 +15,13 @@ export interface CSSProperty {
15
15
  valueAst?: CssNode
16
16
  /** CSS variable names referenced via var() in this property's value */
17
17
  varRefs?: string[]
18
+ /**
19
+ * True when the declaration sits inside an at-rule block (e.g. `@media`, `@supports`, `@layer`),
20
+ * other than `@keyframes`. A top-level declaration wins over one in an at-rule when resolving a
21
+ * default value — conditional rules don't describe the default appearance, and unlayered rules
22
+ * beat layered ones in the cascade.
23
+ */
24
+ inAtRule?: boolean
18
25
  }
19
26
 
20
27
  export interface CssSelectorMatch {
@@ -24,10 +31,25 @@ export interface CssSelectorMatch {
24
31
  }
25
32
 
26
33
  export interface MatchedCssData {
34
+ /** Matches whose selectors apply in the element's regular (non-state) appearance. */
27
35
  matches: CssSelectorMatch[]
36
+ /** Custom properties declared by regular-scope matches. */
28
37
  customProperties: Record<string, string>
38
+ /** Matches whose selectors apply only in one design state, keyed by state name (e.g. `hover`). */
39
+ stateMatches?: Record<string, CssSelectorMatch[]>
40
+ /** Custom properties declared by state-scope matches, keyed by state name. */
41
+ stateCustomProperties?: Record<string, Record<string, string>>
29
42
  }
30
43
 
44
+ /**
45
+ * Which appearance of its subject element a selector styles:
46
+ * - `regular` — the element's default appearance (no state condition on the subject).
47
+ * - `state` — exactly one design state on the subject (`.root:hover`, `.root:global(.x--hover)`).
48
+ * - `unmappedState` — state-dependent, but not attributable to a single state of the subject
49
+ * (an ancestor's state, several states combined, a state inside `:is()`).
50
+ */
51
+ export type SelectorStateScope = { kind: 'regular' } | { kind: 'state'; stateName: string } | { kind: 'unmappedState' }
52
+
31
53
  /**
32
54
  * A CSS custom property registered via the standard `@property` at-rule.
33
55
  * The `syntax` descriptor gives the type (and, for a pipe-separated ident list, a
@@ -47,23 +69,6 @@ export interface RegisteredCustomProperty {
47
69
  * (`a | b | c`). Present only when `cssPropertyType` is `customEnum`.
48
70
  */
49
71
  enumOptions?: string[]
50
- /**
51
- * The numeric range for the control, as raw descriptor strings. Validated at
52
- * conversion time. Meaningful only when `cssPropertyType` is `number`.
53
- */
54
- range?: RegisteredPropertyRange
55
- }
56
-
57
- /**
58
- * `--min` / `--max` / `--step` descriptors an importer may emit inside an `@property`
59
- * block. The `--` prefix keeps them in the author namespace: browsers ignore descriptors
60
- * they don't know, so the CSS stays valid and CSS can't standardise these names out from
61
- * under us.
62
- */
63
- export interface RegisteredPropertyRange {
64
- min?: string
65
- max?: string
66
- step?: string
67
72
  }
68
73
 
69
74
  export type NativePseudoClass = 'hover' | 'focus' | 'disabled' | 'invalid'
@@ -133,6 +138,13 @@ export interface CSSParserAPI {
133
138
  */
134
139
  getSelectorSpecificity: (selector: string) => [number, number, number] | null
135
140
 
141
+ /**
142
+ * Classifies which appearance of its subject element a selector styles — regular, a single
143
+ * design state, or a state that cannot be attributed to the subject. Returns `regular` for
144
+ * selectors that were not parsed (e.g. keyframe selectors).
145
+ */
146
+ getSelectorStateScope: (selector: string) => SelectorStateScope
147
+
136
148
  /**
137
149
  * Determines the CSS property type this one stylesheet can attribute to a custom property.
138
150
  * A `@property` registration is the top-priority source (declared type). Otherwise falls back
@@ -373,10 +373,17 @@ function extractCssInfo(cssImportPaths: string[], componentName: string): Extrac
373
373
 
374
374
  const properties = new Map<string, string>()
375
375
  const customProperties = new Map<string, string>()
376
+ const topLevelCustomPropertyNames = new Set<string>()
376
377
 
377
- for (const [, props] of allProps) {
378
+ for (const [selector, props] of allProps) {
379
+ // A state rule (`.root:hover { --x: … }`) must not supply the regular default.
380
+ const isRegularSelector = api.getSelectorStateScope(selector).kind === 'regular'
378
381
  for (const prop of props) {
379
382
  if (prop.name.startsWith('--')) {
383
+ if (!isRegularSelector) continue
384
+ // An at-rule (`@media`) value never overrides a top-level one.
385
+ if (prop.inAtRule && topLevelCustomPropertyNames.has(prop.name)) continue
386
+ if (!prop.inAtRule) topLevelCustomPropertyNames.add(prop.name)
380
387
  customProperties.set(prop.name, prop.value)
381
388
  } else {
382
389
  properties.set(prop.name, prop.value)