During my time with 11ty, I've struggled to incorporate a CSS workflow that met all my needs. My specific requirements:
- Build the CSS and watch for changes
- Automate cache busters
- Don't do a full rebuild just for new CSS
First I'll explain each of these requirements, then I'll get into the code.
Requirement 1: Build the CSS and watch for changes
The first requirement is pretty simple in theory, but its the one I've struggled with the most. Should 11ty be involved in this step? If so, how? And if not, what should I use?
I've used Gulp as a task runner on several projects, but Gulp and it's many plugins are no longer actively maintained and it makes npm audit
lose its mind. It's also a pretty heavy solution for my needs, though not as heavy as something like Webpack.
I have also tried making custom template types in 11ty. This works very well on smaller sites, but one of the 11ty sites I've created has a huge global data overhead, and it seems that even when rebuilding a single template, 11ty will re-process global data. There are ways to speed this up with fetch caching and other shortcuts, but even after all that, I was looking at 25+ seconds just to rebuild the CSS. To be clear, that's not 11ty's fault, that's just how much data was going into this particular site. This issue also ruled out other potential solutions, such adding the CSS build to 11ty's before
or after
events.
The solution I settled on was to decouple the CSS build from 11ty entirely and use npm scripts as the task runner instead. I wrote small a Node script that runs postcss and writes the compiled CSS and sourcemap to 11ty's output folder. The npm script build:css
executes the Node script with node -e
.
I also added an npm script called watch:css
to watch the source stylesheet for changes using the npm-watch library. When it detects changes, it runs build:css
again.
Requirement 2: Automate cache busters
When a user visits the site, they should always get the latest version of the CSS. There are many ways to accomplish this, but I was looking for something lightweight and simple, with very few dependencies. I ended up spinning up my own solution.
I placed a file in 11ty's _data
folder called cacheBusters.js
. This creates a cacheBusters
object that you can reference in your templates. This file references a small module I wrote called get-hash
that generates a hash based on a string or a file. This allows me to generate a hash based on the minified CSS from the postcss
process above.
This hash remains the same if the input doesn't change. If there are any problems generating the hash from the input, the module writes out the error and returns a fake hash based on a random number. This means that even in an error state, the result will still be useful for cache busting. The worst that will happen is the user will download the unchanged CSS again on a later visit.
Requirement 3: Don't do a full 11ty rebuild just for new CSS
When you're in development mode and running the things locally, you don't really need the entire site rebuilt every time the CSS changes, just to update the cache buster. In a pinch, you could force a reload with cache disabled, but we can do better than that.
By disconnecting the CSS build process from 11ty, this problem is already half solved. Even though the hash would theoretically be different whenever the CSS changes, 11ty is not aware anythibg has changed because we didn't tell it to watch anything related to the CSS. So hash remains the same in the HTML.
You might say that this defeats the purpose of a cache buster, and you'd be right. How does the browser know it needs to load the updated CSS? The answer is to explicitly tell 11ty dev server to watch for changes to the CSS file (which isn't the same thing as telling 11ty to watch the CSS, which would trigger a build). By default, 11ty dev server watches HTML files in _site
for changes, but you can tell it to watch other files too, like our stylesheet. This means the browser will hot-reload the CSS when it detects a change, regardless of the value of the cache buster.
This only affects the development process. On a full build from scratch, the cache buster works as expected.
Shut up and show me the code already
-
lib/process-styles.js
builds the final CSS and sourcemap and moves the files to the 11ty output folder.const fs = require('fs'); const path = require('path'); const postcss = require('postcss'); const cssnano = require('cssnano'); const atImport = require('postcss-import'); // Build the css and its map, write them to the 11ty output folder module.exports = async () => { const start = Date.now(); console.log('Processing styles'); const srcFile = 'styles/styles.css'; const minFile = '_site/s/styles/styles.min.css'; const mapFile = '_site/s/styles/styles.min.css.map'; try { // process read, process, and write css const srcString = (await fs.promises.readFile(srcFile)).toString(); const mkdir = fs.promises.mkdir(path.dirname(minFile), { recursive: true }); const processed = postcss([ cssnano, atImport ]).process(srcString, { from: srcFile, to: minFile, map: { inline: false }, }); // wait for mkdir and postcss to finish await Promise.all([ mkdir, processed ]); // write out the minified css and the source map const minWrite = fs.promises.writeFile(minFile, processed.css); const mapWrite = fs.promises.writeFile(mapFile, processed.map.toString()); // wait for the writes to finish await Promise.all([ minWrite, mapWrite ]); console.log(`Finished processing styles (${Date.now() - start}ms)`); return processed.css; // return the css } catch (e) { console.error('There was a problem processing styles.'); console.error(e); return Promise.reject(); } };
-
package.json
contains thebuild:css
andwatch:css
npm scripts, as well as thenpm-watch
config.… "watch": { "build:css": { "patterns": [ "styles" ], "extensions": "css", "quiet": false, "runOnChangeOnly": false } }, "scripts": { "build": "npm run clean; npm run build:css; npx @11ty/eleventy", "build:css": "node -e 'require(\"./lib/process-styles.js\")();'", "clean": "rm -rf _site", "dev": "npm run clean; npm run build:css; npm run watch:css & npx @11ty/eleventy --serve --incremental --quiet", "watch:css": "npm-watch" }, …
-
_data/cacheBusters.js
goes in your 11ty_data
folder and creates acacheBusters
object for your templates.const getHash = require('../../lib/get-hash'); // Generate hashes for assets to use for cache busting module.exports = async () => { return { // Generate a hash based on the minified css styles: getHash.fromFile('_site/s/styles/styles.min.css'), }; };
-
lib/get-hash.js
is used bycacheBusters.js
and returns a hash key based on a string or a file for use by the cache buster.const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); module.exports.fromFile = async (hashPath) => { try { const fileBuffer = await fs.promises.readFile(path.resolve(hashPath)); return _getHash(fileBuffer); } catch (e) { console.error('There was a problem creating file hash.', e); // return something useful anyway return _getFakeHash(); } }; module.exports.fromString = (hashString) => { try { return _getHash(hashString); } catch (e) { console.error('There was a problem creating string hash.', e); // return something useful anyway return _getFakeHash(); } }; // some random characters function _getFakeHash (len = 64) { return crypto.randomBytes(len).toString('hex'); } function _getHash (value) { const hashSum = crypto.createHash('sha256'); hashSum.update(value); return hashSum.digest('hex'); }
-
With all of the above in place, you can use the
cacheBusters
values in templates as a querystring parameter when referencing an asset.<link rel="stylesheet" href="/s/styles/styles.min.css?v={{cacheBusters.styles}}">
This can be expanded to work for any asset. Styles, scripts, images, whatever.