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+13-2
Changes to .github/workflows/ci.yml
@@ -176,7 +176,6 @@ jobs:
176176 run: bun scripts/verify-tokens.ts
177177
178178 build:
179 if: github.event_name == 'pull_request'
180179 runs-on: ubuntu-latest
181180
182181 steps:
@@ -217,6 +216,9 @@ jobs:
217216 - name: Validate responsive visual contracts
218217 run: bun run visual:validate
219218
219 - name: Validate production release artifact
220 run: bun run release:validate
221
220222 - name: Verify generated files are current
221223 run: git diff --exit-code
222224
@@ -230,7 +232,7 @@ jobs:
230232 #
231233 # A red build therefore means NO deploy, silently and by construction.
232234 deploy:
233 needs: [lint, typecheck, test, test-postgres]
235 needs: [lint, typecheck, test, test-postgres, build]
234236 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
235237 runs-on: ubuntu-latest
236238
@@ -266,6 +268,12 @@ jobs:
266268 - name: Build artifacts
267269 run: bun run build:artifacts
268270
271 - name: Build application
272 run: bun run build
273
274 - name: Validate production artifact
275 run: bun run release:validate
276
269277 # ts-cloud reads the PUBLIC key off disk to register it, so it is derived
270278 # from the private one rather than stored as a second secret. Two secrets
271279 # that have to agree eventually stop agreeing.
@@ -343,6 +351,9 @@ jobs:
343351
344352 exit $fail
345353
354 - name: Verify public metadata
355 run: bun run deployment:smoke
356
346357 # The same suite against Postgres, and a hard gate on the deploy.
347358 #
348359 # Production is Postgres and the suite defaults to SQLite, so every difference
config/ui.tsmodified+1-1
Changes to config/ui.ts
@@ -31,7 +31,7 @@ export default {
3131 // resolves through this. See the plugin file for the lookup order.
3232 plugins: ['./storage/framework/defaults/stx-components-plugin.ts'],
3333
34 layoutsDir: 'layouts',
34 layoutsDir: 'views/layouts',
3535
3636 partialsDir: 'partials',
3737
package.jsonmodified+3-1
Changes to package.json
@@ -32,6 +32,8 @@
3232 "docs:validate": "bun scripts/validate-docs.ts",
3333 "ui:validate": "bun scripts/validate-ui.ts",
3434 "visual:validate": "bun scripts/validate-visual.ts",
35 "release:validate": "bun scripts/validate-release.ts",
36 "deployment:smoke": "bun scripts/smoke-deployment.ts",
3537 "prod": "./buddy build",
3638 "deploy": "./buddy deploy",
3739 "lint": "./buddy lint",
@@ -57,7 +59,7 @@
5759 "changelog": "./buddy changelog",
5860 "generate": "./buddy generate",
5961 "typecheck": "bun run typecheck:app && bun run typecheck:framework",
60 "typecheck:framework": "bun x --bun tsc --noEmit -p storage/framework/tsconfig.framework.json --pretty false && (cd storage/framework/core/orm && bun run typecheck:inference)",
62 "typecheck:framework": "bun x --bun tsc --noEmit -p storage/framework/tsconfig.framework.json --pretty false",
6163 "typecheck:app": "bun x --bun tsc --noEmit -p tsconfig.json --pretty false",
6264 "typecheck:clean": "bunx --bun rimraf storage/framework/.cache/typescript",
6365 "types:check": "bun run typecheck",
resources/partials/AuthGuard.stxmodified+2-0
Changes to resources/partials/AuthGuard.stx
@@ -17,6 +17,7 @@
1717 authenticate. The first says where to go; the second says to bin the stale
1818 cookie on the way, which is what stops an expired token from bouncing
1919 somebody back and forth forever. --}}
20<span hidden aria-hidden="true">
2021<script>
2122(function () {
2223 if (!document.getElementById('auth-required')) return
@@ -32,3 +33,4 @@
3233 window.location.replace('/login')
3334}())
3435</script>
36</span>
resources/views/account.stxmodified+2-2
Changes to resources/views/account.stx
@@ -5,9 +5,9 @@
55// than about a project: who they are, and how to stop being signed in. Team
66// billing arrives with #17.
77import { db as __db } from '@stacksjs/database'
8import { sessionCookieName, sessionFrom } from '../../app/Support/session'
8import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
99
10const session = await sessionFrom(cookies)
10const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1111const cookieName = sessionCookieName()
1212const user = session.user
1313const authPending = session.stale
resources/views/forgot.stxmodified+2-2
Changes to resources/views/forgot.stx
@@ -4,9 +4,9 @@
44// Renders for a stranger, like /login. The response never says whether the
55// address has an account, so this page cannot be used to find out who is a
66// customer; the confirmation below is deliberately the same either way.
7import { sessionCookieName, sessionFrom } from '../../app/Support/session'
7import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
88
9const session = await sessionFrom(cookies)
9const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1010const cookieName = sessionCookieName()
1111const signedIn = session.user !== null
1212</script>
resources/views/index.stxmodified+1-1
Changes to resources/views/index.stx
@@ -10,7 +10,7 @@
1010// The sample is stated as a sample. Numbers on a marketing page that imply a
1111// real customer's business are a lie with a chart on it.
1212import { useSeoMeta } from 'stx'
13import { PLANS } from '../../app/Billing/limits'
13import { PLANS } from '../../../app/Billing/limits'
1414
1515const siteUrl = 'https://reportshq.org'
1616const siteDescription = 'Reports that run inside your own Stacks or Laravel application, against your own database. Install a package, point it at the models you already have, and the numbers never leave your servers.'
resources/views/login.stxmodified+2-2
Changes to resources/views/login.stx
@@ -5,9 +5,9 @@
55// AuthGuard and no app chrome. It does the opposite check instead: somebody who
66// already has a session is sent to their reports rather than shown a form
77// asking them to do something they have already done.
8import { sessionCookieName, sessionFrom } from '../../app/Support/session'
8import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
99
10const session = await sessionFrom(cookies)
10const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1111const cookieName = sessionCookieName()
1212const signedIn = session.user !== null
1313</script>
resources/views/pricing.stxmodified+1-1
Changes to resources/views/pricing.stx
@@ -9,7 +9,7 @@
99//
1010// If a limit changes, this page changes with it. Nothing here is typed twice.
1111import { useSeoMeta } from 'stx'
12import { PLANS, TIERS } from '../../app/Billing/limits'
12import { PLANS, TIERS } from '../../../app/Billing/limits'
1313
1414const siteUrl = 'https://reportshq.org'
1515const pricingDescription = 'Three plans, priced per installed application. Free covers one application with unlimited reports and no card. Your data never leaves your servers, and nothing here can stop a report rendering.'
resources/views/register.stxmodified+2-2
Changes to resources/views/register.stx
@@ -5,9 +5,9 @@
55// session. The only account this creates is the person's own; teammates arrive
66// through project invites instead, which is why there is no organisation step
77// here.
8import { sessionCookieName, sessionFrom } from '../../app/Support/session'
8import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
99
10const session = await sessionFrom(cookies)
10const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1111const cookieName = sessionCookieName()
1212const signedIn = session.user !== null
1313</script>
resources/views/reports/[slug].stxmodified+1-1
Changes to resources/views/reports/[slug].stx
@@ -15,7 +15,7 @@ import { elementHtml, License } from '@reportshq/stacks'
1515import { sessionFrom } from '../../../app/Support/session'
1616import { reportHandlers } from '../../../app/Support/reports'
1717
18const session = await sessionFrom(cookies)
18const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1919const user = session.user
2020const authPending = session.stale
2121
resources/views/reports/index.stxmodified+1-1
Changes to resources/views/reports/index.stx
@@ -10,7 +10,7 @@ import { sessionFrom } from '../../../app/Support/session'
1010import { reportHandlers } from '../../../app/Support/reports'
1111import { License } from '@reportshq/stacks'
1212
13const session = await sessionFrom(cookies)
13const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1414const user = session.user
1515const authPending = session.stale
1616
resources/views/reset.stxmodified+7-6
Changes to resources/views/reset.stx
@@ -4,18 +4,19 @@
44// The token and address come from the query string, which is where the email
55// put them. They are read here only to hand back to the API: the page never
66// decides whether a token is valid, because a page cannot be trusted to.
7import { param, sessionCookieName, sessionFrom } from '../../app/Support/session'
7import { param, sessionCookieName, sessionFrom } from '../../../app/Support/session'
88
9const session = await sessionFrom(cookies)
9const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1010const cookieName = sessionCookieName()
1111// Deliberately not bounced when signed in. Somebody resetting a password may
1212// well have a stale session open in another tab, and that is the case they are
1313// most likely trying to fix.
1414const signedIn = false
1515
16const token = param(query, 'token') ?? ''
17const email = param(query, 'email') ?? ''
18const usable = token !== '' && email !== ''
16const pageQuery = typeof query !== 'undefined' ? query : undefined
17const __resetToken = param(pageQuery, 'token') ?? ''
18const email = param(pageQuery, 'email') ?? ''
19const usable = __resetToken !== '' && email !== ''
1920</script>
2021
2122<template>
@@ -44,7 +45,7 @@ const usable = token !== '' && email !== ''
4445 submits. The script intercepts it; without the script it still
4546 posts somewhere sensible rather than nowhere. --}}
4647 <form id="signin" method="post" action="/api/auth/reset" class="grid gap-4" @if (!usable) hidden @endif>
47 <input id="token" type="hidden" value="" />
48 <input id="token" type="hidden" value="" />
4849 <input id="email" type="hidden" value="async function email() { return (await authUser())?.email; }" />
4950
5051 <label class="block">
scripts/smoke-deployment.tsadded+63-0
Changes to scripts/smoke-deployment.ts
@@ -0,0 +1,63 @@
1import { readFileSync } from 'node:fs'
2import { resolve } from 'node:path'
3
4interface SmokeContract {
5 domain: string
6 homeText: string
7}
8
9const CONTRACTS: Record<string, SmokeContract> = {
10 analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' },
11 bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' },
12 commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' },
13 loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' },
14 reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' },
15 status: { domain: 'statushq.org', homeText: 'Know the moment' },
16}
17
18const projectRoot = resolve(import.meta.dir, '..')
19const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
20const packageName = String(packageJson.name || '')
21const directoryName = projectRoot.split('/').pop() || ''
22const appName = CONTRACTS[packageName] ? packageName : directoryName
23const contract = CONTRACTS[appName]
24
25if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`)
26
27const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '')
28const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10))
29const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000))
30
31async function readWithRetry(path: string): Promise<string> {
32 let lastFailure = 'no response'
33 for (let attempt = 1; attempt <= attempts; attempt += 1) {
34 try {
35 const separator = path.includes('?') ? '&' : '?'
36 const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, {
37 headers: { accept: path === '/robots.txt' ? 'text/plain' : 'text/html' },
38 redirect: 'follow',
39 signal: AbortSignal.timeout(10_000),
40 })
41 const body = await response.text()
42 if (response.ok) return body
43 lastFailure = `HTTP ${response.status}`
44 }
45 catch (error) {
46 lastFailure = error instanceof Error ? error.message : String(error)
47 }
48 if (attempt < attempts) await Bun.sleep(pauseMs)
49 }
50 throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`)
51}
52
53const home = await readWithRetry('/')
54if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`)
55
56const login = await readWithRetry('/login')
57if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.')
58
59const robots = await readWithRetry('/robots.txt')
60if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
61if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
62
63console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`)
scripts/validate-release.tsadded+199-0
Changes to scripts/validate-release.ts
@@ -0,0 +1,199 @@
1import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2import { extname, join, resolve } from 'node:path'
3
4interface AppContract {
5 domain: string
6 minPages: number
7 routes: Record<string, string[]>
8}
9
10const CONTRACTS: Record<string, AppContract> = {
11 analyticshq: {
12 domain: 'analyticshq.org',
13 minPages: 30,
14 routes: {
15 '/': ['analyticshq'],
16 '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'],
17 '/login': ['analyticshq'],
18 },
19 },
20 bughq: {
21 domain: 'bughq.org',
22 minPages: 25,
23 routes: {
24 '/': ['Error tracking for people who ship.', 'From throw to triaged'],
25 '/login': ['bughq'],
26 '/dashboard': ['bughq'],
27 },
28 },
29 commshq: {
30 domain: 'commshq.org',
31 minPages: 45,
32 routes: {
33 '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'],
34 '/login': ['CommsHQ'],
35 '/dashboard/commshq': ['CommsHQ'],
36 },
37 },
38 loghq: {
39 domain: 'loghq.org',
40 minPages: 30,
41 routes: {
42 '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'],
43 '/login': ['loghq'],
44 '/compare/sentry': ['loghq', 'Sentry'],
45 },
46 },
47 reportshq: {
48 domain: 'reportshq.org',
49 minPages: 20,
50 routes: {
51 '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'],
52 '/login': ['ReportsHQ'],
53 '/pricing': ['ReportsHQ'],
54 },
55 },
56 status: {
57 domain: 'statushq.org',
58 minPages: 35,
59 routes: {
60 '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'],
61 '/login': ['Welcome back'],
62 '/register': ['Create your account'],
63 },
64 },
65}
66
67const projectRoot = resolve(import.meta.dir, '..')
68const distRoot = resolve(projectRoot, 'dist')
69const ssrRoot = resolve(projectRoot, '.output')
70const ssrPages = resolve(ssrRoot, 'server/pages')
71const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
72const packageName = String(packageJson.name || '')
73const directoryName = projectRoot.split('/').pop() || ''
74const appName = CONTRACTS[packageName] ? packageName : directoryName
75const contract = CONTRACTS[appName]
76
77if (!contract)
78 throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`)
79
80const isSsr = existsSync(ssrPages)
81if (!existsSync(distRoot) && !isSsr)
82 throw new Error('No production artifact found. Run bun run build first.')
83
84function walkFiles(directory: string): string[] {
85 if (!existsSync(directory)) return []
86 const files: string[] = []
87 for (const entry of readdirSync(directory)) {
88 const path = join(directory, entry)
89 if (statSync(path).isDirectory()) files.push(...walkFiles(path))
90 else files.push(path)
91 }
92 return files
93}
94
95function routeArtifact(route: string): { html: string, source: string } | undefined {
96 if (!isSsr) {
97 const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`)
98 const nested = join(distRoot, route.slice(1), 'index.html')
99 const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined
100 return source ? { html: readFileSync(source, 'utf8'), source } : undefined
101 }
102
103 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
104 const source = join(ssrPages, `${name}.compiled.json`)
105 if (!existsSync(source)) return undefined
106 const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string }
107 return { html: compiled.html || '', source }
108}
109
110const pageFiles = isSsr
111 ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json'))
112 : walkFiles(distRoot).filter(path => path.endsWith('.html'))
113
114const failures: string[] = []
115if (pageFiles.length < contract.minPages)
116 failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`)
117
118const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot
119for (const [route, expectedText] of Object.entries(contract.routes)) {
120 const artifact = routeArtifact(route)
121 if (!artifact) {
122 failures.push(`${route}: production artifact is missing`)
123 continue
124 }
125
126 const withoutCode = artifact.html
127 .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
128 .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
129 .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ')
130 const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
131
132 for (const fragment of expectedText) {
133 if (!visibleText.includes(fragment))
134 failures.push(`${route}: expected text is missing: ${fragment}`)
135 }
136 if (/@(if|foreach|forelse)\b/.test(visibleText))
137 failures.push(`${route}: unresolved template syntax is visible`)
138 if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode))
139 failures.push(`${route}: an empty primary heading was emitted`)
140 if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html))
141 failures.push(`${route}: a secret-shaped assignment was emitted`)
142
143 const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi
144 let match: RegExpExecArray | null
145 while ((match = assetPattern.exec(artifact.html))) {
146 const assetPath = match[1]
147 const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/')
148 if (!looksLikeAsset) continue
149 const local = resolve(publicRoot, `.${assetPath}`)
150 if (!local.startsWith(`${publicRoot}/`) || !existsSync(local))
151 failures.push(`${route}: referenced asset is missing: ${assetPath}`)
152 }
153}
154
155for (const metadataFile of ['sitemap.xml', 'robots.txt']) {
156 const path = join(publicRoot, metadataFile)
157 if (!existsSync(path)) {
158 failures.push(`${metadataFile}: missing from production artifact`)
159 continue
160 }
161 const contents = readFileSync(path, 'utf8')
162 if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`)
163 if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`)
164}
165
166const migrationsDir = resolve(projectRoot, 'database/migrations')
167if (existsSync(migrationsDir)) {
168 for (const file of readdirSync(migrationsDir).sort()) {
169 const path = join(migrationsDir, file)
170 if (!statSync(path).isFile()) continue
171 if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`)
172 const sql = readFileSync(path, 'utf8')
173 if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`)
174 }
175}
176
177if (isSsr && failures.length === 0) {
178 const { startProductionServer } = await import('@stacksjs/stx')
179 const port = 42_000 + Math.floor(Math.random() * 8_000)
180 const server = await startProductionServer({ outputDir: ssrRoot, port })
181 try {
182 for (const route of Object.keys(contract.routes)) {
183 const response = await fetch(`http://127.0.0.1:${server.port}${route}`)
184 const body = await response.text()
185 if (!response.ok) failures.push(`${route}: production server returned ${response.status}`)
186 if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`)
187 }
188 }
189 finally {
190 server.stop()
191 }
192}
193
194if (failures.length) {
195 for (const failure of failures) console.error(`- ${failure}`)
196 process.exit(1)
197}
198
199console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`)
site.config.tsadded+17-0
Changes to site.config.ts
@@ -0,0 +1,17 @@
1const description = 'Build, schedule, export, and securely share reports from the data sources your team already uses.'
2
3export default {
4 name: 'ReportsHQ',
5 url: 'https://reportshq.org',
6 description,
7 seo: {
8 siteName: 'ReportsHQ',
9 title: 'ReportsHQ - Reports without the busywork',
10 description,
11 image: 'https://reportshq.org/og.png',
12 favicon: '/favicon.svg',
13 locale: 'en_US',
14 type: 'website',
15 },
16 pages: {},
17}