Skip to content

Commit afdc201

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/) can't work as space-level redirects. The validator now checks each redirect belongs to its config's section and suggests using site-level redirects in the GitBook UI for cross-section cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df9fde6 commit afdc201

1 file changed

Lines changed: 83 additions & 27 deletions

File tree

codegen/validate-redirects.ts

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,111 @@ 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 {
9+
root?: string
910
redirects?: Record<string, string>
1011
}
1112

12-
interface Redirect {
13+
interface BrokenRedirect {
14+
configPath: string
1315
source: string
1416
target: string
17+
reason: string
1518
}
1619

17-
const redirects: Redirect[] = Object.entries(gitbookConfig.redirects ?? {}).map(
18-
([source, target]) => ({ source, target }),
19-
)
20+
function findForeignSection(path: string, ownRoot: string): string | undefined {
21+
for (const other of siteSections) {
22+
if (other.root === ownRoot) continue
23+
const prefix = other.urlPrefix.replace(/^\//, '') + '/'
24+
if (prefix !== '/' && path.startsWith(prefix)) return other.name
25+
}
26+
return undefined
27+
}
2028

21-
function pageExists(fullPath: string): boolean {
22-
return existsSync(fullPath) && statSync(fullPath).isFile()
29+
const broken: BrokenRedirect[] = []
30+
31+
interface ConfigToValidate {
32+
configPath: string
33+
root: string
34+
redirects: Record<string, string>
2335
}
2436

25-
function resolveTarget(target: string): boolean {
26-
for (const section of siteSections) {
27-
if (section.urlPrefix === '') continue
37+
const configs: ConfigToValidate[] = []
38+
39+
for (const section of siteSections) {
40+
const configPath = join(section.root, '.gitbook.yaml')
41+
if (!existsSync(configPath)) continue
42+
const config = YAML.parse(readFileSync(configPath, 'utf-8')) as GitbookConfig
43+
configs.push({
44+
configPath,
45+
root: section.root,
46+
redirects: config.redirects ?? {},
47+
})
48+
}
49+
50+
const rootConfigPath = '.gitbook.yaml'
51+
const rootIsSection = configs.some((c) => c.configPath === rootConfigPath)
52+
if (!rootIsSection && existsSync(rootConfigPath)) {
53+
const config = YAML.parse(
54+
readFileSync(rootConfigPath, 'utf-8'),
55+
) as GitbookConfig
56+
const root = (config.root ?? './').replace(/^\.\//, '').replace(/\/$/, '')
57+
configs.push({
58+
configPath: rootConfigPath,
59+
root,
60+
redirects: config.redirects ?? {},
61+
})
62+
}
2863

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))
64+
for (const { configPath, root, redirects } of configs) {
65+
for (const [source, target] of Object.entries(redirects)) {
66+
const foreignSource = findForeignSection(source, root)
67+
if (foreignSource != null) {
68+
broken.push({
69+
configPath,
70+
source,
71+
target,
72+
reason: `Source belongs to the "${foreignSource}" section. Use a site-level redirect in the GitBook UI instead`,
73+
})
74+
continue
3375
}
34-
}
3576

36-
const guidesSection = siteSections.find((s) => s.urlPrefix === '')
37-
if (guidesSection == null) return false
77+
const foreignTarget = findForeignSection(target, root)
78+
if (foreignTarget != null) {
79+
broken.push({
80+
configPath,
81+
source,
82+
target,
83+
reason: `Target points to the "${foreignTarget}" section. Use a site-level redirect in the GitBook UI instead`,
84+
})
85+
continue
86+
}
3887

39-
return pageExists(join(guidesSection.root, target))
88+
const fullPath = join(root, target)
89+
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
90+
broken.push({
91+
configPath,
92+
source,
93+
target,
94+
reason: `File not found: ${fullPath}`,
95+
})
96+
}
97+
}
4098
}
4199

42-
const broken: Redirect[] = redirects.filter((r) => !resolveTarget(r.target))
43-
44100
if (broken.length > 0) {
45101
// eslint-disable-next-line no-console
46-
console.error(
47-
`Found ${broken.length} redirect(s) with missing target(s) in .gitbook.yaml:\n`,
48-
)
49-
for (const { source, target } of broken) {
102+
console.error(`Found ${broken.length} redirect issue(s):\n`)
103+
for (const { configPath, source, target, reason } of broken) {
104+
// eslint-disable-next-line no-console
105+
console.error(` [${configPath}] ${source}`)
50106
// eslint-disable-next-line no-console
51-
console.error(` ${source}`)
107+
console.error(` target: ${target}`)
52108
// eslint-disable-next-line no-console
53-
console.error(` target: ${target}\n`)
109+
console.error(` ${reason}\n`)
54110
}
55111
process.exit(1)
56112
} else {
57113
// eslint-disable-next-line no-console
58-
console.log('All .gitbook.yaml redirect targets are valid.')
114+
console.log('All redirect targets are valid.')
59115
}

0 commit comments

Comments
 (0)