@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.
- package/CHANGELOG.md +5 -2
- package/lib/__node_tests__/codemods.test.ts +8 -0
- package/lib/__node_tests__/runTest.ts +8 -5
- package/lib/index.ts +3 -1
- package/lib/multiVersionThemeVariablesCodemod/migrateComponentOverridesToThemeOverride.ts +480 -0
- package/lib/multiVersionThemeVariablesCodemod/multiVersionThemeVariablesCodemod.ts +49 -0
- package/lib/multiVersionThemeVariablesCodemod/themeVariableMappings/index.ts +1255 -0
- package/lib/multiVersionThemeVariablesCodemod/transformThemeVariables.ts +734 -0
- package/lib/utils/codemodHelpers.ts +23 -3
- package/package.json +3 -2
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -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 }
|