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