ReviewOS

also looking at this

stacks/bench-review

Adopt the hq-apps app conventions

#76
Merged glennmichael123 wants to merge chore/hq-apps-conventions into main
31 files +1656 -395
DEPLOY.mdadded+159-0
Changes to DEPLOY.md
@@ -0,0 +1,159 @@
1# Deploying Bench Review
2
3> **Host not yet chosen.** This document describes the artifact bench-review
4> produces and the contract any host must satisfy to serve it. It does not
5> describe a provisioned environment, because there isn't one yet. See
6> [Outstanding before first deploy](#outstanding-before-first-deploy).
7
8Production domain: **benchreview.org** (declared in [`site.config.ts`](site.config.ts)).
9
10## What ships
11
12Two artifacts, deployed independently:
13
14| Artifact | Produced by | What it is |
15|---|---|---|
16| `dist/` | `bun run build` | Pre-rendered static HTML, one file per route, plus hashed assets, `sitemap.xml` and `robots.txt` |
17| the API | `bun dev/api.ts` (or your process manager) | bun-router serving `/api/*` — auth, reviews, moderation, uploads |
18
19stx is a **build-time** templating engine here: every page in `dist/` is
20already rendered HTML. There is no Node render server for the frontend and
21**no package publish step** is needed to deploy — the built artifact is
22self-contained.
23
24## The deploy contract
25
26The host must provide two things that are **not** part of the built artifact.
27A host that skips them serves a site that looks fine and is broken in two
28specific ways.
29
30### 1. Rewrite `/verify-email/:id/:token` → `/verify-email.html` (status 200)
31
32Verification tokens are minted per request, so unlike every other dynamic
33route there is no finite set of pages to pre-render. Without this rewrite the
34link in **every signup confirmation email 404s**.
35
36It must be a *rewrite*, not a redirect: `Bench/VerifyEmail` reads the id and
37token off `location.pathname`, so the URL has to survive.
38
39### 2. Proxy `/api/*` → the API origin (same-origin, status 200)
40
41Every store calls the API with a **relative** path (`/api/auth`, `/api/me`,
42`/api/judges`, …). The dev server proxies this; a static host does not.
43Without the proxy every API call resolves against the static origin and 404s,
44so the site renders its shells and then stays empty forever.
45
46`preview.ts` fakes both locally so the built artifact can be exercised
47end-to-end before shipping — that is a local convenience, not a substitute for
48configuring them at the host.
49
50On CloudFront these are both cache behaviours: an `/api/*` behaviour with the
51API as origin, and a CloudFront Function (or a `/verify-email/*` behaviour)
52rewriting the URI. On nginx/Caddy they are a `try_files`/`rewrite` and a
53`proxy_pass`. Whatever the host, **these two are the deploy contract.**
54
55## Environment
56
57`APP_URL` is the one variable the *build* reads. It is baked into canonicals,
58Open Graph tags, the JSON-LD graph, `sitemap.xml` and `robots.txt`, so it must
59be the production origin at build time, not at serve time.
60
61| Var | Purpose | Notes |
62|---|---|---|
63| `APP_URL` | Canonical origin, baked into the artifact | **Required for a deployable build.** Unset ⇒ the gate fails with `APP_URL unset — artifact carries N loopback URLs` |
64| `APP_ENV` | `production` | |
65| `APP_KEY` | Encryption/signing key | `./buddy key:generate` |
66| `DB_CONNECTION` / `DB_*` | Database | dev is `sqlite`; production should be `postgres` |
67| `MAIL_*` | SMTP | **On the critical path** — email verification and password reset do not work without it. Dev points at a local catcher (`127.0.0.1:2525`) |
68| `FILESYSTEM_DISK` | `local` or `s3` | Review photos + avatars. See `app/Storage/disk.ts` |
69| `AWS_*` | S3 credentials | Only when `FILESYSTEM_DISK=s3` |
70| `API_ORIGIN` | Where `preview.ts` forwards `/api/*` | Local preview only; defaults to `http://localhost:4008` |
71| `TS_ANALYTICS_*` | Analytics endpoint override | Optional; the App ID lives in `config/ts-analytics.ts` |
72
73Local ports: views `4000`, API `4008`.
74
75## Build
76
77```bash
78APP_URL=https://benchreview.org bun run build
79```
80
81The build runs `buildApp()` (stx SSG) and then splices in, in order: per-page
82SEO head tags, `noindex` markers, per-entity SEO for dynamic pages, canonicals,
83the analytics tag, webfont tags, the CSP meta, and finally a DB-derived
84`sitemap.xml` + `robots.txt`. See [`build.ts`](build.ts).
85
86> Dynamic routes (`/judges/:id`, `/court-houses/:id`, `/article/:id`) are
87> enumerated from the **database** at build time via `getStaticPaths`. Building
88> against an empty database produces zero entity pages and the gate fails with
89> `no pages for: judges, court-houses, article`. Seed or restore the database
90> before building for release.
91
92## Validate before shipping
93
94```bash
95bun run gate # build + 17 build/SEO integrity checks
96bun run ui:validate # accessibility lint over resources/
97bun run visual:validate # headless-Chrome contrast / overflow / focus audit
98bun run release:validate # route text contracts, assets, metadata, migrations
99```
100
101`release:validate` asserts that `sitemap.xml` and `robots.txt` name
102`benchreview.org` and contain no `localhost` URLs, so it **only passes on a
103build made with a production `APP_URL`**. That is deliberate: it is the check
104that catches a dev artifact being shipped.
105
106`visual:validate` needs a Chrome or Chromium binary; set `CHROME_PATH` if it
107isn't in a standard location. Screenshots land in
108`storage/logs/visual-contract/`.
109
110## Preview the real artifact
111
112```bash
113bun run preview # serves dist/ on :3001, proxies /api to :4008
114```
115
116This is the only way to exercise what actually ships: the dev server has the
117API but serves from source (so it shows none of the build-time output), and
118`dist/` alone has no API.
119
120## After deploying
121
122```bash
123bun run deployment:smoke
124```
125
126Hits the live domain and asserts the homepage marker, a login form, `/api/health`
127(including `checks.database.ok`), `robots.txt`, and that no page ships
128unresolved stx expressions in attributes. Override the target with
129`SMOKE_BASE_URL`; tune retries with `SMOKE_ATTEMPTS` / `SMOKE_RETRY_MS`.
130
131Health is served at **`GET /api/health`** and answers `503` when the database
132or cache probe fails, so a plain HTTP check gets the right answer without
133parsing the body. See [`app/Support/health.ts`](app/Support/health.ts).
134
135## Database
136
137Migrations are plain SQL under `database/migrations/`, applied with
138`./buddy migrate`. Seeders under `database/seeders/` are idempotent and keyed
139on natural keys, so re-running is safe.
140
141> The migrations directory currently mixes two timestamp epochs — 10-digit
142> (seconds) and 13-digit (milliseconds) filenames — because a scaffold sync
143> renumbered some tables. Filename sort therefore interleaves the two families.
144> Ordering happens to still be correct (every `create` precedes its `alter`),
145> but add new migrations with the 13-digit form and treat the directory as
146> needing a deliberate cleanup pass.
147
148## Outstanding before first deploy
149
1501. **Pick a host** and implement the two deploy-contract rules above.
1512. **`config/cloud.ts` is still stock Stacks** — it declares sites for
152 `stacksjs.org`, `docs.stacksjs.org`, `tlsx.stacksjs.org` and an AWS
153 serverless target. Nothing in it describes bench-review. Either rewrite it
154 for the chosen host or delete it; do not assume `./buddy deploy` works.
1553. **Set a production `APP_URL`** — without it the artifact is not deployable.
1564. **Configure real SMTP** — verification and password reset are dead without it.
1575. **Seed or restore the database** so dynamic pages are pre-rendered.
1586. **`MAIL_FROM_ADDRESS`** still reads `no-reply@benchreview.com`; the
159 canonical domain is `benchreview.org`.
README.mdmodified+115-373
Changes to README.md
@@ -1,400 +1,142 @@
1![Social Card of Stacks](./public/images/social.png)
1# Bench Review
22
3# Rapid App & Library Development
3[![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](./LICENSE.md)
44
5[![npm version](https://img.shields.io/npm/v/stacks?style=flat-square)](https://npmjs.com/package/stacks)
6[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/stacksjs/stacks/ci.yml?style=flat-square&branch=main)](https://github.com/stacksjs/stacks/actions?query=workflow%3Aci)
7[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
8[![npm downloads](https://img.shields.io/npm/dm/stacks?style=flat-square)](https://npmjs.com/package/stacks)
9<!-- [![Codecov][codecov-src]][codecov-href] -->
5A public directory of judges, with first-hand reviews from the attorneys,
6clerks, and court staff who appear before them. Think Yelp, for the bench.
107
11> [!WARNING]
12> Stay tuned. Open Beta coming soon.
13
14Stacks is a rapid development framework, where the goal is to _help you_ create & maintain frontends, backends, and clouds—without having to worry about the boilerplate. _An all-in-one toolkit that meets all your full stack needs._
15
16- Web & Desktop Applications _(including system tray apps)_
17- Serverless & Traditional APIs
18- Cloud Infrastructure Creation & Maintenance
19- Interactive CLIs
20- Framework-agnostic Component & Function Libraries
21- Deployment & Release Manager _(CI & CD)_
22
23## Convention Over Configuration
24
25As a developer, Stacks helps you every step along the way—in beginner & expert-friendly ways, allowing you to focus on the _what & why_ of your project, all while enabling you to stay in control & ownership of your _(& your users’)_ data.
8Built as a [Stacks](https://github.com/stacksjs/stacks) app: `defineModel()`
9for schema, [stx](https://github.com/stacksjs/stx) for views, Crosswind for
10styling, and `buddy` for everything else.
2611
27> “It is the framework’s responsibility to remove patterns that lead to boilerplate code. And Stacks is really good at that.” _- Chris_
28
29<!-- ![Atomic UI & FX Design](./docs/assets/diagram.png) -->
30
31## Get Started
32
33It’s incredibly easy to get started with this framework. Simply run the following command in your terminal:
12> [!WARNING]
13> **Pre-launch.** The application surface is substantially complete, but the
14> directory has no production content yet — building against an empty database
15> produces zero judge and courthouse pages. Content ingestion is the open
16> blocker. See [Status](#status).
17
18## What it does
19
20**The directory** — judges and courthouses, each with a profile, a reviews
21tab, and (for judges) recent rulings. Courthouses carry real building-level
22coordinates and render on a Leaflet/OSM map.
23
24**Reviews** — a rich-text editor with server-side draft autosave (so a draft
25survives a device change), photo upload that strips EXIF and emits a
26three-size resize ladder, 1–5 star ratings, threaded comments, and a
27"helpful" vote with a denormalised counter so public feeds stay `COUNT`-free.
28Reviews can be posted anonymously; the reviewer label is derived through a
29helper built to avoid de-anonymisation.
30
31**Moderation** — an admin surface with queues for flagged reviews, judge
32submissions, and credential claims, plus user suspension (reversible) and
33deletion, and an append-only moderation audit log recording who did what.
34
35**Trust** — reviewers can submit a credential claim (role + state) that an
36admin verifies out of band. Judges can claim their own profile and post an
37official response to a review of them; the same right-of-reply is also
38available admin-mediated.
39
40**Accounts** — registration, login, email verification (with resend),
41password reset, password change, and sign-out-everywhere. Plus data export and
42self-serve account deletion with a full cascade.
43
44**Public hygiene** — a DB-derived sitemap and robots.txt, per-page canonical /
45Open Graph / Twitter tags, a JSON-LD Organization + WebSite + WebPage graph,
46and a Content-Security-Policy with every inline script hashed. Rate limits sit
47on every public and mutating endpoint.
48
49## Stack
50
51| Layer | What |
52|---|---|
53| Framework | [Stacks](https://github.com/stacksjs/stacks) |
54| Views | [stx](https://github.com/stacksjs/stx) — build-time rendered, no frontend render server |
55| Styling | Crosswind (Tailwind-compatible utilities) |
56| Data | `defineModel()` + [bun-query-builder](https://github.com/stacksjs/bun-query-builder); SQLite in dev, Postgres in production |
57| Search | Meilisearch configured; the UI still filters client-side |
58| Images | sharp (EXIF strip + resize) |
59| Storage | `app/Storage/disk.ts` — local disk or Bun-native S3 via `FILESYSTEM_DISK` |
60| Lint | [pickier](https://github.com/stacksjs/pickier) (never eslint directly) |
61
62## Getting started
3463
3564```bash
36curl -Ssf stacksjs.org/install | sh # wip
37
38# alternatively, if Bun >= v1.2.5 is installed already
39# you may also get started via
40bunx buddy new my-project
65bun install
66cp .env.example .env
67./buddy key:generate
68./buddy migrate
69./buddy seed
70./buddy dev
4171```
4272
43## Usage
73Views come up on `:4000`, the API on `:4008`.
4474
45The following list includes some of the most common ways to interact with the Stacks API.
75> `./buddy seed` loads 6 courthouses, 24 judges, and a handful of reviews —
76> enough to exercise every page, not enough to be a product.
4677
47_Meet the toolkit, Buddy:_
78## Layout
4879
49```bash
50buddy install # installs all dependencies
51buddy dev # starts one of the dev servers (frontend, API, components, docs, desktop, etc.)
52buddy share # creates a sharable link of your local project
53buddy build # follow CLI prompts to select which library (or server) to build
54buddy commit # follow CLI prompts for committing changes
55buddy release # creates the releases for the stack & consequently, publishes them to npm
56buddy upgrade # auto-update all deps & the Stacks framework
57
58buddy make:component HelloWorld # scaffolds a Component
59buddy make:function HelloWorld # scaffolds a Function
60buddy make:model Car # scaffolds a Model
61buddy make:action UpdateCar # scaffolds an Action
62buddy make:migration create_cars_table # scaffolds a Migration
63buddy make:view Car # scaffolds a Page (https://my-project.test/car)
64buddy make:command Inspire # scaffolds a Command
65buddy make:job SendEmail # scaffolds a Job
66buddy make:middleware Logger # scaffolds a new Middleware
67buddy make:notification WelcomeEmail # scaffolds a Notification
68buddy make:lang de # scaffolds a language file
69
70buddy list # lists all available commands
71buddy --help
7280```
73
74<details>
75<summary>View the complete Buddy Toolkit</summary>
76
77```bash
78buddy --version # get the Stacks version
79buddy --help # view help menu
80# please note: you may suffix any command with the
81# `command --help` flag to review the help menu
82
83buddy install # installs dependencies
84buddy add # adds a stack or dependency
85buddy fresh # fresh reinstall of all deps
86buddy clean # removes all deps
87buddy setup # sets up the project initially
88buddy setup:oh-my-zsh # optional: sets up Oh My Zsh with auto-completions & "aliases"
89
90buddy upgrade # upgrades all dependencies
91buddy upgrade -i # prompts you to select which updates to apply (wip)
92buddy upgrade:dependencies # auto-upgrades package.json deps
93buddy upgrade:framework # auto-upgrades deps & the Stacks framework
94buddy upgrade:search-engine # auto-upgrades configured search engine
95buddy upgrade:shell # upgrades the shell integration
96buddy upgrade:binary # upgrades the `stacks` binary
97buddy upgrade:bun # upgrades to latest project-defined Bun version
98buddy upgrade:all # auto-upgrades all of the above
99
100# if you need any more info on any command listed here, you may suffix
101# any of them via the "help option", i.e. `buddy ... --help`
102
103buddy dev # starts the frontend dev server
104buddy dev -i # prompts any of the dev servers (components, functions, views, docs, or api)
105buddy dev:api # starts the API dev server
106buddy dev:dashboard # starts the Admin/Dashboard dev server
107buddy dev:desktop # starts the Desktop dev server
108buddy dev:views # starts frontend dev server
109buddy dev:components # starts component dev server
110buddy dev:functions # stubs functions
111buddy dev:docs # starts local docs dev server
112buddy dev docs # also starts the local docs dev server (colon is optional for all commands)
113buddy development # `buddy dev` alias
114
115buddy share # creates a sharable link to your local project
116
117# for Laravel folks, `serve` may ring more familiar than the `dev` name. Hence, we aliased it
118buddy serve
119buddy serve:components
120buddy serve:desktop
121buddy serve:views
122buddy serve:functions
123buddy serve:docs
124
125# building for production (e.g. AWS, Google Cloud, npm, Vercel, Netlify, et al.)
126buddy build # select a specific build (follow CLI prompts)
127buddy build:views # builds SSG views
128buddy build:desktop # builds Desktop application
129buddy build:library # builds any or all libraries
130buddy build:functions # builds function library
131buddy build:components # builds Vue component library & Web Component library
132buddy build:web-components # builds framework agnostic Web Component library (i.e. Custom Elements)
133buddy build:vue-components # builds Vue 2 & 3-ready Component library
134buddy build:all # builds all your code
135
136# `buddy build` aliases
137buddy prod
138buddy prod:components
139buddy prod:desktop
140buddy prod:library
141buddy prod:views
142buddy prod:functions
143buddy prod:vue-components
144buddy prod:web-components
145buddy prod:all
146buddy production # `buddy prod` alias
147
148# sets your application key
149buddy key:generate
150
151buddy make:component HelloWorld # bootstraps a HelloWorld component
152buddy make:function hello-world # bootstraps a hello-world function
153buddy make:view hello-world # bootstraps a hello-word page
154buddy make:model Car # bootstraps a Car model
155buddy make:database cars # creates a cars database
156buddy make:migration create_cars_table # creates a cars migration file
157buddy make:factory cars # creates a Car factory file
158buddy make:table cars # bootstraps a cars data table
159buddy make:notification welcome-email # bootstraps a welcome-email notification
160buddy make:lang de # bootstraps a lang/de.yml language file
161buddy make:stack my-project # shares logic with `bunx --bun stacks new my-project`
162
163buddy migrate # runs database migrations
164buddy migrate:dns # sets the ./config/dns.ts file
165
166buddy dns example.com # list all DNS records for example.com
167buddy dns example.com --type MX # list MX records for example.com (proxies dog)
168
169buddy https httpie.io/hello
170# http [flags] [METHOD] URL [ITEM [ITEM]]
171buddy http --help
172buddy http PUT pie.dev/put X-API-Token:123 name=John # Custom HTTP method, HTTP headers and JSON data
173buddy http -v pie.dev/get # See the request that is being sent using one of the output options
174buddy http -f POST pie.dev/post hello=World # submitting forms
175buddy http --offline pie.dev/post hello=offline
176buddy http -a USERNAME POST https://api.github.com/repos/httpie/cli/issues/83/comments body='HTTPie is awesome! :heart:'
177buddy http pie.dev/post < files/data.json
178buddy http pie.dev/image/png > image.png
179buddy http --download pie.dev/image/png
180buddy http --session=logged-in -a username:password pie.dev/get API-Key:123
181buddy http --session=logged-in pie.dev/headers
182buddy http localhost:8000 Host:example.com
183
184buddy lint # runs linter
185buddy lint:fix # runs linter and fixes issues
186
187buddy commit # follow CLI prompts for committing staged changes
188buddy release # creates the releases for the stack & triggers the Release Action (workflow)
189buddy changelog # generates CHANGELOG.md
190
191# when deploying your app/s to a remote server or cloud provider
192buddy deploy # select a specific deployment (follow CLI prompts)
193# buddy deploy:docs # deploys docs to AWS (or other configured provider)
194# buddy deploy:functions # deploys functions to AWS (or other configured provider)
195# buddy deploy:views # deploys views to AWS (or other configured provider)
196# buddy deploy:all # deploys all your code
197buddy undeploy # be careful: "undeploys" removes/deletes your deployed resources
198
199buddy cloud:remove # removes cloud setup
200buddy cloud:cleanup # removes cloud setup & cleans up all potentially leftover resources
201buddy cloud:add --jump-box # adds a jump box to your cloud setup
202
203# select the example to run (follow CLI prompts)
204buddy example # prompts you to select which example to run
205buddy example:vue # runs the Vue example
206buddy example:web-components # runs the Web Component example
207
208# you likely won’t need to run these commands as they are auto-triggered, but they are available
209buddy generate # prompts you to select which generator to run
210buddy generate:types # generates types for your components, functions, & views
211buddy generate:entries # generates entry files for components, functions, & views
212buddy generate:web-types # generates Web Component types
213buddy generate:vscode-custom-data # generates VSCode custom data
214buddy generate:ide-helpers # generates IDE helpers
215buddy generate:component-meta # generates component meta
216buddy generate:all # runs all generators
217
218# generates your application key
219buddy key:generate # generates your application key
220
221# manage your environment variables
222buddy env:get # get an environment variable
223buddy env:set # set an environment variable
224buddy env:encrypt # encrypt an environment variable
225buddy env:decrypt # decrypt an environment variable
226buddy env:keypair # generate a keypair
227buddy env:rotate # rotate a keypair
228
229# generate your TypeScript declarations
230buddy types:generate # generates types for your components, functions, & views
231buddy types:fix # auto-fixes types for your components, functions, & views
232
233buddy domains # alias for `buddy domains:list`
234buddy domains:add stacksjs.org # adds a domain
235buddy domains:remove stacksjs.org # removes a domain
236buddy domains:list # lists all domains
237buddy domains:update # apply ./config/dns.ts updates
238buddy domains:purchase stacksjs.org # purchase a new domain
239
240# test your stack
241buddy test # runs test suite (unit & e2e)
242buddy test:coverage # runs test coverage
243buddy test:types # runs typecheck
244
245# the CLI may be triggered in any
246# of the following syntax
247stx fresh
248buddy fresh
249bud fresh
81app/
82 Actions/ one file per endpoint, grouped by surface (Reviews, Judges, Admin, Me, …)
83 Models/ defineModel() schema; `useApi.routes` is OPT-OUT — always state it
84 Helpers/ SEO head injection, sitemap, CSP, review photos, storage disk
85 Support/ health endpoint
86resources/
87 views/ one file per route; dynamic routes enumerate via getStaticPaths
88 components/ Bench/* — the real UI lives here
89 stores/ all data access goes through a store, never a fetch in a component
90 layouts/ page shells
91routes/api.ts hand-written API routes (auto-prefixed with /api)
92database/ plain-SQL migrations + idempotent seeders
93scripts/ the validation suite (see below)
94site.config.ts single source of truth for name / url / SEO
95gate.ts 17-check build + SEO conformance gate
25096```
25197
252</details>
253
254Read more here about the Stacks CLI in the documentation.
255
256## Features
257
258The Stacks framework is a harmony of several “engines” to build any web and/or desktop application, in highly scalable & privacy-friendly ways. It consists of the following engines:
259
260### Frontend Development
261
262_Develop dynamic UIs with helpers for atomic design, and much more._
263
264- �� **Components** _primitive to develop user interfaces_
265- �� **Functions** _primitive to develop business logic (and grant your UI superpowers)_
266- �� **UI Kit** _modern & deeply-integrated components_
267- �� **Web** _“a routing & templating engine that makes sense”_
268- ��️ **Desktop** _transforms your web app into a desktop app, plus more_
269- �� **Documentation** _markdown-based documentation, auto-generated_
270- �� **Library** _auto-builds & manages component & function libraries_
271- ⚡️ Powered by Bun, Tauri, UnoCSS, Vite, VitePress and Vue
272
273### Backend Development
274
275_Develop serverless (or server) functions with countless helpers to build scalable & fast APIs._
276
277- �� **AI** _deep AI integrations & foundational model access_
278- �� **APIs** _scalability & maintainability built-in_
279- ��️ **Cache** _unified caching for DynamoDB, Redis and more_
280- ⚙️ **CLIs** _create beautiful CLIs for Linux, Windows, and Mac (dependency-free binaries)_
281- ��️ **Commerce** _own & grow your own online business with ease (SaaS-optimized)_
282- �� **Database** _DynamoDB, SQLite, MySQL, Postgres, and more_
283- �� **Errors** _native type-safe error handling_
284- ��️ **Events** _functional event (front & backend) communication_
285- �� **Notifications** _emails, SMSs, direct, and push notifications & webhooks_
286- ��️ **ORM** _automated schemas for scale & a pretty API_
287- �� **Payments** _unified API for one-off & subscription billing methods for Stripe_
288- ⚙️ **Queues** _run any heavy workload in the background_
289- ��️ **Query Builder** _powerful, type-safe SQL query builder_
290- �� **Realtime** _“everything you need to build dynamic real-time apps”_
291- �� **Router** _smart routing, file-based or Laravel-like_
292- �� **Search Engine** _smart searching, advanced filtering & sorting, pagination, headless UI_
293- �� **Storage** _a secure-by-default File API that feels right_
294- �� **Tinker** _a powerful TypeScript REPL_
295- ��️ **Validation** _e2e type-safety (true frontend & backend harmony)_
296- �� **X-Ray** _all you need to debug, log & analyze_
297
298### Cloud Development
299
300_Develop & maintain cloud infrastructure with ease. “Imagine Vercel, Vapor and Forge having been unified.”_
301
302- ☁️ **Server** _local development server & production-ready servers out-of-the-box_
303- ⛅️ **Serverless** _on-demand, auto-scaling, zero maintenance_
304- ⏰ **Alarms** _built-in cloud infrastructure monitoring to avoid surprises_
305- �� **CDN** _zero-config, low-latency, request life-cycle hooks, optimized request compressions (Brotli & gzip)_
306- �� **Domain** _version-controlled & zero-config domain management (e.g. DNS management)_
307- �� **AI** _fine-tune a foundational model using your application data_
308- �� **Email** _secure & zero-setup <easy-peasy@custom-domains.com> mailboxes_
309- �� **Firewall** _native web application firewall support_
310- �� **Storage** _unlimited cloud storage & automatic backups_
311- �� **Maintenance** _maintain your cloud infrastructure with ease using Buddy & Stacks_
312- �� **Infrastructure as Code** _version-controlled cloud infrastructure (AWS, Google next?)_
313
314### CI/CD
315
316_Focus on coding, not publishing._
317
318- �� **Deployment Manager** _takes the sweat out of production deployments—zero-setup push-to-deploy_
319- 0️⃣ **Zero Downtime** _deploy with confidence using a zero-downtime deployment strategy_
320- �� **Release Manager** _libraries (component & function) auto-published to npm, git helpers, and more_
321
322### Developer Experience (DX)
323
324Convention over configuration, while staying wholly configurable. _No more boilerplate._
325
326- �� **Automated Upgrades** _no need to worry about upgrading to the latest versions, Stacks upgrades you_
327- �� **Pretty Dev URLs** _your-project.localhost instead of localhost:3000_
328- �� **IDE Integration** _auto-completions, inline docs & a powerful IDE setup_
329- �� **Zero-Config** _yet highly configurable—convention over configuration_
330- �� **Linter & Formatter** _auto-configured & built into your IDE_
331- ���� **Type Strong** _built-in e2e type-safety_
332- ✨ **Git Workflows** _committing with ease_
333- �� **Auto Imports** _your components & functions, including date, string, array, & object helpers_
334- ⏩ **Code Snippets** _goodbye to the boilerplate code—thank you Sarah Drasner_
335- �� **Spell Checker** _be notified once there are typos_
336- ��️ **Essential Utilities** _powers at your fingertips. Collections, VueUse, and more_
337- �� **Team Management** _manage your team & their permissions_
338- �� **Streamlined Testing** _unit & e2e tests powered by Bun, Vitest & Playwright_
339
340No matter whether you are a beginner or an expert, the approachable Stacks design allows you to learn at your own pace, using our thorough documentation covering every aspect of the framework. Stacks is extremely beginner & expert-friendly.
341
342Develop beautiful, reactive, composable UIs without learning a new set of languages. HTML, CSS, and minimal JavaScript—that’s all you need to dive in now! _Or TypeScript ✌��_
343
344> _An actual rapid application development framework for all Full Stack needs. Next-level simplicity & DX._
345
346## Testing
98## Quality gates
34799
348100```bash
349./buddy test
101bun run lint # pickier
102bun run typecheck:app # app-owned TypeScript
103bun run gate # build + 17 build/SEO integrity checks
104bun run ui:validate # accessibility lint over resources/
105bun run visual:validate # headless-Chrome contrast / overflow / focus audit
106bun run release:validate # route text contracts, assets, metadata, migrations
107bun run deployment:smoke # post-deploy checks against the live domain
350108```
351109
352## Changelog
353
354Please see our [releases](https://github.com/stacksjs/stacks/releases) page for more information on what has changed recently.
355
356## Contributing
357
358Please see the [Contributing Guide](https://github.com/stacksjs/contributing) for details.
359
360## Community
361
362For help, discussion about best practices, or any other conversation that would benefit from being searchable:
110`release:validate` only passes on a build made with a production `APP_URL` —
111that is the check that catches a dev artifact being shipped.
363112
364[Discussions on GitHub](https://github.com/stacksjs/stacks/discussions)
113## Deploying
365114
366For casual chit-chat with others using this package:
115See [DEPLOY.md](./DEPLOY.md). The short version: the build produces a static
116`dist/` plus an API process, and the host must provide two things the artifact
117cannot — an `/api/*` proxy and a `/verify-email/:id/:token` rewrite.
367118
368[Join the Stacks Discord Server](https://stacksjs.com/discord)
119## Status
369120
370## Postcardware
121Working and verified: the full review lifecycle, moderation, auth, privacy
122export/delete, SEO surface, and rate limiting. `lint`, `typecheck:app`,
123`ui:validate` and `release:validate` are green; `gate` is 16/17.
371124
372“Software that is free, but hopes for a postcard.” We love receiving postcards from around the world showing where Stacks is being used! We showcase them on our website too.
125Open before launch:
373126
374Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States ��
375
376## Sponsors
377
378We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.
379
380- [JetBrains](https://www.jetbrains.com/)
381- [The Solana Foundation](https://solana.com/)
382
383## Credits
384
385- [Laravel](https://laravel.com) _many thanks to their community_
386- [Chris Breuer](https://github.com/chrisbbreuer)
387- [All Contributors](../../contributors)
388
389And a special thanks to [Dan Scanlon](https://twitter.com/danscan) for donating the `stacks` name on npm ✨
1271. **Content ingestion** — the directory is empty, so no entity pages render.
128 This is the one blocker that makes the rest moot.
1292. **A host** — `config/cloud.ts` is still stock Stacks and describes
130 `stacksjs.org`, not this app.
1313. **Production SMTP** — email verification and password reset depend on it.
1324. **`routes: []` on `app/Models/Request.ts`** — it currently declares
133 `useApi: true` with no `routes`, which the ORM generator turns into
134 unauthenticated full CRUD at `/api/requests`.
1355. **`HttpOnly` on the auth cookie** — the token is readable from JS today.
1366. **Tests** — `tests/` is still placeholder stubs.
1377. **`docs/`** — still the inherited Stacks framework documentation; either
138 curate it for this app or remove it.
390139
391140## License
392141
393The MIT License (MIT). Please see [LICENSE](LICENSE.md) for more information.
394
395Made with ��
396
397<!-- Badges -->
398
399<!-- [codecov-src]: https://img.shields.io/codecov/c/gh/stacksjs/stacks/main?style=flat-square
400[codecov-href]: https://codecov.io/gh/stacksjs/buddy -->
142[MIT](./LICENSE.md)
app/Helpers/seoPages.tsmodified+10-7
Changes to app/Helpers/seoPages.ts
@@ -14,6 +14,8 @@
1414 * they need build-time static generation (#46).
1515 */
1616
17import site from '~/site.config'
18
1719interface PageSeo {
1820 /** Public path, e.g. '/judges'. Canonical + og:url = base + path. */
1921 path: string
@@ -105,7 +107,7 @@ export const escapeAttr = (s: string): string =>
105107/** Extract the page's existing <title> text for og__stx_title="IkFkb3B0IHRoZSBocS1hcHBzIGFwcCBjb252ZW50aW9ucyBieSBnbGVubm1pY2hhZWwxMjMgwrcgc3RhY2tzL2JlbmNoLXJldmlldyAjNzYi" / twitter:title. */
106108function extractTitle(html: string): string {
107109 const m = html.match(/<title>([^<]*)<\/title>/i)
108 return m?.[1]?.trim() || 'Bench Review'
110 return m?.[1]?.trim() || site.seo.siteName
109111}
110112
111113/**
@@ -140,18 +142,18 @@ function buildJsonLd(seo: PageSeo, base: string, title: string): string {
140142 {
141143 '@type': 'Organization',
142144 '@id': `${base}/#organization`,
143 'name': 'Bench Review',
145 'name': site.seo.siteName,
144146 'url': `${base}/`,
145147 'logo': {
146148 '@type': 'ImageObject',
147 'url': `${base}/images/bench/logo.png`,
149 'url': `${base}${site.seo.logo}`,
148150 },
149151 },
150152 {
151153 '@type': 'WebSite',
152154 '@id': `${base}/#website`,
153155 'url': `${base}/`,
154 'name': 'Bench Review',
156 'name': site.seo.siteName,
155157 'publisher': { '@id': `${base}/#organization` },
156158 'potentialAction': {
157159 '@type': 'SearchAction',
@@ -186,12 +188,13 @@ export function injectSeoHead(html: string, seo: PageSeo, base: string): string
186188 const url = `${base}${seo.path}`
187189 const title = extractTitle(html)
188190 const ogDesc = seo.ogDescription ?? seo.description
189 const ogImage = `${base}/images/og-image.png`
191 const ogImage = `${base}${site.seo.imagePath}`
190192
191193 const tags = [
192194 `<link rel="canonical" href="${escapeAttr(url)}">`,
193 `<meta property="og:type" content="website">`,
194 `<meta property="og:site_name" content="Bench Review">`,
195 `<meta property="og:type" content="${escapeAttr(site.seo.type)}">`,
196 `<meta property="og:site_name" content="${escapeAttr(site.seo.siteName)}">`,
197 `<meta property="og:locale" content="${escapeAttr(site.seo.locale)}">`,
195198 `<meta property="og:url" content="${escapeAttr(url)}">`,
196199 `<meta property="og:title" content="${escapeAttr(title)}">`,
197200 `<meta property="og:description" content="${escapeAttr(ogDesc)}">`,
app/Helpers/sitemap.tsmodified+14-1
Changes to app/Helpers/sitemap.ts
@@ -1,4 +1,5 @@
11import { db } from '@stacksjs/database'
2import site from '~/site.config'
23
34/**
45 * Shared sitemap + robots builders.
@@ -29,11 +30,23 @@ const STATIC_PATHS: Array<{ path: string, priority: number, changefreq: string }
2930 { path: '/register', priority: 0.5, changefreq: 'yearly' },
3031]
3132
32/** Normalize a base URL: trim trailing slashes so `${base}${path}` is clean. */
33/**
34 * Normalize a base URL: trim trailing slashes so `${base}${path}` is clean.
35 *
36 * The fallback is deliberately the LOCAL dev host, not the production
37 * canonical: a build with APP_URL unset must produce an obviously-local
38 * artifact that the conformance gate rejects ("APP_URL unset — artifact
39 * carries N loopback URLs"), rather than a plausible-looking one that
40 * silently claims to be production. `site.url` is the canonical host and is
41 * what you get once APP_URL is set; see DEPLOY.md.
42 */
3343export function normalizeBase(raw: string | undefined): string {
3444 return (raw || 'http://localhost:4000').replace(/\/+$/, '')
3545}
3646
47/** The production canonical origin, from site.config.ts. */
48export const CANONICAL_BASE: string = site.url.replace(/\/+$/, '')
49
3750/**
3851 * Build the full sitemap XML from current DB content. Every judge gets a
3952 * /judges/:id/profile entry, every courthouse /court-houses/:id/profile,
app/Support/health.tsadded+90-0
Changes to app/Support/health.ts
@@ -0,0 +1,90 @@
1import type { ApplicationHealthResult } from '@stacksjs/router'
2import { checkApplicationHealth } from '@stacksjs/router'
3
4/**
5 * The name this app answers to in its own health body.
6 *
7 * Matches `package.json` → `name`, which is also the key
8 * `scripts/smoke-deployment.ts` and `scripts/validate-release.ts` resolve
9 * their contracts by. Keep the three in step.
10 */
11export const APP = 'bench-review'
12
13export interface HealthOptions {
14 /**
15 * The probe runner. Defaults to the framework's, which checks the database
16 * (`SELECT 1`) and the cache (set, then delete) with a 1.5s timeout each.
17 * Injectable so the failure path can be tested without taking a real
18 * dependency down.
19 */
20 check?: () => Promise<ApplicationHealthResult>
21 /** App-specific facts merged into the body. */
22 extra?: Record<string, unknown>
23}
24
25/**
26 * The health endpoint an uptime monitor can actually trust.
27 *
28 * ## Why this is registered by hand
29 *
30 * The framework already ships this as `route.health()` from
31 * `storage/framework/defaults/routes/dashboard.ts`, but reaching it depends on
32 * conditions that have nothing to do with health:
33 *
34 * - `config/dashboard.ts` → `enabled`. Turning off the admin UI also removes
35 * `/api/health`. bench-review has no `config/dashboard.ts` at all, so there
36 * is nothing guaranteeing that call site runs.
37 * - the vendored `storage/framework/` tree reaching the box. bench's
38 * production artifact is a pre-rendered `dist/` plus the API process; the
39 * framework defaults are not something the deploy contract promises.
40 *
41 * Registering it here makes the endpoint a property of this app rather than of
42 * a UI flag and a build artifact. User routes load before the framework's, so
43 * this also wins wherever the default one is present.
44 *
45 * ## Why not a static 200
46 *
47 * A literal `{ status: 'ok' }` cannot fail: it returns 200 with the database
48 * down, the cache gone, and every page 500ing. Pointing a monitor at it would
49 * produce a green dashboard straight through an outage — worse than having no
50 * monitor at all, because one is a known gap and the other is a false
51 * assurance.
52 *
53 * Answering 503 on a failed probe is what makes this readable by a plain HTTP
54 * check, so no monitor has to understand the body to get the right answer.
55 *
56 * ## Route registration
57 *
58 * `routes/api.ts` auto-prefixes every path with `/api` (stacksjs/stacks#1835),
59 * so the declaration there is a bare `/health` and the served path is
60 * `/api/health`. Do not declare `/api/health` in that file — it becomes
61 * `/api/api/health`.
62 */
63export async function healthResponse(options: HealthOptions = {}): Promise<Response> {
64 const check = options.check ?? checkApplicationHealth
65
66 let health: ApplicationHealthResult
67 try {
68 health = await check()
69 }
70 catch (err) {
71 // A probe runner that throws is itself a failure, and reporting it as one
72 // beats a 500 from an unhandled rejection: the monitor gets a body it can
73 // read rather than an opaque error page.
74 health = {
75 status: 'degraded',
76 checks: { probe: { ok: false, ms: 0, message: err instanceof Error ? err.message : String(err) } },
77 timestamp: Date.now(),
78 }
79 }
80
81 return new Response(JSON.stringify({ app: APP, ...health, ...(options.extra ?? {}) }), {
82 status: health.status === 'healthy' ? 200 : 503,
83 headers: {
84 'Content-Type': 'application/json',
85 // A cached health check reports the past. Nothing between us and the
86 // monitor may answer this from a store.
87 'Cache-Control': 'no-store',
88 },
89 })
90}
bun.lockmodified+18-1
Changes to bun.lock
@@ -18,6 +18,7 @@
1818 "ts-medium-editor": "^0.1.6",
1919 },
2020 "devDependencies": {
21 "@happy-dom/global-registrator": "^20.14.5",
2122 "@stacksjs/stx": "^0.2.170",
2223 "@stacksjs/ts-i18n": "0.1.8",
2324 "@types/leaflet": "^1.9.21",
@@ -33,6 +34,8 @@
3334
3435 "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
3536
37 "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.5" } }, "sha512-B05ID9DhSwLs6mlm1fzlkAtTIvB3duCvjJjfr19LBrlTK7VZtRjDqoTRIVv13GuYuNdhByu8LSZgThwV3Rkj7g=="],
38
3639 "@iconify-json/f7": ["@iconify-json/f7@1.2.2", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-7+RhvnncD+LRu0smuHOun4emOUu48XUGG6G0tYYbrXS7n4Io2xntx6nxa09/iI41t9hnH2oHHU3Dg7Ee60Ovow=="],
3740
3841 "@iconify-json/hugeicons": ["@iconify-json/hugeicons@1.2.32", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-J+iRbVCR9jZnX619pHzTuB5XSVJIPVE66i0a3TFcTjsjhoaULpjl/kgqDByQABhcNxhz0GnBIXHHke5XdDA36A=="],
@@ -367,6 +370,10 @@
367370
368371 "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
369372
373 "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
374
375 "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
376
370377 "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
371378
372379 "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -427,6 +434,8 @@
427434
428435 "buddy-bot": ["buddy-bot@0.9.27", "", { "dependencies": { "prompts": "^2.4.2", "ts-pantry": "^0.11.19" }, "bin": { "buddy-bot": "./dist/bin/cli.js" } }, "sha512-vog9xkkxix/n+6lJd6etL0mgla4goCyW4aTkPs1AsAk3j3EzH1J4mRPQWzPFCifUg5M9AnL0rXZJRo2vj8cOkw=="],
429436
437 "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
438
430439 "bun-git-hooks": ["bun-git-hooks@0.3.2", "", { "bin": { "git-hooks": "./dist/bin/cli.js", "bun-git-hooks": "./dist/bin/cli.js" } }, "sha512-cHkmB7sxmtPNekzG0nGXqbN8Q+dZf4muvV4eCB8dBCduW1DHR+4YulKinPUk/C6Rogy+JMyL1ysVVVWwZobwZw=="],
431440
432441 "bun-plugin-auto-imports": ["bun-plugin-auto-imports@0.4.1", "", {}, "sha512-S/+M76UtZc+1SBpqLKOmFXuEVBDnEBqjwnD3dsbHQao8vV+C46uN0JMQNJUZLdhR35KChZh1mHVJuiNqBrOSXQ=="],
@@ -457,6 +466,8 @@
457466
458467 "dynamodb-tooling": ["dynamodb-tooling@0.3.5", "", { "bin": { "dbtooling": "./dist/cli.js" } }, "sha512-iYbwz4Kns4Lm3+JB8shdM+x20dAEkPm7i9Z/7t6V2LpHnE7vsxElIYdoCbXJWCBvRVNq8qGHVEOG7AsoRmpNHg=="],
459468
469 "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
470
460471 "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
461472
462473 "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -465,6 +476,8 @@
465476
466477 "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
467478
479 "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="],
480
468481 "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
469482
470483 "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -491,7 +504,7 @@
491504
492505 "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
493506
494 "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
507 "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
495508
496509 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
497510
@@ -549,6 +562,10 @@
549562
550563 "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
551564
565 "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
566
567 "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
568
552569 "@stacksjs/i18n/@stacksjs/ts-i18n": ["@stacksjs/ts-i18n@0.1.11", "", { "dependencies": { "@stacksjs/clapp": "^0.2.9", "bunfig": "^0.15.6" }, "bin": { "i18n": "./dist/cli.js" } }, "sha512-33FM0gKdxBy6EUsUZLGAMe0D7QUZ4f/BQXRsQIyzkZgs2+juruUTevPuJGCjWSZsgJuiaBfEJn8Oq8ctuo1OWQ=="],
553570
554571 "@stacksjs/image/ts-images": ["ts-images@0.2.8", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-sm4d9pcP25FSUrRpP8kUDHvxZ4unK4Y3FYZ4pdt6AELLvcP93yUp49XVjaQBljlL2ajfblkQNa2blD393ychhg=="],
config/ui.tsmodified+1-1
Changes to config/ui.ts
@@ -33,7 +33,7 @@ export default {
3333 classPrefix: 'stx-',
3434 reset: 'tailwind',
3535
36 icons: ['heroicons'],
36 icons: ['hugeicons'],
3737
3838 fonts: {
3939 email: {
gate.tsmodified+32-0
Changes to gate.ts
@@ -299,6 +299,38 @@ for (const p of pages) html.set(p, await Bun.file(p).text())
299299 )
300300}
301301
302// 6h. Every getStaticPaths-wired route actually produced pages.
303//
304// This closes a hole the other checks cannot see. When the database has no
305// content, every hook returns zero paths, so no dynamic page is built — AND
306// the sitemap, which is built from the same queries, shrinks to match. The
307// two then agree with each other, "every sitemap URL has a built file"
308// stays true, and the gate passes over an artifact that dropped from 151
309// pages to 41. Agreement between two outputs of the same broken query is
310// not evidence that either is right.
311//
312// Caught exactly that: a wiped dev database produced a green 15/15 build
313// containing no judges, no courthouses and no articles.
314{
315 const routes: Array<[string, string]> = [
316 ['judges/*/profile.html', 'judges'],
317 ['court-houses/*/profile.html', 'court-houses'],
318 ['article/*.html', 'article'],
319 ]
320 const empty: string[] = []
321 for (const [pattern, label] of routes) {
322 let n = 0
323 for (const _f of new Bun.Glob(pattern).scanSync('dist')) n++
324 if (n === 0)
325 empty.push(label)
326 }
327 add(
328 'dynamic routes produced pages',
329 empty.length === 0,
330 empty.length ? `no pages for: ${empty.join(', ')} — getStaticPaths returned nothing, usually an empty database (run \`./buddy seed\`)` : '',
331 )
332}
333
302334// 7. The sitemap never advertises a path robots.txt forbids.
303335{
304336 const sitemap = await Bun.file('dist/sitemap.xml').text()
package.jsonmodified+7-1
Changes to package.json
@@ -15,7 +15,12 @@
1515 "typecheck": "bun tsc --noEmit",
1616 "typecheck:app": "bun tsc --noEmit -p tsconfig.app.json",
1717 "gate": "bun run build && bun gate.ts",
18 "gate:only": "bun gate.ts"
18 "gate:only": "bun gate.ts",
19 "docs:validate": "bun scripts/validate-docs.ts",
20 "ui:validate": "bun scripts/validate-ui.ts",
21 "visual:validate": "bun scripts/validate-visual.ts",
22 "release:validate": "bun scripts/validate-release.ts",
23 "deployment:smoke": "bun scripts/smoke-deployment.ts"
1924 },
2025 "dependencies": {
2126 "@stacksjs/components": "^0.2.170",
@@ -31,6 +36,7 @@
3136 "ts-medium-editor": "^0.1.6"
3237 },
3338 "devDependencies": {
39 "@happy-dom/global-registrator": "^20.14.5",
3440 "@stacksjs/stx": "^0.2.170",
3541 "@stacksjs/ts-i18n": "0.1.8",
3642 "@types/leaflet": "^1.9.21",
preview.tsmodified+46-2
Changes to preview.ts
@@ -49,8 +49,9 @@ const distDir = 'dist'
4949 * /api/judges, …). In dev the views server proxies it; a static host does
5050 * not. Without the proxy every API call resolves against the static origin
5151 * and 404s, so the site renders its shells and then stays empty forever.
52 * Note this preview server does NOT fake it, so `bun run preview` shows
53 * chrome without data — that is expected here, not a bug.
52 * This server DOES fake it (see API_ORIGIN below) so the built artifact can
53 * be exercised end-to-end locally — but that is a local convenience, not a
54 * substitute for configuring it at the host.
5455 *
5556 * On CloudFront (config/cloud.ts sets driver: 'aws') both are cache behaviours:
5657 * an /api/* behaviour with the API as origin, and a CloudFront Function or a
@@ -62,12 +63,55 @@ const SHELL_REWRITES: Array<[RegExp, string]> = [
6263 [/^\/verify-email\/[^/]+\/[^/]+\/?$/, '/verify-email.html'],
6364]
6465
66/**
67 * Where /api/* is forwarded, so the BUILT artifact can be exercised locally
68 * against a real API.
69 *
70 * Without this, previewing dist/ was only half an end-to-end: the pre-rendered
71 * pages, the per-entity SEO and the hashed CSP were all real, but every store
72 * call 404'd against the static origin, so nothing ever loaded data. Meanwhile
73 * the dev server has the API but serves from source, so it shows NONE of the
74 * build-time output. Neither one alone exercises what actually ships.
75 *
76 * Defaults to the dev API port. Override with API_ORIGIN to point at a deployed
77 * one.
78 */
79const apiOrigin = (process.env.API_ORIGIN || 'http://localhost:4008').replace(/\/+$/, '')
80
6581Bun.serve({
6682 port,
6783 async fetch(req) {
6884 const url = new URL(req.url)
6985 let pathname = url.pathname
7086
87 // API proxy runs before everything: /api/... has no file behind it, and the
88 // extensionless-to-.html mapping below would otherwise turn it into a
89 // confusing 404 for a page nobody asked for.
90 if (pathname.startsWith('/api/')) {
91 const target = `${apiOrigin}${pathname}${url.search}`
92 try {
93 const upstream = await fetch(target, {
94 method: req.method,
95 headers: req.headers,
96 body: req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer(),
97 redirect: 'manual',
98 })
99 return new Response(upstream.body, {
100 status: upstream.status,
101 statusText: upstream.statusText,
102 headers: upstream.headers,
103 })
104 }
105 catch {
106 // Distinct from a 404: the API simply isn't up. Say so, rather than
107 // letting the client see an ambiguous failure and blame the artifact.
108 return new Response(
109 JSON.stringify({ error: `preview: no API reachable at ${apiOrigin}. Start it, or set API_ORIGIN.` }),
110 { status: 502, headers: { 'content-type': 'application/json' } },
111 )
112 }
113 }
114
71115 // Shell rewrites run FIRST — before the .html mapping below, which would
72116 // otherwise turn /verify-email/1/abc into a lookup for a file that by
73117 // definition cannot exist.
resources/components/Bench/Admin/ReviewsTable.stxmodified+1-0
Changes to resources/components/Bench/Admin/ReviewsTable.stx
@@ -272,6 +272,7 @@
272272 <textarea
273273 x-model="responseDraft"
274274 rows="4"
275 aria-label="Judge's official response"
275276 placeholder="Enter the judge's official response…"
276277 class="block mt-2 px-3 py-2 w-full text-gray-900 text-sm bg-white border border-gray-300 rounded-md focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
277278 ></textarea>
resources/components/Bench/ArticleView.stxmodified+1-0
Changes to resources/components/Bench/ArticleView.stx
@@ -820,6 +820,7 @@
820820 <textarea
821821 x-model="judgeResponseDraft"
822822 rows="4"
823 aria-label="Your official response to this review"
823824 placeholder="Write your official response…"
824825 class="block mt-2 px-3 py-2 w-full text-gray-900 text-sm bg-white border border-gray-300 rounded-md focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
825826 ></textarea>
resources/components/Bench/BenchComingSoon.stxmodified+1-1
Changes to resources/components/Bench/BenchComingSoon.stx
@@ -78,7 +78,7 @@
7878 <div class="mt-12">
7979 <form @submit.prevent="submitEmail()" class="flex gap-x-4 max-w-md">
8080 <div class="relative flex-auto">
81 <input type="email" x-model="email" placeholder="Enter your email" required x-disabled="loading"
81 <input type="email" x-model="email" aria-label="Email address" placeholder="Enter your email" required x-disabled="loading"
8282 class="block px-4 py-3 w-full text-gray-900 sm:leading-6 sm:text-sm placeholder:text-gray-400 bg-off-gray border-0 ring-1 ring-gray-300 ring-inset rounded-md focus:ring-2 focus:ring-gray-600 focus:ring-inset shadow-sm">
8383 </div>
8484 <button type="submit" x-disabled="loading"
resources/components/Bench/Comments.stxmodified+1-0
Changes to resources/components/Bench/Comments.stx
@@ -205,6 +205,7 @@
205205 id="comment-body"
206206 x-model="commentDraft"
207207 rows="3"
208 aria-label="Add a comment"
208209 placeholder="Add a comment — context, clarification, or a respectful pushback."
209210 @input="commentFieldError.set(false)"
210211 @focus="commentFieldError.set(false)"
resources/components/Bench/ConfirmHost.stxmodified+14-3
Changes to resources/components/Bench/ConfirmHost.stx
@@ -117,7 +117,18 @@
117117 @click="$event.stopPropagation()"
118118 class="p-6 max-w-md w-full bg-white border border-gray-200 rounded-2xl shadow-xl"
119119 >
120 <h2 id="bench-confirm-title" class="font-semibold text-gray-900 text-lg" :text="confirmStore.title()"></h2>
120 {{--
121 The static text inside these four elements mirrors the confirm store's
122 own defaults ('Are you sure?' / 'Confirm' / 'Cancel'), and `:text`
123 overwrites it the moment the dialog is asked to open. It is NOT dead
124 markup: without it the static build ships an empty <h2> and two buttons
125 with no accessible name, which is both an a11y defect (a screen reader
126 announces an empty heading and two unlabelled buttons) and a
127 release-gate failure — scripts/validate-release.ts rejects an empty
128 primary heading, and scripts/validate-ui.ts rejects an icon-only button
129 with no name. Keep these in sync with resources/stores/confirm.ts.
130 --}}
131 <h2 id="bench-confirm-title" class="font-semibold text-gray-900 text-lg" :text="confirmStore.title()">Are you sure?</h2>
121132 <p id="bench-confirm-message" class="mt-2 text-gray-600 text-sm" :text="confirmStore.message()"></p>
122133 <div class="flex gap-3 justify-end mt-6">
123134 <button
@@ -125,14 +136,14 @@
125136 @click="confirmStore.dismiss()"
126137 class="px-4 py-2 font-medium text-gray-700 text-sm bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors"
127138 :text="confirmStore.cancelLabel()"
128 ></button>
139 >Cancel</button>
129140 <button
130141 type="button"
131142 @click="confirmStore.accept()"
132143 class="px-4 py-2 font-medium text-sm text-white rounded-lg transition-colors"
133144 x-class="acceptClass()"
134145 :text="confirmStore.confirmLabel()"
135 ></button>
146 >Confirm</button>
136147 </div>
137148 </div>
138149</div>
resources/components/Bench/Court/CourtHouseDirectory.stxmodified+1-0
Changes to resources/components/Bench/Court/CourtHouseDirectory.stx
@@ -196,6 +196,7 @@
196196 <input
197197 type="text"
198198 x-model="searchQuery"
199 aria-label="Search courthouses by name, city, or state"
199200 placeholder="Search by name, city, or state..."
200201 class="block pl-12 pr-4 py-3 w-full text-gray-900 sm:leading-6 sm:text-sm placeholder:text-gray-400 bg-gray-50 border-0 ring-1 ring-gray-200 ring-inset rounded-xl focus:ring-2 focus:ring-inset focus:ring-softBrown duration-200 transition-all"
201202 >
resources/components/Bench/Court/CourtJudges.stxmodified+1-1
Changes to resources/components/Bench/Court/CourtJudges.stx
@@ -22,7 +22,7 @@
2222 <p class="mt-1 text-gray-500 text-sm">{{ `Active judges currently sitting at ${court.name}` }}</p>
2323 </div>
2424 <div class="hidden sm:block">
25 <select class="pl-3 pr-10 py-2 text-gray-900 sm:text-sm bg-white ring-1 ring-gray-300 ring-inset rounded-md focus:ring-2 focus:ring-blue-600 focus:ring-inset">
25 <select aria-label="Filter judges by department" class="pl-3 pr-10 py-2 text-gray-900 sm:text-sm bg-white ring-1 ring-gray-300 ring-inset rounded-md focus:ring-2 focus:ring-blue-600 focus:ring-inset">
2626 <option>All Departments</option>
2727 <option>Criminal</option>
2828 <option>Civil</option>
resources/components/Bench/Judge/JudgeDirectory.stxmodified+1-0
Changes to resources/components/Bench/Judge/JudgeDirectory.stx
@@ -263,6 +263,7 @@
263263 type="text"
264264 x-model="searchQuery"
265265 @input="resetWindow()"
266 aria-label="Search judges by name, court, or location"
266267 placeholder="Search by name, court, or location..."
267268 class="block pl-12 pr-4 py-3 w-full text-gray-900 sm:leading-6 sm:text-sm placeholder:text-gray-400 bg-gray-50 border-0 ring-1 ring-gray-200 ring-inset rounded-xl focus:ring-2 focus:ring-inset focus:ring-softBrown duration-200 transition-all"
268269 >
resources/components/Bench/Judge/ReviewJudgeSearch.stxmodified+1-0
Changes to resources/components/Bench/Judge/ReviewJudgeSearch.stx
@@ -88,6 +88,7 @@
8888 :value="searchInput()"
8989 @input="handleInput($event.target.value)"
9090 class="block pl-10 pr-10 py-3 w-full text-gray-900 text-sm placeholder:text-gray-400 bg-white border border-gray-300 rounded-md focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 shadow-sm"
91 aria-label="Search for a judge"
9192 placeholder="Search for a judge..."
9293 autocomplete="off"
9394 >
resources/components/Bench/MobileMenu.stxmodified+1-0
Changes to resources/components/Bench/MobileMenu.stx
@@ -34,6 +34,7 @@
3434 <input
3535 type="text"
3636 x-model="mobileSearchQuery"
37 aria-label="Search judges or courts"
3738 placeholder="Search judges or courts..."
3839 class="block pl-9 pr-3 py-2 w-full text-gray-900 sm:text-sm placeholder:text-gray-400 bg-white border-0 ring-1 ring-gray-200 ring-inset rounded-lg focus:ring-2 focus:ring-blue-600 focus:ring-inset"
3940 >
resources/components/Bench/SearchView.stxmodified+1-0
Changes to resources/components/Bench/SearchView.stx
@@ -75,6 +75,7 @@
7575 <input
7676 type="text"
7777 x-model="search.query"
78 aria-label="Search judges, courts, or locations"
7879 placeholder="Search judges, courts, or locations..."
7980 class="block pl-12 pr-32 py-4 w-full text-gray-900 sm:text-base placeholder:text-gray-400 bg-white border-0 ring-1 ring-gray-200 ring-inset rounded-xl focus:ring-2 focus:ring-blue-600 focus:ring-inset shadow-sm"
8081 >
resources/components/Bench/VerifyEmail.stxmodified+2-2
Changes to resources/components/Bench/VerifyEmail.stx
@@ -93,7 +93,7 @@
9393 x-disabled="resending"
9494 class="inline-flex items-center mt-5 px-4 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
9595 :text="resending() ? 'Sending…' : 'Resend verification email'"
96 ></button>
96 >Resend verification email</button>
9797 <p class="mt-3 text-gray-500 text-xs" :text="resendNote()"></p>
9898 </div>
9999
@@ -107,7 +107,7 @@
107107 x-disabled="resending"
108108 class="inline-flex items-center mt-5 px-4 py-2 font-semibold text-gray-800 text-sm bg-white hover:bg-gray-50 border border-gray-300 rounded-md disabled:opacity-50"
109109 :text="resending() ? 'Sending…' : 'Resend verification email'"
110 ></button>
110 >Resend verification email</button>
111111 <p class="mt-3 text-gray-500 text-xs" :text="resendNote()"></p>
112112 </div>
113113 </div>
routes/api.tsmodified+9-0
Changes to routes/api.ts
@@ -1,4 +1,5 @@
11import { response, route } from '@stacksjs/router'
2import { healthResponse } from '../app/Support/health'
23
34/**
45 * This file is the entry point for your application's API routes.
@@ -17,6 +18,14 @@ import { response, route } from '@stacksjs/router'
1718 * @see https://docs.stacksjs.com/routing
1819 */
1920
21// Health — the endpoint uptime monitoring points at. Declared bare (`/health`)
22// because this file auto-prefixes with `/api`, so it serves at `/api/health`.
23// Unauthenticated and deliberately uncached; answers 503 when the database or
24// cache probe fails, so a plain HTTP check gets the right answer without
25// parsing the body. See app/Support/health.ts for why it isn't the framework's
26// route.health().
27route.get('/health', () => healthResponse())
28
2029// Your custom routes go here:
2130route.get('/', () => response.text('hello'))
2231route.get('/coming-soon', 'Controllers/ComingSoonController@index')
scripts/smoke-deployment.tsadded+143-0
Changes to scripts/smoke-deployment.ts
@@ -0,0 +1,143 @@
1import { readFileSync } from 'node:fs'
2import { resolve } from 'node:path'
3
4interface SmokeContract {
5 domain: string
6 homeText: string
7}
8
9interface HealthPayload {
10 app?: unknown
11 status?: unknown
12 checks?: {
13 database?: {
14 ok?: unknown
15 message?: unknown
16 }
17 }
18}
19
20const CONTRACTS: Record<string, SmokeContract> = {
21 'bench-review': { domain: 'benchreview.org', homeText: 'Bench Review' },
22 analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' },
23 bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' },
24 commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' },
25 loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' },
26 reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' },
27 status: { domain: 'statushq.org', homeText: 'Know the moment' },
28}
29
30const projectRoot = resolve(import.meta.dir, '..')
31const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
32const packageName = String(packageJson.name || '')
33const directoryName = projectRoot.split('/').pop() || ''
34const appName = CONTRACTS[packageName] ? packageName : directoryName
35const contract = CONTRACTS[appName]
36
37if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`)
38
39const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '')
40const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10))
41const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000))
42
43async function readWithRetry(path: string): Promise<string> {
44 let lastFailure = 'no response'
45 for (let attempt = 1; attempt <= attempts; attempt += 1) {
46 try {
47 const separator = path.includes('?') ? '&' : '?'
48 const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, {
49 headers: {
50 accept: path === '/robots.txt'
51 ? 'text/plain'
52 : path === '/api/health'
53 ? 'application/json'
54 : 'text/html',
55 },
56 redirect: 'follow',
57 signal: AbortSignal.timeout(10_000),
58 })
59 const body = await response.text()
60 if (response.ok) return body
61 lastFailure = `HTTP ${response.status}`
62 }
63 catch (error) {
64 lastFailure = error instanceof Error ? error.message : String(error)
65 }
66 if (attempt < attempts) await Bun.sleep(pauseMs)
67 }
68 throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`)
69}
70
71export function assertHealthyDeployment(body: string, expectedApp: string = appName): void {
72 let health: HealthPayload
73 try {
74 health = JSON.parse(body) as HealthPayload
75 }
76 catch {
77 throw new Error('Health smoke response is not valid JSON.')
78 }
79
80 if (health.app !== expectedApp)
81 throw new Error(`Health smoke response belongs to ${String(health.app || 'an unknown app')}, not ${expectedApp}.`)
82
83 if (health.status !== 'healthy')
84 throw new Error(`Health smoke reported application status ${String(health.status || 'unknown')}.`)
85
86 if (health.checks?.database?.ok !== true) {
87 const detail = health.checks?.database?.message
88 throw new Error(`Health smoke reported an unhealthy database${detail ? `: ${String(detail)}` : '.'}`)
89 }
90}
91
92/*
93 * Unresolved stx expressions in the SERVED html.
94 *
95 * This has to run against a served page, because the two render paths disagree:
96 * `bun run build` resolves a component's expressions correctly, while
97 * `buddy serve` — what production actually runs — can ship the component's own
98 * template verbatim. bughq went live with every <Button> reading
99 * `class=""` while its dist/ was correct and lint, tsc, stx
100 * typecheck, the tests and release:validate were all clean (stacksjs/stx#2037).
101 * No check over dist/ can see it.
102 *
103 * Attributes specifically: the visual gate already looks for `{{`, but through
104 * `document.body.innerText`, which by definition never contains an attribute
105 * value. That blind spot is why it shipped. A served attribute value never
106 * legitimately contains `{{`.
107 */
108function unresolvedAttributes(html: string): string[] {
109 const found = new Set<string>()
110 for (const m of html.matchAll(/\s([a-zA-Z_:@][\w:.-]*)\s*=\s*"([^"]*\{\{[^"]*)"/g))
111 found.add(`${m[1]}="${m[2].trim()}"`)
112 return [...found]
113}
114
115async function run(): Promise<void> {
116 const home = await readWithRetry('/')
117 if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`)
118
119 const login = await readWithRetry('/login')
120 if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.')
121
122 const health = await readWithRetry('/api/health')
123 assertHealthyDeployment(health)
124
125 for (const [label, body] of [['homepage', home], ['login', login]] as const) {
126 const leftovers = unresolvedAttributes(body)
127 if (leftovers.length > 0) {
128 throw new Error(
129 `Deployed ${label} ships ${leftovers.length} unresolved stx expression(s) in attributes, `
130 + `so a component rendered its own template instead of its output: ${leftovers.join(', ')}`,
131 )
132 }
133 }
134
135 const robots = await readWithRetry('/robots.txt')
136 if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
137 if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
138
139 console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, database health, robots metadata, and no unresolved stx expressions are live.`)
140}
141
142if (import.meta.main)
143 await run()
scripts/validate-docs.tsadded+102-0
Changes to scripts/validate-docs.ts
@@ -0,0 +1,102 @@
1import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2import { dirname, join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const docsDir = resolve(root, 'docs')
6const configPath = resolve(root, 'config/docs.ts')
7const issues = new Set<string>()
8const incomingRoutes = new Set<string>()
9const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status']
10
11function markdownFiles(dir: string): string[] {
12 return readdirSync(dir).flatMap((name) => {
13 const path = join(dir, name)
14 return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : []
15 })
16}
17
18function proseOnly(text: string): string {
19 let inFence = false
20 return text.split('\n').filter((line) => {
21 if (/^(?:```|~~~)/.test(line)) {
22 inFence = !inFence
23 return false
24 }
25 return !inFence
26 }).join('\n')
27}
28
29function routeForFile(file: string): string {
30 const path = relative(docsDir, file).replace(/\\/g, '/')
31 if (path === 'index.md') return '/'
32 return `/${path.replace(/(?:\/index)?\.md$/, '')}`
33}
34
35function resolveDocTarget(source: string, rawTarget: string): string | null {
36 let target = rawTarget.replace(/^<|>$/g, '').split(/[?#]/, 1)[0]
37 if (!target || /^(?:[a-z]+:|#|mailto:|tel:)/i.test(rawTarget)) return null
38
39 if (target === '/docs') target = '/'
40 else if (target.startsWith('/docs/')) target = target.slice('/docs'.length)
41
42 const base = target.startsWith('/') ? resolve(docsDir, `.${target}`) : resolve(dirname(source), target)
43 const candidates = [base, `${base}.md`, join(base, 'index.md')]
44 const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile())
45 if (found) return found
46 if (target.startsWith('/') && appRoutePrefixes.some(prefix => target === prefix || target.startsWith(`${prefix}/`))) return null
47 return ''
48}
49
50function checkTarget(source: string, rawTarget: string): void {
51 const target = resolveDocTarget(source, rawTarget)
52 if (target === null) return
53 if (target === '') {
54 issues.add(`${relative(root, source)}: missing local target ${rawTarget}`)
55 return
56 }
57 if (target.startsWith(docsDir)) incomingRoutes.add(routeForFile(target))
58}
59
60if (!existsSync(docsDir)) {
61 console.error('Documentation directory does not exist: docs/')
62 process.exit(1)
63}
64
65const files = markdownFiles(docsDir)
66for (const file of files) {
67 const text = readFileSync(file, 'utf8')
68 const prose = proseOnly(text)
69 const name = relative(root, file)
70 const headings = prose.match(/^#\s+\S.*$/gm) ?? []
71 const homeLayout = /^layout:\s*home\s*$/m.test(text)
72
73 if (headings.length !== 1 && !(homeLayout && headings.length === 0)) {
74 issues.add(`${name}: expected one H1, found ${headings.length}`)
75 }
76
77 const fences = text.match(/^(?:```|~~~)/gm) ?? []
78 if (fences.length % 2 !== 0) issues.add(`${name}: unbalanced fenced code blocks`)
79 if (/[—–]/.test(prose)) issues.add(`${name}: contains a prohibited long dash`)
80 if (/\b(?:TODO|TBD|FIXME|XXX)\b/.test(prose)) issues.add(`${name}: contains an unfinished marker`)
81
82 for (const match of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) checkTarget(file, match[1])
83 for (const match of prose.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) checkTarget(file, match[1])
84}
85
86if (existsSync(configPath)) {
87 const config = readFileSync(configPath, 'utf8')
88 for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1])
89}
90
91for (const file of files) {
92 const route = routeForFile(file)
93 if (route !== '/' && !incomingRoutes.has(route)) {
94 issues.add(`${relative(root, file)}: not linked from navigation or another documentation page`)
95 }
96}
97
98console.log(`Validated ${files.length} documentation pages.`)
99if (issues.size > 0) {
100 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
101 process.exit(1)
102}
scripts/validate-release.tsadded+214-0
Changes to scripts/validate-release.ts
@@ -0,0 +1,214 @@
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 // bench-review. Markers are chosen to survive the coming-soon -> launched
12 // transition: `/` is served by the coming-soon gate today (title "Coming
13 // Soon - Bench Review") and by home.stx once that gate lifts, so the brand
14 // name is the only stable assertion for it. The other three assert their
15 // <h1>, which is real page content rather than chrome.
16 'bench-review': {
17 domain: 'benchreview.org',
18 minPages: 35,
19 routes: {
20 '/': ['Bench Review'],
21 '/judges': ['Judicial Directory'],
22 '/login': ['Welcome back'],
23 '/register': ['Create your account'],
24 },
25 },
26 analyticshq: {
27 domain: 'analyticshq.org',
28 minPages: 30,
29 routes: {
30 '/': ['analyticshq'],
31 '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'],
32 '/login': ['analyticshq'],
33 },
34 },
35 bughq: {
36 domain: 'bughq.org',
37 minPages: 25,
38 routes: {
39 '/': ['Error tracking for people who ship.', 'From throw to triaged'],
40 '/login': ['bughq'],
41 '/dashboard': ['bughq'],
42 },
43 },
44 commshq: {
45 domain: 'commshq.org',
46 minPages: 45,
47 routes: {
48 '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'],
49 '/login': ['CommsHQ'],
50 '/dashboard/commshq': ['CommsHQ'],
51 },
52 },
53 loghq: {
54 domain: 'loghq.org',
55 minPages: 30,
56 routes: {
57 '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'],
58 '/login': ['loghq'],
59 '/compare/sentry': ['loghq', 'Sentry'],
60 },
61 },
62 reportshq: {
63 domain: 'reportshq.org',
64 minPages: 20,
65 routes: {
66 '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'],
67 '/login': ['ReportsHQ'],
68 '/pricing': ['ReportsHQ'],
69 },
70 },
71 status: {
72 domain: 'statushq.org',
73 minPages: 35,
74 routes: {
75 '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'],
76 '/login': ['Welcome back'],
77 '/register': ['Create your account'],
78 },
79 },
80}
81
82const projectRoot = resolve(import.meta.dir, '..')
83const distRoot = resolve(projectRoot, 'dist')
84const ssrRoot = resolve(projectRoot, '.output')
85const ssrPages = resolve(ssrRoot, 'server/pages')
86const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
87const packageName = String(packageJson.name || '')
88const directoryName = projectRoot.split('/').pop() || ''
89const appName = CONTRACTS[packageName] ? packageName : directoryName
90const contract = CONTRACTS[appName]
91
92if (!contract)
93 throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`)
94
95const isSsr = existsSync(ssrPages)
96if (!existsSync(distRoot) && !isSsr)
97 throw new Error('No production artifact found. Run bun run build first.')
98
99function walkFiles(directory: string): string[] {
100 if (!existsSync(directory)) return []
101 const files: string[] = []
102 for (const entry of readdirSync(directory)) {
103 const path = join(directory, entry)
104 if (statSync(path).isDirectory()) files.push(...walkFiles(path))
105 else files.push(path)
106 }
107 return files
108}
109
110function routeArtifact(route: string): { html: string, source: string } | undefined {
111 if (!isSsr) {
112 const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`)
113 const nested = join(distRoot, route.slice(1), 'index.html')
114 const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined
115 return source ? { html: readFileSync(source, 'utf8'), source } : undefined
116 }
117
118 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
119 const source = join(ssrPages, `${name}.compiled.json`)
120 if (!existsSync(source)) return undefined
121 const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string }
122 return { html: compiled.html || '', source }
123}
124
125const pageFiles = isSsr
126 ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json'))
127 : walkFiles(distRoot).filter(path => path.endsWith('.html'))
128
129const failures: string[] = []
130if (pageFiles.length < contract.minPages)
131 failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`)
132
133const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot
134for (const [route, expectedText] of Object.entries(contract.routes)) {
135 const artifact = routeArtifact(route)
136 if (!artifact) {
137 failures.push(`${route}: production artifact is missing`)
138 continue
139 }
140
141 const withoutCode = artifact.html
142 .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
143 .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
144 .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ')
145 const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
146
147 for (const fragment of expectedText) {
148 if (!visibleText.includes(fragment))
149 failures.push(`${route}: expected text is missing: ${fragment}`)
150 }
151 if (/@(if|foreach|forelse)\b/.test(visibleText))
152 failures.push(`${route}: unresolved template syntax is visible`)
153 if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode))
154 failures.push(`${route}: an empty primary heading was emitted`)
155 if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html))
156 failures.push(`${route}: a secret-shaped assignment was emitted`)
157
158 const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi
159 let match: RegExpExecArray | null
160 while ((match = assetPattern.exec(artifact.html))) {
161 const assetPath = match[1]
162 const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/')
163 if (!looksLikeAsset) continue
164 const local = resolve(publicRoot, `.${assetPath}`)
165 if (!local.startsWith(`${publicRoot}/`) || !existsSync(local))
166 failures.push(`${route}: referenced asset is missing: ${assetPath}`)
167 }
168}
169
170for (const metadataFile of ['sitemap.xml', 'robots.txt']) {
171 const path = join(publicRoot, metadataFile)
172 if (!existsSync(path)) {
173 failures.push(`${metadataFile}: missing from production artifact`)
174 continue
175 }
176 const contents = readFileSync(path, 'utf8')
177 if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`)
178 if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`)
179}
180
181const migrationsDir = resolve(projectRoot, 'database/migrations')
182if (existsSync(migrationsDir)) {
183 for (const file of readdirSync(migrationsDir).sort()) {
184 const path = join(migrationsDir, file)
185 if (!statSync(path).isFile()) continue
186 if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`)
187 const sql = readFileSync(path, 'utf8')
188 if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`)
189 }
190}
191
192if (isSsr && failures.length === 0) {
193 const { startProductionServer } = await import('@stacksjs/stx')
194 const port = 42_000 + Math.floor(Math.random() * 8_000)
195 const server = await startProductionServer({ outputDir: ssrRoot, port })
196 try {
197 for (const route of Object.keys(contract.routes)) {
198 const response = await fetch(`http://127.0.0.1:${server.port}${route}`)
199 const body = await response.text()
200 if (!response.ok) failures.push(`${route}: production server returned ${response.status}`)
201 if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`)
202 }
203 }
204 finally {
205 server.stop()
206 }
207}
208
209if (failures.length) {
210 for (const failure of failures) console.error(`- ${failure}`)
211 process.exit(1)
212}
213
214console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`)
scripts/validate-ui.tsadded+90-0
Changes to scripts/validate-ui.ts
@@ -0,0 +1,90 @@
1import { readdirSync, readFileSync, statSync } from 'node:fs'
2import { join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const resources = resolve(root, 'resources')
6const issues = new Set<string>()
7
8function filesIn(directory: string): string[] {
9 return readdirSync(directory).flatMap((name) => {
10 const path = join(directory, name)
11 return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : []
12 })
13}
14
15function withoutComments(value: string): string {
16 const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ')
17 return value
18 .replace(/<!--[^]*?-->/g, preserveLines)
19 .replace(/\{\{--[^]*?--\}\}/g, preserveLines)
20 .replace(/\/\*[^]*?\*\//g, preserveLines)
21 .replace(/^\s*\/\/.*$/gm, preserveLines)
22}
23
24function lineAt(source: string, index: number): number {
25 return source.slice(0, index).split('\n').length
26}
27
28function named(attributes: string): boolean {
29 return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes)
30}
31
32function report(file: string, source: string, index: number, message: string): void {
33 issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`)
34}
35
36const files = filesIn(resources)
37const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file))
38const combined = files.map(file => readFileSync(file, 'utf8')).join('\n')
39
40for (const file of markupFiles) {
41 const original = readFileSync(file, 'utf8')
42 const source = withoutComments(original)
43
44 for (const match of source.matchAll(/<img\b[^>]*>/gi)) {
45 if (!/\b(?::?alt)=/.test(match[0]))
46 report(file, source, match.index, 'image has no alt text')
47 }
48
49 for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) {
50 const attributes = match[1]
51 const content = match[2]
52 .replace(/<[^>]+>/g, '')
53 .replace(/\{\{[^}]+\}\}/g, 'dynamic')
54 .replace(/@[a-z]+\([^)]*\)/gi, '')
55 .trim()
56 if (!content && !named(attributes))
57 report(file, source, match.index, 'icon-only button has no accessible name')
58 }
59
60 for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
61 const attributes = match[2]
62 if (/\btype=["']hidden["']/i.test(attributes) || named(attributes))
63 continue
64 const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1]
65 const before = source.slice(0, match.index)
66 const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>')
67 const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false
68 if (!wrapped && !referenced)
69 report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`)
70 }
71
72 for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi))
73 report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`)
74
75 for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) {
76 if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1]))
77 report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible')
78 }
79}
80
81if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined))
82 issues.add('resources: no visible keyboard focus treatment found')
83if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined))
84 issues.add('resources: no responsive breakpoint treatment found')
85
86console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`)
87if (issues.size) {
88 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
89 process.exit(1)
90}
scripts/validate-visual.tsadded+469-0
Changes to scripts/validate-visual.ts
@@ -0,0 +1,469 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { join, resolve } from 'node:path'
4
5interface Viewport {
6 name: string
7 width: number
8 height: number
9 mobile: boolean
10}
11
12interface PageAudit {
13 contrast: string[]
14 focusable: number
15 imagesWithoutAlt: number
16 overflow: number
17 undersizedControls: string[]
18 unresolved: boolean
19 unnamedControls: string[]
20}
21
22interface Pending {
23 reject: (error: Error) => void
24 resolve: (value: any) => void
25 timer: ReturnType<typeof setTimeout>
26}
27
28const DIST = resolve('dist')
29const SSR_OUTPUT = resolve('.output')
30const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages')
31const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public')
32const OUTPUT = resolve('storage/logs/visual-contract')
33const VIEWPORTS: Viewport[] = [
34 { name: 'desktop', width: 1440, height: 1000, mobile: false },
35 { name: 'tablet', width: 768, height: 1024, mobile: false },
36 { name: 'mobile', width: 390, height: 844, mobile: true },
37]
38// bench-review is light-only by design: the Gavel Blue design system ships
39// zero `dark:` utilities, no `data-theme` hook and no prefers-color-scheme
40// rule (verified: 0 occurrences across resources/). Auditing a "dark" pass
41// here would just re-audit light twice and double the runtime. Add 'dark'
42// back the day dark mode lands — the loop below already handles it.
43const THEMES = ['light'] as const
44const ROUTE_CANDIDATES = [
45 '/',
46 '/judges',
47 '/court-houses',
48 '/reviews',
49 '/search',
50 '/login',
51 '/register',
52 '/about',
53 '/faq',
54 '/guidelines',
55 '/profile',
56 '/settings',
57 '/my-reviews',
58]
59
60/**
61 * The authenticated app surface, where a 44px minimum touch target is a real
62 * requirement rather than a nice-to-have.
63 *
64 * Marketing and directory pages are dense with inline prose links that are
65 * legitimately smaller than 44px; holding those to a touch-target floor would
66 * fail every page on day one and the check would get switched off. Scoping it
67 * to the app shell is what keeps it enforceable.
68 */
69const APP_ROUTE_PREFIXES = ['/profile', '/settings', '/my-reviews', '/admin', '/notifications']
70
71
72function chromePath(): string {
73 const candidates = [
74 process.env.CHROME_PATH,
75 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
76 '/usr/bin/google-chrome',
77 '/usr/bin/google-chrome-stable',
78 '/usr/bin/chromium',
79 '/usr/bin/chromium-browser',
80 ]
81 const found = candidates.find(candidate => candidate && existsSync(candidate))
82 if (!found) throw new Error('Chrome was not found. Set CHROME_PATH to a Chrome or Chromium executable.')
83 return found
84}
85
86async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise<T> {
87 const started = Date.now()
88 let value = await read()
89 while (!value && Date.now() - started < timeout) {
90 await Bun.sleep(100)
91 value = await read()
92 }
93 if (!value) throw new Error(`Timed out after ${timeout}ms`)
94 return value
95}
96
97/**
98 * Wait for Chrome to publish its DevTools port, and say something useful when it
99 * does not.
100 *
101 * This used to be a plain `waitFor(() => existsSync(portFile), 10_000)` against a
102 * Chrome spawned with stderr discarded, so every launch failure — missing shared
103 * library, unwritable profile, sandbox refusal, OOM — arrived as the same four
104 * words, `Timed out after 10000ms`, with the one line that would have explained it
105 * thrown away. It failed twice in three CI runs and was undiagnosable both times.
106 *
107 * Two changes: the process is polled as well as the file, so a Chrome that exits
108 * reports immediately instead of burning the whole timeout first; and whatever it
109 * wrote to stderr is attached to the error.
110 */
111async function waitForDevToolsPort(
112 portFile: string,
113 chrome: { exitCode: number | null },
114 stderr: () => string,
115 chromeBinary: string,
116 timeout = 30_000,
117): Promise<void> {
118 const started = Date.now()
119 const describe = (headline: string): string => {
120 const tail = stderr().trim().split('\n').slice(-12).join('\n')
121 return [
122 headline,
123 ` binary: ${chromeBinary}`,
124 ` profile: ${portFile.replace(/\/DevToolsActivePort$/, '')}`,
125 tail ? ` stderr:\n${tail.replace(/^/gm, ' ')}` : ' stderr: (nothing was written)',
126 ].join('\n')
127 }
128
129 while (!existsSync(portFile)) {
130 // A dead Chrome will never write the file, so there is nothing to wait for.
131 if (chrome.exitCode !== null)
132 throw new Error(describe(`Chrome exited with code ${chrome.exitCode} before opening a debugging port.`))
133 if (Date.now() - started >= timeout)
134 throw new Error(describe(`Chrome did not open a debugging port within ${timeout}ms.`))
135 await Bun.sleep(100)
136 }
137}
138
139class Cdp {
140 private id = 0
141 private pending = new Map<number, Pending>()
142
143 constructor(private socket: WebSocket) {
144 socket.addEventListener('message', (event) => {
145 const message = JSON.parse(String(event.data))
146 if (!message.id) return
147 const pending = this.pending.get(message.id)
148 if (!pending) return
149 this.pending.delete(message.id)
150 clearTimeout(pending.timer)
151 if (message.error) pending.reject(new Error(message.error.message))
152 else pending.resolve(message.result)
153 })
154 }
155
156 send(method: string, params: Record<string, any> = {}): Promise<any> {
157 const id = ++this.id
158 return new Promise((resolve, reject) => {
159 const timer = setTimeout(() => {
160 this.pending.delete(id)
161 reject(new Error(`Chrome command timed out: ${method}`))
162 }, 15_000)
163 this.pending.set(id, { reject, resolve, timer })
164 this.socket.send(JSON.stringify({ id, method, params }))
165 })
166 }
167
168 async evaluate<T>(expression: string): Promise<T> {
169 const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
170 if (result.exceptionDetails)
171 throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
172 return result.result?.value as T
173 }
174}
175
176async function connect(url: string): Promise<Cdp> {
177 const socket = new WebSocket(url)
178 await new Promise<void>((resolve, reject) => {
179 socket.addEventListener('open', () => resolve(), { once: true })
180 socket.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true })
181 })
182 return new Cdp(socket)
183}
184
185function routeName(route: string): string {
186 return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')
187}
188
189function routeFile(route: string): { kind: 'html' | 'ssr', path: string } | undefined {
190 if (existsSync(DIST)) {
191 const base = route === '/' ? join(DIST, 'index.html') : join(DIST, `${route.slice(1)}.html`)
192 if (existsSync(base)) return { kind: 'html', path: base }
193 const index = join(DIST, route.slice(1), 'index.html')
194 if (existsSync(index)) return { kind: 'html', path: index }
195 }
196
197 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
198 const compiled = join(SSR_PAGES, `${name}.compiled.json`)
199 return existsSync(compiled) ? { kind: 'ssr', path: compiled } : undefined
200}
201
202function pageResponse(page: { kind: 'html' | 'ssr', path: string }): Response {
203 if (page.kind === 'html') return new Response(Bun.file(page.path))
204 const compiled = JSON.parse(readFileSync(page.path, 'utf8')) as { html?: string }
205 return new Response(compiled.html || '', { headers: { 'content-type': 'text/html; charset=utf-8' } })
206}
207
208const auditExpression = `(() => {
209 const visible = element => {
210 const style = getComputedStyle(element)
211 const rect = element.getBoundingClientRect()
212 if (element.closest('details:not([open])') && element.tagName !== 'SUMMARY') return false
213 return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
214 && rect.width > 0 && rect.height > 0
215 }
216 const label = element => {
217 if (element.getAttribute('aria-label')) return element.getAttribute('aria-label')
218 const labelledBy = element.getAttribute('aria-labelledby')
219 if (labelledBy && labelledBy.split(/\\s+/).some(id => document.getElementById(id)?.textContent?.trim())) return labelledBy
220 if (element.id && document.querySelector('label[for="' + CSS.escape(element.id) + '"]')?.textContent?.trim()) return element.id
221 if (element.closest('label')?.textContent?.trim()) return 'wrapped'
222 if (element.tagName === 'BUTTON' && element.textContent?.trim()) return 'text'
223 if (element.tagName === 'A' && element.textContent?.trim()) return 'text'
224 // A link or button whose only content is an image takes its accessible
225 // name from that image's alt text (accname step 2F). Without this, the
226 // footer brand link -- <a href="/"><img alt="Bench Review"></a> -- is
227 // reported as unnamed on every single page even though a screen reader
228 // announces it correctly.
229 if ((element.tagName === 'A' || element.tagName === 'BUTTON')
230 && element.querySelector('img[alt]:not([alt=""])')) return 'image-alt'
231 if (element.getAttribute('title')) return element.getAttribute('title')
232 return ''
233 }
234 // Chrome preserves the authored color space in the COMPUTED value, so a
235 // Crosswind/Tailwind-v4 palette hands this back 'oklch(0.546 0.245 262.881)'
236 // rather than 'rgb(37, 99, 235)'. The upstream rgb()-only regex returns null
237 // for that, background() then walks past the element and falls back to white,
238 // and every white-on-brand button reports a bogus 1.00:1 contrast failure --
239 // while any REAL low-contrast pair sitting on an oklch background is silently
240 // measured against white instead, so true failures get missed too. Verified
241 // against this app's own dist/: computed background was
242 // 'oklch(0.546 0.245 262.881)' and computed color 'rgb(255, 255, 255)'.
243 //
244 // The canvas round-trip delegates parsing to the engine, so it covers every
245 // CSS color function -- oklch, lab, lch, color(), hsl, hwb, named -- present
246 // or future, instead of chasing each one with another regex. 'copy'
247 // compositing makes fillRect replace rather than blend, so alpha survives.
248 const probeContext = (() => {
249 const canvas = document.createElement('canvas')
250 canvas.width = 1
251 canvas.height = 1
252 const context = canvas.getContext('2d', { willReadFrequently: true })
253 if (context) context.globalCompositeOperation = 'copy'
254 return context
255 })()
256 const parseRgb = value => {
257 if (!value) return null
258 const match = value.match(/^rgba?\\(([^)]+)\\)$/)
259 if (match) {
260 const parts = match[1].split(/[ ,/]+/).filter(Boolean).map(Number)
261 if (parts.length >= 3 && !parts.some(Number.isNaN))
262 return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 }
263 }
264 if (value === 'transparent' || !probeContext) return null
265 try {
266 probeContext.fillStyle = value
267 probeContext.fillRect(0, 0, 1, 1)
268 const data = probeContext.getImageData(0, 0, 1, 1).data
269 return { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }
270 }
271 catch {
272 return null
273 }
274 }
275 const luminance = color => {
276 const channel = value => {
277 const normalized = value / 255
278 return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
279 }
280 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
281 }
282 const background = element => {
283 let current = element
284 while (current) {
285 const color = parseRgb(getComputedStyle(current).backgroundColor)
286 if (color && color.a >= 0.98) return color
287 current = current.parentElement
288 }
289 return { r: 255, g: 255, b: 255, a: 1 }
290 }
291 const controls = [...document.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea')].filter(visible)
292 const unnamedControls = controls.filter(element => !label(element)).map(element => {
293 const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : ''
294 return element.tagName.toLowerCase() + identity
295 })
296 const undersizedControls = controls.filter(element => {
297 const rect = element.getBoundingClientRect()
298 return rect.width < 44 || rect.height < 44
299 }).map(element => {
300 const text = element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 30)
301 return text || element.getAttribute('aria-label') || element.tagName.toLowerCase()
302 })
303 const contrast = []
304 const textElements = [...document.querySelectorAll('h1, h2, h3, h4, p, label, button, a, summary, span')]
305 for (const element of textElements) {
306 if (!visible(element) || !element.textContent?.trim()) continue
307 if (element.children.length && !['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)) continue
308 const style = getComputedStyle(element)
309 const foreground = parseRgb(style.color)
310 const behind = background(element)
311 if (!foreground || foreground.a < 0.98) continue
312 const lighter = Math.max(luminance(foreground), luminance(behind))
313 const darker = Math.min(luminance(foreground), luminance(behind))
314 const ratio = (lighter + 0.05) / (darker + 0.05)
315 const large = Number.parseFloat(style.fontSize) >= 24 || (Number.parseFloat(style.fontSize) >= 18.66 && Number(style.fontWeight) >= 700)
316 if (ratio + 0.01 < (large ? 3 : 4.5)) {
317 const sample = element.textContent.trim().replace(/\\s+/g, ' ').slice(0, 42)
318 contrast.push(sample + ' (' + ratio.toFixed(2) + ':1)')
319 }
320 if (contrast.length >= 10) break
321 }
322 return {
323 contrast,
324 focusable: controls.length,
325 imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length,
326 overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
327 undersizedControls,
328 unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'),
329 unnamedControls,
330 }
331})()`
332
333async function main(): Promise<void> {
334 if (!existsSync(DIST) && !existsSync(SSR_PAGES))
335 throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`)
336 const routes = ROUTE_CANDIDATES.filter(route => routeFile(route))
337 if (routes.length < 3) throw new Error(`Expected at least three visual routes, found ${routes.join(', ') || 'none'}.`)
338
339 mkdirSync(OUTPUT, { recursive: true })
340 const server = Bun.serve({
341 hostname: '127.0.0.1',
342 port: 0,
343 fetch(request) {
344 const url = new URL(request.url)
345 const route = decodeURIComponent(url.pathname)
346 const page = routeFile(route)
347 if (page) return pageResponse(page)
348 const publicRoot = existsSync(DIST) ? DIST : SSR_PUBLIC
349 const asset = resolve(publicRoot, `.${route}`)
350 const file = asset.startsWith(`${publicRoot}/`) && existsSync(asset) ? asset : undefined
351 return file ? new Response(Bun.file(file)) : new Response('Not found', { status: 404 })
352 },
353 })
354 const profile = mkdtempSync(join(tmpdir(), 'bench-visual-'))
355 const chromeBinary = chromePath()
356 const chrome = Bun.spawn([
357 chromeBinary,
358 '--headless=new',
359 '--remote-debugging-port=0',
360 `--user-data-dir=${profile}`,
361 '--no-first-run',
362 '--no-default-browser-check',
363 '--disable-background-networking',
364 '--disable-component-update',
365 '--disable-extensions',
366 '--hide-scrollbars',
367 '--no-sandbox',
368 // CI runners give /dev/shm 64MB and run no session bus, and Chrome will sit
369 // there retrying both instead of binding its debugging port. Locally these
370 // are no-ops, so they stay unconditional rather than gated on process.env.CI.
371 '--disable-dev-shm-usage',
372 '--disable-gpu',
373 'about:blank',
374 ], { stdout: 'ignore', stderr: 'pipe' })
375
376 // Drained continuously rather than read on failure: a chatty Chrome can fill the
377 // pipe buffer and block on its own output, which would look exactly like the
378 // startup hang this is here to explain.
379 let chromeStderr = ''
380 const drainingStderr = (async () => {
381 try {
382 const decoder = new TextDecoder()
383 for await (const chunk of chrome.stderr as ReadableStream<Uint8Array>)
384 chromeStderr += decoder.decode(chunk, { stream: true })
385 }
386 catch {
387 // The process was killed mid-read; whatever arrived is still worth having.
388 }
389 })()
390
391 const failures: string[] = []
392 let screenshots = 0
393 try {
394 const portFile = join(profile, 'DevToolsActivePort')
395 await waitForDevToolsPort(portFile, chrome, () => chromeStderr, chromeBinary)
396 const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
397 const targets = await waitFor(async () => {
398 const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => [])
399 return Array.isArray(items) && items.length ? items : null
400 }) as Array<{ type: string, url: string, webSocketDebuggerUrl: string }>
401 const target = targets.find(item => item.type === 'page' && item.url === 'about:blank') || targets.find(item => item.type === 'page')
402 if (!target) throw new Error('Chrome did not expose a page target.')
403
404 const cdp = await connect(target.webSocketDebuggerUrl)
405 await cdp.send('Page.enable')
406 await cdp.send('Runtime.enable')
407 await cdp.send('Emulation.setScriptExecutionDisabled', { value: true })
408
409 for (const route of routes) {
410 for (const viewport of VIEWPORTS) {
411 await cdp.send('Emulation.setDeviceMetricsOverride', {
412 width: viewport.width,
413 height: viewport.height,
414 deviceScaleFactor: 1,
415 mobile: viewport.mobile,
416 })
417 await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` })
418 await waitFor(() => cdp.evaluate("document.readyState === 'complete'"))
419 await Bun.sleep(100)
420 for (const theme of THEMES) {
421 await cdp.evaluate(`(() => {
422 const theme = ${JSON.stringify(theme)}
423 document.documentElement.classList.toggle('dark', theme === 'dark')
424 document.documentElement.setAttribute('data-theme', theme)
425 document.documentElement.setAttribute('data-color-mode', theme)
426 document.documentElement.setAttribute('color-mode', theme)
427 })()`)
428 await Bun.sleep(500)
429 const audit = await cdp.evaluate<PageAudit>(auditExpression)
430 const key = `${route} at ${viewport.name}/${theme}`
431 if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`)
432 if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`)
433 if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`)
434 if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`)
435 if (APP_ROUTE_PREFIXES.some(prefix => route.startsWith(prefix)) && viewport.width <= 860 && audit.undersizedControls.length)
436 failures.push(`${key}: controls smaller than 44px ${audit.undersizedControls.join(', ')}`)
437 for (const issue of audit.contrast) failures.push(`${key}: low text contrast ${issue}`)
438
439 if (audit.focusable) {
440 await cdp.evaluate("document.body.setAttribute('tabindex', '-1'); document.body.focus()")
441 await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
442 await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
443 const focused = await cdp.evaluate("document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement")
444 if (!focused) failures.push(`${key}: Tab did not move focus to a control`)
445 }
446
447 const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true })
448 writeFileSync(join(OUTPUT, `${routeName(route)}-${theme}-${viewport.name}.png`), Buffer.from(shot.data, 'base64'))
449 screenshots += 1
450 }
451 }
452 }
453 }
454 finally {
455 chrome.kill()
456 await chrome.exited
457 await drainingStderr
458 server.stop(true)
459 rmSync(profile, { force: true, recursive: true })
460 }
461
462 console.log(`Rendered ${routes.length} routes in ${THEMES.length} themes at ${VIEWPORTS.length} viewports and wrote ${screenshots} screenshots.`)
463 if (failures.length) {
464 for (const failure of failures) console.error(`- ${failure}`)
465 process.exit(1)
466 }
467}
468
469await main()
site.config.tsadded+77-0
Changes to site.config.ts
@@ -0,0 +1,77 @@
1/**
2 * Site identity — the single source of truth for who this app says it is.
3 *
4 * Matches the convention used across the hq-apps fleet (analyticshq, bughq,
5 * commshq, loghq, reportshq, statushq): one root-level `site.config.ts` that
6 * owns the name, the canonical URL, and the SEO defaults, so the same strings
7 * stop being retyped in four places.
8 *
9 * Before this file, bench-review's identity lived in three places that could
10 * (and did) drift:
11 * - `stx.config.ts` → `app.head.title` + the default meta description
12 * - `app/Helpers/seoPages.ts` → the JSON-LD Organization / WebSite nodes,
13 * which hardcoded 'Bench Review' three times and the logo path once
14 * - `app/Helpers/sitemap.ts` → the fallback base URL
15 * All three now read from here. The one thing that stays in the environment is
16 * the *build-time* base URL (`APP_URL`), because it legitimately differs per
17 * environment — `url` below is the production canonical, used as the fallback
18 * when APP_URL is unset.
19 *
20 * NOTE: the framework does not auto-load this file (verified: nothing in the
21 * hq-apps imports their own `site.config.ts` either — it is a convention, not
22 * a hook). Consumers import it explicitly via the `~/site.config` alias.
23 */
24
25export interface SiteSeo {
26 /** Brand name as it appears in structured data and og:site_name. */
27 siteName: string
28 /** Default <title> for pages that don't set their own. */
29 title: string
30 /** Default meta description. */
31 description: string
32 /** Absolute URL to the default social-share image. */
33 image: string
34 /** Site-root-relative path to the same image, for `${base}`-prefixed use. */
35 imagePath: string
36 /** Favicon path, relative to the site root. */
37 favicon: string
38 /** og:locale. */
39 locale: string
40 /** og:type for the site as a whole. */
41 type: string
42 /** Absolute-path logo used by the JSON-LD Organization node. */
43 logo: string
44}
45
46export interface SiteConfig {
47 name: string
48 url: string
49 description: string
50 seo: SiteSeo
51 /** Reserved for per-page overrides; bench keeps those in app/Helpers/seoPages.ts. */
52 pages: Record<string, unknown>
53}
54
55const description = 'Bench Review is a public directory of judges where attorneys, clerks, and court staff share first-hand reviews. Search judges by name and court.'
56
57const url = 'https://benchreview.org'
58
59export default {
60 name: 'Bench Review',
61 url,
62 description,
63 seo: {
64 siteName: 'Bench Review',
65 title: 'Bench Review: Read and Write Reviews of Judges',
66 description,
67 // Both of these resolve to files that actually exist under public/ —
68 // validate-release.ts fails the build on a referenced asset that 404s.
69 image: `${url}/images/og-image.png`,
70 imagePath: '/images/og-image.png',
71 favicon: '/favicon.svg',
72 locale: 'en_US',
73 type: 'website',
74 logo: '/images/bench/logo.png',
75 },
76 pages: {},
77} satisfies SiteConfig
stx.config.tsmodified+5-2
Changes to stx.config.ts
@@ -14,6 +14,7 @@
1414import { tsAnalytics } from '@stacksjs/ts-analytics/stx'
1515import { FONT_HEAD_TAGS } from './app/Helpers/fontHead'
1616import { TS_ANALYTICS_APP_ID } from './config/ts-analytics'
17import site from './site.config'
1718
1819export default {
1920 componentsDir: 'components',
@@ -29,9 +30,11 @@ export default {
2930 head: {
3031 // Fallback title/description for pages that don't set their own via
3132 // @section('title') / @head (hoisted into the static <head>, stx#1756).
32 title: 'Bench Review: Read and Write Reviews of Judges',
33 // Both come from site.config.ts so the shell, the JSON-LD graph, and
34 // the sitemap can't disagree about what this site is called.
35 title: site.seo.title,
3336 meta: [
34 { name: 'description', content: 'Bench Review is a public directory of judges where attorneys, clerks, and court staff share first-hand reviews. Search judges by name and court.' },
37 { name: 'description', content: site.seo.description },
3538 ],
3639 // ts-analytics, added like a Nuxt module — just an App ID (Fathom-style),
3740 // set once in config/ts-analytics.ts. The endpoint is baked into the
tests/setup.tsmodified+29-0
Changes to tests/setup.ts
@@ -1,3 +1,4 @@
1import { GlobalRegistrator } from '@happy-dom/global-registrator'
12import { setupTestEnvironment } from '@stacksjs/testing'
23
34/**
@@ -6,6 +7,34 @@ import { setupTestEnvironment } from '@stacksjs/testing'
67 * A place to register logic that should run before the tests run.
78 * e.g. you may abstract your module mocks here, if you want
89 * to prevent the original module from being evaluated.
10 *
11 * ## Why happy-dom is registered here
12 *
13 * `bun test` runs in a plain server runtime with no `document`, and
14 * `setupTestEnvironment()` does not add one — `@stacksjs/testing` depends only
15 * on `@stacksjs/auth` and `@stacksjs/ts-cloud`, and calling it leaves
16 * `globalThis.document` undefined. So `tests/feature/ui.test.ts`, which came
17 * with the project scaffold and touches `document.body`, failed with
18 * `ReferenceError: document is not defined` from the first commit onward. The
19 * `test` job has been red on `main` ever since, which also meant it could not
20 * report a real regression.
21 *
22 * `GlobalRegistrator.register()` installs happy-dom's `window`/`document` onto
23 * the global scope — the pattern Bun documents for DOM testing — so a test that
24 * needs a DOM has one, and the suite stops being decorative.
25 *
26 * Registered BEFORE `setupTestEnvironment()` deliberately: anything the
27 * framework's setup does that sniffs for a browser-ish global should see the
28 * DOM already in place rather than racing it.
29 *
30 * Note this makes every test file DOM-ish. That is fine for this project (the
31 * only non-DOM concerns here are pure functions and `Response` objects, neither
32 * of which care), but if a test ever needs to assert genuinely server-only
33 * behaviour, move that file out from under this preload rather than
34 * conditionally unregistering — partial teardown of a registered global DOM is
35 * its own source of cross-test bleed.
936 */
1037
38GlobalRegistrator.register()
39
1140setupTestEnvironment()