@wix/zero-config-implementation 1.20.0 → 1.21.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.
@@ -1,3 +1,6 @@
1
1
  import { ExtractedCssInfo } from '../../index';
2
2
  import { ExtractedElement } from '../react/extractors/core/tree-builder';
3
- export declare function matchCssSelectors(html: string, elements: ExtractedElement[], cssInfos: ExtractedCssInfo[]): ExtractedElement[];
3
+ export declare function matchCssSelectors(html: string, elements: ExtractedElement[], cssInfos: ExtractedCssInfo[]): {
4
+ elements: ExtractedElement[];
5
+ varUsedByTraceId: Map<string, Set<string>>;
6
+ };
@@ -46,4 +46,14 @@ export interface CSSParserAPI {
46
46
  * @returns DOM-matchable selector string or null
47
47
  */
48
48
  getDomSelector: (selector: string) => string | null;
49
+ /**
50
+ * Determines the CSS property type for a custom property based on how it is used.
51
+ * If all usages of varName are within the same CSS property, returns that property name.
52
+ * If usages differ, returns the CSS data type inferred from the initial value
53
+ * ('color', 'length', 'number', or 'string').
54
+ * Returns undefined if the variable is never used via var().
55
+ * @param varName - The CSS variable name (with or without --)
56
+ * @param defaultValue - The initial value string of the custom property
57
+ */
58
+ getVarPropertyType: (varName: string, defaultValue: string) => string | undefined;
49
59
  }
@@ -17,6 +17,7 @@ export interface ExtractedCssInfo {
17
17
  }
18
18
  export interface ComponentInfoWithCss extends CoupledComponentInfo {
19
19
  css: ExtractedCssInfo[];
20
+ varUsedByTraceId: Map<string, Set<string>>;
20
21
  }
21
22
  /** The result of processing a single component through the manifest pipeline. */
22
23
  export interface ProcessComponentResult {
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.20.0",
7
+ "version": "1.21.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",
@@ -76,5 +76,5 @@
76
76
  ]
77
77
  }
78
78
  },
79
- "falconPackageHash": "9d8e025c881d82e1e831ca0c9455a53b725239084e43035bef8a1981"
79
+ "falconPackageHash": "3957b9b1458a26239d8d8e3088fe1996b439e4c7aa91e8add92e1305"
80
80
  }
@@ -377,7 +377,7 @@ function handleSemanticType(dataItem: DataItem, resolvedType: ResolvedType): voi
377
377
  dataItem.text = {}
378
378
  }
379
379
 
380
- const EVENT_HANDLER_ATTR_TO_DATA_TYPE: Record<string, string> = {
380
+ const EVENT_HANDLER_ATTR_TO_DATA_TYPE: Record<string, (typeof DATA_TYPE)[keyof typeof DATA_TYPE]> = {
381
381
  onclick: DATA_TYPE.onClick,
382
382
  onchange: DATA_TYPE.onChange,
383
383
  onkeypress: DATA_TYPE.onKeyPress,
@@ -24,22 +24,32 @@ import { formatDisplayName } from './utils'
24
24
  const EXCLUDED_PROPS = new Set(['id', 'className', 'elementProps', 'wix'])
25
25
 
26
26
  export function toEditorReactComponent(component: ComponentInfoWithCss): EditorReactComponent {
27
+ const nearestCommonAncestorCustomProps = buildNearestCommonAncestorCustomProps(component)
27
28
  return {
28
- editorElement: buildEditorElement(component),
29
+ editorElement: buildEditorElement(component, nearestCommonAncestorCustomProps),
29
30
  }
30
31
  }
31
32
 
32
- function buildEditorElement(component: ComponentInfoWithCss): EditorElement {
33
+ function buildEditorElement(
34
+ component: ComponentInfoWithCss,
35
+ nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
36
+ ): EditorElement {
33
37
  const rootElement = component.elements[0]
34
38
  const childElements = rootElement?.children ?? []
39
+ const rootCustomProps = rootElement ? (nearestCommonAncestorCustomProps.get(rootElement.traceId) ?? {}) : {}
35
40
 
36
41
  return {
37
42
  selector: buildSelector(rootElement),
38
43
  displayName: formatDisplayName(component.componentName),
39
44
  data: buildData(component.props, component.propUsages),
40
- elements: buildElements(childElements, component.innerElementProps, component.propUsages),
45
+ elements: buildElements(
46
+ childElements,
47
+ nearestCommonAncestorCustomProps,
48
+ component.innerElementProps,
49
+ component.propUsages,
50
+ ),
41
51
  cssProperties: buildCssProperties(rootElement),
42
- cssCustomProperties: buildCssCustomPropertiesForElement(rootElement),
52
+ cssCustomProperties: rootCustomProps,
43
53
  }
44
54
  }
45
55
 
@@ -75,6 +85,7 @@ function buildData(
75
85
 
76
86
  function buildElements(
77
87
  elements: ExtractedElement[],
88
+ nearestCommonAncestorCustomProps: Map<string, Record<string, CssCustomPropertyItem>>,
78
89
  innerElementProps?: CoupledComponentInfo['innerElementProps'],
79
90
  propUsages?: TrackingStores['propUsages'],
80
91
  ): Record<string, ElementItem> {
@@ -84,7 +95,7 @@ function buildElements(
84
95
  const elementData = innerElementProps?.get(element.traceId)
85
96
  const data = elementData && propUsages ? buildData(elementData, propUsages) : undefined
86
97
  const cssProps = buildCssProperties(element)
87
- const cssCustomProps = buildCssCustomPropertiesForElement(element)
98
+ const cssCustomProps = nearestCommonAncestorCustomProps.get(element.traceId) ?? {}
88
99
 
89
100
  result[element.name] = {
90
101
  elementType: ELEMENTS.ELEMENT_TYPE.inlineElement,
@@ -95,11 +106,13 @@ function buildElements(
95
106
  ...(data && Object.keys(data).length > 0 && { data }),
96
107
  // CSS properties from heuristic + matched CSS files
97
108
  ...(Object.keys(cssProps).length > 0 && { cssProperties: cssProps }),
98
- // CSS custom properties from matched rules for this element
109
+ // CSS custom properties placed on the Nearest Common Ancestor of all elements that use each variable
99
110
  ...(Object.keys(cssCustomProps).length > 0 && { cssCustomProperties: cssCustomProps }),
100
111
  // Recursively build nested elements
101
112
  elements:
102
- element.children.length > 0 ? buildElements(element.children, innerElementProps, propUsages) : undefined,
113
+ element.children.length > 0
114
+ ? buildElements(element.children, nearestCommonAncestorCustomProps, innerElementProps, propUsages)
115
+ : undefined,
103
116
  },
104
117
  }
105
118
  }
@@ -107,6 +120,174 @@ function buildElements(
107
120
  return result
108
121
  }
109
122
 
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // CSS Custom Properties — Nearest Common Ancestor computation
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Pre-computes a map of traceId → custom property items to assign to each element.
129
+ * Each custom property is placed on the nearest common ancestor (Nearest Common Ancestor) of all elements
130
+ * that use it via var(). Falls back to the element that defines it if unused.
131
+ */
132
+ function buildNearestCommonAncestorCustomProps(
133
+ component: ComponentInfoWithCss,
134
+ ): Map<string, Record<string, CssCustomPropertyItem>> {
135
+ const parentMap = buildParentMap(component.elements)
136
+
137
+ // Collect default values from matched elements only — this excludes values from theme/modifier
138
+ // selectors that were not applied during mock rendering (e.g. .component.dark when variant='light').
139
+ const allCustomPropertyValues = collectMatchedCustomPropertyValues(component.elements)
140
+
141
+ // Fall back to the raw CSS scan for any vars not captured by element matching
142
+ // (e.g. vars defined in a selector that matched no element in the rendered output).
143
+ for (const cssInfo of component.css) {
144
+ for (const [varName, defaultValue] of cssInfo.customProperties) {
145
+ if (!allCustomPropertyValues.has(varName)) {
146
+ allCustomPropertyValues.set(varName, defaultValue)
147
+ }
148
+ }
149
+ }
150
+
151
+ const nearestCommonAncestorCustomProps = new Map<string, Record<string, CssCustomPropertyItem>>()
152
+
153
+ for (const [varName, defaultValue] of allCustomPropertyValues) {
154
+ if (varName === '--display') continue
155
+
156
+ const usedByTraceIds = component.varUsedByTraceId.get(varName) ?? new Set<string>()
157
+
158
+ let nearestCommonAncestorTraceId: string | undefined
159
+ if (usedByTraceIds.size > 0) {
160
+ nearestCommonAncestorTraceId = findNearestCommonAncestor(usedByTraceIds, parentMap)
161
+ } else {
162
+ // Fallback: place on the element whose CSS selector defines the variable
163
+ nearestCommonAncestorTraceId = findDefiningElement(component.elements, varName)
164
+ }
165
+
166
+ if (!nearestCommonAncestorTraceId) continue
167
+
168
+ const cssPropertyType = getVarPropertyTypeFromCssInfos(varName, defaultValue, component.css)
169
+ const cleanVarName = varName.startsWith('--') ? varName.slice(2) : varName
170
+
171
+ const existingProps = nearestCommonAncestorCustomProps.get(nearestCommonAncestorTraceId) ?? {}
172
+ existingProps[cleanVarName] = {
173
+ defaultValue,
174
+ ...(cssPropertyType !== undefined && { cssPropertyType }),
175
+ }
176
+ nearestCommonAncestorCustomProps.set(nearestCommonAncestorTraceId, existingProps)
177
+ }
178
+
179
+ return nearestCommonAncestorCustomProps
180
+ }
181
+
182
+ /**
183
+ * Queries each CSS parser API for the property type of a variable and returns
184
+ * the first defined result.
185
+ */
186
+ function getVarPropertyTypeFromCssInfos(
187
+ varName: string,
188
+ defaultValue: string,
189
+ cssInfos: ComponentInfoWithCss['css'],
190
+ ): CssCustomPropertyItem['cssPropertyType'] {
191
+ for (const cssInfo of cssInfos) {
192
+ const propertyType = cssInfo.api.getVarPropertyType(varName, defaultValue)
193
+ if (propertyType !== undefined) return propertyType as CssCustomPropertyItem['cssPropertyType']
194
+ }
195
+ return undefined
196
+ }
197
+
198
+ /**
199
+ * Walks the element tree and collects custom property default values from the CSS selector
200
+ * matches recorded on each element. Only values from selectors that matched during mock rendering
201
+ * are included, so theme/modifier variant values (e.g. .component.dark) are excluded when the
202
+ * corresponding class was not present on the rendered element.
203
+ */
204
+ function collectMatchedCustomPropertyValues(elements: ExtractedElement[]): Map<string, string> {
205
+ const values = new Map<string, string>()
206
+ for (const element of elements) {
207
+ const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
208
+ if (matcherData) {
209
+ for (const [varName, varValue] of Object.entries(matcherData.customProperties)) {
210
+ if (!values.has(varName)) values.set(varName, varValue)
211
+ }
212
+ }
213
+ for (const [varName, varValue] of collectMatchedCustomPropertyValues(element.children)) {
214
+ if (!values.has(varName)) values.set(varName, varValue)
215
+ }
216
+ }
217
+ return values
218
+ }
219
+
220
+ /**
221
+ * Builds a map of traceId → parent traceId by recursively walking the element tree.
222
+ */
223
+ function buildParentMap(elements: ExtractedElement[], parentTraceId?: string): Map<string, string> {
224
+ const parentMap = new Map<string, string>()
225
+ for (const element of elements) {
226
+ if (parentTraceId !== undefined) {
227
+ parentMap.set(element.traceId, parentTraceId)
228
+ }
229
+ for (const [childTraceId, childParentTraceId] of buildParentMap(element.children, element.traceId)) {
230
+ parentMap.set(childTraceId, childParentTraceId)
231
+ }
232
+ }
233
+ return parentMap
234
+ }
235
+
236
+ /**
237
+ * Returns the path from the root element down to the given traceId (inclusive).
238
+ */
239
+ function pathFromRoot(traceId: string, parentMap: Map<string, string>): string[] {
240
+ const path = [traceId]
241
+ let currentTraceId = traceId
242
+ while (parentMap.has(currentTraceId)) {
243
+ currentTraceId = parentMap.get(currentTraceId) as string
244
+ path.unshift(currentTraceId)
245
+ }
246
+ return path
247
+ }
248
+
249
+ /**
250
+ * Finds the nearest common ancestor traceId for a set of element traceIds.
251
+ * If a single traceId is given, returns it directly.
252
+ */
253
+ function findNearestCommonAncestor(traceIds: Set<string>, parentMap: Map<string, string>): string {
254
+ const traceIdList = [...traceIds]
255
+ if (traceIdList.length === 1) return traceIdList[0]
256
+
257
+ const paths = traceIdList.map((traceId) => pathFromRoot(traceId, parentMap))
258
+ const shortestPathLength = Math.min(...paths.map((path) => path.length))
259
+
260
+ let nearestCommonAncestorTraceId = paths[0][0]
261
+ for (let depth = 0; depth < shortestPathLength; depth++) {
262
+ const candidateTraceId = paths[0][depth]
263
+ if (paths.every((path) => path[depth] === candidateTraceId)) {
264
+ nearestCommonAncestorTraceId = candidateTraceId
265
+ } else {
266
+ break
267
+ }
268
+ }
269
+
270
+ return nearestCommonAncestorTraceId
271
+ }
272
+
273
+ /**
274
+ * Finds the traceId of the first element in the tree whose matched CSS rules
275
+ * define the given custom property (fallback when var() usage is not tracked).
276
+ */
277
+ function findDefiningElement(elements: ExtractedElement[], varName: string): string | undefined {
278
+ for (const element of elements) {
279
+ const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
280
+ if (matcherData?.customProperties[varName] !== undefined) return element.traceId
281
+ const childResult = findDefiningElement(element.children, varName)
282
+ if (childResult !== undefined) return childResult
283
+ }
284
+ return undefined
285
+ }
286
+
287
+ // ─────────────────────────────────────────────────────────────────────────────
288
+ // CSS Properties helpers
289
+ // ─────────────────────────────────────────────────────────────────────────────
290
+
110
291
  /**
111
292
  * Returns the CSS property values matched for this specific element's selector(s).
112
293
  * Reads from css-matcher.matches which is populated by matchCssSelectors.
@@ -146,30 +327,3 @@ function buildCssProperties(element: ExtractedElement | undefined): Record<strin
146
327
 
147
328
  return result
148
329
  }
149
-
150
- /**
151
- * Build cssCustomProperties for a single element from this element's matched rules only.
152
- * Only includes --vars that were declared in a rule that matched this element's selector(s).
153
- */
154
- function buildCssCustomPropertiesForElement(
155
- element: ExtractedElement | undefined,
156
- ): Record<string, CssCustomPropertyItem> {
157
- const matcherData = element?.extractorData.get('css-matcher') as MatchedCssData | undefined
158
- const customProperties = matcherData?.customProperties
159
- if (!customProperties || Object.keys(customProperties).length === 0) {
160
- return {}
161
- }
162
-
163
- const result: Record<string, CssCustomPropertyItem> = {}
164
- for (const [name, value] of Object.entries(customProperties)) {
165
- // Skip --display (handled in regular properties)
166
- if (name === '--display') continue
167
-
168
- // Strip the -- prefix from the name
169
- const cleanName = name.startsWith('--') ? name.slice(2) : name
170
- result[cleanName] = {
171
- defaultValue: value,
172
- }
173
- }
174
- return result
175
- }
@@ -1,4 +1,7 @@
1
+ import { CSS_PROPERTIES } from '@wix/zero-config-schema'
2
+ import { camelCase } from 'case-anything'
1
3
  import { transform } from 'lightningcss'
4
+ import type { TokenOrValue } from 'lightningcss'
2
5
  import type { CSSParserAPI, CSSProperty } from './types'
3
6
  // Store parsed selectors for later DOM selector computation
4
7
  const parsedSelectors = new Map<string, unknown[]>()
@@ -13,7 +16,7 @@ export function parseCss(cssString: string): CSSParserAPI {
13
16
  parsedSelectors.clear()
14
17
 
15
18
  // Parse all properties eagerly at initialization using lightningcss
16
- const allProperties = parseAllProperties(cssString)
19
+ const { propertiesMap: allProperties, varUsagesByProperty } = parseAllProperties(cssString)
17
20
 
18
21
  return {
19
22
  getPropertiesForSelector(selector: string): CSSProperty[] {
@@ -25,27 +28,8 @@ export function parseCss(cssString: string): CSSParserAPI {
25
28
  },
26
29
 
27
30
  getVarUsages(varName: string): string[] {
28
- // Normalize variable name to include --
29
31
  const normalizedVarName = varName.startsWith('--') ? varName : `--${varName}`
30
-
31
- const propertyNames = new Set<string>()
32
-
33
- // Search through all properties for var() usage
34
- for (const properties of allProperties.values()) {
35
- for (const property of properties) {
36
- // Check if the value contains var(--varName)
37
- const varPattern = new RegExp(`var\\(\\s*${escapeRegex(normalizedVarName)}\\s*[,)]`, 'i')
38
-
39
- if (varPattern.test(property.value)) {
40
- // Ignore shorthand properties (values with multiple space-separated values)
41
- if (!isShorthandValue(property.value)) {
42
- propertyNames.add(property.name)
43
- }
44
- }
45
- }
46
- }
47
-
48
- return Array.from(propertyNames)
32
+ return [...(varUsagesByProperty.get(normalizedVarName) ?? [])]
49
33
  },
50
34
 
51
35
  getUniqueProperties(selectors: string[]): Map<string, string> {
@@ -76,17 +60,35 @@ export function parseCss(cssString: string): CSSParserAPI {
76
60
  if (!parsed) return null
77
61
  return buildDomSelector(parsed)
78
62
  },
63
+
64
+ getVarPropertyType(varName: string, defaultValue: string): string | undefined {
65
+ const usages = this.getVarUsages(varName)
66
+ if (usages.length === 0) return undefined
67
+ const uniqueProperties = [...new Set(usages)]
68
+ if (uniqueProperties.length === 1) {
69
+ const camelCased = camelCase(uniqueProperties[0])
70
+ const validValues = Object.values(CSS_PROPERTIES.CSS_PROPERTY_TYPE) as string[]
71
+ if (validValues.includes(camelCased)) return camelCased
72
+ }
73
+ return inferCssDataType(defaultValue)
74
+ },
79
75
  }
80
76
  }
81
77
 
82
78
  /**
83
- * Parses all selectors and their properties from the CSS using lightningcss
79
+ * Parses all selectors and their properties from the CSS using lightningcss.
80
+ * Also builds a direct map of var() usages by scanning unparsed declaration tokens,
81
+ * since serialized property values lose var() references.
84
82
  * Supports @media rules, @keyframes, and nested selectors
85
83
  * @param cssString - CSS string to parse
86
- * @returns Map from selector to its CSS properties
84
+ * @returns propertiesMap (selector properties) and varUsagesByProperty (varName → CSS property names)
87
85
  */
88
- function parseAllProperties(cssString: string): Map<string, CSSProperty[]> {
86
+ function parseAllProperties(cssString: string): {
87
+ propertiesMap: Map<string, CSSProperty[]>
88
+ varUsagesByProperty: Map<string, Set<string>>
89
+ } {
89
90
  const propertiesMap = new Map<string, CSSProperty[]>()
91
+ const varUsagesByProperty = new Map<string, Set<string>>()
90
92
 
91
93
  try {
92
94
  // Use lightningcss transform with visitor to collect properties
@@ -113,10 +115,26 @@ function parseAllProperties(cssString: string): Map<string, CSSProperty[]> {
113
115
  // Extract properties from declarations
114
116
  const properties: CSSProperty[] = []
115
117
  for (const decl of rule.value.declarations?.declarations ?? []) {
116
- const extracted = extractPropertyNameAndValue(decl)
118
+ const declTyped = decl as LightningDecl
119
+ const extracted = extractPropertyNameAndValue(declTyped)
117
120
  if (extracted) {
118
121
  properties.push(extracted)
119
122
  }
123
+
124
+ // Track var() usages by scanning unparsed declaration tokens directly.
125
+ // propertyValueToString cannot recover var() from unparsed values, so
126
+ // we inspect the token array instead.
127
+ if (declTyped.property === 'unparsed') {
128
+ const unparsed = declTyped.value as { propertyId?: { property?: string }; value?: unknown[] }
129
+ const cssPropertyName = unparsed.propertyId?.property
130
+ if (cssPropertyName) {
131
+ for (const varName of extractVarNamesFromTokens(unparsed.value ?? [])) {
132
+ const usageSet = varUsagesByProperty.get(varName) ?? new Set<string>()
133
+ usageSet.add(cssPropertyName)
134
+ varUsagesByProperty.set(varName, usageSet)
135
+ }
136
+ }
137
+ }
120
138
  }
121
139
 
122
140
  // Store properties for each selector
@@ -176,7 +194,36 @@ function parseAllProperties(cssString: string): Map<string, CSSProperty[]> {
176
194
  console.error('CSS parsing error:', error)
177
195
  }
178
196
 
179
- return propertiesMap
197
+ return { propertiesMap, varUsagesByProperty }
198
+ }
199
+
200
+ /**
201
+ * Extracts all CSS custom property names referenced via var() from a lightningcss token array.
202
+ * Handles nested token arrays recursively (e.g. tokens inside function arguments).
203
+ */
204
+ function extractVarNamesFromTokens(tokens: unknown[]): string[] {
205
+ const varNames: string[] = []
206
+
207
+ for (const token of tokens) {
208
+ if (!token || typeof token !== 'object') continue
209
+ const tokenObj = token as Record<string, unknown>
210
+
211
+ if (tokenObj.type === 'var') {
212
+ const varValue = tokenObj.value as Record<string, unknown> | undefined
213
+ const nameObj = varValue?.name as Record<string, unknown> | undefined
214
+ const identName = nameObj?.ident as string | undefined
215
+ if (identName) {
216
+ varNames.push(identName.startsWith('--') ? identName : `--${identName}`)
217
+ }
218
+ } else if (tokenObj.type === 'function' || Array.isArray(tokenObj.value)) {
219
+ const nestedTokens = tokenObj.value as unknown[]
220
+ if (Array.isArray(nestedTokens)) {
221
+ varNames.push(...extractVarNamesFromTokens(nestedTokens))
222
+ }
223
+ }
224
+ }
225
+
226
+ return varNames
180
227
  }
181
228
 
182
229
  /**
@@ -271,7 +318,7 @@ interface LightningDecl {
271
318
 
272
319
  interface CustomPropertyValue {
273
320
  name: string
274
- value: unknown[]
321
+ value: TokenOrValue[]
275
322
  }
276
323
 
277
324
  /**
@@ -339,19 +386,33 @@ function serializeCustomPropertyValue(valueArray: unknown[]): string {
339
386
  parts.push(`rgba(${r}, ${g}, ${b}, ${a})`)
340
387
  }
341
388
  }
342
- } else if (t.type === 'length' || t.type === 'dimension') {
343
- const dim = (t.value as Record<string, unknown>) ?? t
344
- const unit = dim.unit as string
345
- const val = dim.value as number
346
- parts.push(`${val}${unit}`)
347
- } else if (t.type === 'number') {
348
- parts.push(String(t.value))
389
+ } else if (t.type === 'length' || t.type === 'resolution') {
390
+ // length/resolution: { unit: string, value: number }
391
+ const dim = t.value as Record<string, unknown>
392
+ parts.push(`${dim.value}${dim.unit}`)
393
+ } else if (t.type === 'angle') {
394
+ // angle: { type: 'deg' | 'rad' | 'grad' | 'turn', value: number }
395
+ const dim = t.value as Record<string, unknown>
396
+ parts.push(`${dim.value}${dim.type}`)
397
+ } else if (t.type === 'time') {
398
+ // time: { type: 'seconds' | 'milliseconds', value: number }
399
+ const dim = t.value as Record<string, unknown>
400
+ const unit = dim.type === 'milliseconds' ? 'ms' : 's'
401
+ parts.push(`${dim.value}${unit}`)
349
402
  } else if (t.type === 'token') {
350
403
  const tokenValue = t.value as Record<string, unknown>
351
404
  if (tokenValue.type === 'ident') {
352
405
  parts.push(tokenValue.value as string)
353
406
  } else if (tokenValue.type === 'string') {
354
407
  parts.push(`"${tokenValue.value}"`)
408
+ } else if (tokenValue.type === 'number') {
409
+ parts.push(String(tokenValue.value))
410
+ } else if (tokenValue.type === 'percentage') {
411
+ parts.push(`${(tokenValue.value as number) * 100}%`)
412
+ } else if (tokenValue.type === 'dimension') {
413
+ parts.push(`${tokenValue.value}${tokenValue.unit}`)
414
+ } else if (tokenValue.type === 'white-space') {
415
+ parts.push(tokenValue.value as string)
355
416
  }
356
417
  }
357
418
  }
@@ -448,3 +509,69 @@ function isShorthandValue(value: string): boolean {
448
509
  function escapeRegex(str: string): string {
449
510
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
450
511
  }
512
+
513
+ const ANGLE_UNITS = new Set(['deg', 'rad', 'grad', 'turn'])
514
+ const TIME_UNITS = new Set(['s', 'ms'])
515
+
516
+ /**
517
+ * Infers the CSS data type category from a custom property's initial value string.
518
+ * Uses lightningcss to parse the value and inspect the first token's type.
519
+ * Returns a value from CSS_PROPERTIES.CSS_DATA_TYPE.
520
+ */
521
+ function inferCssDataType(defaultValue: string): string {
522
+ const { CSS_DATA_TYPE } = CSS_PROPERTIES
523
+ let inferredType: string = CSS_DATA_TYPE.string
524
+
525
+ try {
526
+ transform({
527
+ filename: 'input.css',
528
+ code: Buffer.from(`.x { --foo: ${defaultValue}; }`),
529
+ minify: false,
530
+ visitor: {
531
+ Rule: {
532
+ style(rule) {
533
+ for (const declaration of rule.value.declarations?.declarations ?? []) {
534
+ const decl = declaration as LightningDecl
535
+ if (decl.property === 'custom') {
536
+ const tokens = (decl.value as CustomPropertyValue).value
537
+ if (tokens?.length > 0) {
538
+ const firstToken = tokens[0]
539
+ if (firstToken.type === 'color') {
540
+ inferredType = CSS_DATA_TYPE.color
541
+ } else if (firstToken.type === 'length') {
542
+ inferredType = CSS_DATA_TYPE.length
543
+ } else if (firstToken.type === 'angle') {
544
+ inferredType = CSS_DATA_TYPE.angle
545
+ } else if (firstToken.type === 'time') {
546
+ inferredType = CSS_DATA_TYPE.time
547
+ } else if (firstToken.type === 'token') {
548
+ const raw = firstToken.value
549
+ if (raw.type === 'number') {
550
+ inferredType = CSS_DATA_TYPE.number
551
+ } else if (raw.type === 'percentage') {
552
+ inferredType = CSS_DATA_TYPE.percentage
553
+ } else if (raw.type === 'dimension') {
554
+ const unit = raw.unit.toString()
555
+ if (ANGLE_UNITS.has(unit)) {
556
+ inferredType = CSS_DATA_TYPE.angle
557
+ } else if (TIME_UNITS.has(unit)) {
558
+ inferredType = CSS_DATA_TYPE.time
559
+ } else {
560
+ inferredType = CSS_DATA_TYPE.length
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }
566
+ }
567
+ return undefined
568
+ },
569
+ },
570
+ },
571
+ })
572
+ } catch {
573
+ // Fall back to 'string' if parsing fails
574
+ }
575
+
576
+ return inferredType
577
+ }
@@ -8,12 +8,13 @@ export function matchCssSelectors(
8
8
  html: string,
9
9
  elements: ExtractedElement[],
10
10
  cssInfos: ExtractedCssInfo[],
11
- ): ExtractedElement[] {
11
+ ): { elements: ExtractedElement[]; varUsedByTraceId: Map<string, Set<string>> } {
12
12
  const $ = cheerio.load(html)
13
13
 
14
14
  // Build traceId -> matches map
15
15
  const matchesByTraceId = new Map<string, CssSelectorMatch[]>()
16
16
  const customPropsByTraceId = new Map<string, Record<string, string>>()
17
+ const varUsedByTraceId = new Map<string, Set<string>>()
17
18
 
18
19
  for (const cssInfo of cssInfos) {
19
20
  const allProps = cssInfo.api.getAllProperties()
@@ -51,6 +52,15 @@ export function matchCssSelectors(
51
52
  const existing = matchesByTraceId.get(traceId) ?? []
52
53
  existing.push({ selector, properties: regular })
53
54
  matchesByTraceId.set(traceId, existing)
55
+
56
+ // Track which CSS custom properties are used (via var()) by this element
57
+ for (const regularProp of regular) {
58
+ for (const varName of extractVarRefs(regularProp.value)) {
59
+ const traceIdSet = varUsedByTraceId.get(varName) ?? new Set()
60
+ traceIdSet.add(traceId)
61
+ varUsedByTraceId.set(varName, traceIdSet)
62
+ }
63
+ }
54
64
  }
55
65
 
56
66
  if (Object.keys(custom).length > 0) {
@@ -65,7 +75,24 @@ export function matchCssSelectors(
65
75
  }
66
76
  }
67
77
 
68
- return enrichElements(elements, matchesByTraceId, customPropsByTraceId)
78
+ return {
79
+ elements: enrichElements(elements, matchesByTraceId, customPropsByTraceId),
80
+ varUsedByTraceId,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Extracts all CSS custom property names referenced via var() in a property value string.
86
+ */
87
+ function extractVarRefs(value: string): string[] {
88
+ const varNames: string[] = []
89
+ const varPattern = /var\(\s*(--[\w-]+)/g
90
+ let varMatch = varPattern.exec(value)
91
+ while (varMatch !== null) {
92
+ varNames.push(varMatch[1])
93
+ varMatch = varPattern.exec(value)
94
+ }
95
+ return varNames
69
96
  }
70
97
 
71
98
  function enrichElements(
@@ -53,4 +53,15 @@ export interface CSSParserAPI {
53
53
  * @returns DOM-matchable selector string or null
54
54
  */
55
55
  getDomSelector: (selector: string) => string | null
56
+
57
+ /**
58
+ * Determines the CSS property type for a custom property based on how it is used.
59
+ * If all usages of varName are within the same CSS property, returns that property name.
60
+ * If usages differ, returns the CSS data type inferred from the initial value
61
+ * ('color', 'length', 'number', or 'string').
62
+ * Returns undefined if the variable is never used via var().
63
+ * @param varName - The CSS variable name (with or without --)
64
+ * @param defaultValue - The initial value string of the custom property
65
+ */
66
+ getVarPropertyType: (varName: string, defaultValue: string) => string | undefined
56
67
  }