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+3-0
Changes to .github/workflows/ci.yml
@@ -203,5 +203,8 @@ jobs:
203203 - name: Validate responsive visual contracts
204204 run: bun run visual:validate
205205
206 - name: Validate production release artifact
207 run: bun run release:validate
208
206209 - name: Verify generated files are current
207210 run: git diff --exit-code
.github/workflows/deploy.ymlmodified+7-3
Changes to .github/workflows/deploy.yml
@@ -34,9 +34,6 @@ jobs:
3434 # promoted, and it costs about a minute. Without it a promotion carrying a
3535 # broken commit deploys, and nothing ever says so.
3636 #
37 # Deliberately not lint or build: neither changes what the box serves. This
38 # gate is about correctness, and the "does it actually serve" half is asserted
39 # against the live site at the end of the deploy job.
4037 gate:
4138 runs-on: ubuntu-latest
4239 steps:
@@ -55,6 +52,10 @@ jobs:
5552 run: bunx --bun tsc --noEmit
5653 - name: Test
5754 run: bun run test
55 - name: Build the production artifact
56 run: bun run build
57 - name: Validate the production artifact
58 run: bun run release:validate
5859
5960 deploy:
6061 needs: gate
@@ -327,3 +328,6 @@ jobs:
327328 || { echo "::error::ingest did not reject a bogus key with 'unknown project'"; exit 1; }
328329
329330 echo "== all deploy verifications passed =="
331
332 - name: Verify public metadata
333 run: bun run deployment:smoke
package.jsonmodified+2-0
Changes to package.json
@@ -34,6 +34,8 @@
3434 "docs:validate": "bun scripts/validate-docs.ts",
3535 "ui:validate": "bun scripts/validate-ui.ts",
3636 "visual:validate": "bun scripts/validate-visual.ts",
37 "release:validate": "bun scripts/validate-release.ts",
38 "deployment:smoke": "bun scripts/smoke-deployment.ts",
3739 "prod": "bun scripts/build-app.ts",
3840 "deploy": "./buddy deploy",
3941 "lint": "./buddy lint",
resources/components/.gitkeepaddedformatting only+1-0
Changes to resources/components/.gitkeep
@@ -0,0 +1,1 @@
1
resources/partials/SiteFooter.stxmodified+1-2
Changes to resources/partials/SiteFooter.stx
@@ -7,7 +7,7 @@
77// Each partial owns its own copy: a partial's scope does not reach across
88// includes, and this is one boolean rather than a signal plus an onMount plus an
99// effect.
10const footerAuthed = !!cookies.loghq_token
10const footerAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token
1111</script>
1212
1313<footer class="footer shell">
@@ -74,4 +74,3 @@ const footerAuthed = !!cookies.loghq_token formatting only
7474 <span>Open source log streaming, built on Stacks and Postgres.</span>
7575 </div>
7676</footer>
77
resources/partials/SiteNav.stxmodified+1-2
Changes to resources/partials/SiteNav.stx
@@ -24,7 +24,7 @@
2424// marketing page changes auth state in place: signing in navigates, and
2525// signOut() does a declared full load, so the next render is server-truth
2626// anyway.
27const navAuthed = !!cookies.loghq_token
27const navAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token
2828</script>
2929
3030{{-- Do NOT write a template comment's opening marker as prose anywhere in this
@@ -115,4 +115,3 @@ const navAuthed = !!cookies.loghq_token formatting only
115115 </details>
116116 </div>
117117</nav>
118
resources/views/analytics.stxmodified+3-3
Changes to resources/views/analytics.stx
@@ -13,7 +13,7 @@ import { archiveConfig } from '../../app/Archive/config'
1313import { projectPlan } from '../../app/Archive/plan'
1414import { archiveSummary, levelsByRelease, volumeSeries } from '../../app/Archive/query'
1515
16const query = new URLSearchParams(__stxServeSearch || '')
16const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
1717
1818// Whitelisted, like the dashboard's ranges: the key comes from the URL and only
1919// the mapped day count is ever used in a query.
@@ -34,9 +34,9 @@ const RANGES: AnalyticsRange[] = [
3434const activeRange = RANGES.find(r => r.key === query.get('range')) ?? DEFAULT_RANGE
3535
3636const requestedProject = query.get('project')
37const cookieProject = cookies.loghq_project ?? ''
37const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : ''
3838
39const __token = cookies.loghq_token ?? ''
39const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
4040let user: AuthUser | null = null
4141if (__token) {
4242 try {
resources/views/dashboard.stxmodified+3-3
Changes to resources/views/dashboard.stx
@@ -38,7 +38,7 @@ import { utcIsoHoursAgo } from '../../app/Support/time'
3838// the serve path immediately before the block runs — not a global to reach
3939// through globalThis for. Reading it bare has no ordering requirement and no
4040// cross-request global to race on.
41const query = new URLSearchParams(__stxServeSearch || '')
41const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
4242
4343// Level filter is a MINIMUM-severity threshold: the key names the floor, and we
4444// expand it to the set of levels at or above it. Whitelisted — the raw param is
@@ -119,7 +119,7 @@ const activeRange = RANGES.find(r => r.key === rangeParam) ?? DEFAULT_RANGE
119119const rangeFilter = activeRange.key
120120
121121const requestedProject = query.get('project')
122const cookieProject = cookies.loghq_project ?? ''
122const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : ''
123123
124124const PAGE_SIZE = 50
125125const pageParam = Number.parseInt(query.get('page') || '1', 10)
@@ -135,7 +135,7 @@ const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? pageParam :
135135// put the raw session credential in the page source of every /dashboard render,
136136// because `useStore('session').token()` appears in the client block below. See
137137// the fuller note in settings.stx.
138const __token = cookies.loghq_token ?? ''
138const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
139139let user: AuthUser | null = null
140140if (__token) {
141141 try {
resources/views/fix.stxmodified+11-8
Changes to resources/views/fix.stx
@@ -23,10 +23,10 @@ useSeoMeta({
2323 robots: 'noindex, nofollow',
2424})
2525
26const query = new URLSearchParams(__stxServeSearch || '')
26const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
2727const logId = (query.get('log') || '').slice(0, 255)
2828
29const __token = cookies.loghq_token ?? ''
29const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
3030let __user: any = null
3131if (__token) {
3232 try {
@@ -124,6 +124,9 @@ function stampOf(value: unknown): string {
124124}
125125
126126const analyzedAt = stampOf(run?.completed_at)
127const analysisSuspects = Array.isArray(analysis?.suspects) ? analysis.suspects : []
128const analysisSteps = Array.isArray(analysis?.steps) ? analysis.steps : []
129const analysisVerification = Array.isArray(analysis?.verification) ? analysis.verification : []
127130
128131// The page handles its own POST (stx#1847). The analysis is one model call, so
129132// it completes inside this request rather than needing a queue and a poller -
@@ -240,10 +243,10 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri
240243 <p class="leading-relaxed text-muted text-sm">{{ analysis.rootCause }}</p>
241244 </div>
242245
243 @if (analysis.suspects && analysis.suspects.length)
246 @if (analysisSuspects.length)
244247 <div class="px-5 py-4 border-b border-line">
245248 <div class="mb-2.5 font-semibold text-subtle text-xs tracking-wider uppercase">Where to look</div>
246 @foreach (analysis.suspects as suspect)
249 @foreach (analysisSuspects as suspect)
247250 <div class="mb-2.5 last:mb-0">
248251 <div class="font-mono text-ink text-xs tnum">{{ suspect.location }}</div>
249252 <div class="mt-0.5 text-muted text-sm">{{ suspect.reason }}</div>
@@ -252,11 +255,11 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri
252255 </div>
253256 @endif
254257
255 @if (analysis.steps && analysis.steps.length)
258 @if (analysisSteps.length)
256259 <div class="px-5 py-4 border-b border-line">
257260 <div class="mb-2.5 font-semibold text-subtle text-xs tracking-wider uppercase">Suggested fix</div>
258261 <ol>
259 @foreach (analysis.steps as step)
262 @foreach (analysisSteps as step)
260263 <li class="grid grid-cols-[1.5rem_1fr] mb-3 last:mb-0">
261264 <span class="font-mono text-subtle text-xs tnum">{{ loop.iteration }}.</span>
262265 <span>
@@ -276,11 +279,11 @@ const action = async ({ form, cookies: reqCookies }: { form: Record<string, stri
276279 </div>
277280 @endif
278281
279 @if (analysis.verification && analysis.verification.length)
282 @if (analysisVerification.length)
280283 <div class="px-5 py-4 border-b border-line">
281284 <div class="mb-2.5 font-semibold text-subtle text-xs tracking-wider uppercase">How to verify</div>
282285 <ul>
283 @foreach (analysis.verification as check)
286 @foreach (analysisVerification as check)
284287 <li class="mb-1.5 last:mb-0 text-muted text-sm">{{ check }}</li>
285288 @endforeach
286289 </ul>
resources/views/index.stxmodified+1-1
Changes to resources/views/index.stx
@@ -48,7 +48,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/' }] })
4848// This also settles ch.9.1. Both CTAs were plain `<a href>` carrying a `:href`
4949// binding, which is a same-origin navigation done as a full document load. They
5050// are `<StxLink>` now — one branch, one destination, no binding to override it.
51const pageAuthed = !!cookies.loghq_token
51const pageAuthed = typeof cookies !== 'undefined' && !!cookies.loghq_token
5252</script>
5353
5454<script client>
resources/views/login.stxmodified+1-1
Changes to resources/views/login.stx
@@ -31,7 +31,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/login' }] })
3131// provider. Read here rather than from location.search in a client block: it is
3232// server-known at render time, so reading it on the client meant the message
3333// could not exist until hydration.
34const oauthError = new URLSearchParams(__stxServeSearch || '').get('error') || ''
34const oauthError = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '').get('error') || ''
3535
3636// What the action merges back on a rejected submit, declared with its
3737// resting values.
resources/views/projects/index.stxmodified+1-1
Changes to resources/views/projects/index.stx
@@ -23,7 +23,7 @@ import { db as __db } from '@stacksjs/database'
2323// `__`-prefixed so the server→client bridge cannot publish it — see the note in
2424// settings.stx. This page does not leak today only because the word "token"
2525// happens not to appear in its client block; that is an accident, not a design.
26const __token = cookies.loghq_token ?? ''
26const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
2727let user: AuthUser | null = null
2828if (__token) {
2929 try {
resources/views/projects/new.stxmodified+2-2
Changes to resources/views/projects/new.stx
@@ -6,7 +6,7 @@ import { Auth } from '@stacksjs/auth'
66import { db as __db } from '@stacksjs/database'
77// Shared with routes/projects.ts so the two callers cannot drift on how an id
88// or an ingest key is minted.
9import { newIngestKey, newProjectId } from '../../../app/Support/projects'
9import { newIngestKey, newProjectId } from '../../app/Support/projects'
1010useSeoMeta({
1111 title: 'New app - loghq',
1212 description: 'Create an app to get an ingest key and start streaming logs.',
@@ -16,7 +16,7 @@ useSeoMeta({
1616// Server-side session check, replacing a client guard that navigated away
1717// after hydration. A logged-out visitor now gets an honest prompt in the first
1818// response instead of a create form that flashes and disappears.
19const authed = !!cookies.loghq_token
19const authed = typeof cookies !== 'undefined' && !!cookies.loghq_token
2020
2121// Resting values for what the action merges back.
2222let formError = ''
resources/views/register.stxmodified+1-1
Changes to resources/views/register.stx
@@ -25,7 +25,7 @@ useHead({ link: [{ rel: 'canonical', href: 'https://loghq.org/register' }] })
2525// It has to survive the POST too. The form posts to /register with no query
2626// string, so both values are carried as hidden inputs below — that is the
2727// no-JS equivalent of the client reading location.search on every submit.
28const __regQuery = new URLSearchParams(__stxServeSearch || '')
28const __regQuery = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
2929const isInvite = !!__regQuery.get('invite')
3030const invitedEmail = __regQuery.get('email') || ''
3131
resources/views/reset-password.stxmodified+1-1
Changes to resources/views/reset-password.stx
@@ -12,7 +12,7 @@ useSeoMeta({
1212// render. They are carried across the POST as hidden inputs, because the form
1313// posts to /reset-password with no query string and the token would otherwise
1414// be gone by the time the action runs.
15const __rpQuery = new URLSearchParams(__stxServeSearch || '')
15const __rpQuery = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
1616const linkToken = __rpQuery.get('token') || ''
1717const linkEmail = __rpQuery.get('email') || ''
1818
resources/views/settings.stxmodified+3-3
Changes to resources/views/settings.stx
@@ -54,9 +54,9 @@ const INGEST_BASE = ingestUrl()
5454//
5555// Deliberately not naming that global here: the ch.5.5 gate greps for it by
5656// name, and prose in a comment would keep the check permanently non-zero.
57const query = new URLSearchParams(__stxServeSearch || '')
57const query = new URLSearchParams(typeof __stxServeSearch !== 'undefined' ? __stxServeSearch : '')
5858const requestedProject = query.get('project')
59const cookieProject = cookies.loghq_project ?? ''
59const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : ''
6060
6161// ---------------------------------------------------------------------------
6262// THE `__` PREFIX IS A SECURITY BOUNDARY IN THIS FILE. READ BEFORE RENAMING.
@@ -81,7 +81,7 @@ const cookieProject = cookies.loghq_project ?? ''
8181// Renaming a `__` binding to a bare one re-opens the leak silently, with no lint
8282// error and no test failure.
8383// ---------------------------------------------------------------------------
84const __token = cookies.loghq_token ?? ''
84const __token = typeof cookies !== 'undefined' ? (cookies.loghq_token ?? '') : ''
8585let user: AuthUser | null = null
8686if (__token) {
8787 try {
scripts/build-app.tsmodified+16-14
Changes to scripts/build-app.ts
@@ -1,10 +1,12 @@
11import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs'
22import { resolve } from 'node:path'
3import { buildForProduction } from '@stacksjs/stx'
34
45const projectRoot = resolve(import.meta.dir, '..')
5const compatibilityPath = resolve(projectRoot, 'pages')
6const resourcesRoot = resolve(projectRoot, 'resources')
7const compatibilityPath = resolve(resourcesRoot, 'pages')
68const viewsPath = resolve(projectRoot, 'resources/views')
7const relativeViewsPath = 'resources/views'
9const relativeViewsPath = 'views'
810
911let createdCompatibilityLink = false
1012
@@ -19,24 +21,24 @@ if (existsSync(compatibilityPath)) {
1921 }
2022}
2123else {
22 // The current SSR builder discovers only a root pages directory and does not
23 // honor ui.pagesDir. Keep the source layout canonical and expose it only while
24 // the framework compiles the application.
24 // The current SSR builder discovers only a pages directory beneath its root
25 // and does not honor ui.pagesDir. Building from resources preserves every
26 // view's relative imports; expose views as resources/pages only while the
27 // framework compiles the application.
2528 symlinkSync(relativeViewsPath, compatibilityPath, 'dir')
2629 createdCompatibilityLink = true
2730}
2831
2932try {
30 const build = Bun.spawn(['./buddy', 'build'], {
31 cwd: projectRoot,
32 stdin: 'inherit',
33 stdout: 'inherit',
34 stderr: 'inherit',
33 const result = await buildForProduction({
34 root: resourcesRoot,
35 outputDir: resolve(projectRoot, '.output'),
36 componentsDir: resolve(resourcesRoot, 'components'),
37 layoutsDir: resolve(resourcesRoot, 'layouts'),
38 partialsDir: resolve(resourcesRoot, 'partials'),
39 publicDir: resolve(projectRoot, 'public'),
3540 })
36 const exitCode = await build.exited
37
38 if (exitCode !== 0)
39 process.exitCode = exitCode
41 console.log(`Compiled ${result.pageCount} SSR routes to ${result.outputDir}.`)
4042}
4143finally {
4244 if (createdCompatibilityLink && existsSync(compatibilityPath))
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'}.`)