also looking at this
Add BunPress guide and pre-E2E quality gates
#4
246 files
+1744
-64381
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -188,6 +188,14 @@ jobs: | ||
| 188 | 188 | - name: Install Dependencies |
| 189 | 189 | run: bun install |
| 190 | 190 | |
| 191 | - name: Build | |
| 191 | - name: Build application | |
| 192 | 192 | run: bun run build |
| 193 | 193 | |
| 194 | - name: Build documentation | |
| 195 | run: bun buddy build docs | |
| 196 | ||
| 197 | - name: Validate documentation | |
| 198 | run: bun run docs:validate | |
| 199 | ||
| 200 | - name: Verify generated files are current | |
| 201 | run: git diff --exit-code | |
| @@ -0,0 +1,102 @@ | ||
| 1 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { dirname, join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const docsDir = resolve(root, 'docs') | |
| 6 | const configPath = resolve(root, 'config/docs.ts') | |
| 7 | const issues = new Set<string>() | |
| 8 | const incomingRoutes = new Set<string>() | |
| 9 | const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status'] | |
| 10 | ||
| 11 | function markdownFiles(dir: string): string[] { | |
| 12 | return readdirSync(dir).flatMap((name) => { | |
| 13 | const path = join(dir, name) | |
| 14 | return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : [] | |
| 15 | }) | |
| 16 | } | |
| 17 | ||
| 18 | function proseOnly(text: string): string { | |
| 19 | let inFence = false | |
| 20 | return text.split('\n').filter((line) => { | |
| 21 | if (/^(?:```|~~~)/.test(line)) { | |
| 22 | inFence = !inFence | |
| 23 | return false | |
| 24 | } | |
| 25 | return !inFence | |
| 26 | }).join('\n') | |
| 27 | } | |
| 28 | ||
| 29 | function routeForFile(file: string): string { | |
| 30 | const path = relative(docsDir, file).replace(/\\/g, '/') | |
| 31 | if (path === 'index.md') return '/' | |
| 32 | return `/${path.replace(/(?:\/index)?\.md$/, '')}` | |
| 33 | } | |
| 34 | ||
| 35 | function resolveDocTarget(source: string, rawTarget: string): string | null { | |
| 36 | let target = rawTarget.replace(/^<|>$/g, '').split(/[?#]/, 1)[0] | |
| 37 | if (!target || /^(?:[a-z]+:|#|mailto:|tel:)/i.test(rawTarget)) return null | |
| 38 | ||
| 39 | if (target === '/docs') target = '/' | |
| 40 | else if (target.startsWith('/docs/')) target = target.slice('/docs'.length) | |
| 41 | ||
| 42 | const base = target.startsWith('/') ? resolve(docsDir, `.${target}`) : resolve(dirname(source), target) | |
| 43 | const candidates = [base, `${base}.md`, join(base, 'index.md')] | |
| 44 | const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile()) | |
| 45 | if (found) return found | |
| 46 | if (target.startsWith('/') && appRoutePrefixes.some(prefix => target === prefix || target.startsWith(`${prefix}/`))) return null | |
| 47 | return '' | |
| 48 | } | |
| 49 | ||
| 50 | function checkTarget(source: string, rawTarget: string): void { | |
| 51 | const target = resolveDocTarget(source, rawTarget) | |
| 52 | if (target === null) return | |
| 53 | if (target === '') { | |
| 54 | issues.add(`${relative(root, source)}: missing local target ${rawTarget}`) | |
| 55 | return | |
| 56 | } | |
| 57 | if (target.startsWith(docsDir)) incomingRoutes.add(routeForFile(target)) | |
| 58 | } | |
| 59 | ||
| 60 | if (!existsSync(docsDir)) { | |
| 61 | console.error('Documentation directory does not exist: docs/') | |
| 62 | process.exit(1) | |
| 63 | } | |
| 64 | ||
| 65 | const files = markdownFiles(docsDir) | |
| 66 | for (const file of files) { | |
| 67 | const text = readFileSync(file, 'utf8') | |
| 68 | const prose = proseOnly(text) | |
| 69 | const name = relative(root, file) | |
| 70 | const headings = prose.match(/^#\s+\S.*$/gm) ?? [] | |
| 71 | const homeLayout = /^layout:\s*home\s*$/m.test(text) | |
| 72 | ||
| 73 | if (headings.length !== 1 && !(homeLayout && headings.length === 0)) { | |
| 74 | issues.add(`${name}: expected one H1, found ${headings.length}`) | |
| 75 | } | |
| 76 | ||
| 77 | const fences = text.match(/^(?:```|~~~)/gm) ?? [] | |
| 78 | if (fences.length % 2 !== 0) issues.add(`${name}: unbalanced fenced code blocks`) | |
| 79 | if (/[—–]/.test(prose)) issues.add(`${name}: contains a prohibited long dash`) | |
| 80 | if (/\b(?:TODO|TBD|FIXME|XXX)\b/.test(prose)) issues.add(`${name}: contains an unfinished marker`) | |
| 81 | ||
| 82 | for (const match of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) checkTarget(file, match[1]) | |
| 83 | for (const match of prose.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) checkTarget(file, match[1]) | |
| 84 | } | |
| 85 | ||
| 86 | if (existsSync(configPath)) { | |
| 87 | const config = readFileSync(configPath, 'utf8') | |
| 88 | for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1]) | |
| 89 | } | |
| 90 | ||
| 91 | for (const file of files) { | |
| 92 | const route = routeForFile(file) | |
| 93 | if (route !== '/' && !incomingRoutes.has(route)) { | |
| 94 | issues.add(`${relative(root, file)}: not linked from navigation or another documentation page`) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | console.log(`Validated ${files.length} documentation pages.`) | |
| 99 | if (issues.size > 0) { | |
| 100 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 101 | process.exit(1) | |
| 102 | } | |