Skip to content

Commit 5f5cd7e

Browse files
louis-preclaude
andcommitted
Add cross-site link validator
Scans all markdown files for broken links: - Absolute docs.seam.co URLs: checks target exists in correct site section - Relative links: resolves from file location and checks target exists Skips images, anchors, GitBook templates, broken-reference placeholders, and file:// URIs. Run with: npm run validate-links Runs in CI as part of the Generate workflow after codegen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6d6988c commit 5f5cd7e

4 files changed

Lines changed: 165 additions & 1 deletion

File tree

.github/workflows/generate.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,17 @@ jobs:
6666
commit_user_name: ${{ secrets.GIT_USER_NAME }}
6767
commit_user_email: ${{ secrets.GIT_USER_EMAIL }}
6868
commit_author: ${{ secrets.GIT_USER_NAME }} <${{ secrets.GIT_USER_EMAIL }}>
69+
validate-links:
70+
name: Validate links
71+
needs: commit
72+
runs-on: ubuntu-latest
73+
timeout-minutes: 30
74+
steps:
75+
- name: Checkout
76+
uses: actions/checkout@v4
77+
with:
78+
ref: ${{ github.head_ref }}
79+
- name: Setup
80+
uses: ./.github/actions/setup
81+
- name: Validate cross-site links
82+
run: npm run validate-links

codegen/validate-links.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
2+
import { dirname, join, resolve } from 'node:path'
3+
4+
import {
5+
apiReferenceRoot,
6+
apiReferenceUrlPrefix,
7+
baseUrl,
8+
guidesRoot,
9+
siteUrlPrefix,
10+
} from './lib/config.js'
11+
12+
// Maps published URL prefixes to their site section root on disk.
13+
const siteSections: Array<{ urlPrefix: string; root: string }> = [
14+
{ urlPrefix: apiReferenceUrlPrefix, root: apiReferenceRoot },
15+
{ urlPrefix: '', root: guidesRoot },
16+
]
17+
18+
const absoluteUrlPattern = new RegExp(
19+
`${baseUrl.replaceAll('.', '\\.')}[^)\\s]+`,
20+
'g',
21+
)
22+
23+
// Matches markdown links like [text](path) but not images ![text](path),
24+
// absolute URLs, anchors-only, GitBook template tags, or angle-bracket paths.
25+
const relativeLinkPattern =
26+
/(?<!!)\]\((?!https?:\/\/|mailto:|#|{%|<|cursor:|file:)([^)]+)\)/g
27+
28+
interface BrokenLink {
29+
file: string
30+
line: number
31+
url: string
32+
reason: string
33+
}
34+
35+
const brokenLinks: BrokenLink[] = []
36+
37+
function walkDir(dir: string): string[] {
38+
const files: string[] = []
39+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
40+
const fullPath = join(dir, entry.name)
41+
if (entry.isDirectory()) {
42+
files.push(...walkDir(fullPath))
43+
} else if (entry.name.endsWith('.md')) {
44+
files.push(fullPath)
45+
}
46+
}
47+
return files
48+
}
49+
50+
function checkAbsoluteUrl(file: string, line: number, rawUrl: string): void {
51+
const cleanUrl = rawUrl.replaceAll('\\', '')
52+
const url = new URL(cleanUrl)
53+
url.pathname = url.pathname.replace(siteUrlPrefix, '')
54+
55+
const pathname = url.pathname
56+
57+
const section = siteSections.find(({ urlPrefix }) =>
58+
urlPrefix === ''
59+
? true
60+
: pathname.startsWith(urlPrefix + '/') || pathname === urlPrefix,
61+
)
62+
63+
if (section == null) {
64+
brokenLinks.push({
65+
file,
66+
line,
67+
url: rawUrl,
68+
reason: 'No matching site section',
69+
})
70+
return
71+
}
72+
73+
const pagePath = pathname.replace(section.urlPrefix, '')
74+
const targetRoot = join(section.root, pagePath)
75+
76+
const targetMd = `${targetRoot}.md`
77+
const targetReadme = join(targetRoot, 'README.md')
78+
79+
if (!existsSync(targetMd) && !existsSync(targetReadme)) {
80+
brokenLinks.push({
81+
file,
82+
line,
83+
url: rawUrl,
84+
reason: `File not found: ${targetMd} or ${targetReadme}`,
85+
})
86+
}
87+
}
88+
89+
function checkRelativeLink(file: string, line: number, rawLink: string): void {
90+
// Strip anchor
91+
const linkPath = rawLink.split('#')[0]
92+
if (linkPath == null || linkPath === '') return
93+
94+
// Strip GitBook markdown escapes and decode URL encoding
95+
const cleanPath = decodeURIComponent(linkPath.replaceAll('\\', ''))
96+
97+
const fileDir = dirname(file)
98+
const resolved = resolve(fileDir, cleanPath)
99+
100+
// Check if target exists as file, as README.md in directory, or as directory
101+
if (!existsSync(resolved) && !existsSync(join(resolved, 'README.md'))) {
102+
brokenLinks.push({
103+
file,
104+
line,
105+
url: rawLink,
106+
reason: `File not found: ${resolved}`,
107+
})
108+
}
109+
}
110+
111+
const files = walkDir('docs')
112+
113+
for (const file of files) {
114+
const contents = readFileSync(file, 'utf-8')
115+
const lines = contents.split('\n')
116+
117+
for (let i = 0; i < lines.length; i++) {
118+
const lineText = lines[i]
119+
if (lineText == null) continue
120+
121+
// Check absolute docs.seam.co URLs
122+
for (const match of lineText.matchAll(absoluteUrlPattern)) {
123+
const rawUrl = match[0].replace(/[).,]*$/, '')
124+
checkAbsoluteUrl(file, i + 1, rawUrl)
125+
}
126+
127+
// Check relative links
128+
for (const match of lineText.matchAll(relativeLinkPattern)) {
129+
const rawLink = match[1]
130+
if (rawLink == null) continue
131+
checkRelativeLink(file, i + 1, rawLink)
132+
}
133+
}
134+
}
135+
136+
if (brokenLinks.length > 0) {
137+
// eslint-disable-next-line no-console
138+
console.error(`Found ${brokenLinks.length} broken link(s):\n`)
139+
for (const { file, line, url, reason } of brokenLinks) {
140+
// eslint-disable-next-line no-console
141+
console.error(` ${file}:${line}\n ${url}\n ${reason}\n`)
142+
}
143+
process.exit(1)
144+
} else {
145+
// eslint-disable-next-line no-console
146+
console.log('All links are valid.')
147+
}

docs/api-reference/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
---
2+
description: Comprehensive reference for integrating with Seam API endpoints
3+
---
14

25
# Overview
36

@@ -29,4 +32,3 @@ See the following reference topics:
2932
* [Events](events/)
3033
* [Action Attempts](action_attempts/)
3134
* [Connected Accounts](connected_accounts/)
32-

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"scripts": {
1717
"generate": "tsx codegen/smith.ts",
1818
"postgenerate": "npm run format",
19+
"validate-links": "tsx codegen/validate-links.ts",
1920
"typecheck": "tsc",
2021
"lint": "eslint .",
2122
"postlint": "prettier --check --ignore-path .prettierignore .",

0 commit comments

Comments
 (0)