Skip to content

Commit 485c66a

Browse files
louis-preclaude
andcommitted
ci: Validate redirects belong to their config's site section
The root .gitbook.yaml is rooted at docs/guides/, so redirects with sources or targets in other sections (api/, brand-guides/) don't work. The validator now flags these and suggests moving them to the correct section's .gitbook.yaml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df9fde6 commit 485c66a

1 file changed

Lines changed: 106 additions & 25 deletions

File tree

codegen/validate-redirects.ts

Lines changed: 106 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,136 @@ import YAML from 'yaml'
55

66
import { siteSections } from './lib/config.js'
77

8-
const gitbookConfig = YAML.parse(readFileSync('.gitbook.yaml', 'utf-8')) as {
8+
interface GitbookConfig {
99
redirects?: Record<string, string>
1010
}
1111

12-
interface Redirect {
12+
interface BrokenRedirect {
13+
configPath: string
1314
source: string
1415
target: string
16+
reason: string
1517
}
1618

17-
const redirects: Redirect[] = Object.entries(gitbookConfig.redirects ?? {}).map(
18-
([source, target]) => ({ source, target }),
19-
)
19+
const broken: BrokenRedirect[] = []
2020

21-
function pageExists(fullPath: string): boolean {
22-
return existsSync(fullPath) && statSync(fullPath).isFile()
23-
}
21+
for (const section of siteSections) {
22+
const configPath = join(section.root, '.gitbook.yaml')
23+
if (!existsSync(configPath)) continue
2424

25-
function resolveTarget(target: string): boolean {
26-
for (const section of siteSections) {
27-
if (section.urlPrefix === '') continue
25+
const config = YAML.parse(readFileSync(configPath, 'utf-8')) as GitbookConfig
26+
const redirects = config.redirects ?? {}
27+
for (const [source, target] of Object.entries(redirects)) {
28+
// Source must not start with another section's URL prefix
29+
for (const other of siteSections) {
30+
if (other.root === section.root) continue
31+
const otherPrefix = other.urlPrefix.replace(/^\//, '') + '/'
32+
if (otherPrefix !== '/' && source.startsWith(otherPrefix)) {
33+
broken.push({
34+
configPath,
35+
source,
36+
target,
37+
reason: `Source belongs to the "${other.name}" section, not "${section.name}". Move this redirect to ${join(other.root, '.gitbook.yaml')}`,
38+
})
39+
}
40+
}
41+
42+
// Target must not start with another section's URL prefix
43+
for (const other of siteSections) {
44+
if (other.root === section.root) continue
45+
const otherPrefix = other.urlPrefix.replace(/^\//, '') + '/'
46+
if (otherPrefix !== '/' && target.startsWith(otherPrefix)) {
47+
broken.push({
48+
configPath,
49+
source,
50+
target,
51+
reason: `Target points to the "${other.name}" section. Move this redirect to ${join(other.root, '.gitbook.yaml')}`,
52+
})
53+
}
54+
}
2855

29-
const prefix = section.urlPrefix.replace(/^\//, '') + '/'
30-
if (target.startsWith(prefix)) {
31-
const relativePath = target.slice(prefix.length)
32-
return pageExists(join(section.root, relativePath))
56+
// Target must resolve to an existing file within this section
57+
const fullPath = join(section.root, target)
58+
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
59+
broken.push({
60+
configPath,
61+
source,
62+
target,
63+
reason: `File not found: ${fullPath}`,
64+
})
3365
}
3466
}
67+
}
3568

36-
const guidesSection = siteSections.find((s) => s.urlPrefix === '')
37-
if (guidesSection == null) return false
69+
// Also check the root .gitbook.yaml if it's not already a section config
70+
const rootConfigPath = '.gitbook.yaml'
71+
const rootIsSection = siteSections.some(
72+
(s) => join(s.root, '.gitbook.yaml') === rootConfigPath,
73+
)
3874

39-
return pageExists(join(guidesSection.root, target))
40-
}
75+
if (!rootIsSection && existsSync(rootConfigPath)) {
76+
const config = YAML.parse(
77+
readFileSync(rootConfigPath, 'utf-8'),
78+
) as GitbookConfig & { root?: string }
79+
const redirects = config.redirects ?? {}
80+
const root = (config.root ?? './').replace(/^\.\//, '').replace(/\/$/, '')
4181

42-
const broken: Redirect[] = redirects.filter((r) => !resolveTarget(r.target))
82+
const section = siteSections.find((s) => s.root === root)
83+
84+
for (const [source, target] of Object.entries(redirects)) {
85+
for (const other of siteSections) {
86+
if (section != null && other.root === section.root) continue
87+
const otherPrefix = other.urlPrefix.replace(/^\//, '') + '/'
88+
if (otherPrefix !== '/' && source.startsWith(otherPrefix)) {
89+
broken.push({
90+
configPath: rootConfigPath,
91+
source,
92+
target,
93+
reason: `Source belongs to the "${other.name}" section. Move this redirect to ${join(other.root, '.gitbook.yaml')}`,
94+
})
95+
}
96+
}
97+
98+
for (const other of siteSections) {
99+
if (section != null && other.root === section.root) continue
100+
const otherPrefix = other.urlPrefix.replace(/^\//, '') + '/'
101+
if (otherPrefix !== '/' && target.startsWith(otherPrefix)) {
102+
broken.push({
103+
configPath: rootConfigPath,
104+
source,
105+
target,
106+
reason: `Target points to the "${other.name}" section. Move this redirect to ${join(other.root, '.gitbook.yaml')}`,
107+
})
108+
}
109+
}
110+
111+
const fullPath = join(root, target)
112+
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
113+
broken.push({
114+
configPath: rootConfigPath,
115+
source,
116+
target,
117+
reason: `File not found: ${fullPath}`,
118+
})
119+
}
120+
}
121+
}
43122

44123
if (broken.length > 0) {
45124
// eslint-disable-next-line no-console
46125
console.error(
47-
`Found ${broken.length} redirect(s) with missing target(s) in .gitbook.yaml:\n`,
126+
`Found ${broken.length} redirect issue(s):\n`,
48127
)
49-
for (const { source, target } of broken) {
128+
for (const { configPath, source, target, reason } of broken) {
129+
// eslint-disable-next-line no-console
130+
console.error(` [${configPath}] ${source}`)
50131
// eslint-disable-next-line no-console
51-
console.error(` ${source}`)
132+
console.error(` target: ${target}`)
52133
// eslint-disable-next-line no-console
53-
console.error(` target: ${target}\n`)
134+
console.error(` ${reason}\n`)
54135
}
55136
process.exit(1)
56137
} else {
57138
// eslint-disable-next-line no-console
58-
console.log('All .gitbook.yaml redirect targets are valid.')
139+
console.log('All redirect targets are valid.')
59140
}

0 commit comments

Comments
 (0)