Out of the box, Jekyll compiles all your styles into one style.css
file which is included on each page.
That approach works fine, but there’s some room for optimisation for my specific usage. My site-wide style is quite simple, but most posts have have custom CSS for visualisation. A single CSS file would result in a lot of unused rules for each post.
Instead, I wanted a method to
<head>
section of all pages.I include custom styles in a post’s front matter.
---
title: "Blog post title"
layout: post
extra_scss: ".white-image {box-shadow: 0 1px 3px rgba(0,0,0,0.1)}"
---
Post content...
The extra scss for each page is concatenated with the main scss, compiled, then injected into the <head>
element. The scss compilation is done with the scssify
filter.
{% capture style %}
{% include {{ main.scss }} %}
{% if page.extra_scss %}{{ page.extra_scss }}{% endif %}
{% endcapture %}
<html>
<head>
<meta charset="utf-8">
<style>
{{ style | scssify }}
</style>
</head>
<body>
{{ content }}
</body>
</html>
The above template inlines all CSS rules on every page. But many of those are unused for a given post, especially with third-party libraries like code highlighting and resets. So I looked for a tool to remove unused CSS on a per-page basis.
PurgeCSS was promising and fast but I couldn’t get their HTML parser to work, and the default regex-based parser had a lot of issues.
UnCSS is what I ended up using. It’s slow because it actually runs each page in a browser engine, but as a result it’s really accurate. UnCSS doesn’t minify inline CSS out of the box, so I wrote a Python wrapper to extract the inline CSS with regex, pass it to UnCSS, then put the result back inside the <style>
tags.
#!/usr/bin/python3
import argparse
import re
import subprocess
START_PATTERN = r'<style>'
END_PATTERN = r'</style>'
CSS_PATTERN = r'<style>(.*?)</style>'
def main(html_path):
with open(html_path) as f:
html = f.read()
# Check there's only one style tag per file. This is the most
# likely thing to break the regex parsing.
n_starts = len(re.findall(START_PATTERN, html))
n_ends = len(re.findall(END_PATTERN, html))
if n_starts == 0:
raise ValueError(f'No start token found for: {html_path}')
if n_ends == 0:
raise ValueError(f'No end token found: {html_path}')
if n_starts > 1:
raise ValueError(f'Too many start tokens found: {html_path}')
if n_ends > 1:
raise ValueError(f'Too many end tokens found: {html_path}')
# Extract css.
css = re.search(CSS_PATTERN, html, re.S)[1].strip()
# Perform minification.
cmd = [
'uncss',
'--raw', css,
'--banner', 'false',
html_path,
]
uncss = subprocess.check_output(cmd).decode("utf-8")
# Put the css back in.
new_css = '<style>' + uncss + '</style>'
new_html = re.sub(CSS_PATTERN, new_css, html, flags=re.DOTALL)
with open(html_path, 'w') as f:
f.write(new_html)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description='Remove unused inline css from an html file.'
)
parser.add_argument(
'html_path',
help='path to html file, will be rewritten',
type=str,
)
args = parser.parse_args()
main(args.html_path)
Then finally a Ruby plugin to call the minification code automatically whenever a post is updated.
Jekyll::Hooks.register :documents, :post_write do |doc, payload|
if doc.output_ext() == '.html'
cmd = 'python3 _plugins/uncss-inline.py ' + doc.destination('./')
r = `#{cmd}`
end
Both the Python and Ruby scripts go into the _plugins
directories.
On average, the inline CSS is 5 times smaller than the full CSS file. Plus it removes a round trip from a user’s first visit, leaving a snappier first impression.
There are some drawbacks though. It now takes about a second to build a page, whereas before the whole site could be built in less than a second. I might try PurgeCSS again if I ever have enough posts for this to be a problem.
Also, when using Jekyll’s incremental build feature a build isn’t triggered after the CSS changes. I don’t change the CSS of the blog often, but when I do I have to disable incremental builds.
Overall I’m really happy with the result. Pages only need a single request to render, I don’t need to deal with style versioning and caching, and can add features like syntax highlighting without reducing site performance.