zfb
GitHub repository

Type to search...

to open search from anywhere

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:

  1. 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.

  2. Deduplicates repeated slugs within the same document by appending a counter (overview, overview-1, overview-2, …). This is a simpler per-base counter than github-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.

  3. Sets the id attribute on the <h*> element.

  4. 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 ::after so 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/zfb/slugify (source: packages/zfb/src/slugify.ts) 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/zfb-content/src/plugins/heading_links.rs, and the public API is an independent TypeScript port. A shared fixture (crates/zfb-content/tests/fixtures/slugify-parity.json) tests that both currently produce matching output; it does not make them one implementation.

Example

## Introduction

## Introduction

Produces:

<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 h2h6. Omitting headingIds entirely keeps this scheme.

strategy: "hierarchical"

Each heading's ID is prefixed with its ancestor chain, joined by -:

## Foo

### Moo

#### Mew

Produces 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 ## Foo is foo-1, so its ### Bar becomes foo-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 id attributes this plugin produces.

  • TOC export — opt-in structured TOC data export.

Revision History

CreatedUpdated