From d0ce55366057a28719c7d2d5acd4324a2301b4e4 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 18 Sep 2026 15:24:40 +0200 Subject: [PATCH] fix: parse heading text to slug anchors as the site does Identifier headings with two or more underscores were mis-slugged, which failed validate on a correct link. --- package-lock.json | 4 +++- package.json | 4 +++- scripts/lib/anchors.mjs | 40 +++++++++++++++++++++++++++------------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7eb97b6a..fd3be221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,9 @@ "@resvg/resvg-js": "^2.6.2", "github-slugger": "^2.0.0", "glob": "^13.0.6", - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-string": "^4.0.0" } }, "node_modules/@astrojs/compiler-binding": { diff --git a/package.json b/package.json index bef17842..27e1d9e3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "@resvg/resvg-js": "^2.6.2", "github-slugger": "^2.0.0", "glob": "^13.0.6", - "gray-matter": "^4.0.3" + "gray-matter": "^4.0.3", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-string": "^4.0.0" } } diff --git a/scripts/lib/anchors.mjs b/scripts/lib/anchors.mjs index a4cd83c2..af4f0ec5 100644 --- a/scripts/lib/anchors.mjs +++ b/scripts/lib/anchors.mjs @@ -1,25 +1,39 @@ // Heading ids as the site generates them. // -// Starlight slugs the *rendered* heading text with github-slugger, so inline -// markdown is stripped first and an explicit `{#id}` wins. Using the same -// library rather than an approximation is deliberate: compared over the 2995 -// headings in docs/, a hand-rolled slug disagreed on 21 of them, all headings -// containing an arrow, an ampersand, or `/*`. +// Starlight slugs the *rendered* heading text with github-slugger, so the +// heading is parsed to its text first and an explicit `{#id}` wins. Both steps +// use the libraries the site itself uses rather than an approximation of them: +// compared against the ids in a built site, every heading on all 200 pages +// agrees, in both directions. // // Shared by scripts/validate.js (every page, every PR) and // scripts/sync-static-site.mjs (the synced tree, before it is written). import fs from 'fs'; import GithubSlugger from 'github-slugger'; +import { fromMarkdown } from 'mdast-util-from-markdown'; +import { toString } from 'mdast-util-to-string'; import { HEADING_ID } from '../../plugins/remark-heading-id.mjs'; -function renderedText(heading) { - return heading - .replace(/`([^`]*)`/g, '$1') - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1') - .replace(/<[^>]+>/g, '') - .trim(); +// The rendered text of a heading, which is what the site slugs: inline markdown +// resolved away, so `code`, **strong**, [links](x) and raw HTML contribute their +// text and nothing else. Parsed rather than pattern-matched, because inline +// markdown does not reduce to a set of regexes. Two cases that defeated one: +// `flexible_http_request` in a code span (an inner `_http_` read as emphasis, +// so both underscores vanished), and `_foo_bar_`, where CommonMark pairs the +// outer underscores and keeps the intraword one. +// +// Takes the whole line, `#` markers included. The text alone is not the same +// document: "1. Create a canister" parses as an ordered list and renders as +// "Create a canister", losing the number the site slugs into the id. +// +// One construct is out of reach: a footnote reference in a heading renders as +// the note's *number*, which is assigned while the document is rendered, so no +// parse of the heading alone can produce it. `## API[^note]` is `footnote-api1` +// on the site. Neither CommonMark nor GFM parsing yields that, so a link to +// such a heading would be reported as broken. No heading in docs/ does this. +function renderedText(line) { + return toString(fromMarkdown(line), { includeHtml: false }).trim(); } export function anchorsOfText(text) { @@ -36,7 +50,7 @@ export function anchorsOfText(text) { if (!m) continue; // `{#id}` and `{$id}` both set an explicit id; the plugin accepts both. const explicit = HEADING_ID.exec(m[1]); - anchors.add(explicit ? explicit[1] : slugger.slug(renderedText(m[1]))); + anchors.add(explicit ? explicit[1] : slugger.slug(renderedText(line))); } return anchors; }