Skip to content

Commit fadd42a

Browse files
louis-preclaude
andcommitted
Add cross-site link validator
Scans all markdown files for absolute docs.seam.co URLs and verifies the target page exists in the correct site section directory. Uses the centralized config for URL prefix → directory mapping. Run with: npm run validate-links Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6d6988c commit fadd42a

4 files changed

Lines changed: 128 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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2+
import { join } 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 urlPattern = new RegExp(
19+
`${baseUrl.replaceAll('.', '\\.')}[^)\\s]+`,
20+
'g',
21+
)
22+
23+
interface BrokenLink {
24+
file: string
25+
line: number
26+
url: string
27+
reason: string
28+
}
29+
30+
const brokenLinks: BrokenLink[] = []
31+
32+
function walkDir(dir: string): string[] {
33+
const files: string[] = []
34+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
35+
const fullPath = join(dir, entry.name)
36+
if (entry.isDirectory()) {
37+
files.push(...walkDir(fullPath))
38+
} else if (entry.name.endsWith('.md')) {
39+
files.push(fullPath)
40+
}
41+
}
42+
return files
43+
}
44+
45+
const files = walkDir('docs')
46+
47+
for (const file of files) {
48+
const contents = readFileSync(file, 'utf-8')
49+
const lines = contents.split('\n')
50+
51+
for (let i = 0; i < lines.length; i++) {
52+
const matches = lines[i]?.matchAll(urlPattern)
53+
if (matches == null) continue
54+
55+
for (const match of matches) {
56+
const rawUrl = match[0].replace(/[).,]*$/, '')
57+
// Strip GitBook markdown escapes (e.g., \_ used to prevent italic rendering)
58+
const cleanUrl = rawUrl.replaceAll('\\', '')
59+
const url = new URL(cleanUrl)
60+
url.pathname = url.pathname.replace(siteUrlPrefix, '')
61+
62+
// Strip hash anchors for file resolution
63+
const pathname = url.pathname
64+
65+
const section = siteSections.find(({ urlPrefix }) =>
66+
urlPrefix === ''
67+
? true
68+
: pathname.startsWith(urlPrefix + '/') || pathname === urlPrefix,
69+
)
70+
71+
if (section == null) {
72+
brokenLinks.push({
73+
file,
74+
line: i + 1,
75+
url: rawUrl,
76+
reason: 'No matching site section',
77+
})
78+
continue
79+
}
80+
81+
const pagePath = pathname.replace(section.urlPrefix, '')
82+
const targetRoot = join(section.root, pagePath)
83+
84+
const targetMd = `${targetRoot}.md`
85+
const targetReadme = join(targetRoot, 'README.md')
86+
87+
if (!existsSync(targetMd) && !existsSync(targetReadme)) {
88+
brokenLinks.push({
89+
file,
90+
line: i + 1,
91+
url: rawUrl,
92+
reason: `File not found: ${targetMd} or ${targetReadme}`,
93+
})
94+
}
95+
}
96+
}
97+
}
98+
99+
if (brokenLinks.length > 0) {
100+
// eslint-disable-next-line no-console
101+
console.error(`Found ${brokenLinks.length} broken cross-site link(s):\n`)
102+
for (const { file, line, url, reason } of brokenLinks) {
103+
// eslint-disable-next-line no-console
104+
console.error(` ${file}:${line}\n ${url}\n ${reason}\n`)
105+
}
106+
process.exit(1)
107+
} else {
108+
// eslint-disable-next-line no-console
109+
console.log('All cross-site links are valid.')
110+
}

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)