test(docs): resolve every content/docs anchor against fumadocs' own heading ids - #1358
Merged
Merged
Conversation
…eading ids `content/docs` had no guard on the anchor half of its intra-doc links, and they rot in four unrelated ways: a copied link whose path was edited and whose anchor was not, a translated heading with an untranslated anchor, an emoji heading whose slug carries a leading hyphen, and a heading renamed after the link was written. Nothing was watching — `docs-object-coverage` checks that pages exist, `next build` does not resolve link targets, and `link-check.yml` is configured `.md` against a tree that is 100% `.mdx`. The guard reads its ids out of fumadocs' own `remarkHeading` rather than re-implementing a slug rule. A hand-rolled slugger disagrees with the rendered page in both directions: false green on `### 🚦 Case Triage` (really `-case-triage`) and false red on `### 🚦 Case Triage [#case-triage]` (really `case-triage`), and a guard that fails on correct docs gets muted. Fixes the nine dangling anchors it found on main — three named on the card and six same-page anchors on zh pages that a cross-page-only probe never measured — and records in `link-check.yml` why that job keeps `.md`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WMzCeNC4SZcPNBpE2zCVCg
…ertion Parsing 201 pages costs ~3s idle and the file paid it twice — fine alone, flaky in `pnpm verify`, where sharing the machine with the rest of the suite pushed both calls past vitest's 5s default and failed the guard on a tree it had just passed. A guard that goes red on correct docs when the box is busy is the muted-guard failure mode by another road. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WMzCeNC4SZcPNBpE2zCVCg
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-steve
marked this pull request as ready for review
August 27, 2026 09:10
This was referenced Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #867
content/docshad nothing watching the anchor half of its intra-doc links. This addstest/docs-anchor-links.test.ts, which resolves every anchored link against the heading idsfumadocs itself emits, fixes the nine dangling anchors it found, and records in
.github/workflows/link-check.ymlwhy that job keepsfile-extension: '.md'.All figures below were measured on this branch;
pnpm verifywas run on the final commit8c9f29dand is green.The guard does not have a second opinion about slugs
The ids come from
remarkHeading(fumadocs-core/mdx-plugins), driven through the real MDXpipeline, and are read off the TOC it writes. A hand-rolled "lowercase and hyphenate" rule is
wrong in four measured ways against the page a reader loads, in both directions:
### 🚦 Case Triage-case-triagecase-triage— false GREEN### 🚦 Case Triage [#case-triage]case-triage-case-triage-case-triage— false RED## 流程(多步骤)流程多步骤## Standard dashboards & reportsstandard-dashboards--reportsremarkMdxis load-bearing rather than decoration: it makes an unparseable page a thrownerror, which is what catches the
{#id}spelling of an explicit id (invalid JS in an MDXexpression — the page dies at build time). A dedicated fixture asserts that spelling is
reported as a parse failure and names
acorn.The root
fumadocs-coreis pinned exactly, and a test asserts it equals the versionapps/docs/package.jsonrenders with —apps/docsis not a workspace member, so those are twoindependent pins and drift would make the guard measure a rule the site no longer uses.
Ablation — the guard shown failing, not only passing
Run at
8c9f29d, working tree clean before and after. Each leg mutates a real page, proves themutation reached disk, reads the guard, then restores and proves the restore.
Baseline (repo as this PR leaves it):
18 passed (18), exit 0.Leg 1 — drop the explicit
[#case-triage]id fromcontent/docs/ai-copilot/skills.mdx:This is the sharpest leg: the guard is green with the explicit id and red without it, so
it demonstrably reads the id rather than ignoring anchors.
Leg 2 — break a link's anchor by one letter in
content/docs/reference/performance-and-limits.mdx:#scheduled-exportscontains#scheduled-exportas a prefix. That check is void for thismutation; the evidence that carries it is the blob hash moving plus the guard's message naming
the mutated anchor.
Restore, both legs: worktree blob equals HEAD blob,
git diff HEAD -- pathempty,git status --porcelainempty. Restores aregit checkout HEAD -- absolute/path(never the bareform, which restores from a possibly-mutated index), the script carries a
trap ... EXIT INT TERM, and both legs were re-run on the final commit.Final (restored tree):
18 passed (18), exit 0.The two headings the acceptance criteria name are green, and pinned so the demonstration
cannot go vacuous:
### 🚦 Case Triage [#case-triage]resolves tocase-triage(and not to-case-triage),### Scheduled export to a warehouse (not shipped yet) [#scheduled-export]resolves to
scheduled-export, and both links that depend on them are asserted to be in theaudited set.
Nine dangling anchors, not three
The card's re-measure found 3. It counted the 21 cross-page links and did not measure the 15
same-page
#anchorlinks, 6 of which also dangle. All 9 are fixed here:service/knowledge-base.mdx:73/docs/service/#service-overview#standard-dashboards--reportsservice/knowledge-base.zh-Hans.mdx:73/zh-Hans/docs/service/#service-overviewservice/knowledge-base.zh-Hant.mdx:73marketing/campaigns.zh-Hans.mdx:59#campaign-enrollment-flowmarketing/campaigns.zh-Hant.mdx:59reference/security-and-compliance.zh-Hans.mdx:85#field-level-controlsreference/security-and-compliance.zh-Hans.mdx:235#at-restreference/security-and-compliance.zh-Hant.mdx:85#field-level-controlsreference/security-and-compliance.zh-Hant.mdx:235#at-restThe English
service/index.mdxtarget is confirmed to be a bold list item, not a missingheading: line 53,
- **Service Overview** (the dashboard's own title is **Customer Service**) — ... a **KB Deflection Rate** tile ..., under## Standard dashboards & reportsat line 51.Bold list items generate no anchor. The link's intent was sound, so it now points at the section
that really holds the deflection metric. The slug came out of the pipeline, not a keyboard:
standard-dashboards--reports.The three
/docs/service/paths also lost their trailing slash —/docs/serviceis the URLfumadocs generates and the form every other link in the tree uses.
Where I had to interpret rather than apply
The six same-page zh anchors are the same defect class as the cross-page ones (translated
heading, untranslated anchor), but the settled rule — zh pages drop the anchor and link the page
only — was written for links that have a page half. A same-page link has none, so "drop the
anchor" collapses to "drop the link". That is what this PR does: the words stay, the broken link
goes.
The alternative is one line per heading and is arguably better: give the zh heading the same
English explicit id its English counterpart uses, e.g.
## 营销活动加入流程 [#campaign-enrollment-flow].That keeps the link working, creates no localized anchor (so the "the surviving localized
anchor does not spread" rule is respected in spirit), and uses the route that was reopened when
#935 stopped being open. I did not take it because it keeps an anchor on a zh page, which
contradicts the letter of a rule marked not re-litigable. Happy to switch on a word — it is a
one-line change per heading.
The
sharing-and-security.zh-Hans.mdxlocalized anchor#字段级安全is untouched, and this PRtakes no position on whether a zh page should carry anchors. The guard answers "does this
resolve", never "does this belong".
link-check.ymlkeeps.md, and the reason is now in the fileMeasured before deciding, not assumed:
find ${FOLDERS} -name "*${FILE_EXTENSION}",and in modified-files mode compares
"${i##*.}" == "${FILE_EXTENSION#.}"— both exact. So.mdxwould trade the 101.mdfiles here for the 204.mdx, not add to them.(
content/docs/service/knowledge-base.mdx) it saw 2 of that page's links and calledboth dead with
Status: 400— it has no base URL, so every site-absolute/docs/…linkis a failure to it, and there are 965 of those under
content/docs.a Callout element are invisible to it — exactly where the three dangling anchors sat — and it
does not resolve fragments against the target document's headings at all.
Verification
pnpm verifyat8c9f29d:VERDICT command-exit 0,Test Files 143 passed (143),Tests 3022 passed | 1 skipped (3023).tsc --noEmit --listFilesconfirms both new files are actually typechecked (they are underinclude: ["test/**/*"], but "the typecheck is clean" says nothing about a file it never read).package-lock.jsonregenerated by the documented recipe;scripts/check-stackblitz-lock.mjsreports
package-lock.json is in sync with package.json (v3).Two things the run corrected in my own work, both worth naming:
off the containing page instead of off the link's own path, so a
/docs/…link on a.zh-Hans.mdxpage was compared against itself and always agreed. Zero pages write one today,so the repo would have stayed green over a rule that could never fire. The fixture that feeds
it the violation is what caught it.
fine in isolation and blew past vitest's 5s default under full-suite load — failing on a tree
it had just passed. Hoisted into one
beforeAllwith a timeout that describes the work. Onlythe re-run of the whole suite on the final commit exposed it.
Dependencies
Four dev-only additions —
fumadocs-core(pinned to the site's version),remark,remark-mdx,remark-frontmatter. The card's cost estimate said one (github-slugger); that estimate belongsto the design the adjudication overrides — driving the renderer's own pipeline needs the
pipeline.
.stackblitzrcinstalls with--omit=dev, so the demo container is unaffected.Generated by Claude Code