Skip to main content

Generating a Markdown Table of Contents That Actually Links to Your Headings

How a Markdown table of contents is built from your headings, why anchor slugs are lowercased and hyphenated, and how to keep long READMEs navigable.

Published By Li Lei
#markdown #documentation #readme #developer-tools

Generating a Markdown Table of Contents That Actually Links to Your Headings

A README that has grown past a few hundred lines stops being a document and starts being a maze. Readers land at the top, hit Ctrl+F, and hunt for "Install" somewhere below the fold. A table of contents fixes that, but writing one by hand is the kind of busywork nobody volunteers for: you copy each heading, lowercase it, swap the spaces for hyphens, strip the punctuation, and pray the anchor resolves. Get one slug wrong and the link 404s silently.

The good news is that every part of that process is mechanical, which means it can be automated. This post walks through how a table of contents is generated from Markdown headings, how the anchor slugs are derived, and how nesting by heading level keeps a long document readable. If you just want the output, the Markdown TOC Generator does all of this in the browser.

Every heading becomes a link

A Markdown table of contents is, at its core, a bulleted list where each item points at one heading in the document. The generator scans your file top to bottom, finds each heading line, and emits a list entry shaped like - [Heading text](#anchor). The visible label is the heading text exactly as written; the #anchor part is the slug the renderer will jump to when someone clicks.

Headings come in two flavors and both count. ATX headings use leading hash marks — ## Setup is an H2. Setext headings underline the text instead: a line of = characters under a title makes it an H1, and a line of - makes it an H2. A solid generator recognizes both, so you don't lose entries just because an older doc used the underline style.

One thing the scanner deliberately ignores: hash marks inside fenced code blocks. A shell sample like # build the image is a comment, not a heading. The parser tracks fences (both ` and ~~~) and skips everything between them. Without that, a tutorial full of bash snippets would pollute the outline with a dozen fake sections.

How the anchor slug is derived

The slug is where most hand-written tables of contents go wrong, because the rules are precise and unforgiving. The standard transformation, the one GitHub uses, goes like this:

  1. Lowercase the entire heading text.
  2. Replace every run of spaces with a single hyphen.
  3. Drop most punctuation — periods, parentheses, colons, slashes.
  4. Keep underscores and Unicode letters and digits.

So ## Quick Start Guide becomes #quick-start-guide, and ## API: Error Codes becomes #api-error-codes (the colon vanishes, the space turns into a hyphen). The heading text stays human-readable in the label; only the link target gets mangled into a slug.

Duplicate headings need special handling. Anchors must be unique on a page, so if you have two ## Setup sections, the first resolves to #setup and the second to #setup-1, the third to #setup-2. The generator dedupes the same way the host does, appending the numeric suffix automatically. If that bothers you, rename the duplicate headings in your source and the outline follows.

Anchor rules also differ by host, which is the quiet cause of broken links after a migration. GitLab keeps underscores as underscores, Bitbucket prefixes every anchor with markdown-header-, and CommonMark renderers have their own conventions. A GitHub-flavored TOC pasted into Bitbucket will look fine and link nowhere. Picking the right anchor flavor before you copy is the difference between working navigation and a wall of dead links.

Nesting by heading level

A flat list of fifty links is barely better than no list at all. The value comes from indentation that mirrors your document's structure. The generator reads each heading's level — H1, H2, H3 — and indents the list item to match, so the outline reads like an actual outline.

Indentation is computed from the shallowest visible level rather than from H1 absolutely. If you generate a TOC for an H2–H4 range (the typical README choice), the H2 entries sit flush left and H3/H4 nest underneath them, instead of all being pushed two levels deep just because there's no H1 in the range. You pick the depth window with a min and max level: drop the document title with a higher minimum, cap the detail with a lower maximum.

There's an optional layer on top of the indentation: hierarchical numbering. Turn it on and the list gains 1., 1.1, 1.1.1, 1.2, 2. prefixes, with counters that reset when their parent advances — exactly like a printed book's contents. This is purely cosmetic; the anchor links underneath are identical whether numbering is on or off, so existing deep links keep working while reviewers get the citable "see 3.2.1" numbers they asked for.

A worked example

Suppose your document has these four headings:

# Coffee Brewing Guide
## Pour Over
### Water Temperature
## French Press

Feed that through the generator with an H1–H3 range and GitHub anchors, and you get:

- [Coffee Brewing Guide](#coffee-brewing-guide)
  - [Pour Over](#pour-over)
    - [Water Temperature](#water-temperature)
  - [French Press](#french-press)

Notice what happened. Each heading became a link. Every slug is the lowercased, hyphenated heading text — "Water Temperature" became #water-temperature. And the two-space indentation steps in with each heading level: the H2s sit one level under the H1, the single H3 sits one level under its H2. Click any line in a rendered Markdown viewer and the page scrolls straight to that section.

My own README cleanup

I keep a side-project README that drifted to about 1,800 lines over a year of feature notes, and at some point I stopped being able to find anything in my own document. I pasted the whole thing into the generator, picked H2–H3 so minor headings wouldn't clutter the list, and dropped the output under a <!-- TOC --> marker near the top. The part that sold me was regeneration: the next time I added a section, I re-ran it, and "Copy full doc" rewrote only the block between the markers. The rest of the file stayed byte-for-byte identical, so the Git diff showed exactly the lines I changed and nothing else. No noisy diffs, no manual slug edits.

Keeping docs navigable over time

A table of contents isn't a one-time chore; it's something you regenerate whenever the structure changes. Three habits keep it painless. Add the <!-- TOC --> / <!-- /TOC --> marker pair so regeneration replaces a known block instead of guessing where the list goes. Match the anchor flavor to wherever the doc actually lives. And pick a depth window that matches the document type — H2–H3 for a README, the full H1–H6 for a long spec.

Once the outline is in place, the rest of the doc benefits from the same treat-it-as-data mindset. If you're hand-aligning columns in a comparison table, the Markdown Table Generator saves the same kind of tedium, and when you need to preview how the finished file renders, Markdown to HTML shows you exactly what readers will see. A navigable document is the cheapest documentation upgrade you can ship, and it costs one paste.


Made by Toolora · Updated 2026-06-13