ReviewOS

also looking at this

stacks/analyticshq

Harden AnalyticsHQ release and dashboard quality

#52
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
34 files +1236 -340

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+6-4
Changes to .github/workflows/ci.yml
@@ -201,7 +201,7 @@ jobs:
201201
202202 deploy-prod:
203203 if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.release-check.outputs.contains_release == '0'
204 needs: [secrets, lint, typecheck, test, release-check]
204 needs: [secrets, lint, typecheck, test, release-check, build]
205205 runs-on: ubuntu-latest
206206 environment: production
207207
@@ -250,7 +250,7 @@ jobs:
250250
251251 deploy-stage:
252252 if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-beta.'))
253 needs: [secrets, lint, typecheck, test]
253 needs: [secrets, lint, typecheck, test, build]
254254 runs-on: ubuntu-latest
255255 environment: staging
256256
@@ -297,7 +297,7 @@ jobs:
297297
298298 deploy-dev:
299299 if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.base_ref == 'main')
300 needs: [secrets, lint, typecheck, test]
300 needs: [secrets, lint, typecheck, test, build]
301301 runs-on: ubuntu-latest
302302 environment: development
303303
@@ -343,7 +343,6 @@ jobs:
343343 run: bun buddy deploy development --yes
344344
345345 build:
346 if: github.event_name == 'pull_request'
347346 runs-on: ubuntu-latest
348347
349348 steps:
@@ -378,5 +377,8 @@ jobs:
378377 - name: Validate responsive visual contracts
379378 run: bun run visual:validate
380379
380 - name: Validate production release artifact
381 run: bun run release:validate
382
381383 - name: Verify generated files are current
382384 run: git diff --exit-code
.github/workflows/deploy.ymlmodified+10-0
Changes to .github/workflows/deploy.yml
@@ -64,6 +64,12 @@ jobs:
6464 - name: Install dependencies
6565 run: bun install --frozen-lockfile
6666
67 - name: Build the production artifact
68 run: bun run build
69
70 - name: Validate the production artifact
71 run: bun run release:validate
72
6773 # IP geolocation database (DB-IP Lite, CC BY 4.0). Fetched here rather than
6874 # committed: an ~8MB binary on a monthly cadence does not belong in git
6975 # history. It ships to the box with the rest of the working tree.
@@ -152,3 +158,7 @@ jobs:
152158 PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }}
153159 PORKBUN_SECRET_KEY: ${{ secrets.PORKBUN_SECRET_KEY }}
154160 COPYFILE_DISABLE: '1'
161
162 - name: Smoke test production
163 if: needs.determine-environment.outputs.environment == 'production'
164 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": "./buddy build",
3840 "deploy": "./buddy deploy",
3941 "export:site": "bun scripts/analytics/export-site.ts",
resources/views/account.stxmodified+2-2
Changes to resources/views/account.stx
@@ -87,7 +87,7 @@ onMount(() => {
8787
8888{{-- Tokens, body and .wordmark now come from resources/layouts/app.stx, which carries
8989 the superset of what this page and the dashboard each had. What stays is what only
90 this page uses. It is pushed rather than left loose because a loose <style> under a
90 this page uses. It is pushed rather than left loose because a loose style block under a
9191 layout gets swept out of the page and appended after the content, in the body. --}}
9292@push('styles')
9393<style>
@@ -118,7 +118,7 @@ onMount(() => {
118118 </div>
119119 </header>
120120
121 {{-- A <div>, not <main>: the layout owns the single router container. Same classes. --}}
121 {{-- A div rather than another main: the layout owns the single router container. Same classes. --}}
122122 <div class="mx-auto px-5 py-8 md:px-6 max-w-3xl">
123123 <h1 class="mb-6 text-2xl wordmark">Account</h1>
124124
resources/views/compare/cloudflare.stxmodified+1-1
Changes to resources/views/compare/cloudflare.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.cloudflare
55const crumbs = crumbsFor(c)
resources/views/compare/fathom.stxmodified+1-1
Changes to resources/views/compare/fathom.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.fathom
55const crumbs = crumbsFor(c)
resources/views/compare/google-analytics.stxmodified+1-1
Changes to resources/views/compare/google-analytics.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors['google-analytics']
55const crumbs = crumbsFor(c)
resources/views/compare/matomo.stxmodified+1-1
Changes to resources/views/compare/matomo.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.matomo
55const crumbs = crumbsFor(c)
resources/views/compare/mixpanel.stxmodified+1-1
Changes to resources/views/compare/mixpanel.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.mixpanel
55const crumbs = crumbsFor(c)
resources/views/compare/plausible.stxmodified+1-1
Changes to resources/views/compare/plausible.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.plausible
55const crumbs = crumbsFor(c)
resources/views/compare/simple-analytics.stxmodified+1-1
Changes to resources/views/compare/simple-analytics.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors['simple-analytics']
55const crumbs = crumbsFor(c)
resources/views/compare/umami.stxmodified+1-1
Changes to resources/views/compare/umami.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.umami
55const crumbs = crumbsFor(c)
resources/views/dashboard.stxmodified+25-21
Changes to resources/views/dashboard.stx
@@ -280,7 +280,7 @@ const regionGeoAllowed = privacy.geo.granularity === 'region'
280280let shareMode = false
281281// The owner's existing share token for the active site ('' if none), so the owner
282282// UI can show/copy/revoke the link. Bridged into the client block.
283let shareToken = ''
283let __shareToken = ''
284284
285285// Race all DB work against a short timeout: if PostgreSQL isn't reachable, a
286286// TCP connect can hang far longer than a render should wait, so we bail to the
@@ -383,7 +383,7 @@ async function loadData() {
383383 || null
384384 // Owner (not a share viewer): surface any existing share token for the link UI.
385385 if (!shareMode)
386 shareToken = (await readSettings(siteId)).share_token || ''
386 __shareToken = (await readSettings(siteId)).share_token || ''
387387
388388 // Team: who can already reach this site, and who has been invited but has not
389389 // accepted yet. Resolved server-side and rendered with @foreach so the panel
@@ -1125,7 +1125,11 @@ const exportPayload = {
11251125 * because the snippets are built from server config but can only be *finished*
11261126 * on the client, which is the one thing that knows the real origin.
11271127 */
1128defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareToken })
1128// This capability is intentionally available only to the authenticated owner
1129// UI so it can display the existing share link. The underlying server binding
1130// stays private and only this exact scalar crosses the bridge.
1131const shareCapability = __shareToken
1132defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareCapability })
11291133
11301134useSeoMeta({
11311135 title: "Dashboard - analyticshq",
@@ -1144,12 +1148,12 @@ useSeoMeta({
11441148const goalError = state('')
11451149const goalBusy = state(false)
11461150const isPro = state(false)
1147// Share link: seeded from the server-bridged existing token (shareToken/activeSiteId
1151// Share link: seeded from the server-bridged existing capability and activeSiteId
11481152// are server consts inlined here by the data bridge), then create/revoke live.
11491153// Built by shareLinkFor rather than by a second copy of the same template -- it is
11501154// declared further down and reached by hoisting, which is the trade for having one
11511155// place that decides what a share URL looks like.
1152const shareUrl = state(shareToken ? shareLinkFor(shareToken) : '')
1156const shareUrl = state(shareCapability ? shareLinkFor(shareCapability) : '')
11531157const shareBusy = state(false)
11541158const shareCopied = state(false)
11551159// Live "current visitors": seeded from the server render, then polled so the
@@ -2051,7 +2055,7 @@ onMount(() => {
20512055{{-- The token block, body and .wordmark that stood here were byte-identical to
20522056 account.stx's and now live once in resources/layouts/app.stx. What remains is what
20532057 only the dashboard uses. It is pushed rather than left loose: under a layout stx
2054 sweeps every orphan <style> out of the page and appends it AFTER the content, in the
2058 sweeps every orphan style block out of the page and appends it AFTER the content, in the
20552059 body, so a loose block would still apply but from the wrong place. --}}
20562060@push('styles')
20572061{{-- ts-maps' own stylesheet. Same-origin from /vendor like the engine beside it
@@ -2343,9 +2347,9 @@ select.panel:hover { border-color: var(--accent); }
23432347</style>
23442348@endpush
23452349
2346{{-- A <div>, not <main>. The router container is the single <main> in
2347 resources/layouts/app.stx: fragment extraction slices from the first <main> to the
2348 LAST </main>, so a page that declares its own makes the router swap the wrong span.
2350{{-- A div rather than another main. The router container is owned by
2351 resources/layouts/app.stx: fragment extraction uses that single container,
2352 so a page that declares its own makes the router swap the wrong span.
23492353 Same classes as before, one tag name different. --}}
23502354@section('content')
23512355<div class="px-4 py-7 md:px-8 lg:px-10 min-h-[100dvh]">
@@ -2503,7 +2507,7 @@ select.panel:hover { border-color: var(--accent); }
25032507
25042508 The tab strip is x-for over the server-built catalogue, and the body is
25052509 reactive because the origin can only be resolved in the browser — see the
2506 installCode() note in the client block. {{ }} HTML-escapes the snippet so
2510 installCode() note in the client block. Server interpolation HTML-escapes the snippet so
25072511 the tag shows as text rather than executing. --}}
25082512 @if (ownsActive)
25092513 <section class="border-border panel site-settings-panel">
@@ -2514,7 +2518,7 @@ select.panel:hover { border-color: var(--accent); }
25142518 <div class="pb-5 px-5">
25152519 {{-- Tabs and prose are SERVER-rendered (@foreach, static labels, server
25162520 mustaches) so this panel still says something useful before the
2517 bundle runs. An x-for with a reactive {{ }} label cloaked the whole
2521 bundle runs. An x-for with a reactive text label cloaked the whole
25182522 body — x-cloak is display:none, so a new user with nothing to look
25192523 at but install instructions got a blank box. Only the code block
25202524 below is reactive, and only because the origin lives in the
@@ -2553,7 +2557,7 @@ select.panel:hover { border-color: var(--accent); }
25532557
25542558 Lists are SERVER-rendered with @foreach and server mustaches, for the
25552559 reason spelled out on the install panel above: an x-for whose rows carry
2556 reactive {{ }} makes stx cloak the container, and x-cloak is display:none.
2560 reactive text makes stx cloak the container, and x-cloak is display:none.
25572561 A mutation re-reads from the server rather than patching a reactive list
25582562 in place — invites are rare and correctness is worth more than avoiding a
25592563 reload here.
@@ -2620,8 +2624,8 @@ select.panel:hover { border-color: var(--accent); }
26202624 <option value="viewer">can view</option>
26212625 <option value="admin">can manage</option>
26222626 </select>
2623 {{-- Static label. It was ``, and stx cloaks any element whose own text is
2627 {{-- Static label. It used to choose between Sending and Send
2628 invitation reactively, and stx cloaks any element whose own text is
26252629 a reactive mustache — so the primary control on this panel was
26262630 display:none until the bundle ran. Busy state is carried by
26272631 :disabled, an ATTRIBUTE binding, which does not cloak, and by
@@ -2866,7 +2870,7 @@ select.panel:hover { border-color: var(--accent); }
28662870 @endif
28672871
28682872 {{-- Share control (owner-only): reactive create/copy/revoke of a read-only link.
2869 Signals live in the <script client> block; shareUrl is seeded from the
2873 Signals live in the client script block; shareUrl is seeded from the
28702874 server-bridged existing token so the current link shows on load. --}}
28712875 @if (ownsActive)
28722876 <div class="p-5 panel site-settings-panel">
@@ -3171,9 +3175,9 @@ select.panel:hover { border-color: var(--accent); }
31713175 that way. The map is drawn into an empty div by a plain module script, so
31723176 nothing in this subtree is reactive and nothing can be hidden.
31733177
3174 A <div>, not <main>: the router's fragment extraction slices from the
3175 first <main> to the last </main>, so a second one makes the SPA swap the
3176 wrong span.
3178 This remains a div because the router's fragment extraction expects the
3179 one main owned by the layout. A second one makes the SPA swap the wrong
3180 span.
31773181
31783182 The bar list below is NOT redundant. A browser-side map is blank until
31793183 the bundle runs, and it is the only thing a reader gets with JS off, on a
@@ -3257,7 +3261,7 @@ select.panel:hover { border-color: var(--accent); }
32573261
32583262 {{-- Top referrers: the actual referring URLs (full referrer), a drill-down beneath the
32593263 normalized Top sources / channels. Clickable to filter; excludes empty/direct traffic.
3260 {{ }} escapes the (beacon-supplied) url. --}}
3264 Server interpolation escapes the beacon-supplied URL. --}}
32613265 <div class="mt-6 p-5 panel reveal">
32623266 <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Referrers</h2>
32633267 @forelse (referrerUrls as r)
@@ -3341,7 +3345,7 @@ select.panel:hover { border-color: var(--accent); }
33413345
33423346 {{-- Top outbound links / file downloads: the URLs behind the auto-tracked Outbound
33433347 Link / File Download events (properties.url). GROUP BY properties (dialect-portable,
3344 no JSON_EXTRACT); site+range scoped like Events. Display-only; {{ }} escapes the url. --}}
3348 no JSON_EXTRACT); site+range scoped like Events. Server interpolation escapes the URL. --}}
33453349 <div class="grid gap-6 md:grid-cols-2 mt-6">
33463350 <div class="p-5 panel reveal">
33473351 <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Outbound links</h2>
@@ -3433,7 +3437,7 @@ select.panel:hover { border-color: var(--accent); }
34333437{{-- Pre-render token guard: send a signed-out visitor to /login before paint.
34343438
34353439 This USED to also mirror the localStorage token into the analyticshq_token cookie
3436 and reload once, so the <script server> block could owner-scope the switcher and
3440 and reload once, so the server script block could owner-scope the switcher and
34373441 setup panel. Both halves are gone (#33): stacks 0.70.369 has LoginAction set the
34383442 cookie in its own response, so it is present on the very first authenticated
34393443 document request — and the cookie is HttpOnly, so this block could not have written
resources/views/invite.stxmodified+4-4
Changes to resources/views/invite.stx
@@ -89,11 +89,11 @@ const roleLabel = invite?.role === 'admin' ? 'view the reports and change settin
8989// Round-trips the reader back here after they sign in or register.
9090const returnTo = `/invite?token=${encodeURIComponent(token)}`
9191
92// The token is the only thing that crosses. Everything else this block resolved
92// The capability is the only thing that crosses. Everything else this block resolved
9393// — the invite row, the viewer, the refusal — stays server-side: the client has
9494// no decision to make, it only posts the token back for the server to re-check.
95const inviteToken = token
96defineClientPayload({ inviteToken })
95const inviteCapability = token
96defineClientPayload({ inviteCapability })
9797</script>
9898
9999<script client>
@@ -110,7 +110,7 @@ async function accept(): Promise<void> {
110110 const res = await session.authFetch('/api/invites/accept', {
111111 method: 'POST',
112112 headers: { 'content-type': 'application/json' },
113 body: JSON.stringify({ token: inviteToken }),
113 body: JSON.stringify({ token: inviteCapability }),
114114 })
115115 const d = await res.json().catch(() => ({}))
116116 if (res.ok && d.siteId) {
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'}.`)