PostCSS is a powerful tool for transforming stylesheets. If it’s unfamiliar to you, PostCSS turns stylesheets into readable objects in JavaScript called ASTs (Abstract Syntax Trees) and then turns those ASTs back into stylesheets, completing the circle.
Nothing changes. What’s the fun in that? The greatness of PostCSS is found in PostCSS plugins.
PostCSS plugins read and modify the AST before it’s turned back into a stylesheet. There are plugins to automatically add vendor prefixes to properties and selectors, or interpret Sass-like variables, mixins, and loops, or down-mix future and experimental features to CSS. PostCSS plugins can even generate entirely new documents based on the CSS, like styleguides.
Writing a PostCSS plugin is remarkably simple, thanks to its solid API. Still, one of the first challenges aspiring plugin authors face is importing other files. In other words, replacing links in a stylesheet with the contents of those links. This is where something like @import another-file.css
is replaced with the actual contents of the another-file.css
file.
So, we’re going to write an import plugin together right now. Let’s begin.
First, we’ll create an empty PostCSS plugin. To make this tutorial clearer, we’ll label every object in our code.
var pluginName = 'postcss-import-barebones';
var pluginInit = function () {
var transform = function (css) {
// do something with css
};
return transform;
};
module.exports = postcss.plugin(pluginName, pluginInit);
Congratulations! We’ve created an empty plugin. Now, there are 3 things to notice so far. The first is pluginName
, which (as the name suggests) is the name of our plugin; it’s there to help us debug issues moving forward. Next, pluginInit
is the function that runs when our plugin first starts up, before our stylesheet is processed. Finally, transform
is the function that actually transforms the AST.
So, we want our transform
function to find "import" at-rules (e.g. @import another-file.css
) and replace them with the contents of the link they referenced (e.g. the contents of another-file.css
).
var transform = function (css) {
// For each `import` at-rule
css.walkAtRules('import', function (atRule) {
// Get the import link; e.g. `another-file.css` within `@import another-file.css`;
var link = atRule.params;
// Replace the at-rule with the contents of its link
transformImport(atRule, link);
});
};
var transformImport = function (atRule, link) {};
In PostCSS, working with CSS is a lot like working with DOM elements in jQuery. We used the .walkAtRules('import', ...)
function to loop over any "import" at-rules in our stylesheet, and we used the params
property of that at-rule to get the link to our stylesheet.
We need to remember that transforming inputs might take time, kind of like when you use jQuery.ajax
to read a link on the web. So, we’ll need to write asynchronous code. To keep our code short and sweet, we’ll make transform
return a Promise. This transform
promise will only resolve once all of our imports have been replaced.
var transform = function (css) {
// Create an empty imports collection
var imports = [];
// For each `import` at-rule
css.walkAtRules('import', function (atRule) {
// Get the reference link; e.g. `another-file.css` within `@import another-file.css`;
var link = atRule.params;
// Get a promise that resolves once the at-rule is replaced with the contents of its link
var importPromise = transformImport(atRule, link);
// Push the promise into the imports collection
imports.push(importPromise);
});
// Get a promise that resolves once every at-rule has been processed and replaced
var importsPromise = Promise.all(imports);
return importsPromise;
};
var transformImport = function (atRule, link) {};
Now our plugin will “resolve” (e.g. tell PostCSS it is finished) after all of its at-rules have been replaced. Hurray! Wait, our transformImport
function hasn’t even been written yet. Let’s move on.
We want to read the contents of our link. We’ll use fs-promise
to return a promise that helps us wait until the link has been read. Remember, this code is happening inside the transformImport
function.
// Get a promise-style file system utility
var fs = require('fs-promise');
// Get a promise resolving once the contents of the link have been read
var filePromise = fs.readFile(link, {
encoding: 'utf8'
});
We have a promise that resolves once the contents of our import link have been read. Those contents are the actual stylesheet itself, and they will be passed into our next function.
We want filePromise.then(...)
to take the contents of our newly imported stylesheet and process them into an object we can insert back to our AST. To process those contents, hey, we’ll use PostCSS, thank-you-very-much. Remember, asynchronous functions take some getting used to, so try to think of them as chained functions in jQuery.
// Get a new PostCSS processor
var processor = postcss();
var fileProcessPromise = filePromise.then(function (contents) {
// Get a promise resolved once the contents have been processed as CSS
var processPromise = processor.process(contents, {
from: link
});
return processPromise;
});
Now we have a promise that resolves once filePromise
and processPromise
have finished, in that order. We passed our link into the processor so any source maps we might generate reference the right link. Next, the AST of our imported stylesheet will be passed into our next function; our last function!
We want to replace our original at-rule with our shiny new AST. Ah, and let’s not forget to scan this new AST for other imports
, too. Remember, we might have imports within imports.
var fileProcessTransformPromise = fileProcessPromise.then(function (ast) {
// Get a promise resolved once the import has been transformed and has replaced its at-rule
var transformPromise = transform(ast.root).then(function () {
// Replace the at-rule with the ast
atRule.replaceWith(ast.root);
});
return transformPromise;
});
Now we have a promise that resolves after all the previous promises have resolved, and once our "import" at-rule has been replaced. Remember, all of this code occurred inside the transformImport
function, so it’s time for us to return our fully chained promise.
return fileProcessTransformPromise;
And that’s it. We’ve done it! Our plugin is finished.
Of course, if our imports move us through different directories then we’re going to have issues, but that’s easy enough to resolve. Remember when we read the link from the at-rule?
// Get the directory of the current link
var dir = path.dirname(atRule.source.input.file);
// Get the reference link; e.g. `another-file.css` within `@import another-file.css`;
var link = atRule.params;
// Update the reference link relative to the current file
link = path.resolve(dir, link);
That was easy enough. Now, we will move our re-usable modules up top and put it all together.
var fs = require('fs-promise');
var path = require('path');
var postcss = require('postcss');
var processor = postcss();
var pluginName = 'postcss-import-barebones';
var pluginInit = function () {
var transform = function (css) {
// Create an empty imports collection
var imports = [];
// For each `import` at-rule
css.walkAtRules('import', function (atRule) {
// Get the directory of the current link
var dir = path.dirname(atRule.source.input.file);
// Get the reference link; e.g. `another-file.css` within `@import another-file.css`;
var link = atRule.params;
// Update the reference link relative to the current file
link = path.resolve(dir, link);
// Get a promise that resolves once the at-rule is replaced with the contents of its link
var importPromise = transformImport(atRule, link);
// Push the promise into the imports collection
imports.push(importPromise);
});
// Get a promise that resolves once every at-rule has been processed and replaced
var importsPromise = Promise.all(imports);
return importsPromise;
};
var transformImport = function (atRule, link) {
// Get a promise resolving once the contents of the link have been read
var filePromise = fs.readFile(link, {
encoding: 'utf8'
});
var fileProcessPromise = filePromise.then(function (contents) {
// Get a promise resolved once the contents have been processed as CSS
var processPromise = processor.process(contents, {
from: link
});
return processPromise;
});
var fileProcessTransformPromise = fileProcessPromise.then(function (ast) {
// Get a promise resolved once the import has been transformed and has replaced its at-rule
var transformPromise = transform(ast.root).then(function () {
// Replace the at-rule with the ast
atRule.replaceWith(ast.root);
});
return transformPromise;
});
return fileProcessTransformPromise;
};
return transform;
};
module.exports = postcss.plugin(pluginName, pluginInit);
Great writing. You should really make a blog post out of this 👍