You rename a heading in your docs from "Install" to "Installation". Your static site generator dutifully changes the heading's id from #install to #installation. Every link that pointed to /docs/setup#install now lands at the top of a long page instead of the section the reader needed.
Your link checker reports no problems. The page returns 200. Technically, nothing is broken.
Fragment links (the #something part of a URL) are one of the quietest kinds of link rot. The server never sees the fragment, so no HTTP check can catch it. Here's how to find them yourself.
Why fragments break so often
The fragment refers to an element with a matching id (or, in older HTML, an <a name="...">). Those ids usually come from one of three places:
- Auto-generated from headings. Most Markdown tools slugify headings: "Getting Started" becomes
#getting-started. Change the words and you change the id. - Hand-written ids in HTML, which survive heading edits but get lost when content is rewritten or moved to another page.
- JavaScript-generated content, where the id only exists after the page renders.
Heading-based ids are the main culprit. Every copy edit to a heading is a potential broken link, and the people editing headings are rarely thinking about who links to them.
The plan
- Collect every link on the site that contains a
#. - Collect every
id(andname) on every page. - Report links whose fragment doesn't exist on the target page.
We'll do this against the built HTML output, which is what readers actually get, rather than the Markdown source.
The script
This uses Node with cheerio for HTML parsing (npm install cheerio). Point it at your build directory.
// check-anchors.mjs import fs from "node:fs"; import path from "node:path"; import * as cheerio from "cheerio"; const root = path.resolve(process.argv[2] ?? "dist"); function* walk(dir) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) yield* walk(p); else if (p.endsWith(".html")) yield p; } } // Map a file path to the URL path it is served at. function urlPathFor(file) { let rel = "/" + path.relative(root, file).split(path.sep).join("/"); return rel.replace(/index\.html$/, "").replace(/\.html$/, ""); } const ids = new Map(); // url path -> Set of ids const links = []; // { from, href } for (const file of walk(root)) { const $ = cheerio.load(fs.readFileSync(file, "utf8")); const page = urlPathFor(file); const set = new Set(); $("[id]").each((_, el) => set.add($(el).attr("id"))); $("a[name]").each((_, el) => set.add($(el).attr("name"))); ids.set(page, set); $("a[href*='#']").each((_, el) => links.push({ from: page, href: $(el).attr("href") })); } let broken = 0; for (const { from, href } of links) { const url = new URL(href, "https://site.invalid" + from); if (url.hostname !== "site.invalid") continue; // external: skip const frag = decodeURIComponent(url.hash.slice(1)); if (!frag || frag === "top") continue; // "#" and "#top" scroll to top const target = url.pathname.replace(/index\.html$/, "").replace(/\.html$/, ""); const known = ids.get(target) ?? ids.get(target.replace(/\/$/, "")) ?? ids.get(target + "/"); if (!known) continue; // missing page: a different checker's job if (!known.has(frag)) { broken++; console.log(`${from} -> ${href} (no #${frag} on ${target})`); } } console.log(`\n${links.length} fragment links checked, ${broken} broken`); process.exitCode = broken ? 1 : 0; Run it:
npm run build node check-anchors.mjs dist Output looks like:
/guide/quickstart -> /docs/setup#install (no #install on /docs/setup) /blog/v2-release -> #breaking-changes (no #breaking-changes on /blog/v2-release) 412 fragment links checked, 2 broken Because it exits non-zero when it finds problems, you can drop it straight into CI after your build step.
Adjusting it for your site
Trailing slashes and clean URLs. The urlPathFor function assumes about/index.html is served at /about/ and about.html at /about. If your host does something different, adjust the mapping. This is the part most likely to need tweaking.
JavaScript-rendered ids. If ids are added by client-side code, they won't be in the built HTML. You'd need a headless browser to render pages first. For most docs sites, ids are in the static output.
Case sensitivity. Fragments are case-sensitive in HTML. #Install and #install are different. The script treats them that way, which matches what browsers do.
Encoded characters. Headings with non-English characters produce encoded ids. The script decodes the fragment before comparing; check a few results if your docs are multilingual.
Fixing them without breaking more
When you find a broken fragment, you have two options.
Update the link to the new id. Fine for links inside your own site, which you control.
Keep the old id alive for links you don't control: other sites, old blog posts, Stack Overflow answers pointing to your docs. Add an empty anchor with the old id next to the new heading:
<h2 id="installation"><span id="install"></span>Installation</h2> Many Markdown tools also let you set explicit heading ids, for example ## Installation {#installation}. Using explicit ids for important headings means copy edits no longer change URLs.
Make it a habit
The most effective fix isn't the script, it's the policy: treat heading ids in docs as public URLs. Once a section has been linked to, its id is part of your API, and changing it deserves the same care as changing a page URL.
Fragment checks are one of the details we care about at linkbruno.com, because a link that lands in the wrong place wastes the reader's time just as surely as a 404.
Related reading
- Postmortem: the deploy deleted last week's JavaScript bundles, and cached pages kept asking for them
- Anchor link not working? How to find and fix broken #section links
- Check links in Markdown docs with a 60-line Python script
- Markdown links in a monorepo: 7 things I'd do differently after the third big folder move
Do you check fragment links on your site today, or have you found a tool that handles them well? I'd love to hear how others deal with heading renames.
