Skip to content

Commit 72cb3c3

Browse files
committed
Prototype a stylelint shim
1 parent 60191f3 commit 72cb3c3

File tree

8 files changed

+656
-184
lines changed

8 files changed

+656
-184
lines changed

.changeset/silent-spiders-poke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/polaris-migrator': minor
3+
---
4+
5+
Add stylelint API shim for forward migration compatibility

polaris-migrator/README.md

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,18 @@ Be aware that this may also create additional code changes in your codebase, we
235235
npx @shopify/polaris-migrator replace-sass-spacing <path>
236236
```
237237

238-
## Creating a migration
238+
## Creating Migrations
239239

240-
### Setup
240+
Sometimes referred to as "codemods", migrations are JavaScript functions which modify some code from one form to another (eg; to move between breaking versions of `@shopify/polaris`). ASTs (Abstract Syntax Trees) are used to "walk" through the code in discreet, strongly typed steps, called "nodes". All changes made to nodes (and thus the AST) are then written out as the new/"migrated" version of the code.
241241

242-
Run `yarn new-migration` to generate a new migration from a template.
242+
`polaris-migrator` supports two types of migrations:
243+
244+
- SASS Migrations
245+
- Typescript Migrations
246+
247+
### Creating a SASS migration
248+
249+
Run `yarn new-migration` to generate a new migration from the `sass-migration` template:
243250

244251
```sh
245252
❯ yarn new-migration
@@ -250,7 +257,7 @@ $ plop
250257
typescript-migration
251258
```
252259

253-
We will use the `sass-migration` and call our migration `replace-sass-function` for this example. Provide the name of your migration:
260+
Next, provide the name of your migration. For example; `replace-sass-function`:
254261

255262
```sh
256263
? [PLOP] Please choose a generator. sass-migration
@@ -269,45 +276,50 @@ migrations
269276
└── replace-sass-function.test.ts
270277
```
271278

272-
### Writing migration function
279+
#### The SASS migration function
273280

274-
A migration is simply a javascript function which serves as the entry-point for your codemod. The `replace-sass-function.ts` file defines a "migration" function. This function is named the same as the provided migration name, `replace-sass-function`, and is the default export of the file.
281+
Each migrator has a default export adhering to the [Stylelint Rule API](https://github.com/postcss/postcss/blob/main/docs/writing-a-plugin.md). A PostCSS AST is passed as the `root` and can be mutated inline, or emit warning/error reports.
275282

276-
Some example code has been provided for each template. You can make any migration code adjustments in the migration function. For Sass migrations, a [PostCSS plugin](https://github.com/postcss/postcss/blob/main/docs/writing-a-plugin.md) is used to parse and transform the source code provided by the [jscodeshift](https://github.com/facebook/jscodeshift).
283+
Continuing the example, here is what the migration may look like if our goal is to replace the Sass function `hello()` with `world()`.
277284

278285
```ts
279286
// polaris-migrator/src/migrations/replace-sass-function/replace-sass-function.ts
280-
281-
import type {FileInfo} from 'jscodeshift';
282-
import postcss, {Plugin} from 'postcss';
283-
import valueParser from 'postcss-value-parser';
284-
285-
const plugin = (): Plugin => ({
286-
postcssPlugin: 'replace-sass-function',
287-
Declaration(decl) {
288-
// const prop = decl.prop;
289-
const parsedValue = valueParser(decl.value);
290-
291-
parsedValue.walk((node) => {
292-
if (!(node.type === 'function' && node.value === 'hello')) return;
293-
294-
node.value = 'world';
287+
import {
288+
isSassFunction,
289+
createStylelintRule,
290+
StopWalkingFunctionNodes,
291+
} from '../../utilities/sass';
292+
import type {PolarisMigrator} from '../../utilities/sass';
293+
294+
const replaceHelloWorld: PolarisMigrator = (_, {methods}, context) => {
295+
return (root) => {
296+
methods.walkDecls(root, (decl, parsedValue) => {
297+
parsedValue.walk((node) => {
298+
if (isSassFunction('hello', node)) {
299+
if (context.fix) {
300+
node.value = 'world';
301+
} else {
302+
methods.report({
303+
node: decl,
304+
severity: 'error',
305+
message:
306+
'Method hello() is no longer supported. Please migrate to world().',
307+
});
308+
}
309+
310+
return StopWalkingFunctionNodes;
311+
}
312+
});
295313
});
314+
};
315+
};
296316

297-
decl.value = parsedValue.toString();
298-
},
299-
});
300-
301-
export default function replaceSassFunction(fileInfo: FileInfo) {
302-
return postcss(plugin()).process(fileInfo.source, {
303-
syntax: require('postcss-scss'),
304-
}).css;
305-
}
317+
export default createStylelintRule('replace-hello-world', replaceHelloWorld);
306318
```
307319

308-
This example migration will replace the Sass function `hello()` with `world()`.
320+
A more complete example can be seen in [`replace-spacing-lengths.ts`](https://github.com/Shopify/polaris/blob/main/polaris-migrator/src/migrations/replace-spacing-lengths/replace-spacing-lengths.ts).
309321

310-
### Testing
322+
#### Testing
311323

312324
The template will also generate starting test files you can use to test your migration. In your migrations `tests` folder, you can see 3 files:
313325

@@ -317,6 +329,8 @@ The template will also generate starting test files you can use to test your mig
317329

318330
The main test file will load the input/output fixtures to test your migration against. You can configure additional fixtures and test migration options (see the `replace-sass-spacing.test.ts` as an example).
319331

332+
## Running Migrations
333+
320334
Run tests locally from workspace root by filtering to the migrations package:
321335

322336
```sh

polaris-migrator/src/migrations/replace-spacing-lengths/replace-spacing-lengths.ts

Lines changed: 97 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,127 @@
1-
import type {FileInfo, API, Options} from 'jscodeshift';
2-
import postcss, {Plugin} from 'postcss';
31
import valueParser from 'postcss-value-parser';
42

5-
import {POLARIS_MIGRATOR_COMMENT} from '../../constants';
63
import {
7-
createInlineComment,
84
getFunctionArgs,
95
isNumericOperator,
106
isSassFunction,
117
isTransformableLength,
128
namespace,
13-
NamespaceOptions,
149
toTransformablePx,
1510
StopWalkingFunctionNodes,
11+
createStylelintRule,
1612
} from '../../utilities/sass';
1713
import {isKeyOf} from '../../utilities/type-guards';
1814

19-
export default function replaceSpacingLengths(
20-
fileInfo: FileInfo,
21-
_: API,
22-
options: Options,
23-
) {
24-
return postcss(plugin(options)).process(fileInfo.source, {
25-
syntax: require('postcss-scss'),
26-
}).css;
27-
}
15+
export default createStylelintRule(
16+
'replace-sass-space',
17+
(_, {methods, options}, context) => {
18+
const namespacedRem = namespace('rem', options);
2819

29-
const processed = Symbol('processed');
20+
return (root) => {
21+
methods.walkDecls(root, (decl, parsedValue) => {
22+
if (!spaceProps.has(decl.prop)) return;
3023

31-
interface PluginOptions extends Options, NamespaceOptions {}
24+
let hasNumericOperator = false;
3225

33-
const plugin = (options: PluginOptions = {}): Plugin => {
34-
const namespacedRem = namespace('rem', options);
26+
handleSpaceProps();
3527

36-
return {
37-
postcssPlugin: 'replace-sass-space',
38-
Declaration(decl) {
39-
// @ts-expect-error - Skip if processed so we don't process it again
40-
if (decl[processed]) return;
28+
if (hasNumericOperator) {
29+
methods.report({
30+
node: decl,
31+
severity: 'warning',
32+
message: 'Numeric operator detected.',
33+
});
34+
}
4135

42-
if (!spaceProps.has(decl.prop)) return;
43-
44-
/**
45-
* A collection of transformable values to migrate (e.g. decl lengths, functions, etc.)
46-
*
47-
* Note: This is evaluated at the end of each visitor execution to determine whether
48-
* or not to replace the declaration or insert a comment.
49-
*/
50-
const targets: {replaced: boolean}[] = [];
51-
let hasNumericOperator = false;
52-
const parsedValue = valueParser(decl.value);
53-
54-
handleSpaceProps();
55-
56-
if (targets.some(({replaced}) => !replaced || hasNumericOperator)) {
57-
decl.before(
58-
createInlineComment(POLARIS_MIGRATOR_COMMENT, {prose: true}),
59-
);
60-
decl.before(
61-
createInlineComment(`${decl.prop}: ${parsedValue.toString()};`),
62-
);
63-
} else {
64-
decl.value = parsedValue.toString();
65-
}
66-
67-
//
68-
// Handlers
69-
//
70-
71-
function handleSpaceProps() {
72-
parsedValue.walk((node) => {
73-
if (isNumericOperator(node)) {
74-
hasNumericOperator = true;
75-
return;
76-
}
77-
78-
if (node.type === 'word') {
79-
if (globalValues.has(node.value)) return;
80-
81-
const dimension = valueParser.unit(node.value);
82-
83-
if (!isTransformableLength(dimension)) return;
84-
85-
targets.push({replaced: false});
86-
87-
const valueInPx = toTransformablePx(node.value);
88-
89-
if (!isKeyOf(spaceMap, valueInPx)) return;
90-
91-
targets[targets.length - 1]!.replaced = true;
92-
93-
node.value = `var(${spaceMap[valueInPx]})`;
94-
return;
95-
}
36+
function handleSpaceProps() {
37+
parsedValue.walk((node) => {
38+
if (isNumericOperator(node)) {
39+
hasNumericOperator = true;
40+
return;
41+
}
9642

97-
if (node.type === 'function') {
98-
if (isSassFunction(namespacedRem, node)) {
99-
targets.push({replaced: false});
43+
if (node.type === 'word') {
44+
if (globalValues.has(node.value)) return;
10045

101-
const args = getFunctionArgs(node);
46+
const dimension = valueParser.unit(node.value);
10247

103-
if (args.length !== 1) return;
48+
if (!isTransformableLength(dimension)) return;
10449

105-
const valueInPx = toTransformablePx(args[0]);
50+
const valueInPx = toTransformablePx(node.value);
10651

107-
if (!isKeyOf(spaceMap, valueInPx)) return;
52+
if (!isKeyOf(spaceMap, valueInPx)) {
53+
methods.report({
54+
node: decl,
55+
severity: 'error',
56+
message: `Non-tokenizable value '${node.value}'`,
57+
});
58+
return;
59+
}
10860

109-
targets[targets.length - 1]!.replaced = true;
61+
if (context.fix) {
62+
node.value = `var(${spaceMap[valueInPx]})`;
63+
return;
64+
}
11065

111-
node.value = 'var';
112-
node.nodes = [
113-
{
114-
type: 'word',
115-
value: spaceMap[valueInPx],
116-
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
117-
sourceEndIndex: spaceMap[valueInPx].length,
118-
},
119-
];
66+
methods.report({
67+
node: decl,
68+
severity: 'error',
69+
message: `Prefer var(${spaceMap[valueInPx]}) Polaris token.`,
70+
});
71+
return;
12072
}
12173

122-
return StopWalkingFunctionNodes;
123-
}
124-
});
125-
}
126-
},
127-
};
128-
};
74+
if (node.type === 'function') {
75+
if (isSassFunction(namespacedRem, node)) {
76+
const args = getFunctionArgs(node);
77+
78+
if (args.length !== 1) {
79+
methods.report({
80+
node: decl,
81+
severity: 'error',
82+
message: `Expected 1 argument, got ${args.length}`,
83+
});
84+
return;
85+
}
86+
87+
const valueInPx = toTransformablePx(args[0]);
88+
89+
if (!isKeyOf(spaceMap, valueInPx)) {
90+
methods.report({
91+
node: decl,
92+
severity: 'error',
93+
message: `Non-tokenizable value '${args[0].trim()}'`,
94+
});
95+
return;
96+
}
97+
98+
if (context.fix) {
99+
node.value = 'var';
100+
node.nodes = [
101+
{
102+
type: 'word',
103+
value: spaceMap[valueInPx],
104+
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
105+
sourceEndIndex: spaceMap[valueInPx].length,
106+
},
107+
];
108+
return;
109+
}
110+
methods.report({
111+
node: decl,
112+
severity: 'error',
113+
message: `Prefer var(${spaceMap[valueInPx]}) Polaris token.`,
114+
});
115+
}
116+
117+
return StopWalkingFunctionNodes;
118+
}
119+
});
120+
}
121+
});
122+
};
123+
},
124+
);
129125

130126
const globalValues = new Set(['inherit', 'initial', 'unset']);
131127

0 commit comments

Comments
 (0)