Skip to content

Add slug='github' for GitHub-compatible heading ids - #43

Draft
PiotrCzapla wants to merge 1 commit into
mainfrom
github-slug-mode
Draft

Add slug='github' for GitHub-compatible heading ids#43
PiotrCzapla wants to merge 1 commit into
mainfrom
github-slug-mode

Conversation

@PiotrCzapla

Copy link
Copy Markdown
Contributor

What this adds

An opt-in slug='github' on to_html, and a matching --slug github on md2html, deriving heading ids by GitHub's rules instead of the dialect's Pandoc-style ones. The default is unchanged.

to_html(to_mdhtml("# Release notes.\n\n## 2021-03-16\n"), slug='github')
# ids: release-notes, 2021-03-16     (default: release-notes.,  section)

Why

An anchor on a document that lives on GitHub is a published address. A wiki page's own table of contents, a README section link, and any link anyone wrote against the rendered page all point at GitHub's slug. Rendering that same Markdown through mdhtml gives a different id, and every one of those links breaks.

I measured the gap rather than assuming it. I fetched GitHub's own _toc fragments for 296 public wiki pages across 18 repositories (gollum, opencv, matplotlib, numpy, scipy, sympy, qBittorrent, rclone, microsoft/terminal and others) and compared them against to_html output page by page. On the 2210 headings where both sides agreed on the heading count, the default rules disagreed on 539 anchors.

The causes, by volume:

count cause
368 heading is all digits or punctuation, so ids collapse to section, section-1, section-2
114 a period is kept, so Reverse Proxy is not working. ends in -working.
46 leading digits or hyphens are stripped, so --page-file-dir loses its dashes
11 other

The first row is the worst of them, and it is not merely a wrong link. OpenCV's meeting-note pages are date headings top to bottom, so one page gets 79 ids named section-1 through section-79. Inserting a heading anywhere renumbers every id below it.

Design choices

A separate slug= parameter, not an auto_ids variant. auto_ids answers whether ids get minted at all, and callers rendering several fragments into one page already pass auto_ids=False. The rule for minting is a different question, and the codebase already spells mode choices as string parameters (refs, math, number_headings). slug='github' composes with auto_ids=False the obvious way, which is to mint nothing.

Unicode general categories, not a port of github-slugger's regex. That library removes punctuation via a generated character class thousands of characters long. Copying it into Rust would be unreviewable and would rot. Keeping letters, numbers and marks, plus -, _ and space, reproduces all 3648 corpus anchors exactly, so the categories are what the regex is actually expressing.

I recommend reviewing this by running the check rather than reading the class. A worked hazard: the real regex begins at U+2000, which renders identically to an ASCII space. My first attempt at transcribing it produced the range "space through U+206F", which deletes every letter and silently emitted empty anchors for the whole corpus. The category rule has no such failure mode.

U+200D is kept. github-slugger's class happens to omit the zero width joiner while removing its neighbours, which keeps emoji sequences intact. This is a quirk rather than a principle, so it is a named constant with a comment. It is the difference between 99.973% and 100% on the corpus.

Reading the heading text verbatim, not through norm_text. GitHub's rule depends on whitespace the dialect's own rule discards, so this mode calls to_text directly. Three behaviours follow, and all three are load-bearing:

  • Only U+0020 becomes -. A newline is dropped outright, so # Minutes\n* is minutes, not minutes-.
  • No trimming, so # ![](i.png) Wiki keeps the space the image left and yields -wiki.
  • No collapsing, so a doubled space yields --.

These look like bugs and will attract a tidy-up. The comment at norm_text's call site says why they are not.

No section fallback. A heading with nothing left takes the empty id and repeats dedupe to -1, -2. That is GitHub's behaviour, and the fallback is precisely what causes the 368-anchor failure mode above.

Validation

End to end, 2285 of 2290 anchors match GitHub across 196 pages.

The 5 misses are all on one page, microsoft/terminal Core-team-North-Stars, whose headings read ## Dustin [@DHowett] with a matching link definition below. [@DHowett] is a CommonMark shortcut reference link, but the parser reads it as a cross-reference, so the heading text becomes Dustin and slugs to dustin-. Given the text GitHub actually slugs, this mode returns dustin-dhowett. The slug rule is exact; that page hits a separate defect, noted below.

Testing

cargo fmt, cargo check, cargo test and gen_docs(check=True) are clean. pytest is 938 passed with 3 failures that reproduce identically on a clean main: two in test_fill.py from an unawaited coroutine, and one from the optional math-core package being absent.

Two tests added to tests/test_export.py, one per divergence and one for dedup and option handling.

Not in this PR

Four issues the same survey turned up, listed so they are not lost:

  1. Setext headings. 58 of 313 pages underline headings with --- or ===. The dialect drops them by design, so they render as a paragraph plus <hr> and vanish from the table of contents. This costs 138 headings across 56 pages, and whole pages disappear: matplotlib/MEP9 has 9 headings on GitHub and 0 here. This is the largest single divergence and it is a deliberate design decision, so it needs your call rather than a patch.
  2. [@name] collides with shortcut reference links, and in the default refs='resolve' mode to_html raises ValueError and produces nothing for the whole page.
  3. A leading UTF-8 BOM hides the first heading, which GitHub strips. One page in the corpus.
  4. <details> and <summary> are outside the raw HTML subset, so they render as visible escaped text. No public wiki in the sample used them, but our own answerdotai wiki uses them 36 times on one page.

`to_html` derives heading ids by Pandoc's rules, which differ from GitHub's
in ways that break links written against a GitHub-rendered page. Measured
against GitHub's own table-of-contents fragments for 296 public wiki pages,
the default rules disagree on 539 of 2210 anchors.

Add an opt-in `slug='github'` deriving ids by GitHub's rules instead, for
documents that already live on GitHub, where an anchor is a published
address. The default is unchanged.

Three corners drive the divergence, each confirmed against GitHub's
rendering rather than inferred:

- Only U+0020 becomes `-`. Other whitespace is dropped, so a heading split
  across source lines slugs as one word.
- Text is neither trimmed nor collapsed, so a heading opening with an image
  keeps the space it left, and a doubled space gives `--`.
- There is no `section` fallback, so an all-digit heading such as a date
  keeps its digits instead of collapsing to `section`, `section-1`, and so
  on, which renumber whenever a heading is inserted above them.

The character rule uses Unicode general categories (letter, number, mark)
rather than a copy of `github-slugger`'s generated regex, which reproduces
all 3648 corpus anchors exactly. U+200D is kept because that regex omits it,
which keeps emoji sequences intact.

End to end, 2285 of 2290 anchors now match GitHub. The 5 remaining are one
page whose `## Dustin [@DHowett]` headings hit the separate collision
between `[@name]` shortcut reference links and cross-reference syntax; the
slug rule is exact given the right heading text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant