@instructure/ui-codemods 11.7.4-snapshot-135 → 11.7.4-snapshot-142

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.
@@ -0,0 +1,734 @@
1
+ /*
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2015 - present Instructure, Inc.
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+ import { JSCodeshift, Collection, Transform } from 'jscodeshift'
25
+ import instUICodemodExecutor from '../utils/instUICodemodExecutor'
26
+ import type { InstUICodemod } from '../utils/instUICodemodExecutor'
27
+ import { isJSXAttribute } from '../utils/codemodTypeCheckers'
28
+ import { findElement, findImport, printWarning } from '../utils/codemodHelpers'
29
+ import { namedTypes } from 'ast-types'
30
+ import { THEME_VARIABLE_MAPPINGS } from './themeVariableMappings'
31
+ import type { ComponentMapping } from './themeVariableMappings'
32
+
33
+ /**
34
+ * Renames/removes component theme variables to their new (multi-version) names.
35
+ */
36
+ const transformThemeVariables: Transform = (
37
+ file,
38
+ api,
39
+ options?: { fileName?: string; usePrettier?: boolean }
40
+ ) => {
41
+ return instUICodemodExecutor(
42
+ transformThemeVariablesCodemod,
43
+ file,
44
+ api,
45
+ options
46
+ )
47
+ }
48
+ /**
49
+ * Returns the name of an object property key whether it is written as an
50
+ * identifier (`Spinner: …`, `smallSize: …`) or a quoted string literal
51
+ * (`'Spinner': …`, `'List.Item': …`, `'smallSize': …`). Component and token
52
+ * keys can legally be either form, so the codemod has to handle both.
53
+ */
54
+ const getKeyName = (
55
+ key: namedTypes.ObjectProperty['key']
56
+ ): string | undefined => {
57
+ if (key.type === 'Identifier') return key.name
58
+ if (
59
+ (key.type === 'Literal' || (key.type as string) === 'StringLiteral') &&
60
+ 'value' in key &&
61
+ typeof key.value === 'string'
62
+ ) {
63
+ return key.value
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ /**
69
+ * Renames an object property key in place, preserving whether it is an
70
+ * identifier or a quoted string literal.
71
+ */
72
+ const setKeyName = (
73
+ key: namedTypes.ObjectProperty['key'],
74
+ name: string
75
+ ): void => {
76
+ if (key.type === 'Identifier') {
77
+ // eslint-disable-next-line no-param-reassign
78
+ key.name = name
79
+ } else if (
80
+ key.type === 'Literal' ||
81
+ (key.type as string) === 'StringLiteral'
82
+ ) {
83
+ // eslint-disable-next-line no-param-reassign
84
+ ;(key as namedTypes.Literal).value = name
85
+ }
86
+ }
87
+
88
+ type ResolvedVariant = 'simple' | 'toggle' | 'ambiguous'
89
+
90
+ /**
91
+ * Resolves a component's `variant` prop from its JSX element, for tokens whose
92
+ * migration depends on it (Checkbox). Returns 'simple' for the default checkbox
93
+ * (no `variant`, or `variant="simple"`), 'toggle' for `variant="toggle"`, and
94
+ * 'ambiguous' when `variant` is a dynamic expression that can't be read
95
+ * statically (a variable, conditional, spread, ...).
96
+ */
97
+ const getResolvedVariant = (
98
+ openingElement: namedTypes.JSXOpeningElement
99
+ ): ResolvedVariant => {
100
+ const attr = openingElement.attributes?.find(
101
+ (a): a is namedTypes.JSXAttribute =>
102
+ isJSXAttribute(a) && a.name.name === 'variant'
103
+ )
104
+ if (!attr) {
105
+ // No explicit `variant`. A spread (`{...props}`) could still set it, so we
106
+ // can't assume the default - treat it as ambiguous (variant-dependent tokens
107
+ // are then left unchanged + warned, rather than guessing the wrong slot).
108
+ const hasSpread = openingElement.attributes?.some(
109
+ (a) => a.type === 'JSXSpreadAttribute'
110
+ )
111
+ return hasSpread ? 'ambiguous' : 'simple'
112
+ }
113
+
114
+ const readString = (node: unknown): string | undefined => {
115
+ const n = node as { type?: string; value?: unknown } | null | undefined
116
+ if (
117
+ (n?.type === 'Literal' || (n?.type as string) === 'StringLiteral') &&
118
+ typeof n?.value === 'string'
119
+ ) {
120
+ return n.value
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ // `variant="toggle"` (string attribute) or `variant={'toggle'}` (literal expr)
126
+ let value = readString(attr.value)
127
+ if (value === undefined && attr.value?.type === 'JSXExpressionContainer') {
128
+ value = readString(attr.value.expression)
129
+ }
130
+ if (value === undefined) return 'ambiguous'
131
+ return value === 'toggle' ? 'toggle' : 'simple'
132
+ }
133
+
134
+ /**
135
+ * Updates theme tokens for a component based on the mappings
136
+ * Returns true if any modifications were made
137
+ */
138
+ const updateComponentThemeTokens = (
139
+ themeObject: namedTypes.ObjectExpression,
140
+ componentName: string,
141
+ filePath: string
142
+ ): boolean => {
143
+ const componentConfig = THEME_VARIABLE_MAPPINGS[componentName]
144
+ if (!componentConfig) return false
145
+
146
+ let wasModified = false
147
+ const originalProperties = [...themeObject.properties]
148
+
149
+ // Pre-scan to find rename targets that would collide - i.e. a new token name
150
+ // produced by more than one old token present in this override (e.g. both
151
+ // `primaryInverseColor` and `secondaryInverseColor` rename to `inverseColor`),
152
+ // or a new name that already exists here as a different key. Colliding renames
153
+ // are left unchanged and warned about, so no value is arbitrarily dropped.
154
+ const existingKeys = new Set<string>()
155
+ const renameSources: Record<string, string[]> = {}
156
+ originalProperties.forEach((property) => {
157
+ if (property.type === 'ObjectProperty') {
158
+ const name = getKeyName(property.key)
159
+ if (!name) return
160
+ existingKeys.add(name)
161
+ const r = componentConfig.renamed?.[name]
162
+ if (r) (renameSources[r.to] ||= []).push(name)
163
+ }
164
+ })
165
+ const isCollidingRename = (target: string, sourceName: string): boolean =>
166
+ // more than one present token renames to `target`...
167
+ (renameSources[target]?.length || 0) > 1 ||
168
+ // ...or `target` already exists as a different key that is NOT itself being
169
+ // renamed away (a swap like `primaryColor`->`baseColor` +
170
+ // `brandColor`->`primaryColor` vacates `primaryColor`, so it is safe).
171
+ (existingKeys.has(target) &&
172
+ target !== sourceName &&
173
+ !componentConfig.renamed?.[target])
174
+ // Track targets already warned about so a collision is reported once, not once
175
+ // per colliding token.
176
+ const warnedTargets = new Set<string>()
177
+
178
+ // Process each property in the theme object - filter out removed tokens, rename others
179
+ // eslint-disable-next-line no-param-reassign
180
+ themeObject.properties = originalProperties.filter((property) => {
181
+ // Only process simple key-value pairs (ignore spread operators, methods, etc.)
182
+ if (property.type === 'ObjectProperty') {
183
+ // token key may be an identifier or a quoted string literal
184
+ const tokenName = getKeyName(property.key)
185
+ if (!tokenName) return true
186
+
187
+ const renamed = componentConfig.renamed?.[tokenName]
188
+ const removed = componentConfig.removed?.[tokenName]
189
+ const warned = componentConfig.warned?.[tokenName]
190
+
191
+ // Renamed - update the key name, surfacing any note.
192
+ if (renamed) {
193
+ // Multiple old tokens in this override map to the same new token (or the
194
+ // new name is already set here). Renaming would clobber a value, so leave
195
+ // it untouched and warn the user (once per target) to consolidate by hand.
196
+ if (isCollidingRename(renamed.to, tokenName)) {
197
+ if (!warnedTargets.has(renamed.to)) {
198
+ warnedTargets.add(renamed.to)
199
+ const sources = renameSources[renamed.to] || [tokenName]
200
+ const sourceList = sources.map((s) => `\`${s}\``).join(', ')
201
+ printWarning(
202
+ filePath,
203
+ property.loc?.start.line,
204
+ sources.length > 1
205
+ ? `Multiple tokens (${sourceList}) rename to \`${renamed.to}\`; left unchanged - consolidate them into a single \`${renamed.to}\` manually.`
206
+ : `\`${tokenName}\` renames to \`${renamed.to}\`, which is already set in this override; left unchanged - consolidate them into a single \`${renamed.to}\` manually.`
207
+ )
208
+ }
209
+ return true
210
+ }
211
+ wasModified = true
212
+ if (renamed.to !== tokenName) {
213
+ setKeyName(property.key, renamed.to)
214
+ }
215
+ if (renamed.warning) {
216
+ printWarning(filePath, property.loc?.start.line, renamed.warning)
217
+ }
218
+ return true
219
+ }
220
+
221
+ // Removed - drop it, surfacing its replacement note if any.
222
+ if (removed) {
223
+ wasModified = true
224
+ if (removed.warning) {
225
+ printWarning(filePath, property.loc?.start.line, removed.warning)
226
+ }
227
+ return false
228
+ }
229
+
230
+ // Warned - kept under the same name, but warn the user to review it. This
231
+ // does not change the AST, so it does not set `wasModified`.
232
+ if (warned) {
233
+ printWarning(filePath, property.loc?.start.line, warned.warning)
234
+ return true
235
+ }
236
+
237
+ // Not in our migration mapping - keep it unchanged.
238
+ return true
239
+ }
240
+ return true
241
+ })
242
+
243
+ return wasModified
244
+ }
245
+
246
+ /**
247
+ * Applies variant-dependent token migrations (currently Checkbox `variant`) to a
248
+ * per-instance `themeOverride` object. The same v1 token renames to a different
249
+ * v2 token per variant, so the outcome is chosen from the resolved variant.
250
+ * An ambiguous (dynamic) variant leaves the token unchanged and warns.
251
+ * Returns true if the object was modified.
252
+ */
253
+ const applyVariantDependentTokens = (
254
+ themeObject: namedTypes.ObjectExpression,
255
+ componentName: string,
256
+ filePath: string,
257
+ variant: ResolvedVariant
258
+ ): boolean => {
259
+ const variantDependent =
260
+ THEME_VARIABLE_MAPPINGS[componentName]?.variantDependent
261
+ if (!variantDependent) return false
262
+
263
+ let wasModified = false
264
+ // Ambiguous-variant tokens are collected and warned about once (listing them
265
+ // all), rather than one warning per token.
266
+ const ambiguousTokens: string[] = []
267
+ let ambiguousLine: number | undefined
268
+ // eslint-disable-next-line no-param-reassign
269
+ themeObject.properties = themeObject.properties.filter((property) => {
270
+ if (property.type !== 'ObjectProperty') return true
271
+ const tokenName = getKeyName(property.key)
272
+ if (!tokenName) return true
273
+ const entry = variantDependent[tokenName]
274
+ if (!entry) return true
275
+
276
+ // Variant couldn't be read statically - we can't tell which v2 token this
277
+ // maps to, so leave it untouched and flag it (consolidated after the loop).
278
+ if (variant === 'ambiguous') {
279
+ ambiguousTokens.push(tokenName)
280
+ if (ambiguousLine === undefined) ambiguousLine = property.loc?.start.line
281
+ return true
282
+ }
283
+
284
+ const outcome = entry[variant]
285
+ // A rename carries a `to`; `keep` leaves the token as-is; otherwise removal.
286
+ if ('to' in outcome && outcome.to) {
287
+ setKeyName(property.key, outcome.to)
288
+ if (outcome.warning) {
289
+ printWarning(filePath, property.loc?.start.line, outcome.warning)
290
+ }
291
+ wasModified = true
292
+ return true
293
+ }
294
+ if ('keep' in outcome) {
295
+ // Unchanged for this variant - leave it exactly as-is.
296
+ return true
297
+ }
298
+ if (outcome.warning) {
299
+ printWarning(filePath, property.loc?.start.line, outcome.warning)
300
+ }
301
+ wasModified = true
302
+ return false
303
+ })
304
+
305
+ if (ambiguousTokens.length > 0) {
306
+ const tokenList = ambiguousTokens.map((t) => `\`${t}\``).join(', ')
307
+ printWarning(
308
+ filePath,
309
+ ambiguousLine,
310
+ `Variant-dependent theme variables for ${componentName} could not be migrated because the \`variant\` prop could not be read statically (they map to different tokens for the \`toggle\` vs \`simple\` variant); left unchanged - update these manually: ${tokenList}.`
311
+ )
312
+ }
313
+
314
+ return wasModified
315
+ }
316
+
317
+ /**
318
+ * Finds which mapped tokens are present in a theme object
319
+ */
320
+ const findMappedTokensInObject = (
321
+ themeObject: namedTypes.ObjectExpression,
322
+ componentMapping: ComponentMapping
323
+ ): string[] => {
324
+ const tokenNames: string[] = []
325
+ themeObject.properties.forEach((prop) => {
326
+ if (prop.type === 'ObjectProperty') {
327
+ const name = getKeyName(prop.key)
328
+ if (
329
+ name &&
330
+ (componentMapping.renamed?.[name] ||
331
+ componentMapping.removed?.[name] ||
332
+ componentMapping.warned?.[name])
333
+ ) {
334
+ tokenNames.push(name)
335
+ }
336
+ }
337
+ })
338
+ return tokenNames
339
+ }
340
+
341
+ /**
342
+ * Handles complex theme overrides that can't be automatically processed
343
+ */
344
+ const handleComplexThemeOverride = (
345
+ filePath: string,
346
+ line: number | undefined,
347
+ componentName: string,
348
+ context: 'themeOverride prop' | 'InstUISettingsProvider',
349
+ reason: string
350
+ ) => {
351
+ const componentConfig = THEME_VARIABLE_MAPPINGS[componentName]
352
+ const allAffectedTokens = [
353
+ ...new Set([
354
+ ...Object.keys(componentConfig?.renamed || {}),
355
+ ...Object.keys(componentConfig?.removed || {}),
356
+ ...Object.keys(componentConfig?.warned || {}),
357
+ ...Object.keys(componentConfig?.variantDependent || {})
358
+ ])
359
+ ]
360
+ if (allAffectedTokens.length === 0) return
361
+
362
+ // One warning per element, listing every token that changed for this
363
+ // component (we can't see which are actually used inside the dynamic/spread
364
+ // value), rather than one warning per token.
365
+ const tokenList = allAffectedTokens.map((t) => `\`${t}\``).join(', ')
366
+ printWarning(
367
+ filePath,
368
+ line,
369
+ `Theme variables for ${componentName} could not be migrated automatically because ${reason} is used in ${context}. Manually check these affected tokens in the expression: ${tokenList}.`
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Processes a themeOverride JSX attribute for a component
375
+ */
376
+ const processThemeOverrideAttribute = (
377
+ filePath: string,
378
+ attribute: namedTypes.JSXAttribute,
379
+ componentName: string,
380
+ variant?: ResolvedVariant
381
+ ): { hasModifications: boolean; shouldKeepAttribute: boolean } => {
382
+ const componentMapping = THEME_VARIABLE_MAPPINGS[componentName]
383
+ if (!componentMapping) {
384
+ return { hasModifications: false, shouldKeepAttribute: true }
385
+ }
386
+
387
+ const line = attribute.loc?.start.line
388
+ const hasModifications = false
389
+ const shouldKeepAttribute = true
390
+
391
+ // --- Combined Case: Dynamic expressions OR object literals with spreads ---
392
+ if (attribute.value?.type === 'JSXExpressionContainer') {
393
+ const expr = attribute.value.expression
394
+ const isDynamicExpression = [
395
+ 'CallExpression',
396
+ 'Identifier',
397
+ 'ConditionalExpression',
398
+ 'ArrowFunctionExpression',
399
+ 'FunctionExpression',
400
+ 'LogicalExpression',
401
+ 'MemberExpression',
402
+ 'TemplateLiteral',
403
+ 'ArrayExpression',
404
+ 'ObjectPattern'
405
+ ].includes(expr?.type)
406
+
407
+ const isObjectWithSpread =
408
+ expr?.type === 'ObjectExpression' &&
409
+ expr.properties.some((prop) => prop.type === 'SpreadElement')
410
+
411
+ if (isDynamicExpression || isObjectWithSpread) {
412
+ // Process tokens if it’s an object with spreads
413
+ let result = { hasModifications: false, shouldKeepAttribute: true }
414
+ if (isObjectWithSpread) {
415
+ result = processThemeObject(expr, componentName, filePath, variant)
416
+ }
417
+
418
+ handleComplexThemeOverride(
419
+ filePath,
420
+ line,
421
+ componentName,
422
+ 'themeOverride prop',
423
+ isObjectWithSpread ? 'a spread element (`...`)' : 'a dynamic expression'
424
+ )
425
+
426
+ return result
427
+ }
428
+ }
429
+
430
+ if (
431
+ attribute.value?.type === 'JSXExpressionContainer' &&
432
+ attribute.value.expression?.type === 'ObjectExpression'
433
+ ) {
434
+ return processThemeObject(
435
+ attribute.value.expression,
436
+ componentName,
437
+ filePath,
438
+ variant
439
+ )
440
+ }
441
+
442
+ return { hasModifications, shouldKeepAttribute }
443
+ }
444
+
445
+ /**
446
+ * Processes a theme object and applies the token migrations
447
+ */
448
+ const processThemeObject = (
449
+ themeObject: namedTypes.ObjectExpression,
450
+ componentName: string,
451
+ filePath: string,
452
+ variant?: ResolvedVariant
453
+ ): { hasModifications: boolean; shouldKeepAttribute: boolean } => {
454
+ const componentMapping = THEME_VARIABLE_MAPPINGS[componentName]
455
+ let hasModifications = false
456
+ let shouldKeepAttribute = true
457
+
458
+ // Find which mapped tokens are actually present
459
+ const presentTokens = findMappedTokensInObject(themeObject, componentMapping)
460
+
461
+ // Process the theme object if there are mappable tokens
462
+ if (presentTokens.length > 0) {
463
+ hasModifications = updateComponentThemeTokens(
464
+ themeObject,
465
+ componentName,
466
+ filePath
467
+ )
468
+ }
469
+
470
+ // Variant-dependent tokens (Checkbox): resolved from the element's `variant`.
471
+ // Only available for per-instance themeOverride props (where `variant` was
472
+ // read off the element); skipped for InstUISettingsProvider overrides.
473
+ if (componentMapping.variantDependent && variant) {
474
+ const changed = applyVariantDependentTokens(
475
+ themeObject,
476
+ componentName,
477
+ filePath,
478
+ variant
479
+ )
480
+ hasModifications = hasModifications || changed
481
+ }
482
+
483
+ // Remove entire attribute if the theme object was emptied out by a migration.
484
+ if (hasModifications && themeObject.properties.length === 0) {
485
+ shouldKeepAttribute = false
486
+ }
487
+
488
+ return { hasModifications, shouldKeepAttribute }
489
+ }
490
+
491
+ /**
492
+ * Processes InstUISettingsProvider theme overrides
493
+ */
494
+ const processInstUISettingsProviderTheme = (
495
+ themeObject: namedTypes.ObjectExpression,
496
+ onModification: () => void,
497
+ filePath: string
498
+ ): boolean => {
499
+ let themeBecameEmpty = false
500
+
501
+ const componentOverrides = themeObject.properties.find(
502
+ (p) =>
503
+ p.type === 'ObjectProperty' &&
504
+ p.key.type === 'Identifier' &&
505
+ p.key.name === 'componentOverrides'
506
+ )
507
+
508
+ if (
509
+ componentOverrides &&
510
+ 'value' in componentOverrides &&
511
+ componentOverrides.value?.type === 'ObjectExpression'
512
+ ) {
513
+ // Process each component override. The component key may be an identifier
514
+ // (`Spinner: …`) or a quoted string literal (`'Spinner': …`,
515
+ // `'List.Item': …`), so resolve it via getKeyName.
516
+ componentOverrides.value.properties =
517
+ componentOverrides.value.properties.filter((overrideProp) => {
518
+ if (
519
+ overrideProp.type === 'ObjectProperty' &&
520
+ 'value' in overrideProp &&
521
+ overrideProp.value?.type === 'ObjectExpression'
522
+ ) {
523
+ const componentName = getKeyName(overrideProp.key)
524
+ if (componentName && THEME_VARIABLE_MAPPINGS[componentName]) {
525
+ const wasModified = updateComponentThemeTokens(
526
+ overrideProp.value,
527
+ componentName,
528
+ filePath
529
+ )
530
+
531
+ if (wasModified) onModification()
532
+
533
+ // Keep the override only if it still has properties
534
+ return overrideProp.value.properties.length > 0
535
+ }
536
+ }
537
+ return true
538
+ })
539
+
540
+ // Remove componentOverrides if it became empty
541
+ if (componentOverrides.value.properties.length === 0) {
542
+ // eslint-disable-next-line no-param-reassign
543
+ themeObject.properties = themeObject.properties.filter(
544
+ (p) => p !== componentOverrides
545
+ )
546
+ }
547
+ }
548
+
549
+ // Check if theme became completely empty
550
+ if (themeObject.properties.length === 0) {
551
+ themeBecameEmpty = true
552
+ }
553
+
554
+ return themeBecameEmpty
555
+ }
556
+
557
+ // Main codemod logic
558
+ const transformThemeVariablesCodemod: InstUICodemod = (
559
+ j: JSCodeshift,
560
+ root: Collection,
561
+ filePath: string
562
+ ) => {
563
+ let hasModifications = false
564
+
565
+ // Part 1: Process component themeOverride props
566
+ Object.keys(THEME_VARIABLE_MAPPINGS).forEach((componentName) => {
567
+ const componentMapping = THEME_VARIABLE_MAPPINGS[componentName]
568
+
569
+ // Dotted component names (`Modal.Body`) render as a member expression
570
+ // `<Modal.Body>`. The import to resolve is the base object (`Modal`); the
571
+ // JSX tag is then `<localName.Child>`, where `localName` respects an
572
+ // aliased import (`import { Modal as M }` -> `<M.Body>`).
573
+ const [baseName, childName] = componentName.split('.')
574
+ // Only the v2 (`/v11_7`, `/latest`) entry points use the new theming system;
575
+ // the bare path and `/v11_6` are v1 (old theming) and must be left alone.
576
+ const componentImport = findImport(j, root, baseName, [
577
+ `${componentMapping.import}/v11_7`,
578
+ `${componentMapping.import}/latest`,
579
+ '@instructure/ui/v11_7',
580
+ '@instructure/ui/latest'
581
+ ])
582
+
583
+ if (componentImport) {
584
+ const tagName = childName
585
+ ? `${componentImport}.${childName}`
586
+ : componentImport
587
+ // Find elements with themeOverride prop
588
+ // Suppress the generic spread warning when the element has an explicit
589
+ // `themeOverride` (we migrate that directly); when it's absent, emit a
590
+ // theme-specific warning, since a `themeOverride` could be hidden in the
591
+ // spread and its tokens would then not be migrated.
592
+ const elements = findElement(
593
+ filePath,
594
+ j,
595
+ root,
596
+ tagName,
597
+ { name: 'themeOverride' },
598
+ true,
599
+ `\`${componentName}\` has a spread (\`{...}\`) and no explicit \`themeOverride\`. If the spread sets a \`themeOverride\`, its theme tokens could not be inspected and were not migrated - review it manually.`
600
+ )
601
+
602
+ elements.forEach((element) => {
603
+ // Tokens that migrate differently per `variant` (Checkbox) need the
604
+ // variant read off this element; resolve it once per element.
605
+ const variant = componentMapping.variantDependent
606
+ ? getResolvedVariant(element.value.openingElement)
607
+ : undefined
608
+ // eslint-disable-next-line no-param-reassign
609
+ element.value.openingElement.attributes =
610
+ element.value.openingElement.attributes?.filter((attr) => {
611
+ if (isJSXAttribute(attr) && attr.name.name === 'themeOverride') {
612
+ const result = processThemeOverrideAttribute(
613
+ filePath,
614
+ attr,
615
+ componentName,
616
+ variant
617
+ )
618
+ hasModifications = hasModifications || result.hasModifications
619
+ return result.shouldKeepAttribute
620
+ }
621
+ return true
622
+ })
623
+ })
624
+ }
625
+ })
626
+
627
+ // Part 2: Process InstUISettingsProvider theme overrides. InstUISettingsProvider-level
628
+ // `componentOverrides` always migrate (token renames here + relocation to
629
+ // `themeOverride.components` in the migrate leg), regardless of which versions
630
+ // the file imports. v1 (v11.6 or earlier) components still read the legacy
631
+ // channel, so the migrate leg warns that their overrides will stop applying.
632
+ const providerImport = findImport(j, root, 'InstUISettingsProvider', [
633
+ '@instructure/ui',
634
+ '@instructure/emotion'
635
+ ])
636
+
637
+ if (providerImport) {
638
+ root.findJSXElements(providerImport).forEach((provider) => {
639
+ let themeBecameEmpty = false
640
+
641
+ provider.node.openingElement.attributes?.forEach((attr) => {
642
+ if (!isJSXAttribute(attr) || attr.name.name !== 'theme') return
643
+
644
+ const line = attr.loc?.start.line
645
+ const expr =
646
+ attr.value?.type === 'JSXExpressionContainer'
647
+ ? attr.value.expression
648
+ : undefined
649
+ if (!expr) return
650
+
651
+ if (expr.type === 'ObjectExpression') {
652
+ // Object theme: rename any component theme variables we can see.
653
+ // A theme with nothing for us to rename is a no-op, with no warning.
654
+ const empty = processInstUISettingsProviderTheme(
655
+ expr,
656
+ () => {
657
+ hasModifications = true
658
+ },
659
+ filePath
660
+ )
661
+ themeBecameEmpty = themeBecameEmpty || empty
662
+
663
+ // A spread at the theme level (`{ ...x }`) is opaque - if `x` sets
664
+ // component theme variables we can't see them to rename, so warn once
665
+ // per InstUISettingsProvider.
666
+ if (expr.properties.some((p) => p.type === 'SpreadElement')) {
667
+ printWarning(
668
+ filePath,
669
+ line,
670
+ "InstUISettingsProvider 'theme' spreads an object (`...`) that may " +
671
+ 'set component theme variables that have changed. These could ' +
672
+ 'not be updated automatically - please review and update them ' +
673
+ 'manually.'
674
+ )
675
+ }
676
+ } else if (expr.type !== 'Identifier') {
677
+ // A computed/dynamic theme (function, function call, ternary, member
678
+ // access, ...) may override component theme variables that have
679
+ // changed, but we cannot inspect it statically to rename them. Warn
680
+ // once per InstUISettingsProvider so the user reviews it manually. A bare identifier
681
+ // (`theme={canvas}`) is a named theme switch with nothing to rename,
682
+ // so it stays silent.
683
+ printWarning(
684
+ filePath,
685
+ line,
686
+ "InstUISettingsProvider 'theme' is set dynamically and may override " +
687
+ 'component theme variables that have changed. These could not be ' +
688
+ 'updated automatically - please review and update them manually.'
689
+ )
690
+ }
691
+ // A bare identifier (e.g. `theme={canvas}`) is a named theme switch -
692
+ // nothing to rename, intentionally left untouched and silent.
693
+ })
694
+
695
+ if (themeBecameEmpty) {
696
+ // The InstUISettingsProvider has no theme left, so unwrap it (hoist its children in
697
+ // place of the element). `replaceWith(children)` fails with "Could not
698
+ // replace path" when the InstUISettingsProvider sits in an expression position (e.g.
699
+ // a sole `return (<InstUISettingsProvider>…</InstUISettingsProvider>)`) because the children array
700
+ // includes whitespace JSXText around the element - multiple nodes can't
701
+ // replace a single expression. So collapse to the meaningful children:
702
+ // one element replaces the InstUISettingsProvider directly; several are wrapped in a
703
+ // fragment; none removes the InstUISettingsProvider outright.
704
+ const children = provider.node.children || []
705
+ const meaningful = children.filter(
706
+ (c) => !(c.type === 'JSXText' && c.value.trim() === '')
707
+ )
708
+ if (meaningful.length === 0) {
709
+ j(provider).remove()
710
+ } else if (meaningful.length === 1) {
711
+ j(provider).replaceWith(meaningful[0])
712
+ } else {
713
+ j(provider).replaceWith(
714
+ j.jsxFragment(
715
+ j.jsxOpeningFragment(),
716
+ j.jsxClosingFragment(),
717
+ children
718
+ )
719
+ )
720
+ }
721
+ hasModifications = true
722
+ }
723
+ })
724
+ }
725
+
726
+ return hasModifications
727
+ }
728
+
729
+ // NOTE: intentionally NOT a default export. `jscodeshift -t <file>` only runs a
730
+ // module's default export, so omitting it prevents this codemod from being run
731
+ // on its own - which would rename tokens but leave them in the legacy
732
+ // `componentOverrides` location (dead on v2 components). Run it via the
733
+ // `multiVersionThemeVariablesCodemod` entry point, which also relocates the overrides.
734
+ export { transformThemeVariables, transformThemeVariablesCodemod }