Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype a stylelint shim #7464

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Begone repeated boilerplate code! 🪄


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