@tamagui/codemod-flat-values 3.0.0-beta.1313.1 → 3.0.0-beta.1341.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -11,13 +11,17 @@ import {
11
11
  ScriptTarget,
12
12
  SyntaxKind,
13
13
  ts,
14
+ type CallExpression,
14
15
  type Expression,
16
+ type JsxOpeningElement,
17
+ type JsxSelfClosingElement,
15
18
  type ObjectLiteralExpression,
19
+ type PropertyAssignment,
16
20
  type SourceFile,
17
21
  } from 'ts-morph'
18
22
  import { planContainers, type ContainerPlan } from './containers'
19
23
  import { convertJsxSite, convertStyleObject, type SiteReport } from './convert'
20
- import { compact, unwrapExpression } from './expressions'
24
+ import { compact, numericValue, runtimeType, unwrapExpression } from './expressions'
21
25
  import {
22
26
  addFunctionalVariantTypeImports,
23
27
  convertFunctionalVariants,
@@ -37,8 +41,408 @@ import { createProvenance } from './provenance'
37
41
  import { renderReport, type FileReport } from './report'
38
42
  import { convertSheetFrames } from './sheetAnatomy'
39
43
  import { convertTransitions } from './transition'
44
+ import { isLegacyConditionName } from './legacyNames'
40
45
 
41
46
  type Provenance = ReturnType<typeof createProvenance>
47
+ type SourceSemantics = 'v2-pixels' | 'v3-ratios'
48
+
49
+ interface SourceEdit {
50
+ start: number
51
+ end: number
52
+ text: string
53
+ dynamic: boolean
54
+ }
55
+
56
+ function propertyName(node: Node): string | null {
57
+ if (
58
+ Node.isIdentifier(node) ||
59
+ Node.isStringLiteral(node) ||
60
+ Node.isNumericLiteral(node)
61
+ ) {
62
+ return node.getText().replace(/^['"]|['"]$/g, '')
63
+ }
64
+ return null
65
+ }
66
+
67
+ function isLineHeightName(name: string | null): boolean {
68
+ return name === 'lineHeight' || name === 'lh'
69
+ }
70
+
71
+ function extractedTokenValue(expression: Expression): boolean {
72
+ const nodes = [expression, ...expression.getDescendants()]
73
+ return nodes.some(
74
+ (node) =>
75
+ (Node.isPropertyAccessExpression(node) && node.getName() === 'val') ||
76
+ (Node.isCallExpression(node) &&
77
+ Node.isIdentifier(node.getExpression()) &&
78
+ node.getExpression().getText() === 'getVariableValue')
79
+ )
80
+ }
81
+
82
+ function pixelReplacement(
83
+ expression: Expression
84
+ ): { text: string; dynamic: boolean } | { flag: SiteReport['flags'][number] } | null {
85
+ const current = unwrapExpression(expression)
86
+ const number = numericValue(current)
87
+ if (number !== null) return { text: JSON.stringify(`${number}px`), dynamic: false }
88
+ const type = runtimeType(current)
89
+ if (type.kind === 'string') return null
90
+
91
+ if (extractedTokenValue(current)) {
92
+ return {
93
+ flag: {
94
+ code: 'ambiguous-line-height-token-value',
95
+ detail: `lineHeight value "${compact(current.getText())}" extracts a token value whose units are no longer visible; replace it with an explicit ratio or px length by hand`,
96
+ },
97
+ }
98
+ }
99
+
100
+ if (type.kind === 'number') {
101
+ return { text: `\`\${${current.getText()}}px\``, dynamic: true }
102
+ }
103
+ return {
104
+ flag: {
105
+ code: 'ambiguous-line-height-expression',
106
+ detail: `lineHeight value "${compact(current.getText())}" has no provable numeric type; replace old pixel semantics with an explicit px length by hand`,
107
+ },
108
+ }
109
+ }
110
+
111
+ function declarationContainsNumericLineHeight(expression: Expression): boolean {
112
+ const symbol = expression.getSymbol()
113
+ if (!symbol) return false
114
+ for (const declaration of symbol.getDeclarations()) {
115
+ for (const property of [
116
+ ...(Node.isPropertyAssignment(declaration) ? [declaration] : []),
117
+ ...declaration.getDescendantsOfKind(SyntaxKind.PropertyAssignment),
118
+ ]) {
119
+ if (!isLineHeightName(propertyName(property.getNameNode()))) continue
120
+ const initializer = property.getInitializer()
121
+ if (!initializer) continue
122
+ const value = unwrapExpression(initializer)
123
+ if (numericValue(value) !== null || runtimeType(value).kind === 'number')
124
+ return true
125
+ }
126
+ }
127
+ return false
128
+ }
129
+
130
+ function lineHeightValueIsUnmigrated(expression: Expression): boolean {
131
+ const current = unwrapExpression(expression)
132
+ if (Node.isObjectLiteralExpression(current)) {
133
+ return current.getProperties().some((property) => {
134
+ if (!Node.isPropertyAssignment(property)) return false
135
+ const initializer = property.getInitializer()
136
+ return initializer !== undefined && lineHeightValueIsUnmigrated(initializer)
137
+ })
138
+ }
139
+ if (Node.isConditionalExpression(current)) {
140
+ return (
141
+ lineHeightValueIsUnmigrated(current.getWhenTrue()) ||
142
+ lineHeightValueIsUnmigrated(current.getWhenFalse())
143
+ )
144
+ }
145
+ return pixelReplacement(current) !== null
146
+ }
147
+
148
+ function replaceWithin(
149
+ source: string,
150
+ sourceStart: number,
151
+ edits: readonly SourceEdit[]
152
+ ): string {
153
+ let output = source
154
+ for (const edit of [...edits].sort((left, right) => right.start - left.start)) {
155
+ output =
156
+ output.slice(0, edit.start - sourceStart) +
157
+ edit.text +
158
+ output.slice(edit.end - sourceStart)
159
+ }
160
+ return output
161
+ }
162
+
163
+ function migrateLegacyLineHeights(
164
+ sourceFile: SourceFile,
165
+ provenance: Provenance
166
+ ): SiteReport[] {
167
+ const edits: SourceEdit[] = []
168
+ const claimed = new Set<number>()
169
+ const reports: SiteReport[] = []
170
+
171
+ const scanLineHeightExpression = (
172
+ expression: Expression,
173
+ contextEdits: SourceEdit[],
174
+ flags: SiteReport['flags']
175
+ ) => {
176
+ const current = unwrapExpression(expression)
177
+ if (Node.isObjectLiteralExpression(current)) {
178
+ for (const property of current.getProperties()) {
179
+ if (!Node.isPropertyAssignment(property)) continue
180
+ const initializer = property.getInitializer()
181
+ if (initializer) scanLineHeightExpression(initializer, contextEdits, flags)
182
+ }
183
+ return
184
+ }
185
+ if (Node.isConditionalExpression(current)) {
186
+ scanLineHeightExpression(current.getWhenTrue(), contextEdits, flags)
187
+ scanLineHeightExpression(current.getWhenFalse(), contextEdits, flags)
188
+ return
189
+ }
190
+ if (claimed.has(current.getStart())) return
191
+ const replacement = pixelReplacement(current)
192
+ if (!replacement) return
193
+ claimed.add(current.getStart())
194
+ if ('flag' in replacement) {
195
+ flags.push(replacement.flag)
196
+ return
197
+ }
198
+ const edit = {
199
+ start: current.getStart(),
200
+ end: current.getEnd(),
201
+ text: replacement.text,
202
+ dynamic: replacement.dynamic,
203
+ }
204
+ edits.push(edit)
205
+ contextEdits.push(edit)
206
+ }
207
+
208
+ const scanProperty = (
209
+ property: PropertyAssignment,
210
+ contextEdits: SourceEdit[],
211
+ flags: SiteReport['flags']
212
+ ) => {
213
+ if (!isLineHeightName(propertyName(property.getNameNode()))) return
214
+ const initializer = property.getInitializer()
215
+ if (initializer) scanLineHeightExpression(initializer, contextEdits, flags)
216
+ }
217
+
218
+ const scanObject = (
219
+ object: ObjectLiteralExpression,
220
+ contextEdits: SourceEdit[],
221
+ flags: SiteReport['flags']
222
+ ) => {
223
+ for (const property of object.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
224
+ scanProperty(property, contextEdits, flags)
225
+ }
226
+ for (const property of object.getProperties()) {
227
+ if (Node.isPropertyAssignment(property)) scanProperty(property, contextEdits, flags)
228
+ }
229
+ }
230
+
231
+ const addReport = (
232
+ node: Node,
233
+ kind: SiteReport['kind'],
234
+ label: string,
235
+ contextEdits: SourceEdit[],
236
+ flags: SiteReport['flags']
237
+ ) => {
238
+ if (!contextEdits.length && !flags.length) return
239
+ const before = compact(node.getText())
240
+ reports.push({
241
+ kind,
242
+ label,
243
+ line: sourceFile.getLineAndColumnAtPos(node.getStart()).line,
244
+ before,
245
+ after: compact(replaceWithin(node.getText(), node.getStart(), contextEdits)),
246
+ programs: contextEdits.map((edit) => ({
247
+ name: 'lineHeight',
248
+ value: edit.text,
249
+ dynamic: edit.dynamic,
250
+ })),
251
+ assessments: [],
252
+ assessmentVerdict: 'clean',
253
+ warnings: [],
254
+ flags,
255
+ inventory: [],
256
+ pending: [],
257
+ notes: [
258
+ contextEdits.length
259
+ ? 'numeric lineHeight used V2 pixel semantics and now carries an explicit px unit'
260
+ : 'lineHeight was left authored for a manual unit decision',
261
+ ],
262
+ legacyLeft: flags.length,
263
+ })
264
+ }
265
+
266
+ const scanJsx = (opening: JsxOpeningElement | JsxSelfClosingElement) => {
267
+ const contextEdits: SourceEdit[] = []
268
+ const flags: SiteReport['flags'] = []
269
+ for (const attribute of opening.getAttributes()) {
270
+ if (Node.isJsxSpreadAttribute(attribute)) {
271
+ const expression = unwrapExpression(attribute.getExpression())
272
+ if (Node.isObjectLiteralExpression(expression)) {
273
+ scanObject(expression, contextEdits, flags)
274
+ } else if (declarationContainsNumericLineHeight(expression)) {
275
+ flags.push({
276
+ code: 'ambiguous-shared-line-height-style',
277
+ detail: `spread "${compact(expression.getText())}" contains numeric lineHeight and may also be consumed outside Tamagui; migrate it at this Tamagui boundary by hand`,
278
+ })
279
+ }
280
+ continue
281
+ }
282
+ if (!Node.isJsxAttribute(attribute)) continue
283
+ const nameNode = attribute.getNameNode()
284
+ const name = Node.isIdentifier(nameNode) ? nameNode.getText() : null
285
+ const initializer = attribute.getInitializer()
286
+ if (isLineHeightName(name)) {
287
+ if (!Node.isJsxExpression(initializer)) continue
288
+ const expression = initializer.getExpression()
289
+ if (!expression) continue
290
+ const current = unwrapExpression(expression)
291
+ const number = numericValue(current)
292
+ if (number !== null && !claimed.has(current.getStart())) {
293
+ claimed.add(current.getStart())
294
+ const edit = {
295
+ start: initializer.getStart(),
296
+ end: initializer.getEnd(),
297
+ text: JSON.stringify(`${number}px`),
298
+ dynamic: false,
299
+ }
300
+ edits.push(edit)
301
+ contextEdits.push(edit)
302
+ } else {
303
+ scanLineHeightExpression(current, contextEdits, flags)
304
+ }
305
+ continue
306
+ }
307
+ if (isLegacyConditionName(name || '') && Node.isJsxExpression(initializer)) {
308
+ const expression = initializer.getExpression()
309
+ if (expression) {
310
+ const current = unwrapExpression(expression)
311
+ for (const object of [
312
+ ...(Node.isObjectLiteralExpression(current) ? [current] : []),
313
+ ...current.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression),
314
+ ]) {
315
+ scanObject(object, contextEdits, flags)
316
+ }
317
+ }
318
+ continue
319
+ }
320
+ if (name !== 'style' || !Node.isJsxExpression(initializer)) continue
321
+ const expression = initializer.getExpression()
322
+ if (!expression) continue
323
+ const current = unwrapExpression(expression)
324
+ const objects = [
325
+ ...(Node.isObjectLiteralExpression(current) ? [current] : []),
326
+ ...current.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression),
327
+ ]
328
+ if (objects.length) {
329
+ for (const object of objects) scanObject(object, contextEdits, flags)
330
+ } else if (declarationContainsNumericLineHeight(current)) {
331
+ flags.push({
332
+ code: 'ambiguous-shared-line-height-style',
333
+ detail: `style "${compact(current.getText())}" contains numeric lineHeight and may also be consumed by React Native or Restyle; migrate it at this Tamagui boundary by hand`,
334
+ })
335
+ }
336
+ }
337
+ addReport(
338
+ opening,
339
+ 'jsx',
340
+ `<${opening.getTagNameNode().getText()}> lineHeight`,
341
+ contextEdits,
342
+ flags
343
+ )
344
+ }
345
+
346
+ const scanCall = (call: CallExpression, label: string) => {
347
+ const contextEdits: SourceEdit[] = []
348
+ const flags: SiteReport['flags'] = []
349
+ for (const argument of call.getArguments()) {
350
+ for (const object of [
351
+ ...(Node.isObjectLiteralExpression(argument) ? [argument] : []),
352
+ ...argument.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression),
353
+ ]) {
354
+ scanObject(object, contextEdits, flags)
355
+ }
356
+ }
357
+ addReport(call, 'styled', label, contextEdits, flags)
358
+ }
359
+
360
+ const wrapperCache = new Map<Node, boolean>()
361
+ for (const opening of [
362
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
363
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
364
+ ]) {
365
+ if (provenance.isTamaguiElement(opening)) {
366
+ scanJsx(opening)
367
+ continue
368
+ }
369
+ const hasLegacyValue = opening.getAttributes().some((attribute) => {
370
+ if (!Node.isJsxAttribute(attribute)) return false
371
+ const name = propertyName(attribute.getNameNode())
372
+ const initializer = attribute.getInitializer()
373
+ if (!initializer || !Node.isJsxExpression(initializer)) return false
374
+ const expression = initializer.getExpression()
375
+ if (!expression) return false
376
+ if (isLineHeightName(name)) return lineHeightValueIsUnmigrated(expression)
377
+ if (name !== 'style') return false
378
+ const current = unwrapExpression(expression)
379
+ const properties = current.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
380
+ return properties.length
381
+ ? properties.some((property) => {
382
+ const value = property.getInitializer()
383
+ return (
384
+ isLineHeightName(propertyName(property.getNameNode())) &&
385
+ value !== undefined &&
386
+ lineHeightValueIsUnmigrated(value)
387
+ )
388
+ })
389
+ : declarationContainsNumericLineHeight(current)
390
+ })
391
+ if (!hasLegacyValue) continue
392
+
393
+ const tag = opening.getTagNameNode()
394
+ const symbol = tag.getSymbol()
395
+ if (!symbol) continue
396
+ const declarations = (symbol.getAliasedSymbol() ?? symbol).getDeclarations()
397
+ const isLocalWrapper = declarations.some((declaration) => {
398
+ const cached = wrapperCache.get(declaration)
399
+ if (cached !== undefined) return cached
400
+ const file = declaration.getSourceFile()
401
+ const filePath = file.getFilePath()
402
+ const local =
403
+ !file.isDeclarationFile() &&
404
+ !filePath.includes('/node_modules/') &&
405
+ !relative(projectRoot, filePath).startsWith('..')
406
+ const wrapsTamagui =
407
+ local &&
408
+ [
409
+ ...declaration.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
410
+ ...declaration.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
411
+ ].some((child) => provenance.isTamaguiElement(child))
412
+ wrapperCache.set(declaration, wrapsTamagui)
413
+ return wrapsTamagui
414
+ })
415
+ if (!isLocalWrapper) continue
416
+ addReport(
417
+ opening,
418
+ 'jsx',
419
+ `<${tag.getText()}> lineHeight`,
420
+ [],
421
+ [
422
+ {
423
+ code: 'ambiguous-wrapper-line-height',
424
+ detail: `local wrapper <${tag.getText()}> renders Tamagui JSX, so numeric lineHeight may still be V2 pixels, but the wrapper may transform units; migrate this call site by hand`,
425
+ },
426
+ ]
427
+ )
428
+ }
429
+
430
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
431
+ if (provenance.isTamaguiStyledCall(call)) {
432
+ scanCall(
433
+ call,
434
+ `styled(${compact(call.getArguments()[0]?.getText() ?? 'unknown')}, …) lineHeight`
435
+ )
436
+ } else if (provenance.isTamaguiStyleCall(call)) {
437
+ scanCall(call, `${compact(call.getExpression().getText())}(…) lineHeight`)
438
+ }
439
+ }
440
+
441
+ if (edits.length) {
442
+ sourceFile.replaceWithText(replaceWithin(sourceFile.getFullText(), 0, edits))
443
+ }
444
+ return reports
445
+ }
42
446
 
43
447
  // every path is resolved against the directory the codemod is invoked from, so it
44
448
  // migrates the project you are standing in whether that is an app or this repo
@@ -368,8 +772,23 @@ function inspectFile(
368
772
  sourceFile: SourceFile,
369
773
  registry: ModifierRegistryView,
370
774
  provenance: Provenance,
775
+ sourceSemantics: SourceSemantics,
776
+ lineHeightOnly: boolean,
371
777
  write: boolean
372
778
  ): FileReport {
779
+ const lineHeightSites =
780
+ sourceSemantics === 'v2-pixels'
781
+ ? migrateLegacyLineHeights(sourceFile, provenance)
782
+ : []
783
+ if (lineHeightOnly) {
784
+ return {
785
+ file: relative(projectRoot, sourceFile.getFilePath()),
786
+ sites: lineHeightSites,
787
+ functionalVariants: [],
788
+ sheetFrames: [],
789
+ transitions: [],
790
+ }
791
+ }
373
792
  // the anatomy rewrite runs first so the Background it adds, and the surface
374
793
  // props it moves there, go through the flat-value conversion below
375
794
  const sheetFrames = convertSheetFrames(sourceFile, provenance, write)
@@ -378,7 +797,7 @@ function inspectFile(
378
797
  const transitions = convertTransitions(sourceFile, provenance, write)
379
798
  const containers = planContainers(sourceFile, registry)
380
799
  const targets = conversionTargets(sourceFile.getFilePath())
381
- const sites: SiteReport[] = []
800
+ const sites: SiteReport[] = [...lineHeightSites]
382
801
  const functionalVariants: FunctionalVariantReport[] = []
383
802
  const requiredTypeImports: RequiredTypeImport[] = []
384
803
  const styledCalls = sourceFile
@@ -455,6 +874,10 @@ const usage = `Converts Tamagui style syntax to V3 flat property values and repo
455
874
  )})
456
875
  --json <path> also write the machine-readable report
457
876
  --write rewrite every statically safe conversion in place
877
+ --source-semantics <v2-pixels|v3-ratios>
878
+ interpret numeric Tamagui lineHeight from that source contract
879
+ (default: v3-ratios)
880
+ --line-height-only apply and report only the selected lineHeight source migration
458
881
  --help print this
459
882
 
460
883
  Run it from your project root, which is where paths and the tsconfig resolve from.
@@ -465,11 +888,15 @@ function parseArguments(argv: readonly string[]): {
465
888
  jsonPath: string | null
466
889
  inputs: string[]
467
890
  write: boolean
891
+ sourceSemantics: SourceSemantics
892
+ lineHeightOnly: boolean
468
893
  } {
469
894
  const inputs: string[] = []
470
895
  let reportPath = defaultReportPath
471
896
  let jsonPath: string | null = null
472
897
  let write = false
898
+ let sourceSemantics: SourceSemantics = 'v3-ratios'
899
+ let lineHeightOnly = false
473
900
 
474
901
  for (let index = 0; index < argv.length; index++) {
475
902
  const argument = argv[index]
@@ -481,6 +908,20 @@ function parseArguments(argv: readonly string[]): {
481
908
  write = true
482
909
  continue
483
910
  }
911
+ if (argument === '--line-height-only') {
912
+ lineHeightOnly = true
913
+ continue
914
+ }
915
+ if (argument === '--source-semantics') {
916
+ const next = argv[index + 1]
917
+ if (next !== 'v2-pixels' && next !== 'v3-ratios') {
918
+ console.error(`${argument} requires v2-pixels or v3-ratios\n\n${usage}`)
919
+ process.exit(2)
920
+ }
921
+ sourceSemantics = next
922
+ index++
923
+ continue
924
+ }
484
925
  if (argument === '--report' || argument === '--json') {
485
926
  const next = argv[index + 1]
486
927
  if (!next) {
@@ -508,10 +949,11 @@ function parseArguments(argv: readonly string[]): {
508
949
  process.exit(2)
509
950
  }
510
951
 
511
- return { reportPath, jsonPath, inputs, write }
952
+ return { reportPath, jsonPath, inputs, write, sourceSemantics, lineHeightOnly }
512
953
  }
513
954
 
514
- const { reportPath, jsonPath, inputs, write } = parseArguments(process.argv.slice(2))
955
+ const { reportPath, jsonPath, inputs, write, sourceSemantics, lineHeightOnly } =
956
+ parseArguments(process.argv.slice(2))
515
957
  const { sourceFiles, ignoredFiles } = collectFiles(inputs)
516
958
  for (const sourceFile of sourceFiles) {
517
959
  const diagnostics = (
@@ -535,7 +977,14 @@ const modifierRegistry = createModifierRegistry({
535
977
  })
536
978
  const provenance = createProvenance()
537
979
  const files = sourceFiles.map((sourceFile) =>
538
- inspectFile(sourceFile, modifierRegistry.registry, provenance, write)
980
+ inspectFile(
981
+ sourceFile,
982
+ modifierRegistry.registry,
983
+ provenance,
984
+ sourceSemantics,
985
+ lineHeightOnly,
986
+ write
987
+ )
539
988
  )
540
989
  if (write) {
541
990
  for (const sourceFile of sourceFiles) {
package/src/provenance.ts CHANGED
@@ -28,6 +28,7 @@ function rootIdentifier(node: Node): Identifier | null {
28
28
  export function createProvenance(): {
29
29
  isTamaguiElement: (opening: JsxOpeningElement | JsxSelfClosingElement) => boolean
30
30
  isTamaguiStyledCall: (call: CallExpression) => boolean
31
+ isTamaguiStyleCall: (call: CallExpression) => boolean
31
32
  } {
32
33
  const resolved = new Map<Node, boolean>()
33
34
  const active = new Set<Node>()
@@ -177,6 +178,15 @@ export function createProvenance(): {
177
178
  return fromIdentifier(callee)
178
179
  }
179
180
 
181
+ const isTamaguiStyleCall = (call: CallExpression): boolean => {
182
+ const callee = call.getExpression()
183
+ if (!Node.isPropertyAccessExpression(callee)) return false
184
+ const method = callee.getName()
185
+ if (method !== 'style' && method !== 'resolve' && method !== 'dynamic') return false
186
+ const owner = rootIdentifier(callee.getExpression())
187
+ return owner !== null && fromIdentifier(owner)
188
+ }
189
+
180
190
  /**
181
191
  * A value re-bound from Tamagui: an alias (`const Sheet = SheetRaw as ...`), a
182
192
  * member of one, or the result of a Tamagui factory (`styled`,
@@ -206,5 +216,6 @@ export function createProvenance(): {
206
216
  return fromIdentifier(tag)
207
217
  },
208
218
  isTamaguiStyledCall,
219
+ isTamaguiStyleCall,
209
220
  }
210
221
  }