-
Notifications
You must be signed in to change notification settings - Fork 61
ci: validate internal documentation anchors #784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import path from 'node:path' | ||
| import { checkInternalAnchors } from '../src/lib/internal-anchor-links.ts' | ||
|
|
||
| const result = checkInternalAnchors(path.resolve('src/pages')) | ||
|
|
||
| if (result.failures.length > 0) { | ||
| console.error('Broken internal anchor links:') | ||
| for (const failure of result.failures) { | ||
| console.error( | ||
| `- ${path.relative(process.cwd(), failure.file)}:${failure.line} ${failure.href} (missing #${failure.fragment} on ${failure.targetRoute})`, | ||
| ) | ||
| } | ||
| process.exit(1) | ||
| } | ||
|
|
||
| console.log( | ||
| `Internal anchor check passed: ${result.linksChecked} links across ${result.pagesChecked} pages.`, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import fs from 'node:fs' | ||
| import os from 'node:os' | ||
| import path from 'node:path' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
| import { checkInternalAnchors } from './internal-anchor-links' | ||
|
|
||
| const temporaryDirectories: string[] = [] | ||
|
|
||
| afterEach(() => { | ||
| for (const directory of temporaryDirectories.splice(0)) { | ||
| fs.rmSync(directory, { recursive: true, force: true }) | ||
| } | ||
| }) | ||
|
|
||
| function pages(files: Record<string, string>): string { | ||
| const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'internal-anchor-links-')) | ||
| temporaryDirectories.push(directory) | ||
| for (const [file, content] of Object.entries(files)) { | ||
| const target = path.join(directory, file) | ||
| fs.mkdirSync(path.dirname(target), { recursive: true }) | ||
| fs.writeFileSync(target, content) | ||
| } | ||
| return directory | ||
| } | ||
|
|
||
| describe('internal anchor links', () => { | ||
| it('accepts generated, duplicate, and JSX anchors', () => { | ||
| const directory = pages({ | ||
| 'docs/index.mdx': ` | ||
| [Generated](/docs/target#hello-world) | ||
| [Duplicate](/docs/target#repeat-1) | ||
| <Card to="/docs/target#custom-id" /> | ||
| `, | ||
| 'docs/target.mdx': ` | ||
| ## Hello, world! | ||
| ## Repeat | ||
| ## Repeat | ||
| <span id="custom-id" /> | ||
| `, | ||
| }) | ||
|
|
||
| expect(checkInternalAnchors(directory)).toMatchObject({ linksChecked: 3, failures: [] }) | ||
| }) | ||
|
|
||
| it('reports stale Markdown and JSX fragments with source locations', () => { | ||
| const directory = pages({ | ||
| 'docs/index.mdx': '[Old](/docs/target#old-heading)\n<Card to="/docs/target#also-old" />\n', | ||
| 'docs/target.mdx': '## New heading\n', | ||
| }) | ||
|
|
||
| expect(checkInternalAnchors(directory).failures).toEqual([ | ||
| expect.objectContaining({ line: 1, href: '/docs/target#old-heading' }), | ||
| expect.objectContaining({ line: 2, href: '/docs/target#also-old' }), | ||
| ]) | ||
| }) | ||
|
|
||
| it('expands imported MDX snippets at their render position', () => { | ||
| const directory = pages({ | ||
| 'docs/index.mdx': '[Imported heading](/docs/target#imported-heading)\n', | ||
| 'docs/target.mdx': ` | ||
| import Shared from '../snippets/shared.mdx' | ||
|
|
||
| # Target | ||
|
|
||
| <Shared /> | ||
| `, | ||
| 'snippets/shared.mdx': '### Imported heading\n', | ||
| }) | ||
|
|
||
| expect(checkInternalAnchors(directory).failures).toEqual([]) | ||
| }) | ||
|
|
||
| it('resolves same-page, relative, and developers-mounted links', () => { | ||
| const directory = pages({ | ||
| 'docs/guide/index.mdx': ` | ||
| # Guide | ||
| [Same](#guide) | ||
| [Relative](./target#target) | ||
| [Mounted](/developers/docs/guide/target#target) | ||
| `, | ||
| 'docs/guide/target.mdx': '# Target\n', | ||
| }) | ||
|
|
||
| expect(checkInternalAnchors(directory)).toMatchObject({ linksChecked: 3, failures: [] }) | ||
| }) | ||
|
|
||
| it('leaves missing pages to the Vocs dead-link check', () => { | ||
| const directory = pages({ | ||
| 'docs/index.mdx': '[Missing](/docs/not-a-page#heading)\n', | ||
| }) | ||
|
|
||
| expect(checkInternalAnchors(directory)).toMatchObject({ linksChecked: 0, failures: [] }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| import fs from 'node:fs' | ||
| import path from 'node:path' | ||
| import GithubSlugger from 'github-slugger' | ||
| import remarkMdx from 'remark-mdx' | ||
| import remarkParse from 'remark-parse' | ||
| import { unified } from 'unified' | ||
|
|
||
| type AstNode = { | ||
| type: string | ||
| name?: string | null | ||
| value?: unknown | ||
| url?: string | ||
| alt?: string | null | ||
| attributes?: Array<{ type: string; name: string; value?: unknown }> | ||
| children?: AstNode[] | ||
| position?: { start: { line: number } } | ||
| } | ||
|
|
||
| type SourceEvent = | ||
| | { type: 'heading'; file: string; line: number; text: string } | ||
| | { type: 'anchor'; file: string; line: number; id: string } | ||
| | { type: 'link'; file: string; line: number; href: string } | ||
|
|
||
| type Page = { | ||
| file: string | ||
| route: string | ||
| routeDirectory: string | ||
| } | ||
|
|
||
| export type BrokenInternalAnchor = { | ||
| file: string | ||
| line: number | ||
| href: string | ||
| targetRoute: string | ||
| fragment: string | ||
| } | ||
|
|
||
| export type InternalAnchorCheck = { | ||
| pagesChecked: number | ||
| linksChecked: number | ||
| failures: BrokenInternalAnchor[] | ||
| } | ||
|
|
||
| const markdownExtensions = new Set(['.md', '.mdx']) | ||
|
|
||
| function markdownFiles(directory: string): string[] { | ||
| return fs | ||
| .readdirSync(directory, { withFileTypes: true }) | ||
| .flatMap((entry) => { | ||
| const file = path.join(directory, entry.name) | ||
| if (entry.isDirectory()) return markdownFiles(file) | ||
| return entry.isFile() && markdownExtensions.has(path.extname(file)) ? [file] : [] | ||
| }) | ||
| .sort() | ||
| } | ||
|
|
||
| function pageFor(file: string, pagesDirectory: string): Page { | ||
| const relativeFile = path.relative(pagesDirectory, file).split(path.sep).join('/') | ||
| const withoutExtension = relativeFile.replace(/\.(?:md|mdx)$/, '') | ||
| const isIndex = withoutExtension === 'index' || withoutExtension.endsWith('/index') | ||
| const route = normalizeRoute(`/${withoutExtension.replace(/(?:^|\/)index$/, '')}`) | ||
|
|
||
| return { | ||
| file, | ||
| route, | ||
| routeDirectory: isIndex ? route : path.posix.dirname(route), | ||
| } | ||
| } | ||
|
|
||
| function normalizeRoute(route: string): string { | ||
| const withoutExtension = route.replace(/\.(?:md|mdx)$/, '') | ||
| const withoutIndex = withoutExtension.replace(/\/index$/, '') | ||
| if (withoutIndex === '/') return '/' | ||
| return withoutIndex.replace(/\/$/, '') | ||
| } | ||
|
|
||
| function nodeText(node: AstNode): string { | ||
| if ((node.type === 'text' || node.type === 'inlineCode') && typeof node.value === 'string') { | ||
| return node.value | ||
| } | ||
| if (node.type === 'image' && typeof node.alt === 'string') return node.alt | ||
| return node.children?.map(nodeText).join('') ?? '' | ||
| } | ||
|
|
||
| function staticAttribute(node: AstNode, names: Set<string>): string | undefined { | ||
| const attribute = node.attributes?.find( | ||
| (candidate) => candidate.type === 'mdxJsxAttribute' && names.has(candidate.name), | ||
| ) | ||
| return typeof attribute?.value === 'string' ? attribute.value : undefined | ||
| } | ||
|
|
||
| function importsFor(node: AstNode, file: string): Map<string, string> { | ||
| const imports = new Map<string, string>() | ||
| const source = typeof node.value === 'string' ? node.value : '' | ||
| const pattern = /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.(?:md|mdx))['"]/g | ||
|
|
||
| for (const match of source.matchAll(pattern)) { | ||
| const [, localName, importPath] = match | ||
| if (!localName || !importPath) continue | ||
| imports.set(localName, path.resolve(path.dirname(file), importPath)) | ||
| } | ||
| return imports | ||
| } | ||
|
|
||
| function parseFile(file: string): AstNode { | ||
| try { | ||
| return unified().use(remarkParse).use(remarkMdx).parse(fs.readFileSync(file, 'utf8')) as AstNode | ||
| } catch (error) { | ||
| throw new Error(`Unable to parse ${file}`, { cause: error }) | ||
| } | ||
| } | ||
|
|
||
| function sourceEvents(file: string, stack: string[] = []): SourceEvent[] { | ||
| if (stack.includes(file)) { | ||
| throw new Error(`Circular MDX import: ${[...stack, file].join(' -> ')}`) | ||
| } | ||
|
|
||
| const tree = parseFile(file) | ||
| const imports = new Map<string, string>() | ||
| for (const child of tree.children ?? []) { | ||
| if (child.type !== 'mdxjsEsm') continue | ||
| for (const [localName, importFile] of importsFor(child, file)) { | ||
| imports.set(localName, importFile) | ||
| } | ||
| } | ||
|
|
||
| const visit = (node: AstNode): SourceEvent[] => { | ||
| const line = node.position?.start.line ?? 1 | ||
| const events: SourceEvent[] = [] | ||
|
|
||
| if (node.type === 'heading') { | ||
| events.push({ type: 'heading', file, line, text: nodeText(node) }) | ||
| } | ||
|
|
||
| if (node.type === 'link' || node.type === 'definition') { | ||
| if (typeof node.url === 'string') { | ||
| events.push({ type: 'link', file, line, href: node.url }) | ||
| } | ||
| } | ||
|
|
||
| if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { | ||
| const id = staticAttribute(node, new Set(['id'])) | ||
| if (id) events.push({ type: 'anchor', file, line, id }) | ||
|
|
||
| const href = staticAttribute(node, new Set(['href', 'to'])) | ||
| if (href) events.push({ type: 'link', file, line, href }) | ||
|
|
||
| const importedFile = node.name ? imports.get(node.name) : undefined | ||
| if (importedFile) { | ||
| if (!fs.existsSync(importedFile)) { | ||
| throw new Error(`${file}:${line} imports missing Markdown file ${importedFile}`) | ||
| } | ||
| events.push(...sourceEvents(importedFile, [...stack, file])) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Imported AGENTS.md reference: AGENTS.md:L20-L22 Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| for (const child of node.children ?? []) events.push(...visit(child)) | ||
| return events | ||
| } | ||
|
|
||
| return visit(tree) | ||
| } | ||
|
|
||
| function headingAnchor(text: string, slugger: GithubSlugger): string { | ||
| return slugger.slug(text) | ||
| } | ||
|
|
||
| function targetFor(href: string, page: Page): { route: string; fragment: string } | undefined { | ||
| if (/^(?:https?:|mailto:|tel:)/.test(href)) return | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For canonical links such as AGENTS.md reference: AGENTS.md:L7-L10 Useful? React with 👍 / 👎. |
||
|
|
||
| const basePath = href.startsWith('#') ? page.route : `${page.routeDirectory}/` | ||
| const base = `https://tempo.local${basePath}` | ||
| const url = new URL(href, base) | ||
| if (!url.hash || url.origin !== 'https://tempo.local') return | ||
|
|
||
| let route = url.pathname | ||
| if (route === '/developers/docs' || route.startsWith('/developers/docs/')) { | ||
| route = route.slice('/developers'.length) | ||
| } | ||
| route = normalizeRoute(route) | ||
| if (route !== '/docs' && !route.startsWith('/docs/')) return | ||
|
|
||
| return { route, fragment: decodeURIComponent(url.hash.slice(1)) } | ||
| } | ||
|
|
||
| export function checkInternalAnchors( | ||
| pagesDirectory = path.resolve('src/pages'), | ||
| ): InternalAnchorCheck { | ||
| const pages = markdownFiles(pagesDirectory).map((file) => pageFor(file, pagesDirectory)) | ||
| const docsPages = pages.filter( | ||
| (page) => page.route === '/docs' || page.route.startsWith('/docs/'), | ||
| ) | ||
| const eventsByRoute = new Map(docsPages.map((page) => [page.route, sourceEvents(page.file)])) | ||
| const anchorsByRoute = new Map<string, Set<string>>() | ||
|
|
||
| for (const page of docsPages) { | ||
| const slugger = new GithubSlugger() | ||
| const anchors = new Set<string>() | ||
| for (const event of eventsByRoute.get(page.route) ?? []) { | ||
| if (event.type === 'heading') anchors.add(headingAnchor(event.text, slugger)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a page follows the prescribed rename pattern AGENTS.md reference: AGENTS.md:L66-L70 Useful? React with 👍 / 👎. |
||
| if (event.type === 'anchor') anchors.add(event.id) | ||
| } | ||
| anchorsByRoute.set(page.route, anchors) | ||
| } | ||
|
|
||
| const failures: BrokenInternalAnchor[] = [] | ||
| let linksChecked = 0 | ||
| for (const page of docsPages) { | ||
| for (const event of eventsByRoute.get(page.route) ?? []) { | ||
| if (event.type !== 'link') continue | ||
| const target = targetFor(event.href, page) | ||
| if (!target) continue | ||
|
|
||
| const anchors = anchorsByRoute.get(target.route) | ||
| if (!anchors) continue // Vocs checks whether the target page exists. | ||
|
|
||
| linksChecked += 1 | ||
| if (anchors.has(target.fragment)) continue | ||
| failures.push({ | ||
| file: event.file, | ||
| line: event.line, | ||
| href: event.href, | ||
| targetRoute: target.route, | ||
| fragment: target.fragment, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return { pagesChecked: docsPages.length, linksChecked, failures } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the command passes only
src/pagesto the checker, fragment-bearing redirect destinations insrc/lib/docs-routing.tsandvocs.config.tsnever become link events. Existing mappings such asdocs-routing.ts:30andvocs.config.ts:1281can therefore point at a renamed heading indefinitely while this required check passes, silently breaking the legacy deep links those redirects preserve; include redirect destinations in the anchor candidates and validate their fragments against the canonical pages.AGENTS.md reference: AGENTS.md:L9-L10
Useful? React with 👍 / 👎.