Skip to content

Sass in Eleventy, with versioning

There are many approaches to adding Sass support in Eleventy, and several plugins to abstract away these approaches. The docs page alone features four separate Sass plugins. When it comes to asset versioning, how you integrate Sass and Eleventy makes all the difference to the development experience.

I've spent the day tweaking the setup for Eleventy 2.0 to work with content-hashed .scss files for a new project. Let me walk you through it.

Producing content hashes

Content hashes are useful as a cache-busting mechanism: whenever the content of the file changes, its corresponding content hash changes as well. For caching purposes, having CSS, JS, images, and other assets include a hash of their content as part of the URL is an excellent idea, and files like style.fc3ff98e.css are not an uncommon sight across the web.

Here's how to produce an 8-character hash for a given string in Node.js:

const { createHash } = require('node:crypto');

function getHash(content, length = 8) {
return createHash('md5')
.update(content)
.digest('hex')
.substr(0, length);
}

getHash("Hello world!")
// => '86fb269d'

getHash('Hello back!')
// => '7f4b5e8b'

Different string, different hash. Ship it!

Adding support for Sass with content hashing in Eleventy

The official docs include sample code for adding support for Sass to Eleventy, paraphrased below:

module.exports = function(config) {
config.addTemplateFormats("scss");
config.addExtension("scss", {
outputFileExtension: "css",
compile: content => {
let { css } = sass.compileString(content);
return data => css;
});
};

This short snippet sets up a basic workflow to transform all .scss files from the input directory to .css, but there's a gotcha. Permalinks for the resulting .css files are generated before the compile() function has chance to run, so we can't extend it to produce permalinks based on file contents.

Instead, Sass processing needs to happen earlier in the build process, in the getData() method. Based on its inputPath, sass can read the file directly and populate the template's data object. The compile() and compileOptions.permalink() methods then simply pick up the bits they're interested in.

const sass = require('sass');
const path = require('node:path');

module.exports = function(config) {
/*
Watch for changes in .scss files.
*/

config.addTemplateFormats('scss');

/*
Define how to process .scss files.
*/

config.addExtension('scss', {
/*
We're feeding the `inputPath` to Sass directly, so we don't need Eleventy to read the content of `.scss` files.
*/

read: false,

/*
Produce the data for each `.scss` file, including its processed CSS content and its MD5 content hash.
*/

getData: async function (inputPath) {
const data = {
/*
Exclude .scss files from `collections.all` so they don't show up in sitemaps, RSS feeds, etc.
*/

eleventyExcludeFromCollections: true
};
/*
Don't process .scss files that start with an underscore as standalone.
*/

if (path.basename(inputPath).startsWith('_')) {
return data;
}
const { css } = sass.compile(inputPath);
data._content = css;
data._hash = getHash(css);
return data;
},
compileOptions: {
/*
Disable caching of `.scss` files, for good measure.
*/

cache: false,
permalink: function (permalink, inputPath) {
/*
Don't output .scss files that start with an underscore, as per Sass conventions…
*/

if (path.basename(inputPath).startsWith('_')) {
return false;
}

/*
…and for other .scss files include the MD5 content hash produced in the `.getData()` method in the output file path.
*/

return data => `${data.page.filePathStem}.${data._hash}.css`;
}
},
/*
Read the processed CSS content from the data object produced with `.getData()`.
*/

compile: () => data => data._content
});
}

So we've placed the content hashes in the permalinks of generated .css files. To retrieve these hashed permalinks inside HTML templates, we prepare an input/output map using a transform, which helpfully runs through each input file. As a small convenience, we strip the input directory (src in the snippet below) from the beginning of input paths.

const outputMap = {};
config.addTransform('outputMap', function (content) {
const filepath = path.relative('src', this.page.inputPath);
outputMap[filepath] = this.page.url;
return content;
});

Put outputMap in a filter to look up versioned URLs for your input files and you're good to go.

config.addFilter('hashed', function (filepath) {
if (!outputMap[filepath]) {
throw new Error(`hashed: ${filepath} not found in map.`);
}
return outputMap[filepath];
});

Here's how to use the hashed filter in a template to obtain the versioned URL for src/_assets/style.scss:

<!-- This… -->
<link
rel='stylesheet'
type='text/css'
href='{{ '_assets/style.scss' | hashed }}'
>


<!-- …turns to this -->
<link
rel='stylesheet'
type='text/css'
href='_assets/style.c8ad33ff.css'
>

Conclusion

This article has discussed one way of adding Sass support to Eleventy 2.0 that lets you include content hashes into the resulting CSS file paths. It's short enough to plop it straight into your .eleventy.js config:

Full listing: add support for Sass with content hashing in Eleventy

Here's the full .eleventy.js configuration file:

const sass = require('sass');
const path = require('node:path');
const { createHash } = require('node:crypto');

/*
For the given `content` string,
generate an MD5 hash of `length` chars.
*/

function getHash(content, length = 8) {
return createHash('md5')
.update(content)
.digest('hex')
.substr(0, length);
}

module.exports = function(config) {

/*
Watch for changes in .scss files.
*/

config.addTemplateFormats('scss');

/*
Define how to process .scss files.
*/

config.addExtension('scss', {
/*
We're feeding the `inputPath` to Sass directly, so we don't need Eleventy to read the content of `.scss` files.
*/

read: false,

/*
Produce the data for each `.scss` file, including its processed CSS content and its MD5 content hash.
*/

getData: async function (inputPath) {
/*
Don't process .scss files that start with an underscore as standalone.
*/

if (path.basename(inputPath).startsWith('_')) {
return false;
}
const { css } = sass.compile(inputPath);
return {
/*
Exclude .scss files from `collections.all` so they don't show up in sitemaps, RSS feeds, etc.
*/

eleventyExcludeFromCollections: true,
_content: css,
_hash: getHash(css)
};
},
compileOptions: {
/*
Disable caching of `.scss` files, for good measure.
*/

cache: false,
permalink: function (permalink, inputPath) {
/*
Don't output .scss files that start with an underscore, as per Sass conventions…
*/

if (path.basename(inputPath).startsWith('_')) {
return false;
}

/*
…and for other .scss files include the MD5 content hash produced in the `.getData()` method in the output file path.
*/

return data => `${data.page.filePathStem}.${data._hash}.css`;
}
},
/*
Read the processed CSS content from the data object produced with `.getData()`.
*/

compile: () => data => data._content
});

const outputMap = {};
config.addTransform('outputMap', function (content) {
const filepath = path.relative('src', this.page.inputPath);
outputMap[filepath] = this.page.url;
return content;
});

config.addFilter('hashed', function (filepath) {
if (!outputMap[filepath]) {
throw new Error(`hashed: ${filepath} not found in map.`);
}
return outputMap[filepath];
});

return {
markdownTemplateEngine: 'njk',
dataTemplateEngine: 'njk',
htmlTemplateEngine: 'njk',
dir: {
input: 'src',
output: 'dist'
}
};
};

If you prefer the plugin route, it looks like eleventy-sass and its companion eleventy-plugin-rev do a similar job.

Addenda

A few hours after first publishing this article, @11ty/eleventy-plugin-bundle was released to help you create minimal per-page or app-level bundles of CSS, JavaScript, or HTML to be included in your Eleventy project.

Update March 1, 2023: .scss partials (i.e. files starting with an underscore) should not be processed at all in getData(). They'll be processed when they're included in regular .scss files.

Update August 9, 2023: .scss partials should likewise not be included in collections, so they don’t show up in RSS, sitemaps, etc.