ReviewOS

also looking at this

stacks/reportshq

Expand BunPress guide and pre-E2E quality gates

#35
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
40 files +1524 -373

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+39-0
Changes to .github/workflows/ci.yml
@@ -175,6 +175,45 @@ jobs:
175175 - name: Design tokens
176176 run: bun scripts/verify-tokens.ts
177177
178 build:
179 if: github.event_name == 'pull_request'
180 runs-on: ubuntu-latest
181
182 steps:
183 - name: Checkout Code
184 uses: actions/checkout@v4
185
186 - name: Setup Pantry
187 uses: pantry-pm/pantry/packages/action@c1417d98f5613290bc3b1f96d35086b834684659 # v0.10.47 + the socket-hang-up retry fix
188 with:
189 install: 'false'
190
191 - name: Use cached node_modules
192 uses: actions/cache@v4
193 with:
194 path: node_modules
195 key: node-modules-${{ hashFiles('**/bun.lock') }}
196 restore-keys: |
197 node-modules-
198
199 - name: Install Dependencies
200 run: bun install
201
202 - name: Build artifacts
203 run: bun run build:artifacts
204
205 - name: Build application
206 run: bun run build
207
208 - name: Build documentation
209 run: bun buddy build docs
210
211 - name: Validate documentation
212 run: bun run docs:validate
213
214 - name: Verify generated files are current
215 run: git diff --exit-code
216
178217 # Push to deploy.
179218 #
180219 # This job lives in CI rather than in a workflow of its own so the gates are
package.jsonmodified+1-0
Changes to package.json
@@ -29,6 +29,7 @@
2929 "dev": "./buddy dev",
3030 "development": "./buddy dev",
3131 "build": "./buddy build",
32 "docs:validate": "bun scripts/validate-docs.ts",
3233 "prod": "./buddy build",
3334 "deploy": "./buddy deploy",
3435 "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}