diff --git a/.changeset/twenty-mayflies-fix.md b/.changeset/twenty-mayflies-fix.md new file mode 100644 index 00000000000..d81a964988f --- /dev/null +++ b/.changeset/twenty-mayflies-fix.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris-migrator': minor +--- + +Add Sass color function migration diff --git a/polaris-migrator/README.md b/polaris-migrator/README.md index 8138514ad30..9d115066cea 100644 --- a/polaris-migrator/README.md +++ b/polaris-migrator/README.md @@ -76,6 +76,21 @@ For projects that use the [`@use` rule](https://sass-lang.com/documentation/at-r npx @shopify/polaris-migrator --namespace="legacy-polaris-v8" ``` +### `replace-sass-color` + +Replace the legacy Sass `color()` function with the supported CSS custom property token equivalent (ex: `var(--p-surface)`). This will only replace a limited subset of mapped values. See the [color-maps.ts](https://github.com/Shopify/polaris/blob/main/polaris-migrator/src/migrations/replace-sass-color/color-maps.ts) for a full list of color mappings based on the CSS property. + +```diff +- color: color('ink'); +- background: color('white'); ++ color: var(--p-text); ++ background: var(--p-surface); +``` + +```sh +npx @shopify/polaris-migrator replace-sass-color +``` + ### `replace-sass-spacing` Replace the legacy Sass `spacing()` function with the supported CSS custom property token equivalent (ex: `var(--p-space-4)`). @@ -354,3 +369,4 @@ git reset $(grep -r -l "polaris-migrator:") - Common utilities: - [`jsx.ts`](https://github.com/Shopify/polaris/blob/main/polaris-migrator/src/utilities/jsx.ts) - [`imports.ts`](https://github.com/Shopify/polaris/blob/main/polaris-migrator/src/utilities/imports.ts) + 0 diff --git a/polaris-migrator/src/migrations/replace-sass-color/replace-sass-color.ts b/polaris-migrator/src/migrations/replace-sass-color/replace-sass-color.ts new file mode 100644 index 00000000000..f13c715ed19 --- /dev/null +++ b/polaris-migrator/src/migrations/replace-sass-color/replace-sass-color.ts @@ -0,0 +1,244 @@ +import type {FileInfo, API, Options} from 'jscodeshift'; +import postcss, {Plugin} from 'postcss'; +import valueParser from 'postcss-value-parser'; +import {colors as tokenColors, createVar} from '@shopify/polaris-tokens'; + +import { + NamespaceOptions, + namespace, + getFunctionArgs, + stripQuotes, + StopWalkingFunctionNodes, +} from '../../utilities/sass'; +import {isKeyOf} from '../../utilities/type-guards'; + +export default function replaceSassColors( + file: FileInfo, + _: API, + options: Options, +) { + return postcss(plugin(options)).process(file.source, { + syntax: require('postcss-scss'), + }).css; +} + +const processed = Symbol('processed'); + +interface PluginOptions extends Options, NamespaceOptions {} + +const plugin = (options: PluginOptions = {}): Plugin => { + const namespacedColor = namespace('color', options); + + return { + postcssPlugin: 'replace-sass-color', + Declaration(decl) { + // @ts-expect-error - Skip if processed so we don't process it again + if (decl[processed]) return; + + if (!isKeyOf(propertyMaps, decl.prop)) return; + const replacementMap = propertyMaps[decl.prop]; + const parsed = valueParser(decl.value); + + parsed.walk((node) => { + if (node.type !== 'function') return; + + if (node.value === 'rgba') { + return StopWalkingFunctionNodes; + } + + // 1. Remove color() fallbacks + if (node.value === 'var') { + const args = getFunctionArgs(node); + const polarisCustomPropertyIndex = args.findIndex((arg) => + polarisCustomPropertyRegEx.test(arg), + ); + const colorFnFallbackIndex = args.findIndex((arg) => + arg.startsWith(namespacedColor), + ); + + if (polarisCustomPropertyIndex < colorFnFallbackIndex) { + node.nodes = [node.nodes[0]]; + } + + return StopWalkingFunctionNodes; + } + + // 2. Replace `color()` with variable + if (node.value === namespacedColor) { + const colorFnArgs = getFunctionArgs(node).map(stripQuotes); + const hue = colorFnArgs[0] ?? ''; + const value = colorFnArgs[1] ?? 'base'; + const forBackground = colorFnArgs[2]; + + // Skip color() with for-background argument + if (forBackground) return; + + // Skip if we don't have a color for the hue and value + if ( + !( + isKeyOf(replacementMap, hue) && + isKeyOf(replacementMap[hue], value) + ) + ) + return; + + const colorCustomProperty: string = replacementMap[hue][value]; + + node.value = 'var'; + node.nodes = [ + { + type: 'word', + value: colorCustomProperty, + sourceIndex: node.nodes[0]?.sourceIndex ?? 0, + sourceEndIndex: colorCustomProperty.length, + }, + ]; + } + }); + + decl.value = parsed.toString(); + + // @ts-expect-error - Mark the declaration as processed + decl[processed] = true; + }, + }; +}; + +/* + * See the legacy Sass API file for the original color palette + * documentation/guides/legacy-polaris-v8-public-api.scss + */ + +const colorMap = { + blue: { + dark: '--p-interactive-hovered', + base: '--p-interactive', + }, + green: { + dark: '--p-text-success', + base: '--p-text-success', + }, + yellow: { + dark: '--p-text-warning', + base: '--p-text-warning', + }, + red: { + dark: '--p-text-critical', + base: '--p-text-critical', + }, + ink: { + base: '--p-text', + light: '--p-text-subdued', + lighter: '--p-text-subdued', + lightest: '--p-text-subdued', + }, + sky: { + dark: '--p-text-subdued-on-dark', + base: '--p-text-on-dark', + light: '--p-text-on-dark', + lighter: '--p-text-on-dark', + }, + black: { + base: '--p-text', + }, + white: { + base: '--p-text-on-dark', + }, +}; + +const backgroundColorMap = { + green: { + light: '--p-surface-success', + lighter: '--p-surface-success-subdued', + }, + yellow: { + light: '--p-surface-warning', + lighter: '--p-surface-warning-subdued', + }, + red: { + light: '--p-surface-critical', + lighter: '--p-surface-critical-subdued', + }, + ink: { + dark: '--p-surface-dark', + base: '--p-surface-neutral-subdued-dark', + }, + sky: { + base: '--p-surface-neutral', + light: '--p-surface-neutral-subdued', + lighter: '--p-surface-subdued', + }, + black: { + base: '--p-surface-dark', + }, + white: { + base: '--p-surface', + }, +}; + +const borderColorMap = { + green: { + dark: '--p-border-success', + base: '--p-border-success', + light: '--p-border-success-subdued', + lighter: '--p-border-success-subdued', + }, + yellow: { + dark: '--p-border-warning', + base: '--p-border-warning', + light: '--p-border-warning-disabled', + lighter: '--p-border-warning-subdued', + }, + red: { + dark: '--p-border-critical', + base: '--p-border-critical', + light: '--p-border-critical-subdued', + lighter: '--p-border-critical-subdued', + }, + ink: { + lightest: '--p-border', + }, + sky: { + light: '--p-border-subdued', + }, +}; + +const fillColorMap = { + green: { + dark: '--p-icon-success', + base: '--p-icon-success', + }, + yellow: { + dark: '--p-icon-warning', + base: '--p-icon-warning', + }, + red: { + dark: '--p-icon-critical', + base: '--p-icon-critical', + }, + ink: { + base: '--p-icon', + light: '--p-icon', + lighter: '--p-icon-subdued', + lightest: '--p-icon-disabled', + }, + black: { + base: '--p-icon', + }, + white: { + base: '--p-icon-on-dark', + }, +}; + +const propertyMaps = { + color: colorMap, + background: backgroundColorMap, + 'background-color': backgroundColorMap, + border: borderColorMap, + 'border-color': borderColorMap, + fill: fillColorMap, +}; + +const polarisCustomPropertyRegEx = new RegExp( + Object.keys(tokenColors).map(createVar).join('|'), +); diff --git a/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.input.scss b/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.input.scss new file mode 100644 index 00000000000..f658252cbdb --- /dev/null +++ b/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.input.scss @@ -0,0 +1,79 @@ +.my-class { + // color + color: color('blue'); + color: color('blue', 'dark'); + color: color('green'); + color: color('green', 'dark'); + color: color('yellow'); + color: color('yellow', 'dark'); + color: color('red'); + color: color('red', 'dark'); + color: color('ink'); + color: color('ink', 'light'); + color: color('ink', 'lighter'); + color: color('ink', 'lightest'); + color: color('sky'); + color: color('sky', 'dark'); + color: color('sky', 'light'); + color: color('sky', 'lighter'); + color: color('black'); + color: color('white'); + + // background + background-color: color('green', 'light'); + background-color: color('green', 'lighter'); + background-color: color('yellow', 'light'); + background-color: color('yellow', 'lighter'); + background-color: color('red', 'light'); + background-color: color('red', 'lighter'); + background-color: color('ink'); + background-color: color('ink', 'dark'); + background-color: color('sky'); + background-color: color('sky', 'light'); + background-color: color('sky', 'lighter'); + background-color: color('black'); + background-color: color('white'); + + // border + border-color: color('green', 'dark'); + border-color: color('green'); + border-color: color('green', 'light'); + border-color: color('green', 'lighter'); + border-color: color('yellow', 'dark'); + border-color: color('yellow'); + border-color: color('yellow', 'light'); + border-color: color('yellow', 'lighter'); + border-color: color('red', 'dark'); + border-color: color('red'); + border-color: color('red', 'light'); + border-color: color('red', 'lighter'); + border-color: color('ink', 'lightest'); + border-color: color('sky', 'light'); + + // fill + fill: color('green', 'dark'); + fill: color('green'); + fill: color('yellow', 'dark'); + fill: color('yellow'); + fill: color('red', 'dark'); + fill: color('red'); + fill: color('ink'); + fill: color('ink', 'light'); + fill: color('ink', 'lighter'); + fill: color('ink', 'lightest'); + fill: color('black'); + fill: color('white'); + + // Remove color() fallbacks + color: var(--p-text, color('white')); + + // Handle declarations with multiple values + border: var(--p-border-width-1) solid color('ink', 'lightest'); + background: border-box color('sky', 'light'); + + // Skip color() with a for-background argument + color: color('ink', 'lighter', #f2ece4); + + // Skip replacing color() within a function + background: rgba(color('black'), 0.5); +} diff --git a/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.output.scss b/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.output.scss new file mode 100644 index 00000000000..13ddb84cea0 --- /dev/null +++ b/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.output.scss @@ -0,0 +1,79 @@ +.my-class { + // color + color: var(--p-interactive); + color: var(--p-interactive-hovered); + color: var(--p-text-success); + color: var(--p-text-success); + color: var(--p-text-warning); + color: var(--p-text-warning); + color: var(--p-text-critical); + color: var(--p-text-critical); + color: var(--p-text); + color: var(--p-text-subdued); + color: var(--p-text-subdued); + color: var(--p-text-subdued); + color: var(--p-text-on-dark); + color: var(--p-text-subdued-on-dark); + color: var(--p-text-on-dark); + color: var(--p-text-on-dark); + color: var(--p-text); + color: var(--p-text-on-dark); + + // background + background-color: var(--p-surface-success); + background-color: var(--p-surface-success-subdued); + background-color: var(--p-surface-warning); + background-color: var(--p-surface-warning-subdued); + background-color: var(--p-surface-critical); + background-color: var(--p-surface-critical-subdued); + background-color: var(--p-surface-neutral-subdued-dark); + background-color: var(--p-surface-dark); + background-color: var(--p-surface-neutral); + background-color: var(--p-surface-neutral-subdued); + background-color: var(--p-surface-subdued); + background-color: var(--p-surface-dark); + background-color: var(--p-surface); + + // border + border-color: var(--p-border-success); + border-color: var(--p-border-success); + border-color: var(--p-border-success-subdued); + border-color: var(--p-border-success-subdued); + border-color: var(--p-border-warning); + border-color: var(--p-border-warning); + border-color: var(--p-border-warning-disabled); + border-color: var(--p-border-warning-subdued); + border-color: var(--p-border-critical); + border-color: var(--p-border-critical); + border-color: var(--p-border-critical-subdued); + border-color: var(--p-border-critical-subdued); + border-color: var(--p-border); + border-color: var(--p-border-subdued); + + // fill + fill: var(--p-icon-success); + fill: var(--p-icon-success); + fill: var(--p-icon-warning); + fill: var(--p-icon-warning); + fill: var(--p-icon-critical); + fill: var(--p-icon-critical); + fill: var(--p-icon); + fill: var(--p-icon); + fill: var(--p-icon-subdued); + fill: var(--p-icon-disabled); + fill: var(--p-icon); + fill: var(--p-icon-on-dark); + + // Remove color() fallbacks + color: var(--p-text); + + // Handle declarations with multiple values + border: var(--p-border-width-1) solid var(--p-border); + background: border-box var(--p-surface-neutral-subdued); + + // Skip color() with a for-background argument + color: color('ink', 'lighter', #f2ece4); + + // Skip replacing color() within a function + background: rgba(color('black'), 0.5); +} diff --git a/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.test.ts b/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.test.ts new file mode 100644 index 00000000000..06782fe95a6 --- /dev/null +++ b/polaris-migrator/src/migrations/replace-sass-color/tests/replace-sass-color.test.ts @@ -0,0 +1,17 @@ +import {check} from '../../../utilities/testUtils'; + +const migration = 'replace-sass-color'; +const fixtures = ['replace-sass-color', 'with-namespace']; + +for (const fixture of fixtures) { + check(__dirname, { + fixture, + migration, + extension: 'scss', + options: { + namespace: fixture.includes('with-namespace') + ? 'legacy-polaris-v8' + : undefined, + }, + }); +} diff --git a/polaris-migrator/src/migrations/replace-sass-color/tests/with-namespace.input.scss b/polaris-migrator/src/migrations/replace-sass-color/tests/with-namespace.input.scss new file mode 100644 index 00000000000..bed02c3ceeb --- /dev/null +++ b/polaris-migrator/src/migrations/replace-sass-color/tests/with-namespace.input.scss @@ -0,0 +1,8 @@ +@use 'global-styles/legacy-polaris-v8'; + +.my-class { + color: legacy-polaris-v8.color('ink'); + color: legacy-polaris-v8.color('ink', 'lighter'); + color: legacy-polaris-v8.color('ink', 'lighter', #f2ece4); + color: var(--p-surface, legacy-polaris-v8.color('white')); +} diff --git a/polaris-migrator/src/migrations/replace-sass-color/tests/with-namespace.output.scss b/polaris-migrator/src/migrations/replace-sass-color/tests/with-namespace.output.scss new file mode 100644 index 00000000000..e1b2d4eb9a8 --- /dev/null +++ b/polaris-migrator/src/migrations/replace-sass-color/tests/with-namespace.output.scss @@ -0,0 +1,8 @@ +@use 'global-styles/legacy-polaris-v8'; + +.my-class { + color: var(--p-text); + color: var(--p-text-subdued); + color: legacy-polaris-v8.color('ink', 'lighter', #f2ece4); + color: var(--p-surface); +} diff --git a/polaris-migrator/src/utilities/sass.ts b/polaris-migrator/src/utilities/sass.ts index 675e4d6ffc6..1e40eca0901 100644 --- a/polaris-migrator/src/utilities/sass.ts +++ b/polaris-migrator/src/utilities/sass.ts @@ -179,6 +179,16 @@ export function getFunctionArgs(node: FunctionNode): string[] { return args; } +/** + * Removes surrounding quotes from a string + * @example + * const string = '"hello"'; + * stripQuotes(string); // hello + */ +export function stripQuotes(string: string) { + return string.replace(/^['"]|['"]$/g, ''); +} + /** * All transformable dimension units. These values are used to determine * if a decl.value can be converted to pixels and mapped to a Polaris custom property.