@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-CtX9f415.js → index-BfHROU60.js} +1 -1
- package/dist/{index-BEdi2EUy.js → index-Ps7eUbv5.js} +12159 -12035
- package/dist/index.d.ts +26 -0
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/converters/custom-states-builder.test.ts +72 -0
- package/src/converters/custom-states-builder.ts +75 -0
- package/src/converters/to-editor-component.ts +47 -3
- package/src/information-extractors/css/parse.test.ts +122 -0
- package/src/information-extractors/css/parse.ts +177 -1
- package/src/information-extractors/css/types.ts +27 -0
- package/src/utils/css-class.ts +10 -0
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-
|
|
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.
|
|
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": "
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
}
|