markdown
How GitHub Turns Headings Into Anchor Links (and Why Your TOC Links 404)
There is no standard Markdown heading-anchor algorithm. GitHub strips punctuation and keeps the double hyphen in c--rust, markdown-it-anchor percent-encodes it, and the DOM id you see on GitHub has a user-content- prefix the href never mentions. Twelve headings run through five slugifiers, with real output, plus which of them our TOC generator matches, and where it deliberately does not.
You wrote a table of contents at the top of your README, every link worked on GitHub, and then someone opened the same file in GitLab, or in VS Code’s preview, or on the docs site your team builds with Astro, and half the links scrolled nowhere. Nothing in the file changed. The anchors changed, because the anchor is not part of the Markdown — it is invented by whichever renderer happens to be drawing the page, and each renderer invents it differently.
This post runs twelve deliberately awkward headings through five real slug algorithms and shows the output side by side. Every value below was either executed with Node 22 against the actual library, or taken from the renderer’s own source or documentation, linked as we go.
Five algorithms, zero specs
CommonMark says nothing about heading ids. The ATX headings section only tells parsers that “the raw contents of the heading are stripped of leading and trailing space or tabs before being parsed as inline content” — which matters later — and stops there. So each renderer rolled its own:
| Renderer | Where the rule lives |
|---|---|
| GitHub | Documented in the section links docs; reproduced by the github-slugger npm package (v2.0.0 here), which is what rehype-slug and Astro (this site included) use to id their headings |
| GitLab | Four rules in the GitLab Flavored Markdown docs; implemented by the Rust comrak parser’s Anchorizer, which GitLab wires up in its glfm_markdown engine |
| VS Code | slugify.ts in the built-in Markdown extension, whose comment reads “Copied from https://github.com/Flet/github-slugger since we can’t use esm yet” |
| markdown-it-anchor (default) | Line 3 of index.js: encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-')) — used unless you pass your own slugify |
| Our tool | slugify() in /tools/markdown-toc: reduces the heading to its rendered plain text, then calls the same github-slugger package GitHub’s column uses |
Four of the five are cousins — VS Code copied GitHub’s regex, GitLab’s character filter lands on the same answer for everything in the table below, and our tool calls github-slugger directly — so the outlier is the one people actually hit: markdown-it-anchor’s default keeps punctuation and percent-encodes it.
Twelve headings, side by side
Executed with npx tsx (Node 22.18.0): github-slugger is the real package from node_modules, VS Code’s rule is its fromHeading re-implemented verbatim from the source above, GitLab’s is the comrak character filter, and markdown-it-anchor’s is its one-liner.
## heading | GitHub | GitLab | VS Code | markdown-it-anchor | Ours |
|---|---|---|---|---|---|
Hello World! | hello-world | hello-world | hello-world | hello-world! | hello-world |
C++ & Rust | c--rust | c--rust | c--rust | c%2B%2B-%26-rust | c--rust |
What's new in 2.0? | whats-new-in-20 | whats-new-in-20 | whats-new-in-20 | what's-new-in-2.0%3F | whats-new-in-20 |
Leading spaces | leading-spaces | leading-spaces | leading-spaces | leading-spaces | leading-spaces |
Über uns | über-uns | über-uns | über-uns | %C3%BCber-uns | über-uns |
Duplicate (first) | duplicate | duplicate | duplicate | duplicate | duplicate |
Duplicate (second) | duplicate-1 | duplicate-1 | duplicate-1 | duplicate-1 | duplicate-1 |
`foo()` | foo | foo | foo | foo() | foo |
[text](url) | text | text | text | text | text |
🚀 Launch | -launch | -launch | -launch | %F0%9F%9A%80-launch | -launch |
I ♥ unicode | i--unicode | i--unicode | i--unicode | i-%E2%99%A5-unicode | i--unicode |
Привет non-latin 你好 | привет-non-latin-你好 | same | same | %D0%BF…-non-latin-%E4%BD%A0%E5%A5%BD | привет-non-latin-你好 |
Two rows need a footnote about what text reaches the slugifier, because that is a separate decision from the slug rule itself. GitHub, GitLab, VS Code, and markdown-it-anchor all slug the heading’s rendered text — GitHub’s docs say “markup formatting is removed, leaving only the contents (for example, _italics_ becomes italics)”, VS Code’s TOC builder calls a #getHeaderTitleAsPlainText helper before slugging, and markdown-it-anchor joins only text and code_inline tokens. That is why [text](url) becomes text rather than something built from the link’s URL; our tool does the same reduction (links to their text, images to their alt text, inline code to its contents, emphasis delimiters and HTML tags dropped) before it slugs. (For the inline-code row the answer happens to coincide anyway: backticks are punctuation, so `foo()` and foo() both slug to foo.)
The rules, one at a time
Lowercase, always. GitHub’s docs: “Letters are converted to lower-case.” Every algorithm in the table does, so #API and #api are the same target.
Punctuation is removed, not turned into hyphens. This is what produces the surprising double hyphen. C++ & Rust loses +, +, and &, but the two spaces around the ampersand survive and each becomes a hyphen: c--rust. github-slugger’s fixture file confirms the pattern on GitHub — "I ♥ unicode" → i--unicode, and "heading with a - dash" → heading-with-a---dash, three hyphens for the literal one plus a space on each side. If your instinct is “collapse runs of hyphens,” you will generate c-rust and 404. The URL-slug convention in /tools/text-to-slug does collapse, trim, and transliterate ü to u, because a pretty URL and a heading id are solving different problems.
Only spaces become hyphens — and the parser has already trimmed them. GitHub’s docs say “Leading and trailing whitespace are removed,” which sounds like the slugifier’s job but is CommonMark’s: heading content is stripped before the slugifier sees it, so ## Leading spaces gives leading-spaces in every real renderer. Call the library on an untrimmed string, though, and the fixtures show " a" → -a. VS Code’s copy adds a .trim() and replaces any whitespace (\s) rather than just the space character, which only diverges from GitHub if a heading contains a tab.
Non-ASCII letters stay. Über uns keeps its umlaut on GitHub, GitLab, and VS Code, and Cyrillic and Han survive intact (привет-non-latin-你好 is a fixture verified against GitHub). github-slugger’s regex is generated from Unicode categories, and comrak’s filter is the Rust equivalent: c.is_letter() || c.is_mark() || c.is_number() || c.is_punctuation_connector() plus space and hyphen. An algorithm that keeps only [a-z0-9] produces ber-uns, which no renderer has as an id — which is why our tool calls github-slugger instead of a hand-rolled \w regex.
An emoji is punctuation; an emoji shortcode is text. 🚀 Launch slugs to -launch — the rocket is removed and the space after it becomes a leading hyphen. Write it as :rocket: Launch and the shortcode’s letters are slugged instead: the fixtures have ":smile: - a gemoji" → smile---a-gemoji, and GitLab documents :thumbsup: → this-heading-has-a-thumbsup-in-it. Same rendered heading, two anchors, depending on how you typed it. When a heading misbehaves for no visible reason, invisible Unicode characters covers the zero-width characters that are stripped or kept without ever appearing on screen.
Duplicates: the first is untouched, the second gets -1. All five agree, including the direction — GitHub’s docs say the suffix is added when an anchor “is identical to an earlier anchor in the same document.” One corner in github-slugger: the counter belongs to the original slug, so after echo, echo, echo 1, a heading literally named echo-1 becomes echo-1-2 (fixture: "echo-1" => "echo-1-2").
The user-content- prefix you never see in the link
Curl any GitHub README and look at the heading markup. From github-slugger’s own repo page:
$ curl -sL https://github.com/Flet/github-slugger | grep -o '<h2 [^>]*>[^<]*</h2><a id="[^"]*"[^>]*>' | head -2
<h2 tabindex="-1" class="heading-element" dir="auto">Install</h2><a id="user-content-install" class="anchor" aria-label="Permalink: Install" href="#install">
<h2 tabindex="-1" class="heading-element" dir="auto">Usage</h2><a id="user-content-usage" class="anchor" aria-label="Permalink: Usage" href="#usage">
$ curl -sL https://github.com/Flet/github-slugger | grep -c 'id="install"'
0
The <h2> itself has no id. The permalink <a> next to it has id="user-content-install", but its href is the bare #install, and there is no element anywhere on the page with the unprefixed id. Native fragment navigation cannot connect those two, so GitHub’s page script does it: when the hash changes, it looks for user-content- plus the fragment and scrolls there. The usual explanation for the prefix is namespacing: without it, Markdown authors could mint ids that collide with GitHub’s own page elements, and in HTML an id is also reachable as a global on window. GitLab does the identical thing; its renderer passes header_id_prefix: Banzai::Renderer::USER_CONTENT_ID_PREFIX to comrak, where the constant is 'user-content-'.
Practically: write #install, never #user-content-install. The unprefixed form is what both platforms’ own permalink icons emit, and it is what their scripts translate. The prefixed form works on the rendered page too, but it breaks the moment the same file is rendered anywhere else.
Non-ASCII anchors and percent-encoding
Since über-uns and 简介 are legitimate GitHub slugs, what goes in the href? The raw characters. From the Chinese-language HelloGitHub README:
$ curl -sL https://github.com/521xueweihan/HelloGitHub | grep -o '<a id="user-content-[^"]*"[^>]*href="#[^"]*"' | head -2
<a id="user-content-简介" class="anchor" aria-label="Permalink: 简介" href="#简介"
<a id="user-content-内容" class="anchor" aria-label="Permalink: 内容" href="#内容"
No percent-encoding in the HTML. The #%E7%AE%80%E4%BB%8B you see after clicking is the URL layer’s doing, not the document’s. Either spelling works in your Markdown, because the HTML standard’s fragment algorithm tries both: it first looks for an element whose id matches the fragment as written, and if nothing matches it percent-decodes the fragment, UTF-8 decodes the bytes, and looks again. So [Über uns](#über-uns) and [Über uns](#%C3%BCber-uns) land on the same heading — which is why markdown-it-anchor’s encodeURIComponent is not wrong in itself; its problem is encoding punctuation it should have removed. Which characters get encoded and why is covered in URL encoding explained.
What our TOC generator actually does
/tools/markdown-toc advertises “GitHub-style anchor links,” and it now gets there the same way GitHub does: it reduces each heading to its rendered plain text and hands that to github-slugger v2.0.0 — the package rehype-slug and Astro use — rather than a hand-rolled regex. Here is the real output for the whole table above, produced by running the tool’s generateToc on ## versions of each heading:
- [Hello World!](#hello-world)
- [C++ & Rust](#c--rust)
- [What's new in 2.0?](#whats-new-in-20)
- [Leading spaces](#leading-spaces)
- [Über uns](#über-uns)
- [Duplicate](#duplicate)
- [Duplicate](#duplicate-1)
- [foo()](#foo)
- [text](#text)
- [🚀 Launch](#-launch)
- [I ♥ unicode](#i--unicode)
- [Привет non-latin 你好](#привет-non-latin-你好)
Every anchor in that block is byte-for-byte the GitHub column of the table: the double hyphen in c--rust and i--unicode, the leading hyphen the removed rocket leaves behind, the umlaut and the Cyrillic and Han characters kept verbatim, -1 on the later duplicate. Inline markup is reduced before slugging, so ## [text](url) produces the link entry [text](#text) instead of a nested link, and ## `foo()` produces [foo()](#foo). Tilde (~~~) fences are skipped alongside backtick ones, and a closing ## sequence is stripped without eating the # in a heading called C#.
Two caveats worth keeping in mind:
- Matching GitHub means matching
rehype-slugand Astro too, since all three are the same package. If your docs site renders Markdown through remark/rehype, the ids the TOC points at are the ids the site generates. - It does not chase GitLab or VS Code where those differ. They agree with GitHub on all twelve headings above, but they are separate implementations: VS Code’s copy replaces any
\s(so a tab inside a heading diverges), and GitLab’s comrak filter is a Rust re-derivation of the same character classes. Andmarkdown-it-anchor’s default — punctuation kept, then percent-encoded — disagrees with all of them; if you publish through it, passslugify: s => new GithubSlugger().slug(s)rather than fixing anchors by hand.
Check any generated TOC in /tools/markdown-preview if a link still misbehaves; a TOC entry that renders as literal brackets is a nested link, not an anchor problem.
The short version
| The assumption | What actually happens |
|---|---|
| ”There is a standard heading-anchor algorithm” | CommonMark defines none; GitHub, GitLab, VS Code, and each plugin implement their own |
| ”Punctuation becomes a hyphen” | It is deleted; only spaces become hyphens, so C++ & Rust is c--rust with a double hyphen |
| ”Emoji are stripped like punctuation” | A literal emoji is; a :shortcode: keeps its letters — :rocket: Launch → rocket-launch, 🚀 Launch → -launch |
| ”Accented and non-Latin letters get transliterated” | GitHub, GitLab, VS Code, and our tool keep them verbatim; ASCII-only slugifiers drop them |
”The second duplicate gets -2” | The first is untouched, the second gets -1, and the counter tracks the original slug |
”Link to #user-content-foo, that’s the real id” | The href GitHub itself emits is #foo; the prefix is added to the DOM id and resolved by page script |
| ”Non-ASCII anchors must be percent-encoded in the href” | GitHub writes them raw; browsers try the fragment as-is and then percent-decoded, so both spellings resolve |
The tools this post leans on: Markdown TOC generator for GitHub-compatible anchors, text to slug for URL slugs (a different convention, as the c--rust row shows), Markdown preview to see whether a generated TOC entry actually renders as a link, and Markdown to HTML if you want to inspect the heading markup a converter produces. For the other place Markdown tables go wrong, there is CSV to Markdown table pitfalls and the Markdown table generator.