Skip to content

Commit 7422a8b

Browse files
louis-preclaude
andcommitted
Add SUMMARY.md path validator
Validates that file paths in SUMMARY.md are consistent with their section group heading. For example, a file listed under "## Developer Tools" must have a path starting with "developer-tools/". Usage: npm run validate-paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d6a6061 commit 7422a8b

2 files changed

Lines changed: 129 additions & 0 deletions

File tree

codegen/validate-paths.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
4+
import { apiReferenceRoot, guidesRoot } from './lib/config.js'
5+
6+
interface SiteSection {
7+
name: string
8+
root: string
9+
summaryPath: string
10+
}
11+
12+
const siteSections: SiteSection[] = [
13+
{
14+
name: 'Guides',
15+
root: guidesRoot,
16+
summaryPath: join(guidesRoot, 'SUMMARY.md'),
17+
},
18+
{
19+
name: 'API Reference',
20+
root: apiReferenceRoot,
21+
summaryPath: join(apiReferenceRoot, 'SUMMARY.md'),
22+
},
23+
]
24+
25+
// Matches markdown links in SUMMARY.md: * [Title](path/to/file.md)
26+
const summaryLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/g
27+
28+
// Matches ## headings that define groups in SUMMARY.md
29+
const groupHeadingPattern = /^## (.+)$/
30+
31+
function slugify(heading: string): string {
32+
return heading
33+
.toLowerCase()
34+
.replace(/[^a-z0-9\s-]/g, '')
35+
.replace(/\s+/g, '-')
36+
.replace(/-+/g, '-')
37+
.trim()
38+
}
39+
40+
// Groups listed here contain files from multiple subdirectories by design,
41+
// so we only check that referenced files exist (not that their path prefix
42+
// matches the group slug).
43+
const exemptGroups = new Set<string>([])
44+
45+
interface PathMismatch {
46+
section: string
47+
line: number
48+
title: string
49+
path: string
50+
reason: string
51+
}
52+
53+
const mismatches: PathMismatch[] = []
54+
55+
for (const section of siteSections) {
56+
if (!existsSync(section.summaryPath)) continue
57+
58+
const contents = readFileSync(section.summaryPath, 'utf-8')
59+
const lines = contents.split('\n')
60+
61+
let currentGroup: string | null = null
62+
63+
for (let i = 0; i < lines.length; i++) {
64+
const lineText = lines[i]
65+
if (lineText == null) continue
66+
67+
// Track current ## group heading
68+
const headingMatch = lineText.match(groupHeadingPattern)
69+
if (headingMatch?.[1] != null) {
70+
currentGroup = slugify(headingMatch[1])
71+
continue
72+
}
73+
74+
for (const match of lineText.matchAll(summaryLinkPattern)) {
75+
const title = match[1] ?? ''
76+
const linkPath = match[2] ?? ''
77+
78+
// Skip external links and anchors
79+
if (linkPath.startsWith('http') || linkPath.startsWith('#')) continue
80+
81+
const fullPath = join(section.root, linkPath)
82+
83+
// Check 1: file exists
84+
if (!existsSync(fullPath)) {
85+
mismatches.push({
86+
section: section.name,
87+
line: i + 1,
88+
title,
89+
path: linkPath,
90+
reason: `File not found: ${fullPath}`,
91+
})
92+
continue
93+
}
94+
95+
// Check 2: file path starts with the group slug
96+
if (
97+
currentGroup != null &&
98+
!exemptGroups.has(currentGroup) &&
99+
!linkPath.startsWith(currentGroup + '/')
100+
) {
101+
mismatches.push({
102+
section: section.name,
103+
line: i + 1,
104+
title,
105+
path: linkPath,
106+
reason: `Path should start with "${currentGroup}/" (listed under "## ${currentGroup}")`,
107+
})
108+
}
109+
}
110+
}
111+
}
112+
113+
if (mismatches.length > 0) {
114+
// eslint-disable-next-line no-console
115+
console.error(`Found ${mismatches.length} SUMMARY.md path issue(s):\n`)
116+
for (const { section, line, title, path, reason } of mismatches) {
117+
// eslint-disable-next-line no-console
118+
console.error(` [${section}] line ${line}: "${title}"`)
119+
// eslint-disable-next-line no-console
120+
console.error(` ${path}`)
121+
// eslint-disable-next-line no-console
122+
console.error(` ${reason}\n`)
123+
}
124+
process.exit(1)
125+
} else {
126+
// eslint-disable-next-line no-console
127+
console.log('All SUMMARY.md paths are valid and consistent.')
128+
}

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-paths": "tsx codegen/validate-paths.ts",
1920
"typecheck": "tsc",
2021
"lint": "eslint .",
2122
"postlint": "prettier --check --ignore-path .prettierignore .",

0 commit comments

Comments
 (0)