30 KiB
title | excerpt | tags | youtube | |||
---|---|---|---|---|---|---|
Version 3 | I'll show you the true power of being third! |
|
true |
Welcome to version 3.0! This has been my pet project for the better part of a year, with plenty of false starts and scrapped ideas.
First off, if what you're after is the source code - here it is. If you'd like to learn more, read on dear reader!
I’ve had a Figma file ready for months now that outlined my basic design - all I needed to do was build it. The problem was I could not settle on how I wanted to do it.
{% image "https://cdn.wonderfulfrog.com/figma-v3.png", "A screenshot from Figma showing my blog prototype. There are various elements visible like buttons and widgets. A colour scheme using teal.", "Figma screenshot" %}
{% image "https://cdn.wonderfulfrog.com/figma-v3-lightmode.png", "Another screenshot from Figma showing my blog prototype. This is showing the 'light theme' with a serif title font, monospaced body font, and teal accent colours.", "Astute observers will notice the site you're looking at doesn't look a whole lot like the design!" %}
Something that always frustrated me was having my content along the code. I sought out a solution for this for months. I tried a whole bunch of headless CMS options like Sanity, Hygraph, Ghost, and even hosting my own WordPress (that was an exciting prospect until I discovered their mobile app does not support plugins).
I was always paranoid that bad actors would clone my git repo and steal my content, so I was so focused on finding a secure, obfuscated method to store content.
One day I thought to myself “who cares?” and it was like a switch flipped. Bad actors can still copy and paste everything, so having the content off the repo makes little difference. LLMs are exploding and would make it trivial to scrape my site (if they wanted to).
At last, that let me narrow down my options. I’ll just keep the text content in the repo. But I still wanted a way to store images and other large media outside of the repo (more on that later).
If the text content can be in the repo, then I can use anything! So let’s stick with Next.js! … actually, let’s not.
An Aside on Breaking Changes
Anyone working in the web dev sphere knows things move fast. Too fast these days - in my opinion. Next.js happens to be one of those libraries. My site hasn’t had any significant updates since 2022, which is approximately 2 years and change at the time of writing. In web years that’s ancient.
I tried to migrate to the new app router structure and found myself getting stuck learning about breaking changes in Next.js and breaking changes in React1. I spent way too much time trying to get things working with my tools that I didn’t stop and think that my tools should be working for me.
As an alternative, I was thinking of using Astro, but it’s still React under the hood. I’m happy to use React for work but I don’t want to have to deal with its newest idiosyncrasies in my own stuff.
Simply put modern React confuses the hell out of me. I’m not all in for “server vs client components”. I’m certainly not the only one2. I don't want this post to digress into my thoughts on modern React so I'm going to sidestep that for now. I am forced to learn its newest architecture for work, but for my personal site I'd rather things be easy. I'm tired of having to deal with breaking changes all the time. I longed for the days of PHP where stuff just worked forever. It was time to embrace an old foe: boring technology.
Boring technology - to me, anyway - is something that’s been around for a while, probably a few years. It’s stable. It’s been around long enough that someone has asked the basic questions on how to do XYZ on StackOverflow. And something important to me is that it’s also averse to breaking changes. It tries its best to avoid them, and if they must then there is a clear migration path and plenty of warning.
My first thought was WordPress. That’s nice boring technology. Battle tested. There are even some web dev folks out there using it as a headless CMS. I ended up not using it because the mobile publishing experience was no good. It didn’t support plugins on the mobile app. I knew I’d want something like ACF at least.
After a while I remembered a project I’d seen ages ago that I knew a lot of folks were using. It’s called Eleventy (or 11ty for short). There’s a video of the creator trying out a blog using the latest version all the way back to the earliest versions of the project3. Now that’s what I like to see. Stable. Fast. Let’s do it.
{% youtube "bPtQmsjXMuo", "How Stable is Eleventy? Can we run a 5-year old project as-is with Eleventy 2.0?" %}
So I decided to do what every web dev with a blog does: rebuild their blog from scratch. At least I had some content to play with!
Cranking it up to 11(ty)
Eleventy is a static site generator. It does what probably other popular options like Next.js or Astro do. I chose it because:
- It’s stable
- It’s reliable
- It’s fast
- It outputs pure HTML and CSS with no JS
- But you can add client-side JS if you want
- It uses Markdown files for content
- Has great documentation
- Minimal tooling required - it’s all plain JS
- The developer seems like a cool person
Those are my biggest reasons for choosing it. But it also does some cool stuff like:
- Grouping content into collections for easy parsing
- Converts JavaScript data stores into collections
- Has plugins for optimizing images
- Is fine with organizing things however you want
- Did I mention it’s fast?
A whole site build from scratch takes 30 seconds, and most of the time is fetching images (for optimization, more on that later).
I really liked how easy it was for me to organize content my way and tell 11ty how to consume it. It took a fair bit of learning on my part, but once it clicked I felt powerful.
Something I’d like to mention and emphasize is the tool chain (or lack of). Under the hood, it’s all plain JS. There’s no Webpack, no Babel. The only real dependency is Node 18. It uses CommonJS which is pretty old nowadays but again - boring technology! What it means is that I don’t have to worry about things breaking because a dependency changed. Even as I write this with 11ty 3.0 on the horizon - it will still work with CommonJS with the option to opt-in to ESM. There are clear migration steps. Amazing!
Let's talk CSS
11ty takes care of the content and leaves the developer to implement CSS however they like. It works great with vanilla CSS out-of-the-box, but mixing in PostCSS or Sass is easy enough.
I wanted to focus on vanilla CSS as much as possible, and use a little post-processing to tidy things up, add missing prefixes (if needed), and all the boring stuff. Otherwise, let's try using some new features like :has
!
A methodology that resonated with me was CUBE CSS. CUBE standing for Composition, Utility, Block, and Exception. The site has a great explanation of how that breaks down, but for my own purposes, I interpreted it as:
- Composition: utility classes that do one thing and one thing well, e.g. a wrapper class for centering a layout and giving it a
max-width
. - Utility: Design tokens and extremely simple utility classes. Very similar to the utility classes that Tailwind provides, albeit fewer.
- Blocks: Like components in React. It's a wild west here, anything goes. What I found those was that my composition and utility classes did 90% of the work for me.
- Exception: Your odd one-offs (like arbitrary values in Tailwind). Uses
data
attributes as the selector because exceptions are represented by state changes (or React props, perhaps?).
I organized everything following this methodolgy, and added a global.css
file that ties everything together with @import
and @import-glob
statements. I used PostCSS to process the CSS into a single file.
For my design tokens, I use a handful of JavaScript files to assemble my JSON design tokens into CSS strings. That leaves me with two chunks of CSS - my design tokens, and site CSS that uses those design tokens.
I used a JavaScript Template Data File (css/styles.11tydata.js
) that assembles all of my CSS into one giant string, run it through PostCSS (which includes some extra plugins), and then data file renames the file using the permalink
property in the frontmatter. It leads to a single style.css
that has everything I need - nice and clean, and no extra npm
task required.
What PostCSS plugins am I using? The following:
postcss-import
- Any@import
statements are replaced with the file being imported.postcss-import-ext-glob
- Like using@import
but with glob file paths. More of a developer convenience.autoprefixer
- Not as essential these days, but there are still some vendor-prefixed CSS rules.cssnano
- Minifies my CSS, and fast.
Combining two CSS strings is perhaps not the most efficient way of doing things, but it makes sense to me and seems fast enough.
Let's take a walk through how my design tokens work!
Reinventing the wheel
I like Tailwind - especially for work - but I think it comes in heavy-handed. It does a lot, and I don't need the majority of it. I've seen some developers strip away everything but the design system that Tailwind provides out of the box, and I thought about doing that... but what if I built my own solution instead?
Tailwind is yet another dependency. If I only need a small subset of its feature-set, why should I have to spend developer-hours stripping away everything I don't need and instead solve the problem myself with less code?
To be fair, I probably spent more time arriving at my final solution than I would've spent just using Tailwind, but it was a lot of fun!
All of my design tokens are stored in JSON files inside my config/design-tokens
folder. They look like this (for example, this is colors.json
):
{
"light": {
"primary": "188deg 84% 35%",
"secondary": "8 84% 50%",
"background": "0 0% 98%",
"surface": "188 27% 94%",
"border": "188 48% 80%",
"text": "0 0% 4%",
"fadeText": "188 12% 32%",
"shadow": "188deg 100% 18%"
},
"dark": {
"primary": "188deg 84% 28%",
"secondary": "8 84% 43%",
"background": "0 0% 4%",
"surface": "202 10% 10%",
"border": "208 27% 15%",
"text": "0 0% 98%",
"fadeText": "188 12% 70%",
"shadow": "188deg 100% 18%"
}
}
I then wrote some JavaScript functions to transform these JSON values into CSS variables and utility classes. In order to achieve this I borrowed heavily from the Tailwind codebase. I decided which utility classes I wanted (in the case of colours, something like bg-primary
or text-primary
). Here is a (shortened) example of the output using thee colors.json
tokens:
:root {
--primary: 188deg 84% 35%;
--color-primary: hsl(--primary);
}
.bg-primary {
background-color: var(--color-primary);
}
.text-primary {
color: var(--color-primary);
}
An approach I borrowed from Tailwind was how to generate helper classes. I have an array of arrays that takes a desired helper class prefix (e.g. bg
), and the corresponding CSS properties (e.g. background-color
) and values. For example - colours again:
const helperClasses = [
["text", ["color"]],
["bg", ["background-color"]],
];
I run this array through a helperClassesToCss
function I wrote, which takes the helper class prefix, appends the colour name (e.g. primary
) to the prefix, and sets the array of CSS properties to the desired value.
A walkthrough
I wanted to run through an example process from JSON to CSS and how it works - hopefully I can refer to this in the future if I ever forget how the process works.
To start, cssPropertiesToCss
takes an array of CSS rules and sets them to the same value
. For example:
const cssProperties = ["margin-left", "margin-right"];
const value = "16px";
const css = cssPropertiesToCss(cssProperties, value);
// css = "margin-left:16px;margin-right:16px;";
cssPropertiesToCss
uses the .reduce()
function to transform the array of properties into a string by concatenating each item in the array together.
helperClassToCss
takes the generated CSS string and wraps it with a CSS selector helperClass
.
const cssProperties = ["margin-left", "margin-right"];
const value = "16px";
const helperClass = "mx-1";
const helperClassCss = helperClassToCss(helperClass, cssProperties, value);
// helperClassCss = ".mx-1{margin-left:16px;margin-right:16px;}"
Finally, helperClassesToCss
ties everything together to make adding new classes and variants easy.
const helperClasses = [["mx", ["margin-left", "margin-right"]], ["my", ["margin-top", "margin-bottom"]]];
const variant = "1";
const value = "16px";
const helperClassesToCss = (helperClasses, variant, value);
// helperClassesToCss = ".mx-1{margin-left:16px;margin-right:16px}.my-1{margin-top:16px;margin-bottom:16px}"
It might not be the best, but it makes sense to me!
All of my code related to generating CSS from design tokens uses nothing but plain JavaScript and built-in modules (I think the only one being path
). I'll never have to worry about my build process breaking. All of my design tokens are JSON. If I ever need to tweak them, the only thing I need to change is a couple of JSON files!
It took me a fair while to write, and certainly some trial and error, but I'm quite pleased with the final outcome. I can shunt this output into any CSS or future project if I want. If I want more helper classes, it's very easy to add more. There are quite a few spacing helpers:
const helperClasses = [
["m", ["margin"]],
["my", ["margin-block-start", "margin-block-end"]],
["mx", ["margin-inline-start", "margin-inline-end"]],
["ml", ["margin-inline-start"]],
["mr", ["margin-inline-start"]],
["mt", ["margin-block-start"]],
["mb", ["margin-block-end"]],
["p", ["padding"]],
["py", ["padding-block-start", "padding-block-end"]],
["px", ["padding-inline-start", "padding-inline-end"]],
["pl", ["padding-inline-start"]],
["pr", ["padding-inline-start"]],
["pt", ["padding-block-start"]],
["pb", ["padding-block-end"]],
["w", ["width"]],
["h", ["height"]],
["size", ["width", "height"]],
["radius", ["border-radius"]],
["gap", ["gap"]],
["row-gap", ["row-gap"]],
["column-gap", ["column-gap"]],
["flow-space", ["--flow-space"]],
];
You'll notice one there that looks different - flow-space
. I'm so happy that my approach works with setting CSS variables too. I use the --flow-space
variable for controlling my flow
composition.
Quick composition chat
Compositions are cool. They're like little helpers that make your content looking good, without having to fuss too much with it. The Flow composition is arguably a fast favourite. Here it is:
.flow > * + * {
margin-top: var(--flow-space, 1em);
}
Yep, that's it. It uses a fancy wildcard selector (* + *
) to achieve the effect. It comes from Every Layout's Stack layout4. Translated to English, it would be something like "for every child of .flow
that is not the first, give it a margin-top
of --flow-space
, or 1em
if that doesn't have a value". The "not the first child" comes from the wildcard adjacent sibling selector.
What ends up happening is if you have a bunch of headers and paragraph tags on the page, this one single class styles it up perfectly so that it looks readable and beautiful. No extra work required, because the spacing value is 1em
it will use whatever the current child's font-size
is. How freakin' cool is that?! I love it.
Utilities like this are a big part of the principles behind Build Excellent Websites. "Be the browser's mentor, not its micromanager." Highly recommend watching Andy Bell's keynote on the subject.
Speaking of Cascade... The Data Cascade
The Data Cascade is what I would consider to be 11ty's killer feature (like a killer app). In short, it allows for injecting (or assembling) data practically anywhere, and rely on context in order to drive where that data goes.
The simplest data source would be frontmatter - the data that lives at the top of Markdown files. The neat part is that frontmatter can be added to non-Markdown files too, which allows for some potential fun stuff.
For example, my catalogue content is inside /catalogue
. Inside that folder are more subfolders, and a template data file for the folder called catalogue.json
. It looks like this:
{
"tags": "catalogue"
}
Just one property but it achieves a lot. It applies the catalogue
tag to every file inside this directory.
Because I'm using the tags
feature, 11ty will automatically group everything inside this directory into a new collection called catalogue
, which I can access from the global collections using collections.catalogue
. With just a few lines of code I have my entire catalogue in an array! No fetching or setup required. Done and done!
But wait... I can keep going... take for example my /catalogue/books
folder. This one also has a template data file in here called books.11tydata.js
which lets me use JS in here:
module.exports = {
layout: "layouts/catalogue-item",
tags: "book",
permalink: "catalogue/books/{{ page.fileSlug }}/index.html",
linkTitle: "View book details",
eleventyComputed: {
tertiary: (data) =>
`<p class="[ flow-space-0.5 ]"><span class="[ text-fadeText ]">by</span> ${data.author}</p>`,
},
};
Lets go through this one:
- Every file inside uses the
layouts/catalogue-item
layout - Applies the
book
tag (and therefore generates a new collection, automatically) - Creates a permalink using the
fileSlug
variable per page - Changes the
linkTitle
frontmatter - Uses
eleventyComputed
(which is a special field) to inject frontmatter into the template's frontmatter
Highly suggest reading the docs on Computed Data.
That's a lot! I'd like to turn your attention to the permalink
variable. By using just this alone, 11ty will automatically generate HTML pages for each of my Markdown files inside this directory and pass in the frontmatter I set here. With just one line of code! What?! That's awesome!!
At this point all I have to do is make sure I have a layout file defined and built the way I like... and 11ty takes care of the rest. Love this!
But wait! There's more! What about Global Data Files? Yup, we can do that too! For example, I used a global data file to fetch my latest Last.fm tracks:
// Simplified imports
const EleventyFetch = require("@11ty/eleventy-fetch");
const dayjs = require("dayjs");
const fetchRecentTracks = async () => {
const url = `http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=wonderfulfrog&api_key=${lastFmApiKey}&format=json`;
const response = await EleventyFetch(url, { duration: "1m", type: "json" });
const tracks = response.recenttracks.track.slice(0, 5);
const recentTracks = tracks.map((track) => {
const timestamp = track.date
? dayjs(track.date["#text"]).utc(true).fromNow()
: dayjs().fromNow();
return {
artist: track.artist["#text"],
track: track.name,
url: track.url,
timestamp,
};
});
return recentTracks;
};
module.exports = async function () {
const recentTracks = await fetchRecentTracks();
return {
recentTracks,
};
};
This allows for my Last.fm data to be accessible via the global data
field! The only catch is that this data is generated at build time and not live. This can be solved different ways, such as:
- Rebuild the site periodically
- Make a web component that fetches the data live
For now I'm content to trigger a build every so often in order for the data to be "live-ish". In the future it would be fun to build a web component (or perhaps someone out there has done that already)! 11ty is very compatible with web components.
Alert the media
Media storage was still a thorny issue. I couldn't decide on how I wanted to manage it for a while. All I knew for certain was that I was tired of keeping it in my repo - it needed to be elsewhere.
As I dug into 11ty, I discovered a truly magical plugin called eleventy-img
. It takes an image (either locally or remote), optimizes it, and stores it in the output directory. The image can therefore be put anywhere you like, and with a small bit of shortcode it works like magic. For example:
{% raw %}{% image "https://path.to.image.jpg" %}{% endraw %}
It's a little bit of extra syntax compared to a Markdown image (and the newest version of eleventy-img
doesn't even require shortcodes), but it saves so much manual effort of resizing and optimizing images.
That left the final question - where do I keep my media? I eventually settled on Bunny.net - simple, no-nonsense storage with clear pricing. Has a REST API for uploading images, and even works with SFTP.
I decided to do the painstaking process of manually updating all of my content by myself rather than automating it. I figured the time investment of an automated solution would be roughly equal to the time it would take to do manually. Regardless, all my media is now behind a robust CDN (and with a custom domain too). When my site is built, 11ty will fetch all those images and generate local copies that are resized and output using srcset
. It's all so seamless and easy, and eleventy-img
even caches the results so subsequent re-builds are super fast!
Other organizational details
The Eleventy Excellent starter was a huge inspiration for this site. I used its organization structure a lot. My config
folder holds a lot of stuff:
- Custom collections,
config/collections
- My design tokens,
config/design-tokens
- Filters,
config/filters
- Plugins,
config/plugins
- Shortcodes,
config/shortcodes
- Transforms,
config/transforms
- Constants,
config/constants.js
Custom collections
These are my "shortcut collections" (or Custom Collections) which help organize things for me. I have a collection for all my posts organized by tag (kind of like collections.post.tag
), and a collection of my catalogues by type (e.g. collections.game
or collections.book
).
Design tokens
As mentioned, a bunch of .json
files that have my design tokens in here. That's it.
Filters
Filters live here. Filters are great - lets me manipulate the data on my templates using Nunjucks. One of my favourite filters organizeByDate
will take an array of posts (or anything with a date) and group them by year. It's the filter that makes the Posts and Catalogue pages show up by year!
Plugins
11ty has a lot of built-in plugins, but also makes it easy to define your own. In my case I've defined a customized Markdown processor using markdown-it
. I added some neat features:
I'm also using the @11ty/eleventy-plugin-rss
plugin to generate my RSS feed. I'm glad to see that 11ty supports RSS feeds out-of-the-box. I always hated that setting up an RSS feed in Next.js felt rather hacky.
Shortcodes
Shortcodes are cool. They're like custom Markdown but supercharged. I have my aforementioned image
shortcode, and another for embedding YouTube videos using lite-youtube
.
Here is an example of the YouTube shortcode:
{% raw %}{% youtube "Video ID", "Video Title" %}{% endraw %}
Transforms
Transforms are great for modifying the template output. I only have one which minifies the HTML output using html-minifier-terser
.
Constants
A couple of properties that are passed to my .eleventy.config.js
file.
Content authoring
To create and manage my content I use Decap CMS (formerly Netlify CMS). As far as I can tell it's the only game in town that does what I want:
- Git-backed file system that works with flat file structures
- Works on desktop and mobile
- Has preview deploys using pull requests
- Configurable schema for content
It's honestly not my favourite, but it works well enough that I want to continue using it. I've toyed with making my own solution for years, but I think that's something best left to someone who has time to not only build it but maintain it.
The only thing I can't seem to figure out is a custom media library. As previously mentioned I use Bunny.net for all my media storage, and they don't offer a plugin for it. From what I can tell it's not possible to roll your own either.
Hosting
My site is hosted on Netlify. I've used it in the past, and would've kept using it had I not switched to Vercel because I was using Next.js. I'm very happy with Netlify.
I use the netlify-plugin-cache
plugin to keep my 11ty cache between builds. It really helps keep the build times down.
Whenever a change is detected on the main
branch, a new version is deployed automatically.
Accessibility
I tried hard to ensure that my site is fully accessible. I'm still learning how to properly test this - automated tools can only go so far - so if I missed something obvious [please tell me]({{ meta.social.mastodon }})!
Conclusion
That was a lot, but I covered everything! I hope.
I'm pleased with this new iteration of my site. I won't kid myself and pretend I'll never built it from scratch again because... I am certain I will. I do feel like though things are in a great and maintainable spot that should hopefully be stable for years to come.
I've already got plans for my next steps... I would like to copy over my Letterboxd content into my catalogue so I can have everything live here. As well as my RateYourMusic ratings and reviews.
This has been a long (but fun) project, and I'm glad it's done. Now time to produce content!
Additional notes
If you'd like to view the source for my website, it's available here!
If you have any questions, please [reach out to me on Mastodon]({{ meta.social.mastodon }})!
Here are some resources I used (likely heavily) while building the site.
Inspiration
- Eleventy Excellent (view source)
- Aleksandr Hovhannisyan (view source)
- Andy Bell (view source)
- Cory Dransfeldt (view source)
- Lea Verou (view source)
Data Cascade
11ty setup and configuration
- Build a Blogroll with Eleventy
- From Wordpress To Eleventy With Ease
- Optimizing Images with the 11ty Image Plugin
11ty and design
- What Are Design Tokens?
- Configuring Web Fonts in 11ty with Global Data
- Easily Use Design Tokens In Eleventy
-
Search for "nextjs react hydration error" to see what I mean. ↩︎
-
People smarter than me have created posts outlining their problems with modern React. Here is a small sample: Annoyed at React React, where are you going? Switching Costs Removing React is just weakness leaving your codebase ↩︎
-
Here is a post on 11ty.dev with additional information, if you're curious. ↩︎
-
Every Layout is a fantastic resource and worth every penny. Check out the free options if you're not sure, and really try them out. ↩︎