skip to content
dz
5 min read

Lauffähige Beispiele im Blog mit Eleventy: ein Quelltext für Artikel und Beispielseite

Read this in English →

Mein Artikel Web Components robust schreiben hat vierzehn Beispiele. Nur Code zu zeigen, hat mir nicht gereicht: Ich wollte, dass man sie im Browser laufen lassen kann, und der Code im Text sollte derselbe sein, der dort läuft. Das ist eine Aufgabe für einen Static Site Generator, ob Eleventy, Hugo, Jekyll oder Astro: Er nimmt Daten, hier Code-Fragmente, und rendert sie so, wie ich sie brauche, einmal als Codeblock im Artikel und einmal als Seite, auf der der Code läuft.

Naheliegend sind zwei Wege, und beide nerven:

  • Den Code in den Artikel und in eine zweite Datei kopieren. Irgendwann laufen die beiden auseinander, und beim Ausprobieren sieht jemand etwas anderes, als im Text steht.
  • Einen Dienst wie CodePen einbetten. Das geht, holt aber einen Drittanbieter und einen <iframe> in die Seite, und der Code im Artikel bleibt trotzdem eine Kopie.

Besser ist eine einzige Datei pro Beispiel. Aus ihr macht der Generator beides: eine Seite, auf der der Code läuft, mit Anleitung, erwarteter Ausgabe und dem Quelltext darunter, und den Codeblock im Artikel mit einem Link auf diese Seite.

Beispieldatei (.njk)BeispielseiteCodeblock im ArtikelEleventy rendertShortcodeLink

Der Generator liest Dateien als Daten und macht daraus, was ich brauche. Hier sind das Seiten und Codeblöcke statt einer Liste von Blogposts.

Zum Nachbauen mit Eleventy

Ein Beispiel ist eine .njk-Datei im Ordner examples/ des Beitrags. Im Front Matter stehen Titel, Anleitung und erwartete Ausgabe, darunter der Code, so wie der Leser ihn sehen soll: Markup und ein Skript, ohne <html> und <body>. Eleventy rendert die Datei von selbst als Seite, dafür braucht es keine Pagination und keine Datenliste.

posts/11ty-runnable-examples/
├── index.md
└── examples/
    ├── 01-mirror.njk
    ├── 02-region.njk
    └── examples.11tydata.js

Ein Beispiel, leicht gekürzt:

---
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>

Damit aus den Dateien Beispielseiten werden und keine Blogposts, bekommt der Ordner eine Data-Datei. Sie sagt, wo die Seiten liegen, und der Rest kommt aus einer kleinen Hilfsdatei:

// 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/'
});

Die Hilfsdatei:

// _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() setzt für jede Datei im Ordner das Layout und nimmt die Seiten aus dem Stream, den Feeds, der Sitemap und der Suche. Aus dem Dateinamen werden Permalink und Nummer, und source ist der Code ohne Front Matter und Marker, den das Layout unter dem Beispiel zeigt. Der Beispielcode selbst bleibt der Inhalt der Seite, die Seite führt also das aus, was in der Datei steht. Die Marker sind nur Kommentare und stören nicht.

Das Layout ist kurz: oben Anleitung und erwartete Ausgabe, dann ein Kasten für die Konsole, das Beispiel und zuletzt der Quelltext.

{# 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 %}

Der Kasten ist ein kleines Skript, das console.log und Fehler in die Seite kopiert. Wer die Konsole nicht öffnen will, sieht die Ausgabe trotzdem. Das Beispiel steckt mit content | safe direkt in der Seite, ohne <iframe>, und läuft dort mit allem, was die Seite sonst auch hat, auch mit deren CSS. Wer die Beispiele davon abschotten will, nimmt einen <iframe srcdoc> und verzichtet dafür auf das Layout der Seite. So sieht das für ein Beispiel aus, das nur die Spiegelung zeigt:

<script>
	console.log('a normal line');
	console.log('several', 'values', 42, null);

	setTimeout(() => {
		throw new Error('an uncaught error, shown as well');
	}, 0);
</script>

Beispiel 01 ausführen →

Im Artikel steht statt des Codes ein Aufruf:

{% example "01-mirror" %}

Der Shortcode liest die Datei, setzt den Code in einen Codeblock und hängt den Link auf die Seite an:

// 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[Beispiel ${number} ausführen →](${data.base}${id}/)`;
	});
}

this.page.inputPath sagt dem Shortcode, in welchem Beitrag er steckt, daraus ergibt sich der Ordner. Er gibt Markdown zurück: Eleventy rendert Shortcodes vor dem Markdown, also baut markdown-it den Codeblock danach ganz normal, mit Syntax-Highlighting. Fehlt ein Beispiel, bricht der Build ab. Ein Artikel mit totem Verweis soll nicht rausgehen.

Oft soll der Artikel nur einen Teil zeigen, während die Seite das ganze Beispiel ausführt. Dafür markiere ich Regionen im Beispiel:

<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" %} zeigt im Artikel dann nur diese vier Zeilen:

document.getElementById('count').addEventListener('click', () => {
	clicks += 1;
	console.log('clicks:', clicks);
});

Beispiel 02 ausführen →

Die Marker gibt es in drei Schreibweisen (//, /* */, <!-- -->), und sie tauchen im Ergebnis nie auf. excerpt() entfernt sie und rückt den Rest ein. Die Seite führt das ganze Beispiel aus.

Die Beispiele in Web Components robust schreiben laufen so. Die Links unter den Codeblöcken führen zu den Seiten.

Damit habe ich jetzt eine richtig gute Architektur für Beispielcode. Ein neuer Beitrag braucht nur einen examples/-Ordner und eine Data-Datei, und ich hoffe, dass ich sie oft nutzen werde.