@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.
- package/README.md +234 -1
- package/dist/builtInNames.mjs +14 -0
- package/dist/builtInNames.mjs.map +1 -0
- package/dist/containers.mjs +141 -0
- package/dist/containers.mjs.map +1 -0
- package/dist/convert.mjs +1233 -0
- package/dist/convert.mjs.map +1 -0
- package/dist/expressions.mjs +188 -0
- package/dist/expressions.mjs.map +1 -0
- package/dist/functionalVariants.mjs +469 -0
- package/dist/functionalVariants.mjs.map +1 -0
- package/dist/grammar.mjs +73 -0
- package/dist/grammar.mjs.map +1 -0
- package/dist/index.mjs +374 -0
- package/dist/index.mjs.map +1 -0
- package/dist/legacyConditions.mjs +293 -0
- package/dist/legacyConditions.mjs.map +1 -0
- package/dist/legacyNames.mjs +130 -0
- package/dist/legacyNames.mjs.map +1 -0
- package/dist/provenance.mjs +137 -0
- package/dist/provenance.mjs.map +1 -0
- package/dist/report.mjs +188 -0
- package/dist/report.mjs.map +1 -0
- package/dist/sheetAnatomy.mjs +200 -0
- package/dist/sheetAnatomy.mjs.map +1 -0
- package/dist/structuredNative.mjs +275 -0
- package/dist/structuredNative.mjs.map +1 -0
- package/dist/transition.mjs +257 -0
- package/dist/transition.mjs.map +1 -0
- package/package.json +35 -7
- package/src/builtInNames.ts +23 -0
- package/src/containers.ts +227 -0
- package/src/convert.ts +1977 -0
- package/src/expressions.ts +220 -0
- package/src/functionalVariants.ts +642 -0
- package/src/grammar.ts +190 -0
- package/src/index.ts +589 -0
- package/src/legacyConditions.ts +357 -0
- package/src/legacyNames.ts +160 -0
- package/src/provenance.ts +210 -0
- package/src/report.ts +362 -0
- package/src/sheetAnatomy.ts +277 -0
- package/src/structuredNative.ts +459 -0
- 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
|
+
}
|