Files
CherryHQ-cherry-studio/scripts/check-doc-links.ts

122 lines
3.4 KiB
TypeScript

import * as fs from 'fs'
import * as path from 'path'
/**
* Checks all internal markdown links in docs/ and src/ README files.
* Validates that relative links point to existing files.
* Exits with code 1 if any broken links are found.
*/
const ROOT = path.resolve(__dirname, '..')
// Markdown link pattern: [text](url) — exclude external URLs and anchors-only
const LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g
interface BrokenLink {
file: string
line: number
link: string
resolvedPath: string
}
function findMarkdownFiles(dir: string): string[] {
const results: string[] = []
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
// Skip node_modules, .git, out, dist
if (['node_modules', '.git', 'out', 'dist'].includes(entry.name)) continue
results.push(...findMarkdownFiles(fullPath))
} else if (entry.name.endsWith('.md')) {
results.push(fullPath)
}
}
return results
}
function isExternalLink(link: string): boolean {
return link.startsWith('http://') || link.startsWith('https://') || link.startsWith('mailto:')
}
function isAnchorOnly(link: string): boolean {
return link.startsWith('#')
}
function checkFile(filePath: string): BrokenLink[] {
const broken: BrokenLink[] = []
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split('\n')
const dir = path.dirname(filePath)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
let match: RegExpExecArray | null
LINK_RE.lastIndex = 0
while ((match = LINK_RE.exec(line)) !== null) {
const rawLink = match[1]
// Skip external links, anchors, special protocols, and placeholder links
if (isExternalLink(rawLink) || isAnchorOnly(rawLink)) continue
if (rawLink.includes('<') || rawLink.includes('>')) continue
// Strip anchor fragment from link
const linkPath = rawLink.split('#')[0]
if (!linkPath) continue // Was just an anchor
const resolved = path.resolve(dir, linkPath)
if (!fs.existsSync(resolved)) {
broken.push({
file: path.relative(ROOT, filePath),
line: i + 1,
link: rawLink,
resolvedPath: path.relative(ROOT, resolved)
})
}
}
}
return broken
}
function main() {
const scanDirs = ['docs', 'src', 'packages', '.agents'].map((d) => path.join(ROOT, d)).filter((d) => fs.existsSync(d))
let allFiles: string[] = []
for (const dir of scanDirs) {
allFiles.push(...findMarkdownFiles(dir))
}
// Also check root-level markdown files
const rootMdFiles = fs.readdirSync(ROOT).filter((f) => f.endsWith('.md') && fs.statSync(path.join(ROOT, f)).isFile())
allFiles.push(...rootMdFiles.map((f) => path.join(ROOT, f)))
// Deduplicate
allFiles = [...new Set(allFiles)]
console.log(`Checking ${allFiles.length} markdown files for broken links...`)
const allBroken: BrokenLink[] = []
for (const file of allFiles) {
allBroken.push(...checkFile(file))
}
if (allBroken.length === 0) {
console.log('All links are valid.')
process.exit(0)
}
console.error(`\nFound ${allBroken.length} broken link(s):\n`)
for (const b of allBroken) {
console.error(` ${b.file}:${b.line}`)
console.error(` Link: ${b.link}`)
console.error(` Resolved to: ${b.resolvedPath}\n`)
}
process.exit(1)
}
main()