Skip to content

Commit 87aa47c

Browse files
louis-preclaude
andcommitted
ci: Add validation that all pages are referenced in SUMMARY.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b55dff5 commit 87aa47c

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

.github/workflows/generate.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,5 @@ jobs:
8282
run: npm run validate-paths
8383
- name: Validate cross-site links
8484
run: npm run validate-links
85+
- name: Validate all pages are in SUMMARY.md
86+
run: npm run validate-orphan-pages

codegen/validate-orphan-pages.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { readdirSync, readFileSync } from 'node:fs'
2+
import { join, relative } from 'node:path'
3+
4+
import { siteSections } from './lib/config.js'
5+
6+
// Matches markdown links in SUMMARY.md: [Title](path/to/file.md)
7+
const summaryLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/g
8+
9+
function walkDir(dir: string): string[] {
10+
const files: string[] = []
11+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
12+
const fullPath = join(dir, entry.name)
13+
if (entry.isDirectory()) {
14+
// Skip .gitbook directories (assets, includes, etc.)
15+
if (entry.name === '.gitbook') continue
16+
files.push(...walkDir(fullPath))
17+
} else if (entry.name.endsWith('.md')) {
18+
// Skip underscore-prefixed files (e.g., _report.md)
19+
if (entry.name.startsWith('_')) continue
20+
// Skip SUMMARY.md itself
21+
if (entry.name === 'SUMMARY.md') continue
22+
files.push(fullPath)
23+
}
24+
}
25+
return files
26+
}
27+
28+
interface OrphanPage {
29+
section: string
30+
path: string
31+
}
32+
33+
const orphans: OrphanPage[] = []
34+
35+
for (const section of siteSections) {
36+
const summaryPath = join(section.root, 'SUMMARY.md')
37+
const contents = readFileSync(summaryPath, 'utf-8')
38+
39+
// Collect all file paths referenced in SUMMARY.md
40+
const referencedPaths = new Set<string>()
41+
for (const match of contents.matchAll(summaryLinkPattern)) {
42+
const linkPath = match[2] ?? ''
43+
if (linkPath.startsWith('http') || linkPath.startsWith('#')) continue
44+
// Strip anchor fragments
45+
const cleanPath = linkPath.split('#')[0] ?? ''
46+
if (cleanPath !== '') {
47+
referencedPaths.add(cleanPath)
48+
}
49+
}
50+
51+
// Walk all markdown files in this section
52+
const allPages = walkDir(section.root)
53+
54+
for (const fullPath of allPages) {
55+
const relPath = relative(section.root, fullPath)
56+
if (!referencedPaths.has(relPath)) {
57+
orphans.push({ section: section.name, path: relPath })
58+
}
59+
}
60+
}
61+
62+
if (orphans.length > 0) {
63+
// Group by section
64+
const bySection = new Map<string, string[]>()
65+
for (const { section, path } of orphans) {
66+
const existing = bySection.get(section) ?? []
67+
existing.push(path)
68+
bySection.set(section, existing)
69+
}
70+
71+
// eslint-disable-next-line no-console
72+
console.error(
73+
`Found ${orphans.length} page(s) not referenced in SUMMARY.md:\n`,
74+
)
75+
for (const [section, paths] of bySection) {
76+
// eslint-disable-next-line no-console
77+
console.error(` [${section}]`)
78+
for (const p of paths) {
79+
// eslint-disable-next-line no-console
80+
console.error(` ${p}`)
81+
}
82+
// eslint-disable-next-line no-console
83+
console.error('')
84+
}
85+
process.exit(1)
86+
} else {
87+
// eslint-disable-next-line no-console
88+
console.log('All pages are referenced in SUMMARY.md.')
89+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"postgenerate": "npm run format",
1919
"validate-paths": "tsx codegen/validate-paths.ts",
2020
"validate-links": "tsx codegen/validate-links.ts",
21+
"validate-orphan-pages": "tsx codegen/validate-orphan-pages.ts",
2122
"typecheck": "tsc",
2223
"lint": "eslint .",
2324
"postlint": "prettier --check --ignore-path .prettierignore .",

0 commit comments

Comments
 (0)