Skip to content

Commit f352cbf

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 3b104fd commit f352cbf

4 files changed

Lines changed: 186 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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 GitBook "mention" hint and anchor
91+
const linkPath = rawLink.replace(/ "mention"$/, '').split('#')[0]
92+
if (linkPath == null || linkPath === '') return
93+
94+
// Skip asset references
95+
if (linkPath.includes('.gitbook/assets')) return
96+
97+
// Strip GitBook markdown escapes and decode URL encoding
98+
const cleanPath = decodeURIComponent(
99+
linkPath.replaceAll('\\(', '(').replaceAll('\\)', ')').replaceAll('\\', ''),
100+
)
101+
102+
const fileDir = dirname(file)
103+
const resolved = resolve(fileDir, cleanPath)
104+
105+
// Check if target exists as file, as README.md in directory, or as directory
106+
if (!existsSync(resolved) && !existsSync(join(resolved, 'README.md'))) {
107+
brokenLinks.push({
108+
file,
109+
line,
110+
url: rawLink,
111+
reason: `File not found: ${resolved}`,
112+
})
113+
}
114+
}
115+
116+
const files = walkDir('docs')
117+
118+
for (const file of files) {
119+
const contents = readFileSync(file, 'utf-8')
120+
const lines = contents.split('\n')
121+
122+
for (let i = 0; i < lines.length; i++) {
123+
const lineText = lines[i]
124+
if (lineText == null) continue
125+
126+
// Check absolute docs.seam.co URLs
127+
for (const match of lineText.matchAll(absoluteUrlPattern)) {
128+
const rawUrl = match[0].replace(/[).,]*$/, '')
129+
checkAbsoluteUrl(file, i + 1, rawUrl)
130+
}
131+
132+
// Check relative links
133+
for (const match of lineText.matchAll(relativeLinkPattern)) {
134+
const rawLink = match[1]
135+
if (rawLink == null) continue
136+
checkRelativeLink(file, i + 1, rawLink)
137+
}
138+
}
139+
}
140+
141+
if (brokenLinks.length > 0) {
142+
// Group by broken target
143+
const groups = new Map<string, Array<{ file: string; line: number }>>()
144+
for (const { file, line, url } of brokenLinks) {
145+
const existing = groups.get(url) ?? []
146+
existing.push({ file, line })
147+
groups.set(url, existing)
148+
}
149+
150+
// eslint-disable-next-line no-console
151+
console.error(
152+
`Found ${brokenLinks.length} broken link(s) across ${groups.size} unique target(s):\n`,
153+
)
154+
for (const [target, sources] of groups) {
155+
// eslint-disable-next-line no-console
156+
console.error(` ${target}`)
157+
for (const { file, line } of sources) {
158+
// eslint-disable-next-line no-console
159+
console.error(` - ${file}:${line}`)
160+
}
161+
// eslint-disable-next-line no-console
162+
console.error('')
163+
}
164+
process.exit(1)
165+
} else {
166+
// eslint-disable-next-line no-console
167+
console.log('All links are valid.')
168+
}

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)