fix(ci): validate links across both content trees, and check anchors - #81
Conversation
`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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`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
|
Pushed It won't run yet, and neither would any other workflow: GitHub Actions is disabled for this repo. 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:
Worth being clear about what this job does and doesn't add: Verification. I ran the workflow's exact steps against a pristine
Exit 0 as-is; appending one bad link to Two scoping decisions in the workflow, both deliberate:
|
There was a problem hiding this comment.
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.tsto scan both content collections, validate JSXhrefattributes, and verify#fragmentanchors usinggithub-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.
| 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; | ||
| } |
| // 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"))); | ||
| } |
| 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.", |
| 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
|
All four Copilot findings were real. Fixed in 1. Include line-number shift — the serious one. My code comment claimed positions above the directive were unaffected — true, and useless, since all three 2. Silently skipped escaping links — genuinely silent. 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
So the fix is diagnostic accuracy, not a missed error. Replaced with an Same for the edge cases: Copilot said 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, |
`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
Follow-up to the gap found while restructuring the Supabase section in #39.
The gap
scripts/validate-links.tsscannedcontent/stackonly, matched only markdown[text](url)syntax, and explicitly skipped every absolute link that didn't start with/stack/:Since the v2 tree is served from the root, every v2 link took that branch. So the whole of
content/docswas unchecked, as was every<Card href=…>in both trees, and#fragmentswere 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/docsat the root,content/stackunder/stack), reads JSXhrefattributes alongside markdown links, and resolves each#fragmentagainst the target page's headings.Anchors needed the most care:
github-slugger— the same one Fumadocs uses, one instance per page so repeated headings get the-1/-2suffixes the renderer actually emits. It was already a direct dependency, so nothing new is added. This matters more than it sounds:"Sorting & range"slugs tosorting--range, notsorting-range, and a hand-rolled slugifier gets that wrong in both directions.## Heading [#custom-id]syntax is honoured.<include cwd>partials are inlined first, since their headings become anchors on the including page.#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 undersrc/app(/llms.txtand 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:
searchable-encryption#exact-match,#match-patternand#range--orderacross 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.content/stack/reference/drizzle.mdxpointed at/integrations/drizzle, which doesn't exist onv2— 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
/docs/prefix,/indexsuffix,.mdxextension, bad JSXhref) and confirmed all six are caught; confirmed a valid page link, a valid anchor, apublic/asset and a route handler all pass.[#id]anchors, which is decent evidence there's no false-positive rate at scale.bun run buildis green: 763 pages,✓ every internal link and anchor resolves.One usability note: TypeDoc output under
content/stack/reference/stack/latest/is gitignored and produced bygenerate-docs, so runningbun run validate-linksstandalone 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.prebuildgenerates before validating, so CI never hits it.