Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Web Design

Web Design

Backlinks and a "Short Version" Box in Eleventy, No Plugins

Part 5 of the thread Building this notebook

THE SHORT VERSION4 points
  • The short version box is a tldr: list in front matter, rendered with markdown-it's renderInline so bold, code and links still work.
  • An articleBody filter adds heading ids and turns > **Tip:** blockquotes into callouts. A toc filter builds the "In this note" list, but only for posts with three or more sections.
  • Backlinks come from reading every post's source file and checking whether it mentions this page's URL. Cached, and cleared on each build.
  • It's string matching, not a link graph. That's the trade-off, and at this site's size it's a good one.

The general posts on this site have a few reading aids you might have noticed: a short version box at the top, an In this note list of sections, labelled callouts like the one further down, and a Linked from box at the bottom showing which other posts point here.

None of it uses a plugin. It's a handful of filters in .eleventy.js and a few lines in the post template. Here's each piece, trimmed down, plus where it cuts corners.

The short version box

This one is mostly front matter. A post gets a tldr: list:

tldr:
  - "The **short version** box is a `tldr:` list in front matter."
  - "Links work too: [like this](/blog/some-post/)."

The config registers a filter that runs each item through markdown-it's inline renderer:

const md = require("markdown-it")({ html: true, linkify: true });
config.addFilter("mdInline", value => md.renderInline(String(value || "")));

And the template loops over it:

{% if tldr %}<section class="note-box tldr-box" aria-label="The short version">
  <div class="script-bar"><span>THE SHORT VERSION</span><span>{{ tldr.length }} points</span></div>
  <ul>{% for item in tldr %}<li>{{ item | mdInline | safe }}</li>{% endfor %}</ul>
</section>{% endif %}

renderInline matters here. The regular render would wrap every bullet in its own <p>, which looks wrong inside a list item. The inline version gives you bold, code and links with no block wrapper.

Heads up

Quote your tldr items in YAML. Plenty of good summaries contain a colon followed by a space, and YAML will happily read that as a key.

Heading ids and the "In this note" list

Markdown-it's default output gives you a bare <h2> with no id, so there's nothing to link to. An articleBody filter fixes that on the way out:

const headingSlug = t => String(t).replace(/<[^>]*>/g, '').toLowerCase().replace(/&[a-z]+;/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
config.addFilter("articleBody", value => {
  let html = String(value || "");
  const used = new Set();
  html = html.replace(/<h2>([\s\S]*?)<\/h2>/g, (m, inner) => { let id = headingSlug(inner) || 'section'; while (used.has(id)) id += '-2'; used.add(id); return `<h2 id="${id}">${inner}</h2>`; });
  return html;
});

A second filter, toc, runs the same regex over the same content and returns { id, text } pairs. The template only draws the list when there are three or more sections, since a two-item table of contents is just noise:

{% set sections = content | toc %}{% if not recipe and sections.length >= 3 %}...{% endif %}

(It also skips recipes, which have their own jump link to the card.)

Callouts from plain blockquotes

I wanted callouts I could write in plain Markdown, without shortcodes. So a blockquote that starts with a bold label becomes one:

> **Tip:** This becomes a labelled callout box.

The same articleBody filter catches it after Markdown has turned it into HTML:

html = html.replace(/<blockquote>\s*<p><strong>(Tip|Note|Heads up|Warning|Try this|Quick version)[:.]?<\/strong>:?\s*/gi, (m, label) => `<blockquote class="callout callout-${label.toLowerCase().replace(/\s+/g, '-')}"><p class="callout-label">${label}</p><p>`);

The label moves into its own paragraph, and the class drives the color of the left edge. A blockquote without one of those labels stays an ordinary quote. It degrades well, too: in the CMS preview or on GitHub, it's still a perfectly readable blockquote.

This is the one that feels like cheating. For every page, the filter goes through every post, reads its source Markdown file, and checks whether the text mentions this page's URL:

const fs = require('node:fs');
const sourceCache = new Map();
config.addFilter("backlinks", (posts, url) => posts.filter(p => {
  if (p.url === url) return false;
  if (!sourceCache.has(p.inputPath)) { try { sourceCache.set(p.inputPath, fs.readFileSync(p.inputPath, 'utf8')); } catch { sourceCache.set(p.inputPath, ''); } }
  const src = sourceCache.get(p.inputPath);
  return src.includes(`(${url})`) || src.includes(`(${url}#`) || src.includes(`href="${url}"`) || src.includes(`${url}"`);
}));
config.on('eleventy.before', () => sourceCache.clear());

Each file is read once and cached, so a build reads every post one time no matter how many pages ask. The cache is cleared on eleventy.before, so during --serve an edit shows up in the backlinks on the next rebuild instead of being stuck with the first version.

Why read the source instead of the rendered HTML? Because another post's rendered content may not exist yet while this page is being built, and Eleventy will complain if you reach for it too early. The Markdown, on the other hand, is sitting right there on disk.

The trade-offs

It's worth being straight about what this is:

Shortcut What can go wrong
String matching on the source A URL mentioned in a code block or front matter counts as a link. A full https:// link to the same page doesn't match (/blog/slug/), and neither does a relative link.
Reads files at build Every page checks every post. Fine for a hundred-odd posts, and it would want a precomputed map at thousands.
Regex over HTML Only matches a bare <h2>. If a plugin ever adds attributes to headings, ids and the toc quietly stop.
Two slug passes articleBody de-duplicates repeated headings with -2. toc doesn't, so two sections with the same title would both link to the first.

I'm fine with all of that here, because the internal links on this site are written one way: root-relative, in Markdown, like [this](/blog/some-post/). If that ever changes, the fix is to build a proper link map once in a collection and look it up, rather than scanning text per page.

Tip

If you copy this, pick one link style for internal links and stick to it. String matching is only as good as your consistency.

That's the whole system. Four small filters and a template, and the site gets most of what a digital-garden plugin would give it.