WhileDo
Instead of a How-To, I'll write a "WhileDo" today.
This is my attempt to Write Like Ron Jeffries. After I published this little post, I realized that I had tried something similar in the past called build in public[1], but there the point was more to immediately post things as they were happening. But that's not necessarily a hard rule. I'll add build in public tags to this and the Ron Jeffries post. I want to do two things on the blog today. Or rather: I want to finish one and start with another:
- Have descriptive text show up on those "cards" on Mastodon instead of my short bio.
- Work on a versioning system for my blog posts
Task 1 descriptive text
This is what I mean:
This is called a preview card it seems. And it is generated using OpenGraph tags. I remember that these tags are metatags and that they come in the form of OG:somethingsomething.
I poke around my blog's repo - which is private btw. - and see that if they should be somewhere, they should be in the base.njk
which is the most basic layout template I have. Everything (well almost) builds on top of that.
<!doctype html>
<html lang="{{ metadata.language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title or metadata.title }}</title>
<meta name="description" content="{{ description or metadata.description }}">
<link rel="alternate" href="/feed.xml" type="application/atom+xml" title="{{ metadata.title }}">
<meta name="generator" content="{{ eleventy.generator }}">
{%- css %}{% include "public/css/index.css" %}{% endcss %}
{%- css %}{% include "public/css/message-box.css" %}{% endcss %}
{#- Render the CSS bundle using inlined CSS (for the fastest site performance in production) #}
<style>{% getBundle "cssFIRST" %}</style>
{#- Add the heading-anchors web component to the JavaScript bundle #}
{%- js %}{% include "node_modules/@zachleat/heading-anchors/heading-anchors.js" %}{% endjs %}
</head>
<body>
But as we can see, there are no OpenGraph tags here. I suspect immediately it's just the description that is used. I'll just try that. Either it works, or I have to dig deeper, we'll see. This won't decide if we can or can't land on the moon.
The more important part is how I want to handle descriptions. I'd like it to work something like this:
- If it's a blog post
- use description
- fallback: the first few words of the post
- Otherwise:
- use the description
- fallback: use the site's description
I'm hesitant to start Codex, then OpenAI coding tool I have access to through my work, because I am now so visible and out in the open, but I do it anyway. My reasoning is that this is basically grunt work, and I'd like to spend more time on defining and refining the second task.
Before I even finish writing a first prompt, I grab a description from a random mastodon post (that I have since lost, sadly):
Now that l've moved back to Sweden without most of my stuff (it'll be on a ship soon enough), everyone's got suggestions...
It's 120 chars long. I think this is a good length. I want the description to include full words and end on an ellipsis (…). Some edge cases are to be considered:
- if the blog post is shorter than 120 chars for some reason? We post it in full.
- if the first few words include tags? We strip them. And we count without tags.
- if the last word would make the description longer than 120 chars? We include it anyway. But only that one word.
I let the agent do its thing, ask a couple of things for it to check. As always, the agent is programming way too defensively for my taste. I'd rather fail loudly if my assumptions don't hold. In the end, we have a nice solution.
We set an isPost
flag in blog.11tydata.js in the dir where all my blog posts can be found:
export default {
tags: ["posts"],
layout: "layouts/post.njk",
permalink:
"{{page.filePathStem.slice(5).replace(page.fileSlug, '')}}{{page.fileSlug|slugify}}/index.html",
isPost: true,
};
In base.njk
we now do this:
{%- if isPost -%}
{%- set pageDescription = description or (content | postContentExcerpt(120)) -%}
{%- else -%}
{%- set pageDescription = description or metadata.description -%}
{%- endif -%}
And we now have a new filter doing the main work:
eleventyConfig.addFilter("postContentExcerpt", (html, maxLength = 120) => {
const raw = html || "";
const withoutTags = raw.replace(/<[^>]*>/g, " ");
const normalized = withoutTags.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
const [first, ...rest] = normalized.split(" ").filter(Boolean);
let result = first || "";
for (const word of rest) {
const next = `${result} ${word}`;
if (next.length > maxLength) {
result = next;
break;
}
result = next;
}
return result;
});
I'm not 100% in love how the filter looks, but I want to test if it works first... it doesn't. I get a weird error that might have nothing to do with the new filter, actually I get a bunch of errors and warnings when running pnpm dev
:
❓ Your types might be out of date. Re-run
wrangler typesto ensure your types are correct.
- that has nothing to do with the blog but with the API, I'll leave that be for the moment▲ [WARNING] Miniflare does not currently trigger scheduled Workers automatically.
- that is not actionable for me, and I'd say not even really a warning.(node:6188) [DEP0169] DeprecationWarning:
url.parse()behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for
url.parse()vulnerabilities.
- this has been like this for a while, I'll skip that too for now.[wrangler:warn] The latest compatibility date supported by the installed Cloudflare Workers Runtime is "2025-08-23", but you've requested "2025-09-18". Falling back to "2025-08-23"...
- skipError: Could not parse CSS stylesheet
- that we'll have to solve[11ty] Problem writing Eleventy templates: Output conflict: multiple input files are writing to
./_site/tags/buildinpublic/index.html. Use distinct
permalinkvalues to resolve this conflict.
- that one too
The CSS stylesheet thing I hand over to codex while I am thinking about it myself. I assume it's some kind of problem that has to do with our filter or something. Could be that stripping tags alone isn't enough because it might still include templating or something? But then again: we just use content
and not the whole rendered page here. Codex iterates and fails at this. I also just now notice that the filter returns only the first n words. Which means that if content
includes the rendered html (instead of the post's body) then it should only include the first few lines of the full html page minus tags. I guess it could still include template tags.
After more back-and-forth and different approaches, I just undo the whole thing. It still doesn't work. Huh? The CSS issue still presents itself. So it had nothing to do with the new description? ... and then I see what's wrong: This post - which I have started writing in my blog repo - includes the head part of my base layout file which includes what?
<style>{% getBundle "cssSECOND" %}</style>
and Eleventy sees that and explodes. Why? Because we would've needed to use raw/endraw so that these template tags are not interpreted. Dang. Well... or so I thought. The nightmare doesn't end there. Just putting raw/endraw everywhere doesn't solve the problem.
I ask codex and I start googling. Maybe I can "just"™ let Eleventy treat things inside markdown code blocks as plain text? I have built a small markdown-it extension for callouts before. It might be possible. It's not possible because the templating engine comes first. That code blocks are not protected by default is by design according to comments on this issue from 2022. Bummer. I remove the work done so far just to make sure that it isn't something we have added. The only changes in the repo are from this blogpost.
According to another comment from the aforementioned issue:
If you're using it in a fenced code block but do NOT want Liquid to parse a block of code, you'd need to wrap it in {% raw %}...{% endraw %} tags to avoid LiquidJS trying to parse some handlebars-esque code:
{%- raw -%} <div {{@foo "default"}}>this</div> {% endraw -%}
This would still let you use Liquid elsewhere in your Markdown template, if that's something you need/want.
If you don't want Liquid to preprocess your Markdown, you can turn if off globally in the config, or on a per-template basis using something like templateEngineOverride:
--- templateEngineOverride: md ---
<div {{@foo "default"}}>this</div>
But setting templateEngineOverride: md
does nothing. I start fighting with the AI agent. It really isn't very helpful right now. According to Eleventy's docs templateEngineOverride: md
ought to be working...
Meanwhile I solve the multiple input files are writing to ./_site/tags/buildinpublic/index.html
error: Tags have to be unique and buildinpublic and BuildInPublic are the same to Obsidian but not to Eleventy.
But back to the bigger issue. Nothing has changed. I found out that it does work if I disable the markdownTemplateEngine
site wide. But I don't want to do that since that would break older posts. I'll have to test it on a pristine base blog. Maybe this is a bug? Of course, with a new base blog install it works. It works wrapping in raw/endraw tags, and it works by setting the templateEngineOverride
. This is bad. Because it means it's something about my blog's config.
Went out with the dog, cleaned the kitchen, took a break away from the keyboard. Time to think about commenting out bits of the config to see if any of my customizations causes this. On a whim, I try out the copilot agent mode of vs code as well. It does the work of bisecting my various eleventy and markdown-it plugins for me. And voilà: It's the interlinker plugin which parses the site using JSDOM which somehow led to already escaped code to be interpreted again.
Actually: The issue is not with template tags, but with style tags. Since what causes the error is that what's between the style tags in not CSS:
<style>{% getBundle "cssFIRST" %}</style>
We're still not done, though. Because I need to figure out how to stop this behavior.
I have now forked the interlinker plugin and try - together with the AI - different approaches to escape problematic tags. I create an issue in the github repo and work on a fix. Or rather, the agent is. Btw: No way in hell would I have had the depth of knowledge or time to hone in on the error, nor even dream of working on a fix. So it's all good. Or, mostly good. There would have just been way too much surface for me to cover in my free time. I can read the code and make sense of it and judge it and so on, but I would have not been able to put the pieces together so quickly and in a way that lets me figure out the best approach after trying a couple of others.
The agent is surely the better grunt but I am the ideas man: We can just silence the CSS-parsing error from JSDOM! We don't need interlinker to care about this, as it is only interested in links anyway.
In the end, this is all the code we need to make JSDOM and therefore interlinker and also my blog happy:
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (error) => {
// heads-up! in later versions this is called "css-parsing" but the currently used version is "css parsing"
if (error.type === "css parsing" || error.type === "css-parsing") {
console.warn(
`[@photogabble/eleventy-plugin-interlinker] CSS parsing error (ignored): ${error.message}`
);
} else {
console.error(error);
}
});
const dom = new JSDOM(document, { virtualConsole });
JSDOM changing the error.type
between what is in the main branch and what was used in the interlinker plugin was its own little riddle.
The PR is out, let's hope it gets merged soon. Until then, I need to use my fork.
pnpm add git+https://github.com/finn-matti/eleventy-plugin-interlinker.git
Where was I? Right.
My description fix! Invigorated by the successful work the copilot agent did, I let it redo the changes that codex had done first. After fiddling a little with what it wrote, this is now the solution that went live:
base.njk
:
<!doctype html>
<html lang="{{ metadata.language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title or metadata.title }}</title>
{%- if isPost -%}
{%- set pageDescription = description or (content | postContentExcerpt(120)) -%}
{%- else -%}
{%- set pageDescription = description or metadata.description -%}
{%- endif -%}
<meta name="description" content="{{ pageDescription }}">
<link rel="alternate" href="/feed.xml" type="application/atom+xml" title="{{ metadata.title }}">
<meta name="generator" content="{{ eleventy.generator }}">
{%- css %}{% include "public/css/index.css" %}{% endcss %}
{%- css %}{% include "public/css/message-box.css" %}{% endcss %}
{#- Render the CSS bundle using inlined CSS (for the fastest site performance in production) #}
<style>{% getBundle "css" %}</style>
{#- Add the heading-anchors web component to the JavaScript bundle #}
{%- js %}{% include "node_modules/@zachleat/heading-anchors/heading-anchors.js" %}{% endjs %}
</head>
blog.11tydata.js
export default {
tags: ["posts"],
layout: "layouts/post.njk",
permalink:
"{{page.filePathStem.slice(5).replace(page.fileSlug, '')}}{{page.fileSlug|slugify}}/index.html",
isPost: true,
};
filters.js
eleventyConfig.addFilter("postContentExcerpt", (html, maxLength = 120) => {
const raw = html || "";
const withoutTags = raw.replace(/<[^>]*>/g, " ");
const normalized = withoutTags.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
// Check if character at maxLength is a space or if we're at word boundary
if (normalized[maxLength] === " " || normalized[maxLength] === undefined) {
return normalized.substring(0, maxLength) + "…";
}
// Find the next space after maxLength to complete the word
const nextSpace = normalized.indexOf(" ", maxLength);
if (nextSpace === -1) {
// No more spaces, return the whole string
return normalized;
}
// Return up to the next space to complete the word
return normalized.substring(0, nextSpace) + "…";
});
It is now 23:13, I started to work on this at 14:00. So... what? 9 hours? Minus dog walking, kitchen cleaning and a couple of other things. I used a lot of AI tooling to get things done. Made and makes me feel weird, because I suspect that people won't like it. But I have written about my stance on AI before, and I feel like I have not been frivolous but used AI here in a way that helped me trace a pretty intractable problem and it even lead to a little PR in a useful package that hopefully will prevent errors like this from cropping up for other people. AI tools used today:
- Copilot autocompletion in VSCode
- Codex-cli from OpenAI using ChatGPT-5
- Copilot agent mode in VSCode using Claude Sonnet 4
From the coding agents used, Copilot + Claude impressed me. Codex is pretty solid, but Copilot agent mode with Claude Sonnet 4 (which isn't even the newest and best offering) is seemingly better.
The actual work on the actual description text feature was minuscule: Not even 50 lines of code. I had thought that I would find the time to really get in there and think about the filter code a bunch and how to make it better. But this is how it goes sometimes. Nice that I managed to find the problem with the BuiltInPublic tag as well while doing other things.
The big versioning feature for blog posts stays untouched. That's fine. I'm looking forward to doing something less intense after hitting publish here.
Which has an unpleasant association with one Vincent Ritter, who is known to be a transphobe and Elon Musk lover. Building in public is not his invention though, and I feel it would be shady to not make that association obvious. ↩︎
-
← Previous
Write Like You're Ron Jeffries -
Next →
DailyDogo 1407 🐶