also looking at this
Harden AnalyticsHQ release and dashboard quality
#52
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.
| @@ -201,7 +201,7 @@ jobs: | ||
| 201 | 201 | |
| 202 | 202 | deploy-prod: |
| 203 | 203 | 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] | |
| 205 | 205 | runs-on: ubuntu-latest |
| 206 | 206 | environment: production |
| 207 | 207 | |
| @@ -250,7 +250,7 @@ jobs: | ||
| 250 | 250 | |
| 251 | 251 | deploy-stage: |
| 252 | 252 | 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] | |
| 254 | 254 | runs-on: ubuntu-latest |
| 255 | 255 | environment: staging |
| 256 | 256 | |
| @@ -297,7 +297,7 @@ jobs: | ||
| 297 | 297 | |
| 298 | 298 | deploy-dev: |
| 299 | 299 | 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] | |
| 301 | 301 | runs-on: ubuntu-latest |
| 302 | 302 | environment: development |
| 303 | 303 | |
| @@ -343,7 +343,6 @@ jobs: | ||
| 343 | 343 | run: bun buddy deploy development --yes |
| 344 | 344 | |
| 345 | 345 | build: |
| 346 | if: github.event_name == 'pull_request' | |
| 347 | 346 | runs-on: ubuntu-latest |
| 348 | 347 | |
| 349 | 348 | steps: |
| @@ -378,5 +377,8 @@ jobs: | ||
| 378 | 377 | - name: Validate responsive visual contracts |
| 379 | 378 | run: bun run visual:validate |
| 380 | 379 | |
| 380 | - name: Validate production release artifact | |
| 381 | run: bun run release:validate | |
| 382 | ||
| 381 | 383 | - name: Verify generated files are current |
| 382 | 384 | run: git diff --exit-code |
| @@ -64,6 +64,12 @@ jobs: | ||
| 64 | 64 | - name: Install dependencies |
| 65 | 65 | run: bun install --frozen-lockfile |
| 66 | 66 | |
| 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 | ||
| 67 | 73 | # IP geolocation database (DB-IP Lite, CC BY 4.0). Fetched here rather than |
| 68 | 74 | # committed: an ~8MB binary on a monthly cadence does not belong in git |
| 69 | 75 | # history. It ships to the box with the rest of the working tree. |
| @@ -152,3 +158,7 @@ jobs: | ||
| 152 | 158 | PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }} |
| 153 | 159 | PORKBUN_SECRET_KEY: ${{ secrets.PORKBUN_SECRET_KEY }} |
| 154 | 160 | COPYFILE_DISABLE: '1' |
| 161 | ||
| 162 | - name: Smoke test production | |
| 163 | if: needs.determine-environment.outputs.environment == 'production' | |
| 164 | run: bun run deployment:smoke | |
| @@ -34,6 +34,8 @@ | ||
| 34 | 34 | "docs:validate": "bun scripts/validate-docs.ts", |
| 35 | 35 | "ui:validate": "bun scripts/validate-ui.ts", |
| 36 | 36 | "visual:validate": "bun scripts/validate-visual.ts", |
| 37 | "release:validate": "bun scripts/validate-release.ts", | |
| 38 | "deployment:smoke": "bun scripts/smoke-deployment.ts", | |
| 37 | 39 | "prod": "./buddy build", |
| 38 | 40 | "deploy": "./buddy deploy", |
| 39 | 41 | "export:site": "bun scripts/analytics/export-site.ts", |
| @@ -87,7 +87,7 @@ onMount(() => { | ||
| 87 | 87 | |
| 88 | 88 | {{-- Tokens, body and .wordmark now come from resources/layouts/app.stx, which carries |
| 89 | 89 | 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 | |
| 91 | 91 | layout gets swept out of the page and appended after the content, in the body. --}} |
| 92 | 92 | @push('styles') |
| 93 | 93 | <style> |
| @@ -118,7 +118,7 @@ onMount(() => { | ||
| 118 | 118 | </div> |
| 119 | 119 | </header> |
| 120 | 120 | |
| 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. --}} | |
| 122 | 122 | <div class="mx-auto px-5 py-8 md:px-6 max-w-3xl"> |
| 123 | 123 | <h1 class="mb-6 text-2xl wordmark">Account</h1> |
| 124 | 124 | |
| @@ -280,7 +280,7 @@ const regionGeoAllowed = privacy.geo.granularity === 'region' | ||
| 280 | 280 | let shareMode = false |
| 281 | 281 | // The owner's existing share token for the active site ('' if none), so the owner |
| 282 | 282 | // UI can show/copy/revoke the link. Bridged into the client block. |
| 283 | let shareToken = '' | |
| 283 | let __shareToken = '' | |
| 284 | 284 | |
| 285 | 285 | // Race all DB work against a short timeout: if PostgreSQL isn't reachable, a |
| 286 | 286 | // TCP connect can hang far longer than a render should wait, so we bail to the |
| @@ -383,7 +383,7 @@ async function loadData() { | ||
| 383 | 383 | || null |
| 384 | 384 | // Owner (not a share viewer): surface any existing share token for the link UI. |
| 385 | 385 | if (!shareMode) |
| 386 | shareToken = (await readSettings(siteId)).share_token || '' | |
| 386 | __shareToken = (await readSettings(siteId)).share_token || '' | |
| 387 | 387 | |
| 388 | 388 | // Team: who can already reach this site, and who has been invited but has not |
| 389 | 389 | // accepted yet. Resolved server-side and rendered with @foreach so the panel |
| @@ -1125,7 +1125,11 @@ const exportPayload = { | ||
| 1125 | 1125 | * because the snippets are built from server config but can only be *finished* |
| 1126 | 1126 | * on the client, which is the one thing that knows the real origin. |
| 1127 | 1127 | */ |
| 1128 | defineClientPayload({ 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. | |
| 1131 | const shareCapability = __shareToken | |
| 1132 | defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareCapability }) | |
| 1129 | 1133 | |
| 1130 | 1134 | useSeoMeta({ |
| 1131 | 1135 | title: "Dashboard - analyticshq", |
| @@ -1144,12 +1148,12 @@ useSeoMeta({ | ||
| 1144 | 1148 | const goalError = state('') |
| 1145 | 1149 | const goalBusy = state(false) |
| 1146 | 1150 | const 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 | |
| 1148 | 1152 | // are server consts inlined here by the data bridge), then create/revoke live. |
| 1149 | 1153 | // Built by shareLinkFor rather than by a second copy of the same template -- it is |
| 1150 | 1154 | // declared further down and reached by hoisting, which is the trade for having one |
| 1151 | 1155 | // place that decides what a share URL looks like. |
| 1152 | const shareUrl = state(shareToken ? shareLinkFor(shareToken) : '') | |
| 1156 | const shareUrl = state(shareCapability ? shareLinkFor(shareCapability) : '') | |
| 1153 | 1157 | const shareBusy = state(false) |
| 1154 | 1158 | const shareCopied = state(false) |
| 1155 | 1159 | // Live "current visitors": seeded from the server render, then polled so the |
| @@ -2051,7 +2055,7 @@ onMount(() => { | ||
| 2051 | 2055 | {{-- The token block, body and .wordmark that stood here were byte-identical to |
| 2052 | 2056 | account.stx's and now live once in resources/layouts/app.stx. What remains is what |
| 2053 | 2057 | 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 | |
| 2055 | 2059 | body, so a loose block would still apply but from the wrong place. --}} |
| 2056 | 2060 | @push('styles') |
| 2057 | 2061 | {{-- ts-maps' own stylesheet. Same-origin from /vendor like the engine beside it |
| @@ -2343,9 +2347,9 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2343 | 2347 | </style> |
| 2344 | 2348 | @endpush |
| 2345 | 2349 | |
| 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. | |
| 2349 | 2353 | Same classes as before, one tag name different. --}} |
| 2350 | 2354 | @section('content') |
| 2351 | 2355 | <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); } | ||
| 2503 | 2507 | |
| 2504 | 2508 | The tab strip is x-for over the server-built catalogue, and the body is |
| 2505 | 2509 | 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 | |
| 2507 | 2511 | the tag shows as text rather than executing. --}} |
| 2508 | 2512 | @if (ownsActive) |
| 2509 | 2513 | <section class="border-border panel site-settings-panel"> |
| @@ -2514,7 +2518,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2514 | 2518 | <div class="pb-5 px-5"> |
| 2515 | 2519 | {{-- Tabs and prose are SERVER-rendered (@foreach, static labels, server |
| 2516 | 2520 | 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 | |
| 2518 | 2522 | body — x-cloak is display:none, so a new user with nothing to look |
| 2519 | 2523 | at but install instructions got a blank box. Only the code block |
| 2520 | 2524 | below is reactive, and only because the origin lives in the |
| @@ -2553,7 +2557,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2553 | 2557 | |
| 2554 | 2558 | Lists are SERVER-rendered with @foreach and server mustaches, for the |
| 2555 | 2559 | 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. | |
| 2557 | 2561 | A mutation re-reads from the server rather than patching a reactive list |
| 2558 | 2562 | in place — invites are rare and correctness is worth more than avoiding a |
| 2559 | 2563 | reload here. |
| @@ -2620,8 +2624,8 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2620 | 2624 | <option value="viewer">can view</option> |
| 2621 | 2625 | <option value="admin">can manage</option> |
| 2622 | 2626 | </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 | |
| 2625 | 2629 | a reactive mustache — so the primary control on this panel was |
| 2626 | 2630 | display:none until the bundle ran. Busy state is carried by |
| 2627 | 2631 | :disabled, an ATTRIBUTE binding, which does not cloak, and by |
| @@ -2866,7 +2870,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2866 | 2870 | @endif |
| 2867 | 2871 | |
| 2868 | 2872 | {{-- 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 | |
| 2870 | 2874 | server-bridged existing token so the current link shows on load. --}} |
| 2871 | 2875 | @if (ownsActive) |
| 2872 | 2876 | <div class="p-5 panel site-settings-panel"> |
| @@ -3171,9 +3175,9 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3171 | 3175 | that way. The map is drawn into an empty div by a plain module script, so |
| 3172 | 3176 | nothing in this subtree is reactive and nothing can be hidden. |
| 3173 | 3177 | |
| 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. | |
| 3177 | 3181 | |
| 3178 | 3182 | The bar list below is NOT redundant. A browser-side map is blank until |
| 3179 | 3183 | 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); } | ||
| 3257 | 3261 | |
| 3258 | 3262 | {{-- Top referrers: the actual referring URLs (full referrer), a drill-down beneath the |
| 3259 | 3263 | 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. --}} | |
| 3261 | 3265 | <div class="mt-6 p-5 panel reveal"> |
| 3262 | 3266 | <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Referrers</h2> |
| 3263 | 3267 | @forelse (referrerUrls as r) |
| @@ -3341,7 +3345,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3341 | 3345 | |
| 3342 | 3346 | {{-- Top outbound links / file downloads: the URLs behind the auto-tracked Outbound |
| 3343 | 3347 | 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. --}} | |
| 3345 | 3349 | <div class="grid gap-6 md:grid-cols-2 mt-6"> |
| 3346 | 3350 | <div class="p-5 panel reveal"> |
| 3347 | 3351 | <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); } | ||
| 3433 | 3437 | {{-- Pre-render token guard: send a signed-out visitor to /login before paint. |
| 3434 | 3438 | |
| 3435 | 3439 | 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 | |
| 3437 | 3441 | setup panel. Both halves are gone (#33): stacks 0.70.369 has LoginAction set the |
| 3438 | 3442 | cookie in its own response, so it is present on the very first authenticated |
| 3439 | 3443 | document request — and the cookie is HttpOnly, so this block could not have written |
| @@ -89,11 +89,11 @@ const roleLabel = invite?.role === 'admin' ? 'view the reports and change settin | ||
| 89 | 89 | // Round-trips the reader back here after they sign in or register. |
| 90 | 90 | const returnTo = `/invite?token=${encodeURIComponent(token)}` |
| 91 | 91 | |
| 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 | |
| 93 | 93 | // — the invite row, the viewer, the refusal — stays server-side: the client has |
| 94 | 94 | // no decision to make, it only posts the token back for the server to re-check. |
| 95 | const inviteToken = token | |
| 96 | defineClientPayload({ inviteToken }) | |
| 95 | const inviteCapability = token | |
| 96 | defineClientPayload({ inviteCapability }) | |
| 97 | 97 | </script> |
| 98 | 98 | |
| 99 | 99 | <script client> |
| @@ -110,7 +110,7 @@ async function accept(): Promise<void> { | ||
| 110 | 110 | const res = await session.authFetch('/api/invites/accept', { |
| 111 | 111 | method: 'POST', |
| 112 | 112 | headers: { 'content-type': 'application/json' }, |
| 113 | body: JSON.stringify({ token: inviteToken }), | |
| 113 | body: JSON.stringify({ token: inviteCapability }), | |
| 114 | 114 | }) |
| 115 | 115 | const d = await res.json().catch(() => ({})) |
| 116 | 116 | if (res.ok && d.siteId) { |
| @@ -0,0 +1,63 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | ||
| 4 | interface SmokeContract { | |
| 5 | domain: string | |
| 6 | homeText: string | |
| 7 | } | |
| 8 | ||
| 9 | const 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 | ||
| 18 | const projectRoot = resolve(import.meta.dir, '..') | |
| 19 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 20 | const packageName = String(packageJson.name || '') | |
| 21 | const directoryName = projectRoot.split('/').pop() || '' | |
| 22 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 23 | const contract = CONTRACTS[appName] | |
| 24 | ||
| 25 | if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`) | |
| 26 | ||
| 27 | const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '') | |
| 28 | const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10)) | |
| 29 | const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000)) | |
| 30 | ||
| 31 | async 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 | ||
| 53 | const home = await readWithRetry('/') | |
| 54 | if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`) | |
| 55 | ||
| 56 | const login = await readWithRetry('/login') | |
| 57 | if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.') | |
| 58 | ||
| 59 | const robots = await readWithRetry('/robots.txt') | |
| 60 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') | |
| 61 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) | |
| 62 | ||
| 63 | console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`) | |
| @@ -0,0 +1,199 @@ | ||
| 1 | import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' | |
| 2 | import { extname, join, resolve } from 'node:path' | |
| 3 | ||
| 4 | interface AppContract { | |
| 5 | domain: string | |
| 6 | minPages: number | |
| 7 | routes: Record<string, string[]> | |
| 8 | } | |
| 9 | ||
| 10 | const 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 | ||
| 67 | const projectRoot = resolve(import.meta.dir, '..') | |
| 68 | const distRoot = resolve(projectRoot, 'dist') | |
| 69 | const ssrRoot = resolve(projectRoot, '.output') | |
| 70 | const ssrPages = resolve(ssrRoot, 'server/pages') | |
| 71 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 72 | const packageName = String(packageJson.name || '') | |
| 73 | const directoryName = projectRoot.split('/').pop() || '' | |
| 74 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 75 | const contract = CONTRACTS[appName] | |
| 76 | ||
| 77 | if (!contract) | |
| 78 | throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`) | |
| 79 | ||
| 80 | const isSsr = existsSync(ssrPages) | |
| 81 | if (!existsSync(distRoot) && !isSsr) | |
| 82 | throw new Error('No production artifact found. Run bun run build first.') | |
| 83 | ||
| 84 | function 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 | ||
| 95 | function 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 | ||
| 110 | const pageFiles = isSsr | |
| 111 | ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json')) | |
| 112 | : walkFiles(distRoot).filter(path => path.endsWith('.html')) | |
| 113 | ||
| 114 | const failures: string[] = [] | |
| 115 | if (pageFiles.length < contract.minPages) | |
| 116 | failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`) | |
| 117 | ||
| 118 | const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot | |
| 119 | for (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 | ||
| 155 | for (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 | ||
| 166 | const migrationsDir = resolve(projectRoot, 'database/migrations') | |
| 167 | if (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 | ||
| 177 | if (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 | ||
| 194 | if (failures.length) { | |
| 195 | for (const failure of failures) console.error(`- ${failure}`) | |
| 196 | process.exit(1) | |
| 197 | } | |
| 198 | ||
| 199 | console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`) | |