Martin Harris

"Ceci n'est pas une API Gateway" — René Magritte (paraphrased)

Making cloud abstractions tangible through physical hardware.

Chat with me →

The Code Block That Wouldn't Light Up

The Code Block That Wouldn't Light Up

A row of identical light bulbs hanging in a dark workshop, all glowing warmly except one that stays dark, dramatic single-source lighting, shallow depth of field, macro photography

Everything I write here gets mirrored to Substack automatically. Same markdown, same words, a script that posts it through their API. It has been running for months and I had never looked closely at the result.

When I did, the code blocks were grey. Not broken — the code was all there, correctly indented, perfectly readable. Just flat. On the blog, certbot certonly --dns-route53 arrives with the flags in one colour and the command in another. On Substack it was the visual equivalent of reading a schematic printed in pencil.

The obvious cause was that the language tag wasn't surviving the trip. Finding out why took two wrong answers, and both wrong answers came from the same mistake.

The first wrong answer

The library I use is python-substack. Its markdown renderer builds a document node for each fenced code block, and that code looked fine:

if t in ("fence", "code_block"):
    return [nodes.code_block(node.content.rstrip("\n"),
                             language=node.info.strip() or None)]

So it reads ```bash and passes bash along. The node builder attaches it as an attribute. I traced it further and the value survives all the way into the JSON that gets POSTed. Then I went looking for it in the delivered page and it wasn't there. I grepped every mirrored post for a code language and got exactly one hit per post — "language":"en", the language the post is written in.

Conclusion: we send it, Substack bins it, nothing to be done. I wrote that up with some confidence.

Then I went further and found a blog post from March 2025 titled, more or less, Dear Substack, please add syntax highlighting. The author's complaint was that code blocks are "all one colour". That seemed to settle it. The platform simply didn't do highlighting, so of course the language was pointless.

The bit that killed it

I opened one of the mirrored posts in Substack's own editor, made a trivial edit, saved it, and reloaded the article.

The code block lit up. Keywords in red, types in blue, comments greyed out, line numbers down the left. Syntax highlighting, on the platform that supposedly didn't have it, in a post that had arrived flat thirty seconds earlier.

Whatever was happening, "Substack discards the language" was not it. Something about going through their editor fixed a post that the API had produced broken — which meant the difference was visible if I looked at the right thing. I had been looking at the rendered page. I should have been looking at what was stored.

# not this
curl -s "https://example.substack.com/p/some-post" | grep language

# this
api.get_draft(post_id)

The public page does not contain the stored document. It contains a rendering of it. Grepping it for an attribute that lives in the source document was never going to prove anything, and the empty result had felt like evidence.

Three node types

Substack's editor, like a lot of modern editors, stores posts as a ProseMirror document — a tree of typed nodes rather than a blob of HTML. Pulling the stored document for the post I'd just re-saved showed this:

{
  "type": "highlighted_code_block",
  "attrs": { "language": "bash", "nodeId": "3a4c901c-789f-4855-9148-2a11c0fea138" },
  "content": [ { "type": "text", "text": "pip install certbot" } ]
}

And pulling one from an untouched post showed this:

{
  "type": "codeBlock",
  "attrs": { "language": "bash" },
  "content": [ { "type": "text", "text": "pip install certbot" } ]
}

Same code. Same language — the language was there the whole time. Different node type.

There are three, and only one of them is wired to a highlighter:

Node typeWhere it comes fromHighlighted?
codeBlockwhat python-substack sendsno
code_blockthe editor's own normalisation when you re-saveno
highlighted_code_blockthe editor, for a block with a language setyes

codeBlock is a legacy node. It renders a plain <pre><code> and ignores the language it is carrying. The library has been faithfully attaching a language to a node type that has no interest in it, for every post, for months.

That also explains the March 2025 complaint. The author was almost certainly right at the time. Substack added highlighting later, wired it to a new node type, and left the old one alone — which is exactly what you'd do if you had a few million existing posts you didn't want to reflow.

The fix is one line, in the rudest possible place

The node builder is a plain module-level function, and the renderer looks it up on the module every time it needs one:

return [nodes.code_block(...)]

That's an attribute lookup at call time, not a reference captured at import. So replacing the attribute redirects every code block the renderer will ever produce, without touching the library:

def _highlighted_code_block(code, language=None):
    return {
        "type": "highlighted_code_block",
        "content": [substack_nodes.text(code)],
        "attrs": {
            "language": (language or "text").strip().lower(),
            "nodeId": str(uuid.uuid4()),
        },
    }

substack_nodes.code_block = _highlighted_code_block

Monkey-patching a dependency is not something to be proud of, and it deserves a comment explaining itself, which it has. But the alternative was forking a library over a single string literal.

Because highlighted_code_block is Substack's node type rather than a documented API contract, I added a guard: after creating a draft, count the code blocks that came back and compare against the fences in the markdown. If they don't match, delete the draft and fail loudly. If they ever stop accepting the node, I want a broken build, not a post published with its code silently missing.

Twenty posts, and a trap I walked into anyway

Three identical wooden crates on a workshop bench, two stencilled with faded old markings and one freshly labelled, warm side lighting, shallow depth of field, conceptual still life photography

New posts were fixed. The twenty already mirrored still had legacy nodes and would never fix themselves, so they needed a backfill.

This turned out to be much easier than expected, precisely because of the thing I'd got wrong: the language was already stored on every legacy node. The backfill isn't reconstructing anything, it's a retype. Read the document, change the node type, write it back. The source markdown is only needed as a fallback for blocks whose fence never had a language in the first place.

I converted one post as a test, fetched the page, and checked for the coloured spans that the re-saved block had.

There weren't any. The node type was right, the CSS classes were right, and the colour spans were missing.

Same mistake, second time in one evening. Shiki's per-token spans are applied client-side. They're absent from curled HTML for every post, including the ones that highlight perfectly. I was about to declare a working fix broken on the strength of a grep — again — until I looked at the actual page in an actual browser, where it was highlighted exactly as intended.

Then the run hit Substack's rate limiter at the sixteenth post. That's fine and expected. What wasn't fine was where it hit: between updating a draft and publishing it. Two posts ended up with a corrected draft and a stale live page — and since my script decided what still needed doing by reading the draft, those two now looked finished. Permanently. Nothing in the draft distinguishes "published version is current" from "published version is three months old": is_published, post_date and draft_updated_at look identical either way. That one needed an explicit --republish flag, because it can't be detected.

The blank line that ate the donate button

With the code fixed I published a post and read it properly, which is how I found the next one. The donate button at the bottom was a plain blue text link rather than the PayPal button image.

The markdown is an image wrapped in a link, and the renderer handles that case explicitly — there's a function whose entire job is to spot a paragraph containing nothing but an image, optionally inside a link, and turn it into a proper image node. Mine wasn't matching, and it took an embarrassing amount of squinting to see why:

DONATE_PLACEHOLDER_RE = re.compile(r"^\s*\{\{donate\}\}\s*$", re.M)

\s matches newlines. My posts end with a `


If you find this blog useful, please consider a donation.

` placeholder on its own line, separated from the author tagline by a blank line — and that `\s*` on each end happily ate the blank lines along with the placeholder. The substituted link got welded onto the end of the tagline paragraph. Once it was sharing a paragraph with a sentence, it was no longer "a paragraph containing nothing but an image", so it fell back to rendering the alt text as a link.

[ \t]* instead of \s* and the button came back. The regex had been wrong since the day I wrote it; it only became visible when something downstream started caring about paragraph structure.

There was a sting in the tail. The library hardcodes 1456×819 into every image node, which is sensible for a header illustration and less so for a donate badge that is 74 pixels wide. Fixed, and worth remembering that a library's defaults encode an assumption about what you're doing.

And one that had already got out

The same publish also fires an announcement to Bluesky, with the teaser written by a model from the article text. This one went out:

Deleted a file from an encrypted LVM-on-LUKS-on-RAID volume, then spent an hour making things worse before I even started recovering it. Here's what I learned the hard way: https://blog.example.com/1092-bytes

That domain is not mine. It doesn't exist. The model invented a plausible-looking URL and my script posted it publicly.

The prompt was the problem, sort of. It said the post "links to the article", meaning the link is attached separately as a card — but it never actually said don't put a URL in the text. So it wasn't disobeyed, it was under-specified. The obvious fix is to say so explicitly, which I did.

The less obvious fix is the one that matters: I also strip anything URL-shaped out of the generated text before posting, and fall back to the title and excerpt if that guts it. A prompt is a request, not a constraint. If a specific output would be actively harmful — and a wrong link in a public post qualifies — then something downstream has to make it impossible rather than merely unlikely. I'd have written that in a code review of someone else's work without hesitating.

Tables, which are a different kind of hopeless

While I was in there I looked at why tables arrive as a wall of pipe characters.

The renderer builds its parser with MarkdownIt("commonmark") and never enables the table rule, so table syntax is never recognised — the lines fall through to the paragraph renderer and arrive as literal text. My first thought was to enable the rule. Then I checked the node definitions and there is no table node at all. The library cannot express a table, so there's nothing for a parser rule to produce.

Substack's actual answer, it turns out, is Datawrapper. Paste a datawrapper.dwcdn.net URL and it renders a real, interactive table or chart. It's in their embed list alongside YouTube and Spotify, and it's how the data-heavy newsletters do all those charts — no special arrangement, just an embed nobody mentions. For now my mirror flattens tables to bullets so at least nothing is mangled, and Datawrapper is the upgrade for tables that deserve to look like tables.

What I'd take away

The language was never lost. It was attached to the wrong kind of box for months, and every piece of evidence I gathered agreed with me right up until the moment I gathered the right piece.

Both wrong answers came from the same habit: checking a rendered page for something that lives in a stored document. A rendered page is a lossy view. It drops what the renderer ignores, and it gains whatever the client-side JavaScript adds after you've stopped looking. grep on curl output answers "is this string in this response", which is rarely the question.

The thing that broke the deadlock wasn't more analysis. It was making one change by hand in the editor and diffing the result — thirty seconds of work that beat an hour of reasoning about a library that was, all along, doing exactly what it said.

The other two bugs only appeared because fixing the first one made me actually read a published post instead of a status message. The \s* had been wrong for months and cost nothing until something downstream started caring about paragraph structure. The invented URL had been possible from the moment I let a model write copy that gets posted without a human in the loop, and it went public the first time it happened.

Which is the part I keep circling back to. Two of the three were mine — a lazy character class and a prompt that asked nicely for something it should have enforced. The one I blamed on the platform was the one where the platform was innocent and had been storing my data correctly the whole time. On a long enough evening, "it's definitely their bug" is just a hypothesis you haven't finished testing.


Martin Harris connects cloud infrastructure to the real world. If you've ever spent an evening proving a platform was at fault, only to find it had been politely storing your data in a box marked "do not open" the entire time, you're in the right place.


If you find this blog useful, please consider a donation.