@wix/zero-config-implementation 1.72.0 → 1.74.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,10 +2,11 @@
2
2
  * CSS Properties Extractor
3
3
  *
4
4
  * Determines which CSS properties are relevant to each element
5
- * based on its tag, role, and whether it has text content.
5
+ * based on semantic traits and effective display behavior.
6
6
  */
7
7
 
8
8
  import { CSS_PROPERTIES } from '@wix/react-component-schema'
9
+ import { resolveCssPropertyValue } from '../../css/parse'
9
10
  import type { MatchedCssData } from '../../css/types'
10
11
  import type { ExtractedElement } from './core/tree-builder'
11
12
  import type { CreateElementEvent, ReactExtractor } from './core/types'
@@ -19,14 +20,9 @@ export interface CssPropertiesData {
19
20
  }
20
21
 
21
22
  // ─────────────────────────────────────────────────────────────────────────────
22
- // Element Categories
23
+ // Semantic Traits
23
24
  // ─────────────────────────────────────────────────────────────────────────────
24
25
 
25
- /**
26
- * Layout Containers - structural boxes focused on spacing and positioning
27
- */
28
- const LAYOUT_CONTAINER_TAGS = new Set(['div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'aside'])
29
-
30
26
  /**
31
27
  * Text & Typography - elements where readability and hierarchy are primary
32
28
  */
@@ -48,26 +44,24 @@ const TEXT_TAGS = new Set([
48
44
  'i',
49
45
  ])
50
46
 
47
+ const DEFAULT_NON_BOX_TEXT_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
48
+
51
49
  /**
52
50
  * Media & Visuals - elements requiring aspect ratio control (excluding svg)
53
51
  */
54
52
  const MEDIA_TAGS = new Set(['img', 'video', 'canvas', 'picture'])
55
53
 
56
- /**
57
- * Form Inputs & Interactive Elements - require state-based styling
58
- */
59
- const INTERACTIVE_TAGS = new Set(['button', 'input', 'textarea', 'select'])
60
-
61
54
  // ─────────────────────────────────────────────────────────────────────────────
62
55
  // CSS Property Sets (using logical properties)
63
56
  // ─────────────────────────────────────────────────────────────────────────────
64
57
 
65
58
  /**
66
- * CSS properties for Layout Containers and Interactive Elements
59
+ * CSS properties for box-generating elements. These are visual box styles,
60
+ * not child-layout controls such as `gap`.
67
61
  */
68
62
  const { CSS_PROPERTY_TYPE } = CSS_PROPERTIES
69
63
 
70
- const CONTAINER_CSS_PROPERTIES = [
64
+ const BOX_CSS_PROPERTIES = [
71
65
  CSS_PROPERTY_TYPE.background,
72
66
  CSS_PROPERTY_TYPE.borderTop,
73
67
  CSS_PROPERTY_TYPE.borderBottom,
@@ -113,81 +107,52 @@ const MEDIA_CSS_PROPERTIES = [
113
107
  CSS_PROPERTY_TYPE.boxShadow,
114
108
  ]
115
109
 
116
- // ─────────────────────────────────────────────────────────────────────────────
117
- // Category Detection
118
- // ─────────────────────────────────────────────────────────────────────────────
119
-
120
- type ElementCategory = 'layout' | 'text' | 'media' | 'interactive' | 'unknown'
121
-
122
- /**
123
- * Determines the category of an element based on its tag and role
124
- */
125
- function getElementCategory(tag: string, role?: string): ElementCategory {
110
+ function hasTextSemantics(tag: string, role?: string): boolean {
126
111
  const normalizedTag = tag.toLowerCase()
127
-
128
- // Check role-based overrides first
129
112
  if (role) {
130
113
  const normalizedRole = role.toLowerCase()
131
- if (normalizedRole === 'button' || normalizedRole === 'textbox') {
132
- return 'interactive'
133
- }
134
114
  if (normalizedRole === 'heading' || normalizedRole === 'paragraph') {
135
- return 'text'
136
- }
137
- if (normalizedRole === 'img' || normalizedRole === 'figure') {
138
- return 'media'
115
+ return true
139
116
  }
140
117
  }
118
+ return TEXT_TAGS.has(normalizedTag)
119
+ }
141
120
 
142
- // Check tag-based categories
143
- if (INTERACTIVE_TAGS.has(normalizedTag)) {
144
- return 'interactive'
145
- }
146
- if (TEXT_TAGS.has(normalizedTag)) {
147
- return 'text'
148
- }
149
- if (MEDIA_TAGS.has(normalizedTag)) {
150
- return 'media'
151
- }
152
- if (LAYOUT_CONTAINER_TAGS.has(normalizedTag)) {
153
- return 'layout'
121
+ function hasMediaSemantics(tag: string, role?: string): boolean {
122
+ const normalizedTag = tag.toLowerCase()
123
+ if (role) {
124
+ const normalizedRole = role.toLowerCase()
125
+ if (normalizedRole === 'img' || normalizedRole === 'figure') {
126
+ return true
127
+ }
154
128
  }
155
-
156
- return 'unknown'
129
+ return MEDIA_TAGS.has(normalizedTag)
157
130
  }
158
131
 
159
132
  /**
160
- * Gets CSS properties relevant to an element based on tag and role.
133
+ * Gets CSS properties relevant to an element from composable traits:
134
+ * text semantics, media semantics, and box-generating display.
161
135
  * Note: hasTextContent determination is deferred to tree building since
162
136
  * we can't know during createElement if a child will have text content.
163
137
  */
164
138
  export function getCssPropertiesForTag(tag: string, role?: string): string[] {
165
- const category = getElementCategory(tag, role)
166
139
  const properties: string[] = []
140
+ const hasTextProperties = hasTextSemantics(tag, role)
141
+ const hasMediaProperties = hasMediaSemantics(tag, role)
142
+ const hasBoxProperties = hasDefaultBoxProperties(tag, role) && !hasMediaProperties
167
143
 
168
- switch (category) {
169
- case 'layout':
170
- case 'interactive':
171
- properties.push(...CONTAINER_CSS_PROPERTIES)
172
- break
173
-
174
- case 'text':
175
- properties.push(...TEXT_CSS_PROPERTIES)
176
- break
177
-
178
- case 'media':
179
- properties.push(...MEDIA_CSS_PROPERTIES)
180
- break
181
-
182
- case 'unknown':
183
- // For unknown elements, default to container properties
184
- // (can be refined during tree building based on children)
185
- properties.push(...CONTAINER_CSS_PROPERTIES)
186
- break
144
+ if (hasTextProperties) {
145
+ properties.push(...TEXT_CSS_PROPERTIES)
146
+ }
147
+ if (hasMediaProperties) {
148
+ properties.push(...MEDIA_CSS_PROPERTIES)
149
+ }
150
+ if (hasBoxProperties) {
151
+ properties.push(...BOX_CSS_PROPERTIES)
187
152
  }
188
153
 
189
154
  properties.push(CSS_PROPERTY_TYPE.display)
190
- return properties
155
+ return [...new Set(properties)]
191
156
  }
192
157
 
193
158
  /**
@@ -204,6 +169,19 @@ export function addTextProperties(existing: string[]): string[] {
204
169
  return result
205
170
  }
206
171
 
172
+ /**
173
+ * Adds box CSS properties to an existing property list without duplicates.
174
+ */
175
+ export function addBoxProperties(existing: string[]): string[] {
176
+ const result = [...existing]
177
+ for (const property of BOX_CSS_PROPERTIES) {
178
+ if (!result.includes(property)) {
179
+ result.push(property)
180
+ }
181
+ }
182
+ return result
183
+ }
184
+
207
185
  /**
208
186
  * Adds gap CSS property to an existing property list if not already present.
209
187
  */
@@ -216,21 +194,91 @@ export function addGapProperty(existing: string[]): string[] {
216
194
  // Display Value Resolution
217
195
  // ─────────────────────────────────────────────────────────────────────────────
218
196
 
197
+ // Based on WebKit and Blink UA stylesheets. The map is intentionally limited
198
+ // to element defaults rather than pseudo-elements or internal control parts.
219
199
  const TAG_DEFAULT_DISPLAY: Record<string, string> = {
220
- span: 'inline',
221
200
  a: 'inline',
222
- strong: 'inline',
223
- em: 'inline',
201
+ address: 'block',
202
+ area: 'none',
203
+ article: 'block',
204
+ aside: 'block',
205
+ audio: 'inline',
224
206
  b: 'inline',
207
+ base: 'none',
208
+ basefont: 'none',
209
+ blockquote: 'block',
210
+ body: 'block',
211
+ button: 'inline-block',
212
+ canvas: 'inline',
213
+ caption: 'table-caption',
214
+ center: 'block',
215
+ col: 'table-column',
216
+ colgroup: 'table-column-group',
217
+ datalist: 'none',
218
+ dd: 'block',
219
+ dir: 'block',
220
+ div: 'block',
221
+ dl: 'block',
222
+ dt: 'block',
223
+ em: 'inline',
224
+ embed: 'inline',
225
+ fieldset: 'block',
226
+ figcaption: 'block',
227
+ figure: 'block',
228
+ footer: 'block',
229
+ form: 'block',
230
+ h1: 'block',
231
+ h2: 'block',
232
+ h3: 'block',
233
+ h4: 'block',
234
+ h5: 'block',
235
+ h6: 'block',
236
+ head: 'none',
237
+ header: 'block',
238
+ hgroup: 'block',
239
+ hr: 'block',
240
+ html: 'block',
225
241
  i: 'inline',
226
- label: 'inline',
242
+ iframe: 'inline',
227
243
  img: 'inline',
228
- input: 'inline',
229
- select: 'inline',
230
- textarea: 'inline',
231
- button: 'inline-block',
244
+ input: 'inline-block',
245
+ label: 'inline',
246
+ legend: 'block',
232
247
  li: 'list-item',
248
+ link: 'none',
249
+ main: 'block',
250
+ map: 'inline',
251
+ marquee: 'inline-block',
252
+ menu: 'block',
253
+ meta: 'none',
254
+ nav: 'block',
255
+ noembed: 'none',
256
+ noframes: 'none',
257
+ object: 'inline',
258
+ ol: 'block',
259
+ p: 'block',
260
+ param: 'none',
261
+ q: 'inline',
262
+ rp: 'none',
263
+ script: 'none',
264
+ search: 'block',
265
+ section: 'block',
266
+ select: 'inline-block',
267
+ span: 'inline',
268
+ strong: 'inline',
269
+ style: 'none',
233
270
  table: 'table',
271
+ tbody: 'table-row-group',
272
+ td: 'table-cell',
273
+ template: 'none',
274
+ textarea: 'inline-block',
275
+ tfoot: 'table-footer-group',
276
+ th: 'table-cell',
277
+ thead: 'table-header-group',
278
+ title: 'none',
279
+ tr: 'table-row',
280
+ ul: 'block',
281
+ video: 'inline',
234
282
  }
235
283
 
236
284
  export function getDefaultDisplayForTag(tag: string): string {
@@ -238,27 +286,44 @@ export function getDefaultDisplayForTag(tag: string): string {
238
286
  }
239
287
 
240
288
  /**
241
- * Resolves the current `display` value from matched CSS data, handling both
242
- * literal values and CSS variable references.
243
- * Returns the resolved value, or `undefined` if `display` is not declared
244
- * or uses a variable that cannot be resolved.
289
+ * Resolves the current `display` value from matched CSS data using the
290
+ * CSS-layer value resolver, including var() fallback handling.
245
291
  */
246
- export function resolveDisplayValue(matcherData: MatchedCssData): string | undefined {
292
+ function getDisplayPropertyMatch(matcherData: MatchedCssData) {
293
+ let matchedDisplayProperty: (typeof matcherData.matches)[number]['properties'][number] | undefined
294
+ let matchedDisplaySpecificity: [number, number, number] | undefined
295
+
247
296
  for (const match of matcherData.matches) {
248
297
  for (const property of match.properties) {
249
- if (property.name !== 'display') continue
250
-
251
- if (!property.varRefs || property.varRefs.length === 0) {
252
- return property.value
298
+ if (property.name === 'display') {
299
+ if (!matchedDisplaySpecificity || compareSpecificity(match.specificity, matchedDisplaySpecificity) >= 0) {
300
+ matchedDisplayProperty = property
301
+ matchedDisplaySpecificity = match.specificity
302
+ }
253
303
  }
304
+ }
305
+ }
306
+ return matchedDisplayProperty
307
+ }
254
308
 
255
- for (const varName of property.varRefs) {
256
- const resolvedValue = matcherData.customProperties[varName]
257
- if (resolvedValue !== undefined) return resolvedValue
258
- }
309
+ function compareSpecificity(
310
+ leftSpecificity: [number, number, number],
311
+ rightSpecificity: [number, number, number],
312
+ ): number {
313
+ for (let specificityIndex = 0; specificityIndex < leftSpecificity.length; specificityIndex += 1) {
314
+ const difference = leftSpecificity[specificityIndex] - rightSpecificity[specificityIndex]
315
+ if (difference !== 0) {
316
+ return difference
259
317
  }
260
318
  }
261
- return undefined
319
+
320
+ return 0
321
+ }
322
+
323
+ export function resolveDisplayValue(matcherData: MatchedCssData): string | undefined {
324
+ const displayProperty = getDisplayPropertyMatch(matcherData)
325
+ if (!displayProperty) return undefined
326
+ return resolveCssPropertyValue(displayProperty, matcherData.customProperties)
262
327
  }
263
328
 
264
329
  // ─────────────────────────────────────────────────────────────────────────────
@@ -266,20 +331,62 @@ export function resolveDisplayValue(matcherData: MatchedCssData): string | undef
266
331
  // ─────────────────────────────────────────────────────────────────────────────
267
332
 
268
333
  const FLEX_GRID_DISPLAY_VALUES = new Set(['flex', 'grid', 'inline-flex', 'inline-grid'])
334
+ const NON_CONTAINER_DISPLAY_VALUES = new Set(['inline', 'contents', 'none'])
335
+ const BOX_GENERATING_DISPLAY_VALUES = new Set([
336
+ 'block',
337
+ 'flex',
338
+ 'grid',
339
+ 'flow-root',
340
+ 'inline-block',
341
+ 'inline-flex',
342
+ 'inline-grid',
343
+ 'inline-table',
344
+ 'table',
345
+ 'list-item',
346
+ 'table-caption',
347
+ 'table-cell',
348
+ 'table-column',
349
+ 'table-column-group',
350
+ 'table-footer-group',
351
+ 'table-header-group',
352
+ 'table-row',
353
+ 'table-row-group',
354
+ ])
355
+
356
+ function normalizeDisplayValue(displayValue: string): string {
357
+ return displayValue.trim().toLowerCase().replace(/\s+/g, ' ')
358
+ }
359
+
360
+ function displayCreatesContainerBox(displayValue: string): boolean {
361
+ const normalizedDisplayValue = normalizeDisplayValue(displayValue)
362
+ if (NON_CONTAINER_DISPLAY_VALUES.has(normalizedDisplayValue)) return false
363
+
364
+ const displayTokens = normalizedDisplayValue.split(' ')
365
+
366
+ return displayTokens.some((displayToken) => BOX_GENERATING_DISPLAY_VALUES.has(displayToken))
367
+ }
368
+
369
+ function displayUsesFlexOrGridLayout(displayValue: string): boolean {
370
+ const normalizedDisplayValue = normalizeDisplayValue(displayValue)
371
+ const displayTokens = normalizedDisplayValue.split(' ')
372
+
373
+ return displayTokens.some((displayToken) => FLEX_GRID_DISPLAY_VALUES.has(displayToken))
374
+ }
269
375
 
270
376
  /**
271
377
  * Returns true if display uses a CSS variable that cannot be resolved --
272
378
  * we optimistically assume it could be flex/grid.
273
379
  */
274
380
  function hasUnresolvableDisplayVar(matcherData: MatchedCssData): boolean {
381
+ const displayProperty = getDisplayPropertyMatch(matcherData)
382
+ if (!displayProperty?.varRefs || displayProperty.varRefs.length === 0) return false
383
+ return resolveCssPropertyValue(displayProperty, matcherData.customProperties) === undefined
384
+ }
385
+
386
+ function hasDisplayDeclaration(matcherData: MatchedCssData): boolean {
275
387
  for (const match of matcherData.matches) {
276
388
  for (const property of match.properties) {
277
- if (property.name !== 'display') continue
278
- if (property.varRefs && property.varRefs.length > 0) {
279
- for (const varName of property.varRefs) {
280
- if (matcherData.customProperties[varName] === undefined) return true
281
- }
282
- }
389
+ if (property.name === 'display') return true
283
390
  }
284
391
  }
285
392
  return false
@@ -287,10 +394,46 @@ function hasUnresolvableDisplayVar(matcherData: MatchedCssData): boolean {
287
394
 
288
395
  export function hasFlexOrGridDisplay(matcherData: MatchedCssData): boolean {
289
396
  const displayValue = resolveDisplayValue(matcherData)
290
- if (displayValue !== undefined) return FLEX_GRID_DISPLAY_VALUES.has(displayValue)
397
+ if (displayValue !== undefined) return displayUsesFlexOrGridLayout(displayValue)
291
398
  return hasUnresolvableDisplayVar(matcherData)
292
399
  }
293
400
 
401
+ export function hasContainerLikeDisplay(matcherData: MatchedCssData): boolean {
402
+ const displayValue = resolveDisplayValue(matcherData)
403
+ if (displayValue !== undefined) return displayCreatesContainerBox(displayValue)
404
+ return hasUnresolvableDisplayVar(matcherData)
405
+ }
406
+
407
+ function hasContainerLikeDefaultDisplay(tag: string): boolean {
408
+ return displayCreatesContainerBox(getDefaultDisplayForTag(tag))
409
+ }
410
+
411
+ function suppressesDefaultBoxProperties(tag: string, role?: string): boolean {
412
+ const normalizedTag = tag.toLowerCase()
413
+ if (DEFAULT_NON_BOX_TEXT_TAGS.has(normalizedTag)) {
414
+ return true
415
+ }
416
+
417
+ const normalizedRole = role?.toLowerCase()
418
+ return normalizedRole === 'heading' || normalizedRole === 'paragraph'
419
+ }
420
+
421
+ function hasDefaultBoxProperties(tag: string, role?: string): boolean {
422
+ if (suppressesDefaultBoxProperties(tag, role)) {
423
+ return false
424
+ }
425
+
426
+ return hasContainerLikeDefaultDisplay(tag)
427
+ }
428
+
429
+ function hasEffectiveContainerDisplay(tag: string, role?: string, matcherData?: MatchedCssData): boolean {
430
+ if (matcherData && hasDisplayDeclaration(matcherData)) {
431
+ return hasContainerLikeDisplay(matcherData)
432
+ }
433
+
434
+ return hasDefaultBoxProperties(tag, role)
435
+ }
436
+
294
437
  /**
295
438
  * Walks the element tree and adds `gap` to relevant CSS properties
296
439
  * for elements that have display: flex|grid and more than 1 child.
@@ -316,6 +459,31 @@ export function enrichGapProperties(elements: ExtractedElement[]): ExtractedElem
316
459
  })
317
460
  }
318
461
 
462
+ /**
463
+ * Walks the element tree and adds box properties to selected text tags
464
+ * when CSS overrides `display` or default UA display creates a box.
465
+ */
466
+ export function enrichContainerProperties(elements: ExtractedElement[]): ExtractedElement[] {
467
+ return elements.map((element) => {
468
+ const role = element.attributes.role
469
+ if (hasTextSemantics(element.tag, role)) {
470
+ const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
471
+ const cssData = element.extractorData.get('css-properties') as CssPropertiesData | undefined
472
+
473
+ if (cssData && hasEffectiveContainerDisplay(element.tag, role, matcherData)) {
474
+ element.extractorData.set('css-properties', {
475
+ relevant: addBoxProperties(cssData.relevant),
476
+ })
477
+ }
478
+ }
479
+
480
+ return {
481
+ ...element,
482
+ children: enrichContainerProperties(element.children),
483
+ }
484
+ })
485
+ }
486
+
319
487
  // ─────────────────────────────────────────────────────────────────────────────
320
488
  // Factory
321
489
  // ─────────────────────────────────────────────────────────────────────────────
@@ -27,6 +27,7 @@ export type { PropTrackerData, PropTrackerExtractorState } from './prop-tracker'
27
27
  export {
28
28
  createCssPropertiesExtractor,
29
29
  enrichGapProperties,
30
+ enrichContainerProperties,
30
31
  resolveDisplayValue,
31
32
  getDefaultDisplayForTag,
32
33
  } from './css-properties'
@@ -17,6 +17,7 @@ export {
17
17
  createPropTrackerExtractor,
18
18
  createCssPropertiesExtractor,
19
19
  enrichGapProperties,
20
+ enrichContainerProperties,
20
21
  resolveDisplayValue,
21
22
  getDefaultDisplayForTag,
22
23
  inferSupportedNativeStates,
@@ -9,6 +9,7 @@ import {
9
9
  type RunExtractorsOptions,
10
10
  createCssPropertiesExtractor,
11
11
  createPropTrackerExtractor,
12
+ enrichContainerProperties,
12
13
  enrichGapProperties,
13
14
  runExtractors,
14
15
  } from './information-extractors/react'
@@ -163,7 +164,8 @@ export function processComponent(
163
164
  if (html && extractedElements.length > 0 && css.length > 0) {
164
165
  try {
165
166
  const matchResult = matchCssSelectors(html, extractedElements, css)
166
- const gapEnrichedElements = enrichGapProperties(matchResult.elements)
167
+ const containerEnrichedElements = enrichContainerProperties(matchResult.elements)
168
+ const gapEnrichedElements = enrichGapProperties(containerEnrichedElements)
167
169
  enhancedInfo = {
168
170
  ...enhancedInfo,
169
171
  elements: convertElements(gapEnrichedElements),