No worries if there isn't a plugin that meets your needs. PostCSS lets you write your own.
In the previous chapter, we encountered the @value
syntax, specific to CSS modules. We'll familiarize a bit with PostCSS' plugin API by replacing @value
with var()
from native CSS.
First, create the utility file src/utils/css/postcss-plugins.ts
, where we can scaffold a PostCSS plugin. For simplicity, we'll disable type checks.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
export const PostcssRemoveAtValue = () => {
return {
postcssPlugin: 'postcss-remove-at-value',
prepare() {
return {};
},
};
};
Then, create the step remove-at-value
. Similarly to remove-css-nesting
, it is to use the custom plugin to update *.css
files.
Solution: src/steps/remove-at-value.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createFiles, findFiles } from '@codemod-utils/files';
import postcss from 'postcss';
import { Options } from '../types/index.js';
import { PostcssRemoveAtValue } from '../utils/css/postcss-plugins.js';
function updateFile(file: string): string {
const plugins = [PostcssRemoveAtValue()];
return postcss(plugins).process(file).css;
}
export function removeAtValue(options: Options): void {
const { projectRoot } = options;
const filePaths = findFiles('app/**/*.css', {
projectRoot,
});
const fileMap = new Map(
filePaths.map((filePath) => {
const oldFile = readFileSync(join(projectRoot, filePath), 'utf8');
const newFile = updateFile(oldFile);
return [filePath, newFile];
}),
);
createFiles(fileMap, options);
}
Currently, native CSS doesn't support using var()
in media queries. So expressions like @media desktop
(note, desktop
is some value) need to be changed.
/* Before */
@media desktop {
/* ... */
}
/* After */
@media only screen and (width >= 960px) {
/* ... */
}
Since @media
starts with an @
symbol, we use PostCSS' AtRule
to target these nodes.
Solution: src/utils/css/postcss-plugins.ts
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
+ const breakpoints = new Map([
+ ['mobile', 'only screen and (width < 480px)'],
+ ['tablet', 'only screen and (width >= 480px) and (width < 960px)'],
+ ['desktop', 'only screen and (width >= 960px)'],
+ ]);
+
export const PostcssRemoveAtValue = () => {
return {
postcssPlugin: 'postcss-remove-at-value',
prepare() {
- return {};
+ return {
+ AtRule(node) {
+ switch (node.name) {
+ case 'media': {
+ if (breakpoints.has(node.params)) {
+ node.params = breakpoints.get(node.params);
+ }
+
+ break;
+ }
+ }
+ },
+ };
},
};
};
Afterwards, run .update-test-fixtures.sh
. Media queries in the output file have been changed.
tests/fixtures/sample-project/output/app/components/ui/page.css
@value (
desktop,
spacing-400,
spacing-600
) from "my-design-tokens";
@value navigation-menu-height: 3rem;
.container {
display: grid;
grid-template-areas:
"header"
"body";
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: calc(100% - navigation-menu-height);
overflow-y: auto;
padding: spacing-600 spacing-400;
scrollbar-gutter: stable;
}
.container .header {
grid-area: header;
}
.container .body {
grid-area: body;
}
- @media desktop {
+ @media only screen and (width >= 960px) {
.container {
grid-template-areas:
"header body";
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
height: 100%
}
}
To replace values in expressions, such as spacing-600 spacing-400
and calc(100% - navigation-menu-height)
, with CSS variables, the codemod needs to know that spacing-400
, spacing-600
, and navigation-menu-height
are indeed things related to the problem that we want to solve.
Consider the import statements:
@value (
desktop,
spacing-400,
spacing-600
) from "my-design-tokens";
@value navigation-menu-height: 3rem;
Since @value
also starts with an @
symbol, we can extend the plugin's AtRule
to record all values. Let's create a Map
to keep track of the value name and how to change this name.
Solution: src/utils/css/postcss-plugins.ts
For brevity, I already added node.remove()
and OnceExit
. These, respectively, remove the @value
imports and log the map to help us understand what's going on.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
const breakpoints = new Map([
['mobile', 'only screen and (width < 480px)'],
['tablet', 'only screen and (width >= 480px) and (width < 960px)'],
['desktop', 'only screen and (width >= 960px)'],
]);
+ function recordValues(expression: string, valueMap: Map<string, string>) {
+ // ...
+ }
+
export const PostcssRemoveAtValue = () => {
return {
postcssPlugin: 'postcss-remove-at-value',
prepare() {
+ const valueMap = new Map<string, string>();
+
return {
AtRule(node) {
switch (node.name) {
case 'media': {
if (breakpoints.has(node.params)) {
node.params = breakpoints.get(node.params);
}
break;
}
+
+ case 'value': {
+ recordValues(node.params, valueMap);
+
+ node.remove();
+
+ break;
+ }
}
},
+
+ OnceExit() {
+ console.log(valueMap);
+ },
};
},
};
};
Next, we implement recordValues()
, which receives two inputs: expression
and valueMap
. It is to parse expression
and update valueMap
by recording the value name and the converted syntax.
Note
Because CSS modules allows a few different possibilities for @value
imports, the correct solution will be far from obvious. Give it a try before checking the solution below.
Solution: recordValues()
function recordValues(expression: string, valueMap: Map<string, string>) {
const isGlobal = expression.includes('"my-design-tokens"');
if (!isGlobal) {
const [oldSyntax, ...values] = expression.split(':');
valueMap.set(oldSyntax, values.join(':').trim());
return;
}
const matches = expression.match(/\(([^)]+)\)/);
if (!matches) {
return;
}
matches[1].split(',').forEach((str) => {
const oldSyntax = str.trim();
if (oldSyntax === '') {
return;
}
const isRenamed = oldSyntax.includes(' as ');
if (!isRenamed) {
valueMap.set(oldSyntax, `var(--${oldSyntax})`);
return;
}
const [originalName, newName] = oldSyntax.split(' as ');
valueMap.set(newName.trim(), `var(--${originalName.trim()})`);
});
}
By running the test
command, we can see how value names will be converted.
Map(4) {
'desktop' => 'var(--desktop)',
'spacing-400' => 'var(--spacing-400)',
'spacing-600' => 'var(--spacing-600)',
'navigation-menu-height' => '3rem'
}
Finally, we replace the values with the corresponding CSS variables or literals.
Solution: PostcssRemoveAtValue
It's hard to update dynamic expressions in calc()
. For simplicity, we'll warn the user and ask them to update the code.
export const PostcssRemoveAtValue = () => {
return {
postcssPlugin: 'postcss-remove-at-value',
prepare() {
+ const errorMessages: string[] = [];
const valueMap = new Map<string, string>();
return {
AtRule(node) {
switch (node.name) {
case 'media': {
if (breakpoints.has(node.params)) {
node.params = breakpoints.get(node.params);
+ } else if (tokenValues.has(node.params)) {
+ node.params = tokenValues.get(node.params);
}
break;
}
case 'value': {
recordValues(node.params, valueMap);
node.remove();
break;
}
}
},
+ Declaration(node) {
+ const matches = node.value.match(/calc\(([^)]+)\)/);
+
+ // Unable to handle calc() expressions
+ if (matches) {
+ const warn = Array.from(valueMap.keys()).some((key) => {
+ return matches[1].includes(key);
+ });
+
+ if (warn) {
+ errorMessages.push(
+ `Couldn't update \`${node.prop}\`, originally on line ${node.source.start.line} (approx.)`,
+ );
+ }
+
+ return;
+ }
+
+ const values = node.value.split(' ');
+
+ const newValues = values.map((value) => {
+ return valueMap.has(value) ? valueMap.get(value) : value;
+ });
+
+ node.value = newValues.join(' ');
+ },
+
OnceExit() {
- console.log(valueMap);
+ console.log(errorMessages.join('\n'));
},
};
},
};
};
Run .update-test-fixtures.sh
once more. You'll see that @value
imports and values have been removed, wherever possible.
tests/fixtures/sample-project/output/app/components/ui/page.css
- @value (
- desktop,
- spacing-400,
- spacing-600
- ) from "my-design-tokens";
-
- @value navigation-menu-height: 3rem;
-
.container {
display: grid;
grid-template-areas:
"header"
"body";
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: calc(100% - navigation-menu-height);
overflow-y: auto;
- padding: spacing-600 spacing-400;
+ padding: var(--spacing-600) var(--spacing-400);
scrollbar-gutter: stable;
}
.container .header {
grid-area: header;
}
.container .body {
grid-area: body;
}
@media only screen and (width >= 960px) {
.container {
grid-template-areas:
"header body";
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
height: 100%
}
}