ReviewOS

also looking at this

loghqorg/loghq

Add BunPress guide and pre-E2E quality gates

#4
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
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.

.github/workflows/ci.ymlmodified+9-1
Changes to .github/workflows/ci.yml
@@ -188,6 +188,14 @@ jobs:
188188 - name: Install Dependencies
189189 run: bun install
190190
191 - name: Build
191 - name: Build application
192192 run: bun run build
193193
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
package.jsonmodified+1-0
Changes to package.json
@@ -31,6 +31,7 @@
3131 "dev:api": "./buddy dev:api",
3232 "dev:full": "STACKS_FULL_DEV=1 ./buddy dev",
3333 "build": "./buddy build",
34 "docs:validate": "bun scripts/validate-docs.ts",
3435 "prod": "./buddy build",
3536 "deploy": "./buddy deploy",
3637 "lint": "./buddy lint",
scripts/validate-docs.tsadded+102-0
Changes to scripts/validate-docs.ts
@@ -0,0 +1,102 @@
1import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2import { dirname, join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const docsDir = resolve(root, 'docs')
6const configPath = resolve(root, 'config/docs.ts')
7const issues = new Set<string>()
8const incomingRoutes = new Set<string>()
9const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status']
10
11function 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
18function 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
29function 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
35function 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
50function 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
60if (!existsSync(docsDir)) {
61 console.error('Documentation directory does not exist: docs/')
62 process.exit(1)
63}
64
65const files = markdownFiles(docsDir)
66for (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
86if (existsSync(configPath)) {
87 const config = readFileSync(configPath, 'utf8')
88 for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1])
89}
90
91for (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
98console.log(`Validated ${files.length} documentation pages.`)
99if (issues.size > 0) {
100 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
101 process.exit(1)
102}