Generating a static blog with Eleventy

Post #1 published on by Tobias Fedder

The first time I've seen someone use Eleventy (11ty) — although I forgot when that was, I do remember that — I thought that this static site generator would be a good choice for me when I would eventually create an actual website for myself. Over the years, while I have been daydreaming about building my own website, I regularly checked what was going on with 11ty, watched and read a few tutorials, and did the Getting started on 11ty.dev a dozen times. Now, finally, I will try to actually build something with it, not following someone else's happy path but instead building step by step what I feel my website should be like. Let's see how that goes.

Setting up stuff

First I create a directory, next to all the other coding stuff I try or test and then abandon shortly after, in my Ubuntu WSL. There I have git and NodeJS (including npm) already installed, so I just initialize both creating the git repository and the package.json file. Then I get to installing 11ty as a development dependency, because I only need it during the build on my machine, where it generates the static files I am going to serve.

npm install @11ty/eleventy --save-dev 

Next step: making the first adjustment to the 11ty configuration, because I prefer to put the templates and content into a src/ subdirectory. To tell 11ty to read from there instead of the root of my repository I create the eleventy.config.js with the following lines.

module.exports = function(conf) {
	return {
		dir: {
			input: "src",
			output: "_site"
		}
	}
}

Now as a starting point I put an index.md with one line of Markdown in the src/ directory, reading # Welcome. A quick edit in my package.json adding two lines in "scripts" and then I let 11ty do its magic, generating a index.html and serving it on http://localhost:8080.

⋮
"scripts": {
	"dev": "eleventy --serve",
	"build": "eleventy"
}
⋮
npm run dev

And there it is, a welcoming headline in my browser. A great success. Let me fix this point in time with a git commit. Although, seeing the changes that happened so far, I should setup a .gitignore file first. There are some directories already, that I do not need to track. Many ready-to-use .gitignore files can be found on the web for all kinds of repositories, but I will keep it short and clean, only adding what I need when I get to it. For now that includes the node_modules directory, obviously, and the _site directory where 11ty outputs the generated files.

# eleventy
_site

# node
node_modules

On a closer look at the source code of that welcome page though, there is an issue. While my one line of Markdown resulted in one valid line of HTML, it is not a valid HTML document 😱. Okay, I am not really shocked, as I mentioned earlier, I've done a few 11ty tutorials before. Nonetheless it serves me as a good reminder that, despite all 11ty magic, I am still in charge — meaning I can still shoot myself in the foot. Let's guard against this happening in future builds.

The Nu Html Checker, which can be found online at https://validator.w3.org/nu/, tells me that I am missing the <!DOCTYPE html> and the <title> element; it also strongly suggests to declare the language of the document. To make checking the validity more comfortable than visiting the validator page on every build for every file I will install yet another package. The Nu Html Checker is, besides other options, available as a npm package. It contains in essence the vnu.jar file and a tiny bit of wrapper code. Based on the example in that package's README.md it is fairly easy to create a test that checks the validity of the files in the _site/ directory. I store it as tests/build/w3c_validity.test.js.

const { exec } = require('child_process')
const vnu = require('vnu-jar')

test('generated files are valid according to W3C specifications', () => {
	return new Promise((resolve, _) => {
		exec(`java -jar ${vnu} _site`, (error, stdout, stderr) => {
			if (error) {
				resolve(error.message)
			} else {
				resolve(stderr)
			}
		})
	}).then(result => {
		expect(result).toBe('')
	})
})

Did I say test? Let's also throw a test runner in the mix. I heard jest is fine. I don't know. Never picked a test runner before. I just used whatever was already installed in any given project.
I update that package.json again and run the test I just created.

npm install vnu-jar --save-dev 
npm install jest --save-dev 
⋮
"scripts": {
	"dev": "eleventy --serve",
	"test": "jest",
	"build": "eleventy"
}
⋮
npm run test

Okay, the test is working, time to fix the output.

Starting with templates

Within the src/ directory I create the directory _includes/layouts/ and therein the file base.njk. According to the validator the generated file had three issues, fixing them in minimal fashion results in the following template.

<!DOCTYPE html>
<html lang="en">
<title>tfedder.de</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ content | safe }}

I have additionally put the <meta name="viewport" …> in there. This is not needed for validity but without this it would be unnecessarily scaled down in some browsers on mobile and thereby close to unuseable. So far I am still missing some metadata that a well‐managed webpage would provide, but these are not strictly necessary, so I will add them when I get there.

I am using Nunjucks here. It is just one of many templating languages 11ty supports out of the box, but it seems to be one of the more popular ones in the 11ty community. I also have used it a little bit in the past, so that is why I picked it. It has some nice features and most of them can be used well with 11ty. For the content — so far still made from just one line of Markdown — to be embedded in the frame the template describes, I need to provide that information to 11ty via the layout key. A straight‐forward way, at least in the beginning, is to just put that information in the Markdown file. At the very top of the index.md I add the so called front matter, which is data enclosed by three hyphen‐minuses above and under. We have a few options in terms of data format, I will use YAML syntax.

---
layout: "layouts/base.njk"
---

# Welcome

Since you're asking, a hyphen‐minus is that ASCII character (sitting at 2D16 or 010 11012 respectively) that did all the heavy lifting back in the days when the Americans couldn't afford more than seven bits for storing a character and therefore simplified all kinds of vertically centered horizontal lines into this one symbol: -
Typewriters, and their descendants: keyboards, aren't innocent bystanders on this matter either. Whatever, where were we?

npm run build
npm run test

11ty performs its magic one more time, then a quick test afterwards and — hooray — the build still works and the test is satisfied.

Multiple languages

I plan to blog in English only, but I do not envision my whole website to be exclusively in English. I am German and plan to have other content on here as well, where some accompanying text might be in — you guessed it — German. The blogging part is what I'm starting with, but at least a homepage in my native language needs to exist from the beginning. By now I added a paragraph after the headline in the Markdown file; just explaining in a few sentences on what kind of website that unfortunate visitor just landed. And the same thing has to be achieved with a German counterpart of that page.

Failing internationalization

The current version of 11ty, as I'm writing this, is 2.0.1, in which a plugin for internationalization (i18n) is included by default. Great! But for the homepage, that page usually placed at /, it is recommended to use server‐side content negotiation. That means that the web server hosting the HTML documents will have multiple files in different languages for the same path and decide to serve one of them based on data such as the Accept-Language header send by the users browser. This is an excellent recommendation, because you want to send every visitor coming to your website, via your domain without any further path, the most fitting page for them to land on. It is also a form of content hiding, but an acceptable one I'd say.

In my situation that is tough luck though because the web server I'm going to use to serve this website doesn't support content negotiation yet, at least not out of the box. I will get into which web server that is, and why I use it, in other blog posts.

After wheighing up my options I decide to put the German homepage at / and will leave it to far future me to patch that unfortunate circumstance somehow. One reason for that is, that a website with a de top‐level domain being in English on / but in German on /de/ just feels a bit weird to me. So I copy the index.md file, call it en.md and then translate the headline and the paragraph in the index.md. So far so good, but now my tiny, five lines long base.njk template isn't good enough anymore. From here on I need to be able to declare different languages for the documents. Therefore I add to the front matter of both Markdown files the key lang with the values "en" and "de" respectively. Then I make a small change to the base.njk, allowing to fill the value of the lang attribute in the <html> tag with the data from the files' front matter.

<!DOCTYPE html>
<html lang="{{ lang }}">
<title>tfedder.de</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ content | safe }}

Et voilà, I get two homepages in different languages and neither of them is in French — excellent.
Continuing with challenges in regards to multiple languages on a website and despite me suggesting just a handful of sentences ago that it will be far future me who needs to find a solution to the multiple‐homepages‐in‐different‐languages‐without‐content‐negotiation‐problem, there is something I can do before a user gets confronted with my content negotiation lacking web server. In case a user finds my website through web search, I can help the search engine suggest the fitting homepage by letting it know that the two homepages are language specific alternatives to each other.

Signaling alternate localizations

One way to do that is placing <link> elements with a rel="alternate", a href and a hreflang attribute in the documents' <head>, to signal the URL to and the language of any alternative to the current page. A quick web search reveals that Google says the URLs in these <link type="alternate"> elements need to be absolute URLs, meaning they need to include the protocol and the full domain name in addition to the path. Well, if web search dominating Google demands you form your search engine metadata a certain way, you either do it or give up on web search right away. Now in contrast to throwing around URLs with fully qualified domain names (FQDN), 11ty allows me to link content with ease by using the origin‐relative path, starting from the configured input directory to the file the generated document will be based on, or by giving the explicitly set permalink of a document. The beauty of the origin‐relative path is, that it works from any host, no matter its domain, including localhost when served, for example, by 11ty's dev‐server.

Okay, so writing URLs with FQDNs is burdensome. Are there any other ways to communicate localizations? Yes, there is one. Instead of setting <link> elements in the documents' <head>, it is possible to have the web server send HTTP response headers, so that a response to a requested resource contains a list of the alternatives' URLs and their languages, even including the one that the response body contains, to know its localization. Once again I weigh up the options and conclude that putting all that content metadata away from the content and instead into my server configuration would be even worse; so let's enable 11ty to write fully qualified URLs where I need it to.

I can think of two pairs of pages that I want to provide in English and German in the foreseeable future. Therefore I am fine with a simple solution that requires a fair amount of manual typing and synching, instead of blocking myself by trying to come up with an elegant but complex solution. That is why I will just write all that information into the front matter of both homepages into an array with the key languageAlternatives. The data is exactly the same for both, as they are supposed to contain the link to themselves as well.

---
layout: "layouts/base.njk"
lang: "en"
languageAlternatives:
- hreflang: en
  href: /en/
- hreflang: de
  href: /
- hreflang: x-default
  href: /en/
---

# Tobi's website
⋮

I need to add some functionality to the eleventy.config.js to make use of that information. But first, I need other information as well. I want 11ty to write the absolute URLs only if I am generating the files to be served from my public web server, not locally during development and testing. I am going to call it production — although that term feels a bit pretentious for a bunch of static files. For 11ty to find out about my intentions when building, I add an environment variable (ENV) to the build script in my package.json.

⋮
"scripts": {
	⋮
	"build": "BUILD_TARGET=prod eleventy"
}
⋮

In the eleventy.config.js I then add two unmutable variables based on the value of the BUILD_TARGET ENV. I use their values in functions in the configuration and add one of those functions as a filter, so I can use it in the templates.

module.exports = function(conf) {
	
	const isProd = process.env.BUILD_TARGET === "prod"
	const fqdn = isProd ? "tfedder.de" : ""

	conf.addFilter("generateLanguageAlternatives", generate_language_alternatives)

	return {
		dir: {
			input: "src",
			output: "_site"
		}
	}

	function generate_language_alternatives(alternatives) {
		if (alternatives === undefined) {
			return ""
		}
		
		let alternateLinks = ""
		alternatives.forEach(alt => {
			if(!alt.hreflang || !alt.href) {
				throw new Error(
					`Invalid languageAlternative: `
				+ `{ hreflang: ${alt.hreflang}, href: ${alt.href} }`
				)
			}

			alternateLinks +=
				`<link rel="alternate" `
			+ `hreflang="${alt.hreflang}" `
			+ `href="${prepend_proto_and_fqdn(alt.href)}"`
			+ `>\n`
		});

		return alternateLinks
	}

	function prepend_proto_and_fqdn(pathFromRoot) {
		return (fqdn ? "https://" + fqdn : "") + pathFromRoot
	}

}

If it isn't prod I will leave it as an empty string for now, but I expect I will change it to another FQDN once my development and testing setup has matured. Now all that is left to do, to put those <link> elements in my documents, is to insert one additional line in the base.njk template.

<!DOCTYPE html>
<html lang="{{ lang }}">
<title>tfedder.de</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ languageAlternatives | generateLanguageAlternatives | safe }}
{{ content | safe }}

A blog post

Cool, multiple hours in and I got two landing pages — am I doing this right? What was it that I said I wanted to achieve here? Is this the thing that frees me from the syndrome? While trying to build a website on my own: actual impostor confirmed‽
⸻Stop! Take a deep breath! You aren't slow, it's just that everyone else is reckless. Keep going, create a subdirectory called src/blog/.

Writing a blog post, or documentation for that matter, feels easier to me when markup, code and formatting get out of the way. That is why I'm interested in writing Markdown and just injecting HTML or Nunjucks syntax where absolutely needed. While putting HTML into Markdown is fine out of the box, the usage of Nunjucks requires an additional line to the object the 11ty configuration returns.

module.exports = function(conf) {
	⋮
	return {
		dir: {…},

		markdownTemplateEngine: "njk"
	}
	⋮

Another aspect that helps is keeping the texts organized. One way to do that is starting the filenames with a date; so I will do that for this post's source Markdown file, calling it 2023-08-12-generating-a-static-blog-with-eleventy.md.

I have been under the impression — wrongfully, as it turned out — that 11ty would leave out the date from the path of the generated document by default. But luckily, it takes only a tiny bit of configuration, that is well described in 11ty's documentation. 11ty provides a lot of useful information about the input file while going through the rendering process. I can get my desired result by using the keys permalink and page.filePathStem. Since the blog posts are in need of more layout than just my base template anyway, I will create a new template blog_post.njk as well.

---
layout: "layouts/base.njk"
permalink: "{{ page.filePathStem }}/"
---
{{content | safe }}

And instead of writing values, that should be the same for all my blog posts, in the front matter of every Markdown file, I create what 11ty calls a Directory Data File in the src/blog/ subdirectory named blog.11tydata.json. Therein I can set all the values that should be set to all blog posts, or at least what they are supposed to default to.

{
	"layout": "layouts/blog_post.njk",
}

That is a lot, I know, but before I expand on this I take a moment to celebrate that 11ty now generates the document of this post with the path I intend it to be. And as before, great success is followed by grave shock. I haven't set the lang attribute for the blog post document yet, so it was rendered with an empty value and my validity checker doesn't even care. In the HTML specification I find, that having an empty string as a value for lang is supposed to indicate, that the primary language of the element and its content is unknown — fair enough. But I do know, I just failed to provide that information. As you may have noticed with the function for generating the alternate links, I'd prefer the build to fail on incomplete data. Therefore I think it is time to add the next filter to my 11ty configuration.

module.exports = function(conf) {
	⋮
	conf.addFilter("failIfMissing", fail_if_missing)
	⋮
	function fail_if_missing(value) {
		if(!value) throw new Error("A necessary value is missing!")
		return value
	}
	⋮

That filter will see a good amount of utilization, starting with checking that I provide the primary language of all my documents in the base.njk template.

<!DOCTYPE html>
<html lang="{{lang | failIfMissing }}">
⋮

In regards to the blog posts it is fairly easy, I just add it to the data directory file.

{
	"layout": "layouts/blog_post.njk",
	"lang": "en"
}

More metadata

The page for the blog post gets rendered, it is time to output different <title>s for different pages. With only the two homepages it wasn't ideal but fine, now it isn't. So far it seems to me that using the same text for the <title> and the <h1> is good enough, therefore I move the text in each file from the first line of Markdown into the above front matter. Except from putting it into the headline, the same goes for metadata regarding the description of each page.

---
description: "From 0 to minimal blog using 11ty. I tell you…" 
title: "Generating a static blog with 11ty"
---
The first time I've seen someone use Eleventy (11ty) …

In the base.njk template I add the title text in both places, as well as some metadata. I also feel like adding <main> is a good idea.

⋮
<title>{{ title | failIfMissing }} | tfedder.de</title>
⋮
<meta name="author" content="{{ author }}">
<meta name="description" content="{{ description | failIfMissing }}">
<meta name="generator" content="{{ eleventy.generator }}">
{{ languageAlternatives | generateLanguageAlternatives | safe }}
<main>
<h1>{{ title | failIfMissing }}</h1> 
{{ content | safe }}

In regards to the author of the documents, well, that is me and only me for everything on this website, and will probably be that way forever. But besides the unlikely event, that others will write something on here, there is a chance that I want to change the name under which I write here. In addition, I want to render whatever name I choose in other contexts as well, setting and changing it just in one place seems useful. That one place is the 11ty configuration where I add it as global data.

module.exports = function(conf) {
	⋮
	conf.addGlobalData("author", "Tobias Fedder")
	⋮

Lastly, I want to express my gratitude to 11ty, the generator that makes it so easy to create my website by mixing templates and data. That is why I name it as the generator in the documents' metadata. When someone visits this brutally boring looking website — especially as I am writing this, when there is almost no CSS present — they can look into the source and appreciate that it was created with 11ty.

A tiny bit of CSS

Did I just say almost no CSS? Yes indeed, while writing this there have been a few issues with the default user agent styles I could not put up with. The lines of prose needed to be limited in length for readability and centered for balance, while the code snippets should be able to use additional space to the right where available. I therefore added a simple grid and some arbitrary tweaks to the typography. No, wait, what I meant to say is: I thoughtfully collected high impact style adjustments to provide only the necessary and thereby perfect set of critical CSS.

⋮
<title>{{ title | failIfMissing }} | tfedder.de</title>
<style>
	body {
		font-family: system-ui;
		margin: 0;
	}
	main {
		display: grid;
		grid-template-columns: 1rem minmax(0, 1fr) min(calc(100% - 2rem), 60ch) minmax(0, 1fr) 1rem;
	}
	main > * {
		grid-column-start: 3;
	}
	main > h1 {
		grid-column: 2 / 5;
		text-align: center;
	}
	main > :is(h2, h3, pre) {
		grid-column-end: 5;
	}
	main :where(p) {
		line-height: 1.7;
	} 
	pre code {
		tab-size: 2;
		white-space: pre-wrap;
		word-wrap: break-word;
	}
</style>
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
⋮

Good catch! Yes, I inserted yet another <meta> the name="color-scheme". The browsers' defaults for dark theme aren't too bad, so in case you insist on it, I am not stopping you.

More visible info on blog posts

Talking about looks, I want readers to have a look at my posts and then instantly know who wrote it and when. I will put that info right under the title — meaning the <h1> in this case. The date I intend to claim a post has been released, you may remember, is the first part of the filename I give to my blog posts. 11ty out of the box reads that filename (the whole path even) and provides a date object in page.date accordingly. I ❤️ 11ty. I want to show these two data points directly after the <h1>, and that means that it is time to move that element into other templates than base.njk. I create the homepage.njk template then add to it and the blog_post.njk a <main> element containing the headline rendering the title value, while the latter template also contains the line for author and date.

---
layout: "layouts/base.njk"
permalink: "{{ page.filePathStem }}/" }} 
---
<main>
	<h1>{{ title | failIfMissing }}</h1>
	<p>Published on <time>{{ page.date | dateToYearMonthDay }}</time> by {{ author }}</p>
	{{ content | safe }}
</main>

The new filter is there in order to inconvenience all readers from a potential global audience equally in regards to the date format. To achieve this I add the following code to the 11ty configuration.

module.exports = function(conf) {
	⋮
	conf.addFilter("dateToYearMonthDay", date_to_year_month_day)
	⋮
	function date_to_year_month_day(dateString) {
		return (new Date(dateString)).toISOString().slice(0,10)
	}
	⋮

The new homepage.njk template also allows me to put the languageAlternatives data into that one place instead of both homepages' Markdown files — neat.

Blog posts collection

Having created the templates to generate future blog posts with just one Markdown file for each feels pretty good. While I am imagining that, I think I should have an index, a page listing all my posts. 11ty has collections for that. I create one such collection by adding the tag blog_post to the directory data file and thereby to all posts in that directory. To show that collection I more or less copy the example from the 11ty documentation on collections, tweak it a bit, and put it into src/blog.njk.


---
layout: "layouts/base.njk"
lang: "en"
description: "An overview of all blog posts on tfedder.de"
title: "All the blog posts"
---
<main>
<h1>{{ title }}</h1>
<p>My ramblings about the joys of web development or whatever, in descending chronological order.
<ol reversed start={{  collections.blog_posts.length - 1}}>
{%- for post in collections.blog_posts | reverse -%}
  <li>
    <a href="{{ post.url }}">{{ post.data.title }}</a>
    <br>published on <time>{{ post.date | dateToYearMonthDay }}</time>
  </li>
{%- endfor -%}
</ol>
</main>

Nice. What's next?

Showing the next and previous posts

The posts, at least the early ones, will to some extend relate to each other. I think it'd be neat to show the posts before and after at the end of the one you've just read. I can use the newly created collection for that too. Once again, my idea is not at all original, the filters to do that exist already and are documented in such detail that even I can grasp them right away. I add an <aside> to my blog_post.njk and render a link for the previous and following post, given they exist. The code is close to identical with the 11ty documentation. Have you noticed that I think the documentation for 11ty is excellent?


	⋮
	<p>Post #{{ collections.blog_posts | getCollectionItemIndex }} published on <time>{{ page.date | dateToYearMonthDay }}</time> by {{ author }}</p>
	{{ content | safe }}
	<aside>
		{% set prevPost = collections.blog_posts | getPreviousCollectionItem %}
		{% set nextPost = collections.blog_posts | getNextCollectionItem %}
		{% if prevPost %}<p><a href="{{ prevPost.url }}">˂ Previous post: {{ prevPost.data.title }}</a></p>{% endif %}
		{% if nextPost %}<p><a href="{{ nextPost.url }}">Next post: {{ nextPost.data.title }} ˃</a></p>{% endif %}
	</aside>
</main>

Good catch, again! Yes, I've also added the getCollectionItemIndex filter to display the number of the post. Let me indulge in some pedantry by pointing out that it does not in fact return the index, but instead the pages offset from the start of the array that is the collection.
Lucky me. That is exactly what I want. This post is already quite long, so I decided to move my babbling about my motivitation for the creation of this website into another post. Motivation precedes doing, usually, so I feel it has to be inserted before this post you are reading right now, which is the real 1st post of this blog. Counting from zero achieves that for me.

Where else to go?

Showing the two adjacent posts is fine, but what about the overview of all blog posts, or conveying the hierarchy of the website as a whole? I think different kinds of navigation complementing each other can achieve that. I start with putting a link to the overview page in every posts' page by adding a breadcrumb. The eleventyNavigationPlugin will help me with this, so I add it as a development dependency and register it in the 11ty config — exactly as described in the documentation, so I don't dare to put that one in here. Then, to make use of it, I need to add the key eleventyNavigation to the pages' data, therein a key called key and, where applicable, a key called parent referring to the key value of the page one level higher in the hierarchy. The hierarchy here goes as follows: the two homepages get the key Home and Startseite respectively, the blog posts overview is Blog with Home as parent; lastly, each blog post's parent is Blog. What about the key for each blog post?, you ask — I imagine. Well, the title should be a good enough default. Therefore I turn to the directory data file one more time and change it from a .json to a .js file.


module.exports = {
	layout: "layouts/blog_post.njk",
	lang: "en",
	tags: ["blog_posts"],
	eleventyComputed: {
		eleventyNavigation: {
			key: data => data.title,
			parent: "Blog",
		}
	}
}

Adding a breadcrumb

The breadcrumb will be useful on differnt kinds of pages, so I create a template src/_includes/breadcrumb.njk. The template for the overview and the layout template for posts include it. Additionally, I add data in the overview's front matter, a breadcrumbOptions key and therein the key‐value‐pair includeSelf: true, because I prefer not showing the title of a post twice directly next to each other, but I'd like to have the hierarchy made clear on the overview page.


{% set navPages = collections.all | eleventyNavigationBreadcrumb(eleventyNavigation.key, breadcrumbOptions) %}
{% for crumb in navPages %}
	{% if loop.first %}<nav aria-label="breadcrumb"><ol>{% endif %}

		<li>
			<a href="{{ crumb.url }}"
			{% if page.url == crumb.url %}aria-current="page"{% endif %}
			>{{ crumb.title }}</a>
		</li>

	{% if loop.last %}</ol></nav>{% endif %}
{% endfor %}

A <nav> with an <ol> inside, it's a hierarchy after all — beautiful. Actually, not yet beautiful, it's just a numbered list at the top of the page. Let me provide some more super impactful critical CSS to make it look like a breadcrumb navigation.

⋮
nav[aria-label=breadcrumb] ol {
	margin: 0;
	padding: 0;
}
nav[aria-label=breadcrumb] li {
	display: inline;
}
nav[aria-label=breadcrumb] li:not(:last-child)::after {
	content: " ˃"
}
⋮

Providing a site map page

By now you can get from every page to every other page of this website, at least indirectly, by following the links. That can be cumbersome though. One way to simplify the navigation of a website is having a page that lists (almost) all the pages of the website called site map — not to be confused with a sitemap.xml, which is a file that website owners can use to delude themselves into thinking they would help search engines distinguish their pointless make‐work pages from their SEO clickbait.

The navigation plugin already provides a data structure that allows me to easily display the pages hierarchically. I am just missing one data point, the language of the documents. Fortunately, I can just add arbitrary data to the object in eleventyNavigation. A new directory data file does the trick. In src/src.11tydata.js I assign the document's lang of every page to their respective navigation data as docLang.

module.exports = {
	eleventyComputed: {
		eleventyNavigation: {
			docLang: data => data.lang
		}
	}
}

With this additional bit of information the site map is able to indicate that a link target is written in a different language. The hierarchy is displayed by nested ordered lists; in case a navigation entry contains children that list of children will be rendered as an ordered list within that parent's <li> element through a recursive macro in the site_map.njk template.


---
layout: "layouts/base.njk"
lang: "en"
eleventyComputed:
  eleventyNavigation:
    parent: "Home"
    key: "{{ title }}"
  description: "Site map listing the pages of {{ fqdn }}"
breadcrumbOptions:
  includeSelf: true
title: "Site map"
---
{% macro listLevels(listOfPages) %}
	{% for linkPage in listOfPages %}
		{% if loop.first %}<ol {% if linkPage.parentKey == "Blog" %}start=0 {% endif %}>{% endif %}
			<li>
				<a
					href="{{ linkPage.url }}"
					{% if linkPage.docLang != lang %}
						hreflang="{{ linkPage.docLang }}"
						lang="{{ linkPage.docLang }}"
					{% endif %}
					{% if linkPage.url == page.url %}
						aria-current=page
					{% endif %}
				>{{ linkPage.title }}{% if linkPage.docLang != lang %} ({{ linkPage.docLang }}){% endif %}</a>
				{{ listLevels(linkPage.children) }}
			</li>
		{% if loop.last %}</ol>{% endif %}
	{% endfor %}
{% endmacro %}

<main>
{% include "breadcrumb.njk" %}
<h1>{{ title | failIfMissing }}</h1>

{% set navPages = collections.all | eleventyNavigation %}
{{ listLevels(navPages) }}

</main>

Hooray — a page to link them all. But how would anyone get to it? I place a link to it in the <footer>. Which footer? The one I just added to the base.njk.


⋮
<footer>
	<p><a href="/site_map/"{% if page.url == "/site_map/" %} aria-current="page"{% endif %}{% if lang != "en" %} hreflang="en"{% endif %}>Site map{% if lang != "en" %} (en){% endif %}</a></p>
</footer>

That footer needs more love, sure, but it will do the job.

Somewhat Simple Syndication

The very last thing to do for this bunch of documents to qualify as a blog from a technical perspective, is providing a RSS feed. Today RSS stands for Really Simple Syndication and here is what it is good for:
Someone publishes something on the internet every now and then, episodes of a podcast maybe or let's say posts to this very blog. Now imagine you like this post and you are interested, at least slightly, in reading the title of the posts I publish in the future. How will you find out? Will you visit this site regularly for weeks only to find out that nothing happened? Will you then still continue to check regularly?
No, by tomorrow the fact that this website even exists will have left your declarative memory. Wouldn't it be nice if you were notified, but not by an annoying push notification or an email the moment I publish something, and instead when you are in the mood of reading a bit? RSS can do that for us and here is how it works:
One of the paths on this website known as the feed will get you a file that lists all my posts in a machine‐readable format. Whenever I publish a new post, that feed will be updated to account also for the additional post. You can take the URL of the feed and put it into a feed aggregator of your choosing. That could be an application or a service. That RSS reader, RSS client or whatever it calls itself, will check for you whether or not there is something new in any of the feeds you added to it.

This method of syndication is just superior to an email newsletter. You don't have to provide any personal data (except your IP address usually) to get notified. I don't need to store anybody's personal data. I don't have to send any emails. I just need to update a file on my website, by which I mean 11ty should generate that blog_feed.xml. And once again there is a 11ty plugin for that, accompanied by very helpful documentation. I take the Nunjucks template for the RSS XML from that documentation and change it to my liking.


---json
{
  "permalink": "/blog_feed.xml",
  "eleventyExcludeFromCollections": true,
	"title": "tfedder.de blog",
	"subtitle": "Ramblings about the joys of web development or whatever",
	"language": "en"
}
---
<?xml version="1.0" encoding="utf-8"?>{% set protoAndFqdn = "/" | prependProtoAndFqdn %}
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xml:base="{{ protoAndFqdn }}" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>{{ title }}</title>
    <link>{{ protoAndFqdn }}</link>
    <atom:link href="{{ permalink | prependProtoAndFqdn }}" rel="self" type="application/rss+xml" />
    <description>{{ subtitle }}</description>
    <language>{{ language }}</language>
    {%- for post in collections.blog_posts | reverse %}
    {%- set absolutePostUrl = post.url | prependProtoAndFqdn %}
    <item>
      <title>{{ post.data.title }}</title>
      <link>{{ absolutePostUrl }}</link>
      <description>{{ post.data.description }}</description>
      <pubDate>{{ post.date | dateToRfc822 }}</pubDate>
      <dc:creator>{{ author }}</dc:creator>
      <guid>{{ absolutePostUrl }}</guid>
    </item>
    {%- endfor %}
  </channel>
</rss>

Thanks to this template and plugin 11ty will take care of my feed. I link it in the footer so that people in the know looking for it can easily find it, while curious people who don't know will be scared by the wall of XML they will encounter. Something else that is scared by the feed is my validation test, turns out RSS XML is not valid HTML — who would have thought? I should scope the files in the test a little better.

⋮
test('generated HTML files are valid according to W3C specifications', () => {
	return new Promise((resolve, _) => {
		exec(`find _site/ -iname '*.html' -exec java -jar ${vnu} {} +`, (error, stdout, stderr) => {
		⋮

Through the lens of web frontend I figure that this concludes the basic necessities of a blog that just barely works.
It's only running on localhost though, which is a good thing, because I don't want to get in any legal trouble.