Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

What twelve renderers call a heading

A heading anchor is not a property of Markdown. ## Setup & Config has no identity until something renders it, and the renderers disagree: github.com publishes setup--config, VitePress publishes setup-config, and Gitea publishes neither if the heading is empty after its filter. Checking guide.md#setup therefore means knowing whose rule applies, and guessing one would report live anchors as missing.

Resolution describes what the resolver does with that. This page retains what the rules are, where each came from, and what each was checked against, because a slugging rule that quietly stops matching its renderer looks exactly like one that still matches.

The rules

RuleServesDistinguishing behavior
githubgithub.com, GitLab, Docusaurus, Hugo’s github typekeeps letters, marks, numbers and connector punctuation; one separator per space; the only rule that also anchors a heading written as raw HTML
giteaGitea 1.27 repository files and wiki pagesdrops marks; publishes nothing for an empty identity; never suffixes a repeat
forgejoForgejo 16 repository files and wiki pagesGitea’s filter, but an empty identity becomes heading and repeats take -1
mdbookmdBook with smart punctuation offRust’s alphanumeric test, so Indic vowel signs survive where Gitea drops them
mdbook-smartmdBook as it shipsthe same, after -- becomes an en dash and ... an ellipsis
goldmarkgoldmark embedders keeping its own idsdrops every multi-byte rune; _ becomes a separator; empty becomes heading
python-markdownMkDocs with the default toc slugNFKD then ASCII fold, so Café is cafe and CJK is empty; repeats take _1
pymdownxMkDocs configured with pymdownx.slugs.slugifykeeps Unicode; one separator per space; repeats take _1
mdit-vueVitePress, VuePressa wide punctuation class collapses to one separator; a leading digit takes _
kramdownJekyll, GitHub Pagesstrips the leading run of non-letters; ASCII only; empty becomes section
docutilsDocutils and Sphinxevery non-alphanumeric run becomes one separator, so foo_bar is foo-bar; a leading digit run is stripped rather than prefixed; NFKD then ASCII fold, so Ⅻ chapter is xii-chapter
asciidoctorAsciidoctor and Antora, at the default idprefix and idseparatorthe only rule whose separator is _ and whose every identity carries a fixed prefix; hyphens and dots survive as themselves; repeats number from _2

The AsciiDoc rule is the one pinned to a configuration rather than to a renderer’s only behaviour. idprefix and idseparator are document attributes, and this engine evaluates no attributes, so the rule holds their defaults and a document set that overrides either publishes identities the rule does not know. Two divergences are known and unverified against a running Asciidoctor: a title whose every character is filtered away publishes nothing here, and the attribute-driven cases above.

An anchor resolves when any of them would publish it, or when the document declares it outright. Adding a rule can only grow that set, so a rule missing from the table is the only way a live anchor is reported absent, and nothing a repository declares can shrink it.

Two of the rows are configurations rather than renderers. mdBook ships with smart punctuation on and MkDocs takes its slug function from mkdocs.yml, so both spellings are carried rather than one being chosen for the reader.

An identity can also be written down rather than derived. Raw HTML declares one with id or name, and the attr_list extension declares one with an attribute block, in any of the spellings it accepts: {#id}, { id="id" }, { id=id }, among classes, and with kramdown’s leading colon. A block whose last line is nothing but an attribute block declares that identity for itself, which is how [](){#anchor-point} and a {#section} line under a paragraph work; an attribute block trailing other text on the same line declares nothing, and one inside a fence is code. The extension reads the block in the document’s own literal text, so a block inside inline code is code and declares nothing. In MDX the attribute spelling is an expression, so Docusaurus writes the identity as a comment instead, ### \noIndex` {/* #noIndex /}, and takes it as written with its case intact. That comment ends the heading or it declares nothing, which is parseMarkdownHeadingId's own rule and the reason {/ #id */} after` names nothing. Every declared identity joins the union whatever the renderer, because it is authored rather than derived, and accepting one a given renderer would not publish can only leave a finding unreported, never invent one.

A heading can also be written as raw HTML, which many projects do for a centered title. github.com anchors those, because its filter runs over the rendered document and sees <h1> and ## in one sequence: the text content of the element is slugged by the same rule, nested tags and comments contribute nothing, and a repeat of an earlier identity takes the next suffix. Forgejo does not, verified on its own README, where <h1 align="center">Welcome to Forgejo</h1> is the only heading rendered without an identity while all four ## headings carry one. The rules built from a Markdown tree, mdBook, goldmark, python-markdown, pymdownx, mdit-vue and kramdown, never see the element at all. So this is the github row’s behavior alone, and the union carries it.

What each rule was checked against

The published expectations are in heading-anchor vectors, which names the implementation behind every column. Twenty-four cases carry the divergences: punctuation runs, intraword underscores, precomposed and decomposed Latin, the Turkish dotted capital, CJK, a Bengali virama, an emoji variation selector, a Roman numeral, a no-break space, and a heading that filters to nothing.

Seven rules have a runnable implementation, and against those the table reproduces all 9,049 headings harvested from the ten repositories in The scan ledger with no mismatch: github-slugger 2.0.0 and comrak 0.54.0 for github, goldmark 1.8.4, python-markdown 3.10, pymdownx, @mdit-vue/shared, and kramdown’s own generator. The remaining five are transcribed and traced by hand: Gitea’s CleanValue, Forgejo’s prefixedIDs, mdBook’s id_from_content, Asciidoctor’s Section.generate_id, and Docutils’ make_id. The published vectors name which of the twelve is which and what each transcription is not checked against.

Eight documents, in corpus/third_party/anchor-fixtures/, carry what a renderer actually published for them, harvested 2026-07-26:

DocumentRendererIdentities
probe.md, this repository’s owngithub.com file view28
probe.mdmdbook 0.5.4, default configuration28
probe.mdpython-markdown 3.10 with toc and attr_list28
probe-html.md, this repository’s owngithub.com file view9
probe-attr.md, this repository’s ownpython-markdown 3.10 with toc, attr_list and fenced_code7
probe-mdx-heading.mdx, this repository’s own@docusaurus/utils 3.10.2, parseMarkdownHeadingId3
awesome-gitea.md, CC0gitea.com50
starship-ja.md, ISCstarship.rs, VitePress32

The github.com column comes from the file view, /repos/{owner}/{repo}/contents/{path} under the HTML media type, which is the renderer that publishes heading anchors. POST /markdown renders the same Markdown and publishes none, so a re-harvest through it would come back empty rather than disagreeing.

The Gitea pair is the only live evidence for that rule and the only place its missing duplicate suffix is visible: that one page publishes fifteen identities twice, so an anchor into it is ambiguous on Gitea and unique on Forgejo, for the same file.

probe-mdx-heading.mdx is the same question in MDX, answered by the function Docusaurus parses headings with. Three of its seven headings declare an identity and four do not: one whose comment is followed by text, one with no identity in the comment, one whose identity carries a space, and the plain {#id} spelling, which that syntax does not read. The identity a{b} is in the set because their expression allows it.

probe-attr.md is the declared identities: four heading spellings, an empty link carrying one, and a paragraph carrying one on its own last line. Five forms declare nothing, and they are pinned too: a block trailing text on the same line, one inside a fence, and three inside inline code, where the extension reads the syntax as the code it is. Its pair is compared as a subset rather than as a list, because these identities join the union beside every rule’s own.

probe-html.md is nine raw-HTML headings and one Markdown heading among them, which is where the wrapped element, the decoded reference, the stripped comment and the shared duplicate counter are pinned. Its <h2> written across three lines publishes --wrapped-title, the leading newline and two spaces intact, which is the kind of detail a transcribed rule gets wrong and a harvest does not.

How far apart the rules actually are

Over those 9,049 headings, github and comrak agree on every one, which is why GitLab reads the same identities as GitHub. gitea, forgejo and mdbook sit within 28 of them, and the 28 are combining marks, no-break spaces and connector punctuation. The site generators are the outliers: goldmark’s default differs on about 1,117, python-markdown on 1,431, and mdit-vue on 1,856.

Switching MkDocs to pymdownx.slugs.slugify moves 1,431 of the 9,049 and lands within 22 of github-slugger, which is the measured reason a MkDocs answer is a configuration rather than a renderer.

Renderer drift

Two of these implementations were rewritten inside twelve months. Gitea moved heading identities out of goldmark and into an HTML post-processor in January 2026, shipped in 1.27, which is where its missing duplicate suffix comes from. mdBook rewrote its generator in September 2025 under a new HTML pipeline. github-slugger’s character class exists only because of a 2021 commit to match GitHub on Unicode, and its one later change was a Unicode data bump made for the same reason.

That is what the fixtures are for. A re-harvest that disagrees fails a test instead of silently changing a verdict.

Translated trees

Translation mirrors are where anchor breaks concentrate in the wild. In the ledger’s survey, 103 of the 122 real anchor breaks sit in starship’s translated pages, every one with the same shape: the heading was translated, its slug moved with it, and the English fragment stayed behind in the links. A translated tree is a first-class scanned surface, since translated readers follow the same links, and nothing scopes it out of a run.

Two repairs hold up. Linking the translated heading’s own slug stays correct in each language under the renderer rules this page pins. Pinning an explicit raw-HTML id on the heading survives translation entirely, since the id is harvested as an anchor identity (Resolution) and does not move when the heading text does. Either way the check is the same one every other page gets: the fragment must name an identity the target actually publishes.

What is not modelled

Renderers outside the table publish identities this check will not match, and a repository served by one of them can see an anchor reported missing that its own site resolves. Pandoc, Hugo’s non-github id types, Sphinx and Docusaurus’s custom slug functions are the known cases. The fix for any of them is another row, derived and pinned the same way, since the union only grows.

Last change: , commit: f3ba0901