mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 20:59:22 +08:00
122 lines
3.4 KiB
TypeScript
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()
|