Skip to content

Commit

Permalink
Prototype a stylelint shim
Browse files Browse the repository at this point in the history
  • Loading branch information
jesstelford committed Oct 25, 2022
1 parent 60191f3 commit 72cb3c3
Show file tree
Hide file tree
Showing 8 changed files with 656 additions and 184 deletions.
5 changes: 5 additions & 0 deletions .changeset/silent-spiders-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris-migrator': minor
---

Add stylelint API shim for forward migration compatibility
80 changes: 47 additions & 33 deletions polaris-migrator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,18 @@ Be aware that this may also create additional code changes in your codebase, we
npx @shopify/polaris-migrator replace-sass-spacing <path>
```

## Creating a migration
## Creating Migrations

### Setup
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.

Run `yarn new-migration` to generate a new migration from a template.
`polaris-migrator` supports two types of migrations:

- SASS Migrations
- Typescript Migrations

### Creating a SASS migration

Run `yarn new-migration` to generate a new migration from the `sass-migration` template:

```sh
❯ yarn new-migration
Expand All @@ -250,7 +257,7 @@ $ plop
typescript-migration
```

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

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

### Writing migration function
#### The SASS migration function

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.
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.

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).
Continuing the example, here is what the migration may look like if our goal is to replace the Sass function `hello()` with `world()`.

```ts
// polaris-migrator/src/migrations/replace-sass-function/replace-sass-function.ts

import type {FileInfo} from 'jscodeshift';
import postcss, {Plugin} from 'postcss';
import valueParser from 'postcss-value-parser';

const plugin = (): Plugin => ({
postcssPlugin: 'replace-sass-function',
Declaration(decl) {
// const prop = decl.prop;
const parsedValue = valueParser(decl.value);

parsedValue.walk((node) => {
if (!(node.type === 'function' && node.value === 'hello')) return;

node.value = 'world';
import {
isSassFunction,
createStylelintRule,
StopWalkingFunctionNodes,
} from '../../utilities/sass';
import type {PolarisMigrator} from '../../utilities/sass';

const replaceHelloWorld: PolarisMigrator = (_, {methods}, context) => {
return (root) => {
methods.walkDecls(root, (decl, parsedValue) => {
parsedValue.walk((node) => {
if (isSassFunction('hello', node)) {
if (context.fix) {
node.value = 'world';
} else {
methods.report({
node: decl,
severity: 'error',
message:
'Method hello() is no longer supported. Please migrate to world().',
});
}

return StopWalkingFunctionNodes;
}
});
});
};
};

decl.value = parsedValue.toString();
},
});

export default function replaceSassFunction(fileInfo: FileInfo) {
return postcss(plugin()).process(fileInfo.source, {
syntax: require('postcss-scss'),
}).css;
}
export default createStylelintRule('replace-hello-world', replaceHelloWorld);
```

This example migration will replace the Sass function `hello()` with `world()`.
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).

### Testing
#### Testing

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:

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

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).

## Running Migrations

Run tests locally from workspace root by filtering to the migrations package:

```sh
Expand Down
Original file line number Diff line number Diff line change
@@ -1,131 +1,127 @@
import type {FileInfo, API, Options} from 'jscodeshift';
import postcss, {Plugin} from 'postcss';
import valueParser from 'postcss-value-parser';

import {POLARIS_MIGRATOR_COMMENT} from '../../constants';
import {
createInlineComment,
getFunctionArgs,
isNumericOperator,
isSassFunction,
isTransformableLength,
namespace,
NamespaceOptions,
toTransformablePx,
StopWalkingFunctionNodes,
createStylelintRule,
} from '../../utilities/sass';
import {isKeyOf} from '../../utilities/type-guards';

export default function replaceSpacingLengths(
fileInfo: FileInfo,
_: API,
options: Options,
) {
return postcss(plugin(options)).process(fileInfo.source, {
syntax: require('postcss-scss'),
}).css;
}
export default createStylelintRule(
'replace-sass-space',
(_, {methods, options}, context) => {
const namespacedRem = namespace('rem', options);

const processed = Symbol('processed');
return (root) => {
methods.walkDecls(root, (decl, parsedValue) => {
if (!spaceProps.has(decl.prop)) return;

interface PluginOptions extends Options, NamespaceOptions {}
let hasNumericOperator = false;

const plugin = (options: PluginOptions = {}): Plugin => {
const namespacedRem = namespace('rem', options);
handleSpaceProps();

return {
postcssPlugin: 'replace-sass-space',
Declaration(decl) {
// @ts-expect-error - Skip if processed so we don't process it again
if (decl[processed]) return;
if (hasNumericOperator) {
methods.report({
node: decl,
severity: 'warning',
message: 'Numeric operator detected.',
});
}

if (!spaceProps.has(decl.prop)) return;

/**
* A collection of transformable values to migrate (e.g. decl lengths, functions, etc.)
*
* Note: This is evaluated at the end of each visitor execution to determine whether
* or not to replace the declaration or insert a comment.
*/
const targets: {replaced: boolean}[] = [];
let hasNumericOperator = false;
const parsedValue = valueParser(decl.value);

handleSpaceProps();

if (targets.some(({replaced}) => !replaced || hasNumericOperator)) {
decl.before(
createInlineComment(POLARIS_MIGRATOR_COMMENT, {prose: true}),
);
decl.before(
createInlineComment(`${decl.prop}: ${parsedValue.toString()};`),
);
} else {
decl.value = parsedValue.toString();
}

//
// Handlers
//

function handleSpaceProps() {
parsedValue.walk((node) => {
if (isNumericOperator(node)) {
hasNumericOperator = true;
return;
}

if (node.type === 'word') {
if (globalValues.has(node.value)) return;

const dimension = valueParser.unit(node.value);

if (!isTransformableLength(dimension)) return;

targets.push({replaced: false});

const valueInPx = toTransformablePx(node.value);

if (!isKeyOf(spaceMap, valueInPx)) return;

targets[targets.length - 1]!.replaced = true;

node.value = `var(${spaceMap[valueInPx]})`;
return;
}
function handleSpaceProps() {
parsedValue.walk((node) => {
if (isNumericOperator(node)) {
hasNumericOperator = true;
return;
}

if (node.type === 'function') {
if (isSassFunction(namespacedRem, node)) {
targets.push({replaced: false});
if (node.type === 'word') {
if (globalValues.has(node.value)) return;

const args = getFunctionArgs(node);
const dimension = valueParser.unit(node.value);

if (args.length !== 1) return;
if (!isTransformableLength(dimension)) return;

const valueInPx = toTransformablePx(args[0]);
const valueInPx = toTransformablePx(node.value);

if (!isKeyOf(spaceMap, valueInPx)) return;
if (!isKeyOf(spaceMap, valueInPx)) {
methods.report({
node: decl,
severity: 'error',
message: `Non-tokenizable value '${node.value}'`,
});
return;
}

targets[targets.length - 1]!.replaced = true;
if (context.fix) {
node.value = `var(${spaceMap[valueInPx]})`;
return;
}

node.value = 'var';
node.nodes = [
{
type: 'word',
value: spaceMap[valueInPx],
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
sourceEndIndex: spaceMap[valueInPx].length,
},
];
methods.report({
node: decl,
severity: 'error',
message: `Prefer var(${spaceMap[valueInPx]}) Polaris token.`,
});
return;
}

return StopWalkingFunctionNodes;
}
});
}
},
};
};
if (node.type === 'function') {
if (isSassFunction(namespacedRem, node)) {
const args = getFunctionArgs(node);

if (args.length !== 1) {
methods.report({
node: decl,
severity: 'error',
message: `Expected 1 argument, got ${args.length}`,
});
return;
}

const valueInPx = toTransformablePx(args[0]);

if (!isKeyOf(spaceMap, valueInPx)) {
methods.report({
node: decl,
severity: 'error',
message: `Non-tokenizable value '${args[0].trim()}'`,
});
return;
}

if (context.fix) {
node.value = 'var';
node.nodes = [
{
type: 'word',
value: spaceMap[valueInPx],
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
sourceEndIndex: spaceMap[valueInPx].length,
},
];
return;
}
methods.report({
node: decl,
severity: 'error',
message: `Prefer var(${spaceMap[valueInPx]}) Polaris token.`,
});
}

return StopWalkingFunctionNodes;
}
});
}
});
};
},
);

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

Expand Down
Loading

0 comments on commit 72cb3c3

Please sign in to comment.