I've found out a way to avoid using Gulp and save compiled assets that then live as regular assets that can be used as includes and whatnot within my Eleventy build. (and yes, I like to precompile, it seemsโฆ) Full code at the end!
For the past few years, I've been using Gulp to convert my JSON tokens to Sass, to compile my Sass into CSS, and to minify my JS files. Gulp is super simple and works great for what I need. However, having to run it in parallel from Eleventy means that I couldn't rely on a nice series of events to trigger my Eleventy build right as the Gulp stuff was done. I did have a delay before re-running but if I set the delay to a second, what if Gulp took 500ms? Half a second, gone forever! And if Gulp took 1500ms, then the build would get interrupted and restart, wasting more time! Let's improve this.
First off, I want to acknowledge that most folks can use a regular assets pipeline like Max Bรถck's or even push it further like Vadim Makeev's recent solution. These are smart and effective solutions that I'd likely use if I weren't inlining. In my case, the CSS gets inlined as a transform via PurgeCSS, so I want the global stylesheet to be available as a source file (so it doesn't compile for every page, though that could likely be cached), and not as an output file with its own permalink. Additionally, I have a JSON file with design tokens that needs to get converted into a Sass file. Fun!
Alright, so the idea is to use the eleventy.before event, which replaces my Gulp setup. But changing stuff is always a good excuse to look for new ways to do things. Inspired by Vadim's aforementioned article, I've switched over to esbuild instead of using terser, though I don't believe that to be essential โ there's both pretty dang fast for a small site like mine.
The callback for the before event passes in the base config, including current folder information, which I can use for the Sass compiler as demonstrated on the Eleventy docs, to properly resolve imports.
Note
I have my assets in /src/assets/scss and /src/assets/js, and the resulting files are output to /src/_includes/assets/css and /src/_includes/assets/js, respectively, so if you decide to use this setup, make sure you adjust for your file structure!
First, the packages I use need to be installed, in my case via npm:
I then require those packages and define the function and ensure all the mandatory properties are passed to the settings object:
This compileAssets function is trying to be forgiving: if you're taking JS files, it's likely you'll want JS in the output as well, so the name and extensions can be omitted and will be copied for the output. Some other cases though, like Sass, will require a different output. The callback for the before event needs to know when everything is done so it can run the actual build, so I am making heavy use of the Promise API. I also have to handle my JSON file for the design tokens before the Sass compilation runs, furthering my need for promises.
Okay so the scaffolding is in place, but that promise is still looking pretty sad, so here's what needs to happen:
Find files by glob
Filter files I don't need (e.g. Sass files starting with _)
Iterate over each file and get their path information
Determine the output subfolder if relevant
Compile the file with a provided compiler
Save the file to the correct folder (but also ensure the folder exists!)
Now that I have a list of the files I want to process, I can compute the output paths and compile each file to their target:
Quite a bit going on here! Since I have some assets that live in subfolders, I need to grab whatever is after the common source folder path, so outputPath is a bit heavy-handed. Then the compileFn is called on the parsed file path object โ the defined function must be able to work with that, which I'll demonstrate below! Then I have a promise to place my file in the correct folder using fs. Once done, I can resolve the promise with success(outputPath). And finally, I resolve the "factory's" promise when all the compiled file promises have succeeded.
I think this is a fairly straightforward piece but the fact that we have multiple layers of promises does make it a little confusing. I hope the variable names I used help keep this understandable!
At this point, the compileAssets function is ready to get to work. Within the context of the eleventy.before handler, below the function definition, I compile my Sass and JS files:
Note
While the filterFn accepts a string, the compileFn makes use of the parsed path object. The filter function is, for my needs, very light so I'd rather only parse the paths of the files I know I'll compile (if this smells of micro-optimisation to youโฆ you're probably right).
So now I need to tell Eleventy this is done and the build can start, right?
Not so fast! I still need that JSON-to-Sass step for my tokens, which can be added below the previous code block. It reads the input file, passes it to jsonSass, and the contents are passed to create a new file. When it's done, it gets resolved, or rejected on error.
Nice and easy! Last thing is to indeed tell Eleventy to build. This is achieved by returning a promise, since this is an asynchronous setup. Eleventy will wait until the promise is resolved. For me, it's a chained promise of JSON then Sass, and in parallel, the JS files. This is accomplished with a neat one-liner:
The final piece of the puzzle is to handle how Eleventy watches the input and output files, or else it's headed straight for Infinite Loop Land! With v2.0.0-canary.18, this is a breeze:
And that's that! I will mention one downside of this process instead of gulp is that JSON, Sass and JS all get recompiled every time, for any change, instead of just being changed as-needed, but I think with a little more Eleventinkering, it can be overcome. The upshot is that now my build starts exactly when it needs to! And it's still blazingly fast, but that's Eleventy for yaโฆ
I realised I didn't provide the logic for the PurgeCSS stuff I mentioned at the top, so here's that as a bonus!
My head.njk file has this line, a comment used as a placeholder to dictate where the final CSS will be inserted (the comment is the important bit, the id attribute is optional):
And my .eleventy.js has a transform (a function run on a file once it has been compiled) that will replace the comment with the actual CSS (this is a simplified version but I'll link to my repository files below):
This will then clean up my CSS by only inlining the relevant declarations based on selectors matching the content provided to PurgeCSS. It does extend the build time a little but on a small site like mine, this little maneuver is going to cost an extra second or two (on average it takes 5 seconds total for 100+ files, the worst offender being the i18n plugin). As a result, the CSS for my homepage goes from about 96 kB to about 49 kB (last I checked), so nearly 50% smaller. A pretty neat optimisation!