@wix/zero-config-implementation 1.98.0 → 1.100.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.d.ts +4 -0
- package/dist/index.js +10442 -10349
- package/package.json +2 -2
- package/src/component-renderer.ts +22 -5
- package/src/converters/css-longhand-resolver.test.ts +35 -0
- package/src/converters/css-longhand-resolver.ts +22 -0
- package/src/converters/states-builder.test.ts +1 -0
- package/src/converters/to-editor-component.test.ts +66 -0
- package/src/converters/to-editor-component.ts +13 -2
- package/src/information-extractors/react/extractors/core/tree-builder.test.ts +186 -0
- package/src/information-extractors/react/extractors/core/tree-builder.ts +41 -5
- package/src/information-extractors/react/extractors/core/types.ts +2 -0
- package/src/information-extractors/react/extractors/css-properties.test.ts +148 -2
- package/src/information-extractors/react/extractors/css-properties.ts +163 -6
- package/src/information-extractors/react/extractors/index.ts +2 -0
- package/src/information-extractors/react/extractors/prop-tracker.test.ts +19 -1
- package/src/information-extractors/react/extractors/prop-tracker.ts +3 -0
- package/src/information-extractors/react/extractors/state-markers.test.ts +1 -0
- package/src/information-extractors/react/index.ts +2 -0
- package/src/manifest-pipeline.ts +3 -1
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.100.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": "
|
|
109
|
+
"falconPackageHash": "80ef17ffc1549b6c4d3f51396955cb0517fe8eceb7b20eed65fc7e7d"
|
|
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
|
|
113
|
-
|
|
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.
|
|
@@ -131,6 +131,41 @@ describe('resolveLonghand', () => {
|
|
|
131
131
|
expect(resolveLonghand('paddingTop', new Map())).toBeUndefined()
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
describe('margin family', () => {
|
|
135
|
+
it('marginTop falls back through marginBlockStart → marginBlock → margin', () => {
|
|
136
|
+
expect(resolveLonghand('marginTop', new Map([['marginBlockStart', '1px']]))).toBe('1px')
|
|
137
|
+
expect(resolveLonghand('marginTop', new Map([['marginBlock', '4px 8px']]))).toBe('4px')
|
|
138
|
+
expect(resolveLonghand('marginTop', new Map([['margin', '1rem 2rem']]))).toBe('1rem')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('marginBottom falls back through marginBlockEnd → marginBlock → margin', () => {
|
|
142
|
+
expect(resolveLonghand('marginBottom', new Map([['marginBlockEnd', '2px']]))).toBe('2px')
|
|
143
|
+
expect(resolveLonghand('marginBottom', new Map([['marginBlock', '4px 8px']]))).toBe('8px')
|
|
144
|
+
expect(resolveLonghand('marginBottom', new Map([['margin', '1rem 2rem']]))).toBe('1rem')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('marginInlineStart falls back through marginLeft → marginInline → margin', () => {
|
|
148
|
+
expect(resolveLonghand('marginInlineStart', new Map([['marginLeft', '7px']]))).toBe('7px')
|
|
149
|
+
expect(resolveLonghand('marginInlineStart', new Map([['marginInline', '12px']]))).toBe('12px')
|
|
150
|
+
expect(resolveLonghand('marginInlineStart', new Map([['marginInline', '12px 24px']]))).toBe('12px')
|
|
151
|
+
expect(resolveLonghand('marginInlineStart', new Map([['margin', '1px 2px 3px 4px']]))).toBe('4px')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('marginInlineEnd falls back through marginRight → marginInline → margin', () => {
|
|
155
|
+
expect(resolveLonghand('marginInlineEnd', new Map([['marginRight', '9px']]))).toBe('9px')
|
|
156
|
+
expect(resolveLonghand('marginInlineEnd', new Map([['marginInline', '12px 24px']]))).toBe('24px')
|
|
157
|
+
expect(resolveLonghand('marginInlineEnd', new Map([['margin', '1px 2px 3px 4px']]))).toBe('2px')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('prefers the literal longhand over any fallback', () => {
|
|
161
|
+
const values = new Map([
|
|
162
|
+
['marginTop', '5px'],
|
|
163
|
+
['margin', '10px'],
|
|
164
|
+
])
|
|
165
|
+
expect(resolveLonghand('marginTop', values)).toBe('5px')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
134
169
|
describe('padding family', () => {
|
|
135
170
|
it('paddingTop falls back through paddingBlockStart → paddingBlock → padding', () => {
|
|
136
171
|
expect(resolveLonghand('paddingTop', new Map([['paddingBlockStart', '1px']]))).toBe('1px')
|
|
@@ -215,6 +215,18 @@ const fromTextDecorationLine: Fallback = (values) => {
|
|
|
215
215
|
return lineTokens.join(' ')
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
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.
|
|
221
|
+
*/
|
|
222
|
+
const FLEX_DIRECTION_KEYWORDS = new Set(['row', 'row-reverse', 'column', 'column-reverse'])
|
|
223
|
+
|
|
224
|
+
const fromFlexFlowDirection: Fallback = (values) => {
|
|
225
|
+
const shorthand = values.get('flexFlow')
|
|
226
|
+
if (shorthand === undefined) return undefined
|
|
227
|
+
return splitTopLevelTokens(shorthand).find((token) => FLEX_DIRECTION_KEYWORDS.has(token.toLowerCase()))
|
|
228
|
+
}
|
|
229
|
+
|
|
218
230
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
219
231
|
// Resolution table
|
|
220
232
|
//
|
|
@@ -233,6 +245,12 @@ const FALLBACKS: Partial<Record<CssPropertyName, Fallback[]>> = {
|
|
|
233
245
|
paddingInlineStart: [fromKey('paddingLeft'), fromAxis('paddingInline', 'start'), fromBox('padding', 'left')],
|
|
234
246
|
paddingInlineEnd: [fromKey('paddingRight'), fromAxis('paddingInline', 'end'), fromBox('padding', 'right')],
|
|
235
247
|
|
|
248
|
+
// Margin — same block/inline convention as padding above.
|
|
249
|
+
marginTop: [fromKey('marginBlockStart'), fromAxis('marginBlock', 'start'), fromBox('margin', 'top')],
|
|
250
|
+
marginBottom: [fromKey('marginBlockEnd'), fromAxis('marginBlock', 'end'), fromBox('margin', 'bottom')],
|
|
251
|
+
marginInlineStart: [fromKey('marginLeft'), fromAxis('marginInline', 'start'), fromBox('margin', 'left')],
|
|
252
|
+
marginInlineEnd: [fromKey('marginRight'), fromAxis('marginInline', 'end'), fromBox('margin', 'right')],
|
|
253
|
+
|
|
236
254
|
// Border radius — schema uses all-logical corner names; physical names map under LTR/horizontal-tb.
|
|
237
255
|
borderStartStartRadius: [fromKey('borderTopLeftRadius'), fromCorner('borderRadius', 'tl')],
|
|
238
256
|
borderStartEndRadius: [fromKey('borderTopRightRadius'), fromCorner('borderRadius', 'tr')],
|
|
@@ -255,6 +273,10 @@ const FALLBACKS: Partial<Record<CssPropertyName, Fallback[]>> = {
|
|
|
255
273
|
// and color in one declaration. Pick out just the line tokens for the schema.
|
|
256
274
|
textDecorationLine: [fromTextDecorationLine],
|
|
257
275
|
|
|
276
|
+
// Flex — `flex-flow: column wrap` carries both direction and wrap; pick out
|
|
277
|
+
// just the direction keyword for the schema.
|
|
278
|
+
flexDirection: [fromFlexFlowDirection],
|
|
279
|
+
|
|
258
280
|
// Shorthand targets (e.g. `font`) live in css-shorthand-composer.ts and are
|
|
259
281
|
// merged in here — composer-driven, conceptually the inverse of the rows above.
|
|
260
282
|
...SHORTHAND_FALLBACKS,
|
|
@@ -346,6 +346,72 @@ describe('toEditorReactComponent — cssProperties defaultValue', () => {
|
|
|
346
346
|
defaultValue: 'var(--wst-primary-background-color)',
|
|
347
347
|
})
|
|
348
348
|
})
|
|
349
|
+
|
|
350
|
+
it('excludes width/height from the root element (sizing is handled via layout config)', () => {
|
|
351
|
+
const extractorData = new Map<string, unknown>()
|
|
352
|
+
extractorData.set('css-matcher', matcherDataFromCss('width: 100px; height: 50px; overflow: hidden;'))
|
|
353
|
+
extractorData.set('css-properties', {
|
|
354
|
+
relevant: [
|
|
355
|
+
CSS_PROPERTIES.CSS_PROPERTY_TYPE.width,
|
|
356
|
+
CSS_PROPERTIES.CSS_PROPERTY_TYPE.height,
|
|
357
|
+
CSS_PROPERTIES.CSS_PROPERTY_TYPE.overflow,
|
|
358
|
+
],
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const rootElement: ExtractedElement = {
|
|
362
|
+
traceId: 'trace-root',
|
|
363
|
+
name: 'root',
|
|
364
|
+
tag: 'div',
|
|
365
|
+
attributes: {},
|
|
366
|
+
extractorData,
|
|
367
|
+
children: [],
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const result = toEditorReactComponent(createComponent(rootElement))
|
|
371
|
+
|
|
372
|
+
expect(result.editorElement?.cssProperties?.width).toBeUndefined()
|
|
373
|
+
expect(result.editorElement?.cssProperties?.height).toBeUndefined()
|
|
374
|
+
expect(result.editorElement?.cssProperties?.overflow).toEqual({ defaultValue: 'hidden' })
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('keeps width/height on inner (non-root) elements', () => {
|
|
378
|
+
const rootExtractorData = new Map<string, unknown>()
|
|
379
|
+
rootExtractorData.set('css-matcher', matcherDataFromCss(''))
|
|
380
|
+
rootExtractorData.set('css-properties', { relevant: [] })
|
|
381
|
+
|
|
382
|
+
const childExtractorData = new Map<string, unknown>()
|
|
383
|
+
childExtractorData.set('css-matcher', matcherDataFromCss('width: 24px; height: 24px;'))
|
|
384
|
+
childExtractorData.set('css-properties', {
|
|
385
|
+
relevant: [CSS_PROPERTIES.CSS_PROPERTY_TYPE.width, CSS_PROPERTIES.CSS_PROPERTY_TYPE.height],
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const childElement: ExtractedElement = {
|
|
389
|
+
traceId: 'trace-icon',
|
|
390
|
+
name: 'icon',
|
|
391
|
+
tag: 'img',
|
|
392
|
+
attributes: {},
|
|
393
|
+
extractorData: childExtractorData,
|
|
394
|
+
children: [],
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const rootElement: ExtractedElement = {
|
|
398
|
+
traceId: 'trace-root',
|
|
399
|
+
name: 'root',
|
|
400
|
+
tag: 'div',
|
|
401
|
+
attributes: {},
|
|
402
|
+
extractorData: rootExtractorData,
|
|
403
|
+
children: [childElement],
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const result = toEditorReactComponent(createComponent(rootElement))
|
|
407
|
+
|
|
408
|
+
expect(result.editorElement?.elements?.icon?.inlineElement?.cssProperties?.width).toEqual({
|
|
409
|
+
defaultValue: '24px',
|
|
410
|
+
})
|
|
411
|
+
expect(result.editorElement?.elements?.icon?.inlineElement?.cssProperties?.height).toEqual({
|
|
412
|
+
defaultValue: '24px',
|
|
413
|
+
})
|
|
414
|
+
})
|
|
349
415
|
})
|
|
350
416
|
|
|
351
417
|
describe('toEditorReactComponent selector conversion', () => {
|
|
@@ -25,7 +25,11 @@ import type {
|
|
|
25
25
|
ExtractedElement,
|
|
26
26
|
TrackingStores,
|
|
27
27
|
} from '../information-extractors/react'
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
ROOT_DISPLAY_CUSTOM_PROPERTY,
|
|
30
|
+
getDefaultDisplayForTag,
|
|
31
|
+
resolveDisplayValue,
|
|
32
|
+
} from '../information-extractors/react'
|
|
29
33
|
import { findPreferredSemanticClass, normalizeClassNames } from '../utils/css-class'
|
|
30
34
|
import { buildActiveItemDisplayGroups, buildActiveItemIndexDisplayFilters } from './active-item-index-builder'
|
|
31
35
|
import { resolveLonghand } from './css-longhand-resolver'
|
|
@@ -307,7 +311,7 @@ function buildNearestCommonAncestorCustomProps(
|
|
|
307
311
|
const nearestCommonAncestorCustomProps = new Map<string, Record<string, CssCustomPropertyItem>>()
|
|
308
312
|
|
|
309
313
|
for (const varName of varNames) {
|
|
310
|
-
if (varName ===
|
|
314
|
+
if (varName === ROOT_DISPLAY_CUSTOM_PROPERTY) continue
|
|
311
315
|
|
|
312
316
|
const registration = registeredByVar.get(varName)
|
|
313
317
|
// Prefer a concrete declared value; fall back to the @property initial-value so a
|
|
@@ -563,6 +567,10 @@ function toDisplayEnumValue(cssValue: string): DisplayEnumValue {
|
|
|
563
567
|
return (CSS_DISPLAY_TO_ENUM[cssValue] ?? cssValue) as DisplayEnumValue
|
|
564
568
|
}
|
|
565
569
|
|
|
570
|
+
// Root sizing is handled separately via layout config, so `width`/`height`
|
|
571
|
+
// are excluded from the root element's own `cssProperties`.
|
|
572
|
+
const ROOT_EXCLUDED_CSS_PROPERTIES = new Set<string>([CSS_PROPERTY_TYPE.width, CSS_PROPERTY_TYPE.height])
|
|
573
|
+
|
|
566
574
|
function buildCssProperties(
|
|
567
575
|
element: ExtractedElement | undefined,
|
|
568
576
|
isRootElement: boolean,
|
|
@@ -576,6 +584,9 @@ function buildCssProperties(
|
|
|
576
584
|
}
|
|
577
585
|
const cssPropertyValues = element ? getMatchedPropertyValues(element) : new Map<string, string>()
|
|
578
586
|
for (const propName of decidedProperties) {
|
|
587
|
+
if (isRootElement && ROOT_EXCLUDED_CSS_PROPERTIES.has(propName)) {
|
|
588
|
+
continue
|
|
589
|
+
}
|
|
579
590
|
if (propName === CSS_PROPERTY_TYPE.display && element) {
|
|
580
591
|
if (!isRootElement) {
|
|
581
592
|
result[propName] = buildDisplayProperty(element)
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
279
|
-
|
|
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
|
|