Skip to content

test(docs): resolve every content/docs anchor against fumadocs' own heading ids - #1358

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-867-docs-anchor-existence-guard
Aug 27, 2026
Merged

test(docs): resolve every content/docs anchor against fumadocs' own heading ids#1358
os-steve merged 2 commits into
mainfrom
claude/issue-867-docs-anchor-existence-guard

Conversation

@claude

@claude claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #867

content/docs had nothing watching the anchor half of its intra-doc links. This adds
test/docs-anchor-links.test.ts, which resolves every anchored link against the heading ids
fumadocs itself emits, fixes the nine dangling anchors it found, and records in
.github/workflows/link-check.yml why that job keeps file-extension: '.md'.

All figures below were measured on this branch; pnpm verify was run on the final commit
8c9f29d and 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 MDX
pipeline, 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:

heading source id fumadocs emits a hand-rolled slugger
### 🚦 Case Triage -case-triage case-triage — false GREEN
### 🚦 Case Triage [#case-triage] case-triage -case-triage-case-triage — false RED
## 流程(多步骤) 流程多步骤 full-width parens survive — false RED
## Standard dashboards & reports standard-dashboards--reports one hyphen — false RED

remarkMdx is load-bearing rather than decoration: it makes an unparseable page a thrown
error, which is what catches the {#id} spelling of an explicit id (invalid JS in an MDX
expression — the page dies at build time). A dedicated fixture asserts that spelling is
reported as a parse failure and names acorn.

The root fumadocs-core is pinned exactly, and a test asserts it equals the version
apps/docs/package.json renders with — apps/docs is not a workspace member, so those are two
independent 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 the
mutation 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 from content/docs/ai-copilot/skills.mdx:

HEAD blob    : 3df1cdaa8a1235d850426c963bdeca3bc45bf777
worktree blob: 76899d3df0cdc297612008933ff28d046b6777a5
grep -c OLD text: 0   grep -c NEW text: 1
--- guard exit: 1 ---
+  "content/docs/service/sla-and-escalation.mdx:125 /docs/ai-copilot/skills#case-triage
     — ai-copilot/skills.mdx has no heading with id \"case-triage\""
Tests  2 failed | 16 passed (18)

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:

HEAD blob    : 849cca8a36cc68bb2941bc3fae60dca52905553a
worktree blob: 7f45b34d08372a895a69f963b661f1c6e5de1d3a
grep -c NEW text: 1
--- guard exit: 1 ---
+  "content/docs/reference/performance-and-limits.mdx:145
     /docs/guides/import-and-export#scheduled-exports
     — guides/import-and-export.mdx has no heading with id \"scheduled-exports\""
Tests  2 failed | 16 passed (18)

⚠️ Stated rather than glossed: in leg 2 the "OLD text count is 0" check printed 1, because
#scheduled-exports contains #scheduled-export as a prefix. That check is void for this
mutation; 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 -- path empty, git status --porcelain empty. Restores are git checkout HEAD -- absolute/path (never the bare
form, 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 to case-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 the
audited 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 #anchor links, 6 of which also dangle. All 9 are fixed here:

page link fix
service/knowledge-base.mdx:73 /docs/service/#service-overview retargeted to #standard-dashboards--reports
service/knowledge-base.zh-Hans.mdx:73 /zh-Hans/docs/service/#service-overview anchor dropped, page kept
service/knowledge-base.zh-Hant.mdx:73 same anchor dropped, page kept
marketing/campaigns.zh-Hans.mdx:59 #campaign-enrollment-flow link removed, words kept
marketing/campaigns.zh-Hant.mdx:59 same link removed, words kept
reference/security-and-compliance.zh-Hans.mdx:85 #field-level-controls link removed, words kept
reference/security-and-compliance.zh-Hans.mdx:235 #at-rest link removed, words kept
reference/security-and-compliance.zh-Hant.mdx:85 #field-level-controls link removed, words kept
reference/security-and-compliance.zh-Hant.mdx:235 #at-rest link removed, words kept

The English service/index.mdx target is confirmed to be a bold list item, not a missing
heading: line 53, - **Service Overview** (the dashboard's own title is **Customer Service**) — ... a **KB Deflection Rate** tile ..., under ## Standard dashboards & reports at 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/service is the URL
fumadocs 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.mdx localized anchor #字段级安全 is untouched, and this PR
takes no position on whether a zh page should carry anchors. The guard answers "does this
resolve", never "does this belong".

link-check.yml keeps .md, and the reason is now in the file

Measured before deciding, not assumed:

  • the input takes one extension. The action runs find ${FOLDERS} -name "*${FILE_EXTENSION}",
    and in modified-files mode compares "${i##*.}" == "${FILE_EXTENSION#.}" — both exact. So
    .mdx would trade the 101 .md files here for the 204 .mdx, not add to them.
  • what it would let in is mostly noise. Run against a real page
    (content/docs/service/knowledge-base.mdx) it saw 2 of that page's links and called
    both dead with Status: 400 — it has no base URL, so every site-absolute /docs/… link
    is a failure to it, and there are 965 of those under content/docs.
  • it would not have caught what it was wanted for. It parses as markdown, so links nested inside
    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 verify at 8c9f29d: VERDICT command-exit 0, Test Files 143 passed (143),
    Tests 3022 passed | 1 skipped (3023).
  • tsc --noEmit --listFiles confirms both new files are actually typechecked (they are under
    include: ["test/**/*"], but "the typecheck is clean" says nothing about a file it never read).
  • package-lock.json regenerated by the documented recipe; scripts/check-stackblitz-lock.mjs
    reports package-lock.json is in sync with package.json (v3).
  • Control-byte scan clean over every new and edited file.

Two things the run corrected in my own work, both worth naming:

  1. The language-prefix rule was unfalsifiable in its first draft — it read the target locale
    off the containing page instead of off the link's own path, so a /docs/… link on a
    .zh-Hans.mdx page 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.
  2. The guard was flaky in its first draft: it audited 201 pages once per assertion, which was
    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 beforeAll with a timeout that describes the work. Only
    the 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 belongs
to the design the adjudication overrides — driving the renderer's own pipeline needs the
pipeline. .stackblitzrc installs with --omit=dev, so the demo container is unaffected.


Generated by Claude Code

claude added 2 commits August 27, 2026 08:54
…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
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 27, 2026 9:05am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline dependencies Dependency bumps and lockfile changes

Projects

None yet

2 participants