@wix/zero-config-implementation 1.61.0 → 1.63.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.61.0",
7
+ "version": "1.63.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",
@@ -80,5 +80,5 @@
80
80
  ]
81
81
  }
82
82
  },
83
- "falconPackageHash": "274e5da8e4ba5ae90001de1df05e55e4cd4e281605bae183858538b7"
83
+ "falconPackageHash": "fc05eca6895716f63fc1c075f50bf661142e5d08ab108de4d195403e"
84
84
  }
@@ -0,0 +1,361 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveLonghand, splitAxis, splitBox, splitCorners, splitTopLevelTokens } from './css-longhand-resolver'
3
+
4
+ describe('splitTopLevelTokens', () => {
5
+ it('splits a single token', () => {
6
+ expect(splitTopLevelTokens('1rem')).toEqual(['1rem'])
7
+ })
8
+
9
+ it('splits multiple plain tokens', () => {
10
+ expect(splitTopLevelTokens('1px solid red')).toEqual(['1px', 'solid', 'red'])
11
+ })
12
+
13
+ it('keeps calc() intact even with embedded spaces', () => {
14
+ expect(splitTopLevelTokens('calc(1rem + 2px) 4px')).toEqual(['calc(1rem + 2px)', '4px'])
15
+ })
16
+
17
+ it('keeps var() intact', () => {
18
+ expect(splitTopLevelTokens('var(--x) var(--y)')).toEqual(['var(--x)', 'var(--y)'])
19
+ })
20
+
21
+ it('keeps nested parens intact', () => {
22
+ expect(splitTopLevelTokens('rgb(0 0 0) calc(1px + var(--y))')).toEqual(['rgb(0 0 0)', 'calc(1px + var(--y))'])
23
+ })
24
+
25
+ it('handles leading and trailing whitespace', () => {
26
+ expect(splitTopLevelTokens(' 1px 2px ')).toEqual(['1px', '2px'])
27
+ })
28
+ })
29
+
30
+ describe('splitBox', () => {
31
+ it('one value: applies to all sides', () => {
32
+ expect(splitBox('1rem')).toEqual({ top: '1rem', right: '1rem', bottom: '1rem', left: '1rem' })
33
+ })
34
+
35
+ it('two values: block / inline', () => {
36
+ expect(splitBox('1rem 2rem')).toEqual({ top: '1rem', right: '2rem', bottom: '1rem', left: '2rem' })
37
+ })
38
+
39
+ it('three values: top / horizontal / bottom', () => {
40
+ expect(splitBox('1px 2px 3px')).toEqual({ top: '1px', right: '2px', bottom: '3px', left: '2px' })
41
+ })
42
+
43
+ it('four values: top / right / bottom / left', () => {
44
+ expect(splitBox('1px 2px 3px 4px')).toEqual({ top: '1px', right: '2px', bottom: '3px', left: '4px' })
45
+ })
46
+
47
+ it('preserves calc() as a single token', () => {
48
+ expect(splitBox('calc(10px + 1rem) 2rem')).toEqual({
49
+ top: 'calc(10px + 1rem)',
50
+ right: '2rem',
51
+ bottom: 'calc(10px + 1rem)',
52
+ left: '2rem',
53
+ })
54
+ })
55
+
56
+ it('preserves var() tokens', () => {
57
+ expect(splitBox('var(--x) var(--y)')).toEqual({
58
+ top: 'var(--x)',
59
+ right: 'var(--y)',
60
+ bottom: 'var(--x)',
61
+ left: 'var(--y)',
62
+ })
63
+ })
64
+
65
+ it('returns undefined for unsupported token counts', () => {
66
+ expect(splitBox('')).toBeUndefined()
67
+ expect(splitBox('1px 2px 3px 4px 5px')).toBeUndefined()
68
+ })
69
+ })
70
+
71
+ describe('splitAxis', () => {
72
+ it('one value: both ends', () => {
73
+ expect(splitAxis('1rem')).toEqual({ start: '1rem', end: '1rem' })
74
+ })
75
+
76
+ it('two values: start / end', () => {
77
+ expect(splitAxis('4px 8px')).toEqual({ start: '4px', end: '8px' })
78
+ })
79
+
80
+ it('returns undefined for zero or > 2 tokens', () => {
81
+ expect(splitAxis('')).toBeUndefined()
82
+ expect(splitAxis('1px 2px 3px')).toBeUndefined()
83
+ })
84
+ })
85
+
86
+ describe('splitCorners', () => {
87
+ it('one value: applies to all corners', () => {
88
+ expect(splitCorners('4px')).toEqual({ tl: '4px', tr: '4px', br: '4px', bl: '4px' })
89
+ })
90
+
91
+ it('two values: tl/br and tr/bl', () => {
92
+ expect(splitCorners('4px 8px')).toEqual({ tl: '4px', tr: '8px', br: '4px', bl: '8px' })
93
+ })
94
+
95
+ it('three values: tl / (tr & bl) / br', () => {
96
+ expect(splitCorners('4px 8px 12px')).toEqual({ tl: '4px', tr: '8px', br: '12px', bl: '8px' })
97
+ })
98
+
99
+ it('four values: tl / tr / br / bl', () => {
100
+ expect(splitCorners('4px 8px 12px 16px')).toEqual({ tl: '4px', tr: '8px', br: '12px', bl: '16px' })
101
+ })
102
+
103
+ it('drops elliptical second radii after /', () => {
104
+ expect(splitCorners('4px 8px 12px 16px / 1px 2px 3px 4px')).toEqual({
105
+ tl: '4px',
106
+ tr: '8px',
107
+ br: '12px',
108
+ bl: '16px',
109
+ })
110
+ })
111
+
112
+ it('returns undefined for unsupported token counts', () => {
113
+ expect(splitCorners('')).toBeUndefined()
114
+ })
115
+ })
116
+
117
+ describe('resolveLonghand', () => {
118
+ it('returns direct hits without consulting shorthand rules', () => {
119
+ const values = new Map([
120
+ ['paddingTop', '5px'],
121
+ ['padding', '10px'],
122
+ ])
123
+ expect(resolveLonghand('paddingTop', values)).toBe('5px')
124
+ })
125
+
126
+ it('returns undefined when target is unknown and absent', () => {
127
+ expect(resolveLonghand('marginTop', new Map())).toBeUndefined()
128
+ })
129
+
130
+ it('returns undefined when no rule produces a value', () => {
131
+ expect(resolveLonghand('paddingTop', new Map())).toBeUndefined()
132
+ })
133
+
134
+ describe('padding family', () => {
135
+ it('paddingTop falls back through paddingBlockStart → paddingBlock → padding', () => {
136
+ expect(resolveLonghand('paddingTop', new Map([['paddingBlockStart', '1px']]))).toBe('1px')
137
+ expect(resolveLonghand('paddingTop', new Map([['paddingBlock', '4px 8px']]))).toBe('4px')
138
+ expect(resolveLonghand('paddingTop', new Map([['padding', '1rem 2rem']]))).toBe('1rem')
139
+ })
140
+
141
+ it('paddingBottom falls back through paddingBlockEnd → paddingBlock → padding', () => {
142
+ expect(resolveLonghand('paddingBottom', new Map([['paddingBlockEnd', '2px']]))).toBe('2px')
143
+ expect(resolveLonghand('paddingBottom', new Map([['paddingBlock', '4px 8px']]))).toBe('8px')
144
+ expect(resolveLonghand('paddingBottom', new Map([['padding', '1rem 2rem']]))).toBe('1rem')
145
+ })
146
+
147
+ it('paddingInlineStart falls back through paddingLeft → paddingInline → padding', () => {
148
+ expect(resolveLonghand('paddingInlineStart', new Map([['paddingLeft', '7px']]))).toBe('7px')
149
+ expect(resolveLonghand('paddingInlineStart', new Map([['paddingInline', '12px']]))).toBe('12px')
150
+ expect(resolveLonghand('paddingInlineStart', new Map([['paddingInline', '12px 24px']]))).toBe('12px')
151
+ expect(resolveLonghand('paddingInlineStart', new Map([['padding', '1px 2px 3px 4px']]))).toBe('4px')
152
+ })
153
+
154
+ it('paddingInlineEnd falls back through paddingRight → paddingInline → padding', () => {
155
+ expect(resolveLonghand('paddingInlineEnd', new Map([['paddingRight', '9px']]))).toBe('9px')
156
+ expect(resolveLonghand('paddingInlineEnd', new Map([['paddingInline', '12px 24px']]))).toBe('24px')
157
+ expect(resolveLonghand('paddingInlineEnd', new Map([['padding', '1px 2px 3px 4px']]))).toBe('2px')
158
+ })
159
+ })
160
+
161
+ describe('border-radius corners', () => {
162
+ it('maps borderTopLeftRadius to borderStartStartRadius (LTR / horizontal-tb)', () => {
163
+ expect(resolveLonghand('borderStartStartRadius', new Map([['borderTopLeftRadius', '5px']]))).toBe('5px')
164
+ })
165
+
166
+ it('falls back to borderRadius corner split', () => {
167
+ const values = new Map([['borderRadius', '4px 8px 12px 16px']])
168
+ expect(resolveLonghand('borderStartStartRadius', values)).toBe('4px')
169
+ expect(resolveLonghand('borderStartEndRadius', values)).toBe('8px')
170
+ expect(resolveLonghand('borderEndEndRadius', values)).toBe('12px')
171
+ expect(resolveLonghand('borderEndStartRadius', values)).toBe('16px')
172
+ })
173
+ })
174
+
175
+ describe('border sides', () => {
176
+ it('borderTop falls back through borderBlockStart → border', () => {
177
+ expect(resolveLonghand('borderTop', new Map([['borderBlockStart', '2px dashed blue']]))).toBe('2px dashed blue')
178
+ expect(resolveLonghand('borderTop', new Map([['border', '1px solid red']]))).toBe('1px solid red')
179
+ })
180
+
181
+ it('borderInlineStart falls back through borderLeft → borderInline → border', () => {
182
+ expect(resolveLonghand('borderInlineStart', new Map([['borderLeft', '3px dotted black']]))).toBe(
183
+ '3px dotted black',
184
+ )
185
+ expect(resolveLonghand('borderInlineStart', new Map([['borderInline', '1px solid grey']]))).toBe('1px solid grey')
186
+ expect(resolveLonghand('borderInlineStart', new Map([['border', '1px solid red']]))).toBe('1px solid red')
187
+ })
188
+ })
189
+
190
+ describe('background', () => {
191
+ it('falls back to backgroundColor when background is absent', () => {
192
+ expect(resolveLonghand('background', new Map([['backgroundColor', '#abcdef']]))).toBe('#abcdef')
193
+ })
194
+
195
+ it('prefers literal background over backgroundColor', () => {
196
+ const values = new Map([
197
+ ['background', '#001'],
198
+ ['backgroundColor', '#abcdef'],
199
+ ])
200
+ expect(resolveLonghand('background', values)).toBe('#001')
201
+ })
202
+ })
203
+
204
+ describe('font composition from longhands', () => {
205
+ it('composes size and family alone', () => {
206
+ const values = new Map([
207
+ ['fontSize', '14px'],
208
+ ['fontFamily', 'Arial'],
209
+ ])
210
+ expect(resolveLonghand('font', values)).toBe('14px Arial')
211
+ })
212
+
213
+ it('includes weight before size', () => {
214
+ const values = new Map([
215
+ ['fontSize', '16px'],
216
+ ['fontFamily', "'Helvetica Neue'"],
217
+ ['fontWeight', '600'],
218
+ ])
219
+ expect(resolveLonghand('font', values)).toBe("600 16px 'Helvetica Neue'")
220
+ })
221
+
222
+ it('joins size and line-height with /', () => {
223
+ const values = new Map([
224
+ ['fontSize', '14px'],
225
+ ['fontFamily', 'sans-serif'],
226
+ ['lineHeight', '1.5'],
227
+ ])
228
+ expect(resolveLonghand('font', values)).toBe('14px/1.5 sans-serif')
229
+ })
230
+
231
+ it('combines weight, size/line-height, family in spec order', () => {
232
+ const values = new Map([
233
+ ['fontStyle', 'italic'],
234
+ ['fontWeight', '700'],
235
+ ['fontSize', '20px'],
236
+ ['lineHeight', '1.3'],
237
+ ['fontFamily', 'serif'],
238
+ ])
239
+ expect(resolveLonghand('font', values)).toBe('italic 700 20px/1.3 serif')
240
+ })
241
+
242
+ it("omits modifiers with literal value 'normal'", () => {
243
+ const values = new Map([
244
+ ['fontStyle', 'normal'],
245
+ ['fontWeight', 'normal'],
246
+ ['fontSize', '16px'],
247
+ ['lineHeight', 'normal'],
248
+ ['fontFamily', 'Arial'],
249
+ ])
250
+ expect(resolveLonghand('font', values)).toBe('16px Arial')
251
+ })
252
+
253
+ it('returns undefined when fontSize is missing (strict)', () => {
254
+ const values = new Map([['fontFamily', 'Arial']])
255
+ expect(resolveLonghand('font', values)).toBeUndefined()
256
+ })
257
+
258
+ it('returns undefined when fontFamily is missing (strict)', () => {
259
+ const values = new Map([['fontSize', '14px']])
260
+ expect(resolveLonghand('font', values)).toBeUndefined()
261
+ })
262
+
263
+ it('returns undefined when only optional modifiers are present', () => {
264
+ const values = new Map([
265
+ ['fontWeight', '700'],
266
+ ['fontStyle', 'italic'],
267
+ ])
268
+ expect(resolveLonghand('font', values)).toBeUndefined()
269
+ })
270
+
271
+ it('prefers literal font shorthand over longhand composition', () => {
272
+ const values = new Map([
273
+ ['font', '400 14px Arial'],
274
+ ['fontSize', '99px'],
275
+ ['fontFamily', 'WrongFont'],
276
+ ])
277
+ expect(resolveLonghand('font', values)).toBe('400 14px Arial')
278
+ })
279
+ })
280
+
281
+ describe('gap composition from row-gap + column-gap', () => {
282
+ it('composes two distinct axis values in row/column order', () => {
283
+ const values = new Map([
284
+ ['rowGap', '8px'],
285
+ ['columnGap', '10px'],
286
+ ])
287
+ expect(resolveLonghand('gap', values)).toBe('8px 10px')
288
+ })
289
+
290
+ it('collapses to a single value when both axes are equal', () => {
291
+ const values = new Map([
292
+ ['rowGap', '4px'],
293
+ ['columnGap', '4px'],
294
+ ])
295
+ expect(resolveLonghand('gap', values)).toBe('4px')
296
+ })
297
+
298
+ it('returns undefined when rowGap is missing (strict)', () => {
299
+ const values = new Map([['columnGap', '10px']])
300
+ expect(resolveLonghand('gap', values)).toBeUndefined()
301
+ })
302
+
303
+ it('returns undefined when columnGap is missing (strict)', () => {
304
+ const values = new Map([['rowGap', '8px']])
305
+ expect(resolveLonghand('gap', values)).toBeUndefined()
306
+ })
307
+
308
+ it('prefers literal gap shorthand over longhand composition', () => {
309
+ const values = new Map([
310
+ ['gap', '12px'],
311
+ ['rowGap', '99px'],
312
+ ['columnGap', '99px'],
313
+ ])
314
+ expect(resolveLonghand('gap', values)).toBe('12px')
315
+ })
316
+ })
317
+
318
+ describe('textDecorationLine extraction from text-decoration shorthand', () => {
319
+ it('extracts the lone line keyword', () => {
320
+ expect(resolveLonghand('textDecorationLine', new Map([['textDecoration', 'underline']]))).toBe('underline')
321
+ })
322
+
323
+ it('extracts the line keyword and drops style + color tokens', () => {
324
+ expect(resolveLonghand('textDecorationLine', new Map([['textDecoration', 'underline wavy red']]))).toBe(
325
+ 'underline',
326
+ )
327
+ })
328
+
329
+ it('ignores token order — line keyword can appear anywhere', () => {
330
+ expect(resolveLonghand('textDecorationLine', new Map([['textDecoration', 'red wavy underline']]))).toBe(
331
+ 'underline',
332
+ )
333
+ })
334
+
335
+ it('keeps multiple line keywords joined (e.g. underline + overline)', () => {
336
+ expect(resolveLonghand('textDecorationLine', new Map([['textDecoration', 'underline overline']]))).toBe(
337
+ 'underline overline',
338
+ )
339
+ })
340
+
341
+ it('extracts `none`', () => {
342
+ expect(resolveLonghand('textDecorationLine', new Map([['textDecoration', 'none']]))).toBe('none')
343
+ })
344
+
345
+ it('returns undefined when shorthand has no line keyword (just color/style)', () => {
346
+ expect(resolveLonghand('textDecorationLine', new Map([['textDecoration', 'red']]))).toBeUndefined()
347
+ })
348
+
349
+ it('returns undefined when textDecoration is absent', () => {
350
+ expect(resolveLonghand('textDecorationLine', new Map())).toBeUndefined()
351
+ })
352
+
353
+ it('prefers literal textDecorationLine over extracting from shorthand', () => {
354
+ const values = new Map([
355
+ ['textDecorationLine', 'overline'],
356
+ ['textDecoration', 'underline red'],
357
+ ])
358
+ expect(resolveLonghand('textDecorationLine', values)).toBe('overline')
359
+ })
360
+ })
361
+ })
@@ -0,0 +1,286 @@
1
+ import type { CSS_PROPERTIES } from '@wix/react-component-schema'
2
+ import { type Value, generate, parse } from 'css-tree'
3
+ import { SHORTHAND_FALLBACKS } from './css-shorthand-composer'
4
+
5
+ /**
6
+ * Resolves a CSS longhand to its default value by reading the developer's
7
+ * parsed CSS map. Accepts whatever shorthand the developer happened to write
8
+ * (`padding`, `padding-block`, `padding-block-start`, …) and splits it into
9
+ * the longhand piece the schema asked for. Returns `undefined` when no
10
+ * shorthand can satisfy the target.
11
+ *
12
+ * Example. Developer wrote `padding: 10px 20px`. Schema asks for `paddingTop`.
13
+ * 1. Direct hit on `paddingTop` — miss.
14
+ * 2. Walk FALLBACKS['paddingTop'] in order:
15
+ * fromKey('paddingBlockStart') → miss
16
+ * fromAxis('paddingBlock', 'start') → miss
17
+ * fromBox('padding', 'top') → splitBox('10px 20px').top = '10px' ✅
18
+ *
19
+ * Writing-mode assumption: `horizontal-tb` + LTR (CSS default). The logical-
20
+ * to-physical equivalences this file relies on (e.g. `padding-block-start` ↔
21
+ * `padding-top`, `padding-left` ↔ `padding-inline-start`) only hold under
22
+ * that mode. RTL and vertical writing modes are out of scope.
23
+ */
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Types
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Every property name the editor's schema knows about — the values of
31
+ * `CSS_PROPERTIES.CSS_PROPERTY_TYPE`. Used to type the FALLBACKS table's
32
+ * target keys so a typo or rename in the schema fails compilation here.
33
+ *
34
+ * Note: source-side names in factory calls (`paddingBlock`, `borderBlockStart`,
35
+ * etc.) stay plain strings because the developer can write any valid CSS
36
+ * property, including names the schema doesn't have controls for.
37
+ */
38
+ type CssPropertyName = (typeof CSS_PROPERTIES.CSS_PROPERTY_TYPE)[keyof typeof CSS_PROPERTIES.CSS_PROPERTY_TYPE]
39
+
40
+ type BoxValues = { top: string; right: string; bottom: string; left: string }
41
+ type AxisValues = { start: string; end: string }
42
+ type CornerValues = { tl: string; tr: string; br: string; bl: string }
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Tokenizer
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Splits a CSS value on top-level whitespace, keeping function calls intact.
50
+ * `1px solid red` → ['1px', 'solid', 'red']
51
+ * `calc(1rem + 2px) 4px` → ['calc(1rem + 2px)', '4px']
52
+ * `rgb(0 0 0) var(--y)` → ['rgb(0 0 0)', 'var(--y)']
53
+ *
54
+ * Backed by css-tree's value AST so function calls (calc/var/rgb/…) stay as
55
+ * one opaque `Function` node — no manual paren tracking. Whitespace and
56
+ * operators are filtered out; operator-aware callers (like splitCorners,
57
+ * which stops at `/`) handle the AST themselves.
58
+ */
59
+ export function splitTopLevelTokens(value: string): string[] {
60
+ const tokens: string[] = []
61
+ try {
62
+ const valueAst = parse(value, { context: 'value' }) as Value
63
+ valueAst.children.forEach((child) => {
64
+ if (child.type === 'WhiteSpace' || child.type === 'Operator') return
65
+ tokens.push(generate(child))
66
+ })
67
+ } catch {
68
+ // Malformed value — let callers see the empty result and fall back conservatively.
69
+ }
70
+ return tokens
71
+ }
72
+
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ // Splitters — parse a shorthand value into named parts
75
+ //
76
+ // Each splitter applies the CSS spec's 1-to-N-value expansion rule for its
77
+ // shorthand family. Unsupported token counts return `undefined` so callers
78
+ // fall back conservatively instead of guessing.
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * `padding` / `margin` / `inset` — the four-sided box.
83
+ * 1 value: `padding: 10px` → all four sides 10px
84
+ * 2 values: `padding: 10px 20px` → block (top/bottom) 10px, inline (right/left) 20px
85
+ * 3 values: `padding: 10px 20px 30px` → top 10px, right & left 20px, bottom 30px
86
+ * 4 values: `padding: 10px 20px 30px 40px` → clockwise from top
87
+ */
88
+ export function splitBox(value: string): BoxValues | undefined {
89
+ const tokens = splitTopLevelTokens(value)
90
+ switch (tokens.length) {
91
+ case 1:
92
+ return { top: tokens[0], right: tokens[0], bottom: tokens[0], left: tokens[0] }
93
+ case 2:
94
+ return { top: tokens[0], right: tokens[1], bottom: tokens[0], left: tokens[1] }
95
+ case 3:
96
+ return { top: tokens[0], right: tokens[1], bottom: tokens[2], left: tokens[1] }
97
+ case 4:
98
+ return { top: tokens[0], right: tokens[1], bottom: tokens[2], left: tokens[3] }
99
+ default:
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /**
105
+ * `padding-block`, `padding-inline`, `margin-block`, `margin-inline` — a single axis.
106
+ * 1 value: `padding-block: 10px` → start 10px, end 10px
107
+ * 2 values: `padding-block: 4px 8px` → start 4px, end 8px
108
+ */
109
+ export function splitAxis(value: string): AxisValues | undefined {
110
+ const tokens = splitTopLevelTokens(value)
111
+ switch (tokens.length) {
112
+ case 1:
113
+ return { start: tokens[0], end: tokens[0] }
114
+ case 2:
115
+ return { start: tokens[0], end: tokens[1] }
116
+ default:
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ /**
122
+ * `border-radius` — the four corners. Keys: tl, tr, br, bl.
123
+ * Anything after `/` describes elliptical second radii and is dropped (v1).
124
+ * 1 corner: `border-radius: 4px` → all four corners 4px
125
+ * 2 corners: `border-radius: 4px 8px` → tl & br 4px, tr & bl 8px
126
+ * 3 corners: `border-radius: 4px 8px 12px` → tl 4px, tr & bl 8px, br 12px
127
+ * 4 corners: `border-radius: 4px 8px 12px 16px` → clockwise from top-left
128
+ */
129
+ export function splitCorners(value: string): CornerValues | undefined {
130
+ const tokens: string[] = []
131
+ try {
132
+ const valueAst = parse(value, { context: 'value' }) as Value
133
+ for (const child of valueAst.children) {
134
+ if (child.type === 'WhiteSpace') continue
135
+ // `/` separates the corner radii from the elliptical second radii (v1: drop them).
136
+ if (child.type === 'Operator' && child.value === '/') break
137
+ tokens.push(generate(child))
138
+ }
139
+ } catch {
140
+ return undefined
141
+ }
142
+ switch (tokens.length) {
143
+ case 1:
144
+ return { tl: tokens[0], tr: tokens[0], br: tokens[0], bl: tokens[0] }
145
+ case 2:
146
+ return { tl: tokens[0], tr: tokens[1], br: tokens[0], bl: tokens[1] }
147
+ case 3:
148
+ return { tl: tokens[0], tr: tokens[1], br: tokens[2], bl: tokens[1] }
149
+ case 4:
150
+ return { tl: tokens[0], tr: tokens[1], br: tokens[2], bl: tokens[3] }
151
+ default:
152
+ return undefined
153
+ }
154
+ }
155
+
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+ // Fallback factories — used to build the FALLBACKS table declaratively
158
+ //
159
+ // A Fallback is a function: given the developer's CSS map, produce a default
160
+ // value (or `undefined` when its source isn't present). Four flavors, each
161
+ // named after what it pulls out:
162
+ //
163
+ // fromKey('paddingBlockStart') → look up that key, return its value as-is
164
+ // fromBox('padding', 'top') → look up 'padding', split it, return the top piece
165
+ // fromAxis('paddingBlock', 'start') → look up 'paddingBlock', split it, return the start piece
166
+ // fromCorner('borderRadius', 'tl') → look up 'borderRadius', split it, return the top-left corner
167
+ //
168
+ // This is the layer that lets FALLBACKS read like a sentence per row, instead
169
+ // of a wall of inline arrow functions.
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ type Fallback = (values: Map<string, string>) => string | undefined
173
+
174
+ function fromKey(from: string): Fallback {
175
+ return (values) => values.get(from)
176
+ }
177
+
178
+ function fromBox(from: string, side: keyof BoxValues): Fallback {
179
+ return (values) => {
180
+ const value = values.get(from)
181
+ return value === undefined ? undefined : splitBox(value)?.[side]
182
+ }
183
+ }
184
+
185
+ function fromAxis(from: string, end: keyof AxisValues): Fallback {
186
+ return (values) => {
187
+ const value = values.get(from)
188
+ return value === undefined ? undefined : splitAxis(value)?.[end]
189
+ }
190
+ }
191
+
192
+ function fromCorner(from: string, corner: keyof CornerValues): Fallback {
193
+ return (values) => {
194
+ const value = values.get(from)
195
+ return value === undefined ? undefined : splitCorners(value)?.[corner]
196
+ }
197
+ }
198
+
199
+ /**
200
+ * `text-decoration: <line> || <style> || <color>` — order is free and any
201
+ * subset is valid. The schema asks for `textDecorationLine` specifically, so
202
+ * we filter the shorthand's tokens down to just the line-keyword ones.
203
+ *
204
+ * Closed set of legal line keywords per the CSS spec (case-insensitive).
205
+ */
206
+ const TEXT_DECORATION_LINE_KEYWORDS = new Set(['none', 'underline', 'overline', 'line-through', 'blink'])
207
+
208
+ const fromTextDecorationLine: Fallback = (values) => {
209
+ const shorthand = values.get('textDecoration')
210
+ if (shorthand === undefined) return undefined
211
+ const lineTokens = splitTopLevelTokens(shorthand).filter((token) =>
212
+ TEXT_DECORATION_LINE_KEYWORDS.has(token.toLowerCase()),
213
+ )
214
+ if (lineTokens.length === 0) return undefined
215
+ return lineTokens.join(' ')
216
+ }
217
+
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+ // Resolution table
220
+ //
221
+ // For each schema longhand, an ordered list of fallbacks to try when the
222
+ // developer didn't write that longhand literally. The resolver walks them
223
+ // top-to-bottom and returns the first non-undefined result.
224
+ //
225
+ // Order matters: most specific source first, broadest source last — this
226
+ // mirrors CSS's own "more specific wins" cascade behavior.
227
+ // ─────────────────────────────────────────────────────────────────────────────
228
+
229
+ const FALLBACKS: Partial<Record<CssPropertyName, Fallback[]>> = {
230
+ // Padding — block axis is physical (top/bottom), inline axis is logical (start/end).
231
+ paddingTop: [fromKey('paddingBlockStart'), fromAxis('paddingBlock', 'start'), fromBox('padding', 'top')],
232
+ paddingBottom: [fromKey('paddingBlockEnd'), fromAxis('paddingBlock', 'end'), fromBox('padding', 'bottom')],
233
+ paddingInlineStart: [fromKey('paddingLeft'), fromAxis('paddingInline', 'start'), fromBox('padding', 'left')],
234
+ paddingInlineEnd: [fromKey('paddingRight'), fromAxis('paddingInline', 'end'), fromBox('padding', 'right')],
235
+
236
+ // Border radius — schema uses all-logical corner names; physical names map under LTR/horizontal-tb.
237
+ borderStartStartRadius: [fromKey('borderTopLeftRadius'), fromCorner('borderRadius', 'tl')],
238
+ borderStartEndRadius: [fromKey('borderTopRightRadius'), fromCorner('borderRadius', 'tr')],
239
+ borderEndStartRadius: [fromKey('borderBottomLeftRadius'), fromCorner('borderRadius', 'bl')],
240
+ borderEndEndRadius: [fromKey('borderBottomRightRadius'), fromCorner('borderRadius', 'br')],
241
+
242
+ // Border sides — these are themselves shorthands (<width> || <style> || <color>).
243
+ // The schema treats them as one opaque value, so `border: 1px solid red` flows
244
+ // through verbatim to every side.
245
+ borderTop: [fromKey('borderBlockStart'), fromKey('border')],
246
+ borderBottom: [fromKey('borderBlockEnd'), fromKey('border')],
247
+ borderInlineStart: [fromKey('borderLeft'), fromKey('borderInline'), fromKey('border')],
248
+ borderInlineEnd: [fromKey('borderRight'), fromKey('borderInline'), fromKey('border')],
249
+
250
+ // Background — populate the schema's `background` from `background-color` when
251
+ // the developer only wrote the color.
252
+ background: [fromKey('backgroundColor')],
253
+
254
+ // Text-decoration — `text-decoration: underline wavy red` carries line, style,
255
+ // and color in one declaration. Pick out just the line tokens for the schema.
256
+ textDecorationLine: [fromTextDecorationLine],
257
+
258
+ // Shorthand targets (e.g. `font`) live in css-shorthand-composer.ts and are
259
+ // merged in here — composer-driven, conceptually the inverse of the rows above.
260
+ ...SHORTHAND_FALLBACKS,
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────────────
264
+ // Public entry point
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Try the literal longhand first (covers the case where the developer wrote
269
+ * the exact key the schema is asking for). On a miss, walk the target's
270
+ * fallbacks and return the first non-undefined result. Returns `undefined`
271
+ * when nothing matches — the caller then emits an empty `cssProperties.<name>`
272
+ * entry, matching pre-resolver behavior for unknown cases.
273
+ */
274
+ export function resolveLonghand(target: string, values: Map<string, string>): string | undefined {
275
+ const literal = values.get(target)
276
+ if (literal !== undefined) return literal
277
+
278
+ const fallbacks = FALLBACKS[target as CssPropertyName]
279
+ if (!fallbacks) return undefined
280
+
281
+ for (const fallback of fallbacks) {
282
+ const resolved = fallback(values)
283
+ if (resolved !== undefined) return resolved
284
+ }
285
+ return undefined
286
+ }