@wix/zero-config-implementation 1.98.0 → 1.99.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.98.0",
7
+ "version": "1.99.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": "741408430f509ba8b4ac3b6f2ff22c4fae1bf06d9f8b98e2a9e8e079"
109
+ "falconPackageHash": "905a961b05476216ee10b85e56b0d50818e5247abe594c0917619c0a"
110
110
  }
@@ -94,6 +94,16 @@ const isDOMTag = (type: unknown): type is string => typeof type === 'string' &&
94
94
 
95
95
  type ElementCreator = (type: unknown, props: unknown, ...rest: unknown[]) => ReactElement
96
96
 
97
+ /**
98
+ * Whether a child slot rendered nothing - what a node prop mocked to null leaves
99
+ * behind. `false` is excluded: it is what an unrendered conditional leaves, and a
100
+ * conditional on a mocked boolean would make the slot depend on the faker sequence.
101
+ */
102
+ function isEmptySlot(child: unknown): boolean {
103
+ if (Array.isArray(child)) return child.some(isEmptySlot)
104
+ return child === null || child === undefined
105
+ }
106
+
97
107
  /**
98
108
  * Creates an intercepted version of an element creation function.
99
109
  * Works with both createElement (children as rest params) and jsx/jsxs (children in props).
@@ -103,14 +113,19 @@ function createInterceptor(
103
113
  listeners: CreateElementListener[],
104
114
  getNextId: () => string,
105
115
  store: ExtractorStore,
116
+ /** jsx/jsxs/jsxDEV take children in props and a key as the third argument. */
117
+ childrenInProps: boolean,
106
118
  ): ElementCreator {
107
119
  return (type, elementProps, ...rest) => {
108
120
  const props = (elementProps ?? {}) as Record<string, unknown>
109
121
  const isDomElement = isDOMTag(type)
110
122
  const traceId = isDomElement ? getNextId() : undefined
111
123
 
112
- // Children location differs: createElement uses rest params, jsx/jsxs uses props.children
113
- const children = rest.length > 0 ? rest : props.children ? [props.children] : []
124
+ // Children location differs: createElement takes rest params but also accepts them
125
+ // in props, jsx/jsxs only in props. Keyed on presence, not truthiness, so a slot
126
+ // that rendered nothing is still visible.
127
+ const children = childrenInProps || rest.length === 0 ? ('children' in props ? [props.children] : []) : rest
128
+ const hasEmptySlot = children.some(isEmptySlot)
114
129
 
115
130
  const event: CreateElementEvent = {
116
131
  type,
@@ -119,6 +134,7 @@ function createInterceptor(
119
134
  props: { ...props, children },
120
135
  traceId,
121
136
  children: children as unknown[],
137
+ hasEmptySlot,
122
138
  store,
123
139
  }
124
140
 
@@ -322,10 +338,11 @@ export function renderWithExtractors(
322
338
  listeners,
323
339
  getNextId,
324
340
  store,
341
+ false,
325
342
  )
326
- const interceptedJsx = createInterceptor(originalJsx as ElementCreator, listeners, getNextId, store)
327
- const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store)
328
- const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store)
343
+ const interceptedJsx = createInterceptor(originalJsx as ElementCreator, listeners, getNextId, store, true)
344
+ const interceptedJsxs = createInterceptor(originalJsxs as ElementCreator, listeners, getNextId, store, true)
345
+ const interceptedJsxDEV = createInterceptor(originalJsxDEV as ElementCreator, listeners, getNextId, store, true)
329
346
 
330
347
  // The shim reads globalThis[REACT_KEY] on every hook call, so pointing it at
331
348
  // userReact ensures ESM hooks use the same dispatcher that SSR drives.
@@ -11,6 +11,7 @@ function makeElement(input: { tag: string; className: string; attributes?: Recor
11
11
  boundProps: [],
12
12
  concatenatedAttrs: new Map(),
13
13
  eventHandlers: [],
14
+ hasEmptySlot: false,
14
15
  }
15
16
  return {
16
17
  traceId: 'trace-0',
@@ -0,0 +1,186 @@
1
+ import { CSS_PROPERTIES } from '@wix/react-component-schema'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { CssPropertiesData } from '../css-properties'
4
+ import { getCssPropertiesForTag } from '../css-properties'
5
+ import { ExtractorStore } from './store'
6
+ import { type ExtractedElement, buildElementTree } from './tree-builder'
7
+
8
+ const { CSS_PROPERTY_TYPE } = CSS_PROPERTIES
9
+
10
+ function createStoreForTags(tagsByTraceId: Record<string, string>, emptySlotTraceIds: string[] = []): ExtractorStore {
11
+ const store = new ExtractorStore()
12
+ for (const [traceId, tag] of Object.entries(tagsByTraceId)) {
13
+ store.set(traceId, 'css-properties', { relevant: getCssPropertiesForTag(tag) })
14
+ store.set(traceId, 'prop-tracker', {
15
+ tag,
16
+ boundProps: [],
17
+ concatenatedAttrs: new Map<string, string>(),
18
+ eventHandlers: [],
19
+ hasEmptySlot: emptySlotTraceIds.includes(traceId),
20
+ })
21
+ }
22
+ return store
23
+ }
24
+
25
+ function findElementByName(elements: ExtractedElement[], name: string): ExtractedElement | undefined {
26
+ for (const element of elements) {
27
+ if (element.name === name) return element
28
+ const match = findElementByName(element.children, name)
29
+ if (match) return match
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ function relevantPropertiesOf(element: ExtractedElement): string[] {
35
+ const cssData = element.extractorData.get('css-properties') as CssPropertiesData | undefined
36
+ return cssData?.relevant ?? []
37
+ }
38
+
39
+ describe('buildElementTree - button typography', () => {
40
+ const TEXT_PROPERTIES = [
41
+ CSS_PROPERTY_TYPE.font,
42
+ CSS_PROPERTY_TYPE.lineHeight,
43
+ CSS_PROPERTY_TYPE.letterSpacing,
44
+ CSS_PROPERTY_TYPE.textDecorationLine,
45
+ CSS_PROPERTY_TYPE.textTransform,
46
+ CSS_PROPERTY_TYPE.textAlign,
47
+ CSS_PROPERTY_TYPE.textShadow,
48
+ CSS_PROPERTY_TYPE.color,
49
+ ]
50
+
51
+ it('gives a button the full text set when its label sits in a dropped wrapper', () => {
52
+ const html = `
53
+ <div data-trace-id="t1" class="label-button">
54
+ <button data-trace-id="t2" class="label-button__trigger">
55
+ <span data-trace-id="t3" class="_label_1x9fa">Play</span>
56
+ </button>
57
+ </div>
58
+ `
59
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span' })
60
+
61
+ const tree = buildElementTree(html, store)
62
+ const trigger = findElementByName(tree, 'labelButtonTrigger')
63
+
64
+ expect(trigger).toBeDefined()
65
+ expect(relevantPropertiesOf(trigger!)).toEqual(expect.arrayContaining(TEXT_PROPERTIES))
66
+ })
67
+
68
+ it('keeps a text-free button to color alone, with no typography', () => {
69
+ const html = `
70
+ <div data-trace-id="t1" class="icon-button">
71
+ <button data-trace-id="t2" class="icon-button__trigger" aria-label="Play">
72
+ <svg data-trace-id="t3" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
73
+ </button>
74
+ </div>
75
+ `
76
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' })
77
+
78
+ const tree = buildElementTree(html, store)
79
+ const trigger = findElementByName(tree, 'iconButtonTrigger')
80
+
81
+ expect(trigger).toBeDefined()
82
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.paddingTop)
83
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.color)
84
+ for (const textProperty of TEXT_PROPERTIES.filter((property) => property !== CSS_PROPERTY_TYPE.color)) {
85
+ expect(relevantPropertiesOf(trigger!)).not.toContain(textProperty)
86
+ }
87
+ })
88
+
89
+ it('keeps typography on a button whose content prop rendered nothing', () => {
90
+ const html = `
91
+ <div data-trace-id="t1" class="cta-button">
92
+ <button data-trace-id="t2" class="cta-button__trigger"></button>
93
+ </div>
94
+ `
95
+ const store = createStoreForTags({ t1: 'div', t2: 'button' }, ['t2'])
96
+
97
+ const tree = buildElementTree(html, store)
98
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
99
+
100
+ // Node props mock to null, so an empty button may still render text at runtime
101
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
102
+ })
103
+
104
+ it('keeps typography on a button whose slotted label sits in a wrapper', () => {
105
+ const html = `
106
+ <div data-trace-id="t1" class="cta-button">
107
+ <button data-trace-id="t2" class="cta-button__trigger">
108
+ <span data-trace-id="t3" class="_label_1x9fa"></span>
109
+ </button>
110
+ </div>
111
+ `
112
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span' }, ['t3'])
113
+
114
+ const tree = buildElementTree(html, store)
115
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
116
+
117
+ // The wrapper is dropped and its slotted label mocked to null, so the button
118
+ // is the only element left that can carry the typography
119
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
120
+ })
121
+
122
+ it('keeps typography on a button holding an icon beside a slotted label', () => {
123
+ const html = `
124
+ <div data-trace-id="t1" class="cta-button">
125
+ <button data-trace-id="t2" class="cta-button__trigger">
126
+ <svg data-trace-id="t3" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
127
+ </button>
128
+ </div>
129
+ `
130
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' }, ['t2'])
131
+
132
+ const tree = buildElementTree(html, store)
133
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
134
+
135
+ // The icon is the only child left once the slotted label mocks to null
136
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
137
+ })
138
+
139
+ it('keeps typography off a button that rendered nothing at all', () => {
140
+ const html = `
141
+ <div data-trace-id="t1" class="spacer-button">
142
+ <button data-trace-id="t2" class="spacer-button__trigger"></button>
143
+ </div>
144
+ `
145
+ const store = createStoreForTags({ t1: 'div', t2: 'button' })
146
+
147
+ const tree = buildElementTree(html, store)
148
+ const trigger = findElementByName(tree, 'spacerButtonTrigger')
149
+
150
+ expect(relevantPropertiesOf(trigger!)).not.toContain(CSS_PROPERTY_TYPE.font)
151
+ })
152
+
153
+ it('finds a slot in a wrapper that also rendered an icon', () => {
154
+ const html = `
155
+ <div data-trace-id="t1" class="cta-button">
156
+ <button data-trace-id="t2" class="cta-button__trigger">
157
+ <span data-trace-id="t3" class="_content_1x9fa">
158
+ <svg data-trace-id="t4" viewBox="0 0 24 24"><path d="M7 4v16l12-8z"></path></svg>
159
+ </span>
160
+ </button>
161
+ </div>
162
+ `
163
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'span', t4: 'svg' }, ['t3'])
164
+
165
+ const tree = buildElementTree(html, store)
166
+ const trigger = findElementByName(tree, 'ctaButtonTrigger')
167
+
168
+ expect(relevantPropertiesOf(trigger!)).toContain(CSS_PROPERTY_TYPE.font)
169
+ })
170
+
171
+ it('ignores text that only SVG metadata renders', () => {
172
+ const html = `
173
+ <div data-trace-id="t1" class="icon-button">
174
+ <button data-trace-id="t2" class="icon-button__trigger">
175
+ <svg data-trace-id="t3" viewBox="0 0 24 24"><title>Play</title><path d="M7 4v16l12-8z"></path></svg>
176
+ </button>
177
+ </div>
178
+ `
179
+ const store = createStoreForTags({ t1: 'div', t2: 'button', t3: 'svg' })
180
+
181
+ const tree = buildElementTree(html, store)
182
+ const trigger = findElementByName(tree, 'iconButtonTrigger')
183
+
184
+ expect(relevantPropertiesOf(trigger!)).not.toContain(CSS_PROPERTY_TYPE.font)
185
+ })
186
+ })
@@ -11,7 +11,7 @@ import { TRACE_ATTR } from '../../../../component-renderer'
11
11
  import { findPreferredSemanticClass, normalizeClassNames } from '../../../../utils/css-class'
12
12
  import { PRESETS_WRAPPER_CLASS_NAME } from '../../utils/mock-generator'
13
13
  import type { CssPropertiesData } from '../css-properties'
14
- import { addTextProperties } from '../css-properties'
14
+ import { addTextProperties, rendersTextThroughContent } from '../css-properties'
15
15
  import type { PropTrackerData } from '../prop-tracker'
16
16
  import type { ExtractorStore } from './store'
17
17
 
@@ -111,11 +111,43 @@ const getAttributes = (element: Element): Record<string, string> => {
111
111
  return attrs
112
112
  }
113
113
 
114
+ const hasVisibleText = (node: Node): boolean => isTextNode(node) && node.value.trim().length > 0
115
+
114
116
  /**
115
117
  * Checks if an element has direct text content (non-whitespace text nodes as children)
116
118
  */
117
- const hasDirectTextContent = (element: Element): boolean =>
118
- element.childNodes.some((child) => isTextNode(child) && child.value.trim().length > 0)
119
+ const hasDirectTextContent = (element: Element): boolean => element.childNodes.some(hasVisibleText)
120
+
121
+ /** Tags whose text is metadata or source, never styleable text in the layout. */
122
+ const NON_RENDERED_TEXT_TAGS = new Set(['title', 'desc', 'metadata', 'script', 'style', 'template'])
123
+
124
+ /** An element whose own content can reach the page. */
125
+ const isRenderedElement = (node: Node): node is Element =>
126
+ isElement(node) && !NON_RENDERED_TEXT_TAGS.has(node.tagName.toLowerCase())
127
+
128
+ /** Checks if an element renders any text anywhere beneath it. */
129
+ const hasDescendantTextContent = (element: Element): boolean =>
130
+ element.childNodes.some(
131
+ (child) => hasVisibleText(child) || (isRenderedElement(child) && hasDescendantTextContent(child)),
132
+ )
133
+
134
+ /** Whether this element's own child slot rendered nothing. */
135
+ const hasEmptySlot = (element: Element, store: ExtractorStore): boolean => {
136
+ const traceId = getAttribute(element, TRACE_ATTR)
137
+ return Boolean(traceId && store.get<PropTrackerData>(traceId, 'prop-tracker')?.hasEmptySlot)
138
+ }
139
+
140
+ /** Whether content is missing from this element's own slot, or from one below it. */
141
+ const hasMissingContent = (element: Element, store: ExtractorStore): boolean =>
142
+ hasEmptySlot(element, store) ||
143
+ element.childNodes.some((child) => isElement(child) && hasMissingContent(child, store))
144
+
145
+ /**
146
+ * Returns true if an element renders no text. Content the probe could not produce
147
+ * leaves this unknown, so an element missing content returns false.
148
+ */
149
+ const rendersNoText = (element: Element, store: ExtractorStore): boolean =>
150
+ !hasDescendantTextContent(element) && !hasMissingContent(element, store)
119
151
 
120
152
  /**
121
153
  * Normalizes a tag name to its semantic camelCase form
@@ -275,8 +307,12 @@ export function buildElementTree(html: string, store: ExtractorStore): Extracted
275
307
  // Check for text content
276
308
  const hasText = hasDirectTextContent(node)
277
309
 
278
- // If element has text content and has css-properties data, add text properties
279
- if (hasText) {
310
+ // Wrappers without a semantic class never reach the tree, so an element whose
311
+ // text comes from its content must own the typography itself.
312
+ const rendersText = hasText || (rendersTextThroughContent(node.tagName) && !rendersNoText(node, store))
313
+
314
+ // If element renders text and has css-properties data, add text properties
315
+ if (rendersText) {
280
316
  const cssData = extractorData.get('css-properties') as CssPropertiesData | undefined
281
317
  if (cssData) {
282
318
  const enhanced: CssPropertiesData = {
@@ -24,6 +24,8 @@ export interface CreateElementEvent {
24
24
  props: Record<string, unknown>
25
25
  traceId?: string
26
26
  children: unknown[]
27
+ /** True when a child slot rendered nothing, e.g. a `children` prop mocked to null. */
28
+ hasEmptySlot?: boolean
27
29
  store: ExtractorStore
28
30
  }
29
31
 
@@ -173,8 +173,8 @@ describe('getCssPropertiesForTag', () => {
173
173
  expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.font)
174
174
  })
175
175
 
176
- it('gives interactive controls both box and text properties', () => {
177
- for (const tag of ['button', 'select', 'textarea']) {
176
+ it('gives controls that render their own text both box and text properties', () => {
177
+ for (const tag of ['select', 'textarea']) {
178
178
  const relevantProperties = getCssPropertiesForTag(tag)
179
179
 
180
180
  expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
@@ -183,6 +183,16 @@ describe('getCssPropertiesForTag', () => {
183
183
  }
184
184
  })
185
185
 
186
+ it('treats a button as box-generating but not text-bearing, except for color', () => {
187
+ const relevantProperties = getCssPropertiesForTag('button')
188
+
189
+ expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.paddingTop)
190
+ // `color` resolves `currentColor` in borders, outlines and fills, text or not
191
+ expect(relevantProperties).toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.color)
192
+ expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.font)
193
+ expect(relevantProperties).not.toContain(CSS_PROPERTIES.CSS_PROPERTY_TYPE.textAlign)
194
+ })
195
+
186
196
  it('treats an input with no type as text-bearing', () => {
187
197
  const relevantProperties = getCssPropertiesForTag('input')
188
198
 
@@ -30,7 +30,8 @@ export interface SemanticAttributes {
30
30
 
31
31
  /**
32
32
  * Text & Typography - elements where readability and hierarchy are primary.
33
- * Form controls render their own text; `input` varies by type (see below).
33
+ * `select`, `textarea`, and `input` render their own text; `input` varies by type
34
+ * (see below). `button` is absent on purpose - see CONTENT_DEPENDENT_TEXT_TAGS.
34
35
  */
35
36
  const TEXT_TAGS = new Set([
36
37
  'h1',
@@ -48,11 +49,21 @@ const TEXT_TAGS = new Set([
48
49
  'em',
49
50
  'b',
50
51
  'i',
51
- 'button',
52
52
  'select',
53
53
  'textarea',
54
54
  ])
55
55
 
56
+ /**
57
+ * Tags whose text comes from their content rather than their own rendering, so
58
+ * tree building decides once the rendered subtree is known.
59
+ */
60
+ const CONTENT_DEPENDENT_TEXT_TAGS = new Set(['button'])
61
+
62
+ /** Whether the tag's content, rather than the tag itself, decides typography. */
63
+ export function rendersTextThroughContent(tag: string): boolean {
64
+ return CONTENT_DEPENDENT_TEXT_TAGS.has(tag.toLowerCase())
65
+ }
66
+
56
67
  /** Anything else - including a missing or invalid `type` - defaults to text. */
57
68
  const NON_TEXT_INPUT_TYPES = new Set(['checkbox', 'radio', 'range', 'color', 'file', 'image', 'hidden'])
58
69
 
@@ -157,6 +168,10 @@ export function getCssPropertiesForTag(tag: string, attributes: SemanticAttribut
157
168
 
158
169
  if (hasTextProperties) {
159
170
  properties.push(...TEXT_CSS_PROPERTIES)
171
+ } else if (rendersTextThroughContent(tag)) {
172
+ // `color` resolves `currentColor` in borders, outlines and fills even with no
173
+ // text; the rest of the typography waits until tree building sees the content.
174
+ properties.push(CSS_PROPERTY_TYPE.color)
160
175
  }
161
176
  if (hasMediaProperties) {
162
177
  properties.push(...MEDIA_CSS_PROPERTIES)
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { createRefElementMarker } from '../../../extensions/ref-elements/path-utils'
3
3
  import type { RefElementMatch } from '../../../extensions/ref-elements/types'
4
4
  import { ExtractorStore } from './core/store'
5
- import { createPropTrackerExtractor } from './prop-tracker'
5
+ import { type PropTrackerData, createPropTrackerExtractor } from './prop-tracker'
6
6
 
7
7
  function markRefElementComponent(runtimeComponent: (...args: unknown[]) => unknown, refComponentType: string): void {
8
8
  Object.defineProperty(
@@ -248,4 +248,22 @@ describe('createPropTrackerExtractor', () => {
248
248
  selector: '.card-cta',
249
249
  })
250
250
  })
251
+
252
+ it('persists the empty-slot signal onto the element data', () => {
253
+ const store = new ExtractorStore()
254
+ const { extractor } = createPropTrackerExtractor()
255
+
256
+ extractor.onCreateElement?.({
257
+ type: 'button',
258
+ isDomElement: true,
259
+ tag: 'button',
260
+ traceId: 'trace-1',
261
+ props: { className: 'trigger' },
262
+ children: [],
263
+ hasEmptySlot: true,
264
+ store,
265
+ })
266
+
267
+ expect(store.get<PropTrackerData>('trace-1', 'prop-tracker')?.hasEmptySlot).toBe(true)
268
+ })
251
269
  })
@@ -26,6 +26,8 @@ export interface PropTrackerData {
26
26
  concatenatedAttrs: Map<string, string> // attrName → propName (for concatenated values)
27
27
  /** on*-handler prop names whose value is a function on this element (e.g. ['onClick', 'onChange']). */
28
28
  eventHandlers: string[]
29
+ /** True when a child slot rendered nothing, so the content is unknown. */
30
+ hasEmptySlot: boolean
29
31
  }
30
32
 
31
33
  export interface PropTrackerExtractorState {
@@ -170,6 +172,7 @@ export function createPropTrackerExtractor(options?: CreatePropTrackerExtractorO
170
172
  boundProps: [...boundProps],
171
173
  concatenatedAttrs,
172
174
  eventHandlers,
175
+ hasEmptySlot: event.hasEmptySlot ?? false,
173
176
  } satisfies PropTrackerData)
174
177
 
175
178
  attachMatchedRefElement(traceId, props, store)
@@ -15,6 +15,7 @@ function makeElement(input: {
15
15
  boundProps: [],
16
16
  concatenatedAttrs: new Map(),
17
17
  eventHandlers: input.eventHandlers ?? [],
18
+ hasEmptySlot: false,
18
19
  }
19
20
  return {
20
21
  traceId: 'trace-0',