Skip to content

Commit

Permalink
createSassMigrator method to wrap common functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
jesstelford committed Oct 27, 2022
1 parent 77d75f9 commit e9271e4
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 157 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 `createSassMigrator` utility to stash common logic, starting with only parsing each event once.
72 changes: 41 additions & 31 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,46 @@ 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 [PostCSS Plugin API](https://github.com/postcss/postcss/blob/main/docs/writing-a-plugin.md) with one main difference: events are only executed once.

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,
StopWalkingFunctionNodes,
createSassMigrator,
} from '../../utilities/sass';

// options can be passed in from cli / config.
export default createSassMigrator('replace-sass-function', (_, options, context) => {
return (root) => {
root.walkDecls((decl) => {
const parsedValue = valueParser(decl.value);

parsedValue.walk((node) => {
if (isSassFunction('hello', node)) {
node.value = 'world';
return StopWalkingFunctionNodes;
}
});

if (context.fix) {
decl.value = parsedValue.toString();
}
});

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

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

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 +325,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,5 +1,3 @@
import type {FileInfo, API, Options} from 'jscodeshift';
import postcss, {Plugin} from 'postcss';
import valueParser from 'postcss-value-parser';

import {POLARIS_MIGRATOR_COMMENT} from '../../constants';
Expand All @@ -10,122 +8,107 @@ import {
isSassFunction,
isTransformableLength,
namespace,
NamespaceOptions,
toTransformablePx,
StopWalkingFunctionNodes,
createSassMigrator,
} 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;
}

const processed = Symbol('processed');

interface PluginOptions extends Options, NamespaceOptions {}

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

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 (!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
//
export default createSassMigrator(
'replace-sass-space',
(_, options, context) => {
const namespacedRem = namespace('rem', options);

return (root) => {
root.walkDecls((decl) => {
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 if (context.fix) {
decl.value = parsedValue.toString();
}

//
// Handlers
//

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

function handleSpaceProps() {
parsedValue.walk((node) => {
if (isNumericOperator(node)) {
hasNumericOperator = true;
return;
}
if (node.type === 'word') {
if (globalValues.has(node.value)) return;

if (node.type === 'word') {
if (globalValues.has(node.value)) return;
const dimension = valueParser.unit(node.value);

const dimension = valueParser.unit(node.value);
if (!isTransformableLength(dimension)) return;

if (!isTransformableLength(dimension)) return;
targets.push({replaced: false});

targets.push({replaced: false});
const valueInPx = toTransformablePx(node.value);

const valueInPx = toTransformablePx(node.value);
if (!isKeyOf(spaceMap, valueInPx)) return;

if (!isKeyOf(spaceMap, valueInPx)) return;
targets[targets.length - 1]!.replaced = true;

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

node.value = `var(${spaceMap[valueInPx]})`;
return;
}
if (node.type === 'function') {
if (isSassFunction(namespacedRem, node)) {
targets.push({replaced: false});

if (node.type === 'function') {
if (isSassFunction(namespacedRem, node)) {
targets.push({replaced: false});
const args = getFunctionArgs(node);

const args = getFunctionArgs(node);
if (args.length !== 1) return;

if (args.length !== 1) return;
const valueInPx = toTransformablePx(args[0]);

const valueInPx = toTransformablePx(args[0]);
if (!isKeyOf(spaceMap, valueInPx)) return;

if (!isKeyOf(spaceMap, valueInPx)) return;
targets[targets.length - 1]!.replaced = true;

targets[targets.length - 1]!.replaced = true;
node.value = 'var';
node.nodes = [
{
type: 'word',
value: spaceMap[valueInPx],
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
sourceEndIndex: spaceMap[valueInPx].length,
},
];
}

node.value = 'var';
node.nodes = [
{
type: 'word',
value: spaceMap[valueInPx],
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
sourceEndIndex: spaceMap[valueInPx].length,
},
];
return StopWalkingFunctionNodes;
}

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

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

Expand Down
52 changes: 51 additions & 1 deletion polaris-migrator/src/utilities/sass.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import postcss from 'postcss';
import type {FileInfo, API, Options} from 'jscodeshift';
import postcss, {Root, Result, Plugin} from 'postcss';
import valueParser, {
Node,
ParsedValue,
Expand Down Expand Up @@ -251,3 +252,52 @@ export function createInlineComment(text: string, options?: {prose?: boolean}) {

return comment;
}

interface PluginOptions extends Options, NamespaceOptions {}

interface PluginContext {
fix: boolean;
}
export type PolarisMigrator = (
primaryOption: true,
secondaryOptions: PluginOptions,
context: PluginContext,
) => (root: Root, result: Result) => void;

export function createSassMigrator(name: string, ruleFn: PolarisMigrator) {
return (fileInfo: FileInfo, _: API, options: Options) => {
const plugin: Plugin = {
postcssPlugin: name,
// PostCSS will rewalk the AST every time a declaration/rule/etc is
// mutated by a plugin. This can be useful in some cases, but in ours we
// only want single-pass behaviour.
//
// This can be avoided in 2 ways:
//
// 1) Flagging each declaration as we pass it, then skipping it on
// subsequent passes.
// 2) Using postcss's Once() plugin callback.
//
// We're going with the Once() callback as it's idomatic PostCSS.
Once(root, {result}) {
// NOTE: For fullest compatibility with stylelint, we initialise the
// rule here _inside_ the postcss Once function so multiple passes can
// be performed without rules accidentally retaining scoped variables,
// etc.
ruleFn(
// Normally, this comes from stylelint config, but for this shim we
// just hard-code it, and instead rely on the "seconary" options
// object for passing through the jscodeshift options.
true,
options,
// Also normally comes from styelint via the cli `--fix` flag.
{fix: true},
)(root, result);
},
};

return postcss(plugin).process(fileInfo.source, {
syntax: require('postcss-scss'),
}).css;
};
}
Loading

0 comments on commit e9271e4

Please sign in to comment.