Runnable examples in an Eleventy blog: one source for the article and the example page
My article Writing robust Web Components has fourteen examples. Showing only the code was not enough for me: I wanted people to be able to run them in the browser, and the code in the text should be the same code that runs there. That is a job for a static site generator, be it Eleventy, Hugo, Jekyll or Astro: it takes data, here code fragments, and renders it the way I need it, once as a code block in the article and once as a page where the code runs.
Two ways come to mind, and both are annoying:
- Copy the code into the article and into a second file. Sooner or later the two drift apart, and someone trying the example sees something other than what the text says.
- Embed a service such as CodePen. That works, but it brings a third party and an
<iframe>into the page, and the code in the article is still a copy.
It is better to have a single file per example. The generator turns it into both: a page where the code runs, with instructions, expected output and the source below it, and the code block in the article with a link to that page.
The generator reads files as data and turns them into what I need. Here that is pages and code blocks instead of a list of blog posts.
Building it with Eleventy
An example is a .njk file in the examples/ folder of the post. The front matter holds title, instructions and expected output, below it comes the code the way the reader should see it: markup and a script, without <html> and <body>. Eleventy renders the file as a page on its own, so there is no pagination and no data list.
posts/11ty-runnable-examples/
├── index.md
└── examples/
├── 01-mirror.njk
├── 02-region.njk
└── examples.11tydata.jsOne example, slightly shortened:
---
heading: What the console box mirrors
intro: Two console.log() calls and an uncaught error. The box on this page repeats them.
steps:
- Open the console of the developer tools and compare it with the "Console output" box.
expected:
- a normal line
- several values 42 null
- "Uncaught Error: an uncaught error, shown as well"
---
<script>
console.log('a normal line');
console.log('several', 'values', 42, null);
setTimeout(() => {
throw new Error('an uncaught error, shown as well');
}, 0);
</script>To make the files example pages and not blog posts, the folder gets a data file. It says where the pages go, and the rest comes from a small helper:
// posts/11ty-runnable-examples/examples/examples.11tydata.js
import { exampleData } from '../../../_11ty/examples.js';
export default exampleData({
base: '/11ty-runnable-examples/examples/',
article: '/11ty-runnable-examples/'
});The helper:
// _11ty/examples.js
import fs from 'node:fs';
// An excerpt sits between `#region name` and `#endregion`, written as a comment of the
// respective language: `// …`, `/* … */` or `<!-- … -->`.
// MARKER matches every region line (start and end), START the start of one given
// region, END any end.
const MARKER = /^\s*(?:\/\/|\/\*|<!--)\s*#(?:end)?region\b/;
const START = (name) => new RegExp(`^\\s*(?:\\/\\/|\\/\\*|<!--)\\s*#region\\s+${name}\\b`);
const END = /^\s*(?:\/\/|\/\*|<!--)\s*#endregion\b/;
// Removes the indentation the lines have in common.
function dedent(lines) {
const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[\t ]*/)[0].length);
const cut = indents.length ? Math.min(...indents) : 0;
return lines.map((l) => l.slice(cut));
}
// The file without front matter: the code of the example, still with markers.
export function readExample(file) {
return fs.readFileSync(file, 'utf8').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
}
// The whole example without marker lines: what the reader sees as source.
export function withoutMarkers(code) {
return dedent(code.split('\n').filter((l) => !MARKER.test(l)))
.join('\n')
.trim();
}
// Only the lines of one named region. If it is missing or not closed,
// the build fails, so a typo in an article never prints the wrong code.
export function excerpt(code, name) {
const lines = code.split('\n');
const start = lines.findIndex((l) => START(name).test(l));
if (start === -1) throw new Error(`example: no region "${name}"`);
const end = lines.findIndex((l, i) => i > start && END.test(l));
if (end === -1) throw new Error(`example: region "${name}" is not closed`);
return withoutMarkers(lines.slice(start + 1, end).join('\n'));
}
// The data for the `examples/` folder of a post. This is what makes the example pages.
export function exampleData({ base, article }) {
return {
base,
article,
layout: 'layouts/example.njk',
noindex: true,
eleventyComputed: {
permalink: (data) => `${base}${data.page.fileSlug}/`,
eleventyExcludeFromCollections: () => true,
// `01-mirror` is example "01", the number in the file name orders the examples.
number: (data) => data.page.fileSlug.match(/^\d+/)?.[0],
title: (data) => `Example ${data.page.fileSlug.match(/^\d+/)?.[0]}: ${data.heading}`,
// This is what the layout shows below the example.
source: (data) => withoutMarkers(readExample(data.page.inputPath))
}
};
}exampleData() sets the layout for every file in the folder and keeps the pages out of the stream, the feeds, the sitemap and the search. The permalink and the number come from the file name, and source is the code without front matter and markers, which the layout shows below the example. The example code itself stays the content of the page, so the page runs what is in the file. The markers are just comments and do no harm.
The layout is short: instructions and expected output at the top, then a box for the console, the example and finally the source.
{# layouts/example.njk #}
<h1>Example {{ number }}: {{ heading }}</h1>
<p>{{ intro | safe }}</p>
<ol>
{%- for step in steps %}
<li>{{ step | safe }}</li>
{%- endfor %}
</ol>
<pre id="log"></pre>
{% if expected %}<pre>{{ expected | join('\n') }}</pre>{% endif %}
<script>
// Mirrors console.log and errors into the box above.
const log = console.log;
console.log = (...args) => {
log(...args);
document.getElementById('log').append(args.map(String).join(' ') + '\n');
};
addEventListener('error', (e) => document.getElementById('log').append(e.message + '\n'));
</script>
<div id="demo">{{ content | safe }}</div>
{% highlight "html" %}{{ source | safe }}{% endhighlight %}The box is a small script that copies console.log and errors into the page. If you do not want to open the console, you still see the output. The example sits straight in the page through content | safe, without an <iframe>, and runs there with everything else the page has, its CSS included. If you want to shield the examples from that, use an <iframe srcdoc> and give up the layout of the page. This is what that looks like for an example that only shows the mirroring:
<script>
console.log('a normal line');
console.log('several', 'values', 42, null);
setTimeout(() => {
throw new Error('an uncaught error, shown as well');
}, 0);
</script>In the article, a call takes the place of the code:
{% example "01-mirror" %}The shortcode reads the file, puts the code into a code block and appends the link to the page:
// eleventy.config.js
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { excerpt, readExample, withoutMarkers } from './_11ty/examples.js';
export default function (eleventyConfig) {
eleventyConfig.addAsyncShortcode('example', async function (id, region, lang = 'html') {
const dir = path.join(path.dirname(this.page.inputPath), 'examples');
const code = readExample(path.join(dir, `${id}.njk`));
const block = '```' + lang + '\n' + (region ? excerpt(code, region) : withoutMarkers(code)) + '\n```';
const { default: data } = await import(pathToFileURL(path.resolve(dir, 'examples.11tydata.js')).href);
const number = id.match(/^\d+/)[0];
return `${block}\n\n[Run example ${number} →](${data.base}${id}/)`;
});
}this.page.inputPath tells the shortcode which post it sits in, and the folder follows from that. It returns Markdown: Eleventy renders shortcodes before the Markdown, so markdown-it builds the code block afterwards as usual, syntax highlighting included. If an example is missing, the build fails. An article with a dead reference should not go out.
Often the article should show only a part while the page runs the whole example. For that I mark regions in the example:
<button id="count" type="button">Count</button>
<script>
let clicks = 0;
// #region handler
document.getElementById('count').addEventListener('click', () => {
clicks += 1;
console.log('clicks:', clicks);
});
// #endregion
</script>{% example "02-region", "handler", "js" %} then shows only these four lines in the article:
document.getElementById('count').addEventListener('click', () => {
clicks += 1;
console.log('clicks:', clicks);
});The markers come in three forms (//, /* */, <!-- -->), and they never show up in the result. excerpt() removes them and dedents the rest. The page runs the whole example.
The examples in Writing robust Web Components run this way. The links below the code blocks lead to the pages.
That leaves me with a really good architecture for example code. A new post only needs an examples/ folder and a data file, and I hope I get to use it often.