Select Page

My Table of Contents Lied to the Machines. My Fix Broke Something for the Humans.

by lukasz | Jul 6, 2026 | Essays

A field note in three failures: a page component that tells AI readers a falsehood, a review that graded the wrong document, and a fix that committed the mirror image of the bug it was fixing. All three happened on this site, to me, within six weeks. The principle they add up to is the most useful thing I've learned about machine readability this year.


An AI reading this site pointed it out before any human did: on posts full of headings, my table of contents was announcing "No headings found in this post."

For me, in a browser, the component worked flawlessly — numbered list, working links, the current section highlighted as I scrolled. For a machine reader, the table of contents didn't exist. What existed instead was its obituary, stated as fact, in plain text, on every post I'd published.

The component is a stock module from a major WordPress page builder. That detail matters, because it means this exact falsehood is being served right now from thousands of sites whose owners, like me until recently, see a working feature and have no reason to look closer.

Failure one: the component

Here is what the server actually sends, verbatim, before any JavaScript runs:

html

<nav aria-label="Table of contents">
  <div class="...__empty"><p>No headings found in this post.</p></div>
  <ol class="...__list"></ol>
</nav>

An empty list, and a visible, unqualified claim of "no headings" — sitting in the same document as the headings themselves. The real list is assembled in the browser: a script scans the DOM, builds the links, hides the empty-state message, and adds the accessibility attributes. Everything a human sees is manufactured client-side.

The nuance worth naming: this is worse than a missing feature. A missing table of contents is a lost convenience. A false statement about document structure is active semantic noise — the page asserting something untrue about itself, in the exact layer that machine readers consume. Most extraction pipelines will treat it as interface debris. Some, especially those reasoning about document structure, will take it at its word. You don't get to choose which of your machine readers falls into which group.

Failure two: my own review

Six weeks earlier I had reviewed this very module and praised its markup — semantic nav, ordered lists, sensible ARIA attributes. That review wasn't wrong about what it examined. It examined the wrong thing.

I had inspected the hydrated DOM — the state of the page after JavaScript finished its work, the thing DevTools shows you. The machine reader consumes the initial payload — the bytes the server sends. Between the two sits a script, and the script was doing all the lying-by-omission: shipping the falsehood by default and manufacturing the truth on demand.

This is the distinction I now think deserves its own audit category. A page can simultaneously be: valid HTML (in the DOM), accessible (for screen readers running JS), visually excellent — and false for machines. No classic tool catches it. The HTML validator sees the hydrated result. Lighthouse runs a full browser. SEO audits check different layers entirely. The gap between payload and DOM is where none of them look, and it's precisely where AI readers live.

The test costs one line and no tooling:

bash

curl -s https://your-site.com/some-post/ | grep -i "no headings\|table of contents"

If you're on a builder-based site, I'd genuinely run it before finishing this post. I'll wait.

Failure three: the mirror

So I built the fix — a small plugin that renders the table of contents server-side. List in the first byte, real href="#section"anchors, properly nested hierarchy, and an empty state that defaults to rendering nothing at all, because the honest response to "no headings" is silence, not a manufactured announcement. JavaScript demoted to what JavaScript is for: behavior, never content.

It worked. And within an hour of going live, it broke a neighboring widget on the same site — an inline script whose JavaScript suddenly appeared on the page as visible text, its HTML strings materializing as actual page elements.

The cause: my content filter was round-tripping the entire post through an HTML parser, even when it had nothing to do. And an inline <script> is not HTML to be parsed — it's a raw block where markup-shaped strings and template literals are landmines for a parser. Serialize it back out and it can come apart.

Sit with the symmetry, because the symmetry is the lesson. The page builder treated content as something the client may fabricate. I treated someone else's script as something the server may chew. The same sin, mirrored: disrespect for the boundary between layers. I was writing a tool to repair what JavaScript breaks for machines — and broke JavaScript for humans on the way.

The repaired version follows two rules I now consider non-negotiable for anything that filters content:

Raw blocks are masked, never parsed. Scripts, styles, and textareas are lifted out before parsing and restored byte-for-byte after. The parser never sees them.

No trace where no work. If the filter has nothing to add, the content returns untouched — not re-serialized, not normalized, identical. A content filter that leaves fingerprints on pages it didn't change is a bug that hasn't been reported yet.

Both rules are enforced by regression tests, one of which is literally the broken widget's script, cloned into the test suite as a permanent memorial.

What this adds up to

Strip away the WordPress specifics and three portable claims remain.

One: the document you inspect is probably not the document machines read. DevTools, "view source" after load, copy-pasting rendered markup — all of it shows the hydrated state. Machine readers, overwhelmingly, consume the pre-hydration payload. If your review methodology doesn't start with curl, you are grading a different exam than the one being taken.

Two: default states are statements. The most dangerous markup on my site wasn't missing — it was a fallback someone wrote assuming no one would ever read it unhidden. Every empty state, loading message, and noscript branch in your templates is being read, right now, by systems that don't run your JavaScript. Audit them as claims, because that is what they are.

Three: the falsehoods are industrial, not artisanal. My lying component came from a stock module. Nobody hand-crafted the lie; it ships by default, at scale, to sites whose owners see a working feature. Which makes me wonder — and this is the open question I'm leaving with — how much of the web's initial-payload layer currently consists of confident, machine-readable statements that are simply not true. Empty carts that aren't empty, "no results" that have results, "no headings" above headings. Nobody has measured it, because until machines became the first readers, nobody needed to.

There's a second open thread I haven't tested yet: whether extraction pipelines even respect the hidden attribute when a fallback is properly concealed — or whether "hidden from humans, served to machines" is its own category of leak. That one needs an experiment, not an opinion. Next field note, probably.


This is a build-and-break report from a working site: the falsehood, the misgraded review, the fix, and the fix's own bug are all real and all mine. The plugin and its test suite are linked from the technical write-up. If your table of contents is currently telling machines your posts have no headings — you now know exactly how to check.

The Field Guide to Agent-Readiness