@cratis/components.migrator 0.0.0 → 4.0.0
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/LICENSE +21 -0
- package/README.md +258 -1
- package/compat-manifest.json +305 -0
- package/lib/buttonVariantToneTransform.js +380 -0
- package/lib/changeHandlerTransform.js +380 -0
- package/lib/compatibility.js +164 -0
- package/lib/namespaceMap.js +87 -0
- package/lib/runTransformCli.js +137 -0
- package/lib/transform.js +355 -0
- package/package.json +33 -4
- package/scripts/button-variant-tone.js +13 -0
- package/scripts/change-handler.js +13 -0
- package/scripts/remove-root-namespace-imports.js +14 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { packageName as defaultPackageName } from './namespaceMap.js';
|
|
6
|
+
|
|
7
|
+
const TODO = 'TODO(cratis-codemod)';
|
|
8
|
+
const LEGACY_VARIANT_PROPS = ['link', 'text', 'outlined'];
|
|
9
|
+
const RELEVANT_PROPS = new Set([
|
|
10
|
+
...LEGACY_VARIANT_PROPS,
|
|
11
|
+
'rounded',
|
|
12
|
+
'severity',
|
|
13
|
+
'variant',
|
|
14
|
+
'tone',
|
|
15
|
+
'shape',
|
|
16
|
+
]);
|
|
17
|
+
const severityToTone = {
|
|
18
|
+
secondary: 'neutral',
|
|
19
|
+
info: 'accent',
|
|
20
|
+
help: 'accent',
|
|
21
|
+
success: 'positive',
|
|
22
|
+
warn: 'caution',
|
|
23
|
+
danger: 'critical',
|
|
24
|
+
contrast: 'neutral',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Migrates deprecated Button JSX appearance props to variant/tone/shape.
|
|
29
|
+
* Only Button bindings imported from the Components Common subpath are considered.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} fileName
|
|
32
|
+
* @param {string} text
|
|
33
|
+
* @param {{ packageName?: string }} [options]
|
|
34
|
+
*/
|
|
35
|
+
export function transformButtonVariantTone(fileName, text, options = {}) {
|
|
36
|
+
const packageName = options.packageName ?? defaultPackageName;
|
|
37
|
+
const sourceFile = ts.createSourceFile(
|
|
38
|
+
fileName,
|
|
39
|
+
text,
|
|
40
|
+
ts.ScriptTarget.Latest,
|
|
41
|
+
true,
|
|
42
|
+
scriptKindFor(fileName),
|
|
43
|
+
);
|
|
44
|
+
const namedButtons = new Set();
|
|
45
|
+
const commonNamespaces = new Set();
|
|
46
|
+
const diagnostics = [];
|
|
47
|
+
const edits = [];
|
|
48
|
+
|
|
49
|
+
for (const statement of sourceFile.statements) {
|
|
50
|
+
if (
|
|
51
|
+
!ts.isImportDeclaration(statement) ||
|
|
52
|
+
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
53
|
+
statement.moduleSpecifier.text !== `${packageName}/Common` ||
|
|
54
|
+
!statement.importClause ||
|
|
55
|
+
statement.importClause.isTypeOnly
|
|
56
|
+
)
|
|
57
|
+
continue;
|
|
58
|
+
|
|
59
|
+
const bindings = statement.importClause.namedBindings;
|
|
60
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
61
|
+
commonNamespaces.add(bindings.name.text);
|
|
62
|
+
} else if (bindings && ts.isNamedImports(bindings)) {
|
|
63
|
+
for (const element of bindings.elements) {
|
|
64
|
+
if (
|
|
65
|
+
!ts.isTypeOnlyImportOrExportDeclaration(element) &&
|
|
66
|
+
(element.propertyName ?? element.name).text === 'Button'
|
|
67
|
+
) {
|
|
68
|
+
namedButtons.add(element.name.text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const report = (node, message) => {
|
|
75
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
|
|
76
|
+
node.getStart(sourceFile),
|
|
77
|
+
);
|
|
78
|
+
diagnostics.push({
|
|
79
|
+
file: fileName,
|
|
80
|
+
line: line + 1,
|
|
81
|
+
column: character + 1,
|
|
82
|
+
message,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const visit = (node) => {
|
|
87
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
88
|
+
if (isImportedButtonTag(node.tagName, namedButtons, commonNamespaces)) {
|
|
89
|
+
migrateOpeningElement(node);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
ts.forEachChild(node, visit);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const migrateOpeningElement = (node) => {
|
|
96
|
+
const attributes = new Map();
|
|
97
|
+
let spread;
|
|
98
|
+
let duplicate;
|
|
99
|
+
for (const attribute of node.attributes.properties) {
|
|
100
|
+
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
101
|
+
spread ??= attribute;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const name = attribute.name.getText(sourceFile);
|
|
105
|
+
if (!RELEVANT_PROPS.has(name)) continue;
|
|
106
|
+
if (attributes.has(name)) duplicate ??= attribute;
|
|
107
|
+
attributes.set(name, attribute);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (spread || duplicate) {
|
|
111
|
+
const problem = spread ?? duplicate;
|
|
112
|
+
report(
|
|
113
|
+
problem,
|
|
114
|
+
spread
|
|
115
|
+
? 'Button appearance migration refused: a JSX spread can provide or override legacy/new appearance props. Expand the spread and review it manually.'
|
|
116
|
+
: 'Button appearance migration refused: duplicate appearance props depend on JSX evaluation order. Resolve the duplicate manually.',
|
|
117
|
+
);
|
|
118
|
+
annotateExpression(node, problem);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const replacements = new Map();
|
|
123
|
+
const removals = new Set();
|
|
124
|
+
let unsupportedNode;
|
|
125
|
+
const unsupportedMessages = [];
|
|
126
|
+
|
|
127
|
+
const variantAttr = attributes.get('variant');
|
|
128
|
+
const legacyVariantAttrs = LEGACY_VARIANT_PROPS.map((name) => [
|
|
129
|
+
name,
|
|
130
|
+
attributes.get(name),
|
|
131
|
+
]).filter(([, attribute]) => attribute);
|
|
132
|
+
let selectedVariant;
|
|
133
|
+
let variantResolved = true;
|
|
134
|
+
|
|
135
|
+
if (variantAttr) {
|
|
136
|
+
if (
|
|
137
|
+
legacyVariantAttrs.length > 0 &&
|
|
138
|
+
staticString(variantAttr) === undefined
|
|
139
|
+
) {
|
|
140
|
+
variantResolved = false;
|
|
141
|
+
unsupportedNode ??= variantAttr;
|
|
142
|
+
unsupportedMessages.push(
|
|
143
|
+
'unknown explicit Button variant conflicts with legacy variant props',
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
for (const [, attribute] of legacyVariantAttrs) removals.add(attribute);
|
|
147
|
+
}
|
|
148
|
+
} else if (legacyVariantAttrs.length > 0) {
|
|
149
|
+
const values = { link: false, text: false, outlined: false };
|
|
150
|
+
for (const [name, attribute] of legacyVariantAttrs)
|
|
151
|
+
values[name] = staticBoolean(attribute);
|
|
152
|
+
const firstUnknown = legacyVariantAttrs.find(
|
|
153
|
+
([name]) => values[name] === undefined,
|
|
154
|
+
);
|
|
155
|
+
if (values.link === true) selectedVariant = 'link';
|
|
156
|
+
else if (values.link === undefined) variantResolved = false;
|
|
157
|
+
else if (values.text === true) selectedVariant = 'ghost';
|
|
158
|
+
else if (values.text === undefined) variantResolved = false;
|
|
159
|
+
else if (values.outlined === true) selectedVariant = 'outline';
|
|
160
|
+
else if (values.outlined === undefined) variantResolved = false;
|
|
161
|
+
else selectedVariant = 'solid';
|
|
162
|
+
|
|
163
|
+
if (variantResolved) {
|
|
164
|
+
replacements.set(
|
|
165
|
+
legacyVariantAttrs[0][1],
|
|
166
|
+
`variant='${selectedVariant}'`,
|
|
167
|
+
);
|
|
168
|
+
for (const [, attribute] of legacyVariantAttrs.slice(1))
|
|
169
|
+
removals.add(attribute);
|
|
170
|
+
} else {
|
|
171
|
+
unsupportedNode ??= firstUnknown?.[1];
|
|
172
|
+
unsupportedMessages.push('dynamic Button text/link/outlined value');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const shapeAttr = attributes.get('shape');
|
|
177
|
+
const roundedAttr = attributes.get('rounded');
|
|
178
|
+
if (shapeAttr && roundedAttr) {
|
|
179
|
+
if (staticString(shapeAttr) === undefined) {
|
|
180
|
+
unsupportedNode ??= shapeAttr;
|
|
181
|
+
unsupportedMessages.push(
|
|
182
|
+
'unknown explicit Button shape conflicts with rounded',
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
removals.add(roundedAttr);
|
|
186
|
+
}
|
|
187
|
+
} else if (roundedAttr) {
|
|
188
|
+
const rounded = staticBoolean(roundedAttr);
|
|
189
|
+
if (rounded === undefined) {
|
|
190
|
+
unsupportedNode ??= roundedAttr;
|
|
191
|
+
unsupportedMessages.push('dynamic Button rounded value');
|
|
192
|
+
} else if (rounded) {
|
|
193
|
+
replacements.set(roundedAttr, "shape='pill'");
|
|
194
|
+
} else {
|
|
195
|
+
removals.add(roundedAttr);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const toneAttr = attributes.get('tone');
|
|
200
|
+
const severityAttr = attributes.get('severity');
|
|
201
|
+
let contrastNeedsSolid = false;
|
|
202
|
+
if (toneAttr && severityAttr) {
|
|
203
|
+
if (staticString(toneAttr) === undefined) {
|
|
204
|
+
unsupportedNode ??= toneAttr;
|
|
205
|
+
unsupportedMessages.push(
|
|
206
|
+
'unknown explicit Button tone conflicts with severity',
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
removals.add(severityAttr);
|
|
210
|
+
}
|
|
211
|
+
} else if (severityAttr) {
|
|
212
|
+
const severity = staticString(severityAttr);
|
|
213
|
+
if (severity === undefined || !Object.hasOwn(severityToTone, severity)) {
|
|
214
|
+
unsupportedNode ??= severityAttr;
|
|
215
|
+
unsupportedMessages.push('dynamic or unknown Button severity value');
|
|
216
|
+
} else {
|
|
217
|
+
const replacement = `tone='${severityToTone[severity]}'`;
|
|
218
|
+
contrastNeedsSolid = severity === 'contrast';
|
|
219
|
+
replacements.set(severityAttr, replacement);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (contrastNeedsSolid && !variantAttr && variantResolved && !selectedVariant) {
|
|
224
|
+
const severityReplacement = replacements.get(severityAttr);
|
|
225
|
+
replacements.set(severityAttr, `${severityReplacement} variant='solid'`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (unsupportedNode) {
|
|
229
|
+
report(
|
|
230
|
+
unsupportedNode,
|
|
231
|
+
`Button appearance migration requires manual review: ${unsupportedMessages.join(' and ')}. The uncertain prop group was left unchanged.`,
|
|
232
|
+
);
|
|
233
|
+
annotateExpression(node, unsupportedNode);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const [attribute, replacement] of replacements) {
|
|
237
|
+
edits.push({
|
|
238
|
+
start: attribute.getStart(sourceFile),
|
|
239
|
+
end: attribute.getEnd(),
|
|
240
|
+
replacement,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
for (const attribute of removals) {
|
|
244
|
+
if (!replacements.has(attribute)) {
|
|
245
|
+
edits.push({
|
|
246
|
+
start: attribute.getStart(sourceFile),
|
|
247
|
+
end: attribute.getEnd(),
|
|
248
|
+
replacement: '',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const annotateExpression = (opening, problem) => {
|
|
255
|
+
if (opening.getText(sourceFile).includes(TODO)) return;
|
|
256
|
+
let expression;
|
|
257
|
+
if (ts.isJsxSpreadAttribute(problem)) expression = problem.expression;
|
|
258
|
+
else if (
|
|
259
|
+
ts.isJsxAttribute(problem) &&
|
|
260
|
+
problem.initializer &&
|
|
261
|
+
ts.isJsxExpression(problem.initializer)
|
|
262
|
+
) {
|
|
263
|
+
expression = problem.initializer.expression;
|
|
264
|
+
}
|
|
265
|
+
if (!expression && ts.isJsxAttribute(problem)) {
|
|
266
|
+
if (problem.initializer && ts.isStringLiteral(problem.initializer)) {
|
|
267
|
+
edits.push({
|
|
268
|
+
start: problem.initializer.getStart(sourceFile),
|
|
269
|
+
end: problem.initializer.getEnd(),
|
|
270
|
+
replacement: `{/* ${TODO}: review unsupported Button appearance props. */ ${problem.initializer.getText(sourceFile)}}`,
|
|
271
|
+
});
|
|
272
|
+
} else if (!problem.initializer) {
|
|
273
|
+
edits.push({
|
|
274
|
+
start: problem.getStart(sourceFile),
|
|
275
|
+
end: problem.getEnd(),
|
|
276
|
+
replacement: `${problem.name.getText(sourceFile)}={/* ${TODO}: review unsupported Button appearance props. */ true}`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (expression) {
|
|
282
|
+
edits.push({
|
|
283
|
+
start: expression.getStart(sourceFile),
|
|
284
|
+
end: expression.getStart(sourceFile),
|
|
285
|
+
replacement: `/* ${TODO}: review unsupported Button appearance props. */ `,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
visit(sourceFile);
|
|
291
|
+
return applyEdits(text, edits, diagnostics);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isImportedButtonTag(tag, namedButtons, commonNamespaces) {
|
|
295
|
+
if (ts.isIdentifier(tag)) return namedButtons.has(tag.text);
|
|
296
|
+
return (
|
|
297
|
+
ts.isPropertyAccessExpression(tag) &&
|
|
298
|
+
ts.isIdentifier(tag.expression) &&
|
|
299
|
+
commonNamespaces.has(tag.expression.text) &&
|
|
300
|
+
tag.name.text === 'Button'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function staticBoolean(attribute) {
|
|
305
|
+
if (!attribute.initializer) return true;
|
|
306
|
+
if (ts.isStringLiteral(attribute.initializer))
|
|
307
|
+
return attribute.initializer.text.length > 0;
|
|
308
|
+
if (!ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
|
|
309
|
+
return undefined;
|
|
310
|
+
const expression = unwrap(attribute.initializer.expression);
|
|
311
|
+
if (expression.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
312
|
+
if (
|
|
313
|
+
expression.kind === ts.SyntaxKind.FalseKeyword ||
|
|
314
|
+
expression.kind === ts.SyntaxKind.NullKeyword
|
|
315
|
+
)
|
|
316
|
+
return false;
|
|
317
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression))
|
|
318
|
+
return expression.text.length > 0;
|
|
319
|
+
if (ts.isNumericLiteral(expression)) return Number(expression.text) !== 0;
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function staticString(attribute) {
|
|
324
|
+
if (!attribute.initializer) return undefined;
|
|
325
|
+
if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
|
|
326
|
+
if (!ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
|
|
327
|
+
return undefined;
|
|
328
|
+
const expression = unwrap(attribute.initializer.expression);
|
|
329
|
+
return ts.isStringLiteral(expression) ||
|
|
330
|
+
ts.isNoSubstitutionTemplateLiteral(expression)
|
|
331
|
+
? expression.text
|
|
332
|
+
: undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function unwrap(node) {
|
|
336
|
+
while (
|
|
337
|
+
ts.isParenthesizedExpression(node) ||
|
|
338
|
+
ts.isAsExpression(node) ||
|
|
339
|
+
ts.isTypeAssertionExpression(node)
|
|
340
|
+
) {
|
|
341
|
+
node = node.expression;
|
|
342
|
+
}
|
|
343
|
+
return node;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function applyEdits(text, edits, diagnostics) {
|
|
347
|
+
if (edits.length === 0) return { text, changed: false, diagnostics };
|
|
348
|
+
edits.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
349
|
+
const unique = [];
|
|
350
|
+
for (const edit of edits) {
|
|
351
|
+
const previous = unique.at(-1);
|
|
352
|
+
if (previous && edit.start < previous.end) continue;
|
|
353
|
+
if (
|
|
354
|
+
previous &&
|
|
355
|
+
edit.start === previous.start &&
|
|
356
|
+
edit.end === previous.end &&
|
|
357
|
+
edit.replacement === previous.replacement
|
|
358
|
+
)
|
|
359
|
+
continue;
|
|
360
|
+
unique.push(edit);
|
|
361
|
+
}
|
|
362
|
+
let output = text;
|
|
363
|
+
for (let index = unique.length - 1; index >= 0; index--) {
|
|
364
|
+
const edit = unique[index];
|
|
365
|
+
output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
|
|
366
|
+
}
|
|
367
|
+
return { text: output, changed: output !== text, diagnostics };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function scriptKindFor(fileName) {
|
|
371
|
+
if (fileName.endsWith('.tsx')) return ts.ScriptKind.TSX;
|
|
372
|
+
if (fileName.endsWith('.jsx')) return ts.ScriptKind.JSX;
|
|
373
|
+
if (
|
|
374
|
+
fileName.endsWith('.js') ||
|
|
375
|
+
fileName.endsWith('.mjs') ||
|
|
376
|
+
fileName.endsWith('.cjs')
|
|
377
|
+
)
|
|
378
|
+
return ts.ScriptKind.JS;
|
|
379
|
+
return ts.ScriptKind.TS;
|
|
380
|
+
}
|