From 3770373e4f86248e891a0776a43ced62fd266163 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:13:44 +0000 Subject: [PATCH] fix(docs): resolve relative links in check-doc-links, and fix the 16 targets it now catches (#3479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `routeExists()` opened with `if (!href.startsWith('/docs')) return true`, so every relative link was waved through unresolved. 33 references to 16 non-existent targets had accumulated behind it. Both halves land together, so the extended gate is green on arrival: - checker: relative hrefs are resolved against the linking file's directory, in the file form fumadocs' `source.resolveHref` keys on (extension included) plus the extensionless-route spellings. Absolute `/docs/...` stays route-only — `/docs/x.md` is a 404 on the site even when `x.md` exists on disk. Fenced and inline code is blanked first (two false positives on main otherwise). - content: 13 A-class links spelled `.md` where the file is `.mdx`; 3 B-class targets that do not exist (retargeted or removed, per-site reasons in the PR). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- content/docs/guide/index.md | 10 - content/docs/guide/plugins.md | 53 ++-- content/docs/guide/record-edit-modes.md | 2 +- content/docs/plugins/plugin-charts.mdx | 2 +- content/docs/plugins/plugin-editor.mdx | 2 +- content/docs/plugins/plugin-kanban.mdx | 2 +- content/docs/plugins/plugin-markdown.mdx | 2 +- scripts/__tests__/check-doc-links.test.ts | 283 ++++++++++++++++++++++ scripts/check-doc-links.mjs | 239 +++++++++++++++--- 9 files changed, 513 insertions(+), 82 deletions(-) create mode 100644 scripts/__tests__/check-doc-links.test.ts diff --git a/content/docs/guide/index.md b/content/docs/guide/index.md index c1d905c223..c609e9fb2f 100644 --- a/content/docs/guide/index.md +++ b/content/docs/guide/index.md @@ -6,13 +6,3 @@ description: "Complete guide to using ObjectUI - from getting started to advance # ObjectUI Guide Welcome to the ObjectUI Guide! This comprehensive guide covers everything you need to know to build powerful server-driven UIs with ObjectUI. - -## Examples - -- [examples/server](examples/server) - Run a metadata app using @objectstack/cli. - -### Run the ObjectStack Server Example - -```bash -pnpm --filter @object-ui/example-objectstack-server serve -``` diff --git a/content/docs/guide/plugins.md b/content/docs/guide/plugins.md index 630576b745..67b293fcec 100644 --- a/content/docs/guide/plugins.md +++ b/content/docs/guide/plugins.md @@ -20,7 +20,7 @@ Object UI provides 14+ official plugins for common use cases: ### Data Visualization & Dashboards -#### [@object-ui/plugin-charts](../plugins/plugin-charts.md) +#### [@object-ui/plugin-charts](../plugins/plugin-charts.mdx) Data visualization components powered by Recharts. @@ -29,11 +29,11 @@ Data visualization components powered by Recharts. - Customizable colors - Lazy-loaded (~80 KB) -[Read full documentation →](../plugins/plugin-charts.md) +[Read full documentation →](../plugins/plugin-charts.mdx) --- -#### [@object-ui/plugin-dashboard](../plugins/plugin-dashboard.md) +#### [@object-ui/plugin-dashboard](../plugins/plugin-dashboard.mdx) Dashboard layouts with metric cards and widgets. @@ -42,11 +42,11 @@ Dashboard layouts with metric cards and widgets. - Widget system - Lazy-loaded (~22 KB) -[Read full documentation →](../plugins/plugin-dashboard.md) +[Read full documentation →](../plugins/plugin-dashboard.mdx) --- -#### [@object-ui/plugin-timeline](../plugins/plugin-timeline.md) +#### [@object-ui/plugin-timeline](../plugins/plugin-timeline.mdx) Timeline component with multiple layout variants. @@ -55,11 +55,11 @@ Timeline component with multiple layout variants. - Date formatting - Lazy-loaded (~20 KB) -[Read full documentation →](../plugins/plugin-timeline.md) +[Read full documentation →](../plugins/plugin-timeline.mdx) --- -#### [@object-ui/plugin-gantt](../plugins/plugin-gantt.md) +#### [@object-ui/plugin-gantt](../plugins/plugin-gantt.mdx) Gantt chart for project visualization. @@ -68,11 +68,11 @@ Gantt chart for project visualization. - ObjectQL integration - Lazy-loaded (~40 KB) -[Read full documentation →](../plugins/plugin-gantt.md) +[Read full documentation →](../plugins/plugin-gantt.mdx) --- -#### [@object-ui/plugin-calendar](../plugins/plugin-calendar.md) +#### [@object-ui/plugin-calendar](../plugins/plugin-calendar.mdx) Calendar visualization for events. @@ -81,11 +81,11 @@ Calendar visualization for events. - ObjectQL integration - Lazy-loaded (~25 KB) -[Read full documentation →](../plugins/plugin-calendar.md) +[Read full documentation →](../plugins/plugin-calendar.mdx) --- -#### [@object-ui/plugin-map](../plugins/plugin-map.md) +#### [@object-ui/plugin-map](../plugins/plugin-map.mdx) Map visualization with markers. @@ -94,13 +94,13 @@ Map visualization with markers. - ObjectQL integration - Lazy-loaded (~60 KB) -[Read full documentation →](../plugins/plugin-map.md) +[Read full documentation →](../plugins/plugin-map.mdx) --- ### Data Management -#### [@object-ui/plugin-grid](../plugins/plugin-grid.md) +#### [@object-ui/plugin-grid](../plugins/plugin-grid.mdx) Advanced data grid with sorting, filtering, and pagination. @@ -109,11 +109,11 @@ Advanced data grid with sorting, filtering, and pagination. - Row selection - Lazy-loaded (~45 KB) -[Read full documentation →](../plugins/plugin-grid.md) +[Read full documentation →](../plugins/plugin-grid.mdx) --- -#### [@object-ui/plugin-form](../plugins/plugin-form.md) +#### [@object-ui/plugin-form](../plugins/plugin-form.mdx) Advanced form builder with validation. @@ -122,11 +122,11 @@ Advanced form builder with validation. - Custom field types - Lazy-loaded (~28 KB) -[Read full documentation →](../plugins/plugin-form.md) +[Read full documentation →](../plugins/plugin-form.mdx) --- -#### [@object-ui/plugin-view](../plugins/plugin-view.md) +#### [@object-ui/plugin-view](../plugins/plugin-view.mdx) ObjectQL-integrated views for automatic CRUD. @@ -135,13 +135,13 @@ ObjectQL-integrated views for automatic CRUD. - Field mapping - Lazy-loaded (~35 KB) -[Read full documentation →](../plugins/plugin-view.md) +[Read full documentation →](../plugins/plugin-view.mdx) --- ### Content & Editing -#### [@object-ui/plugin-editor](../plugins/plugin-editor.md) +#### [@object-ui/plugin-editor](../plugins/plugin-editor.mdx) Code editor component powered by Monaco Editor. @@ -150,11 +150,11 @@ Code editor component powered by Monaco Editor. - Multiple themes - Lazy-loaded (~120 KB) -[Read full documentation →](../plugins/plugin-editor.md) +[Read full documentation →](../plugins/plugin-editor.mdx) --- -#### [@object-ui/plugin-markdown](../plugins/plugin-markdown.md) +#### [@object-ui/plugin-markdown](../plugins/plugin-markdown.mdx) Markdown renderer with GitHub Flavored Markdown support. @@ -163,11 +163,11 @@ Markdown renderer with GitHub Flavored Markdown support. - Code syntax highlighting - Lazy-loaded (~30 KB) -[Read full documentation →](../plugins/plugin-markdown.md) +[Read full documentation →](../plugins/plugin-markdown.mdx) --- -#### [@object-ui/plugin-chatbot](../plugins/plugin-chatbot.md) +#### [@object-ui/plugin-chatbot](../plugins/plugin-chatbot.mdx) Chat interface component. @@ -178,13 +178,13 @@ Chat interface component. - Inline responding, stop, and retry states - Lazy-loaded (~35 KB) -[Read full documentation →](../plugins/plugin-chatbot.md) +[Read full documentation →](../plugins/plugin-chatbot.mdx) --- ### Workflows & Tasks -#### [@object-ui/plugin-kanban](../plugins/plugin-kanban.md) +#### [@object-ui/plugin-kanban](../plugins/plugin-kanban.mdx) Kanban board component with drag-and-drop powered by @dnd-kit. @@ -193,7 +193,7 @@ Kanban board component with drag-and-drop powered by @dnd-kit. - Card badges for status/priority - Lazy-loaded (~100 KB) -[Read full documentation →](../plugins/plugin-kanban.md) +[Read full documentation →](../plugins/plugin-kanban.mdx) --- @@ -512,7 +512,6 @@ export function registerComponents() { - [Component Registry](./component-registry.md) - Understanding the registry - [Schema Rendering](./schema-rendering.md) - How schemas become UI -- [Lazy-Loaded Plugins Architecture](./lazy-loading.md) - Deep dive - [Creating Components](/spec/component-package.md) - Component development - **[Create Plugin Utility](/docs/utilities/create-plugin)** - Scaffold new plugins quickly - **[CLI Tool](/docs/utilities/cli)** - Test plugins with the CLI diff --git a/content/docs/guide/record-edit-modes.md b/content/docs/guide/record-edit-modes.md index 8efc3eaad4..e379d2a830 100644 --- a/content/docs/guide/record-edit-modes.md +++ b/content/docs/guide/record-edit-modes.md @@ -128,5 +128,5 @@ migrate a single object to page mode: ## See also - [`@object-ui/app-shell` README](https://www.objectui.org/docs/layout/app-shell) -- [`ObjectForm` API](../plugins/plugin-form.md) +- [`ObjectForm` API](../plugins/plugin-form.mdx) - [Schema rendering](./schema-rendering.md) diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index fc47f5daba..b490123e50 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -278,7 +278,7 @@ const chartSchema: BarChartSchema = { ## Related Documentation - [Plugin System Overview](/docs/guide/plugins) -- [Lazy-Loaded Plugins Architecture](../concepts/lazy-loading) +- [Lazy-Loaded Plugins Architecture](/docs/guide/plugins#lazy-loading-architecture) - [Package README](https://github.com/objectstack-ai/objectui/tree/main/packages/plugin-charts) diff --git a/content/docs/plugins/plugin-editor.mdx b/content/docs/plugins/plugin-editor.mdx index 30ecaf1255..93da2db490 100644 --- a/content/docs/plugins/plugin-editor.mdx +++ b/content/docs/plugins/plugin-editor.mdx @@ -151,7 +151,7 @@ const editorSchema: CodeEditorSchema = { ## Related Documentation - [Plugin System Overview](/docs/guide/plugins) -- [Lazy-Loaded Plugins Architecture](../concepts/lazy-loading) +- [Lazy-Loaded Plugins Architecture](/docs/guide/plugins#lazy-loading-architecture) - [Package README](https://github.com/objectstack-ai/objectui/tree/main/packages/plugin-editor) diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 6491bbaeda..5ab6643737 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -362,7 +362,7 @@ const kanbanSchema: KanbanSchema = { ## Related Documentation - [Plugin System Overview](/docs/guide/plugins) -- [Lazy-Loaded Plugins Architecture](../concepts/lazy-loading) +- [Lazy-Loaded Plugins Architecture](/docs/guide/plugins#lazy-loading-architecture) - [Package README](https://github.com/objectstack-ai/objectui/tree/main/packages/plugin-kanban) diff --git a/content/docs/plugins/plugin-markdown.mdx b/content/docs/plugins/plugin-markdown.mdx index 5fd163f1ce..d6a0456e4b 100644 --- a/content/docs/plugins/plugin-markdown.mdx +++ b/content/docs/plugins/plugin-markdown.mdx @@ -359,7 +359,7 @@ const markdownSchema: MarkdownSchema = { ## Related Documentation - [Plugin System Overview](/docs/guide/plugins) -- [Lazy-Loaded Plugins Architecture](../concepts/lazy-loading) +- [Lazy-Loaded Plugins Architecture](/docs/guide/plugins#lazy-loading-architecture) - [Package README](https://github.com/objectstack-ai/objectui/tree/main/packages/plugin-markdown) diff --git a/scripts/__tests__/check-doc-links.test.ts b/scripts/__tests__/check-doc-links.test.ts new file mode 100644 index 0000000000..e3abf074e7 --- /dev/null +++ b/scripts/__tests__/check-doc-links.test.ts @@ -0,0 +1,283 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// @ts-expect-error — plain-JS CI helper, intentionally untyped +import { collectBrokenLinks, routeExists, stripCode } from '../check-doc-links.mjs'; + +/** + * objectui#3479 — the behaviour test for `scripts/check-doc-links.mjs`. + * + * The gate was wired into CI by #3450 (see `docs-links-workflow.test.ts`, which + * pins the wiring). What nothing pinned was what it actually *checked*: its + * `routeExists()` opened with an early `return true` for any href not starting + * with `/docs`, so every relative link in the tree was waved through unresolved. + * 33 references to 16 non-existent targets had accumulated behind it. + * + * These tests pin the two halves that early return hid, in the two shapes the + * site actually resolves: + * + * - a RELATIVE href names the target FILE, extension included — that is what + * fumadocs' `source.resolveHref` looks up (its page index is keyed by source + * file path *with* extension), via `createRelativeLink` in + * `apps/site/app/docs/[[...slug]]/page.tsx`; + * - an ABSOLUTE `/docs/...` href names an extensionless ROUTE — `resolveHref` + * never touches it, so it reaches the browser verbatim. + * + * The A-class defect in #3479 is exactly the first shape violated: 13 links + * spelled `../plugins/plugin-*.md` while every one of those files is `.mdx`. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const tempRoots: string[] = []; + +/** Materialises `{ 'guide/a.md': '...' }` into a throwaway docs root. */ +function docsRootWith(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-links-')); + tempRoots.push(root); + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return root; +} + +/** The hrefs reported broken, in file order. */ +function brokenHrefs(files: Record): string[] { + return collectBrokenLinks(docsRootWith(files)).map((item: { href: string }) => item.href); +} + +afterAll(() => { + for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +const FENCE = '```'; + +describe('relative hrefs are resolved — the hole objectui#3479 closed', () => { + it('reports a relative link whose target does not exist', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[gone](./nowhere.md)', + }), + ).toEqual(['./nowhere.md']); + }); + + it('reports the #3479 A-class shape: link says .md, file is .mdx', () => { + // The single assertion that dies if the `startsWith('/docs') -> return true` + // early return ever comes back. 13 of the 16 broken targets were this. + expect( + brokenHrefs({ + 'guide/plugins.md': '[Charts](../plugins/plugin-charts.md)', + 'plugins/plugin-charts.mdx': '# Charts', + }), + ).toEqual(['../plugins/plugin-charts.md']); + }); + + it('accepts the same link once it names the real file', () => { + expect( + brokenHrefs({ + 'guide/plugins.md': '[Charts](../plugins/plugin-charts.mdx)', + 'plugins/plugin-charts.mdx': '# Charts', + }), + ).toEqual([]); + }); + + it('accepts a relative link to a real .md file', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[b](./b.md)', + 'guide/b.md': '# B', + }), + ).toEqual([]); + }); + + it('accepts the extensionless-route spelling of a relative link', () => { + // Not the recommended form — it misses fumadocs' resolver and only works by + // browser URL-relative resolution — but it is not a broken link, and this + // gate does not invent style rules. + expect( + brokenHrefs({ + 'guide/plugins.md': '[Charts](../plugins/plugin-charts)', + 'plugins/plugin-charts.mdx': '# Charts', + }), + ).toEqual([]); + }); + + it('accepts a relative link to a directory index', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[plugins](../plugins)', + 'plugins/index.md': '# Plugins', + }), + ).toEqual([]); + }); + + it('rejects a relative link to a directory that has no index page', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[plugins](../plugins)', + 'plugins/plugin-charts.mdx': '# Charts', + }), + ).toEqual(['../plugins']); + }); + + it('resolves relative to the linking file, not the docs root', () => { + // `./b.md` from `guide/a.md` is `guide/b.md` — a root-relative reading would + // look for `b.md` at the top level and call a valid link broken. + expect( + brokenHrefs({ + 'guide/a.md': '[b](./b.md)', + 'guide/b.md': '# B', + 'b.md': '# decoy at the root', + }), + ).toEqual([]); + + expect( + brokenHrefs({ + 'guide/a.md': '[b](./b.md)', + 'b.md': '# only at the root', + }), + ).toEqual(['./b.md']); + }); + + it('ignores the fragment and query when resolving', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[b](./b.md#a-section) and [c](./b.md?x=1)', + 'guide/b.md': '# B', + }), + ).toEqual([]); + }); +}); + +describe('absolute /docs hrefs stay strict — they are routes, not files', () => { + it('accepts an extensionless route', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[plugins](/docs/plugins/plugin-charts)', + 'plugins/plugin-charts.mdx': '# Charts', + }), + ).toEqual([]); + }); + + it('rejects a /docs route carrying a file extension, even when that file exists', () => { + // `next.config.mjs` rewrites only `/docs/:path*.mdx` (to the raw-markdown + // llms route); `/docs/guide/b.md` is a 404 on the site whatever is on disk. + // A relative-link check that resolved absolute hrefs on the filesystem too + // would quietly bless both. + expect( + brokenHrefs({ + 'guide/a.md': '[b](/docs/guide/b.md)', + 'guide/b.md': '# B', + }), + ).toEqual(['/docs/guide/b.md']); + }); + + it('accepts /docs itself when the root has an index page', () => { + expect(brokenHrefs({ 'index.md': '[home](/docs)' })).toEqual([]); + }); + + it('waves through site routes outside the docs collection', () => { + // `/api/core`, `/img/...`, `/spec/...` are not in this collection and cannot + // be resolved from `content/docs` alone. + expect( + brokenHrefs({ + 'guide/a.md': '[api](/api/core) ![shot](/img/guide/x.png)', + }), + ).toEqual([]); + }); +}); + +describe('external and non-path hrefs are left alone', () => { + it('skips schemes, in-page anchors and empty targets', () => { + expect( + brokenHrefs({ + 'guide/a.md': [ + '[web](https://example.com/nope)', + '[mail](mailto:a@b.c)', + '[tel](tel:+1000)', + '[anchor](#section)', + ].join('\n\n'), + }), + ).toEqual([]); + }); +}); + +describe('code is stripped before scanning — markdown syntax quoted in code is not a link', () => { + it('blanks a fenced block but keeps every newline, so line numbers stay true', () => { + const source = ['before', FENCE + 'tsx', '[x](./nowhere.md)', FENCE, 'after'].join('\n'); + const stripped = stripCode(source); + + expect(stripped.split('\n')).toHaveLength(source.split('\n').length); + expect(stripped).toHaveLength(source.length); + expect(stripped.split('\n')[0]).toBe('before'); + expect(stripped.split('\n')[2].trim()).toBe(''); + expect(stripped.split('\n')[4]).toBe('after'); + }); + + it('blanks an inline code span', () => { + expect(stripCode('- **Links**: `[text](url)`').trim()).toBe('- **Links**:'); + }); + + it('does not report the fenced JSX false positive main carries', () => { + // content/docs/guide/notifications.md:54 — inside a ```tsx fence, + // `toast[n.severity](n.title, { description: n.message })` matches the link + // regex with an "href" of `n.title, { description: n.message }`. + expect( + brokenHrefs({ + 'guide/notifications.md': [ + FENCE + 'tsx', + 'onToast={(n) => toast[n.severity](n.title, { description: n.message })}', + FENCE, + ].join('\n'), + }), + ).toEqual([]); + }); + + it('does not report the inline-code false positive main carries', () => { + // content/docs/fields/rich-text.mdx:47 documents the syntax itself. + expect( + brokenHrefs({ + 'fields/rich-text.mdx': '- **Links**: `[text](url)`\n- **Images**: `![alt](url)`', + }), + ).toEqual([]); + }); + + it('still reports a real link on a line that also contains code', () => { + expect( + brokenHrefs({ + 'guide/a.md': 'Use `[text](url)` to write [this](./nowhere.md).', + }), + ).toEqual(['./nowhere.md']); + }); + + it('closes a fence only on a matching, bare marker', () => { + const source = [FENCE, '[x](./nowhere.md)', FENCE + 'ts', '[y](./nowhere.md)', FENCE].join('\n'); + expect(stripCode(source).replace(/\s/g, '')).toBe(''); + }); +}); + +describe('the repo it guards', () => { + it('has no broken internal docs links', () => { + // The other half of objectui#3479: the extended check must land GREEN, not + // arrive with a backlog it merely describes. + const broken = collectBrokenLinks(path.join(repoRoot, 'content/docs')) as { + file: string; + href: string; + line: number; + }[]; + const report = broken.map((b) => `${path.relative(repoRoot, b.file)}:${b.line} -> ${b.href}`); + expect(report).toEqual([]); + }); + + it('exposes routeExists with the context the scan gives it', () => { + const root = docsRootWith({ 'guide/a.md': '# A', 'plugins/plugin-charts.mdx': '# Charts' }); + const fromFile = path.join(root, 'guide/a.md'); + + expect(routeExists('../plugins/plugin-charts.mdx', { fromFile, docsRoot: root })).toBe(true); + expect(routeExists('../plugins/plugin-charts.md', { fromFile, docsRoot: root })).toBe(false); + }); +}); diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 7c673a4938..ec82bab589 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -1,11 +1,95 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +#!/usr/bin/env node +/** + * Rejects internal documentation links under `content/docs/**` that point nowhere. + * + * Run: node scripts/check-doc-links.mjs (also `pnpm docs:check-links`) + * Exit: 0 = every internal link resolves, 1 = at least one does not + * + * ## Why this file changed (objectui#3479) + * + * The check used to look at exactly one kind of href: the absolute site route + * `/docs/...`. Its `routeExists()` opened with + * + * if (!cleanHref.startsWith('/docs')) return true; + * + * so every *relative* link (`./foo.md`, `../plugins/bar.mdx`) was waved through + * without being resolved at all. Nothing else gated them either — the site build + * does not fail on an unresolvable link, it just renders one — so they accrued + * silently: 33 references to 16 non-existent targets by the time anyone ran + * `lychee --offline` over the tree. + * + * The information needed to check them was already here. The scan walks every + * file, so it knows the path each href was written in; a relative href is just + * that directory plus the href. + * + * ## The two link forms, and why they are checked differently + * + * This is not a style preference — the site resolves the two forms through + * different machinery, and each is only correct in its own shape. + * + * **Relative hrefs name a FILE, extension included.** `apps/site/app/docs/ + * [[...slug]]/page.tsx` renders MDX anchors through fumadocs' + * `createRelativeLink(source, page)`, which calls `source.resolveHref(href, + * page)`. That function only acts on hrefs starting with `./` or `../`: it joins + * them onto `dirname(page.path)` and looks the result up in the page indexer — + * and the indexer is keyed by **source file path including the extension** + * (`createPageIndexer.scan()` stores `pathToPage.set(filePath, page)`). So + * `../plugins/plugin-charts.mdx` resolves to the real page URL + * `/docs/plugins/plugin-charts`, while `../plugins/plugin-charts.md` misses the + * index, falls through unresolved, and reaches the browser verbatim as + * `/docs/plugins/plugin-charts.md` — a 404, because `next.config.mjs` rewrites + * only the `.mdx` suffix (to the raw-markdown `llms.mdx` route). That mismatch + * — link says `.md`, file is `.mdx` — is what all 13 A-class links in #3479 were. + * + * An extensionless relative href (`../concepts/lazy-loading`) misses the indexer + * too, and is *accidentally* right whenever browser URL-relative resolution + * happens to land on the correct route. It is accepted below (it is not a broken + * link, and rejecting a working form would be this gate over-reaching), but it + * is the fragile spelling: it stops working the moment the target page moves, + * with no resolver to notice. + * + * **Absolute hrefs name a ROUTE, extensionless.** `resolveHref` ignores them + * entirely, so `/docs/guide/plugins` goes to the browser as written. All 297 + * absolute `/docs/...` links in the tree are extensionless, and that branch is + * therefore kept strict: `/docs/guide/plugins.md` is NOT accepted just because + * `guide/plugins.md` exists on disk, because that URL 404s on the site. + * + * Non-`/docs` absolute hrefs (`/spec/...`, `/api/...`, `/img/...`) are still + * waved through: they are routes outside this collection and cannot be resolved + * from `content/docs` alone. + * + * ## Code spans are stripped before scanning + * + * Required, not tidiness. Extending the scan to relative hrefs turns markdown's + * own link syntax, quoted inside code, into false positives — `main` carries two + * today: + * + * - `content/docs/guide/notifications.md:54` — inside a ```tsx fence, + * `toast[n.severity](n.title, { description: n.message })` matches the link + * regex with an "href" of `n.title, { description: n.message }`. + * - `content/docs/fields/rich-text.mdx:47` — the inline code span + * `` `[text](url)` `` documents the syntax itself. + * + * Neither renders as an anchor, so neither is this gate's business. Blanking + * fenced blocks and inline spans (padding with spaces, so reported line numbers + * stay true) is what makes the relative-link check safe to turn on. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const DOCS_ROOT = path.resolve('content/docs'); const DOCS_ROUTE_PREFIX = '/docs'; const MARKDOWN_LINK_RE = /\[[^\]]+\]\(([^)]+)\)/g; +/** A fence line: optional indent, 3+ backticks or tildes, optional info string. */ +const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/; +/** An inline code span on a single line: `like this`, ``or this``. */ +const INLINE_CODE_RE = /(`+)[^`\n]*\1/g; +/** Any URI scheme (`https:`, `mailto:`, `tel:`) — not ours to resolve. */ +const EXTERNAL_HREF_RE = /^(?:#|[a-zA-Z][a-zA-Z0-9+.-]*:)/; -function walk(dir, files = []) { +const blank = (text) => text.replace(/[^\n]/g, ' '); + +export function walk(dir, files = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { @@ -19,57 +103,132 @@ function walk(dir, files = []) { return files; } -function routeExists(href) { - const cleanHref = href.split('#')[0].split('?')[0].trim(); +/** + * Replaces every fenced block and inline code span with spaces, leaving the + * byte length and every newline untouched so offsets — and therefore reported + * line numbers — still line up with the original file. + */ +export function stripCode(source) { + const out = []; + let openFence = null; + + for (const line of source.split('\n')) { + const fence = FENCE_RE.exec(line); - if (!cleanHref || !cleanHref.startsWith(DOCS_ROUTE_PREFIX)) { - return true; + if (openFence) { + out.push(blank(line)); + const closes = + fence && fence[1][0] === openFence[0] && fence[1].length >= openFence.length && fence[2].trim() === ''; + if (closes) openFence = null; + continue; + } + + if (fence) { + openFence = fence[1]; + out.push(blank(line)); + continue; + } + + out.push(line.replace(INLINE_CODE_RE, (match) => blank(match))); } - const routePath = cleanHref.replace(/^\/docs\/?/, ''); - if (!routePath) { - return existsSync(path.join(DOCS_ROOT, 'index.md')) || existsSync(path.join(DOCS_ROOT, 'index.mdx')); + return out.join('\n'); +} + +function isFile(candidate) { + try { + return statSync(candidate).isFile(); + } catch { + return false; } +} - const candidates = [ - path.join(DOCS_ROOT, `${routePath}.md`), - path.join(DOCS_ROOT, `${routePath}.mdx`), - path.join(DOCS_ROOT, routePath, 'index.md'), - path.join(DOCS_ROOT, routePath, 'index.mdx'), - ]; +/** The extensionless-route spellings of `base`, as fumadocs would serve them. */ +function routeCandidates(base) { + return [`${base}.md`, `${base}.mdx`, path.join(base, 'index.md'), path.join(base, 'index.mdx')]; +} - return candidates.some((candidate) => existsSync(candidate)); +/** + * Resolves one href. + * + * @param {string} href the raw href as authored + * @param {{ fromFile: string, docsRoot: string }} context + */ +export function routeExists(href, { fromFile, docsRoot }) { + let cleanHref = href.split('#')[0].split('?')[0].trim(); + if (!cleanHref) return true; // pure in-page anchor or query + try { + cleanHref = decodeURI(cleanHref); + } catch { + /* keep the raw form — a malformed escape is checked as written */ + } + + if (cleanHref === DOCS_ROUTE_PREFIX || cleanHref.startsWith(`${DOCS_ROUTE_PREFIX}/`)) { + const routePath = cleanHref.slice(DOCS_ROUTE_PREFIX.length).replace(/^\//, ''); + // Route form only — see the header. A `/docs/...` href carrying a file + // extension is a 404 on the site even when that file exists on disk. + return routeCandidates(routePath ? path.join(docsRoot, routePath) : docsRoot).some(isFile); + } + + // Site routes outside this docs collection — not resolvable from here. + if (cleanHref.startsWith('/')) return true; + + const base = path.resolve(path.dirname(fromFile), cleanHref); + // File form first (what fumadocs' `resolveHref` keys on), then the + // extensionless-route spellings. + return [base, ...routeCandidates(base)].some(isFile); } -const broken = []; +/** @returns {{ file: string, href: string, line: number }[]} */ +export function collectBrokenLinks(docsRoot) { + const broken = []; -for (const file of walk(DOCS_ROOT)) { - const source = readFileSync(file, 'utf8'); - let match; + for (const file of walk(docsRoot)) { + const source = stripCode(readFileSync(file, 'utf8')); + MARKDOWN_LINK_RE.lastIndex = 0; + let match; - while ((match = MARKDOWN_LINK_RE.exec(source)) !== null) { - const href = match[1].trim(); - if ( - href.startsWith('http://') || - href.startsWith('https://') || - href.startsWith('mailto:') || - href.startsWith('#') - ) { - continue; - } + while ((match = MARKDOWN_LINK_RE.exec(source)) !== null) { + const href = match[1].trim(); + if (EXTERNAL_HREF_RE.test(href)) continue; + if (routeExists(href, { fromFile: file, docsRoot })) continue; - if (!routeExists(href)) { - broken.push({ file, href }); + broken.push({ + file, + href, + line: source.slice(0, match.index).split('\n').length, + }); } } + + return broken; } -if (broken.length > 0) { - console.error(`Found ${broken.length} broken docs link${broken.length === 1 ? '' : 's'}:`); - for (const item of broken) { - console.error(`- ${path.relative(process.cwd(), item.file)} -> ${item.href}`); +// Run only when invoked directly — the test suite imports the helpers above and +// must not trigger a repo scan (or a `process.exit`) on import. Same guard shape +// as scripts/check-control-bytes.mjs. +const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedDirectly) { + const docsRoot = path.resolve('content/docs'); + const broken = collectBrokenLinks(docsRoot); + + if (broken.length > 0) { + const targets = new Set(broken.map((item) => `${path.dirname(item.file)}|${item.href.split('#')[0]}`)); + console.error( + `Found ${broken.length} broken docs link${broken.length === 1 ? '' : 's'} (${targets.size} distinct target${targets.size === 1 ? '' : 's'}):`, + ); + for (const item of broken) { + console.error(`- ${path.relative(process.cwd(), item.file)}:${item.line} -> ${item.href}`); + } + console.error( + '\nRelative links must name the target FILE including its real extension' + + ' (`../plugins/plugin-charts.mdx`); absolute `/docs/...` links must be' + + ' extensionless routes (`/docs/plugins/plugin-charts`). See the header of' + + ' scripts/check-doc-links.mjs.', + ); + process.exit(1); } - process.exit(1); -} -console.log('Docs links are valid.'); + console.log('Docs links are valid.'); +}