@wix/zero-config-implementation 1.66.0 → 1.68.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.
@@ -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
  }
@@ -0,0 +1,29 @@
1
+ import path from 'node:path'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { compileTsFile } from '../../ts-compiler'
4
+ import { extractAllComponentInfo } from './components'
5
+
6
+ const propTriggeredFixture = path.resolve(
7
+ __dirname,
8
+ '../../../../example-components/src/components/DesignStates/Custom/PropTriggered/WithMatchingClass/component.tsx',
9
+ )
10
+
11
+ describe('TS extractor — ElementState<> trigger detection', () => {
12
+ it('flags an ElementState<>-wrapped prop and unwraps its inner type', async () => {
13
+ const programResult = await compileTsFile(propTriggeredFixture)
14
+ expect(programResult.isOk()).toBe(true)
15
+ const program = programResult._unsafeUnwrap()
16
+
17
+ const components = extractAllComponentInfo(program, propTriggeredFixture)
18
+ const component = components.find((candidate) => candidate.componentName === 'PropStates')
19
+ expect(component).toBeDefined()
20
+
21
+ const isLoading = component!.props.isLoading
22
+ expect(isLoading.isStateTrigger).toBe(true)
23
+ // The `ElementState<boolean>` wrapper is unwrapped — the prop behaves as a plain boolean.
24
+ expect(isLoading.type).toBe('boolean')
25
+
26
+ // Plain booleans are never flagged as state triggers.
27
+ expect(component!.props.showLabel.isStateTrigger).toBeUndefined()
28
+ })
29
+ })
@@ -208,16 +208,27 @@ function convertComponentDoc(doc: ComponentDoc, program: ts.Program, checker: ts
208
208
 
209
209
  // Resolve the type deeply
210
210
  let declaredTypeInfo: DeclaredTypeInfo | undefined
211
+ let stateTrigger: StateTriggerInfo | undefined
211
212
  if (propSymbol) {
212
213
  const decl = propSymbol.getDeclarations()?.[0]
213
214
  if (decl) {
214
- declaredTypeInfo = getDeclaredTypeInfo(propSymbol, decl.getSourceFile(), checker)
215
+ const declSourceFile = decl.getSourceFile()
216
+ declaredTypeInfo = getDeclaredTypeInfo(propSymbol, declSourceFile, checker)
217
+ stateTrigger = detectStateTrigger(propSymbol, declSourceFile, checker)
215
218
  }
216
219
  }
217
220
 
218
- const typeString = declaredTypeInfo?.name ?? propItem.type.name
221
+ // An `ElementState<Inner>` prop should behave as its inner type everywhere except
222
+ // for the `isStateTrigger` flag, so unwrap the inner type for `type` and resolution
223
+ // (and drop the `ElementState` declared symbol so it isn't mistaken for a semantic type).
224
+ const typeString = stateTrigger?.innerTypeName ?? declaredTypeInfo?.name ?? propItem.type.name
219
225
  const resolvedType = propType
220
- ? resolveType({ type: propType, checker, typeString, declaredSymbol: declaredTypeInfo?.symbol })
226
+ ? resolveType({
227
+ type: propType,
228
+ checker,
229
+ typeString,
230
+ declaredSymbol: stateTrigger ? undefined : declaredTypeInfo?.symbol,
231
+ })
221
232
  : { kind: 'primitive' as const, value: propItem.type.name }
222
233
 
223
234
  // Convert default value
@@ -239,6 +250,7 @@ function convertComponentDoc(doc: ComponentDoc, program: ts.Program, checker: ts
239
250
  resolvedType,
240
251
  description: propItem.description || undefined,
241
252
  deprecated: tags?.deprecated !== undefined ? true : undefined,
253
+ isStateTrigger: stateTrigger ? true : undefined,
242
254
  }
243
255
  }
244
256
 
@@ -477,6 +489,63 @@ interface DeclaredTypeInfo {
477
489
  symbol?: ts.Symbol
478
490
  }
479
491
 
492
+ interface StateTriggerInfo {
493
+ /** Text of the inner type argument, e.g. `'boolean'`. */
494
+ innerTypeName: string
495
+ }
496
+
497
+ /**
498
+ * Detects when a prop is declared with the `ElementState<>` marker type from
499
+ * `@wix/react-component-utils` (e.g. `isLoading?: ElementState<boolean>`), which opts
500
+ * the prop in as the trigger for a custom design state. Returns the inner type
501
+ * argument so callers can treat the prop as its underlying type. Returns
502
+ * `undefined` for any other declaration.
503
+ */
504
+ function detectStateTrigger(
505
+ prop: ts.Symbol,
506
+ sourceFile: ts.SourceFile,
507
+ checker: ts.TypeChecker,
508
+ ): StateTriggerInfo | undefined {
509
+ const declaration = prop.getDeclarations()?.[0]
510
+ if (!declaration) return undefined
511
+
512
+ if (
513
+ !(ts.isPropertySignature(declaration) || ts.isPropertyDeclaration(declaration) || ts.isParameter(declaration)) ||
514
+ !declaration.type ||
515
+ !ts.isTypeReferenceNode(declaration.type) ||
516
+ !declaration.type.typeArguments ||
517
+ declaration.type.typeArguments.length !== 1
518
+ ) {
519
+ return undefined
520
+ }
521
+
522
+ const typeName = declaration.type.typeName
523
+ const identifier = ts.isIdentifier(typeName) ? typeName : typeName.right
524
+ if (identifier.text !== 'ElementState') return undefined
525
+ if (!isElementStateAliasFromUtils(identifier, checker)) return undefined
526
+
527
+ return { innerTypeName: declaration.type.typeArguments[0].getText(sourceFile) }
528
+ }
529
+
530
+ /**
531
+ * Confirms an identifier referencing `ElementState` resolves to the marker type
532
+ * alias exported by `@wix/react-component-utils`, rather than an unrelated local
533
+ * type that happens to be named `ElementState`.
534
+ */
535
+ function isElementStateAliasFromUtils(identifier: ts.Identifier, checker: ts.TypeChecker): boolean {
536
+ let symbol = checker.getSymbolAtLocation(identifier)
537
+ if (!symbol) return false
538
+ if ((symbol.getFlags() & ts.SymbolFlags.Alias) !== 0) {
539
+ symbol = checker.getAliasedSymbol(symbol)
540
+ }
541
+
542
+ const declaration = symbol.getDeclarations()?.[0]
543
+ if (!declaration || !ts.isTypeAliasDeclaration(declaration)) return false
544
+ if (declaration.name.text !== 'ElementState') return false
545
+
546
+ return declaration.getSourceFile().fileName.includes('react-component-utils')
547
+ }
548
+
480
549
  function getDeclaredTypeInfo(
481
550
  prop: ts.Symbol,
482
551
  sourceFile: ts.SourceFile,
@@ -51,6 +51,11 @@ export interface PropInfo {
51
51
  description?: string
52
52
  // Whether the prop is marked as deprecated via @deprecated JSDoc tag
53
53
  deprecated?: boolean
54
+ // True when the prop is wrapped in the `ElementState<>` marker type from
55
+ // @wix/react-component-utils, declaring it as the trigger for a custom design
56
+ // state. `type` and `resolvedType` reflect the unwrapped inner type, so the prop
57
+ // still behaves as a normal data prop everywhere else.
58
+ isStateTrigger?: boolean
54
59
  }
55
60
 
56
61
  export interface ComponentInfo {
@@ -46,3 +46,13 @@ export function findPreferredSemanticClass(classNames: string[]): string | undef
46
46
  const semanticClasses = classNames.filter(isGlobalSemanticClass)
47
47
  return semanticClasses.find((className) => !HAS_BEM_MODIFIER.test(className)) ?? semanticClasses[0]
48
48
  }
49
+
50
+ /**
51
+ * Derives the state name from a BEM modifier class: the segment after the last `--`
52
+ * (`custom-states--featured` → `featured`, `card__label--in-progress` → `in-progress`).
53
+ * Falls back to the whole modifier when no `--` is present.
54
+ */
55
+ export function stateNameFromModifier(modifier: string): string {
56
+ const separatorIndex = modifier.lastIndexOf('--')
57
+ return separatorIndex >= 0 ? modifier.slice(separatorIndex + 2) : modifier
58
+ }