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 thirteen 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. One fragment needs no rule at all: the HTML standard scrolls #top, in any case, to the top of every page, so it resolves on any document whatever its headings. Starlight writes _top on the title of every page it builds, so on a page of an Astro project whose configuration imports @astrojs/starlight, #_top resolves the same way. github.com also lowercases a fragment before it looks for the heading, so #Setup--Config reaches setup--config there, while gitlab.com matches the fragment exactly, as a browser does on a built site. The resolver lowercases too when a run declares a GitHub repository with --repository, and matches exactly otherwise.

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; also anchors a heading written as raw HTML, on the counter its Markdown headings use
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; spaces, hyphens, dots and underscores all become one _; keeps Unicode letters; (C), ..., arrows and -- vanish, so A -- B is _ab; repeats number from _2
mdnMDN Web Docs, built by rari, under a front-matter-config.jsondrops ASCII punctuation except !, -, . and _ and keeps everything else, Unicode included; a run of spaces becomes one _; empty becomes sect; repeats number from _2, and a definition-list term or raw HTML heading takes its number after every Markdown heading holds one

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. The title is read after Asciidoctor’s replacements, which turn (C), (R), (TM), an ellipsis, the four arrows and a dash between words into character references that the id rule then deletes; a spaced -- takes its spaces with it. A title whose every character is filtered away gets an empty id, which no link can name, so it publishes nothing here.

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.

The union has a price in the other direction. A heading Setup & Config publishes setup--config on github.com and setup-config under VitePress, and a link spelled either way passes in a repository GitHub renders, where only the first works. So a repository that knows its renderer can say so, and the pin can only narrow the set. anchor_renderers in the scanner policy names rules from the table above, and a Markdown or MDX page is then read under those rules alone, beside the identities it declares. AsciiDoc and reStructuredText pages keep their own rules whatever the pin says, and a name the table does not hold refuses the policy rather than emptying the set. A narrower pin can only turn a resolved anchor into a missing one, which is why it fits a policy that only raises. Dropping the pin, or adding a name the base’s pin did not hold, is policy-weakened under policy/anchor-renderers-widened.

mdn is the one row read only in some trees: under front-matter-config.json or .front-matter-config.json, the front-matter schema mdn/content and mdn/translated-content keep at their root. Its underscore spelling is what most other rules write with a dash, so read everywhere it would answer for a broken link on any site. k8s-community’s #kinds_of_tests is one: github.com publishes kinds-of-tests, and the link is broken there.

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.

Footnotes

A footnote is no heading, but most renderers give its note and its first call an identity, and a page links #fn:1 to reach the note. Six rules carry those spellings, each checked against its renderer’s output. They key the identity three ways: by the label as the document writes it, by the label lowercased, or by the place the note’s first call takes among the notes called. So [^note], called first, is fn:note under Python-Markdown and fn:1 under goldmark.

RuleServesNoteFirst call
python-markdownMkDocsfn:<label>, also for a note nothing callsfnref:<label>
kramdownJekyll, GitHub Pagesfn:<label>fnref:<label>
goldmarkHugofn:<order>fnref:<order>
markdown-itVitePress, VuePressfn<order>fnref<order>
remarkDocusaurus, Starlightuser-content-fn-<lowercased label>user-content-fnref-<lowercased label>
mdbookmdBookfootnote-<label>fr-<label>-1

Only the first call is read. A second call to the same note publishes fnref2:note under Python-Markdown and fnref:note:1 under kramdown, and each renderer numbers repeats its own way. github.com is not in the table: it suffixes every footnote identity with a digest of the render, so no spelling a document writes can name one.

What a document declares

An identity can also be written down rather than left to a heading’s slug, and then it belongs to the author or to a construct no heading rule reads. Each of these spellings joins the union for every renderer, because accepting an identity a given renderer would not publish can only leave a finding unreported, never invent one. Seven of the rows write down no identity: they are the spellings a declared generator, hook, extension or layout owns, five saying the page’s identities are built elsewhere and two naming a reference. Eight rows are gated on a file in the tree, the way the route rules are; the rest are read wherever their profile is.

DeclarationSpellingRead inSelected by
html-idan id or name attribute on a raw HTML element, or on one written inside an mdx-code-block fencemarkdown, mdxany tree
attr-listan attribute block alone on a block’s first or last line, {#id}markdownany tree
attr-list-inlinean attribute block directly after an inline construct or a bracketed span, **text**{#id} or [text]{#id}markdownany tree
definition-terma term line above a : definition linemarkdownany tree
mkdocs-snippeta --8<-- line naming a quoted path, alone on the linemarkdownmkdocs.yml, mkdocs.yaml, zensical.toml
mkdocs-directivea ::: line naming what a generator renders, alone on the linemarkdownmkdocs.yml, mkdocs.yaml, zensical.toml
mkdocs-shortcodean HTML comment naming a hook’s shortcode, <!-- md:name -->markdownmkdocs.yml, mkdocs.yaml, zensical.toml
mkdocs-content-taba content tab opening a quoted title, === "Title"markdownmkdocs.yml, mkdocs.yaml, zensical.toml
hugo-shortcodea shortcode call alone on its line or anywhere in a heading, {{% name %}} or {{< name >}}markdownhugo.toml, hugo.yaml, hugo.json, config.toml, config.yaml, config.json, config/_default/hugo.toml, config/_default/hugo.yaml, config/_default/hugo.json, config/_default/config.toml, config/_default/config.yaml, config/_default/config.json
eleventy-templatea Liquid tag or output anywhere in a heading, {% name %} or {{ name }}markdowneleventy.config.ts, eleventy.config.js, eleventy.config.mjs, eleventy.config.cjs, .eleventy.js
myst-targeta target alone on its line, (name)=markdownany tree
myst-directive-namea directive’s :name: option, or a figure-md opener’s argumentmarkdown, rstany tree
myst-glossarya term of a definition list opening with {.glossary}markdownany tree
myst-domain-objecta domain:type directive’s object name, {py:class} widgets.Widgetmarkdownany tree
myst-rolea cross-reference role, {doc}`name`markdownconf.py
myst-linka plain link naming a label, [text](name) or [text](#name)markdownconf.py
mdx-commentan MDX comment ending a heading, {/* #id */}mdxany tree
mdx-heading-idan MDX expression ending a heading, {#id}mdxany tree
jsx-idan id attribute on a lowercase JSX elementmdxany tree
mdx-partiala default import of a relative document, rendered as an elementmdxany tree
mdn-interactive-examplean InteractiveExample macro call, which renders a Try it headingmarkdownany tree
asciidoc-anchora block anchor or attribute list alone on its line, [[id]], [#id], [source#id] or [id=name]asciidocany tree
asciidoc-inline-anchoran anchor in the flow of a line or a section title, [[id]], [[[bib]]] or anchor:id[]asciidocany tree
asciidoc-reference-texta section title, or the reference text an anchor or reftext gives, that a natural cross reference namesasciidocany tree
rst-targetan internal hyperlink target, .. _name:rstany tree

html-id is every id and name a raw HTML region carries, wherever in the document it sits. It reads an mdx-code-block fence too, because that fence is markup rather than code: Docusaurus strips the two fence lines out of the file before anything parses it, so the <details id="node-env"> written between them opens an element of the built page and the id on it is an identity of that page. Its loader unwraps the three- and four-backtick spellings with nothing but the word on the opener, so those are the ones read here and every other fence stays code. On the Docusaurus tree that spelling is thirteen of the missing-target findings, one link written into thirteen versions of the same page.

attr-list is the extension’s own 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. A quoted value is one attribute however many spaces it holds, so ### with pip <small>recommended</small> { #with-pip data-toc-label="with pip" } still names with-pip.

The other end of the block is MyST’s attrs_block, which writes the identity of what follows on the line above it rather than on the last line of the block itself. {#paragraph-target} alone above a paragraph names that paragraph, and the identity is the same one the trailing form declares, so both ends are read. A directive opener leaves no blank line under it, so the opener and the block it holds share one paragraph and the attribute block is its second line rather than its first. {#mypara} under :::{note} still names the paragraph beneath it.

attr-list-inline is the extension’s other half, the block that attaches to the inline construct it directly follows rather than to the block around it. That is how * **\locale`**{ #mkdocs-locale }: the locale usednames a list item's own term while the sentence carries on after it, and the identity is the same one the block form declares. The block opens the text that follows the construct, because anything between the two breaks the pairing. A bracketed span is the other carrier,[text]{#id}, which MyST's attrs_inlineand Pandoc both write. A bracket pair naming no definition is plain text to the Markdown grammar, so no node is built for one and the]` is the construct’s own end: a block against one is read from the text it sits in wherever that text falls.

definition-term is the one row derived from the document’s text rather than written down by its author. Hugo publishes an identity for a definition-list term under autoDefinitionTermID, slugged by the rule its headings take and numbered on the counter its headings occupy, so a matchers term above a later ## Matchers publishes matchers and then matchers-1. The term is the line above a line opening with : , and a second definition under one term names no new term. Hugo’s own flag decides whether any of this is published and this engine does not read it, so a term joins the union wherever the spelling appears. A page that writes a definition list in a tree Hugo never builds gains an identity nothing publishes, which can leave a finding unreported and cannot invent one.

MDN writes its definition lists another way, - term over a nested - : definition, and the mdn rule reads those terms itself rather than through this row. rari names a term once every Markdown heading holds its identity, from the text of the element the term opens with, or from the whole term when it opens with text, so - `accept` (optional) publishes accept. A term opening with a cross-reference macro publishes what the macro displays, its second string argument or else its first, so {{cssxref("@layer")}} publishes layer.

mdn-interactive-example is the Try it heading MDN’s InteractiveExample macro renders above the example it embeds. Its identity is try_it, the English title under the mdn rule, and a call standing alone on its line declares it.

The two MDX rows are the same heading identity written two ways, because the attribute spelling is an expression in that grammar. mdx-heading-id is the classic ### Hello {#hello}, which Docusaurus escapes before MDX parses the file and then reads back out of the heading text; it is on by default and off under future.v4.mdx1CompatDisabledByDefault. mdx-comment is ### \noIndex` {/* #noIndex /}, the spelling that needs no escape. Either one ends the heading or it declares nothing, which is parseMarkdownHeadingId's own rule and the reason {/ #id */} after` names nothing, and either one is taken as written with its case intact.

jsx-id is the MDX answer to html-id, because MDX has no raw HTML and <details id="x"> in an MDX file is JSX. The split JSX itself makes is the one this rule uses: a tag name starting with a lowercase letter is an HTML element, so its id reaches the page and is read, and every other name is a component. A component’s rendered output is unknown here, so an id written on one is a prop rather than an identity. The blocks a component wraps are a different thing. The MDX grammar parsed them as Markdown of this page, so they are read like any other blocks, and a plain element written there publishes its id as it would anywhere else. What the component makes of those blocks stays unknown, which is why the option tables Docusaurus builds with <APITable> publish row identities this check cannot see, and why a link into one stays a finding.

mdx-partial is the one rule that reads another file. Docusaurus composes a page out of several documents by importing one and rendering it, import Tags from './_tags.mdx' and then <Tags />, and the headings that partial writes are headings of the page. The import resolves through the same path rules a link uses, only to a document the tree already holds, and only for a default import of a relative .md or .mdx file rendered as an element, so a package, an alias, or a stylesheet declares no edge at all. A partial that renders a partial expands too, under the include budget in Resolution, and two documents rendering each other leave the set past the cycle undecided rather than recursing.

The identities flow one way. The page publishes what the partial writes, because the partial’s headings are on the page. A link written inside a partial that names a heading of the page rendering it is a different question, and this check does not answer it: the same partial can be rendered into several pages, so the identity that link names belongs to a rendering context rather than to the file the link sits in. That is a known gap, and on the Docusaurus tree it is eight of the findings.

mkdocs-snippet does the same composition in Markdown, and it is the one row this table gates on a file. The syntax belongs to the pymdownx.snippets extension, so the line is read only when mkdocs.yml or mkdocs.yaml sits on the document’s ancestor chain, or the zensical.toml of Zensical, which builds the same sites with the same extensions, and on that chain alone: the rule moves the directory an include is read from, which the one site a tree declares cannot stand in for the way it does for the routes in Route spellings. Without one the line is ordinary text and includes nothing, which is what it is. The path is resolved from the directory holding that file rather than from beside the document, because that is where MkDocs runs, so a page whose whole body is --8<-- "CONTRIBUTING.md" publishes what the repository’s own contributing guide publishes. Only the single-line form with a quoted path is read; a path carrying a section coordinate names part of a file, which this engine cannot reproduce, so that edge is refused and absence in the page stays undecided.

mkdocs-directive is the line that pulls content in from outside the tree, and the one edge this engine reads and then stops at. ::: pydantic.config asks mkdocstrings for the documentation of a Python object and ::: mkdocs-click asks that plugin for a command tree, so the headings the built page carries come from a program rather than from a file. The page keeps everything it writes itself: an anchor naming one of its own headings still resolves, and an anchor naming none of them is declared unsupported instead of reported absent, which is the same answer a snippet the engine cannot follow gives. Only the form with whitespace after the marker is an instruction, so the :::note and bare ::: fences Docusaurus writes name no generator; a VitePress container such as ::: tip is the same shape, and it is read only in a tree that declares MkDocs, which a VitePress tree does not. That is the gate: without mkdocs.yml or mkdocs.yaml above the document, three colons are three colons.

mkdocs-shortcode is the page a hook rewrites before anything is rendered. Material for MkDocs ships one that replaces every <!-- md:name argument --> comment in the Markdown, so #### <!-- md:setting config.blog_dir --> reaches the built page as a heading whose identity is config.blog_dir. A comment carries no text under any rule in the table, so what this engine reads there is an empty heading, which is not what the page publishes. The page keeps every heading it writes itself and an anchor naming none of them is declared unsupported instead of reported absent, which is the answer the generator instruction gives. Without that declaration above the document a comment is a comment.

mkdocs-content-tab is the same answer for pymdownx.tabbed. A tab opens with === "Title", and the extension publishes an identity for the title under the slug function the site configures, combined with the heading above it where combine_header_slug asks for that. Both settings sit inside mkdocs.yml, whose presence is all this engine reads, so a page carrying a tab leaves its identity set incomplete rather than guessing which of the two spellings the build wrote. Three equals signs with nothing quoted after them are prose.

hugo-shortcode is the same answer for Hugo, where the call names a template rather than a file. {{% include "_common/store-methods.md" %}} reads like an include, but include there is a shortcode the site defines under layouts/, and it works by fetching a page and rendering it, so what arrives is a template’s output. The headings in that output are headings of the built page, and under autoDefinitionTermID so are the terms definition-term reads, which is why an anchor into either was reported absent before. A page that calls one now keeps every identity it writes itself and leaves the rest undecided. On the Hugo documentation tree that is all twenty-three of its missing-target findings, and no repository outside a Hugo tree moves by one.

Only a call standing alone as a block is read, since one written in the flow of a sentence renders inside that sentence and can open neither a heading nor a term. Both markers count, the {{% %}} form whose output is rendered as Markdown and the {{< >}} form whose output is raw HTML, because a heading can arrive through either. That tree does not separate the two readings: none of the eighteen pages whose only call is inline is anchored into at all, so the narrow rule is the smaller claim rather than the measured one. Without a Hugo configuration above the document a pair of braces is a pair of braces.

A call inside a heading is the exception to the block rule, because it writes part of that heading’s text, and a renderer slugs the identity from the text it was given. The Kubernetes website titles its closing sections ## {{% heading "whatsnext" %}}, which the site renders in each page’s own language, so no spelling in the tree names what that heading publishes. Reading it moves 38 of that site’s claims to undecided. Seventeen named the templated headings themselves and were wrong. The other 21 name headings a translation never wrote, so they are almost certainly broken, and they go undecided with the rest of the page, the same price the block rule pays. A heading that sets its own id with {#id} keeps that id and says nothing about the rest.

A Hugo page can hand itself to a template whole. The Kubernetes glossary is a page whose frontmatter names layout: glossary and whose body writes nothing, and the layout fills it with a term- anchor for every entry under the directory beside it. So a page naming a layout and publishing no heading or anchor of its own is read the way a page calling a shortcode is, and an anchor into it stays undecided. A page that names a layout and writes its own headings keeps them: a Kubernetes blog post names layout: blog, the layout wraps the post, and a link to a heading the post never wrote is still reported. eleventy-template is the same reading for Eleventy, which renders a Markdown page through Liquid before Markdown sees it: ## Passthrough File Copy {% addedin "0.2.14" %} and a ## {{ t }} a loop repeats are headings a template finishes, and all seven claims the rule moves on the Eleventy site were wrong. Liquid is read only under an Eleventy configuration and a shortcode only under a Hugo one, so a brace in a heading anywhere else is part of the text it slugs.

myst-target and myst-role are the two MyST spellings, which is how a Sphinx project writes its pages in Markdown. (name)= alone on its line is the target: the renderer writes that identity onto the block after it, and Sphinx keeps the same name as a label, so it also joins the label table a {ref} is answered from. The role is the reference, {doc}`quickstart` where reStructuredText writes :doc:`quickstart`, and Resolution says which roles are answered and which are counted and left alone. The target is read wherever the spelling appears, because an identity can only widen the set an anchor may match, while a role is read only under a conf.py, so a brace before a code span in an ordinary Markdown file stays the prose it is.

myst-directive-name is the name a directive carries. Every docutils directive takes a :name: option and MyST spells a directive as a brace-tagged fence, so :name: build-note under :::{note} publishes build-note the way (build-note)= above the block would. The option is read under a {name} tag and nowhere else, so the :::note fence Docusaurus writes and an ordinary code fence declare nothing. An eval-rst body is reStructuredText and its directives spell the same option, so a .. figure:: carrying :name: rst-fun-fish inside a Markdown page publishes that name too, and a reStructuredText document publishes it directly. figure-md takes the name as its argument instead, :::{figure-md} fig-target, since it is MyST’s own Markdown figure; every other directive writes a path or a title there, so the argument is read under that one tag and the domain tags the next row names.

myst-domain-object is the argument of the other kind of directive, the one that describes an object rather than formatting a block. Sphinx stores what {py:class}, {js:function} or any other domain:type opener names under that name as written, so [](#widgets.Widget) finds the class its own page describes. The colon in the tag is what marks one, which keeps {note} and {figure} out of the reading. What follows the name is the domain’s own signature grammar and this engine parses none of it, so a parameter list comes off the end and anything still carrying a space declares nothing. {py:function} open(name) publishes open, and {cpp:class} template<typename T> Holder publishes nothing at all.

myst-glossary is a definition list read the way Sphinx reads one. A list opening with a {.glossary} attribute block is a glossary, and what a glossary term publishes is the term itself, spaces and all, rather than a slug of it, which is how [](<#my other term>) finds it. One list can therefore publish two identities, the term as written here and the slug the definition-term row gives it, and a list with no {.glossary} above it publishes only the slug. Both of these rows are read wherever the spelling appears, since an identity can only widen the set an anchor may match, and both reach the label table a {ref} and a plain link are answered from only where a conf.py governs the page that writes them.

myst-link reads those same names from the other side, so it is a reference rather than a declaration. Sphinx keeps every name a page declares as a global label, and a plain Markdown link can name one where a path goes: [alert extension](syntax/alerts) in myst-parser’s own admonitions page reaches the (syntax/alerts)= target in a second file, and [colon_fence](#syntax/colon_fence) reaches one through a bare fragment. The tree answers first, so a destination naming a file still resolves as that file, and the label table is asked only once the tree has said no. A name no document declares keeps the missing target it had, since an undeclared cross reference is as broken as an absent path. The reading needs a conf.py above the document the way the role does, because a label is a global name and a path is not, so an ordinary Markdown repository is untouched.

The last rows belong to the other two profiles. asciidoc-anchor is the anchor an AsciiDoc author writes on a line of its own: [[id]], with the identity being everything before the first comma, or an attribute list naming one, through the #id shorthand after any style and before any .role or %option, [source#hello] or [#intro.lead], or through the named id attribute, [quote,id=qid]. asciidoc-inline-anchor is the anchor written in the flow of a line, on a list item, mid-paragraph or inside a section title: [[id]], the bibliography spelling [[[bib]]], and the anchor:id[] macro. Asciidoctor reads each as an anchor on the construct it sits in; an escaped \[[ and one inside an inline passthrough declare nothing. A section title that carries one publishes it beside the identity the asciidoctor rule generates for the title text with the anchor taken out, which is how == [[custom-sec]]Install Guide publishes both custom-sec and _install_guide. The title can also open with # characters instead of =, which Asciidoctor accepts from Markdown. A title reads the attributes the document’s own entries have defined by then, :product: Acme before == Using {product} publishing _using_acme, and {empty} or {sp} as themselves. A title naming an attribute the document does not define keeps the reference as written, the way Asciidoctor does, but the page then proves no identity absent, since Antora or the command line may define it. asciidoc-reference-text is the reference text a natural cross reference such as <<API entrypoints>> looks its target up under: a section’s own title, the text after an anchor’s comma, or a reftext attribute, each published where it carries a space or a capital. rst-target is the internal hyperlink target, .. _name:, which Docutils turns into an identity on whatever follows it. That name is also what a Sphinx :ref: looks up, and Resolution describes that lookup, which is a different question from whether a fragment names an identity.

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 asciidoctor column is what Asciidoctor 2.1.0.alpha.0 generated for each case. The remaining four are transcribed and traced by hand: Gitea’s CleanValue, Forgejo’s prefixedIDs, mdBook’s id_from_content, and Docutils’ make_id. The mdn column came from rari’s own anchorize, compiled from its source at a86696d and run over every case. Against mdn/content it leaves 3 of the 2,581 anchor findings a release without it reported, and two of the three are real breaks. The published vectors name which of the thirteen 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 under its mdx-comment syntax. Three of its seven headings declare an identity to that call 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 call alone does not read. Its pair is compared as a subset, since the loader runs the classic spelling too and mdx-heading-id carries it. 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. Within MDN, a heading whose text is a macro takes its identity from what the macro renders, which only the definition-list terms model, and a live sample’s frame, frame_ and the sample’s identity, is not read. Zola is a narrower case: it transliterates a heading to ASCII before slugging it, so Привет мир publishes privet-mir, and no rule here transliterates. A page under a Zola config.toml whose headings reach outside ASCII therefore keeps absence undecided: an identity the union knows still resolves, and one it does not is declined rather than reported missing. The fix for any of them is another row, derived and pinned the same way, since the union only grows and a pin can only name rows the table holds.

Last change: , commit: 9db51ff9