Markdown TOC Generator

Anchor links break quietly. The table of contents looks fine, every entry is a link, and three of them scroll nowhere because the heading had a colon in it or because two sections share a name. The fix is to see the slug, not guess it, so this prints the anchor next to every heading it found and tells you which ones collided.

Markdown TOC Generator — Anchored Contents With SlugsBuildFigure

What counts as a heading

Two forms are recognised. The common one is ATX: one to six hash marks, a space, then the text, with optional trailing hashes that are stripped. The older one is setext, where a line of text is underlined with equals signs for a level one heading or hyphens for a level two. Setext still turns up in generated files and in older READMEs, and it is easy to miss by eye because the heading itself looks like a plain paragraph.

Three things are deliberately not headings. A hash mark indented four spaces or more is a code block in every CommonMark implementation. Anything between a pair of fences is code, hashes included — that alone accounts for most of the "my heading disappeared" reports, because installation instructions are full of shell comments. And a hash mark with no space after it is not a heading in CommonMark, which is why a line reading #1 priority stays a paragraph.

How a heading becomes an anchor

The repository-host scheme implemented here does four things in order. It strips inline markup so that a heading with a link or some bold text slugs on the visible words. It lowercases. It removes every character that is not a letter, a digit, a space, a hyphen or an underscore — so ampersands, colons, parentheses and full stops vanish rather than becoming hyphens. Then it replaces each remaining space with a hyphen, one for one.

That last detail is where the schemes diverge. A heading reading Install & Run has two spaces around the ampersand; the ampersand is deleted and both spaces become hyphens, so the anchor is install--run with a double hyphen. The Python-Markdown table of contents extension collapses runs of separators instead, producing install-run. Neither is wrong. They are simply different functions, and an anchor written for one will not resolve in the other.

Duplicates are the second divergence. Repeat a heading and the second occurrence needs a distinct fragment; the host scheme appends -1, -2 and so on, while Python-Markdown appends _1. Both count from document order, which means the numbering is positional rather than stable: insert a new "Configuration" section near the top and every anchor for the later ones shifts down by one.

Reading the anchor table

The table lists every heading in the document, not only the ones that made it into the list, because the ones left out are usually what you are looking for. A heading outside the level range is marked as such. A heading with empty text after the hashes gets no anchor at all and is worth deleting. And a heading whose slug ended up identical to an earlier one shows the suffixed form, which is the string you need if you are hand-writing a cross-reference to it.

If a link still fails after checking here, the remaining suspects are short: the renderer uses a scheme neither of these matches, the heading contains an emoji or a character class handled differently, or the page you are linking from applies its own prefix to fragment identifiers. Test one link rather than reasoning about it.

Level range and nesting

Starting at H2 is the usual choice for a README, because the H1 is the document title and listing it in its own contents is noise. Stopping at H3 keeps the list navigable; a contents block that runs longer than the first screen of the document it introduces has stopped helping. Section numbering is offered for documents that are read as specifications rather than browsed, where "see 3.2.1" is more useful than a link title.

Skipped levels are reported but not corrected. Jumping from H2 to H4 produces a contents list with a visible indentation gap, and more importantly it produces a document outline that assistive technology reports as broken. It is nearly always an accident of writing rather than a deliberate structure, and it is much easier to fix in the source than to paper over in the list.

Keeping it in sync

A generated contents block is a copy, and copies drift. Regenerating it is cheap, so the practical habit is to regenerate whenever headings change rather than editing the list by hand — a hand-edited entry with a stale anchor looks exactly like a working one. Between markdown jobs, the markdown preview shows how the whole document renders, the table to markdown converter handles the tables inside it, the heading structure checker does the same outline check for HTML pages, and the slug generator covers the same lowercase-and-hyphenate problem for URLs.

Questions people ask

My heading is in the document but not in the list.

Check three things in order. Is it inside a fenced code block — the count of skipped hash lines is reported under the table when that happens. Is its level outside the range you chose, which the table marks explicitly. And does it have a space after the hash marks, because CommonMark requires one and a line like #Overview is a paragraph, not a heading. The table lists every heading the parser found, so if it is absent from the table too, the parser genuinely did not see it as a heading.

Why does my anchor have two hyphens in a row?

Because a character was deleted rather than converted. In the host scheme, punctuation is removed and then each remaining space becomes a hyphen, so the two spaces around an ampersand or a dash survive as two hyphens with nothing between them. It looks like a bug and it is the documented behaviour. The Python-Markdown scheme collapses those runs, which is why the same heading produces a different anchor in a static site generator than in a repository README.

Which scheme should I pick?

The one used wherever the document will actually be rendered. If it is a README in a repository, the host scheme is the match. If it is a page in a documentation site built with a Python toolchain, the second option is closer. If the destination is something else entirely, generate with either, open one link in the real renderer, and switch if it misses. There is no scheme that is correct everywhere, which is the whole reason the choice is exposed.

Do the anchors work if the heading contains a link?

The anchor is built from the visible text with the markup stripped, so a heading written as [Install](https://example.com) slugs as install. That matches what renderers do, but a heading that is entirely a link is worth avoiding anyway — the contents entry becomes a link to a link, and a reader clicking it can end up somewhere other than the section.

Is the document uploaded to generate the list?

No. The parsing, slugging and list building all run in JavaScript in this tab. No request is made and nothing is stored. That matters more than it sounds for internal design documents and unreleased release notes, which are the two things most often pasted into a tool like this.

Related