DEV Community

Usama Ejaz
Usama Ejaz

Posted on Originally published at justblogged.com

Heading Anchors After a Blog Migration: 12 Headings Through 9 Markdown Renderers

A 301 keeps the #section part of a link. Your new page usually doesn't.

When someone opens /old-post/#step-3 and the server answers with a 301 to /new-post/, the browser carries #step-3 over to the new address, per RFC 9110 section 10.2.2. The reader lands on the right section only if the new page has an element with exactly that ID. The server can't fix it either... the fragment is never sent in the request (RFC 9110 section 17.11), so no redirect rule can rename it.

So it comes down to how the old and new platforms turn heading text into an id. While writing migration docs for JustBlogged, the blog platform I run, I wanted actual answers instead of guesses. On October 22, 2026 I ran the same 12 headings through nine renderers.

Jekyll, Hugo, Astro and pandoc's GFM mode gave identical IDs for all 12. Ghost, Python-Markdown (what MkDocs uses), pandoc's default Markdown, Eleventy's IdAttributePlugin and markdown-it-anchor's defaults each disagreed with them on several.

What the browser does with #section after a redirect

I served two test pages from a local Python server, with /old/ answering 301 to the new page, and opened /old/#example-1 in Chromium 141 through Playwright. The address bar ended on the new URL with #example-1 still attached, and the page scrolled to that heading.

With a fragment the new page didn't have, the URL kept it and the page just stayed at the top. No error. That's why broken anchors go unnoticed after a migration: the page loads, the link checker sees a 200, and the reader scrolls around looking for the section.

One browser rule helps when the old IDs were encoded. The HTML standard's fragment-scrolling steps look for an element whose ID equals the fragment as written, and if none exists, try again with the percent-decoded fragment. So #caf%C3%A9-menus finds id="café-menus". That matters for Ghost, which writes percent-encoded IDs.

How nine renderers turned the same headings into IDs

The test post had 12 H2 headings picked to hit the usual differences: punctuation, a straight and a curly apostrophe, accents, an emoji, Japanese text, a spaced hyphen, underscores, a code span and a repeated heading.

Versions: Jekyll 4.4.1 (kramdown 2.5.2 with the GFM parser, Jekyll's default), Hugo 0.168.0 on defaults, Astro's Markdown pipeline (@astrojs/markdown-remark 7.3.4), pandoc 3.1.3 in both markdown and gfm modes, Python-Markdown 3.11.2 with the toc extension, markdown-it 15.0.3 with markdown-it-anchor 10.0.0 on defaults, Eleventy 3.1.7 with IdAttributePlugin on, and Ghost's @tryghost/kg-lexical-html-renderer 1.4.5. The Ghost column comes from that renderer package run directly, not a live Ghost site.

Two headings came out the same everywhere: Using `noindex` on tag pages became using-noindex-on-tag-pages, and the first of two "Example" headings became example. The other ten split like this ("same" means identical to the first column):

Heading Jekyll, Hugo, Astro, pandoc gfm Ghost Python-Markdown Eleventy IdAttributePlugin markdown-it-anchor default pandoc markdown
Set up DNS (A/AAAA records) set-up-dns-aaaaa-records same same set-up-dns-a-aaaa-records set-up-dns-(a%2Faaaa-records) same
What's new in v2.0? whats-new-in-v20 same same whats-new-in-v2-0 what's-new-in-v2.0%3F whats-new-in-v2.0
Ghost's 404 page (curly apostrophe) ghosts-404-page ghost%E2%80%99s-404-page same ghost-s-404-page ghost%E2%80%99s-404-page same
C++ & C# c--c c-c c-c c-and-c c%2B%2B-%26-c%23 c-c
Café menus, naïve UTF-8 café-menus-naïve-utf-8 caf%C3%A9-menus-na%C3%AFve-utf-8 cafe-menus-naive-utf-8 cafe-menus-naive-utf-8 caf%C3%A9-menus%2C-na%C3%AFve-utf-8 same
(rocket emoji) Launch notes -launch-notes %F0%9F%9A%80-launch-notes launch-notes launch-notes %F0%9F%9A%80-launch-notes launch-notes
日本語の見出し 日本語の見出し percent-encoded form of the same text _1 empty id="" percent-encoded form of the same text same
Example (second time) example-1 same example_1 example-2 same same
Step 1 - Install step-1---install step-1install step-1-install step-1-install same same
snake_case_names snake_case_names snakecasenames same snake-case-names same same

Eleventy only adds heading IDs if you opt in to its IdAttributePlugin, which uses the built-in slugify filter and @sindresorhus/slugify. An Eleventy site that wired up markdown-it-anchor itself follows whatever slug function its config passes in, so check yours.

Ghost's rules are short enough to read in its slugify source: remove ASCII punctuation including underscores, replace spaces with hyphens, delete any run of two or more hyphens outright, then percent-encode.

Which moves keep their anchors

Jekyll, Hugo and Astro on default settings kept every anchor between each other, because all three follow GitHub's heading rules: Jekyll's default config sets kramdown's input to GFM, Hugo's autoIDType defaults to github, and Astro uses github-slugger. A Jekyll site on kramdown's plain parser, or a Hugo site set to github-ascii or blackfriday, is outside what I tested.

Ghost to one of those is mixed. Plain letters, digits and spaces matched, and so did parentheses, a straight apostrophe and a question mark. Accents and Japanese text also worked in Chromium through the percent-decoding rule: /old/#caf%C3%A9-menus-na%C3%AFve-utf-8 landed on id="café-menus-naïve-utf-8". A curly apostrophe, an emoji, an underscore, a spaced hyphen or & sent the reader to the top of the page.

Python-Markdown and Eleventy's plugin transliterate accents to ASCII, so "Café menus" breaks in either direction between them and a GitHub-style generator. Python-Markdown gave the Japanese heading _1, and Eleventy wrote an empty id="" that no link can reach. Eleventy also numbers a repeated heading from 2, so the second "Example" became example-2 where everything except Python-Markdown wrote example-1. markdown-it-anchor's default keeps punctuation and percent-encodes most of it, so almost any heading with punctuation changes when you leave it.

WordPress core doesn't add heading IDs at all. Anchors on a WordPress post came from the heading block's HTML anchor field or a table-of-contents plugin, each with its own format. You can't predict those from the heading text, so read them out of the old pages.

List the IDs your new pages are missing

Run this before you change DNS, while the old site still answers. It reads a CSV of old_url,new_url pairs (the redirect map you need anyway), fetches both pages, and prints every id or name on the old page with no match on the new one. A percent-decoded match counts as found, same as the browser. Standard library only.

# Usage: python3 anchor_diff.py redirects.csv
# Each CSV row: old_url,new_url
import csv, sys, urllib.request
from html.parser import HTMLParser
from urllib.parse import unquote

class IDs(HTMLParser):
    def __init__(self):
        super().__init__()
        self.ids = set()
    def handle_starttag(self, tag, attrs):
        for name, value in attrs:
            if name in ("id", "name") and value:
                self.ids.add(value)

def ids_at(url):
    req = urllib.request.Request(url, headers={"User-Agent": "anchor-diff"})
    with urllib.request.urlopen(req) as r:
        p = IDs()
        p.feed(r.read().decode("utf-8", "replace"))
        return p.ids

for old, new in csv.reader(open(sys.argv[1])):
    new_ids = ids_at(new)
    reachable = new_ids | {unquote(i) for i in new_ids}
    for i in sorted(ids_at(old)):
        if i not in reachable and unquote(i) not in reachable:
            print(f"{old}#{i}\tmissing on {new}")
Enter fullscreen mode Exit fullscreen mode

On my Ghost-rendered test page and its Astro-rendered replacement it printed five IDs: the curly apostrophe, the emoji, c-c, snakecasenames and step-1install. The same five fragments that left Chromium at the top of the page. On a real site you'll also get theme IDs like navigation, comment and footnote anchors... skim past the ones nobody links to.

Not every missing ID needs fixing. The ones worth keeping are linked from somewhere: your own posts (search your export for href values containing #), docs or forum threads pointing at a section, anything you shared yourself. A heading nobody linked to can take the new ID.

Keep the old ID on the new page

Two ways: set the heading's ID explicitly, or leave the new ID alone and put an empty element with the old ID right before the heading. I built both in Jekyll, Hugo, Astro and pandoc.

  • Jekyll and pandoc accept an explicit ID after the heading text: ## Step 1 - Install {#step-1install}. Both produced <h2 id="step-1install">, per kramdown's header ID syntax.
  • Hugo accepted the same {#step-1install} syntax with no config change. It dropped raw HTML from Markdown though, because renderer.unsafe defaults to false, so the empty-element alias won't survive unless you change that.
  • Astro didn't understand {#...}. It slugged the braces into the ID and produced step-1---install-step-1install. Writing the heading as raw HTML, <h2 id="step-1install">Step 1 - Install</h2>, kept the ID, and so did an empty element before a Markdown heading.
  • Python-Markdown can take an explicit ID through the attr_list extension, per its toc documentation. I didn't build that case.

The empty-element alias lets the heading keep its new clean ID while old links still land:

<span id="ghost%E2%80%99s-404-page"></span>
## Ghost's 404 page
Enter fullscreen mode Exit fullscreen mode

Copy the old ID exactly as the script prints it, percent signs included. In Chromium that span caught both #ghost%E2%80%99s-404-page and the typed curly-apostrophe version, because the browser encodes the typed character before matching.

I still haven't tested Jekyll on kramdown's plain parser or Hugo's other autoIDType values, so if you're on one of those, run the script before you trust the table

Top comments (0)