Heading links
Automatically adds slug-based id attributes and anchor links to every heading.
HeadingLinksPlugin is always active. It slugifies every <h2>–<h6> and injects a self-referencing anchor link so readers can copy a deep-link to any section.
Behaviour
For each heading the plugin:
Computes a slug from the heading text: lowercased, a fixed set of ASCII punctuation stripped, whitespace collapsed to a single
-. This is a github-slugger-style algorithm, not a byte-for-byte compatible one — see the note below.Deduplicates repeated slugs within the same document by appending a counter (
overview,overview-1,overview-2, …). This is a simpler per-base counter thangithub-slugger's own numbering, which is collision-aware (it skips a suffix that's already taken by another heading's literal slug); zfb's counter does not check for that case.Sets the
idattribute on the<h*>element.Appends an empty
<a href="#slug" class="hash-link" aria-label="…">as the heading's last child. The heading text is left unwrapped. The visible#glyph is rendered via CSS::afterso the anchor body stays empty, keeping heading-text extraction (e.g. for TOC) clean.
Not github-slugger compatible
The slug step (1 above) is not compatible with npm github-slugger, despite the resemblance. The key divergence: characters in the stripped punctuation set collapse to a - separator instead of being removed outright — "a,b" slugifies to "a-b" here, versus "ab" ingithub-slugger. Unicode letters, numbers, symbols, and punctuation outside that fixed ASCII set (CJK, kana, hangul, full-width punctuation, emoji) pass through unchanged, lowercased where casing applies — this has tripped up full-width-paren headings in Japanese content before.
The heading-ID algorithm above and the slugify() function exported publicly from @takazudo/ (source: packages/) are the same algorithm — not two unrelated slugifiers, as the naming might suggest — but they are two separate implementations: heading IDs come from the Rust slugify() in crates/, and the public API is an independent TypeScript port. A shared fixture (crates/) tests that both currently produce matching output; it does not make them one implementation.
Example
## Introduction
## IntroductionProduces:
<h2 id="introduction">Introduction<a href="#introduction" class="hash-link" aria-label="Direct link to Introduction"></a></h2>
<h2 id="introduction-1">Introduction<a href="#introduction-1" class="hash-link" aria-label="Direct link to Introduction"></a></h2>Config
The plugin itself is always on. The ID strategy is configurable via markdown.features.headingIds:
// zfb.config.ts
export default {
markdown: {
features: {
headingIds: { strategy: "hierarchical" },
},
},
};strategy: "flat" (default)
The behaviour described above: github-slugger-style slugs (see the note above) with one dedup counter shared across h2–h6. Omitting headingIds entirely keeps this scheme.
strategy: "hierarchical"
Each heading's ID is prefixed with its ancestor chain, joined by -:
## Foo
### Moo
#### MewProduces id="foo", id="foo-moo", id="foo-moo-mew" (instead of the flat foo, moo, mew). The in-heading hash-link anchors, the headings export, TOC export, and link validation all follow the same IDs.
Details:
A duplicated full path still gets the dedup counter (
a-b,a-b-1).A deduplicated parent contributes its final ID to children: the second
## Fooisfoo-1, so its### Barbecomesfoo-1-bar.Hierarchical anchors are reconstructible from the heading outline and collide far less often than flat slugs, at the cost of longer URLs.
Warning
Switching strategies is anchor-breaking: existing deep links to nested headings (#moo) stop resolving once IDs become #foo-moo.
Ordering note
HeadingLinksPlugin runs first in the hast phase. Plugins that depend on stable heading id values — such as TocPlugin (opt-in heading-marker-toc) and TocExportPlugin (opt-in toc-export) — must run after it.
See also
Heading-marker TOC — opt-in TOC insertion that reads the
idattributes this plugin produces.TOC export — opt-in structured TOC data export.