also looking at this
Expand BunPress guide and pre-E2E quality gates
#35
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.
| @@ -176,7 +176,6 @@ jobs: | ||
| 176 | 176 | run: bun scripts/verify-tokens.ts |
| 177 | 177 | |
| 178 | 178 | build: |
| 179 | if: github.event_name == 'pull_request' | |
| 180 | 179 | runs-on: ubuntu-latest |
| 181 | 180 | |
| 182 | 181 | steps: |
| @@ -217,6 +216,9 @@ jobs: | ||
| 217 | 216 | - name: Validate responsive visual contracts |
| 218 | 217 | run: bun run visual:validate |
| 219 | 218 | |
| 219 | - name: Validate production release artifact | |
| 220 | run: bun run release:validate | |
| 221 | ||
| 220 | 222 | - name: Verify generated files are current |
| 221 | 223 | run: git diff --exit-code |
| 222 | 224 | |
| @@ -230,7 +232,7 @@ jobs: | ||
| 230 | 232 | # |
| 231 | 233 | # A red build therefore means NO deploy, silently and by construction. |
| 232 | 234 | deploy: |
| 233 | needs: [lint, typecheck, test, test-postgres] | |
| 235 | needs: [lint, typecheck, test, test-postgres, build] | |
| 234 | 236 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' |
| 235 | 237 | runs-on: ubuntu-latest |
| 236 | 238 | |
| @@ -266,6 +268,12 @@ jobs: | ||
| 266 | 268 | - name: Build artifacts |
| 267 | 269 | run: bun run build:artifacts |
| 268 | 270 | |
| 271 | - name: Build application | |
| 272 | run: bun run build | |
| 273 | ||
| 274 | - name: Validate production artifact | |
| 275 | run: bun run release:validate | |
| 276 | ||
| 269 | 277 | # ts-cloud reads the PUBLIC key off disk to register it, so it is derived |
| 270 | 278 | # from the private one rather than stored as a second secret. Two secrets |
| 271 | 279 | # that have to agree eventually stop agreeing. |
| @@ -343,6 +351,9 @@ jobs: | ||
| 343 | 351 | |
| 344 | 352 | exit $fail |
| 345 | 353 | |
| 354 | - name: Verify public metadata | |
| 355 | run: bun run deployment:smoke | |
| 356 | ||
| 346 | 357 | # The same suite against Postgres, and a hard gate on the deploy. |
| 347 | 358 | # |
| 348 | 359 | # Production is Postgres and the suite defaults to SQLite, so every difference |
| @@ -32,6 +32,8 @@ | ||
| 32 | 32 | "docs:validate": "bun scripts/validate-docs.ts", |
| 33 | 33 | "ui:validate": "bun scripts/validate-ui.ts", |
| 34 | 34 | "visual:validate": "bun scripts/validate-visual.ts", |
| 35 | "release:validate": "bun scripts/validate-release.ts", | |
| 36 | "deployment:smoke": "bun scripts/smoke-deployment.ts", | |
| 35 | 37 | "prod": "./buddy build", |
| 36 | 38 | "deploy": "./buddy deploy", |
| 37 | 39 | "lint": "./buddy lint", |
| @@ -57,7 +59,7 @@ | ||
| 57 | 59 | "changelog": "./buddy changelog", |
| 58 | 60 | "generate": "./buddy generate", |
| 59 | 61 | "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", | |
| 61 | 63 | "typecheck:app": "bun x --bun tsc --noEmit -p tsconfig.json --pretty false", |
| 62 | 64 | "typecheck:clean": "bunx --bun rimraf storage/framework/.cache/typescript", |
| 63 | 65 | "types:check": "bun run typecheck", |
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | authenticate. The first says where to go; the second says to bin the stale |
| 18 | 18 | cookie on the way, which is what stops an expired token from bouncing |
| 19 | 19 | somebody back and forth forever. --}} |
| 20 | <span hidden aria-hidden="true"> | |
| 20 | 21 | <script> |
| 21 | 22 | (function () { |
| 22 | 23 | if (!document.getElementById('auth-required')) return |
| @@ -32,3 +33,4 @@ | ||
| 32 | 33 | window.location.replace('/login') |
| 33 | 34 | }()) |
| 34 | 35 | </script> |
| 36 | </span> | |
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | // than about a project: who they are, and how to stop being signed in. Team |
| 6 | 6 | // billing arrives with #17. |
| 7 | 7 | import { db as __db } from '@stacksjs/database' |
| 8 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 8 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 9 | 9 | |
| 10 | const session = await sessionFrom(cookies) | |
| 10 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 11 | 11 | const cookieName = sessionCookieName() |
| 12 | 12 | const user = session.user |
| 13 | 13 | const authPending = session.stale |
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | // Renders for a stranger, like /login. The response never says whether the |
| 5 | 5 | // address has an account, so this page cannot be used to find out who is a |
| 6 | 6 | // customer; the confirmation below is deliberately the same either way. |
| 7 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 7 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 8 | 8 | |
| 9 | const session = await sessionFrom(cookies) | |
| 9 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 10 | 10 | const cookieName = sessionCookieName() |
| 11 | 11 | const signedIn = session.user !== null |
| 12 | 12 | </script> |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | // The sample is stated as a sample. Numbers on a marketing page that imply a |
| 11 | 11 | // real customer's business are a lie with a chart on it. |
| 12 | 12 | import { useSeoMeta } from 'stx' |
| 13 | import { PLANS } from '../../app/Billing/limits' | |
| 13 | import { PLANS } from '../../../app/Billing/limits' | |
| 14 | 14 | |
| 15 | 15 | const siteUrl = 'https://reportshq.org' |
| 16 | 16 | const 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.' |
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | // AuthGuard and no app chrome. It does the opposite check instead: somebody who |
| 6 | 6 | // already has a session is sent to their reports rather than shown a form |
| 7 | 7 | // asking them to do something they have already done. |
| 8 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 8 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 9 | 9 | |
| 10 | const session = await sessionFrom(cookies) | |
| 10 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 11 | 11 | const cookieName = sessionCookieName() |
| 12 | 12 | const signedIn = session.user !== null |
| 13 | 13 | </script> |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | // |
| 10 | 10 | // If a limit changes, this page changes with it. Nothing here is typed twice. |
| 11 | 11 | import { useSeoMeta } from 'stx' |
| 12 | import { PLANS, TIERS } from '../../app/Billing/limits' | |
| 12 | import { PLANS, TIERS } from '../../../app/Billing/limits' | |
| 13 | 13 | |
| 14 | 14 | const siteUrl = 'https://reportshq.org' |
| 15 | 15 | const 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.' |
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | // session. The only account this creates is the person's own; teammates arrive |
| 6 | 6 | // through project invites instead, which is why there is no organisation step |
| 7 | 7 | // here. |
| 8 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 8 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 9 | 9 | |
| 10 | const session = await sessionFrom(cookies) | |
| 10 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 11 | 11 | const cookieName = sessionCookieName() |
| 12 | 12 | const signedIn = session.user !== null |
| 13 | 13 | </script> |
| @@ -15,7 +15,7 @@ import { elementHtml, License } from '@reportshq/stacks' | ||
| 15 | 15 | import { sessionFrom } from '../../../app/Support/session' |
| 16 | 16 | import { reportHandlers } from '../../../app/Support/reports' |
| 17 | 17 | |
| 18 | const session = await sessionFrom(cookies) | |
| 18 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 19 | 19 | const user = session.user |
| 20 | 20 | const authPending = session.stale |
| 21 | 21 | |
| @@ -10,7 +10,7 @@ import { sessionFrom } from '../../../app/Support/session' | ||
| 10 | 10 | import { reportHandlers } from '../../../app/Support/reports' |
| 11 | 11 | import { License } from '@reportshq/stacks' |
| 12 | 12 | |
| 13 | const session = await sessionFrom(cookies) | |
| 13 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 14 | 14 | const user = session.user |
| 15 | 15 | const authPending = session.stale |
| 16 | 16 | |
| @@ -4,18 +4,19 @@ | ||
| 4 | 4 | // The token and address come from the query string, which is where the email |
| 5 | 5 | // put them. They are read here only to hand back to the API: the page never |
| 6 | 6 | // decides whether a token is valid, because a page cannot be trusted to. |
| 7 | import { param, sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 7 | import { param, sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 8 | 8 | |
| 9 | const session = await sessionFrom(cookies) | |
| 9 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 10 | 10 | const cookieName = sessionCookieName() |
| 11 | 11 | // Deliberately not bounced when signed in. Somebody resetting a password may |
| 12 | 12 | // well have a stale session open in another tab, and that is the case they are |
| 13 | 13 | // most likely trying to fix. |
| 14 | 14 | const signedIn = false |
| 15 | 15 | |
| 16 | const token = param(query, 'token') ?? '' | |
| 17 | const email = param(query, 'email') ?? '' | |
| 18 | const usable = token !== '' && email !== '' | |
| 16 | const pageQuery = typeof query !== 'undefined' ? query : undefined | |
| 17 | const __resetToken = param(pageQuery, 'token') ?? '' | |
| 18 | const email = param(pageQuery, 'email') ?? '' | |
| 19 | const usable = __resetToken !== '' && email !== '' | |
| 19 | 20 | </script> |
| 20 | 21 | |
| 21 | 22 | <template> |
| @@ -44,7 +45,7 @@ const usable = token !== '' && email !== '' | ||
| 44 | 45 | submits. The script intercepts it; without the script it still |
| 45 | 46 | posts somewhere sensible rather than nowhere. --}} |
| 46 | 47 | <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="" /> | |
| 48 | 49 | <input id="email" type="hidden" value="async function email() { return (await authUser())?.email; }" /> |
| 49 | 50 | |
| 50 | 51 | <label class="block"> |
| @@ -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'}.`) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | const description = 'Build, schedule, export, and securely share reports from the data sources your team already uses.' | |
| 2 | ||
| 3 | export 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 | } | |