@wise/wds-codemods 0.0.1-experimental-731cdc7 → 0.0.1-experimental-cbae00f
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/.changeset/better-impalas-drop.md +5 -0
- package/.changeset/config.json +13 -0
- package/.changeset/quick-mails-joke.md +128 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/actions/bootstrap/action.yml +49 -0
- package/.github/actions/commitlint/action.yml +27 -0
- package/.github/actions/test/action.yml +23 -0
- package/.github/workflows/cd-cd.yml +127 -0
- package/.github/workflows/renovate.yml +16 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.nvmrc +1 -0
- package/.prettierignore +1 -0
- package/.prettierrc.js +5 -0
- package/DEVELOPER.md +783 -0
- package/babel.config.js +28 -0
- package/commitlint.config.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +135 -133
- package/dist/index.js.map +1 -1
- package/dist/transforms/button.d.ts +16 -0
- package/dist/transforms/button.js +566 -493
- package/dist/transforms/button.js.map +1 -1
- package/eslint.config.js +15 -0
- package/jest.config.js +9 -0
- package/mkdocs.yml +4 -0
- package/package.json +14 -19
- package/renovate.json +9 -0
- package/scripts/build.sh +10 -0
- package/src/__tests__/runCodemod.test.ts +96 -0
- package/src/index.ts +4 -0
- package/src/runCodemod.ts +88 -0
- package/src/transforms/button/__tests__/button.test.tsx +153 -0
- package/src/transforms/button/button.ts +418 -0
- package/src/transforms/helpers/__tests__/createTestTransform.test.ts +27 -0
- package/src/transforms/helpers/__tests__/hasImport.test.ts +52 -0
- package/src/transforms/helpers/__tests__/iconUtils.test.ts +207 -0
- package/src/transforms/helpers/__tests__/jsxElementUtils.test.ts +130 -0
- package/src/transforms/helpers/__tests__/jsxReportingUtils.test.ts +265 -0
- package/src/transforms/helpers/createTestTransform.ts +18 -0
- package/src/transforms/helpers/hasImport.ts +60 -0
- package/src/transforms/helpers/iconUtils.ts +87 -0
- package/src/transforms/helpers/index.ts +5 -0
- package/src/transforms/helpers/jsxElementUtils.ts +67 -0
- package/src/transforms/helpers/jsxReportingUtils.ts +224 -0
- package/src/utils/__tests__/getOptions.test.ts +170 -0
- package/src/utils/__tests__/handleError.test.ts +18 -0
- package/src/utils/__tests__/loadTransformModules.test.ts +51 -0
- package/src/utils/__tests__/reportManualReview.test.ts +42 -0
- package/src/utils/getOptions.ts +63 -0
- package/src/utils/handleError.ts +6 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/loadTransformModules.ts +28 -0
- package/src/utils/reportManualReview.ts +17 -0
- package/test-button.tsx +230 -0
- package/test-file.js +2 -0
- package/tsconfig.json +14 -0
- package/tsup.config.js +13 -0
- package/dist/reportManualReview-DQ00-OKx.js +0 -50
- package/dist/reportManualReview-DQ00-OKx.js.map +0 -1
package/DEVELOPER.md
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# Developer Documentation
|
|
2
|
+
|
|
3
|
+
A comprehensive guide for developing codemods for the Wise Design System.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Getting Started](#getting-started)
|
|
8
|
+
- [Transform Structure](#transform-structure)
|
|
9
|
+
- [Helper Functions](#helper-functions)
|
|
10
|
+
- [Testing](#testing)
|
|
11
|
+
- [Package Requirements](#package-requirements)
|
|
12
|
+
- [Manual Review System](#manual-review-system)
|
|
13
|
+
- [Best Practices](#best-practices)
|
|
14
|
+
- [Examples](#examples)
|
|
15
|
+
|
|
16
|
+
## Getting Started
|
|
17
|
+
|
|
18
|
+
### Prerequisites
|
|
19
|
+
|
|
20
|
+
- Node.js (version specified in `.nvmrc`)
|
|
21
|
+
- pnpm 9.15.4
|
|
22
|
+
- Understanding of AST (Abstract Syntax Trees)
|
|
23
|
+
- Basic knowledge of jscodeshift
|
|
24
|
+
|
|
25
|
+
### Development Setup
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Install dependencies
|
|
29
|
+
pnpm install
|
|
30
|
+
|
|
31
|
+
# Build the project
|
|
32
|
+
pnpm run build
|
|
33
|
+
|
|
34
|
+
# Run tests
|
|
35
|
+
pnpm test
|
|
36
|
+
|
|
37
|
+
# Run tests in watch mode
|
|
38
|
+
pnpm test:watch
|
|
39
|
+
|
|
40
|
+
# Run with coverage
|
|
41
|
+
pnpm test:coverage
|
|
42
|
+
|
|
43
|
+
# Lint code
|
|
44
|
+
pnpm run lint
|
|
45
|
+
|
|
46
|
+
# Auto-fix linting issues
|
|
47
|
+
pnpm run lint:fix
|
|
48
|
+
|
|
49
|
+
# Type checking
|
|
50
|
+
pnpm run lint:types
|
|
51
|
+
|
|
52
|
+
# Create a changeset for releases
|
|
53
|
+
pnpm run changeset
|
|
54
|
+
|
|
55
|
+
# Publish releases
|
|
56
|
+
pnpm run release
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Transform Structure
|
|
60
|
+
|
|
61
|
+
Every transform follows this basic structure:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// src/transforms/my-transform/my-transform.ts
|
|
65
|
+
import type { API, FileInfo, JSCodeshift, Options } from 'jscodeshift';
|
|
66
|
+
import { validatePackageRequirements, createReporter, hasImport } from '../helpers';
|
|
67
|
+
import reportManualReview from '../../utils/reportManualReview';
|
|
68
|
+
|
|
69
|
+
export const parser = 'tsx'; // Always use tsx for TypeScript + JSX support
|
|
70
|
+
export const packageRequirements = [{ name: '@transferwise/components', version: '>=46.5.0' }];
|
|
71
|
+
|
|
72
|
+
const transformer = (file: FileInfo, api: API, options: Options) => {
|
|
73
|
+
// 1. Validate package requirements
|
|
74
|
+
if (!validatePackageRequirements(options, packageRequirements)) {
|
|
75
|
+
return file.source;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Set up jscodeshift and reporter
|
|
79
|
+
const j: JSCodeshift = api.jscodeshift;
|
|
80
|
+
const root = j(file.source);
|
|
81
|
+
const manualReviewIssues: string[] = [];
|
|
82
|
+
const reporter = createReporter(j, manualReviewIssues);
|
|
83
|
+
|
|
84
|
+
// 3. Check for relevant imports
|
|
85
|
+
const { exists: hasComponentImport } = hasImport(
|
|
86
|
+
root,
|
|
87
|
+
'@transferwise/components',
|
|
88
|
+
'MyComponent',
|
|
89
|
+
j,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (!hasComponentImport) {
|
|
93
|
+
return file.source; // No relevant imports, skip file
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4. Perform transformations
|
|
97
|
+
root.findJSXElements('MyComponent').forEach((path) => {
|
|
98
|
+
// Transform logic here
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 5. Report manual review issues
|
|
102
|
+
if (manualReviewIssues.length > 0) {
|
|
103
|
+
manualReviewIssues.forEach(async (issue) => {
|
|
104
|
+
await reportManualReview(file.path, issue);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return root.toSource();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export default transformer;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Helper Functions
|
|
115
|
+
|
|
116
|
+
### Import Management
|
|
117
|
+
|
|
118
|
+
#### `hasImport(root, moduleName, importName, j)`
|
|
119
|
+
|
|
120
|
+
Checks if a specific import exists and provides utilities to remove it.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { hasImport } from '../helpers';
|
|
124
|
+
|
|
125
|
+
// Check for named import
|
|
126
|
+
const { exists: hasComponentImport, remove: removeComponentImport } = hasImport(
|
|
127
|
+
root,
|
|
128
|
+
'@transferwise/components',
|
|
129
|
+
'MyComponent',
|
|
130
|
+
j,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Check for legacy component import with removal capability
|
|
134
|
+
const { exists: hasLegacyImport, remove: removeLegacyImport } = hasImport(
|
|
135
|
+
root,
|
|
136
|
+
'@transferwise/components',
|
|
137
|
+
'LegacyComponent',
|
|
138
|
+
j,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (hasComponentImport) {
|
|
142
|
+
// Transform MyComponent elements
|
|
143
|
+
root.findJSXElements('MyComponent').forEach(transformComponent);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (hasLegacyImport) {
|
|
147
|
+
// Transform LegacyComponent to MyComponent
|
|
148
|
+
root.findJSXElements('LegacyComponent').forEach(transformLegacyComponent);
|
|
149
|
+
|
|
150
|
+
// Remove the LegacyComponent import after transformation
|
|
151
|
+
removeLegacyImport();
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Returns:**
|
|
156
|
+
|
|
157
|
+
- `exists: boolean` - Whether the import exists
|
|
158
|
+
- `remove: () => void` - Function to remove the import
|
|
159
|
+
|
|
160
|
+
### JSX Element Utilities
|
|
161
|
+
|
|
162
|
+
#### `setNameIfJSXIdentifier(elementName, newName)`
|
|
163
|
+
|
|
164
|
+
Safely renames JSX elements, only if they are simple identifiers (not member expressions).
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import { setNameIfJSXIdentifier } from '../helpers';
|
|
168
|
+
|
|
169
|
+
root.findJSXElements('LegacyComponent').forEach((path) => {
|
|
170
|
+
const { openingElement, closingElement } = path.node;
|
|
171
|
+
|
|
172
|
+
// Rename opening tag from LegacyComponent to NewComponent
|
|
173
|
+
openingElement.name = setNameIfJSXIdentifier(openingElement.name, 'NewComponent')!;
|
|
174
|
+
|
|
175
|
+
// Rename closing tag if it exists
|
|
176
|
+
if (closingElement) {
|
|
177
|
+
closingElement.name = setNameIfJSXIdentifier(closingElement.name, 'NewComponent')!;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
#### `hasAttributeOnElement(openingElement, attributeName)`
|
|
183
|
+
|
|
184
|
+
Checks if a JSX element has a specific attribute.
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import { hasAttributeOnElement } from '../helpers';
|
|
188
|
+
|
|
189
|
+
root.findJSXElements('MyComponent').forEach((path) => {
|
|
190
|
+
const { openingElement } = path.node;
|
|
191
|
+
|
|
192
|
+
// Skip if already has v2 prop
|
|
193
|
+
if (hasAttributeOnElement(openingElement, 'v2')) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Add v2 prop
|
|
198
|
+
addAttributesIfMissing(j, openingElement, [
|
|
199
|
+
{ attribute: j.jsxAttribute(j.jsxIdentifier('v2')), name: 'v2' },
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### `addAttributesIfMissing(j, openingElement, attributes)`
|
|
205
|
+
|
|
206
|
+
Adds attributes to a JSX element only if they don't already exist.
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { addAttributesIfMissing } from '../helpers';
|
|
210
|
+
|
|
211
|
+
// Add multiple attributes if missing
|
|
212
|
+
addAttributesIfMissing(j, openingElement, [
|
|
213
|
+
{
|
|
214
|
+
attribute: j.jsxAttribute(j.jsxIdentifier('v2')),
|
|
215
|
+
name: 'v2',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
attribute: j.jsxAttribute(j.jsxIdentifier('variant'), j.literal('primary')),
|
|
219
|
+
name: 'variant',
|
|
220
|
+
},
|
|
221
|
+
]);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Parameters:**
|
|
225
|
+
|
|
226
|
+
- `j` - jscodeshift instance
|
|
227
|
+
- `openingElement` - JSX opening element
|
|
228
|
+
- `attributes` - Array of `{ attribute: JSXAttribute, name: string }`
|
|
229
|
+
|
|
230
|
+
### Icon Processing
|
|
231
|
+
|
|
232
|
+
#### `processIconChildren(j, children, iconImports, openingElement)`
|
|
233
|
+
|
|
234
|
+
Automatically processes icon children and converts them to appropriate addon props.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import { processIconChildren } from '../helpers';
|
|
238
|
+
|
|
239
|
+
// First, collect icon imports
|
|
240
|
+
const iconImports = new Set<string>();
|
|
241
|
+
root.find(j.ImportDeclaration, { source: { value: '@transferwise/icons' } }).forEach((path) => {
|
|
242
|
+
path.node.specifiers?.forEach((specifier) => {
|
|
243
|
+
if (specifier.local) {
|
|
244
|
+
const localName = (specifier.local as { name: string }).name;
|
|
245
|
+
iconImports.add(localName);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Then process icons in components
|
|
251
|
+
root.findJSXElements('MyComponent').forEach((path) => {
|
|
252
|
+
processIconChildren(j, path.node.children, iconImports, path.node.openingElement);
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
This function:
|
|
257
|
+
|
|
258
|
+
- Detects icon components in children
|
|
259
|
+
- Converts them to addon object props: `addonStart={{ type: 'icon', value: <IconComponent /> }}`
|
|
260
|
+
- Removes the icon children from the component
|
|
261
|
+
- Handles complex icon expressions that need manual review
|
|
262
|
+
|
|
263
|
+
**Example transformation:**
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
// Before
|
|
267
|
+
<MyComponent>
|
|
268
|
+
<CheckIcon />
|
|
269
|
+
Content here
|
|
270
|
+
</MyComponent>
|
|
271
|
+
|
|
272
|
+
// After
|
|
273
|
+
<MyComponent addonStart={{ type: 'icon', value: <CheckIcon /> }}>
|
|
274
|
+
Content here
|
|
275
|
+
</MyComponent>
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Reporting System
|
|
279
|
+
|
|
280
|
+
#### `createReporter(j, issues)`
|
|
281
|
+
|
|
282
|
+
Creates a reporter instance for collecting manual review issues.
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import { createReporter } from '../helpers';
|
|
286
|
+
|
|
287
|
+
const manualReviewIssues: string[] = [];
|
|
288
|
+
const reporter = createReporter(j, manualReviewIssues);
|
|
289
|
+
|
|
290
|
+
// The reporter provides many methods for different scenarios
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
#### Reporter Methods
|
|
294
|
+
|
|
295
|
+
**Element-level reporting:**
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// Report general element issues
|
|
299
|
+
reporter.reportElement(componentElement, 'has complex configuration that needs review');
|
|
300
|
+
|
|
301
|
+
// Report spread props
|
|
302
|
+
reporter.reportSpreadProps(componentElement);
|
|
303
|
+
|
|
304
|
+
// Report attribute issues automatically
|
|
305
|
+
reporter.reportAttributeIssues(componentElement);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**Prop-specific reporting:**
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
// Report specific prop issues
|
|
312
|
+
reporter.reportProp(element, 'variant', 'has unsupported value');
|
|
313
|
+
|
|
314
|
+
// Report attribute with custom reason
|
|
315
|
+
reporter.reportAttribute(attr, element, 'contains dynamic expression');
|
|
316
|
+
|
|
317
|
+
// Report unsupported values
|
|
318
|
+
reporter.reportUnsupportedValue(element, 'theme', 'custom-theme');
|
|
319
|
+
|
|
320
|
+
// Report ambiguous expressions
|
|
321
|
+
reporter.reportAmbiguousExpression(element, 'onClick');
|
|
322
|
+
|
|
323
|
+
// Report conflicting props and children
|
|
324
|
+
reporter.reportPropWithChildren(element, 'label');
|
|
325
|
+
|
|
326
|
+
// Report deprecated props
|
|
327
|
+
reporter.reportDeprecatedProp(element, 'legacy', 'use "variant" instead');
|
|
328
|
+
|
|
329
|
+
// Report missing required props
|
|
330
|
+
reporter.reportMissingRequiredProp(element, 'children');
|
|
331
|
+
|
|
332
|
+
// Report conflicting props
|
|
333
|
+
reporter.reportConflictingProps(element, ['variant', 'theme']);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**Children-specific reporting:**
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// Report ambiguous children (icons, conditional rendering, etc.)
|
|
340
|
+
reporter.reportAmbiguousChildren(element, 'icon');
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Package Validation
|
|
344
|
+
|
|
345
|
+
#### `validatePackageRequirements(options, packageRequirements)`
|
|
346
|
+
|
|
347
|
+
Validates that required packages are available before running the transform.
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
import { validatePackageRequirements } from '../helpers';
|
|
351
|
+
|
|
352
|
+
export const packageRequirements = [
|
|
353
|
+
{ name: '@transferwise/components', version: '>=46.5.0' },
|
|
354
|
+
{ name: '@transferwise/icons', version: '>=1.0.0' },
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
const transformer = (file: FileInfo, api: API, options: Options) => {
|
|
358
|
+
// This will return false if packages aren't found/compatible
|
|
359
|
+
if (!validatePackageRequirements(options, packageRequirements)) {
|
|
360
|
+
return file.source; // Skip transformation
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Continue with transformation...
|
|
364
|
+
};
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Testing
|
|
368
|
+
|
|
369
|
+
### Basic Test Setup
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// src/transforms/my-transform/__tests__/my-transform.test.ts
|
|
373
|
+
import { createTestTransform } from '../../helpers/createTestTransform';
|
|
374
|
+
import transform from '../my-transform';
|
|
375
|
+
|
|
376
|
+
// Create test transform with package requirements
|
|
377
|
+
const testTransform = createTestTransform(transform, [
|
|
378
|
+
{ name: '@transferwise/components', version: '46.5.0' },
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
describe('my-transform', () => {
|
|
382
|
+
it('should transform basic component', () => {
|
|
383
|
+
const input = `
|
|
384
|
+
import { OldComponent } from '@transferwise/components';
|
|
385
|
+
|
|
386
|
+
<OldComponent prop="value" />
|
|
387
|
+
`;
|
|
388
|
+
|
|
389
|
+
const expected = `
|
|
390
|
+
import { NewComponent } from '@transferwise/components';
|
|
391
|
+
|
|
392
|
+
<NewComponent newProp="value" />
|
|
393
|
+
`;
|
|
394
|
+
|
|
395
|
+
expect(testTransform({ source: input }).trim()).toBe(expected.trim());
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Testing with Different Package Scenarios
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
import {
|
|
404
|
+
createTestTransform,
|
|
405
|
+
createTestTransformWithPackage,
|
|
406
|
+
createTestTransformWithoutPackage,
|
|
407
|
+
} from '../../helpers/createTestTransform';
|
|
408
|
+
|
|
409
|
+
describe('package requirement scenarios', () => {
|
|
410
|
+
it('should transform when package is available', () => {
|
|
411
|
+
const testTransform = createTestTransformWithPackage(
|
|
412
|
+
transform,
|
|
413
|
+
'@transferwise/components',
|
|
414
|
+
'46.5.0',
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const result = testTransform({ source: input });
|
|
418
|
+
expect(result).toBe(expectedOutput);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should skip transformation when package is missing', () => {
|
|
422
|
+
const testTransform = createTestTransformWithoutPackage(
|
|
423
|
+
transform,
|
|
424
|
+
'@transferwise/components',
|
|
425
|
+
'46.5.0',
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const result = testTransform({ source: input });
|
|
429
|
+
expect(result).toBe(input); // Should remain unchanged
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Testing Manual Review Cases
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
describe('manual review cases', () => {
|
|
438
|
+
it('should handle spread props', () => {
|
|
439
|
+
const input = `<MyComponent {...props} />`;
|
|
440
|
+
const result = testTransform({ source: input });
|
|
441
|
+
|
|
442
|
+
// The transform should still run but flag for manual review
|
|
443
|
+
expect(result).toContain('v2');
|
|
444
|
+
// Manual review issues would be captured in a real scenario
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should handle complex expressions', () => {
|
|
448
|
+
const input = `<MyComponent variant={computeVariant()} />`;
|
|
449
|
+
const result = testTransform({ source: input });
|
|
450
|
+
|
|
451
|
+
// Should preserve the expression but flag for review
|
|
452
|
+
expect(result).toContain('computeVariant()');
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Testing Icon Processing
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
describe('icon processing', () => {
|
|
461
|
+
it('should convert leading icons to addon props', () => {
|
|
462
|
+
const input = `
|
|
463
|
+
import { MyComponent } from '@transferwise/components';
|
|
464
|
+
import { CheckIcon } from '@transferwise/icons';
|
|
465
|
+
|
|
466
|
+
<MyComponent>
|
|
467
|
+
<CheckIcon />
|
|
468
|
+
Content text
|
|
469
|
+
</MyComponent>
|
|
470
|
+
`;
|
|
471
|
+
|
|
472
|
+
const expected = `
|
|
473
|
+
import { MyComponent } from '@transferwise/components';
|
|
474
|
+
import { CheckIcon } from '@transferwise/icons';
|
|
475
|
+
|
|
476
|
+
<MyComponent v2 addonStart={{ type: 'icon', value: <CheckIcon /> }}>
|
|
477
|
+
Content text
|
|
478
|
+
</MyComponent>
|
|
479
|
+
`;
|
|
480
|
+
|
|
481
|
+
expect(testTransform({ source: input })).toBe(expected);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## Package Requirements
|
|
487
|
+
|
|
488
|
+
### Defining Requirements
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
export const packageRequirements = [
|
|
492
|
+
{
|
|
493
|
+
name: '@transferwise/components',
|
|
494
|
+
version: '>=46.5.0', // Semantic version range
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: '@transferwise/icons',
|
|
498
|
+
version: '^1.0.0', // Caret range
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
name: '@transferwise/tokens',
|
|
502
|
+
version: '~1.5.0', // Tilde range
|
|
503
|
+
},
|
|
504
|
+
];
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### How Package Validation Works
|
|
508
|
+
|
|
509
|
+
The system checks for packages in this order:
|
|
510
|
+
|
|
511
|
+
1. **Direct dependencies** - `package.json` files
|
|
512
|
+
2. **Lockfiles** - `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`
|
|
513
|
+
3. **Node modules** - Installed packages in `node_modules/`
|
|
514
|
+
|
|
515
|
+
For monorepos, it checks across all workspace packages and provides a summary.
|
|
516
|
+
|
|
517
|
+
## Manual Review System
|
|
518
|
+
|
|
519
|
+
### When to Use Manual Review
|
|
520
|
+
|
|
521
|
+
Use manual review reporting when:
|
|
522
|
+
|
|
523
|
+
- **Spread props** are present: `<MyComponent {...props} />`
|
|
524
|
+
- **Dynamic expressions** can't be safely transformed: `<MyComponent variant={calculateVariant()} />`
|
|
525
|
+
- **Complex children** need human review: `<MyComponent>{condition ? <Icon1 /> : <Icon2 />}</MyComponent>`
|
|
526
|
+
- **Unsupported prop values** are found: `<MyComponent theme="custom" />`
|
|
527
|
+
- **Conflicting props** are present: `<MyComponent label="Click" children="Also click" />`
|
|
528
|
+
|
|
529
|
+
### Best Practices for Reporting
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
// ✅ Good - Specific and actionable
|
|
533
|
+
reporter.reportUnsupportedValue(element, 'variant', 'custom');
|
|
534
|
+
// "prop "variant" on <MyComponent> at line 42 has unsupported value "custom""
|
|
535
|
+
|
|
536
|
+
// ✅ Good - Explains the conflict
|
|
537
|
+
reporter.reportPropWithChildren(element, 'label');
|
|
538
|
+
// "prop "label" on <MyComponent> at line 42 conflicts with children"
|
|
539
|
+
|
|
540
|
+
// ❌ Avoid - Too vague
|
|
541
|
+
reporter.reportElement(element, 'needs review');
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
## Best Practices
|
|
545
|
+
|
|
546
|
+
### Transform Design
|
|
547
|
+
|
|
548
|
+
1. **Start small** - Begin with simple transformations and gradually add complexity
|
|
549
|
+
2. **Fail safely** - When in doubt, preserve existing code and report for manual review
|
|
550
|
+
3. **Be specific** - Provide clear, actionable manual review messages
|
|
551
|
+
4. **Test edge cases** - Include tests for complex scenarios and error conditions
|
|
552
|
+
|
|
553
|
+
### Code Organisation
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
// Group related transformations
|
|
557
|
+
const transformComponent = (path: ASTPath<JSXElement>) => {
|
|
558
|
+
// Component-specific logic
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const transformLegacyComponent = (path: ASTPath<JSXElement>) => {
|
|
562
|
+
// Legacy component-specific logic
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// Main transformer delegates to specific functions
|
|
566
|
+
const transformer = (file: FileInfo, api: API, options: Options) => {
|
|
567
|
+
// Setup...
|
|
568
|
+
|
|
569
|
+
if (hasComponentImport) {
|
|
570
|
+
root.findJSXElements('MyComponent').forEach(transformComponent);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (hasLegacyImport) {
|
|
574
|
+
root.findJSXElements('LegacyComponent').forEach(transformLegacyComponent);
|
|
575
|
+
removeLegacyImport();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Cleanup...
|
|
579
|
+
};
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### Error Handling
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
// Wrap risky operations in try-catch
|
|
586
|
+
openingElement.attributes?.forEach((attr) => {
|
|
587
|
+
try {
|
|
588
|
+
if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') {
|
|
589
|
+
// Safe to access attr.name.name
|
|
590
|
+
const propName = attr.name.name;
|
|
591
|
+
// Process attribute...
|
|
592
|
+
}
|
|
593
|
+
} catch (error) {
|
|
594
|
+
reporter.reportAttribute(attr, path, 'could not be processed automatically');
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Performance Considerations
|
|
600
|
+
|
|
601
|
+
1. **Early returns** - Skip files without relevant imports
|
|
602
|
+
2. **Efficient queries** - Use specific selectors instead of broad searches
|
|
603
|
+
3. **Minimise DOM traversal** - Cache frequently accessed nodes
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
// ✅ Good - Early return if no relevant imports
|
|
607
|
+
if (!hasComponentImport && !hasLegacyImport) {
|
|
608
|
+
return file.source;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ✅ Good - Specific query
|
|
612
|
+
root.findJSXElements('MyComponent').forEach(transformComponent);
|
|
613
|
+
|
|
614
|
+
// ❌ Avoid - Broad search
|
|
615
|
+
root.find(j.JSXElement).forEach((path) => {
|
|
616
|
+
if (
|
|
617
|
+
path.node.openingElement.name.type === 'JSXIdentifier' &&
|
|
618
|
+
path.node.openingElement.name.name === 'MyComponent'
|
|
619
|
+
) {
|
|
620
|
+
transformComponent(path);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
## Examples
|
|
626
|
+
|
|
627
|
+
### Complete Transform Example (Simplified)
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
// src/transforms/my-component-v2/my-component-v2.ts
|
|
631
|
+
import type { API, FileInfo, JSCodeshift, Options } from 'jscodeshift';
|
|
632
|
+
import reportManualReview from '../../utils/reportManualReview';
|
|
633
|
+
import {
|
|
634
|
+
addAttributesIfMissing,
|
|
635
|
+
createReporter,
|
|
636
|
+
hasAttributeOnElement,
|
|
637
|
+
hasImport,
|
|
638
|
+
validatePackageRequirements,
|
|
639
|
+
} from '../helpers';
|
|
640
|
+
|
|
641
|
+
export const parser = 'tsx';
|
|
642
|
+
export const packageRequirements = [{ name: '@transferwise/components', version: '>=46.5.0' }];
|
|
643
|
+
|
|
644
|
+
const transformer = (file: FileInfo, api: API, options: Options) => {
|
|
645
|
+
// 1. Validate package requirements
|
|
646
|
+
if (!validatePackageRequirements(options, packageRequirements)) {
|
|
647
|
+
return file.source;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 2. Setup
|
|
651
|
+
const j: JSCodeshift = api.jscodeshift;
|
|
652
|
+
const root = j(file.source);
|
|
653
|
+
const manualReviewIssues: string[] = [];
|
|
654
|
+
const reporter = createReporter(j, manualReviewIssues);
|
|
655
|
+
|
|
656
|
+
// 3. Check for component import
|
|
657
|
+
const { exists: hasComponentImport } = hasImport(
|
|
658
|
+
root,
|
|
659
|
+
'@transferwise/components',
|
|
660
|
+
'MyComponent',
|
|
661
|
+
j,
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
if (!hasComponentImport) {
|
|
665
|
+
return file.source;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 4. Transform components
|
|
669
|
+
root.findJSXElements('MyComponent').forEach((path) => {
|
|
670
|
+
const { openingElement } = path.node;
|
|
671
|
+
|
|
672
|
+
// Skip if already has v2 prop
|
|
673
|
+
if (hasAttributeOnElement(openingElement, 'v2')) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Add v2 prop
|
|
678
|
+
addAttributesIfMissing(j, openingElement, [
|
|
679
|
+
{ attribute: j.jsxAttribute(j.jsxIdentifier('v2')), name: 'v2' },
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
// Handle variant prop transformation
|
|
683
|
+
const variantAttr = openingElement.attributes?.find(
|
|
684
|
+
(attr) =>
|
|
685
|
+
attr.type === 'JSXAttribute' &&
|
|
686
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
687
|
+
attr.name.name === 'variant',
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
if (
|
|
691
|
+
variantAttr &&
|
|
692
|
+
variantAttr.type === 'JSXAttribute' &&
|
|
693
|
+
variantAttr.value?.type === 'StringLiteral'
|
|
694
|
+
) {
|
|
695
|
+
const oldVariant = variantAttr.value.value;
|
|
696
|
+
const newVariant =
|
|
697
|
+
oldVariant === 'accent' ? 'primary' : oldVariant === 'subtle' ? 'secondary' : oldVariant;
|
|
698
|
+
variantAttr.value.value = newVariant;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Report spread props for manual review
|
|
702
|
+
if (openingElement.attributes?.some((attr) => attr.type === 'JSXSpreadAttribute')) {
|
|
703
|
+
reporter.reportSpreadProps(path);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// 5. Report manual review issues
|
|
708
|
+
if (manualReviewIssues.length > 0) {
|
|
709
|
+
manualReviewIssues.forEach(async (issue) => {
|
|
710
|
+
await reportManualReview(file.path, issue);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return root.toSource();
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
export default transformer;
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Complete Test Example
|
|
721
|
+
|
|
722
|
+
```typescript
|
|
723
|
+
// src/transforms/my-component-v2/__tests__/my-component-v2.test.ts
|
|
724
|
+
import { createTestTransform } from '../../helpers/createTestTransform';
|
|
725
|
+
import transform from '../my-component-v2';
|
|
726
|
+
|
|
727
|
+
const testTransform = createTestTransform(transform, [
|
|
728
|
+
{ name: '@transferwise/components', version: '46.5.0' },
|
|
729
|
+
]);
|
|
730
|
+
|
|
731
|
+
describe('my-component-v2 transform', () => {
|
|
732
|
+
it('should add v2 prop to MyComponent instances', () => {
|
|
733
|
+
const input = `
|
|
734
|
+
import { MyComponent } from '@transferwise/components';
|
|
735
|
+
|
|
736
|
+
<MyComponent variant="accent">Content</MyComponent>
|
|
737
|
+
`;
|
|
738
|
+
|
|
739
|
+
const expected = `
|
|
740
|
+
import { MyComponent } from '@transferwise/components';
|
|
741
|
+
|
|
742
|
+
<MyComponent v2 variant="primary">Content</MyComponent>
|
|
743
|
+
`;
|
|
744
|
+
|
|
745
|
+
expect(testTransform({ source: input })).toBe(expected);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('should skip components that already have v2 prop', () => {
|
|
749
|
+
const input = `
|
|
750
|
+
import { MyComponent } from '@transferwise/components';
|
|
751
|
+
|
|
752
|
+
<MyComponent v2 variant="primary">Already upgraded</MyComponent>
|
|
753
|
+
`;
|
|
754
|
+
|
|
755
|
+
expect(testTransform({ source: input })).toBe(input);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('should skip files without component imports', () => {
|
|
759
|
+
const input = `
|
|
760
|
+
import { OtherComponent } from '@transferwise/components';
|
|
761
|
+
|
|
762
|
+
<OtherComponent>Content</OtherComponent>
|
|
763
|
+
`;
|
|
764
|
+
|
|
765
|
+
expect(testTransform({ source: input })).toBe(input);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('should handle spread props', () => {
|
|
769
|
+
const input = `
|
|
770
|
+
import { MyComponent } from '@transferwise/components';
|
|
771
|
+
|
|
772
|
+
<MyComponent {...props}>Content</MyComponent>
|
|
773
|
+
`;
|
|
774
|
+
|
|
775
|
+
const result = testTransform({ source: input });
|
|
776
|
+
|
|
777
|
+
// Should still add v2 prop but flag for manual review
|
|
778
|
+
expect(result).toContain('v2');
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
This comprehensive guide should help developers understand how to create effective, robust, and well-tested codemods for the Wise Design System.
|