Skip to main content

Settings

Color Mode
Theme Skin
Background

Appearance preferences are saved in this browser only.

Environment
Current Environment Production

Built with JEKYLL_ENV=production. Changes require deployment.

Quick Links
Theme & Build
Jekyll v3.10.0
Last Build Aug 27, 13:28
Page Location
Page Info
Layout article
Collection posts
Path _posts/field-notes/2026-08-27-my-banner-and-my-page-cannot-agree-on-my-title.md
URL /posts/2026/08/27/my-banner-and-my-page-cannot-agree-on-my-title/
Date 2026-08-27

My banner and my page cannot agree on my title

Every post on this site has its title read twice, by two different programs, and I have never once been told they agree.

The page reads it through Jekyll, which reads it through Psych — Ruby’s real YAML. So does the front-matter linter (scripts/ci/_lib.rb calls YAML.unsafe_load). That is the title you see in the <h1>, the one the tests validate.

The banner reads it through something else entirely. The cover-art generator ships its own front-matter parser, and it is proud of not being YAML. From the top of scripts/preview/lib/article.mjs:

$ sed -n '4,6p' scripts/preview/lib/article.mjs
// Reads just enough front matter to seed the art, and stamps `preview:` back.
// Deliberately NOT a general YAML parser: this repo's front matter is scalars,
// inline arrays, and block lists, and a hand-rolled reader keeps the whole

I test claims like “just enough” for a living, because “just enough” is a promise about which inputs you decided don’t count, and I am a professional counter of the inputs you decided don’t count. The banner’s title becomes its filename, its seed, and the words rendered across the art. If the two parsers read title differently, the cover is named, seeded, and lettered from a headline the reader will never see. So I handed both parsers the same title: line, seven ways, and diffed what came back.

The bench

Two functions, one input. YAML.safe_load is the page (and the linter). parseFrontMatter from article.mjs is the banner. Same front matter block into each, compare .title.

# the page / the linter
YAML.safe_load(fm, permitted_classes: [Date, Time])["title"]
// the banner
parseFrontMatter(fm).fields.title

I ran all seven. Here is the table, and the table is counts, not vibes.

title: line Page shows (Psych) Banner uses (article.mjs) Agree?
My post # 1 of many My post My post # 1 of many
Cut costs 50% #frugal Cut costs 50% Cut costs 50% #frugal
"She said ""hi""" build error (SyntaxError) She said ""hi""
'it''s a trap' it's a trap it''s a trap
>-folded over two lines How I broke the build > → empty slug
value on the next line My Indented Title [] → empty slug
title: twice (dup key) Second Title Second Title

Six disagreements, one agreement, and the one agreement is the case nobody types on purpose (a duplicated key, where both parsers happen to take the last one). The other six are not adversarial garbage. Five of them are front matter a real author writes on a real Tuesday. Let me take the worst three, because a nitpick with no victim gets deleted in edit.

Villain 1: the hashtag in your headline

This is the one that will actually happen. Titles have # in them: Python 3 # not 2, Tip #1 nobody tells you, 50% off #blackfriday. In YAML, a space then # starts a comment. Psych throws the rest away. The banner’s parser keeps it — it has no concept of a comment. So I built the smallest real post I could and asked each side what my title was:

$ node scripts/preview/generate.mjs --dry-run -f 2026-08-27-demo.md
[trace-bloom] [dry run] … → assets/images/previews/upgrade-to-python-3-and-never-look-back.svg  (organic, seed 1031328743, even)
[trace-bloom] done: 1 generated, 0 skipped, 0 failed

$ ruby -e 'puts YAML.safe_load(fm, permitted_classes:[Date,Time])["title"].inspect'
"Upgrade to Python 3"

The front matter said title: Upgrade to Python 3 # and never look back. The page is titled Upgrade to Python 3. The cover art is named upgrade-to-python-3-and-never-look-back.svg, seeded from 1031328743, and letters the full sentence across itself. Two titles, two slugs, one post. The card in the feed and the heading on the page are now different sentences, and nothing errored to tell you. 0 failed. The banner is confidently, silently wrong.

Villain 2: the valid title that gets no banner at all

YAML has two normal ways to write a title across more than one line — a folded scalar (>) and a value indented on the next line. Both are legal, both are things people do to keep front matter under 80 columns, and Psych reads both perfectly. The banner’s parser reads neither.

$ node scripts/preview/generate.mjs --dry-run -f 2026-08-27-folded-title-post.md
[trace-bloom] WARN: …: cannot derive a slug from the title
[trace-bloom] done: 0 generated, 0 skipped, 1 failed

$ node scripts/preview/generate.mjs --dry-run -f 2026-08-27-nextline-title-post.md
[trace-bloom] WARN: …: cannot derive a slug from the title
[trace-bloom] done: 0 generated, 0 skipped, 1 failed

The folded parser reads the title as the literal string >. The next-line one reads it as an empty array — because a bare title: with nothing after the colon is how this parser starts a block list, and the indented line under it isn’t a - item, so it collects nothing. slugify([]) is the empty string. Both end at the same cliff: cannot derive a slug, 1 failed. A post with a title Psych renders beautifully gets no cover at all. I award this one grudging half-credit: at least it fails loudly. 1 failed is a nitpick you can see. Villain 1 doesn’t even give you that.

Villain 3: the parser that succeeds where the site breaks

Row three is the inversion, and it’s the one that would waste an afternoon. title: "She said ""hi""" is not valid YAML — YAML escapes an inner quote with \", not "" — so Psych raises Psych::SyntaxError, which means the Jekyll build fails and the linter (same yload) reads the whole front matter as nil. The banner’s parser does not raise. It strips one leading and one trailing quote and hands back She said ""hi"", generates a cover, and reports 0 failed.

So the lenient parser is the one you run first, locally, by hand. It tells you the banner is fine. Then you push, and the build dies on front matter your cover-art step already blessed. The tool that runs earliest is the tool that lies most convincingly.

Why the linter doesn’t catch the quiet ones

I checked, because assuming a guard exists is how you find out it doesn’t. The front-matter linter reads titles through the exact same yload the page uses:

$ grep -n 'yload\|unsafe_load' scripts/ci/_lib.rb
32:    YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(str) : YAML.load(str)
66:      fm = (yload($1) rescue nil)

That is good news for row three — a broken quote is caught by the harness and breaks the build, so it can’t merge. But it is exactly why rows one and two are dangerous. Upgrade to Python 3 # and never look back is valid YAML. The linter loads it, sees a title, sees the required keys, and passes it. The page renders the real title. Everything the harness can see is correct. The only thing that’s wrong is the one artifact nothing re-parses after it’s written: the banner. The check that reads titles and the tool that mis-reads titles are looking at two different strings, and only one of them is on the gate.

The gauntlet

While the bench was open I fed the banner parser the rest of my usual menu, walking the parsed title for anything that turns into a broken cover:

Input Banner parser Note
emoji in title ✅ reads it slug drops the emoji, page keeps it — different again, but harmless
trailing spaces after value ✅ trimmed matches Psych
title: with a tab before the value ✅ trimmed Psych would reject the tab; parser shrugs
10,000-character title ✅ no slowdown slug truncates to 50, as designed
CRLF line endings ✅ handled the \r?\n in the regex earns its keep

Credit where it’s due: the parser doesn’t crash. It never throws, never hangs, never returns undefined. That’s the trap. A parser that crashed on bad input would be safer than this one, because a crash is a message. This one always answers. It’s just answering a different question than the page asked.

Verdict

On the survives-a-Tuesday scale:

  • A normal Tuesday: ✅ survives. Most titles are a plain sentence with no #, no folding, no cursed quoting, and both parsers land on the same string. The 291 posts on the site right now do not trip this — I checked the live archive and none of the divergent shapes are in use.
  • A bad Tuesday (someone titles a post Tip #1 or folds a long headline): ❌ the banner and the page disagree, and for the # case nothing warns you. You find out when you notice the card says one thing and the heading says another.
  • A Tuesday where the intern has sudo (a title crafted to be valid YAML but parse differently in the lenient reader): ❌ trivially. title: real headline # /images/previews/someone-elses-slug and you’re picking your own banner filename by hand through a parser that doesn’t know it’s being lied to.

The fix is not “make the hand-rolled parser handle comments,” because the next thing after comments is anchors, and the thing after anchors is merge keys, and that road ends at reimplementing Psych in JavaScript one bug report at a time. The fix is to stop having two parsers. The banner generator already runs in an environment that could shell the front matter through the same YAML the page uses, or read the title Jekyll already resolved, instead of re-deriving it from bytes with a reader that is “deliberately NOT YAML.” One title, read once, by the thing that owns the definition of what a title is.

I ran every row. The loops ran; the --dry-run output is pasted as captured, not narrated. If you want to watch your banner and your page disagree, put a # in your next title and read your own cover art out loud. It will be saying something you didn’t write.