@tamagui/codemod-flat-values 0.0.0-bootstrap.0 → 3.0.0-beta.643.1

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/src/convert.ts ADDED
@@ -0,0 +1,1784 @@
1
+ // One conversion site is one style object: a JSX attribute list, a `styled()`
2
+ // config, or a single variant branch. Conversion is a two-phase pass over the
3
+ // site's members in authored order.
4
+ //
5
+ // Phase one classifies each member. A legacy condition object becomes clauses
6
+ // through the shared converter. A base value that V3 spells differently (`$token`)
7
+ // must convert. Every other base value stays exactly as authored, and only folds
8
+ // into a program when a clause would otherwise need a second attribute of the same
9
+ // name (`opacity={0.5}` plus `enterStyle={{ opacity: 0 }}` cannot stay two
10
+ // `opacity` props, so it becomes `opacity="0.5 enter:0"`).
11
+ //
12
+ // Phase two merges the contributions with the grammar's own clause merge, prints
13
+ // each program at the position of its first contributing member, and re-parses
14
+ // what it printed. A program that does not read back identically is reported
15
+ // instead of suggested.
16
+
17
+ import {
18
+ Node,
19
+ SyntaxKind,
20
+ type Expression,
21
+ type JsxAttribute,
22
+ type JsxOpeningElement,
23
+ type JsxSelfClosingElement,
24
+ type ObjectLiteralExpression,
25
+ type PropertyAssignment,
26
+ } from 'ts-morph'
27
+ import type { ContainerPlan } from './containers'
28
+ import {
29
+ compact,
30
+ literalTree,
31
+ numericValue,
32
+ runtimeType,
33
+ staticLeafValue,
34
+ unwrapExpression,
35
+ } from './expressions'
36
+ import {
37
+ assessFlatConversion,
38
+ convertLegacyConditionProp,
39
+ expandToLonghands,
40
+ flatStringValue,
41
+ mergeProgramValues,
42
+ parseValue,
43
+ printProgram,
44
+ resolveProp,
45
+ sharedPayload,
46
+ shorthands,
47
+ styleProps,
48
+ unitSuffix,
49
+ type ConversionReason,
50
+ type ConversionTargets,
51
+ type HostView,
52
+ type ModifierRegistryView,
53
+ type ParsedClause,
54
+ type ParsedValue,
55
+ } from './grammar'
56
+ import { isLegacyConditionName, resolveLegacyName } from './legacyNames'
57
+ import { classifyStructuredNativeValue } from './structuredNative'
58
+
59
+ export type SiteKind = 'jsx' | 'styled'
60
+
61
+ export interface Flag {
62
+ code: string
63
+ detail: string
64
+ }
65
+
66
+ /** one converted flat prop, as the report suggests writing it */
67
+ export interface EmittedProgram {
68
+ name: string
69
+ /** the flat program text, with `${...}` where the source was dynamic */
70
+ value: string
71
+ dynamic: boolean
72
+ }
73
+
74
+ export interface ConversionFinding {
75
+ property: string
76
+ verdict: 'needs-relocation' | 'unknown-host' | 'ineligible'
77
+ reasons: readonly ConversionReason[]
78
+ }
79
+
80
+ export interface SiteReport {
81
+ kind: SiteKind
82
+ label: string
83
+ line: number
84
+ before: string
85
+ after: string
86
+ programs: EmittedProgram[]
87
+ /** semantic or host constraints that make an otherwise valid rewrite unsafe */
88
+ assessments: ConversionFinding[]
89
+ assessmentVerdict: 'clean' | ConversionFinding['verdict']
90
+ /** non-blocking configuration risks the codemod cannot verify */
91
+ warnings: Flag[]
92
+ /** the site cannot be converted correctly without a human */
93
+ flags: Flag[]
94
+ /** values left authored because they belong to another migration */
95
+ inventory: Flag[]
96
+ /** conversions the runtime cannot read yet, so they are not offered */
97
+ pending: Flag[]
98
+ notes: string[]
99
+ /** legacy condition props the conversion could not remove */
100
+ legacyLeft: number
101
+ }
102
+
103
+ function assessmentVerdict(
104
+ assessments: readonly ConversionFinding[]
105
+ ): SiteReport['assessmentVerdict'] {
106
+ if (assessments.some((assessment) => assessment.verdict === 'ineligible')) {
107
+ return 'ineligible'
108
+ }
109
+ if (assessments.some((assessment) => assessment.verdict === 'needs-relocation')) {
110
+ return 'needs-relocation'
111
+ }
112
+ return assessments.length ? 'unknown-host' : 'clean'
113
+ }
114
+
115
+ interface Contribution {
116
+ prop: string
117
+ clause: ParsedClause
118
+ dynamic: boolean
119
+ }
120
+
121
+ /** an opaque spread: its contents can reorder any program merged across it */
122
+ interface SpreadMember {
123
+ type: 'spread'
124
+ index: number
125
+ text: string
126
+ }
127
+
128
+ /** a prop the conversion keeps verbatim at its authored position */
129
+ interface PassthroughMember {
130
+ type: 'passthrough'
131
+ index: number
132
+ text: string
133
+ }
134
+
135
+ interface AuthoredMember {
136
+ type: 'authored'
137
+ index: number
138
+ prop: string
139
+ text: string
140
+ /** the flat payload this value folds into a program as, when it can */
141
+ payload: string | null
142
+ dynamic: boolean
143
+ /** why it cannot fold, raised only if a clause forces it */
144
+ blocked: Flag | null
145
+ /** the authored value contains a `$token` spelling that v3 must rewrite */
146
+ token: boolean
147
+ activated: boolean
148
+ }
149
+
150
+ interface LegacyMember {
151
+ type: 'legacy'
152
+ index: number
153
+ name: string
154
+ text: string
155
+ contributions: Contribution[]
156
+ /** the longhands this condition object sets, or null when it could not be read */
157
+ properties: ReadonlySet<string> | null
158
+ failed: boolean
159
+ }
160
+
161
+ type Member = SpreadMember | PassthroughMember | AuthoredMember | LegacyMember
162
+
163
+ interface Slot {
164
+ property: string
165
+ sourceProp: string
166
+ value: ParsedValue
167
+ anchor: number
168
+ last: number
169
+ dynamic: boolean
170
+ }
171
+
172
+ export interface Site {
173
+ kind: SiteKind
174
+ registry: ModifierRegistryView
175
+ /** which `group` declarations have a proven descendant needing a query container */
176
+ containers: ContainerPlan
177
+ targets: ConversionTargets
178
+ host: HostView | undefined
179
+ members: Member[]
180
+ comments: Map<number, readonly string[]>
181
+ extras: Array<{ index: number; text: string }>
182
+ warnings: Flag[]
183
+ flags: Flag[]
184
+ inventory: Flag[]
185
+ pending: Flag[]
186
+ assessments: ConversionFinding[]
187
+ notes: string[]
188
+ /** the site contains v1-only syntax, so it is a conversion site at all */
189
+ legacy: boolean
190
+ index: number
191
+ }
192
+
193
+ function createSite(
194
+ kind: SiteKind,
195
+ registry: ModifierRegistryView,
196
+ containers: ContainerPlan,
197
+ targets: ConversionTargets,
198
+ host: HostView | undefined
199
+ ): Site {
200
+ return {
201
+ kind,
202
+ registry,
203
+ containers,
204
+ targets,
205
+ host,
206
+ members: [],
207
+ comments: new Map(),
208
+ extras: [],
209
+ warnings: [],
210
+ flags: [],
211
+ inventory: [],
212
+ pending: [],
213
+ assessments: [],
214
+ notes: [],
215
+ legacy: false,
216
+ index: 0,
217
+ }
218
+ }
219
+
220
+ function assessProgram(
221
+ site: Site,
222
+ property: string,
223
+ modifiers: readonly string[]
224
+ ): boolean {
225
+ const targetProperty = resolveProp(property)
226
+ const assessment = assessFlatConversion(
227
+ {
228
+ property: targetProperty,
229
+ modifiers,
230
+ targets: site.targets,
231
+ host: site.host,
232
+ },
233
+ site.registry
234
+ )
235
+ if (assessment.verdict !== 'clean') addAssessment(site, targetProperty, assessment)
236
+
237
+ // A host warning or platform relocation was useful while this tool was only a
238
+ // report, but V3 has no legacy syntax to leave behind. Only a property family
239
+ // with no flat spelling can block the rewrite; every other assessment remains
240
+ // visible in the report for review while the migration proceeds.
241
+ return assessment.verdict !== 'ineligible'
242
+ }
243
+
244
+ function addAssessment(
245
+ site: Site,
246
+ property: string,
247
+ assessment: ReturnType<typeof assessFlatConversion>
248
+ ): void {
249
+ if (assessment.verdict === 'clean') return
250
+ if (
251
+ !site.assessments.some(
252
+ (finding) =>
253
+ finding.property === property &&
254
+ finding.verdict === assessment.verdict &&
255
+ JSON.stringify(finding.reasons) === JSON.stringify(assessment.reasons)
256
+ )
257
+ ) {
258
+ site.assessments.push({
259
+ property,
260
+ verdict: assessment.verdict,
261
+ reasons: assessment.reasons,
262
+ })
263
+ }
264
+ }
265
+
266
+ function addFlag(list: Flag[], code: string, detail: string): void {
267
+ if (!list.some((flag) => flag.code === code && flag.detail === detail)) {
268
+ list.push({ code, detail })
269
+ }
270
+ }
271
+
272
+ function addNote(site: Site, note: string): void {
273
+ if (!site.notes.includes(note)) site.notes.push(note)
274
+ }
275
+
276
+ const legacyPaletteStepPattern =
277
+ /(?:^|[^\w-])\$?((?:gray|mauve|slate|sage|olive|sand|tomato|red|ruby|crimson|pink|plum|purple|violet|iris|indigo|blue|cyan|teal|jade|green|grass|bronze|gold|brown|orange|amber|yellow|lime|mint|sky)(?:1[0-2]|[1-9]))(?![\w-])/g
278
+
279
+ function legacyPaletteStepWarning(prop: string, values: readonly string[]): Flag | null {
280
+ const names = new Set<string>()
281
+ for (const value of values) {
282
+ legacyPaletteStepPattern.lastIndex = 0
283
+ for (const match of value.matchAll(legacyPaletteStepPattern)) names.add(match[1])
284
+ }
285
+ if (!names.size) return null
286
+ const formatted = [...names]
287
+ .sort()
288
+ .map((name) => `\`${name}\``)
289
+ .join(', ')
290
+ return {
291
+ code: 'legacy-palette-token',
292
+ detail: `${prop} preserves ${formatted}, which @tamagui/config/v6 does not define; choose an absolute palette token or an adaptive colorN value`,
293
+ }
294
+ }
295
+
296
+ // —— value classification ————————————————————————————————————————————————
297
+
298
+ interface Classification {
299
+ /** the flat payload the value folds into a program as */
300
+ payload: string | null
301
+ /** the authored text, rewritten when the source held `$` token spellings */
302
+ text: string | null
303
+ /** the payload interpolates an expression, so the program prints as a template */
304
+ dynamic: boolean
305
+ /** a problem with the value itself, raised on sight */
306
+ problem: Flag | null
307
+ /** a non-blocking configuration risk visible in the authored literals */
308
+ warning: Flag | null
309
+ /** raised only when a clause forces the value into a program */
310
+ blocked: Flag | null
311
+ /** recorded for a migration that is not the flat-value migration */
312
+ inventory: Flag | null
313
+ }
314
+
315
+ const empty: Classification = {
316
+ payload: null,
317
+ text: null,
318
+ dynamic: false,
319
+ problem: null,
320
+ warning: null,
321
+ blocked: null,
322
+ inventory: null,
323
+ }
324
+
325
+ function interpolate(
326
+ prop: string,
327
+ text: string,
328
+ kind: 'number' | 'string',
329
+ registry: ModifierRegistryView
330
+ ): string {
331
+ return `\${${text}}${kind === 'number' ? unitSuffix(resolveProp(prop), registry) : ''}`
332
+ }
333
+
334
+ /**
335
+ * A static value's flat spelling, taken from the shared converter so a base and a
336
+ * clause payload for the same property always agree on units and token names.
337
+ */
338
+ function classifyStatic(
339
+ prop: string,
340
+ value: string | number,
341
+ registry: ModifierRegistryView
342
+ ): Classification {
343
+ const probe = sharedPayload(prop, value, registry)
344
+ const error = probe.errors[0]
345
+ if (error || probe.payload === null) {
346
+ const flag: Flag = {
347
+ code: error?.code ?? 'unsupported-legacy-value',
348
+ detail: `${prop}: ${error?.message ?? `"${String(value)}" has no flat spelling`}`,
349
+ }
350
+ return { ...empty, problem: flag, blocked: flag }
351
+ }
352
+ return { ...empty, payload: probe.payload }
353
+ }
354
+
355
+ /**
356
+ * A plain string is only safe to leave authored if the flat parser reads it back
357
+ * as exactly one base value: a top-level colon or brace in a v1 string means
358
+ * something else in V3.
359
+ */
360
+ function reparsesAsBase(value: string, registry: ModifierRegistryView): boolean {
361
+ const parsed = parseValue(value, registry)
362
+ return (
363
+ parsed.ok && parsed.value.clauses.length === 0 && parsed.value.base === value.trim()
364
+ )
365
+ }
366
+
367
+ function classifyDynamic(
368
+ prop: string,
369
+ expression: Expression,
370
+ registry: ModifierRegistryView
371
+ ): Classification {
372
+ const current = unwrapExpression(expression)
373
+ const source = compact(current.getText())
374
+
375
+ const structured = classifyStructuredNativeValue(
376
+ resolveProp(prop),
377
+ current,
378
+ source,
379
+ registry
380
+ )
381
+ if (structured) {
382
+ return {
383
+ ...empty,
384
+ payload: structured.payload,
385
+ blocked: structured.blocked,
386
+ }
387
+ }
388
+
389
+ const tree = literalTree(current, registry)
390
+ if (tree && tree.error) {
391
+ const flag: Flag = {
392
+ code: tree.error.code,
393
+ detail: `${prop}: ${tree.error.message}`,
394
+ }
395
+ return { ...empty, problem: flag, blocked: flag }
396
+ }
397
+ if (tree && tree.kind !== 'nullish') {
398
+ const rewrittenTokenText =
399
+ /\$(?=[\w-])/.test(source) && tree.text !== source ? tree.text : null
400
+ return {
401
+ ...empty,
402
+ payload: interpolate(prop, tree.text, tree.kind, registry),
403
+ text: rewrittenTokenText,
404
+ dynamic: true,
405
+ warning: legacyPaletteStepWarning(prop, tree.strings),
406
+ }
407
+ }
408
+ if (tree) {
409
+ const flag: Flag = {
410
+ code: 'empty-style-value',
411
+ detail: `${prop} value "${source}" is always nullish and cannot join a program`,
412
+ }
413
+ return { ...empty, blocked: flag }
414
+ }
415
+
416
+ const runtime = runtimeType(current)
417
+ if (runtime.kind === 'number') {
418
+ return {
419
+ ...empty,
420
+ payload: interpolate(prop, source, 'number', registry),
421
+ dynamic: true,
422
+ }
423
+ }
424
+ if (runtime.kind === 'string') {
425
+ const tokens = runtime.literals?.filter((literal) => literal.startsWith('$'))
426
+ if (tokens?.length) {
427
+ const flag: Flag = {
428
+ code: 'legacy-token-constant',
429
+ detail: `${prop} value "${source}" resolves to legacy token ${tokens
430
+ .map((token) => `"${token}"`)
431
+ .join(', ')}; migrate the constant it comes from`,
432
+ }
433
+ return { ...empty, problem: flag, blocked: flag }
434
+ }
435
+ if (runtime.literals === null) {
436
+ return {
437
+ ...empty,
438
+ payload: interpolate(prop, source, 'string', registry),
439
+ dynamic: true,
440
+ inventory: {
441
+ code: 'dynamic-string-value',
442
+ detail: `${prop} value "${source}" is an open string; confirm it never holds a legacy "$token" spelling`,
443
+ },
444
+ }
445
+ }
446
+ return {
447
+ ...empty,
448
+ payload: interpolate(prop, source, 'string', registry),
449
+ dynamic: true,
450
+ }
451
+ }
452
+
453
+ const flag: Flag = {
454
+ code: 'unprovable-dynamic-value',
455
+ detail: `${prop} value "${source}" has no provable number or string type, so it cannot fold into a program`,
456
+ }
457
+ return { ...empty, blocked: flag }
458
+ }
459
+
460
+ // —— members ————————————————————————————————————————————————————————————
461
+
462
+ function pushBase(
463
+ site: Site,
464
+ prop: string,
465
+ text: string,
466
+ value: Expression | null,
467
+ literalString: string | null
468
+ ): void {
469
+ const index = site.index++
470
+
471
+ if (literalString !== null) {
472
+ const warning = legacyPaletteStepWarning(prop, [literalString])
473
+ if (warning) addFlag(site.warnings, warning.code, warning.detail)
474
+ if (literalString.includes('$')) {
475
+ site.legacy = true
476
+ const allowed = assessProgram(site, prop, [])
477
+ // the same converter a clause payload goes through, so a base and a clause
478
+ // for one property agree on which `$` is a token candidate at all: one
479
+ // inside a quoted string or an unquoted url() body is literal CSS
480
+ const flat = flatStringValue(literalString, site.registry)
481
+ const problem: Flag | null =
482
+ flat.text === null
483
+ ? {
484
+ code: flat.error?.code ?? 'unsupported-legacy-value',
485
+ detail: `${prop}: ${flat.error?.message ?? `${JSON.stringify(literalString)} has no flat spelling`}`,
486
+ }
487
+ : !reparsesAsBase(flat.text, site.registry)
488
+ ? {
489
+ code: 'value-reparses-as-program',
490
+ detail: `${prop} value ${JSON.stringify(flat.text)} does not read back as one flat base value`,
491
+ }
492
+ : null
493
+
494
+ if (problem !== null) addFlag(site.flags, problem.code, problem.detail)
495
+ // v3 sends clause-free strings through the flat engine too, so a token base
496
+ // becomes a base-only program even when no legacy condition targets it
497
+ site.members.push({
498
+ type: 'authored',
499
+ index,
500
+ prop,
501
+ text,
502
+ payload: allowed && problem === null ? flat.text : null,
503
+ dynamic: false,
504
+ blocked: problem,
505
+ token: allowed,
506
+ activated: false,
507
+ })
508
+ return
509
+ }
510
+
511
+ if (!reparsesAsBase(literalString, site.registry)) {
512
+ const flag: Flag = {
513
+ code: 'value-reparses-as-program',
514
+ detail: `${prop} value ${JSON.stringify(literalString)} does not read back as one flat base value`,
515
+ }
516
+ addFlag(site.flags, flag.code, flag.detail)
517
+ site.members.push({
518
+ type: 'authored',
519
+ index,
520
+ prop,
521
+ text,
522
+ payload: null,
523
+ dynamic: false,
524
+ blocked: flag,
525
+ token: false,
526
+ activated: false,
527
+ })
528
+ return
529
+ }
530
+
531
+ site.members.push({
532
+ type: 'authored',
533
+ index,
534
+ prop,
535
+ text,
536
+ payload: literalString,
537
+ dynamic: false,
538
+ blocked: null,
539
+ token: false,
540
+ activated: false,
541
+ })
542
+ return
543
+ }
544
+
545
+ const number = value ? numericValue(value) : null
546
+ if (number !== null) {
547
+ const classified = classifyStatic(prop, number, site.registry)
548
+ site.members.push({
549
+ type: 'authored',
550
+ index,
551
+ prop,
552
+ text,
553
+ payload: classified.payload,
554
+ dynamic: false,
555
+ blocked: classified.blocked,
556
+ token: false,
557
+ activated: false,
558
+ })
559
+ return
560
+ }
561
+
562
+ const kind = value?.getKind()
563
+ if (
564
+ value === null ||
565
+ kind === SyntaxKind.TrueKeyword ||
566
+ kind === SyntaxKind.FalseKeyword ||
567
+ kind === SyntaxKind.NullKeyword
568
+ ) {
569
+ site.members.push({
570
+ type: 'authored',
571
+ index,
572
+ prop,
573
+ text,
574
+ payload: null,
575
+ dynamic: false,
576
+ blocked: {
577
+ code: 'non-css-style-value',
578
+ detail: `${prop} value "${compact(text)}" is not a CSS value and cannot join a program`,
579
+ },
580
+ token: false,
581
+ activated: false,
582
+ })
583
+ return
584
+ }
585
+
586
+ const classified = classifyDynamic(prop, value, site.registry)
587
+ if (classified.problem) {
588
+ site.legacy = true
589
+ addFlag(site.flags, classified.problem.code, classified.problem.detail)
590
+ }
591
+ if (classified.warning) {
592
+ addFlag(site.warnings, classified.warning.code, classified.warning.detail)
593
+ }
594
+ if (classified.inventory) {
595
+ addFlag(site.inventory, classified.inventory.code, classified.inventory.detail)
596
+ }
597
+ // a rewritten expression (`active ? '$red10' : '$blue10'`) also becomes a
598
+ // base-only program when no legacy condition targets it
599
+ if (classified.text !== null) site.legacy = true
600
+ const allowed = classified.text === null || assessProgram(site, prop, [])
601
+ site.members.push({
602
+ type: 'authored',
603
+ index,
604
+ prop,
605
+ text,
606
+ payload: allowed ? classified.payload : null,
607
+ dynamic: classified.dynamic,
608
+ blocked: classified.blocked,
609
+ token: allowed && classified.text !== null,
610
+ activated: false,
611
+ })
612
+ }
613
+
614
+ interface LegacyLeaf {
615
+ path: string
616
+ prop: string
617
+ expression: Expression
618
+ }
619
+
620
+ interface LegacyObject {
621
+ value: Record<string, unknown> | null
622
+ fatal: string | null
623
+ leaves: LegacyLeaf[]
624
+ }
625
+
626
+ const sentinelMark = '\u0001'
627
+
628
+ function evaluateLegacyObject(
629
+ object: ObjectLiteralExpression,
630
+ rootPath: string
631
+ ): LegacyObject {
632
+ const leaves: LegacyLeaf[] = []
633
+
634
+ const visit = (
635
+ current: ObjectLiteralExpression,
636
+ currentPath: string
637
+ ): { value: Record<string, unknown> | null; fatal: string | null } => {
638
+ const value: Record<string, unknown> = {}
639
+
640
+ for (const property of current.getProperties()) {
641
+ if (Node.isSpreadAssignment(property)) {
642
+ return {
643
+ value: null,
644
+ fatal: `spread "${compact(property.getText())}" hides legacy condition entries`,
645
+ }
646
+ }
647
+ if (!Node.isPropertyAssignment(property)) {
648
+ return {
649
+ value: null,
650
+ fatal: `property "${compact(property.getText())}" is not a static assignment`,
651
+ }
652
+ }
653
+
654
+ const nameNode = property.getNameNode()
655
+ if (Node.isComputedPropertyName(nameNode)) {
656
+ return {
657
+ value: null,
658
+ fatal: `computed property "${compact(nameNode.getText())}" hides the affected style property`,
659
+ }
660
+ }
661
+ const name = propertyName(nameNode)
662
+ if (name === null) {
663
+ return {
664
+ value: null,
665
+ fatal: `property name "${compact(nameNode.getText())}" is not statically known`,
666
+ }
667
+ }
668
+
669
+ const path = `${currentPath}.${name}`
670
+ const initializer = unwrapExpression(property.getInitializerOrThrow())
671
+ if (Node.isObjectLiteralExpression(initializer)) {
672
+ const nested = visit(initializer, path)
673
+ if (nested.fatal) return nested
674
+ value[name] = nested.value
675
+ continue
676
+ }
677
+
678
+ const leaf = staticLeafValue(initializer)
679
+ if (leaf) {
680
+ value[name] = leaf.value
681
+ continue
682
+ }
683
+
684
+ value[name] = `${sentinelMark}${leaves.length}${sentinelMark}`
685
+ leaves.push({ path, prop: name, expression: initializer })
686
+ }
687
+
688
+ return { value, fatal: null }
689
+ }
690
+
691
+ const result = visit(object, rootPath)
692
+ return { ...result, leaves }
693
+ }
694
+
695
+ /**
696
+ * Every longhand a legacy condition object sets, at any condition depth. A nested
697
+ * condition this pass cannot resolve still sets its descendants under some
698
+ * condition, so they all belong here: this set is the member's barrier, and
699
+ * leaving them out lets a later clause move across a value that can beat it.
700
+ */
701
+ function conditionProperties(value: Record<string, unknown>): Set<string> {
702
+ const properties = new Set<string>()
703
+ const visit = (object: Record<string, unknown>): void => {
704
+ for (const key in object) {
705
+ const child = object[key]
706
+ if (child !== null && typeof child === 'object' && isLegacyConditionName(key)) {
707
+ visit(child as Record<string, unknown>)
708
+ continue
709
+ }
710
+ if (!styleProps.has(key)) continue
711
+ for (const property of expandToLonghands(key, shorthands)) properties.add(property)
712
+ }
713
+ }
714
+ visit(value)
715
+ return properties
716
+ }
717
+
718
+ function pushLegacy(
719
+ site: Site,
720
+ name: string,
721
+ text: string,
722
+ initializer: Expression | null,
723
+ node: Node
724
+ ): void {
725
+ const index = site.index++
726
+ site.legacy = true
727
+
728
+ const keep = (properties: ReadonlySet<string> | null): void => {
729
+ site.members.push({
730
+ type: 'legacy',
731
+ index,
732
+ name,
733
+ text,
734
+ contributions: [],
735
+ properties,
736
+ failed: true,
737
+ })
738
+ }
739
+
740
+ if (initializer === null || !Node.isObjectLiteralExpression(initializer)) {
741
+ addFlag(
742
+ site.flags,
743
+ 'dynamic-legacy-condition',
744
+ `"${name}" is not an inline object literal, so its entries are not statically known`
745
+ )
746
+ keep(null)
747
+ return
748
+ }
749
+
750
+ const evaluated = evaluateLegacyObject(initializer, name)
751
+ if (evaluated.fatal || evaluated.value === null) {
752
+ addFlag(site.flags, 'dynamic-legacy-condition', evaluated.fatal ?? 'unresolved')
753
+ keep(null)
754
+ return
755
+ }
756
+
757
+ // what this object sets, whether or not it converts: an object left authored
758
+ // still contributes at its position, which is what decides whether a program can
759
+ // merge across it
760
+ const properties = conditionProperties(evaluated.value)
761
+
762
+ const eligibilityStack: Array<{
763
+ object: Record<string, unknown>
764
+ path: string
765
+ }> = [{ object: evaluated.value, path: name }]
766
+ let hasRejectedProperty = false
767
+ while (eligibilityStack.length > 0) {
768
+ const current = eligibilityStack.pop()!
769
+ for (const prop in current.object) {
770
+ const value = current.object[prop]
771
+ const targetProp = resolveProp(prop)
772
+ if (styleProps.has(targetProp) && !isLegacyConditionName(prop)) {
773
+ const assessment = assessFlatConversion(
774
+ {
775
+ property: targetProp,
776
+ targets: site.targets,
777
+ host: site.host,
778
+ },
779
+ site.registry
780
+ )
781
+ if (assessment.verdict === 'ineligible') {
782
+ addAssessment(site, targetProp, assessment)
783
+ hasRejectedProperty = true
784
+ }
785
+ continue
786
+ }
787
+ if (
788
+ value !== null &&
789
+ typeof value === 'object' &&
790
+ !Array.isArray(value) &&
791
+ isLegacyConditionName(prop)
792
+ ) {
793
+ eligibilityStack.push({
794
+ object: value as Record<string, unknown>,
795
+ path: `${current.path}.${prop}`,
796
+ })
797
+ }
798
+ }
799
+ }
800
+ if (hasRejectedProperty) {
801
+ keep(properties)
802
+ return
803
+ }
804
+
805
+ // the condition would convert, but the query it becomes needs a container this
806
+ // pass cannot place, so the whole object stays authored rather than converting
807
+ // into a query nothing matches
808
+ const unresolved = site.containers.unresolved.get(node)
809
+ if (unresolved !== undefined) {
810
+ addFlag(site.flags, unresolved.code, unresolved.detail)
811
+ keep(properties)
812
+ return
813
+ }
814
+
815
+ const resolution = resolveLegacyName(name, site.registry)
816
+ if (!resolution.ok) {
817
+ addFlag(site.flags, resolution.code, resolution.message)
818
+ keep(properties)
819
+ return
820
+ }
821
+
822
+ const payloads = new Map<string, string>()
823
+ let failed = false
824
+ for (let index = 0; index < evaluated.leaves.length; index++) {
825
+ const leaf = evaluated.leaves[index]
826
+ const classified = classifyDynamic(leaf.prop, leaf.expression, site.registry)
827
+ if (classified.warning) {
828
+ addFlag(
829
+ site.warnings,
830
+ classified.warning.code,
831
+ `${leaf.path}: ${classified.warning.detail}`
832
+ )
833
+ }
834
+ if (classified.payload === null) {
835
+ const flag = classified.problem ?? classified.blocked
836
+ addFlag(
837
+ site.flags,
838
+ flag?.code ?? 'dynamic-condition-value',
839
+ `${leaf.path}: ${flag?.detail ?? 'the value is not statically known'}`
840
+ )
841
+ failed = true
842
+ continue
843
+ }
844
+ payloads.set(`${sentinelMark}${index}${sentinelMark}`, classified.payload)
845
+ }
846
+ if (failed) {
847
+ keep(properties)
848
+ return
849
+ }
850
+
851
+ const { canonical, replaceRoot } = resolution.resolved
852
+ const converted = convertLegacyConditionProp(canonical, evaluated.value, {
853
+ registry: site.registry,
854
+ })
855
+ if (converted === null) {
856
+ addFlag(
857
+ site.flags,
858
+ 'unknown-legacy-condition',
859
+ `"${name}" is not a registered legacy condition spelling`
860
+ )
861
+ keep(properties)
862
+ return
863
+ }
864
+
865
+ for (const error of converted.errors) {
866
+ const path = error.path.startsWith(canonical)
867
+ ? `${name}${error.path.slice(canonical.length)}`
868
+ : error.path
869
+ addFlag(site.flags, error.code, `${path}: ${error.message}`)
870
+ failed = true
871
+ }
872
+
873
+ const contributions: Contribution[] = []
874
+ for (const contribution of converted.contributions) {
875
+ if (!styleProps.has(contribution.prop)) {
876
+ addFlag(
877
+ site.flags,
878
+ 'non-style-condition-entry',
879
+ `${name}.${contribution.prop} is not a style property, so a flat value cannot carry it`
880
+ )
881
+ failed = true
882
+ continue
883
+ }
884
+ const modifiers = replaceRoot
885
+ ? [...replaceRoot, ...contribution.clause.modifiers.slice(1)]
886
+ : contribution.clause.modifiers
887
+ const dynamicPayload = payloads.get(contribution.clause.payload)
888
+ contributions.push({
889
+ prop: contribution.prop,
890
+ clause: {
891
+ modifiers,
892
+ payload: dynamicPayload ?? contribution.clause.payload,
893
+ },
894
+ dynamic: dynamicPayload !== undefined,
895
+ })
896
+ if (!assessProgram(site, contribution.prop, modifiers)) failed = true
897
+ }
898
+
899
+ if (failed) {
900
+ keep(properties)
901
+ return
902
+ }
903
+
904
+ site.members.push({
905
+ type: 'legacy',
906
+ index,
907
+ name,
908
+ text,
909
+ contributions,
910
+ properties,
911
+ failed: false,
912
+ })
913
+ }
914
+
915
+ // —— assembly ————————————————————————————————————————————————————————————
916
+
917
+ function activationName(prop: string): string {
918
+ return resolveProp(prop)
919
+ }
920
+
921
+ interface Entry {
922
+ prop: string
923
+ value: ParsedValue
924
+ dynamic: boolean
925
+ index: number
926
+ base: boolean
927
+ /** the legacy attribute this clause came from, when it came from one */
928
+ from: LegacyMember | null
929
+ /** V2 resolved overlapping pseudo objects by this fixed priority. */
930
+ legacyStatePriority: number | null
931
+ }
932
+
933
+ /**
934
+ * A member the merge leaves in place still contributes at its authored position, so
935
+ * a program merged across it can change what wins. A base only competes with other
936
+ * bases and a clause only with other clauses, and an opaque spread competes with
937
+ * both.
938
+ */
939
+ interface Barrier {
940
+ index: number
941
+ /** the authored text, so the report can name what blocks the merge */
942
+ source: string
943
+ bases: ReadonlySet<string> | null
944
+ clauses: ReadonlySet<string> | null
945
+ }
946
+
947
+ const noProperties: ReadonlySet<string> = new Set()
948
+
949
+ const legacyStatePriorities: Readonly<Record<string, number>> = Object.freeze({
950
+ hover: 2,
951
+ press: 3,
952
+ active: 3,
953
+ focus: 4,
954
+ 'focus-visible': 4,
955
+ 'focus-within': 4,
956
+ enter: 4,
957
+ disabled: 5,
958
+ exit: 5,
959
+ })
960
+
961
+ function legacyStatePriority(modifiers: readonly string[]): number | null {
962
+ let priority: number | null = null
963
+ for (const modifier of modifiers) {
964
+ const candidate = legacyStatePriorities[modifier]
965
+ if (candidate !== undefined && (priority === null || candidate > priority)) {
966
+ priority = candidate
967
+ }
968
+ }
969
+ return priority
970
+ }
971
+
972
+ function orderedEntries(site: Site): Entry[] {
973
+ const ordered: Entry[] = []
974
+ for (const member of site.members) {
975
+ if (member.type === 'authored') {
976
+ if (!member.activated || member.payload === null) continue
977
+ ordered.push({
978
+ prop: member.prop,
979
+ value: { base: member.payload, clauses: [] },
980
+ dynamic: member.dynamic,
981
+ index: member.index,
982
+ base: true,
983
+ from: null,
984
+ legacyStatePriority: null,
985
+ })
986
+ continue
987
+ }
988
+ if (member.type !== 'legacy' || member.failed) continue
989
+ for (const contribution of member.contributions) {
990
+ // no base is invented here. A legacy condition object only ever added a
991
+ // conditional value, and a clause-only program (decision 21) keeps whatever
992
+ // base the styled component, variant, or call site defined — the same thing
993
+ // v1's separate `enterStyle` prop did
994
+ ordered.push({
995
+ prop: contribution.prop,
996
+ value: { base: null, clauses: [contribution.clause] },
997
+ dynamic: contribution.dynamic,
998
+ index: member.index,
999
+ base: false,
1000
+ from: member,
1001
+ legacyStatePriority: legacyStatePriority(contribution.clause.modifiers),
1002
+ })
1003
+ }
1004
+ }
1005
+
1006
+ // New flat programs are authored-order by design, but V2 pseudo objects had
1007
+ // fixed overlap precedence regardless of object-property order. Reorder only
1008
+ // the legacy state entries in their existing positions; authored bases and
1009
+ // non-state conditions remain anchored exactly where they were.
1010
+ const ranked = ordered
1011
+ .filter((entry) => entry.legacyStatePriority !== null)
1012
+ .sort(
1013
+ (left, right) =>
1014
+ left.legacyStatePriority! - right.legacyStatePriority! || left.index - right.index
1015
+ )
1016
+ if (ranked.length < 2) return ordered
1017
+
1018
+ let rankedIndex = 0
1019
+ return ordered.map((entry) =>
1020
+ entry.legacyStatePriority === null ? entry : ranked[rankedIndex++]
1021
+ )
1022
+ }
1023
+
1024
+ function buildSlots(ordered: readonly Entry[]): Map<string, Slot> {
1025
+ const slots = new Map<string, Slot>()
1026
+ for (const entry of ordered) {
1027
+ for (const property of expandToLonghands(entry.prop, shorthands)) {
1028
+ const previous = slots.get(property)
1029
+ const value = previous
1030
+ ? mergeProgramValues(previous.value, entry.value)
1031
+ : entry.value
1032
+ slots.delete(property)
1033
+ slots.set(property, {
1034
+ property,
1035
+ sourceProp: entry.prop,
1036
+ value,
1037
+ anchor: previous ? Math.min(previous.anchor, entry.index) : entry.index,
1038
+ last: previous ? Math.max(previous.last, entry.index) : entry.index,
1039
+ dynamic: (previous?.dynamic ?? false) || entry.dynamic,
1040
+ })
1041
+ }
1042
+ }
1043
+ return slots
1044
+ }
1045
+
1046
+ function barriers(site: Site): Barrier[] {
1047
+ const list: Barrier[] = []
1048
+ for (const member of site.members) {
1049
+ if (member.type === 'spread') {
1050
+ list.push({ index: member.index, source: member.text, bases: null, clauses: null })
1051
+ continue
1052
+ }
1053
+ if (member.type === 'authored' && !member.activated) {
1054
+ list.push({
1055
+ index: member.index,
1056
+ source: member.text,
1057
+ bases: new Set(expandToLonghands(member.prop, shorthands)),
1058
+ clauses: noProperties,
1059
+ })
1060
+ continue
1061
+ }
1062
+ if (member.type === 'legacy' && member.failed) {
1063
+ list.push({
1064
+ index: member.index,
1065
+ source: member.name,
1066
+ bases: noProperties,
1067
+ clauses: member.properties,
1068
+ })
1069
+ }
1070
+ }
1071
+ return list
1072
+ }
1073
+
1074
+ /**
1075
+ * Merging is only allowed when the merged program lands where every contribution
1076
+ * still beats and loses to the same things it did. A barrier between contributions
1077
+ * of the same kind breaks that, so the contributions after it go back to being
1078
+ * authored: partial conversion in authored order beats a reordered whole one.
1079
+ */
1080
+ function resolveBarriers(
1081
+ site: Site,
1082
+ ordered: readonly Entry[],
1083
+ slots: Map<string, Slot>
1084
+ ): boolean {
1085
+ let changed = false
1086
+
1087
+ for (const slot of slots.values()) {
1088
+ const contributions = ordered.filter((entry) =>
1089
+ expandToLonghands(entry.prop, shorthands).includes(slot.property)
1090
+ )
1091
+ for (const barrier of barriers(site)) {
1092
+ if (barrier.index <= slot.anchor || barrier.index >= slot.last) continue
1093
+
1094
+ if (barrier.clauses === null || barrier.clauses.has(slot.property)) {
1095
+ for (const entry of contributions) {
1096
+ if (entry.base || entry.index < barrier.index || !entry.from) continue
1097
+ addFlag(
1098
+ site.flags,
1099
+ 'condition-order-not-preservable',
1100
+ `"${compact(barrier.source)}" can set "${slot.property}" between the values contributing to it, so "${entry.from.name}" stays authored instead of merging`
1101
+ )
1102
+ entry.from.failed = true
1103
+ changed = true
1104
+ }
1105
+ }
1106
+
1107
+ if (barrier.bases === null || barrier.bases.has(slot.property)) {
1108
+ for (const entry of contributions) {
1109
+ if (!entry.base || entry.index < barrier.index) continue
1110
+ const authored = site.members.find(
1111
+ (member) =>
1112
+ member.type === 'authored' &&
1113
+ member.index === entry.index &&
1114
+ member.activated
1115
+ )
1116
+ if (authored && authored.type === 'authored') {
1117
+ authored.payload = null
1118
+ authored.blocked = {
1119
+ code: 'base-order-not-preservable',
1120
+ detail: `"${compact(barrier.source)}" can set "${slot.property}" between the values contributing to it, so this base cannot move`,
1121
+ }
1122
+ changed = true
1123
+ continue
1124
+ }
1125
+ addFlag(
1126
+ site.flags,
1127
+ 'base-order-not-preservable',
1128
+ `"${compact(barrier.source)}" can set "${slot.property}" between the values contributing to it, so the merged base may win where it did not before`
1129
+ )
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ return changed
1136
+ }
1137
+
1138
+ function assemble(site: Site): {
1139
+ entries: Array<{ index: number; text: string }>
1140
+ programs: EmittedProgram[]
1141
+ } {
1142
+ let slots = new Map<string, Slot>()
1143
+
1144
+ // an authored base folds into a program only when the program would otherwise
1145
+ // need a second attribute of the same name; a base that cannot fold fails the
1146
+ // legacy attributes that need it, which can in turn release other bases and
1147
+ // change which merges are still in authored order
1148
+ for (;;) {
1149
+ let changed = false
1150
+ const contributed = new Set<string>()
1151
+ for (const member of site.members) {
1152
+ if (member.type !== 'legacy' || member.failed) continue
1153
+ for (const contribution of member.contributions) {
1154
+ contributed.add(activationName(contribution.prop))
1155
+ }
1156
+ }
1157
+
1158
+ for (const member of site.members) {
1159
+ if (member.type !== 'authored') continue
1160
+ const name = activationName(member.prop)
1161
+ member.activated =
1162
+ (member.token && member.payload !== null) || contributed.has(name)
1163
+ if (!member.activated || member.payload !== null) continue
1164
+
1165
+ // one root reason: the base a condition needs cannot become a flat payload
1166
+ const blocked = member.blocked
1167
+ addFlag(
1168
+ site.flags,
1169
+ blocked?.code ?? 'unprovable-dynamic-value',
1170
+ `a legacy condition targets "${member.prop}": ${
1171
+ blocked?.detail ??
1172
+ `its base value "${compact(member.text)}" cannot join a program`
1173
+ }`
1174
+ )
1175
+ member.activated = false
1176
+ for (const other of site.members) {
1177
+ if (other.type !== 'legacy' || other.failed) continue
1178
+ if (other.contributions.some((one) => activationName(one.prop) === name)) {
1179
+ other.failed = true
1180
+ changed = true
1181
+ }
1182
+ }
1183
+ }
1184
+ if (changed) continue
1185
+
1186
+ const ordered = orderedEntries(site)
1187
+ slots = buildSlots(ordered)
1188
+ if (!resolveBarriers(site, ordered, slots)) break
1189
+ }
1190
+
1191
+ const entries: Array<{ index: number; text: string }> = []
1192
+ for (const member of site.members) {
1193
+ if (member.type === 'authored' && member.activated) continue
1194
+ if (member.type === 'legacy' && !member.failed) continue
1195
+ entries.push({ index: member.index, text: member.text })
1196
+ }
1197
+ const printed = printSlots(site, slots)
1198
+ entries.push(...printed.output)
1199
+ entries.push(...site.extras)
1200
+ entries.sort((left, right) => left.index - right.index)
1201
+ return { entries, programs: printed.programs }
1202
+ }
1203
+
1204
+ function printSlots(
1205
+ site: Site,
1206
+ slots: Map<string, Slot>
1207
+ ): { output: Array<{ index: number; text: string }>; programs: EmittedProgram[] } {
1208
+ const printed = new Set<string>()
1209
+ const output: Array<{ index: number; text: string }> = []
1210
+ const programs: EmittedProgram[] = []
1211
+ const outputCommentRanges: Array<{ outputIndex: number; first: number; last: number }> =
1212
+ []
1213
+
1214
+ for (const [property, slot] of slots) {
1215
+ if (printed.has(property)) continue
1216
+ const serialized = printProgram(slot.value)
1217
+ const expansion = expandToLonghands(slot.sourceProp, shorthands)
1218
+ const collapses =
1219
+ expansion.length > 0 &&
1220
+ expansion.every((expanded) => {
1221
+ const candidate = slots.get(expanded)
1222
+ return (
1223
+ candidate !== undefined &&
1224
+ candidate.sourceProp === slot.sourceProp &&
1225
+ candidate.anchor === slot.anchor &&
1226
+ candidate.dynamic === slot.dynamic &&
1227
+ printProgram(candidate.value) === serialized
1228
+ )
1229
+ })
1230
+ const name = collapses ? slot.sourceProp : property
1231
+ if (collapses) for (const expanded of expansion) printed.add(expanded)
1232
+ else printed.add(property)
1233
+
1234
+ const value = slot.dynamic ? `\`${serialized}\`` : JSON.stringify(serialized)
1235
+ programs.push({ name, value: serialized, dynamic: slot.dynamic })
1236
+ const propertyText =
1237
+ site.kind === 'styled'
1238
+ ? `${name}: ${value}`
1239
+ : `${name}=${slot.dynamic ? `{${value}}` : value}`
1240
+ output.push({
1241
+ index: slot.anchor,
1242
+ text: propertyText,
1243
+ })
1244
+ outputCommentRanges.push({
1245
+ outputIndex: output.length - 1,
1246
+ first: slot.anchor,
1247
+ last: slot.last,
1248
+ })
1249
+ }
1250
+
1251
+ verify(
1252
+ site,
1253
+ slots,
1254
+ [...output].sort((left, right) => left.index - right.index)
1255
+ )
1256
+ if (site.kind === 'styled') {
1257
+ const commentedIndexes = new Set<number>()
1258
+ for (const range of outputCommentRanges) {
1259
+ const comments: string[] = []
1260
+ for (const [index, texts] of site.comments) {
1261
+ if (index < range.first || index > range.last || commentedIndexes.has(index)) {
1262
+ continue
1263
+ }
1264
+ comments.push(...texts)
1265
+ commentedIndexes.add(index)
1266
+ }
1267
+ if (comments.length) {
1268
+ output[range.outputIndex].text = `${comments.join('\n')}\n${
1269
+ output[range.outputIndex].text
1270
+ }`
1271
+ }
1272
+ }
1273
+ }
1274
+ return { output, programs }
1275
+ }
1276
+
1277
+ /**
1278
+ * Every `${...}` hole replaced by one opaque word, so a printed program can be
1279
+ * re-parsed as a program. Brace matching, not a regex: an interpolated expression
1280
+ * can hold braces of its own (`${`accent${n}`}`).
1281
+ */
1282
+ export function sanitize(text: string): string {
1283
+ let result = ''
1284
+ for (let index = 0; index < text.length; index++) {
1285
+ if (text[index] !== '$' || text[index + 1] !== '{') {
1286
+ result += text[index]
1287
+ continue
1288
+ }
1289
+ let depth = 0
1290
+ let end = index + 1
1291
+ for (; end < text.length; end++) {
1292
+ if (text[end] === '{') depth++
1293
+ else if (text[end] === '}' && --depth === 0) break
1294
+ }
1295
+ result += 'zz'
1296
+ index = end
1297
+ }
1298
+ return result
1299
+ }
1300
+
1301
+ /**
1302
+ * The printed props are parsed back with the real value parser and merged with the
1303
+ * real clause merge. Anything the printer got wrong — a lost clause, a bad unit, an
1304
+ * unregistered modifier, a collapse that does not expand back — shows up here
1305
+ * instead of in an app.
1306
+ */
1307
+ function verify(
1308
+ site: Site,
1309
+ slots: Map<string, Slot>,
1310
+ output: Array<{ index: number; text: string }>
1311
+ ): void {
1312
+ const separator = site.kind === 'styled' ? ': ' : '='
1313
+ const reparsed = new Map<string, ParsedValue>()
1314
+
1315
+ for (const entry of output) {
1316
+ const split = entry.text.indexOf(separator)
1317
+ const prop = entry.text.slice(0, split)
1318
+ let raw = entry.text.slice(split + separator.length)
1319
+ if (raw.startsWith('{')) raw = raw.slice(1, -1)
1320
+ const text = sanitize(raw.slice(1, -1))
1321
+ const parsed = parseValue(text, site.registry)
1322
+ if (!parsed.ok) {
1323
+ addFlag(
1324
+ site.flags,
1325
+ 'emitted-value-invalid',
1326
+ `"${prop}=${text}" does not parse: ${parsed.errors.map((error) => error.message).join('; ')}`
1327
+ )
1328
+ return
1329
+ }
1330
+ for (const property of expandToLonghands(prop, shorthands)) {
1331
+ const previous = reparsed.get(property)
1332
+ reparsed.set(
1333
+ property,
1334
+ previous ? mergeProgramValues(previous, parsed.value) : parsed.value
1335
+ )
1336
+ }
1337
+ }
1338
+
1339
+ for (const [property, slot] of slots) {
1340
+ const actual = reparsed.get(property)
1341
+ const expected = sanitize(printProgram(slot.value))
1342
+ if (actual === undefined || sanitize(printProgram(actual)) !== expected) {
1343
+ addFlag(
1344
+ site.flags,
1345
+ 'emitted-program-mismatch',
1346
+ `"${property}" reads back as "${actual ? sanitize(printProgram(actual)) : '(missing)'}" instead of "${expected}"`
1347
+ )
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ // —— JSX and styled walkers ——————————————————————————————————————————————
1353
+
1354
+ function propertyName(node: Node): string | null {
1355
+ if (
1356
+ Node.isIdentifier(node) ||
1357
+ Node.isStringLiteral(node) ||
1358
+ Node.isNumericLiteral(node)
1359
+ ) {
1360
+ return node.getText().replace(/^['"]|['"]$/g, '')
1361
+ }
1362
+ return null
1363
+ }
1364
+
1365
+ function jsxAttributeName(attribute: JsxAttribute): string | null {
1366
+ const name = attribute.getNameNode()
1367
+ return Node.isIdentifier(name) ? name.getText() : null
1368
+ }
1369
+
1370
+ function jsxLiteralString(attribute: JsxAttribute): string | null {
1371
+ const initializer = attribute.getInitializer()
1372
+ if (Node.isStringLiteral(initializer)) return initializer.getLiteralValue()
1373
+ const expression = jsxExpression(attribute)
1374
+ if (
1375
+ expression &&
1376
+ (Node.isStringLiteral(expression) || Node.isNoSubstitutionTemplateLiteral(expression))
1377
+ ) {
1378
+ return expression.getLiteralValue()
1379
+ }
1380
+ return null
1381
+ }
1382
+
1383
+ function jsxExpression(attribute: JsxAttribute): Expression | null {
1384
+ const initializer = attribute.getInitializer()
1385
+ if (!Node.isJsxExpression(initializer)) return null
1386
+ const expression = initializer.getExpression()
1387
+ return expression ? unwrapExpression(expression) : null
1388
+ }
1389
+
1390
+ type JsxElementWithAttributes = JsxOpeningElement | JsxSelfClosingElement
1391
+
1392
+ function isConvertedJsxAttribute(attribute: Node): boolean {
1393
+ if (Node.isJsxSpreadAttribute(attribute)) return true
1394
+ if (!Node.isJsxAttribute(attribute)) return false
1395
+ const name = jsxAttributeName(attribute)
1396
+ return (
1397
+ !!name && (name === 'group' || styleProps.has(name) || isLegacyConditionName(name))
1398
+ )
1399
+ }
1400
+
1401
+ function rewriteJsxSite(
1402
+ opening: JsxElementWithAttributes,
1403
+ entries: Array<{ index: number; text: string }>
1404
+ ): void {
1405
+ const attributes = opening.getAttributes()
1406
+ const rendered: string[] = []
1407
+ let inserted = false
1408
+ for (const attribute of attributes) {
1409
+ if (isConvertedJsxAttribute(attribute)) {
1410
+ if (!inserted) {
1411
+ rendered.push(...entries.map((entry) => entry.text))
1412
+ inserted = true
1413
+ }
1414
+ } else {
1415
+ rendered.push(attribute.getText())
1416
+ }
1417
+ }
1418
+
1419
+ const source = opening.getText()
1420
+ const start = opening.getStart()
1421
+ const first = attributes[0]
1422
+ const last = attributes[attributes.length - 1]
1423
+ const prefix = source.slice(0, first.getStart() - start)
1424
+ const suffix = source.slice(last.getEnd() - start)
1425
+ opening.replaceWithText(`${prefix}${rendered.join(' ')}${suffix}`)
1426
+ }
1427
+
1428
+ /**
1429
+ * V3 separates the group from the query container, so a legacy group condition
1430
+ * carrying a container size (`$group-card-sm-hover`) needs the element that
1431
+ * declares the group to declare the container too. `declaration` is the node that
1432
+ * declares `group`; the plan decided which declarations a container belongs on.
1433
+ */
1434
+ function containerExtras(site: Site, declaration: Node, index: number): void {
1435
+ const target = site.containers.targets.get(declaration)
1436
+ if (target === undefined) return
1437
+ const name = target.named && target.group !== '' ? target.group : null
1438
+ const text =
1439
+ site.kind === 'styled'
1440
+ ? name === null
1441
+ ? 'container: true'
1442
+ : `container: true, containerName: ${JSON.stringify(name)}`
1443
+ : name === null
1444
+ ? 'container'
1445
+ : `container containerName=${JSON.stringify(name)}`
1446
+ // adding the container is itself a migration edit, so this element is a site even
1447
+ // when it has no other v1 syntax
1448
+ site.legacy = true
1449
+ site.extras.push({ index, text })
1450
+ if (target.flag !== null) addFlag(site.flags, target.flag.code, target.flag.detail)
1451
+ addNote(
1452
+ site,
1453
+ `a descendant uses a legacy container-size condition on this group, so it declares a query container`
1454
+ )
1455
+ }
1456
+
1457
+ export function convertJsxSite(
1458
+ opening: JsxElementWithAttributes,
1459
+ registry: ModifierRegistryView,
1460
+ containers: ContainerPlan,
1461
+ targets: ConversionTargets,
1462
+ host: HostView | undefined,
1463
+ write = false
1464
+ ): SiteReport | null {
1465
+ const site = createSite('jsx', registry, containers, targets, host)
1466
+ const before: string[] = []
1467
+
1468
+ for (const attribute of opening.getAttributes()) {
1469
+ if (Node.isJsxSpreadAttribute(attribute)) {
1470
+ const expression = unwrapExpression(attribute.getExpression())
1471
+ // spreading an object literal is the same thing as writing its properties
1472
+ if (Node.isObjectLiteralExpression(expression)) {
1473
+ before.push(compact(attribute.getText()))
1474
+ for (const property of expression.getProperties()) {
1475
+ if (Node.isPropertyAssignment(property)) {
1476
+ const name = propertyName(property.getNameNode())
1477
+ if (name !== null) {
1478
+ // a member the conversion leaves authored has to print as the JSX
1479
+ // attribute it becomes here, not as the object member it was
1480
+ if (
1481
+ name === 'group' ||
1482
+ styleProps.has(name) ||
1483
+ isLegacyConditionName(name)
1484
+ ) {
1485
+ pushStyledProperty(
1486
+ site,
1487
+ name,
1488
+ property,
1489
+ `${name}={${property.getInitializerOrThrow().getText()}}`
1490
+ )
1491
+ } else {
1492
+ site.members.push({
1493
+ type: 'passthrough',
1494
+ index: site.index++,
1495
+ text: `${name}={${property.getInitializerOrThrow().getText()}}`,
1496
+ })
1497
+ }
1498
+ continue
1499
+ }
1500
+ }
1501
+ // a nested spread or a member whose key is not statically known can set
1502
+ // anything, so it stays where it was authored and orders the merge
1503
+ site.members.push({
1504
+ type: 'spread',
1505
+ index: site.index++,
1506
+ text: Node.isSpreadAssignment(property)
1507
+ ? `{${property.getText()}}`
1508
+ : `{...{ ${property.getText()} }}`,
1509
+ })
1510
+ }
1511
+ continue
1512
+ }
1513
+ before.push(compact(attribute.getText()))
1514
+ site.members.push({
1515
+ type: 'spread',
1516
+ index: site.index++,
1517
+ text: attribute.getText(),
1518
+ })
1519
+ continue
1520
+ }
1521
+
1522
+ const name = jsxAttributeName(attribute)
1523
+ if (!name) continue
1524
+ const text = compact(attribute.getText())
1525
+
1526
+ if (name === 'group') {
1527
+ before.push(text)
1528
+ containerExtras(site, attribute, site.index)
1529
+ site.members.push({ type: 'passthrough', index: site.index++, text })
1530
+ continue
1531
+ }
1532
+
1533
+ if (isLegacyConditionName(name)) {
1534
+ before.push(text)
1535
+ pushLegacy(site, name, text, jsxExpression(attribute), attribute)
1536
+ continue
1537
+ }
1538
+ if (!styleProps.has(name)) continue
1539
+ before.push(text)
1540
+
1541
+ const literal = jsxLiteralString(attribute)
1542
+ pushBase(
1543
+ site,
1544
+ name,
1545
+ text,
1546
+ literal === null ? jsxExpression(attribute) : null,
1547
+ literal
1548
+ )
1549
+ }
1550
+
1551
+ if (!site.legacy) return null
1552
+
1553
+ const { entries, programs } = assemble(site)
1554
+ const sourceFile = opening.getSourceFile()
1555
+ const report: SiteReport = {
1556
+ kind: 'jsx',
1557
+ label: `<${opening.getTagNameNode().getText()}>`,
1558
+ line: sourceFile.getLineAndColumnAtPos(opening.getStart()).line,
1559
+ before: before.join(' '),
1560
+ after: entries.map((entry) => entry.text).join(' ') || '(no style props left)',
1561
+ programs,
1562
+ assessments: site.assessments,
1563
+ assessmentVerdict: assessmentVerdict(site.assessments),
1564
+ warnings: site.warnings,
1565
+ flags: site.flags,
1566
+ inventory: site.inventory,
1567
+ pending: site.pending,
1568
+ notes: site.notes,
1569
+ legacyLeft: site.members.filter((member) => member.type === 'legacy' && member.failed)
1570
+ .length,
1571
+ }
1572
+ if (
1573
+ write &&
1574
+ !site.flags.some(
1575
+ (flag) =>
1576
+ flag.code === 'emitted-program-mismatch' || flag.code === 'emitted-value-invalid'
1577
+ )
1578
+ ) {
1579
+ rewriteJsxSite(opening, entries)
1580
+ }
1581
+ return report
1582
+ }
1583
+
1584
+ function pushStyledProperty(
1585
+ site: Site,
1586
+ name: string,
1587
+ property: PropertyAssignment,
1588
+ authoredText?: string
1589
+ ): void {
1590
+ const comments = allCommentTexts(property)
1591
+ if (comments.length) site.comments.set(site.index, comments)
1592
+ const text = authoredText ?? textWithOuterComments(property)
1593
+ const initializer = unwrapExpression(property.getInitializerOrThrow())
1594
+
1595
+ if (name === 'group') {
1596
+ containerExtras(site, property, site.index)
1597
+ site.members.push({ type: 'passthrough', index: site.index++, text })
1598
+ return
1599
+ }
1600
+
1601
+ if (isLegacyConditionName(name)) {
1602
+ pushLegacy(site, name, text, initializer, property)
1603
+ return
1604
+ }
1605
+ if (!styleProps.has(name)) return
1606
+
1607
+ const literal =
1608
+ Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)
1609
+ ? initializer.getLiteralValue()
1610
+ : null
1611
+ pushBase(site, name, text, literal === null ? initializer : null, literal)
1612
+ }
1613
+
1614
+ export function convertStyleObject(
1615
+ object: ObjectLiteralExpression,
1616
+ kind: SiteKind,
1617
+ label: string,
1618
+ registry: ModifierRegistryView,
1619
+ containers: ContainerPlan,
1620
+ targets: ConversionTargets,
1621
+ host: HostView | undefined,
1622
+ write = false
1623
+ ): SiteReport | null {
1624
+ const site = createSite(kind, registry, containers, targets, host)
1625
+ const before: string[] = []
1626
+
1627
+ for (const property of object.getProperties()) {
1628
+ if (Node.isSpreadAssignment(property)) {
1629
+ const expression = unwrapExpression(property.getExpression())
1630
+ before.push(compact(property.getText()))
1631
+ if (Node.isObjectLiteralExpression(expression)) {
1632
+ for (const nested of expression.getProperties()) {
1633
+ if (Node.isPropertyAssignment(nested)) {
1634
+ const name = propertyName(nested.getNameNode())
1635
+ if (name !== null) {
1636
+ if (
1637
+ name === 'group' ||
1638
+ styleProps.has(name) ||
1639
+ isLegacyConditionName(name)
1640
+ ) {
1641
+ pushStyledProperty(site, name, nested)
1642
+ } else {
1643
+ site.members.push({
1644
+ type: 'passthrough',
1645
+ index: site.index++,
1646
+ text: compact(nested.getText()),
1647
+ })
1648
+ }
1649
+ continue
1650
+ }
1651
+ }
1652
+ // a nested spread or a member whose key is not statically known can set
1653
+ // anything, so it stays where it was authored and orders the merge
1654
+ site.members.push({
1655
+ type: 'spread',
1656
+ index: site.index++,
1657
+ text: compact(nested.getText()),
1658
+ })
1659
+ }
1660
+ continue
1661
+ }
1662
+ site.members.push({
1663
+ type: 'spread',
1664
+ index: site.index++,
1665
+ text: compact(property.getText()),
1666
+ })
1667
+ continue
1668
+ }
1669
+ if (!Node.isPropertyAssignment(property)) continue
1670
+
1671
+ const nameNode = property.getNameNode()
1672
+ if (Node.isComputedPropertyName(nameNode)) {
1673
+ addFlag(
1674
+ site.flags,
1675
+ 'computed-property',
1676
+ `"${compact(nameNode.getText())}" hides the affected style property`
1677
+ )
1678
+ continue
1679
+ }
1680
+ const name = propertyName(nameNode)
1681
+ if (name === null) continue
1682
+ if (!styleProps.has(name) && !isLegacyConditionName(name) && name !== 'group')
1683
+ continue
1684
+ before.push(compact(property.getText()))
1685
+ pushStyledProperty(site, name, property)
1686
+ }
1687
+
1688
+ if (!site.legacy) return null
1689
+
1690
+ const { entries, programs } = assemble(site)
1691
+ const sourceFile = object.getSourceFile()
1692
+ const report: SiteReport = {
1693
+ kind,
1694
+ label,
1695
+ line: sourceFile.getLineAndColumnAtPos(object.getStart()).line,
1696
+ before: before.join(', '),
1697
+ after: entries.map((entry) => entry.text).join(', ') || '(no style props left)',
1698
+ programs,
1699
+ assessments: site.assessments,
1700
+ assessmentVerdict: assessmentVerdict(site.assessments),
1701
+ warnings: site.warnings,
1702
+ flags: site.flags,
1703
+ inventory: site.inventory,
1704
+ pending: site.pending,
1705
+ notes: site.notes,
1706
+ legacyLeft: site.members.filter((member) => member.type === 'legacy' && member.failed)
1707
+ .length,
1708
+ }
1709
+ if (
1710
+ write &&
1711
+ !site.flags.some(
1712
+ (flag) =>
1713
+ flag.code === 'emitted-program-mismatch' || flag.code === 'emitted-value-invalid'
1714
+ )
1715
+ ) {
1716
+ rewriteStyleObject(object, entries)
1717
+ }
1718
+ return report
1719
+ }
1720
+
1721
+ function isConvertedStyledProperty(property: Node): boolean {
1722
+ if (Node.isSpreadAssignment(property)) return true
1723
+ if (!Node.isPropertyAssignment(property)) return false
1724
+ const nameNode = property.getNameNode()
1725
+ if (Node.isComputedPropertyName(nameNode)) return false
1726
+ const name = propertyName(nameNode)
1727
+ return (
1728
+ !!name && (name === 'group' || styleProps.has(name) || isLegacyConditionName(name))
1729
+ )
1730
+ }
1731
+
1732
+ function allCommentTexts(node: Node): string[] {
1733
+ const comments = new Map<number, string>()
1734
+ for (const current of [node, ...node.getDescendants()]) {
1735
+ for (const range of [
1736
+ ...current.getLeadingCommentRanges(),
1737
+ ...current.getTrailingCommentRanges(),
1738
+ ]) {
1739
+ comments.set(range.getPos(), range.getText())
1740
+ }
1741
+ }
1742
+ return [...comments.entries()]
1743
+ .sort((left, right) => left[0] - right[0])
1744
+ .map((entry) => entry[1])
1745
+ }
1746
+
1747
+ function textWithOuterComments(node: Node): string {
1748
+ const comments = new Map<number, string>()
1749
+ for (const range of [
1750
+ ...node.getLeadingCommentRanges(),
1751
+ ...node.getTrailingCommentRanges(),
1752
+ ]) {
1753
+ comments.set(range.getPos(), range.getText())
1754
+ }
1755
+ const prefix = [...comments.entries()]
1756
+ .sort((left, right) => left[0] - right[0])
1757
+ .map((entry) => entry[1])
1758
+ return [...prefix, node.getText()].join('\n')
1759
+ }
1760
+
1761
+ function rewriteStyleObject(
1762
+ object: ObjectLiteralExpression,
1763
+ entries: Array<{ index: number; text: string }>
1764
+ ): void {
1765
+ const rendered: string[] = []
1766
+ let inserted = false
1767
+ for (const property of object.getProperties()) {
1768
+ if (isConvertedStyledProperty(property)) {
1769
+ if (!inserted) {
1770
+ rendered.push(...entries.map((entry) => entry.text))
1771
+ inserted = true
1772
+ }
1773
+ } else {
1774
+ rendered.push(textWithOuterComments(property))
1775
+ }
1776
+ }
1777
+ object.replaceWithText(
1778
+ rendered.length
1779
+ ? `{
1780
+ ${rendered.join(',\n')}
1781
+ }`
1782
+ : '{}'
1783
+ )
1784
+ }