I went looking for a table of contents at 2AM. There wasn’t one. There had never been one.
Which should have been a five-second finding — oh, I never added the heading — except that my build config says otherwise. Sitting in astro.config.ts, running on all eighteen posts, every single build:
processor: unified({
remarkPlugins: [
remarkToc,
[remarkCollapse, { test: "Table of contents" }],
],
}),
Two plugins. Both installed. Both wired in. Both doing absolutely nothing, and neither of them making the faintest sound about it.
So I did what you do at 2AM, which is open the package and start reading the source instead of going to bed.
The Test That Settles It 🔬
Before theorising, the one command that decides whether a plugin is broken or unused:
$ grep -ril "table of contents" src/data/blog/
No output. Zero files. Then the whole source tree:
$ grep -ril "table of contents" src/
# nothing
And the built site:
$ grep -ro 'class="toc[^"]*"' dist/
# nothing
The trigger heading does not exist anywhere. Not in a post, not in a layout, not in any file the build has ever read.
Why Neither Plugin Said So 😶
Here’s the part that turned a five-second fix into a 2AM autopsy.
Both of these plugins are built on the same convention: they look for a heading, and if the heading isn’t there, they quietly do nothing. That’s not a bug in either of them — it’s the documented contract.
remark-toc, from its own source:
heading: (options && options.heading) || '(table[ -]of[ -])?contents?|toc',
And its readme describes the behaviour plainly: it looks for the first heading matching that pattern, then replaces everything between it and the next heading of equal or higher level with a list of links.
Look for. Not insert. If there’s nothing to look for, there’s nothing to do, and no error to raise.
remark-collapse is the same species of polite. Its test option gets wrapped like this:
new RegExp('^(' + value + ')$', 'i')
So { test: "Table of contents" } becomes ^(Table of contents)$, case-insensitive. No matching heading, no collapse, no warning.
Both plugins are working perfectly. They’re just working perfectly on nothing.
The Second Trap, Which Is Better 🪤
Now read those two triggers side by side, because they do not agree:
| Plugin | What it will fire on |
|---|---|
remark-toc | Contents, Content, Table of contents, table-of-contents, TOC |
remark-collapse | Table of contents — and nothing else |
So if I’d sat down at 2AM and “fixed” this by adding a ## Contents heading to a post — the most natural, most human thing to do — remark-toc would have sprung to life and remark-collapse would still have been dead. One plugin working is indistinguishable, at a glance, from the whole setup working.
The only heading that wakes both is the exact phrase:
## Table of contents
Two plugins, two vocabularies, one word of overlap. That’s the kind of thing you find at 2AM or not at all.
Why The Upgrade Made This Dangerous ⚠️
This is the part that actually got me out of bed.
The config above didn’t always look like that. During the Astro 5 → 7 migration, the plugin list had to move:
- markdown: {
- remarkPlugins: [remarkToc, [remarkCollapse, { test: "Table of contents" }]],
- },
+ markdown: {
+ processor: unified({
+ remarkPlugins: [remarkToc, [remarkCollapse, { test: "Table of contents" }]],
+ }),
+ },
A real breaking change, a real config move, a real chance of getting it wrong.
And if I had got it wrong, I’d have had no way to tell.
The plugins were producing nothing before the migration. If the new wiring had silently dropped them, they’d have produced nothing after it too. Identical output, identical build, identical success. The regression would have been invisible — sitting there until the day I went looking for a table of contents, which might have been a year later.
That’s the actual finding of this autopsy, and it isn’t about table of contents:
You cannot tell a broken plugin from an unused one when both produce no output.
A feature you never exercise cannot fail. It can only lie in wait.
The Fix, Such As It Is 🛠️
Two options, and I sat with them for a while:
- Add a
## Table of contentsheading to the long posts and finally get the TOCs the config has been promising. - Delete both plugins and stop paying for code paths that never run.
I went with neither immediately. Instead I did the boring version of due diligence first — I made sure the plugins were actually installed and the versions resolved, so that option 1 wouldn’t be a surprise:
$ node -e "console.log(require.resolve('remark-toc'))"
…/remark-toc@9.0.0/node_modules/remark-toc/index.js
They’re there. Version 9, resolving cleanly, being invoked on every post. The machinery is fine. It’s the fuel that’s missing.
The honest answer is that this site’s posts don’t want an in-page TOC — they run to a handful of sections and the ## headings are visible in the outline anyway. Adding ## Table of contents to every post just to justify two lines of config would be dressing the build to satisfy the build.
So the config stays, and the posts don’t get headings they don’t need — but now I know the two lines are dormant, and dormant is a decision rather than an accident. That’s the whole difference between a latent bug and an unopened drawer.
The Habit I’m Taking From It 📋
Three things I now do that I didn’t before:
- For every plugin in the build, prove it fires at least once. If you can’t produce an example where it does something, you don’t have a working plugin — you have an installed one.
- Check the trigger pattern against the data, not against your memory. Both of these looked configured. Neither matched anything in
src/. - Treat “no output” as a state to explain, not a state to trust. Silence is the default for a no-op and for a success. You have to go look.
Point 1 is the one I’d keep if I could only keep one. The next plugin I add to this build gets a post that provably exercises it before I call the wiring finished — because I now know exactly how long a plugin can sit in your config, running perfectly, doing nothing, while you believe you have the feature. 🩺
Have you ever found a feature that was configured, installed and completely dead — and how long had it been sitting there? 👇