Skip to content

fix(ci): validate links across both content trees, and check anchors - #81

Merged
coderdan merged 3 commits into
v2from
docs/validate-links-v2-tree
Jul 29, 2026
Merged

fix(ci): validate links across both content trees, and check anchors#81
coderdan merged 3 commits into
v2from
docs/validate-links-v2-tree

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Follow-up to the gap found while restructuring the Supabase section in #39.

The gap

scripts/validate-links.ts scanned content/stack only, matched only markdown [text](url) syntax, and explicitly skipped every absolute link that didn't start with /stack/:

// Skip other absolute links (e.g., /api/, external paths)
if (urlWithoutAnchor.startsWith("/")) continue;

Since the v2 tree is served from the root, every v2 link took that branch. So the whole of content/docs was unchecked, as was every <Card href=…> in both trees, and #fragments were stripped and discarded rather than verified. A page could be renamed or merged away and the resulting 404s would sail through a green build — which is exactly the risk in #39, where two pages were folded into others.

What it does now

Walks both collections with their real base URLs (content/docs at the root, content/stack under /stack), reads JSX href attributes alongside markdown links, and resolves each #fragment against the target page's headings.

Anchors needed the most care:

  • Slugged with github-slugger — the same one Fumadocs uses, one instance per page so repeated headings get the -1/-2 suffixes the renderer actually emits. It was already a direct dependency, so nothing new is added. This matters more than it sounds: "Sorting & range" slugs to sorting--range, not sorting-range, and a hand-rolled slugifier gets that wrong in both directions.
  • Fumadocs' explicit ## Heading [#custom-id] syntax is honoured.
  • <include cwd> partials are inlined first, since their headings become anchors on the including page.
  • Fenced code blocks are blanked, with line count preserved so reported line numbers still point at the right source line — otherwise a # comment in a bash sample reads as a heading.

Non-page targets are now resolved rather than skipped: static files under public/, and static route handlers under src/app (/llms.txt and friends, enumerated from the filesystem so the list can't go stale). Keeping these honest is the point — the old blanket skip is what let the v2 tree through.

What it found

14 genuine breakages, all fixed in this PR:

  • 13 stale anchors. Links to searchable-encryption#exact-match, #match-pattern and #range--order across 6 files. Those headings were renamed at some point to "Exact matching", "Free-text search" and "Sorting and range queries", and every one of those links has been quietly landing at the top of the page instead of the right section.
  • 1 dead page link. content/stack/reference/drizzle.mdx pointed at /integrations/drizzle, which doesn't exist on v2 — it arrives with Docs V2: Supabase integration and supabase-js guide #39. I dropped the forward reference rather than repoint it at the legacy guide, since the sentence claimed "the current, EQL v3 surface" and the legacy page isn't that. The API list immediately below it already covers the surface.

Verification

  • Deliberately broke one of each rule (missing page, missing anchor, /docs/ prefix, /index suffix, .mdx extension, bad JSX href) and confirmed all six are caught; confirmed a valid page link, a valid anchor, a public/ asset and a route handler all pass.
  • 183 anchor-bearing internal links exist across the 275-page corpus, and all pass after the 13 fixes — including headings with backticks and explicit [#id] anchors, which is decent evidence there's no false-positive rate at scale.
  • Full bun run build is green: 763 pages, ✓ every internal link and anchor resolves.

One usability note: TypeDoc output under content/stack/reference/stack/latest/ is gitignored and produced by generate-docs, so running bun run validate-links standalone on a fresh checkout makes ~18 links into it look broken. The script now detects that and says so, rather than emitting a wall of spurious errors. prebuild generates before validating, so CI never hits it.

`validate-links` only ever looked at `content/stack`, and only at markdown
link syntax. That left the entire v2 tree unchecked, plus every
`<Card href=…>` in both trees — so a v2 page could be renamed or merged
away and every link to it would 404 against a green build. It also never
checked `#fragments` at all, in either tree.

It now walks both collections with their real base URLs (content/docs at
the root, content/stack under /stack), reads JSX href attributes as well
as markdown links, and resolves each `#fragment` against the target
page's headings.

Getting anchors right needed care:

- Headings are slugged with `github-slugger`, the same one Fumadocs uses,
  one instance per page so repeated headings get the same -1/-2 suffixes
  the renderer emits. Hand-rolled slugification is wrong in ways that
  matter here — "Sorting & range" is `sorting--range`, not
  `sorting-range`.
- Fumadocs' explicit `## Heading [#custom-id]` syntax is honoured.
- `<include cwd>` partials are inlined first, since their headings become
  anchors on the including page.
- Fenced code blocks are blanked (line numbers preserved), so a `#` in a
  bash sample isn't mistaken for a heading.

Non-page targets are resolved rather than skipped: static files under
public/ and static route handlers under src/app. Previously anything not
starting with /stack/ was waved through, which is precisely how the v2
tree escaped checking.

This surfaced 14 genuine breakages, all fixed here:

- 13 links to `searchable-encryption#exact-match`, `#match-pattern` and
  `#range--order` across 6 files. Those headings were renamed at some
  point to "Exact matching", "Free-text search" and "Sorting and range
  queries"; the links have been landing at the top of the page ever
  since, silently.
- `reference/drizzle.mdx` pointed at `/integrations/drizzle`, which does
  not exist on v2 yet — it arrives with #39. Dropped the forward
  reference; the API list under it already covers the surface.

TypeDoc output is gitignored and produced by `generate-docs`, so run
standalone on a fresh checkout every link into it looks broken. The
script now says so instead of emitting a wall of spurious errors —
`prebuild` generates before validating, which is why CI never sees it.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
public-docs Ready Ready Preview, Comment Jul 29, 2026 1:53am

Request Review

coderdan added a commit that referenced this pull request Jul 29, 2026
`content/stack/reference/drizzle.mdx` points at `/integrations/drizzle`,
which is a page this branch creates. #81 (the link-validator fix, off v2)
removes that link, because on v2 alone it is a genuine 404 — the target
does not exist there yet.

That set up a silent failure. This branch did not touch the line, so
rebasing onto a v2 containing #81 would have taken #81's removal without
a conflict, dropping the cross-link even though the page it points at had
just landed.

Rewording the sentence here makes the branch an explicit editor of that
line, so the two changes conflict and the merge has to make a decision
instead of quietly picking one. The rewrite is also more accurate on its
own terms: this page documents the older `@cipherstash/stack/drizzle`
entry point, which the previous phrasing left implicit.

The redirect itself needed no change — `/stack/reference/drizzle` →
`/integrations/drizzle` has been in v2-redirects.mjs all along. What this
branch supplies is the destination, so the redirect resolves here where
it did not on v2.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
Adds a `Docs / Internal links` job so a broken link is a named, blocking
status check rather than a failure buried in a Vercel deploy log.

Worth being precise about what this does and does not add: `validate-links`
already runs inside `prebuild`, and bun runs `prebuild` before `build`, so
broken links have always failed the Vercel build. What was missing is a
check that branch protection can require — which is the point of this job,
not new coverage.

It runs `generate-docs` first. The TypeDoc reference under
content/stack/reference/stack/latest/ is gitignored and built from a clone
of cipherstash/stack; roughly 18 links point into it, so skipping that step
would report them all as missing. All three repos involved are public, so
no secrets are needed. On a cold clone the whole job is install 4s +
generate 16s + validate 1s.

Scoped to `v2` only. `main` trails it by ~100 commits and its bun.lock is
out of sync with its package.json, so `bun install --frozen-lockfile`
fails there for reasons unrelated to links — verified, not assumed. Add
`main` to the trigger once #37 lands and makes it current.

Verified end to end against a pristine clone: the steps as written pass
(exit 0), and a deliberately broken link fails the job (exit 1).

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed 543f2a8 — adds .github/workflows/docs.yml with a Docs / Internal links job.

It won't run yet, and neither would any other workflow: GitHub Actions is disabled for this repo.

$ gh api repos/cipherstash/docs/actions/permissions
{"enabled":false,"sha_pinning_required":false}

That's why this PR shows only the two Vercel checks and no Actions run. The Dependabot and Copilot entries under Actions are GitHub-managed "dynamic" workflows, which bypass the setting — so the tab looking active is misleading.

Two admin changes are needed to get to "blocked from merging", both outside what I should flip on my own:

  1. Enable Actionsgh api -X PUT repos/cipherstash/docs/actions/permissions -F enabled=true -f allowed_actions=all
  2. Require the check on v2v2 currently has no branch protection at all (404 Branch not protected), and the only ruleset, protected-main, is disabled and targets main. So today nothing blocks a merge, including a red Vercel deploy.

Worth being clear about what this job does and doesn't add: validate-links already runs in prebuild, and bun runs prebuild ahead of build, so a broken link has always failed the Vercel build. What's missing is a named, requireable check — that's what this adds, not new coverage.

Verification. I ran the workflow's exact steps against a pristine --depth 1 clone of this branch rather than my working tree:

Step Cold timing
bun install --frozen-lockfile 4s
bun run generate-docs 16s
bun run validate-links 1s

Exit 0 as-is; appending one bad link to content/docs/index.mdx gives exit 1 with the file, line and reason. So the job will do its job the moment Actions is on.

Two scoping decisions in the workflow, both deliberate:

  • generate-docs runs first. The TypeDoc reference under content/stack/reference/stack/latest/ is gitignored and built from a clone of cipherstash/stack; ~18 links point into it, so without that step they'd all report missing. All three repos involved are public, so no secrets.
  • Triggers are v2 only. I checked main rather than assuming: its bun.lock is out of sync with its package.json, so bun install --frozen-lockfile fails there outright — a required check on main would go red for reasons unrelated to links. There's a comment in the file saying to add main once Docs V2: new information architecture (CIP-3307) #37 lands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens documentation integrity checks by expanding the internal link validator to cover both MDX content trees (v2 content/docs and legacy content/stack), including JSX href="..." links and #fragment validation against generated heading IDs. It also fixes a set of stale anchors (and one dead link) uncovered by the improved validation, and adds a dedicated GitHub Actions workflow to make link validation a required/visible PR status check.

Changes:

  • Extend scripts/validate-links.ts to scan both content collections, validate JSX href attributes, and verify #fragment anchors using github-slugger.
  • Fix stale anchor links across v2 + legacy docs, and remove a forward reference to a non-existent Drizzle integration page.
  • Add a “Docs / Internal links” GitHub Actions workflow to run link validation (including generating gitignored SDK reference docs first).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
scripts/validate-links.ts Expands validation scope (both trees), adds anchor checking + route/public asset resolution.
content/stack/reference/glossary.mdx Updates a stale #fragment anchor to match current heading slug.
content/stack/reference/eql-guide.mdx Updates stale anchors for exact/match/range sections.
content/stack/reference/drizzle.mdx Removes a dead link to a non-existent /integrations/drizzle page.
content/stack/reference/cipher-cell.mdx Updates stale anchors in index type references.
content/docs/reference/eql/v2/payload.mdx Updates stale anchors pointing into legacy searchable encryption doc.
content/docs/reference/eql/v2/index.mdx Updates stale anchors for exact/match/range links.
.github/workflows/docs.yml Adds a dedicated CI job to generate SDK reference docs and validate internal links/anchors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/validate-links.ts Outdated
Comment on lines 156 to 165
function resolveRelative(file: string, link: string): string | null {
for (const { dir, baseUrl } of COLLECTIONS) {
const base = path.join(ROOT, dir);
if (!file.startsWith(base + path.sep)) continue;
const resolved = path.resolve(path.dirname(file), link);
if (!resolved.startsWith(base)) return null;
return `${baseUrl}/${path.relative(base, resolved)}`.replace(/\/+$/, "");
}
return null;
}
Comment thread scripts/validate-links.ts Outdated
Comment on lines +110 to +114
// Keep the directive's own line so numbering above it is unchanged;
// appended partial lines report against the end of the file, which is
// close enough to find them.
out.push(line, ...blankCodeFences(fs.readFileSync(partial, "utf8")));
}
Comment thread scripts/validate-links.ts Outdated
Comment on lines +245 to +249
if (target.startsWith("/docs/")) {
report(
i + 1,
url,
reason:
"Link ends with /index which will 404. Remove /index — Fumadocs serves index.mdx at the directory URL.",
});
} else if (hasMdxExtension(urlWithoutAnchor)) {
broken.push({
file: path.relative(process.cwd(), filePath),
line: i + 1,
"Uses a /docs/ prefix — Next's basePath prepends it automatically, so this resolves to /docs/docs/… and 404s.",
Comment thread scripts/validate-links.ts Outdated
Comment on lines +240 to +244
const target = targetRaw.startsWith("/")
? targetRaw
: resolveRelative(file, targetRaw);
if (target === null) continue;

All four of Copilot's findings were real. Each was reproduced against the
corpus before fixing, and re-run after.

1. Includes renumbered the rest of the file. `inlineIncludes()` spliced a
   partial's lines into the including page, shifting every position below
   the directive by the partial's length. A broken link on line 110 of
   reference/eql/text.mdx was reported as line 193 — 83 lines out. The
   comment claiming positions above the directive were unaffected was
   right but useless; the damage was all below it. Lines now carry their
   own origin (`SourceLine { text, file, line }`), so a link inside a
   partial is reported against the partial's own path and line rather
   than an offset position in whichever page pulled it in.

2. Relative links escaping a collection were skipped silently.
   `resolveRelative()` returned null and the caller `continue`d, so
   `[x](../../../etc/passwd)` produced no output at all. Escapes are now
   reported, as are relative links inside shared partials — those have no
   single base to resolve against, so they need an absolute path.

3. `resolved.startsWith(base)` matched sibling directories with a shared
   prefix: content/docs-other satisfied the content/docs guard. Replaced
   with an `isInside()` helper requiring an exact match or a path
   separator. Impact was diagnostic rather than missed errors — the link
   still failed, but as "No such page" instead of "resolves outside
   content/docs".

4. Trailing slashes defeated the shape checks. `/a/index/` and `/a.mdx/`
   fell through to the generic "no such page" instead of their precise
   diagnosis, and a bare `/docs` missed the basePath check entirely.
   Normalising the trailing slash before the checks fixes all three.

Also de-duplicates reports, since a partial is scanned once per including
page. Defensive today — each partial has exactly one includer.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@coderdan

Copy link
Copy Markdown
Contributor Author

All four Copilot findings were real. Fixed in 33bec22. I reproduced each against the actual corpus before changing anything, and re-ran after — worth doing, because the severities turned out quite different from how they read.

1. Include line-number shift — the serious one. inlineIncludes() spliced the partial's lines into the including page, shifting every position below the directive. Measured:

include at line 105; broken link inserted at TRUE line 110
  content/docs/reference/eql/text.mdx:193      <- 83 lines out

My code comment claimed positions above the directive were unaffected — true, and useless, since all three <include>s sit near the end of their pages with 17–58 lines of content after them. Lines now carry their own origin (SourceLine { text, file, line }), so a link inside a partial is reported against the partial's own path and line, which is where you'd actually go to fix it:

  content/partials/eql/functions-text.mdx:84

2. Silently skipped escaping links — genuinely silent. [x](../../../etc/passwd) produced no output at all. Now:

  content/docs/index.mdx:42
    Relative link resolves outside content/docs.

Relative links inside shared partials are reported too, with a different message — a partial included by several pages has no single base to resolve against, so an absolute path is the only correct answer there.

3 and 4 were milder than described, and I'd rather say so than overstate the fix.

The startsWith(base) prefix bug is real — content/docs-other satisfied the content/docs guard — but it never let a bad link through. Old vs new on the same input:

Reported as
before No such page, static asset, or route.
after Relative link resolves outside content/docs.

So the fix is diagnostic accuracy, not a missed error. Replaced with an isInside() helper requiring an exact match or a path separator, which also removes an inconsistency in my own code — the file check two lines up already did it correctly.

Same for the edge cases: Copilot said /docs, /foo/index/ and /foo.mdx/ "won't be caught", but all three were caught — just misattributed to "no such page" instead of their precise reason. Normalising the trailing slash before the shape checks fixes it, and a bare /docs now hits the basePath check:

  /docs                          -> Uses a /docs prefix — Next's basePath prepends it…
  /get-started/index/            -> Ends with /index, which 404s…
  /get-started/quickstart.mdx/   -> Has a .mdx extension, which 404s as a page link.

Also added report de-duplication, since a partial gets scanned once per including page. That's defensive rather than load-bearing — each partial has exactly one includer today.

Baseline is still clean (275 files, ✓ every internal link and anchor resolves), full build green at 763 pages, biome and types:check clean.

@coderdan
coderdan merged commit 30acb72 into v2 Jul 29, 2026
2 checks passed
coderdan added a commit that referenced this pull request Jul 30, 2026
`content/stack/reference/drizzle.mdx` points at `/integrations/drizzle`,
which is a page this branch creates. #81 (the link-validator fix, off v2)
removes that link, because on v2 alone it is a genuine 404 — the target
does not exist there yet.

That set up a silent failure. This branch did not touch the line, so
rebasing onto a v2 containing #81 would have taken #81's removal without
a conflict, dropping the cross-link even though the page it points at had
just landed.

Rewording the sentence here makes the branch an explicit editor of that
line, so the two changes conflict and the merge has to make a decision
instead of quietly picking one. The rewrite is also more accurate on its
own terms: this page documents the older `@cipherstash/stack/drizzle`
entry point, which the previous phrasing left implicit.

The redirect itself needed no change — `/stack/reference/drizzle` →
`/integrations/drizzle` has been in v2-redirects.mjs all along. What this
branch supplies is the destination, so the redirect resolves here where
it did not on v2.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
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.

2 participants