Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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/dirty-mice-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris-migrator': minor
---

Add sass padding migration
1 change: 1 addition & 0 deletions polaris-migrator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"generate": "plop"
},
"dependencies": {
"@shopify/polaris-tokens": "^6.0.0",
"chalk": "^4.1.0",
"globby": "11.0.1",
"is-git-clean": "^1.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type {FileInfo, API, Options} from 'jscodeshift';
import postcss, {Plugin} from 'postcss';
import valueParser, {Node} from 'postcss-value-parser';
import {toPx} from '@shopify/polaris-tokens';

import {POLARIS_MIGRATOR_COMMENT} from '../../constants';
import {
NamespaceOptions,
namespace,
createIsSassFunction,
} from '../../utilities/sass';

// List of the props we want to run this migration on
const targetProps = [
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'padding-inline',
'padding-inline-start',
'padding-inline-end',
'padding-block',
'padding-block-start',
'padding-block-end',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'margin-inline',
'margin-inline-start',
'margin-inline-end',
'margin-block',
'margin-block-start',
'margin-block-end',
] as const;

type TargetProp = typeof targetProps[number];

const isTargetProp = (propName: unknown): propName is TargetProp =>
targetProps.includes(propName as TargetProp);

// Mapping of spacing tokens and their corresponding px values
const spacingTokensMap = {
'0': '--p-space-0',
'0px': '--p-space-0',
'1px': '--p-space-025',
'2px': '--p-space-05',
'4px': '--p-space-1',
'8px': '--p-space-2',
'12px': '--p-space-3',
'16px': '--p-space-4',
'20px': '--p-space-5',
'24px': '--p-space-6',
'32px': '--p-space-8',
'40px': '--p-space-10',
'48px': '--p-space-12',
'64px': '--p-space-16',
'80px': '--p-space-20',
'96px': '--p-space-24',
'112px': '--p-space-28',
'128px': '--p-space-32',
} as const;

type SpacingToken = keyof typeof spacingTokensMap;

const isSpacingTokenValue = (value: unknown): value is SpacingToken =>
Object.keys(spacingTokensMap).includes(value as SpacingToken);

/**
* All supported dimension units. These values are used to determine
* if a decl.value can be converted to pixels and mapped to a Polaris custom property.
* Note: The empty string is used to match the `0` value
*/
const supportedDimensionUnits = ['px', 'rem', ''];

const processed = Symbol('processed');

/**
* Exit early and stop traversing descendant nodes:
* https://www.npmjs.com/package/postcss-value-parser:~:text=Returning%20false%20in%20the%20callback%20will%20prevent%20traversal%20of%20descendent%20nodes
*/
const ExitAndStopTraversing = false;

interface PluginOptions extends Options, NamespaceOptions {}

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

return {
postcssPlugin: 'replace-sass-lengths',
Declaration(decl) {
// @ts-expect-error - Skip if processed so we don't process it again
if (decl[processed]) return;

const prop = decl.prop;
const parsedValue = valueParser(decl.value);

if (!isTargetProp(prop)) return;

parsedValue.walk((node) => {
if (isRemFunction(node)) {
const argDimension = valueParser.unit(node.nodes[0]?.value ?? '');

if (
argDimension &&
supportedDimensionUnits.includes(argDimension.unit)
) {
const argInPx = toPx(`${argDimension.number}${argDimension.unit}`);

if (!isSpacingTokenValue(argInPx)) return ExitAndStopTraversing;

const spacingToken = spacingTokensMap[argInPx];

node.value = 'var';
node.nodes = [
{
type: 'word',
value: spacingToken,
sourceIndex: node.nodes[0]?.sourceIndex ?? 0,
sourceEndIndex: spacingToken.length,
},
...node.nodes.slice(1),
];
}

return ExitAndStopTraversing;
} else if (node.type === 'word') {
const dimension = valueParser.unit(node.value);

if (dimension && supportedDimensionUnits.includes(dimension.unit)) {
const dimensionInPx = toPx(`${dimension.number}${dimension.unit}`);

if (!isSpacingTokenValue(dimensionInPx)) return;

node.value = `var(${spacingTokensMap[dimensionInPx]})`;
}
}
});

if (hasCalculation(parsedValue)) {
// Insert comment if the declaration value contains calculations
decl.before(postcss.comment({text: POLARIS_MIGRATOR_COMMENT}));
decl.before(
postcss.comment({text: `${decl.prop}: ${parsedValue.toString()};`}),
);
} else {
decl.value = parsedValue.toString();
}

// @ts-expect-error - Mark the declaration as processed
decl[processed] = true;
},
};
};

export default function replaceSassLengths(
fileInfo: FileInfo,
_: API,
options: Options,
) {
return postcss(plugin(options)).process(fileInfo.source, {
syntax: require('postcss-scss'),
}).css;
}

function hasCalculation(parsedValue: valueParser.ParsedValue): boolean {
let hasCalc = false;

parsedValue.walk((node) => {
if (isNumericOperator(node)) hasCalc = true;
});

return hasCalc;
}

function isNumericOperator(node: Node): boolean {
return (
node.value === '+' ||
node.value === '-' ||
node.value === '*' ||
node.value === '/' ||
node.value === '%'
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.my-class {
color: red;
padding-top: 13px;
padding: 0 1rem 2em 3px 4in;
padding-bottom: var(--p-space-0);
padding: rem(2px) 2px;
/* Expect to skip negative lengths */
padding: rem(-2px) -2px;
}

.another-class {
padding-bottom: 0.5rem;
padding-top: 1.25rem;
padding-right: calc(2px + 2px);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.my-class {
color: red;
padding-top: 13px;
padding: var(--p-space-0) var(--p-space-4) 2em 3px 4in;
padding-bottom: var(--p-space-0);
padding: var(--p-space-05) var(--p-space-05);
/* Expect to skip negative lengths */
padding: rem(-2px) -2px;
}

.another-class {
padding-bottom: var(--p-space-2);
padding-top: var(--p-space-5);
/* polaris-migrator: This is a complex expression that we can't automatically convert. Please check this manually. */
/* padding-right: calc(var(--p-space-05) + var(--p-space-05)); */
padding-right: calc(2px + 2px);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {check} from '../../../utilities/testUtils';

const migration = 'replace-sass-lengths';
const fixtures = ['replace-sass-lengths', 'with-namespace'];

for (const fixture of fixtures) {
check(__dirname, {
fixture,
migration,
extension: 'scss',
options: {
namespace: fixture.includes('with-namespace')
? 'legacy-polaris-v8'
: undefined,
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@use 'global-styles/legacy-polaris-v8';

.my-class {
color: red;
padding-top: 13px;
padding: 0 1rem 2em 3px 4in;
padding-bottom: var(--p-space-0);
padding: legacy-polaris-v8.rem(2px) 2px;
/* Expect to skip negative lengths */
padding: legacy-polaris-v8.rem(-2px) -2px;
}

.another-class {
padding-bottom: 0.5rem;
padding-top: 1.25rem;
padding-right: calc(2px + 2px);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@use 'global-styles/legacy-polaris-v8';

.my-class {
color: red;
padding-top: 13px;
padding: var(--p-space-0) var(--p-space-4) 2em 3px 4in;
padding-bottom: var(--p-space-0);
padding: var(--p-space-05) var(--p-space-05);
/* Expect to skip negative lengths */
padding: legacy-polaris-v8.rem(-2px) -2px;
}

.another-class {
padding-bottom: var(--p-space-2);
padding-top: var(--p-space-5);
/* polaris-migrator: This is a complex expression that we can't automatically convert. Please check this manually. */
/* padding-right: calc(var(--p-space-05) + var(--p-space-05)); */
padding-right: calc(2px + 2px);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type {FileInfo, API, Options} from 'jscodeshift';
import postcss, {Plugin} from 'postcss';
import valueParser, {Node, FunctionNode} from 'postcss-value-parser';
import valueParser, {Node} from 'postcss-value-parser';

import {POLARIS_MIGRATOR_COMMENT} from '../../constants';
import {
NamespaceOptions,
namespace,
createIsSassFunction,
} from '../../utilities/sass';

const spacingMap = {
none: '--p-space-0',
Expand Down Expand Up @@ -30,16 +35,11 @@ function isNumericOperator(node: Node): boolean {

const processed = Symbol('processed');

interface PluginOptions extends Options {
namespace?: string;
}
interface PluginOptions extends Options, NamespaceOptions {}

const plugin = (options: PluginOptions = {}): Plugin => {
const namespace = options?.namespace || '';
const functionName = namespace ? `${namespace}.spacing` : 'spacing';
const isSpacingFn = (node: Node): node is FunctionNode => {
return node.type === 'function' && node.value === functionName;
};
const spacingFunction = namespace('spacing', options);
const isSpacingFunction = createIsSassFunction(spacingFunction);

return {
postcssPlugin: 'ReplaceSassSpacing',
Expand All @@ -53,10 +53,10 @@ const plugin = (options: PluginOptions = {}): Plugin => {
let containsCalculation = false;

parsed.walk((node) => {
if (isSpacingFn(node)) containsSpacingFn = true;
if (isSpacingFunction(node)) containsSpacingFn = true;
if (isNumericOperator(node)) containsCalculation = true;

if (!isSpacingFn(node)) return;
if (!isSpacingFunction(node)) return;

const spacing = node.nodes[0]?.value ?? '';

Expand Down
31 changes: 31 additions & 0 deletions polaris-migrator/src/utilities/sass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type {Node, FunctionNode} from 'postcss-value-parser';

function getNamespace(options?: NamespaceOptions) {
return options?.namespace || '';
}

export interface NamespaceOptions {
namespace?: string;
}

export function namespace(name: string, options?: NamespaceOptions) {
const namespace = getNamespace(options);
return namespace ? `${namespace}.${name}` : name;
}

/**
* @example
* const spacingFunction = namespace('spacing', options);
* const remFunction = namespace('rem', options);
*
* const isSpacingFunction = createIsSassFunction(spacingFunction);
* const isRemFunction = createIsSassFunction(remFunction);
* const isCalcFunction = createIsSassFunction('calc');
*
* if (isSpacingFunction(node)) node // FunctionNode
*/
export function createIsSassFunction(name: string) {
return (node: Node): node is FunctionNode => {
return node.type === 'function' && node.value === name;
};
}