blog/src/posts/2024-10-19_building-a-blog-with-eleventy.md
Sebin Nyshkim e37b5a9823 build: 🔧 use externally hosted image service
Build time increases exponentially with more images pulled in by the eleventy-image plugin. This keeps build times low and shifts computational load to an external image hosting service.
2025-04-20 16:22:53 +02:00

688 lines
37 KiB
Markdown

---
title: Building a Blog with Eleventy (blind, any%)
image:
src: https://img.sebin-nyshkim.net/i/3163c7b0-4657-40bb-b08a-462a06d1fdd5
alt: Close-up of SVG code on a computer screen
credit: Photo by Paico Oficial on Unsplash
width: 1200
height: 630
type: 'image/png'
tags: ["coding", "eleventy"]
---
I recently felt like getting back into blogging. But setting up and maintaining WordPress felt like more than I was looking for. I was looking for something much simpler. Preferably file-based and with Markdown support. That was my introduction to Eleventy.
<!-- more -->
> [!info] Update 2025-02-17
> Updated Tailwind instructions for Tailwind v4
Starting an Eleventy project is fairly straight-forward if you've ever worked with any Node.js package:
1. Create a project folder
1. Initialize a `package.json`: `npm init -y`
1. Install the package from npm: `npm install @11ty/eleventy`
1. Create a content file in [one of the supported template languages](https://www.11ty.dev/docs/languages/)
1. Run `npx @11ty/eleventy` to let Eleventy work its magic
An `eleventy.config.js` file is entirely optional, but will become necessary for more complex setups down the line. But as a starter, it doesn't get any easier than this. No config, no build steps to set up, it just works(tm)!
So with that in mind, let's go through the steps of learning how to go from the basics to a blog.
## Setting up `package.json`
It's probably a good idea to add at least two entries to the `scripts` section of `package.json`. One is for starting the local development environment, to view the site live in a web browser and have it reload automatically after hitting save in your code editor. The other is for building a production ready build to put on a webserver.
```json
"scripts": {
"start": "eleventy --serve",
"build": "eleventy"
}
```
If you're using a code editor that has support for NPM built in (e.g. Visual Studio Code), you can easily start any entry with a simple click and get helpful debug output of what Eleventy is doing.
## Content
I love Markdown as a document format! ❤️
Its syntax makes it easy to produce well-formatted documents, and it remains readable even when viewed in plain text. It has widespread support across the web and in development communities, so it was a no-brainer for me to want to write my blog posts in Markdown. Eleventy supports it as a content templating language. It's like a match made in heaven!
Eleventy uses file names as basis for routing. So a file called `index.md` at the project root will become the `index.html` in the web root of the site. If you name the file something else, say `about.md`, Eleventy will output it as `about/index.html`. By default, most web servers are looking for an `index.html` file when a web browser just requests a path without an explicit file name. That's how I can have readable URLs and not having to worry about routing at the application level too much. Awesome!
What I'm trying to achieve doesn't need a database, complex routing rules, or any big CMS in the backend. I just wanna put words on the internet and for that simple HTML is the best way to do that.
After all the building blocks around that are put in place, I can just focus on writing the words. There's many apps on desktop and mobile that have Markdown formatting built in that allow me to work on posts at home and on the go. One such example would be Nextcloud's Notes app that uses Markdown for formatting notes. I can use something like [Iotas](https://flathub.org/apps/org.gnome.World.Iotas) or [Apostrophe](https://apps.gnome.org/Apostrophe/) on my GNOME Linux desktop to compose drafts or jot down something real quick in the [Nextcloud Notes app for Android](https://play.google.com/store/apps/details?id=it.niedermann.owncloud.notes), push the completed post written in Markdown to the Git repo, re-build the site and deploy it.
It doesn't get any simpler than that!
## Layouts
Markdown, as a simple markup language, is not intended to create fully compliant HTML documents however. This is where Eleventy layouts come in. Layouts can be written in [any template language Eleventy supports](https://www.11ty.dev/docs/languages/). Nunjucks (`*.njk`) seems to be a pretty popular choice in the Eleventy community, so I went with that. There's many languages available, but not as well documented as either Nunjucks or Liquid (the latter of which I've never heard of and its documentation seems equally sparse…)
You can think of layouts as re-usable content wrappers. Which is great, since it spares me having to wrap all my Markdown files in the same boilerplate every time, which would be kind of a drag and detract from the whole putting my words on the internet with ease and such.
Eleventy by default looks for layouts in a sub-directory called `_includes`. The naming for layout files is arbitrary, so I just went with a `base.njk` layout that holds all the default boilerplate HTML structure that will be common to all pages on the site. Since I'm using Nunjucks as my templating language for layouts, it offers me some variables inside the HTML and this is what makes this a whole lot more dynamic.
```twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% raw %}{{ title }}{% endraw %}</title>
</head>
<body>
{% raw %}{{ content | safe }}{% endraw %}
</body>
</html>
```
The `safe` in this case is what Eleventy calls a "filter". It's basically just a JavaScript function that gets the value of the thing in front of the pipe as its first argument and does stuff to it. In this case it prevents escaping of `content` because it is already valid HTML from transforming the Markdown contents, so double-escaping that would create garbage.
## Linking content and layouts
Creating a layout by itself does nothing yet, though. I have to somehow let Eleventy know that content and layout are somehow related. This is achieved by a block of YAML at the beginning of each file, which Eleventy calls *front matter*, where that information lives.
In this case, any blogpost I write has to have at least a reference to a layout in the beginning:
```md
---
layout: base.njk
---
# Look ma! A blog post!
Words words words words…
```
The path is always relative to the `layout` path variable configured in Eleventy, which by default points to `_includes`.
This way {% raw %}`{{ content }}`{% endraw %} will receive the rendered output of that Markdown file.
There's still one problem, though: What if not all pages on your site look exactly the same? What if you want some to look different from others?
## Layout-ception
Eleventy doesn't really care what the {% raw %}`{{ content }}`{% endraw %} in a layout really is. It can even be another layout! This is what Eleventy calls "layout chaining": The rendered output of one layout becomes the content of another layout. This is what I'm doing with single blog posts (the one you're reading right now, in fact)! This allows for highly re-usable layouts and actually encourages modularity in your code structure.
Suppose the layout to be chained is called `blogpost.njk` and will receive all the, well, blog posts. It could look something like this:
```twig
---
layout: base.njk
---
<article>
{% raw %}{{ content | safe }}{% endraw %}
</article>
```
See what I did there? This is the whole thing! Now I just have to point every blog post to a different layout and I'm golden.
## Data files
"But Sebin," I hear you say, "writing all that stuff in the beginning of each post is tedious! What if you forget to do it?"
The authors of Eleventy probably thought the exact same, apparently. Introducing another Eleventy concept: **Data files!**
Data files are just `*.json` files which can be used to set some or all of the common *front matter* data for every file in a directory, e.g. if all my blog posts live in a sub-directory `posts` I can put a data file in there named the same as the directory (i.e. `posts/posts.json`):
```json
{
"layout": "blogpost.njk"
}
```
This applies the `blogpost.njk` layout to every file. But we can take this even further and do a lot more to free up some space in the actual blog post Markdown files.
```json
{
"layout": "blogpost.njk",
"date": "Created",
"permalink": "/posts/{% raw %}{{ title | slugify }}{% endraw %}/",
"author": {
"name": "Sebin Nyshkim",
"href": "https://blog.sebin-nyshkim.net"
},
"twitter": {
"account": "@SebinNyshkim"
},
"mastodon": {
"fediverseCreator": "@SebinNyshkim@meow.social"
}
}
```
The `date` tells Eleventy how to resolve the date associated with a blog post, in this instance the date of when the file was created[^eleventydates]. The `permalink` variable allows me control where a post will be saved to, with the `slugify` filter taking the value of the `title` variable and making it URL friendly. And as you can see, you can add anything you want.
[^eleventydates]: Specifically, the file system creation date. This can get a little tricky if you care about the accuracy of the post date, because if you were to create an empty file in the `posts` directory, Eleventy will take the exact date the file was written to disk, not the date the post was *actually published* to the site. You could get around that with `git Created` to instruct Eleventy to take the date the file was pushed to the Git repo instead, but this has [caveats](https://www.11ty.dev/docs/dates/).
## Data cascade
Of particular note in this context is what Eleventy calls the *data cascade*, meaning data set earlier in the cascade gets overridden by later data. In the earlier example this would mean that if a blog post were to re-assign the `layout` variable, it would look different from any other post, even if the `*.json` data file said something else. That is because the post always comes last in the cascade.
This opens up an interesting use-case, however. A friend asked me, if I'd allow guest articles on my blog and I told him I'd look into how to do that. I think this is a workable solution!
If I set myself as default author in the data file, all my friend would have to do is override the author information in the blog post Markdown file:
```md
---
title: How to have someone else write in your blog
date: Created
author:
name: Not Sebin
href: https://no-derg.zone
twitter:
account: "@FakeSebin42069"
mastodon:
fediverseCreator: "@FakeSebin@no-derg.zone"
---
# There be no dragons here
This post brought to you by the *Anti-Sebin Gang*
```
When using a more complex data structure like this, it's important to note that setting the top level object to something like `false` does not unset it. If I don't keep to the same data structure and set the individual properties to something *falsey* the data from earlier in the cascade will persist. Something worth keeping in mind!
## Styling
Until now the site has no styles, which doesn't look particularly pretty. Adding styles isn't very complicated either, though. It's just one line to add to the `base.njk` layout in the page's `<head>` section:
```twig
<link rel="stylesheet" href="{% raw %}{{ '/css/style.css' | url }}{% endraw %}">
```
The `url` filter on the `href` attribute makes sure the resulting path will be dynamic enough that if the site were to move in a sub-directory nothing breaks.
Eleventy does not process CSS files, however, so we need to tell it to do something with them.
## Configuring Eleventy
Up until this point a config was entirely optional, but since we're bound to do something more mildly complex with Eleventy down the line, we might as well just add it.
Eleventy's config file is named `eleventy.config.js` and the format should be immediately familiar to anybody having worked with Webpack, Vite, NextJS and the like[^eleventyconfig]:
[^eleventyconfig]: Eleventy supports both CommonJS and ESM syntax. I'm using ESM syntax because it's widely adopted in the Node.js development world and is supported by Eleventy since v3 (which just so happens to have released shortly before I started this blog, fancy that)
```js
export default async function(eleventyConfig) {
// Access to the full `eleventyConfig` Configuration API
};
```
Unlike Webpack for example, however, the config format is pretty straight-forward. You get the entire Eleventy Configuration API interface to play with; no entire config format to figure out.
With it, you can add watch targets, copy files to the output directory, add plugins, shortcodes, filters, collections and extend included libraries. This makes Eleventy extremely capable for a static site generator!
### Custom paths
The config is also where the default paths of source and output directories can be adjusted. I like my work environment clean and tidy, so I'm giving the things I work with clear distinct names and places where they go:
```js
export default async function(eleventyConfig) {};
export const config = {
dir: {
input: 'src',
output: 'public',
layouts: 'layouts',
includes: 'includes'
}
};
```
The Eleventy docs [recommend](https://www.11ty.dev/docs/config-shapes/) setting the paths outside of the callback function for order-of-operation reasons, which sounds like something I'd want to adhere to.
### Adding watch targets and pass-through copies
To have Eleventy watch for changes in files and copy them over during serve and build we have the `addWatchTarget()` and `addPassthroughCopy()` helper functions. Pointing these at directories copies the entire content of that directory:
```js
export default async function(eleventyConfig) {
eleventyConfig.addWatchTarget('./src/css/');
eleventyConfig.addWatchTarget('./src/fonts/');
eleventyConfig.addWatchTarget('./src/img/');
eleventyConfig.addPassthroughCopy('./src/css/');
eleventyConfig.addPassthroughCopy('./src/fonts/');
eleventyConfig.addPassthroughCopy('./src/img/');
};
```
Since Eleventy also uses `browsersync` it makes developing locally really comfortable, as the browser reloads the page automatically when I hit save in my code editor.
### Adding shortcodes
Shortcodes are a really neat feature allowing me to have small reusable snippets I can call from anywhere in my templates:
```js
export default async function (eleventyConfig) {
eleventyConfig.addShortcode('year', () => `${new Date().getFullYear()}`);
};
```
Now I can just put {% raw %}`{% year %}`{% endraw %} anywhere and it will give me the current year for things like the copyright notice at the bottom of the page, it will update itself when the year changes.
### Adding filters
You've seen filters being used with the {% raw %}`{{ content | safe }}`{% endraw %} variable in content files. The config is also where I can declare a couple of filters myself for text transformations I need in multiple places, e.g. the date on the top of posts. I can declare it with the `addFilter()` helper method:
```js
export default async function (eleventyConfig) {
eleventyConfig.addFilter('toDateObj', (dateString) => new Date(dateString));
eleventyConfig.addFilter('isoDate', (dateObj) => dateObj.toISOString());
eleventyConfig.addFilter('longDate', (dateObj) => dateObj.toString());
eleventyConfig.addFilter('readableDate', (dateObj) =>
dateObj.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
);
};
```
With this, I can now do things like {% raw %}`{{ date | readableDate }}`{% endraw %} to format a JavaScript date object instance as a nicely readable string. These are also chainable, so doing something like {% raw %}`{{ '2024-10-13' | toDateObj | readableDate }}`{% endraw %} gives me *Oct 13, 2024* from a date string the `Date()` constructor accepts[^jsdateobject].
[^jsdateobject]: The core JavaScript `Date` object is perfectly capable of formatting date and time strings. A library such as `luxon`, like it's mentioned in the Eleventy docs, is better suited when needing to do more complex things with dates like calculating durations and intervals, e.g. I wanted the post date to say something like "3 days ago" or something.
### Adding collections
I like to have some tags on my posts to categorize them into topics. After all, that's what tags are for. One issue I encountered with the `tags` variable in *front matter*, however, is that it causes the tag "posts" to show up in the list of tags on every post, which feels kind of redundant. Of course it belongs to posts, that's the majority of the content on this site! Problem is, if I remove it from *front matter* data I lose the one mechanism that gives me all posts in a *collection* I can easily loop over for generating the post listing page. Bummer! 😖
That's when I discovered I can also define collections via the Configuration API in `eleventy.config.js`, which allows me to circumvent this issue. It's like I cat eat my cake and have it, too!
```js
export default async function (eleventyConfig) {
eleventyConfig.addCollection('posts', (collection) => collection.getFilteredByGlob('./src/posts/*.md'));
};
```
Adding collections via the config also has other benefits like being able to pre-filter and sort them. That way they're already the way they need to be at the time of consumption in templates and layouts[^jsarraymutate].
[^jsarraymutate]: In fact, the [docs](https://www.11ty.dev/docs/collections/#do-not-use-array-reverse()) specifically tell you to avoid calling array methods on collections that mutate arrays *in place* which can have undesirable side-effects and cause you a lot of headaches. So, yeah, don't do that!
## Turning it up to 11ty
At this stage the blog really started to come together. However, I still had a few requirements that were not covered by the basic package.
For one I really like having callouts in case I write up tutorials that could really benefit from some notes, warnings and such. Having footnotes also would be really nice, so side-thoughts don't clutter the main text. I thought it was covered by basic Markdown, but turns out it isn't or it's part of some flavor of Markdown that Eleventy doesn't do. Syntax highlighting in code blocks are also really good for code snippet readability!
Also, since this is a blog after all, it would really benefit from having an RSS feed people can subscribe to. And even though I'll try not to go too crazy on images, whenever I do decide to add some to a post, it should be light on the bandwidth, so an image processing pipeline would be pretty dope!
I found all of that possible with the help of plugins for Eleventy itself and the extensible parsers it uses!
### Extending Eleventy
Although Eleventy is still relatively new (v1 was released in January 2022), there's already quite a few plugins from [its creators](https://www.11ty.dev/docs/plugins/official/) and [the community](https://www.11ty.dev/docs/plugins/community/).
In addition to that, it also uses parsers which are themselves very extensible. For example, rendering Markdown is done via `markdown-it` and there exist [a plethora of plugins](https://www.npmjs.com/search?q=keywords:markdown-it-plugin) to extend the Markdown syntax available to me.
The handy-dandy `addPlugin()` helper method adds plugins to Eleventy:
```js
import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';
import { feedPlugin } from '@11ty/eleventy-plugin-rss';
import eleventyPluginLucideIcons from '@grimlink/eleventy-plugin-lucide-icons';
import eleventyPluginNavigation from '@11ty/eleventy-navigation';
import eleventyPluginSyntaxHighlight from '@11ty/eleventy-plugin-syntaxhighlight';
export default async function (eleventyConfig) {
eleventyConfig.addPlugin(eleventyPluginLucideIcons);
eleventyConfig.addPlugin(eleventyPluginNavigation);
eleventyConfig.addPlugin(eleventyPluginSyntaxHighlight);
eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
// Plugin options go here
});
eleventyConfig.addPlugin(feedPlugin, {
// Plugin options go here
});
};
```
Most plugins for Eleventy still use CommonJS in their examples, but they're still easily added by treating them like default exports using regular ESM `import` statements. Only some plugins have named exports. Some also allow for configuration, like the image transform plugin, which accepts some parameters for image sizes, formats and default attributes on `<img>` tags. The RSS feed plugin has some required options that must be set for it to work, like the type of feed, output file name, number of entries, which collection to include, etc. The plugin's documentation will likely tell if it's configurable and which options are required and which are optional.
### Adding navigation
Eleventy offers a 1st party navigation plugin. It operates on collections to generate the navigation in templates via filters. The most simple way to add navigation to a site is:
```twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<header>
<nav>{% raw %}{{ collections.all | eleventyNavigation | eleventyNavigationToHtml | safe }}{% endraw %}</nav>
</header>
</body>
</html>
```
This outputs an `<ul>` with all the pages that have the `eleventyNavigation` *front matter* set up:
```md
---
eleventyNavigation:
key: home
---
# Rawr there! 👋🏻
Welcome to the dragon's hoard
```
```md
---
eleventyNavigation:
key: about
---
# About me
I'm a durgin! I go rawr! I also code 👾
```
That gives us 2 entries in the navigation. Every navigation element must have at least a `key` by which it is identified in the navigation structure. Optional parameters include `parent`, which is used to refer to a `key` to get nested navigation items. If the entries need to be reordered, the `order` parameter does exactly that.
Now, having just an `<ul>` full of links doesn't exactly make for the prettiest navigation. For that reason, the `eleventyNavigationToHtml` filter accepts an option object that lets you customize the elements and classes it will put in the markup of the final page. That gives you enough control over the visual design of the navigation, like hover states and highlighting the currently active page.
Something I wish the docs were clearer about, though, is the fact that this is not entirely solvable in *front matter* as shown in the [examples](https://www.11ty.dev/docs/plugins/navigation/#advanced-all-rendering-options-for-eleventynavigationtohtml). Added to my `base.njk` layout it just kept throwing errors about `eleventyNavigation` not being defined (I guess it means the value for the `activeKey`?) which is confusing as hell when it's right there. So either the example given in the docs is just flat-out wrong or the filter doesn't get passed the instance of `eleventyNavigation`. That kept me busy for longer than I would've liked. Eventually I put it in directly in the options of the filter. Doesn't look as tidy as I would've liked, but it works.
```twig
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<header>
<nav>
{% raw %}{{
collections.all |
eleventyNavigation |
eleventyNavigationToHtml({
listClass: "nav-list",
anchorClass: "nav-link",
activeAnchorClass: "active",
activeKey: eleventyNavigation.key
}) | safe
}}{% endraw %}
</nav>
</header>
</body>
</html>
```
### Pagination
As long as the blog is still in its infancy, the post overview page is still quite manageable. Depending on how frequent I'll be posting, however, this may change. Even though I'll only show the first paragraph of each post in the listing, I don't want readers to have to scroll for ages to find something.
This is where the pagination plugin comes to the rescue! If I want the blog post page to only show a limited number of posts before it's necessary to "flip the page", I just add it to that page like so:
```twig
---
layout: postlist.njk
title: Posts
eleventyNavigation:
key: posts
order: 3
pagination:
data: collections.posts
size: 10
alias: blogposts
reverse: true
---
{% raw %}{% for post in blogposts %}
<article>
<h1>{{ post.data.title }}</h1>
<p>{{ post.data.description }}</p>
<a href="{{ post.url }}" class="button">Read on</a>
</article>
{% endfor %}{% endraw %}
```
As you can see, pagination is done with the aptly named `pagination` object in *front matter*. For it to work properly it needs data to operate on, in this case the `posts` collection, and the maximum number of items per page. Optional parameters I've used here are `alias` for reference in the actual template code and `reverse` to sort posts from newest to oldest.
Adding it to the page where the posts will eventually get listed:
```twig
<nav>
<ol class="pagination">
<li><a href="{% raw %}{{ pagination.href.first }}{% endraw %}">First Page</a></li>
<li><a href="{% raw %}{{ pagination.href.previous }}{% endraw %}">Previous Page</a></li>
{% raw %}{%- for pageEntry in pagination.pages %}
<li>
<a href="{{ pagination.hrefs[ loop.index0 ] }}"
{% if page.url == pagination.hrefs[ loop.index0 ] %}
aria-current="page"
{% endif %}
>
<!-- The aria-current attribute gives us an additional selector for CSS -->
<!-- It's also a quick accessibility win! -->
{{ loop.index }}
</a>
</li>
{%- endfor %}{% endraw %}
<li><a href="{% raw %}{{ pagination.href.next }}{% endraw %}">Next Page</a></li>
<li><a href="{% raw %}{{ pagination.href.last }}{% endraw %}">Last Page</a></li>
</ol>
</nav>
```
It might be unnecessary to show pagination when there's less than 10 posts (only 1 page). So let's hide it when it would be otherwise unnecessary:
```twig
{% raw %}{% if pagination.pages.length > 1 %}
<nav>
<ol class="pagination">
<li><a href="{{ pagination.href.first }}">First Page</a></li>
<li><a href="{{ pagination.href.previous }}">Previous Page</a></li>
{%- for pageEntry in pagination.pages %}
<li>
<a href="{{ pagination.hrefs[ loop.index0 ] }}"
{% if page.url == pagination.hrefs[ loop.index0 ] %}
aria-current="page"
{% endif %}
>
<!-- The aria-current attribute gives us an additional selector for CSS -->
<!-- It's also a quick accessibility win! -->
{{ loop.index }}
</a>
</li>
{%- endfor %}
<li><a href="{{ pagination.href.next }}">Next Page</a></li>
<li><a href="{{ pagination.href.last }}">Last Page</a></li>
</ol>
</nav>
{% endif %}{% endraw %}
```
We can take this even further: If the visitor is viewing the first page in the pagination it wouldn't make much sense for the first and previous links to be clickable. The same goes for the next and last links. So let's give them elements that aren't clickable by default and add the `aria-disabled` attributes for both communicating to assistive technologies that the element is disabled and to have an additional way to target them in CSS.
```twig
{% raw %}{% if pagination.pages.length > 1 %}
<nav aria-label="Post pages">
<ol class="pagination">
<li>
{% if page.url != pagination.href.first %}
<a href="{{ pagination.href.first }}">First Page</a>
{% else %}
<span aria-disabled="true">First Page</span>
{% endif %}
</li>
<li>
{% if pagination.href.previous %}
<a href="{{ pagination.href.previous }}">Previous Page</a>
{% else %}
<span aria-disabled="true">Previous Page</span>
{% endif %}
</li>
<!-- Loop over pagination.pages array and add the pages to the list -->
{%- for pageEntry in pagination.pages %}
<li>
<a href="{{ pagination.hrefs[ loop.index0 ] }}"
{% if page.url == pagination.hrefs[ loop.index0 ] %}
aria-current="page"
{% endif %}
>
{{ loop.index }}
</a>
</li>
{%- endfor %}
<li>
{% if pagination.href.next %}
<a href="{{ pagination.href.next }}">Next Page</a>
{% else %}
<span aria-disabled="true">Next Page</span>
{% endif %}
</li>
<li>
{% if page.url != pagination.href.last %}
<a href="{{ pagination.href.last }}">Last Page</a>
{% else %}
<span aria-disabled="true">Last Page</span>
{% endif %}
</li>
</ol>
</nav>
{% endif %}{% endraw %}
```
This might seem a bit excessive, but the additional if-statements allow for some clear visual cues for when a visitor has reached the beginning/end of the list and prevents clicking on navigation elements that shouldn't be clickable.
### Extending bundled libraries
As mentioned, Eleventy comes with a pretty extensible Markdown parser called `markdown-it`. Extending the included libraries is done via the `amendLibrary()` helper function:
```js
import markdownIt from 'markdown-it';
import markdownItAbbr from 'markdown-it-abbr';
import markdownItAnchor from 'markdown-it-anchor';
import markdownItCallouts from 'markdown-it-obsidian-callouts';
import markdownItCollapsible from 'markdown-it-collapsible';
import markdownItFootnote from 'markdown-it-footnote';
export default async function (eleventyConfig) {
eleventyConfig.setLibrary('md', markdownIt({
html: true,
linkify: true,
typographer: true
}));
eleventyConfig.amendLibrary('md', (mdLib) =>
mdLib
.use(markdownItAbbr)
.use(markdownItAnchor)
.use(markdownItCollapsible)
.use(markdownItCallouts)
.use(markdownItFootnote)
);
};
```
The `mdLib` parameter makes the library interface of the chosen file type (`'md'`) available. The `use()` method is the same method that's showcased in the majority of [`markdown-it` plugins on NPM](https://www.npmjs.com/search?q=keywords%3Amarkdown%2Dit%2Dplugin).
### Adding Tailwind
Tailwind is a popular CSS framework that gets you quick results very easily without ever writing a single line of CSS yourself. I've been using it since this year and after some initial learning curve I've gotten some good use out of it[^sebinontailwind].
[^sebinontailwind]: I've gone [on record](https://meow.social/@SebinNyshkim/111771456618970816) in the past that I'm not particularly fond of the "CSS class barf" that Tailwind makes you write in your markup. I still think it looks awful, but the design clearly targets re-usable components written in frameworks like React and Vue, and the speed at which it allows you to get good looking results is undeniable.
Adding Tailwind to an Eleventy project is pretty straight-forward:
```bash
npm install tailwindcss @tailwindcss/cli
```
Starting with v4, integrating Tailwind is as easy as creating a CSS file and adding a single line to it:
```css
@import "tailwindcss";
```
In order for Tailwind and Eleventy to run next to each other the entries in the `script` section of `package.json` need to be adjusted a bit:
```json
"scripts": {
"start": "eleventy --serve & npx @tailwindcss/cli -i ./src/css/style.css -o ./public/css/style.css --watch",
"build": "ELEVENTY_PRODUCTION=true eleventy && NODE_ENV=production npx tailwindcss -i ./src/css/style.css -o ./public/css/style.css --minify"
}
```
And that's about it! Tailwind is now ready to use in Eleventy templates and layouts.
There's just one problem: I can't add Tailwind classes to my Markdown files! 😨
### Tailwind Typography
Not to worry, though! Tailwind offers a 1st party typography plugin that is specifically made for this use case.
It's a quick install with `npm`:
```bash
npm install @tailwindcss/typography
```
And just one additional line of CSS:
```css
@import "tailwindcss";
@plugin '@tailwindcss/typography';
```
Now there's a whole set of new utility classes available. Tailwind Typography comes with sensible default styles for prose content. Just adding the `prose` CSS class to the blog post layout is enough:
```twig
<article class="prose">
{% raw %}{{ content | safe }}{% endraw %}
</article>
```
Yes, that's really all there is to it! Tailwind Typography makes heavy use of the CSS [`:where()` pseudo-class](https://developer.mozilla.org/en-US/docs/Web/CSS/:where) for styling descendants of the element with the `prose` class. If something in the styling of descendants is not to my liking, Tailwind Typography comes with its own set of modifiers to adjust the formatting of the most common content tags (`<h1>` - `<h6>`, `<p>`, `<a>`, `<strong>`, `<em>`, `<ul>`, `<ol>`, `<li>`, `<img>`, `<table>`, `<tr>`, `<th>`, `<td>`, `<blockquote>`, and so on). And if something should absolutely not be formatted like prose the `not-prose` class prevents that.
See the Tailwind Typography [GitHub](https://github.com/tailwindlabs/tailwindcss-typography#basic-usage) page for a primer on how to get the most out of it.
## Documentation woes
As fun as it was learning Eleventy and being really impressed with what it can help you accomplish, the learning curve at times was not. Lots of that comes down to its documentation, which could use lots more love.
Don't get me wrong, it gets the job done and large parts of my blog are lifted straight from there. But when learning something new, especially something like a framework, I expect the docs to be better than "just barely made it". It's the make or break factor whether it can garner wider adoption.
Getting the blog to a functionally and visually pleasing state took me about 3 days, most of which was spent on trying to figure out problems the docs couldn't provide a clear answer for. It was hard for me to decide if I simply used it wrong, or I ran into bugs or some limitation that I wasn't made aware of. Sure, there's the [pitfall section](https://www.11ty.dev/docs/pitfalls/), but it didn't cover the issues I kept running into.
What made this even more frustrating was that searching online brought up the Eleventy docs repeatedly as the top result—for multiple major versions, too! So when I found myself in this sort of feedback loop, it just added to my frustration, that the docs didn't provide what I needed to understand the issue I was having.
Sure, there are community contributions linked all over the docs, but my point is if there's docs, I expect them to be comprehensive and everything else to be complementary. The way the docs also repeatedly link to tutorial videos and webcasts instead of a clear textual explanation that's indexable by search engines is something I find very annoying. I don't want to watch video tutorials for v2 or v1 from 2 or 4 years ago that have aged poorly. Neither do I want to read the outdated tutorials of other people the official docs link to which haven't seen updates in years. Just 👏🏻 update 👏🏻 the 👏🏻 official 👏🏻 docs!
Good examples that come to mind in this regard are the [React](https://react.dev/learn) and [Vue](https://vuejs.org/guide/introduction.html) docs, which are heavily tutorialized and leave the in-depth stuff for later or for when I really need to understand the inner workings better. This is where I feel the Eleventy docs can be very hit or miss.
There's lots of abstract examples on how to use the navigation and pagination components, but both are missing clear examples of how the data structure looks like. Not just on a surface level, I would've liked examples of how it looks like when it's populated with actual data like it would be when building a site with Eleventy—not just the usual `testdata: [foo, bar, baz]`. And as mentioned earlier, some examples given don't work as shown, as is the case with the Eleventy navigation plugin, where the docs make it look like it should work, but it actually doesn't—either because the examples are outdated or incomplete.
And while the broad support for every templating language under the sun is nice and all, it's really excessive, to the point I had a hard time deciding which one I should even start with. The differences all seem very minor and if I'm being completely honest, most look like some variation of EJS and Handlebars. I didn't feel like I'm really making an informed decision, more like a gut feeling of what the majority of the community around Eleventy chose to go with and hop on the bandwagon. At least that is sure to get me lots of example code to look at on GitHub when I'm getting desperate, I guess. Still, I would prefer if Eleventy would either reduce the number of templating languages (which would certainly also reduce the amount of work involved to support them all) or give some clear recommendations on which are best supported by the developers of Eleventy (not just based on popularity).
When it comes to docs of your own framework, you can't apply the Node.js developer mindset of just pulling in tons of other people's work and call it a day. At some point you're begging to have someone pull a leftpad on you.
## Closing thoughts
Even though that last part about the docs might come off a little ranty, I still like what I've built with Eleventy and I intend to keep it around.
It's what I want for a blog based around Markdown and the simplicity of just having to serve static files means it's easy to host from home and actually own my little corner of the web.
Who knows? Maybe I'll use it for other projects or migrate some other existing projects over. Time will tell.
Well, I've been rambling for long enough now. This post has been in the oven for well over a week and has received a complete rewrite in-between 😂 (I'm a hopeless perfectionist)
And so, I declare this new foray into me blogging officially 🎉 **OPEN** 🥳