@wix/zero-config-implementation 1.101.0 → 1.103.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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.101.0",
7
+ "version": "1.103.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",
@@ -106,5 +106,5 @@
106
106
  ]
107
107
  }
108
108
  },
109
- "falconPackageHash": "7aa5841bf5ff6a0fa0800fc3d71a267d6e96dbe084125f5fd56fc333"
109
+ "falconPackageHash": "37bdab4bfdcda072c3c9d9edbfc88017a9270a42765e4b943aa0f51b"
110
110
  }
@@ -393,4 +393,31 @@ describe('resolveLonghand', () => {
393
393
  expect(resolveLonghand('textDecorationLine', values)).toBe('overline')
394
394
  })
395
395
  })
396
+
397
+ describe('flexDirection extraction from flex-flow shorthand', () => {
398
+ it('extracts the lone direction keyword', () => {
399
+ expect(resolveLonghand('flexDirection', new Map([['flexFlow', 'column']]))).toBe('column')
400
+ })
401
+
402
+ it('ignores token order — direction keyword can appear anywhere', () => {
403
+ expect(resolveLonghand('flexDirection', new Map([['flexFlow', 'wrap column-reverse']]))).toBe('column-reverse')
404
+ })
405
+
406
+ it('defaults to row when the shorthand carries only a wrap keyword', () => {
407
+ expect(resolveLonghand('flexDirection', new Map([['flexFlow', 'wrap']]))).toBe('row')
408
+ expect(resolveLonghand('flexDirection', new Map([['flexFlow', 'nowrap']]))).toBe('row')
409
+ })
410
+
411
+ it('returns undefined when flexFlow is absent', () => {
412
+ expect(resolveLonghand('flexDirection', new Map())).toBeUndefined()
413
+ })
414
+
415
+ it('prefers literal flexDirection over extracting from shorthand', () => {
416
+ const values = new Map([
417
+ ['flexDirection', 'row-reverse'],
418
+ ['flexFlow', 'column wrap'],
419
+ ])
420
+ expect(resolveLonghand('flexDirection', values)).toBe('row-reverse')
421
+ })
422
+ })
396
423
  })
@@ -217,14 +217,20 @@ const fromTextDecorationLine: Fallback = (values) => {
217
217
 
218
218
  /**
219
219
  * `flex-flow: <flex-direction> || <flex-wrap>` — order is free and either
220
- * part may be omitted. Pick out just the direction keyword for the schema.
220
+ * part may be omitted. Pick out the direction keyword for the schema, or the
221
+ * spec's default (`row`) when the developer only wrote a wrap keyword, e.g.
222
+ * `flex-flow: wrap`.
221
223
  */
222
224
  const FLEX_DIRECTION_KEYWORDS = new Set(['row', 'row-reverse', 'column', 'column-reverse'])
225
+ const DEFAULT_FLEX_DIRECTION = 'row'
223
226
 
224
227
  const fromFlexFlowDirection: Fallback = (values) => {
225
228
  const shorthand = values.get('flexFlow')
226
229
  if (shorthand === undefined) return undefined
227
- return splitTopLevelTokens(shorthand).find((token) => FLEX_DIRECTION_KEYWORDS.has(token.toLowerCase()))
230
+ const directionToken = splitTopLevelTokens(shorthand).find((token) =>
231
+ FLEX_DIRECTION_KEYWORDS.has(token.toLowerCase()),
232
+ )
233
+ return directionToken ?? DEFAULT_FLEX_DIRECTION
228
234
  }
229
235
 
230
236
  // ─────────────────────────────────────────────────────────────────────────────
@@ -6,6 +6,7 @@ import type { ExtractedElement } from './core/tree-builder'
6
6
  import {
7
7
  enrichContainerProperties,
8
8
  enrichFlexProperties,
9
+ enrichMarginProperties,
9
10
  getCssPropertiesForTag,
10
11
  getDefaultDisplayForTag,
11
12
  hasContainerLikeDisplay,
@@ -541,3 +542,71 @@ describe('enrichContainerProperties', () => {
541
542
  expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
542
543
  })
543
544
  })
545
+
546
+ describe('enrichMarginProperties', () => {
547
+ it('adds the block-axis margins to a non-replaced inline element whose matched CSS changes display to block', () => {
548
+ const elements = [createElementWithCssProperties('span', 'display: block')]
549
+
550
+ const [enrichedElement] = enrichMarginProperties(elements)
551
+ const cssData = enrichedElement.extractorData.get('css-properties') as { relevant: string[] }
552
+
553
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginTop)
554
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginBottom)
555
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineStart)
556
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineEnd)
557
+ })
558
+
559
+ it('adds the full margin family to a non-replaced inline element whose matched CSS changes display to inline-flex', () => {
560
+ const elements = [createElementWithCssProperties('span', 'display: inline-flex')]
561
+
562
+ const [enrichedElement] = enrichMarginProperties(elements)
563
+ const cssData = enrichedElement.extractorData.get('css-properties') as { relevant: string[] }
564
+
565
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginTop)
566
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginBottom)
567
+ })
568
+
569
+ it('keeps only the horizontal margins when a span stays inline', () => {
570
+ const elements = [createElementWithCssProperties('span', 'display: inline')]
571
+
572
+ const [enrichedElement] = enrichMarginProperties(elements)
573
+ const cssData = enrichedElement.extractorData.get('css-properties') as { relevant: string[] }
574
+
575
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineStart)
576
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineEnd)
577
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginTop)
578
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginBottom)
579
+ })
580
+
581
+ it('removes all margin from an element whose matched CSS changes display to an internal table value', () => {
582
+ const elements = [createElementWithCssProperties('div', 'display: table-cell')]
583
+
584
+ const [enrichedElement] = enrichMarginProperties(elements)
585
+ const cssData = enrichedElement.extractorData.get('css-properties') as { relevant: string[] }
586
+
587
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginTop)
588
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginBottom)
589
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineStart)
590
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineEnd)
591
+ })
592
+
593
+ it('leaves the tag-default margin guess alone when no CSS declares display', () => {
594
+ const elements = [createElementWithCssProperties('span', '')]
595
+
596
+ const [enrichedElement] = enrichMarginProperties(elements)
597
+ const cssData = enrichedElement.extractorData.get('css-properties') as { relevant: string[] }
598
+
599
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginInlineStart)
600
+ expect(cssData.relevant).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginTop)
601
+ })
602
+
603
+ it('corrects margin via the --display root convention, not just a literal display declaration', () => {
604
+ const elements = [createElementWithCssProperties('span', '--display: block')]
605
+
606
+ const [enrichedElement] = enrichMarginProperties(elements)
607
+ const cssData = enrichedElement.extractorData.get('css-properties') as { relevant: string[] }
608
+
609
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginTop)
610
+ expect(cssData.relevant).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.marginBottom)
611
+ })
612
+ })
@@ -135,26 +135,35 @@ const MARGIN_UNSUPPORTED_DISPLAY_VALUES = new Set([
135
135
  ])
136
136
 
137
137
  /**
138
- * Margin properties relevant to a tag's default display. Internal table
138
+ * Margin properties relevant to a given `display` value. Internal table
139
139
  * display values (`tr`, `thead`/`tbody`/`tfoot`, `col`/`colgroup`, `td`/`th`)
140
140
  * don't honor margin at all. Non-replaced inline elements (`span`, `a`, …)
141
141
  * only honor the horizontal (inline) margins — `margin-top`/`margin-bottom`
142
142
  * have no effect on them. Replaced inline elements (`img`, `video`, …) honor
143
143
  * margin on every side, same as a block box.
144
144
  */
145
- function getMarginPropertiesForTag(tag: string): string[] {
146
- const normalizedTag = tag.toLowerCase()
147
- const defaultDisplay = getDefaultDisplayForTag(normalizedTag)
148
-
149
- if (MARGIN_UNSUPPORTED_DISPLAY_VALUES.has(defaultDisplay)) {
145
+ function getMarginPropertiesForDisplay(displayValue: string, isReplacedElement: boolean): string[] {
146
+ const normalizedDisplayValue = normalizeDisplayValue(displayValue)
147
+ if (MARGIN_UNSUPPORTED_DISPLAY_VALUES.has(normalizedDisplayValue)) {
150
148
  return []
151
149
  }
152
- if (defaultDisplay === 'inline' && !MEDIA_TAGS.has(normalizedTag)) {
150
+ if (normalizedDisplayValue === 'inline' && !isReplacedElement) {
153
151
  return HORIZONTAL_MARGIN_CSS_PROPERTIES
154
152
  }
155
153
  return MARGIN_CSS_PROPERTIES
156
154
  }
157
155
 
156
+ /**
157
+ * Margin properties relevant to a tag's default display, used as the
158
+ * best-effort guess at element creation time, before CSS matching has run.
159
+ * `enrichMarginProperties` corrects this once the effective display —
160
+ * matched CSS, including the `--display` root convention — is known.
161
+ */
162
+ function getMarginPropertiesForTag(tag: string): string[] {
163
+ const normalizedTag = tag.toLowerCase()
164
+ return getMarginPropertiesForDisplay(getDefaultDisplayForTag(normalizedTag), MEDIA_TAGS.has(normalizedTag))
165
+ }
166
+
158
167
  /**
159
168
  * Child-layout controls for flex/grid containers, added post-hoc by
160
169
  * `enrichFlexProperties` once the element's resolved `display` is known.
@@ -654,6 +663,49 @@ export function enrichContainerProperties(elements: ExtractedElement[]): Extract
654
663
  })
655
664
  }
656
665
 
666
+ /**
667
+ * Replaces whatever margin properties are currently in `existing` with the
668
+ * ones relevant to `marginProperties` — used by `enrichMarginProperties` to
669
+ * correct the tag-default guess once the effective display is known.
670
+ */
671
+ function withMarginProperties(existing: string[], marginProperties: string[]): string[] {
672
+ const withoutMargin = existing.filter((property) => !(MARGIN_CSS_PROPERTIES as string[]).includes(property))
673
+ return [...withoutMargin, ...marginProperties]
674
+ }
675
+
676
+ /**
677
+ * Walks the element tree and corrects margin properties from the tag-default
678
+ * guess made at creation time to the element's effective display — matched
679
+ * CSS, including the `--display` root convention. A non-replaced inline
680
+ * element (e.g. `<span>`) whose matched CSS changes display to `block` or
681
+ * `flex` gains the block-axis margins it was missing; conversely, an element
682
+ * whose effective display becomes an internal table value loses margin
683
+ * entirely. Must be called after CSS selector matching (requires
684
+ * css-matcher data).
685
+ */
686
+ export function enrichMarginProperties(elements: ExtractedElement[]): ExtractedElement[] {
687
+ return elements.map((element) => {
688
+ const matcherData = element.extractorData.get('css-matcher') as MatchedCssData | undefined
689
+ const cssData = element.extractorData.get('css-properties') as CssPropertiesData | undefined
690
+
691
+ if (cssData && matcherData && hasEffectiveDisplayDeclaration(matcherData)) {
692
+ const effectiveDisplay = resolveEffectiveDisplayValue(matcherData)
693
+ if (effectiveDisplay !== undefined) {
694
+ const isReplacedElement = MEDIA_TAGS.has(element.tag.toLowerCase())
695
+ const marginProperties = getMarginPropertiesForDisplay(effectiveDisplay, isReplacedElement)
696
+ element.extractorData.set('css-properties', {
697
+ relevant: withMarginProperties(cssData.relevant, marginProperties),
698
+ })
699
+ }
700
+ }
701
+
702
+ return {
703
+ ...element,
704
+ children: enrichMarginProperties(element.children),
705
+ }
706
+ })
707
+ }
708
+
657
709
  // ─────────────────────────────────────────────────────────────────────────────
658
710
  // Factory
659
711
  // ─────────────────────────────────────────────────────────────────────────────
@@ -29,6 +29,7 @@ export {
29
29
  enrichGapProperties,
30
30
  enrichContainerProperties,
31
31
  enrichFlexProperties,
32
+ enrichMarginProperties,
32
33
  resolveDisplayValue,
33
34
  getDefaultDisplayForTag,
34
35
  ROOT_DISPLAY_CUSTOM_PROPERTY,
@@ -19,6 +19,7 @@ export {
19
19
  enrichGapProperties,
20
20
  enrichContainerProperties,
21
21
  enrichFlexProperties,
22
+ enrichMarginProperties,
22
23
  resolveDisplayValue,
23
24
  getDefaultDisplayForTag,
24
25
  ROOT_DISPLAY_CUSTOM_PROPERTY,
@@ -12,6 +12,7 @@ import {
12
12
  enrichContainerProperties,
13
13
  enrichFlexProperties,
14
14
  enrichGapProperties,
15
+ enrichMarginProperties,
15
16
  runExtractors,
16
17
  } from './information-extractors/react'
17
18
  import type { CoupledComponentInfo, CoupledProp, DOMBinding, TrackingStores } from './information-extractors/react'
@@ -150,7 +151,8 @@ export function processComponent(
150
151
  if (html && extractedElements.length > 0 && css.length > 0) {
151
152
  try {
152
153
  const matchResult = matchCssSelectors(html, extractedElements, css)
153
- const containerEnrichedElements = enrichContainerProperties(matchResult.elements)
154
+ const marginEnrichedElements = enrichMarginProperties(matchResult.elements)
155
+ const containerEnrichedElements = enrichContainerProperties(marginEnrichedElements)
154
156
  const gapEnrichedElements = enrichGapProperties(containerEnrichedElements)
155
157
  const flexEnrichedElements = enrichFlexProperties(gapEnrichedElements)
156
158
  enhancedInfo = {
@@ -139,4 +139,95 @@ describe('validateEditorElement', () => {
139
139
  })
140
140
  expect(() => validateEditorElement(editorElement, 'MyButton')).toThrow('MyButton')
141
141
  })
142
+
143
+ it('throws ValidationError when a part name is reused on a direct child element', () => {
144
+ const editorElement = buildMinimalEditorElement({
145
+ elements: {
146
+ element1: buildInlineElementItem({
147
+ inlineElement: {
148
+ selector: '.element1',
149
+ displayName: 'Element1',
150
+ elements: {
151
+ element1: buildInlineElementItem(),
152
+ },
153
+ },
154
+ }),
155
+ },
156
+ })
157
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(
158
+ 'editorElement.elements.element1.inlineElement.elements.element1: part name "element1" is already used by an ancestor element',
159
+ )
160
+ })
161
+
162
+ it('throws ValidationError when a part name is reused on a grandchild, not just a direct child', () => {
163
+ const editorElement = buildMinimalEditorElement({
164
+ elements: {
165
+ outer: buildInlineElementItem({
166
+ inlineElement: {
167
+ selector: '.outer',
168
+ displayName: 'Outer',
169
+ elements: {
170
+ middle: buildInlineElementItem({
171
+ inlineElement: {
172
+ selector: '.middle',
173
+ displayName: 'Middle',
174
+ elements: {
175
+ outer: buildInlineElementItem(),
176
+ },
177
+ },
178
+ }),
179
+ },
180
+ },
181
+ }),
182
+ },
183
+ })
184
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).toThrow(
185
+ 'editorElement.elements.outer.inlineElement.elements.middle.inlineElement.elements.outer: part name "outer" is already used by an ancestor element',
186
+ )
187
+ })
188
+
189
+ it('does not throw when the same part name is reused across unrelated sibling subtrees', () => {
190
+ const editorElement = buildMinimalEditorElement({
191
+ elements: {
192
+ left: buildInlineElementItem({
193
+ inlineElement: {
194
+ selector: '.left',
195
+ displayName: 'Left',
196
+ elements: { shared: buildInlineElementItem() },
197
+ },
198
+ }),
199
+ right: buildInlineElementItem({
200
+ inlineElement: {
201
+ selector: '.right',
202
+ displayName: 'Right',
203
+ elements: { shared: buildInlineElementItem() },
204
+ },
205
+ }),
206
+ },
207
+ })
208
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).not.toThrow()
209
+ })
210
+
211
+ it('does not throw for a deeply nested tree with all-unique part names', () => {
212
+ const editorElement = buildMinimalEditorElement({
213
+ elements: {
214
+ outer: buildInlineElementItem({
215
+ inlineElement: {
216
+ selector: '.outer',
217
+ displayName: 'Outer',
218
+ elements: {
219
+ middle: buildInlineElementItem({
220
+ inlineElement: {
221
+ selector: '.middle',
222
+ displayName: 'Middle',
223
+ elements: { inner: buildInlineElementItem() },
224
+ },
225
+ }),
226
+ },
227
+ },
228
+ }),
229
+ },
230
+ })
231
+ expect(() => validateEditorElement(editorElement, 'TestComponent')).not.toThrow()
232
+ })
142
233
  })
@@ -21,10 +21,14 @@ export function validateEditorElement(editorElement: RawEditorElement, component
21
21
  })
22
22
  }
23
23
 
24
- function collectElementViolations(element: AnyElement, elementPath: string): string[] {
24
+ function collectElementViolations(
25
+ element: AnyElement,
26
+ elementPath: string,
27
+ ancestorNames: ReadonlySet<string> = new Set(),
28
+ ): string[] {
25
29
  return [
26
30
  ...validateCssCustomProperties(element.cssCustomProperties, elementPath),
27
- ...validateElementsMap(element.elements, elementPath),
31
+ ...validateElementsMap(element.elements, elementPath, ancestorNames),
28
32
  ]
29
33
  }
30
34
 
@@ -44,7 +48,11 @@ function validateCssCustomProperties(
44
48
  return violations
45
49
  }
46
50
 
47
- function validateElementsMap(elements: Record<string, ElementItem> | undefined, elementPath: string): string[] {
51
+ function validateElementsMap(
52
+ elements: Record<string, ElementItem> | undefined,
53
+ elementPath: string,
54
+ ancestorNames: ReadonlySet<string> = new Set(),
55
+ ): string[] {
48
56
  if (!elements) return []
49
57
 
50
58
  const violations: string[] = []
@@ -56,8 +64,16 @@ function validateElementsMap(elements: Record<string, ElementItem> | undefined,
56
64
  violations.push(`${itemPath}: elementType is not set or UNKNOWN`)
57
65
  }
58
66
 
67
+ if (ancestorNames.has(elementKey)) {
68
+ violations.push(`${itemPath}: part name "${elementKey}" is already used by an ancestor element`)
69
+ }
70
+
59
71
  if (elementItem.inlineElement) {
60
- violations.push(...collectElementViolations(elementItem.inlineElement, `${itemPath}.inlineElement`))
72
+ const descendantAncestorNames = new Set(ancestorNames)
73
+ descendantAncestorNames.add(elementKey)
74
+ violations.push(
75
+ ...collectElementViolations(elementItem.inlineElement, `${itemPath}.inlineElement`, descendantAncestorNames),
76
+ )
61
77
  }
62
78
  }
63
79
  return violations