also looking at this
Adopt the hq-apps app conventions
#76
31 files
+1656
-395
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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 | ||
| 8 | Production domain: **benchreview.org** (declared in [`site.config.ts`](site.config.ts)). | |
| 9 | ||
| 10 | ## What ships | |
| 11 | ||
| 12 | Two 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 | ||
| 19 | stx is a **build-time** templating engine here: every page in `dist/` is | |
| 20 | already 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 | |
| 22 | self-contained. | |
| 23 | ||
| 24 | ## The deploy contract | |
| 25 | ||
| 26 | The host must provide two things that are **not** part of the built artifact. | |
| 27 | A host that skips them serves a site that looks fine and is broken in two | |
| 28 | specific ways. | |
| 29 | ||
| 30 | ### 1. Rewrite `/verify-email/:id/:token` → `/verify-email.html` (status 200) | |
| 31 | ||
| 32 | Verification tokens are minted per request, so unlike every other dynamic | |
| 33 | route there is no finite set of pages to pre-render. Without this rewrite the | |
| 34 | link in **every signup confirmation email 404s**. | |
| 35 | ||
| 36 | It must be a *rewrite*, not a redirect: `Bench/VerifyEmail` reads the id and | |
| 37 | token off `location.pathname`, so the URL has to survive. | |
| 38 | ||
| 39 | ### 2. Proxy `/api/*` → the API origin (same-origin, status 200) | |
| 40 | ||
| 41 | Every 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. | |
| 43 | Without the proxy every API call resolves against the static origin and 404s, | |
| 44 | so the site renders its shells and then stays empty forever. | |
| 45 | ||
| 46 | `preview.ts` fakes both locally so the built artifact can be exercised | |
| 47 | end-to-end before shipping — that is a local convenience, not a substitute for | |
| 48 | configuring them at the host. | |
| 49 | ||
| 50 | On CloudFront these are both cache behaviours: an `/api/*` behaviour with the | |
| 51 | API as origin, and a CloudFront Function (or a `/verify-email/*` behaviour) | |
| 52 | rewriting 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, | |
| 58 | Open Graph tags, the JSON-LD graph, `sitemap.xml` and `robots.txt`, so it must | |
| 59 | be 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 | ||
| 73 | Local ports: views `4000`, API `4008`. | |
| 74 | ||
| 75 | ## Build | |
| 76 | ||
| 77 | ```bash | |
| 78 | APP_URL=https://benchreview.org bun run build | |
| 79 | ``` | |
| 80 | ||
| 81 | The build runs `buildApp()` (stx SSG) and then splices in, in order: per-page | |
| 82 | SEO head tags, `noindex` markers, per-entity SEO for dynamic pages, canonicals, | |
| 83 | the 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 | |
| 95 | bun run gate # build + 17 build/SEO integrity checks | |
| 96 | bun run ui:validate # accessibility lint over resources/ | |
| 97 | bun run visual:validate # headless-Chrome contrast / overflow / focus audit | |
| 98 | bun 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 | |
| 103 | build made with a production `APP_URL`**. That is deliberate: it is the check | |
| 104 | that catches a dev artifact being shipped. | |
| 105 | ||
| 106 | `visual:validate` needs a Chrome or Chromium binary; set `CHROME_PATH` if it | |
| 107 | isn't in a standard location. Screenshots land in | |
| 108 | `storage/logs/visual-contract/`. | |
| 109 | ||
| 110 | ## Preview the real artifact | |
| 111 | ||
| 112 | ```bash | |
| 113 | bun run preview # serves dist/ on :3001, proxies /api to :4008 | |
| 114 | ``` | |
| 115 | ||
| 116 | This is the only way to exercise what actually ships: the dev server has the | |
| 117 | API 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 | |
| 123 | bun run deployment:smoke | |
| 124 | ``` | |
| 125 | ||
| 126 | Hits 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 | |
| 128 | unresolved stx expressions in attributes. Override the target with | |
| 129 | `SMOKE_BASE_URL`; tune retries with `SMOKE_ATTEMPTS` / `SMOKE_RETRY_MS`. | |
| 130 | ||
| 131 | Health is served at **`GET /api/health`** and answers `503` when the database | |
| 132 | or cache probe fails, so a plain HTTP check gets the right answer without | |
| 133 | parsing the body. See [`app/Support/health.ts`](app/Support/health.ts). | |
| 134 | ||
| 135 | ## Database | |
| 136 | ||
| 137 | Migrations are plain SQL under `database/migrations/`, applied with | |
| 138 | `./buddy migrate`. Seeders under `database/seeders/` are idempotent and keyed | |
| 139 | on 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 | ||
| 150 | 1. **Pick a host** and implement the two deploy-contract rules above. | |
| 151 | 2. **`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. | |
| 155 | 3. **Set a production `APP_URL`** — without it the artifact is not deployable. | |
| 156 | 4. **Configure real SMTP** — verification and password reset are dead without it. | |
| 157 | 5. **Seed or restore the database** so dynamic pages are pre-rendered. | |
| 158 | 6. **`MAIL_FROM_ADDRESS`** still reads `no-reply@benchreview.com`; the | |
| 159 | canonical domain is `benchreview.org`. | |
| @@ -1,400 +1,142 @@ | ||
| 1 |  | |
| 1 | # Bench Review | |
| 2 | 2 | |
| 3 | # Rapid App & Library Development | |
| 3 | [](./LICENSE.md) | |
| 4 | 4 | |
| 5 | [](https://npmjs.com/package/stacks) | |
| 6 | [](https://github.com/stacksjs/stacks/actions?query=workflow%3Aci) | |
| 7 | [](http://commitizen.github.io/cz-cli/) | |
| 8 | [](https://npmjs.com/package/stacks) | |
| 9 | <!-- [![Codecov][codecov-src]][codecov-href] --> | |
| 5 | A public directory of judges, with first-hand reviews from the attorneys, | |
| 6 | clerks, and court staff who appear before them. Think Yelp, for the bench. | |
| 10 | 7 | |
| 11 | > [!WARNING] | |
| 12 | > Stay tuned. Open Beta coming soon. | |
| 13 | ||
| 14 | Stacks 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 | ||
| 25 | As 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. | |
| 8 | Built as a [Stacks](https://github.com/stacksjs/stacks) app: `defineModel()` | |
| 9 | for schema, [stx](https://github.com/stacksjs/stx) for views, Crosswind for | |
| 10 | styling, and `buddy` for everything else. | |
| 26 | 11 | |
| 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 | <!--  --> | |
| 30 | ||
| 31 | ## Get Started | |
| 32 | ||
| 33 | It’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 | |
| 21 | tab, and (for judges) recent rulings. Courthouses carry real building-level | |
| 22 | coordinates and render on a Leaflet/OSM map. | |
| 23 | ||
| 24 | **Reviews** — a rich-text editor with server-side draft autosave (so a draft | |
| 25 | survives a device change), photo upload that strips EXIF and emits a | |
| 26 | three-size resize ladder, 1–5 star ratings, threaded comments, and a | |
| 27 | "helpful" vote with a denormalised counter so public feeds stay `COUNT`-free. | |
| 28 | Reviews can be posted anonymously; the reviewer label is derived through a | |
| 29 | helper built to avoid de-anonymisation. | |
| 30 | ||
| 31 | **Moderation** — an admin surface with queues for flagged reviews, judge | |
| 32 | submissions, and credential claims, plus user suspension (reversible) and | |
| 33 | deletion, and an append-only moderation audit log recording who did what. | |
| 34 | ||
| 35 | **Trust** — reviewers can submit a credential claim (role + state) that an | |
| 36 | admin verifies out of band. Judges can claim their own profile and post an | |
| 37 | official response to a review of them; the same right-of-reply is also | |
| 38 | available admin-mediated. | |
| 39 | ||
| 40 | **Accounts** — registration, login, email verification (with resend), | |
| 41 | password reset, password change, and sign-out-everywhere. Plus data export and | |
| 42 | self-serve account deletion with a full cascade. | |
| 43 | ||
| 44 | **Public hygiene** — a DB-derived sitemap and robots.txt, per-page canonical / | |
| 45 | Open Graph / Twitter tags, a JSON-LD Organization + WebSite + WebPage graph, | |
| 46 | and a Content-Security-Policy with every inline script hashed. Rate limits sit | |
| 47 | on 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 | |
| 34 | 63 | |
| 35 | 64 | ```bash |
| 36 | curl -Ssf stacksjs.org/install | sh # wip | |
| 37 | ||
| 38 | # alternatively, if Bun >= v1.2.5 is installed already | |
| 39 | # you may also get started via | |
| 40 | bunx buddy new my-project | |
| 65 | bun install | |
| 66 | cp .env.example .env | |
| 67 | ./buddy key:generate | |
| 68 | ./buddy migrate | |
| 69 | ./buddy seed | |
| 70 | ./buddy dev | |
| 41 | 71 | ``` |
| 42 | 72 | |
| 43 | ## Usage | |
| 73 | Views come up on `:4000`, the API on `:4008`. | |
| 44 | 74 | |
| 45 | The 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. | |
| 46 | 77 | |
| 47 | _Meet the toolkit, Buddy:_ | |
| 78 | ## Layout | |
| 48 | 79 | |
| 49 | ```bash | |
| 50 | buddy install # installs all dependencies | |
| 51 | buddy dev # starts one of the dev servers (frontend, API, components, docs, desktop, etc.) | |
| 52 | buddy share # creates a sharable link of your local project | |
| 53 | buddy build # follow CLI prompts to select which library (or server) to build | |
| 54 | buddy commit # follow CLI prompts for committing changes | |
| 55 | buddy release # creates the releases for the stack & consequently, publishes them to npm | |
| 56 | buddy upgrade # auto-update all deps & the Stacks framework | |
| 57 | ||
| 58 | buddy make:component HelloWorld # scaffolds a Component | |
| 59 | buddy make:function HelloWorld # scaffolds a Function | |
| 60 | buddy make:model Car # scaffolds a Model | |
| 61 | buddy make:action UpdateCar # scaffolds an Action | |
| 62 | buddy make:migration create_cars_table # scaffolds a Migration | |
| 63 | buddy make:view Car # scaffolds a Page (https://my-project.test/car) | |
| 64 | buddy make:command Inspire # scaffolds a Command | |
| 65 | buddy make:job SendEmail # scaffolds a Job | |
| 66 | buddy make:middleware Logger # scaffolds a new Middleware | |
| 67 | buddy make:notification WelcomeEmail # scaffolds a Notification | |
| 68 | buddy make:lang de # scaffolds a language file | |
| 69 | ||
| 70 | buddy list # lists all available commands | |
| 71 | buddy --help | |
| 72 | 80 | ``` |
| 73 | ||
| 74 | <details> | |
| 75 | <summary>View the complete Buddy Toolkit</summary> | |
| 76 | ||
| 77 | ```bash | |
| 78 | buddy --version # get the Stacks version | |
| 79 | buddy --help # view help menu | |
| 80 | # please note: you may suffix any command with the | |
| 81 | # `command --help` flag to review the help menu | |
| 82 | ||
| 83 | buddy install # installs dependencies | |
| 84 | buddy add # adds a stack or dependency | |
| 85 | buddy fresh # fresh reinstall of all deps | |
| 86 | buddy clean # removes all deps | |
| 87 | buddy setup # sets up the project initially | |
| 88 | buddy setup:oh-my-zsh # optional: sets up Oh My Zsh with auto-completions & "aliases" | |
| 89 | ||
| 90 | buddy upgrade # upgrades all dependencies | |
| 91 | buddy upgrade -i # prompts you to select which updates to apply (wip) | |
| 92 | buddy upgrade:dependencies # auto-upgrades package.json deps | |
| 93 | buddy upgrade:framework # auto-upgrades deps & the Stacks framework | |
| 94 | buddy upgrade:search-engine # auto-upgrades configured search engine | |
| 95 | buddy upgrade:shell # upgrades the shell integration | |
| 96 | buddy upgrade:binary # upgrades the `stacks` binary | |
| 97 | buddy upgrade:bun # upgrades to latest project-defined Bun version | |
| 98 | buddy 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 | ||
| 103 | buddy dev # starts the frontend dev server | |
| 104 | buddy dev -i # prompts any of the dev servers (components, functions, views, docs, or api) | |
| 105 | buddy dev:api # starts the API dev server | |
| 106 | buddy dev:dashboard # starts the Admin/Dashboard dev server | |
| 107 | buddy dev:desktop # starts the Desktop dev server | |
| 108 | buddy dev:views # starts frontend dev server | |
| 109 | buddy dev:components # starts component dev server | |
| 110 | buddy dev:functions # stubs functions | |
| 111 | buddy dev:docs # starts local docs dev server | |
| 112 | buddy dev docs # also starts the local docs dev server (colon is optional for all commands) | |
| 113 | buddy development # `buddy dev` alias | |
| 114 | ||
| 115 | buddy 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 | |
| 118 | buddy serve | |
| 119 | buddy serve:components | |
| 120 | buddy serve:desktop | |
| 121 | buddy serve:views | |
| 122 | buddy serve:functions | |
| 123 | buddy serve:docs | |
| 124 | ||
| 125 | # building for production (e.g. AWS, Google Cloud, npm, Vercel, Netlify, et al.) | |
| 126 | buddy build # select a specific build (follow CLI prompts) | |
| 127 | buddy build:views # builds SSG views | |
| 128 | buddy build:desktop # builds Desktop application | |
| 129 | buddy build:library # builds any or all libraries | |
| 130 | buddy build:functions # builds function library | |
| 131 | buddy build:components # builds Vue component library & Web Component library | |
| 132 | buddy build:web-components # builds framework agnostic Web Component library (i.e. Custom Elements) | |
| 133 | buddy build:vue-components # builds Vue 2 & 3-ready Component library | |
| 134 | buddy build:all # builds all your code | |
| 135 | ||
| 136 | # `buddy build` aliases | |
| 137 | buddy prod | |
| 138 | buddy prod:components | |
| 139 | buddy prod:desktop | |
| 140 | buddy prod:library | |
| 141 | buddy prod:views | |
| 142 | buddy prod:functions | |
| 143 | buddy prod:vue-components | |
| 144 | buddy prod:web-components | |
| 145 | buddy prod:all | |
| 146 | buddy production # `buddy prod` alias | |
| 147 | ||
| 148 | # sets your application key | |
| 149 | buddy key:generate | |
| 150 | ||
| 151 | buddy make:component HelloWorld # bootstraps a HelloWorld component | |
| 152 | buddy make:function hello-world # bootstraps a hello-world function | |
| 153 | buddy make:view hello-world # bootstraps a hello-word page | |
| 154 | buddy make:model Car # bootstraps a Car model | |
| 155 | buddy make:database cars # creates a cars database | |
| 156 | buddy make:migration create_cars_table # creates a cars migration file | |
| 157 | buddy make:factory cars # creates a Car factory file | |
| 158 | buddy make:table cars # bootstraps a cars data table | |
| 159 | buddy make:notification welcome-email # bootstraps a welcome-email notification | |
| 160 | buddy make:lang de # bootstraps a lang/de.yml language file | |
| 161 | buddy make:stack my-project # shares logic with `bunx --bun stacks new my-project` | |
| 162 | ||
| 163 | buddy migrate # runs database migrations | |
| 164 | buddy migrate:dns # sets the ./config/dns.ts file | |
| 165 | ||
| 166 | buddy dns example.com # list all DNS records for example.com | |
| 167 | buddy dns example.com --type MX # list MX records for example.com (proxies dog) | |
| 168 | ||
| 169 | buddy https httpie.io/hello | |
| 170 | # http [flags] [METHOD] URL [ITEM [ITEM]] | |
| 171 | buddy http --help | |
| 172 | buddy http PUT pie.dev/put X-API-Token:123 name=John # Custom HTTP method, HTTP headers and JSON data | |
| 173 | buddy http -v pie.dev/get # See the request that is being sent using one of the output options | |
| 174 | buddy http -f POST pie.dev/post hello=World # submitting forms | |
| 175 | buddy http --offline pie.dev/post hello=offline | |
| 176 | buddy http -a USERNAME POST https://api.github.com/repos/httpie/cli/issues/83/comments body='HTTPie is awesome! :heart:' | |
| 177 | buddy http pie.dev/post < files/data.json | |
| 178 | buddy http pie.dev/image/png > image.png | |
| 179 | buddy http --download pie.dev/image/png | |
| 180 | buddy http --session=logged-in -a username:password pie.dev/get API-Key:123 | |
| 181 | buddy http --session=logged-in pie.dev/headers | |
| 182 | buddy http localhost:8000 Host:example.com | |
| 183 | ||
| 184 | buddy lint # runs linter | |
| 185 | buddy lint:fix # runs linter and fixes issues | |
| 186 | ||
| 187 | buddy commit # follow CLI prompts for committing staged changes | |
| 188 | buddy release # creates the releases for the stack & triggers the Release Action (workflow) | |
| 189 | buddy changelog # generates CHANGELOG.md | |
| 190 | ||
| 191 | # when deploying your app/s to a remote server or cloud provider | |
| 192 | buddy 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 | |
| 197 | buddy undeploy # be careful: "undeploys" removes/deletes your deployed resources | |
| 198 | ||
| 199 | buddy cloud:remove # removes cloud setup | |
| 200 | buddy cloud:cleanup # removes cloud setup & cleans up all potentially leftover resources | |
| 201 | buddy cloud:add --jump-box # adds a jump box to your cloud setup | |
| 202 | ||
| 203 | # select the example to run (follow CLI prompts) | |
| 204 | buddy example # prompts you to select which example to run | |
| 205 | buddy example:vue # runs the Vue example | |
| 206 | buddy 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 | |
| 209 | buddy generate # prompts you to select which generator to run | |
| 210 | buddy generate:types # generates types for your components, functions, & views | |
| 211 | buddy generate:entries # generates entry files for components, functions, & views | |
| 212 | buddy generate:web-types # generates Web Component types | |
| 213 | buddy generate:vscode-custom-data # generates VSCode custom data | |
| 214 | buddy generate:ide-helpers # generates IDE helpers | |
| 215 | buddy generate:component-meta # generates component meta | |
| 216 | buddy generate:all # runs all generators | |
| 217 | ||
| 218 | # generates your application key | |
| 219 | buddy key:generate # generates your application key | |
| 220 | ||
| 221 | # manage your environment variables | |
| 222 | buddy env:get # get an environment variable | |
| 223 | buddy env:set # set an environment variable | |
| 224 | buddy env:encrypt # encrypt an environment variable | |
| 225 | buddy env:decrypt # decrypt an environment variable | |
| 226 | buddy env:keypair # generate a keypair | |
| 227 | buddy env:rotate # rotate a keypair | |
| 228 | ||
| 229 | # generate your TypeScript declarations | |
| 230 | buddy types:generate # generates types for your components, functions, & views | |
| 231 | buddy types:fix # auto-fixes types for your components, functions, & views | |
| 232 | ||
| 233 | buddy domains # alias for `buddy domains:list` | |
| 234 | buddy domains:add stacksjs.org # adds a domain | |
| 235 | buddy domains:remove stacksjs.org # removes a domain | |
| 236 | buddy domains:list # lists all domains | |
| 237 | buddy domains:update # apply ./config/dns.ts updates | |
| 238 | buddy domains:purchase stacksjs.org # purchase a new domain | |
| 239 | ||
| 240 | # test your stack | |
| 241 | buddy test # runs test suite (unit & e2e) | |
| 242 | buddy test:coverage # runs test coverage | |
| 243 | buddy test:types # runs typecheck | |
| 244 | ||
| 245 | # the CLI may be triggered in any | |
| 246 | # of the following syntax | |
| 247 | stx fresh | |
| 248 | buddy fresh | |
| 249 | bud fresh | |
| 81 | app/ | |
| 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 | |
| 86 | resources/ | |
| 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 | |
| 91 | routes/api.ts hand-written API routes (auto-prefixed with /api) | |
| 92 | database/ plain-SQL migrations + idempotent seeders | |
| 93 | scripts/ the validation suite (see below) | |
| 94 | site.config.ts single source of truth for name / url / SEO | |
| 95 | gate.ts 17-check build + SEO conformance gate | |
| 250 | 96 | ``` |
| 251 | 97 | |
| 252 | </details> | |
| 253 | ||
| 254 | Read more here about the Stacks CLI in the documentation. | |
| 255 | ||
| 256 | ## Features | |
| 257 | ||
| 258 | The 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 | ||
| 324 | Convention 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 | ||
| 340 | No 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 | ||
| 342 | Develop 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 | |
| 347 | 99 | |
| 348 | 100 | ```bash |
| 349 | ./buddy test | |
| 101 | bun run lint # pickier | |
| 102 | bun run typecheck:app # app-owned TypeScript | |
| 103 | bun run gate # build + 17 build/SEO integrity checks | |
| 104 | bun run ui:validate # accessibility lint over resources/ | |
| 105 | bun run visual:validate # headless-Chrome contrast / overflow / focus audit | |
| 106 | bun run release:validate # route text contracts, assets, metadata, migrations | |
| 107 | bun run deployment:smoke # post-deploy checks against the live domain | |
| 350 | 108 | ``` |
| 351 | 109 | |
| 352 | ## Changelog | |
| 353 | ||
| 354 | Please see our [releases](https://github.com/stacksjs/stacks/releases) page for more information on what has changed recently. | |
| 355 | ||
| 356 | ## Contributing | |
| 357 | ||
| 358 | Please see the [Contributing Guide](https://github.com/stacksjs/contributing) for details. | |
| 359 | ||
| 360 | ## Community | |
| 361 | ||
| 362 | For 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` — | |
| 111 | that is the check that catches a dev artifact being shipped. | |
| 363 | 112 | |
| 364 | [Discussions on GitHub](https://github.com/stacksjs/stacks/discussions) | |
| 113 | ## Deploying | |
| 365 | 114 | |
| 366 | For casual chit-chat with others using this package: | |
| 115 | See [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 | |
| 117 | cannot — an `/api/*` proxy and a `/verify-email/:id/:token` rewrite. | |
| 367 | 118 | |
| 368 | [Join the Stacks Discord Server](https://stacksjs.com/discord) | |
| 119 | ## Status | |
| 369 | 120 | |
| 370 | ## Postcardware | |
| 121 | Working and verified: the full review lifecycle, moderation, auth, privacy | |
| 122 | export/delete, SEO surface, and rate limiting. `lint`, `typecheck:app`, | |
| 123 | `ui:validate` and `release:validate` are green; `gate` is 16/17. | |
| 371 | 124 | |
| 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. | |
| 125 | Open before launch: | |
| 373 | 126 | |
| 374 | Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States �� | |
| 375 | ||
| 376 | ## Sponsors | |
| 377 | ||
| 378 | We 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 | ||
| 389 | And a special thanks to [Dan Scanlon](https://twitter.com/danscan) for donating the `stacks` name on npm ✨ | |
| 127 | 1. **Content ingestion** — the directory is empty, so no entity pages render. | |
| 128 | This is the one blocker that makes the rest moot. | |
| 129 | 2. **A host** — `config/cloud.ts` is still stock Stacks and describes | |
| 130 | `stacksjs.org`, not this app. | |
| 131 | 3. **Production SMTP** — email verification and password reset depend on it. | |
| 132 | 4. **`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`. | |
| 135 | 5. **`HttpOnly` on the auth cookie** — the token is readable from JS today. | |
| 136 | 6. **Tests** — `tests/` is still placeholder stubs. | |
| 137 | 7. **`docs/`** — still the inherited Stacks framework documentation; either | |
| 138 | curate it for this app or remove it. | |
| 390 | 139 | |
| 391 | 140 | ## License |
| 392 | 141 | |
| 393 | The MIT License (MIT). Please see [LICENSE](LICENSE.md) for more information. | |
| 394 | ||
| 395 | Made 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) | |
| @@ -14,6 +14,8 @@ | ||
| 14 | 14 | * they need build-time static generation (#46). |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import site from '~/site.config' | |
| 18 | ||
| 17 | 19 | interface PageSeo { |
| 18 | 20 | /** Public path, e.g. '/judges'. Canonical + og:url = base + path. */ |
| 19 | 21 | path: string |
| @@ -105,7 +107,7 @@ export const escapeAttr = (s: string): string => | ||
| 105 | 107 | /** Extract the page's existing <title> text for og__stx_title="IkFkb3B0IHRoZSBocS1hcHBzIGFwcCBjb252ZW50aW9ucyBieSBnbGVubm1pY2hhZWwxMjMgwrcgc3RhY2tzL2JlbmNoLXJldmlldyAjNzYi" / twitter:title. */ |
| 106 | 108 | function extractTitle(html: string): string { |
| 107 | 109 | const m = html.match(/<title>([^<]*)<\/title>/i) |
| 108 | return m?.[1]?.trim() || 'Bench Review' | |
| 110 | return m?.[1]?.trim() || site.seo.siteName | |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | /** |
| @@ -140,18 +142,18 @@ function buildJsonLd(seo: PageSeo, base: string, title: string): string { | ||
| 140 | 142 | { |
| 141 | 143 | '@type': 'Organization', |
| 142 | 144 | '@id': `${base}/#organization`, |
| 143 | 'name': 'Bench Review', | |
| 145 | 'name': site.seo.siteName, | |
| 144 | 146 | 'url': `${base}/`, |
| 145 | 147 | 'logo': { |
| 146 | 148 | '@type': 'ImageObject', |
| 147 | 'url': `${base}/images/bench/logo.png`, | |
| 149 | 'url': `${base}${site.seo.logo}`, | |
| 148 | 150 | }, |
| 149 | 151 | }, |
| 150 | 152 | { |
| 151 | 153 | '@type': 'WebSite', |
| 152 | 154 | '@id': `${base}/#website`, |
| 153 | 155 | 'url': `${base}/`, |
| 154 | 'name': 'Bench Review', | |
| 156 | 'name': site.seo.siteName, | |
| 155 | 157 | 'publisher': { '@id': `${base}/#organization` }, |
| 156 | 158 | 'potentialAction': { |
| 157 | 159 | '@type': 'SearchAction', |
| @@ -186,12 +188,13 @@ export function injectSeoHead(html: string, seo: PageSeo, base: string): string | ||
| 186 | 188 | const url = `${base}${seo.path}` |
| 187 | 189 | const title = extractTitle(html) |
| 188 | 190 | const ogDesc = seo.ogDescription ?? seo.description |
| 189 | const ogImage = `${base}/images/og-image.png` | |
| 191 | const ogImage = `${base}${site.seo.imagePath}` | |
| 190 | 192 | |
| 191 | 193 | const tags = [ |
| 192 | 194 | `<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)}">`, | |
| 195 | 198 | `<meta property="og:url" content="${escapeAttr(url)}">`, |
| 196 | 199 | `<meta property="og:title" content="${escapeAttr(title)}">`, |
| 197 | 200 | `<meta property="og:description" content="${escapeAttr(ogDesc)}">`, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { db } from '@stacksjs/database' |
| 2 | import site from '~/site.config' | |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * Shared sitemap + robots builders. |
| @@ -29,11 +30,23 @@ const STATIC_PATHS: Array<{ path: string, priority: number, changefreq: string } | ||
| 29 | 30 | { path: '/register', priority: 0.5, changefreq: 'yearly' }, |
| 30 | 31 | ] |
| 31 | 32 | |
| 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 | */ | |
| 33 | 43 | export function normalizeBase(raw: string | undefined): string { |
| 34 | 44 | return (raw || 'http://localhost:4000').replace(/\/+$/, '') |
| 35 | 45 | } |
| 36 | 46 | |
| 47 | /** The production canonical origin, from site.config.ts. */ | |
| 48 | export const CANONICAL_BASE: string = site.url.replace(/\/+$/, '') | |
| 49 | ||
| 37 | 50 | /** |
| 38 | 51 | * Build the full sitemap XML from current DB content. Every judge gets a |
| 39 | 52 | * /judges/:id/profile entry, every courthouse /court-houses/:id/profile, |
| @@ -0,0 +1,90 @@ | ||
| 1 | import type { ApplicationHealthResult } from '@stacksjs/router' | |
| 2 | import { 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 | */ | |
| 11 | export const APP = 'bench-review' | |
| 12 | ||
| 13 | export 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 | */ | |
| 63 | export 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 | } | |
| @@ -15,7 +15,12 @@ | ||
| 15 | 15 | "typecheck": "bun tsc --noEmit", |
| 16 | 16 | "typecheck:app": "bun tsc --noEmit -p tsconfig.app.json", |
| 17 | 17 | "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" | |
| 19 | 24 | }, |
| 20 | 25 | "dependencies": { |
| 21 | 26 | "@stacksjs/components": "^0.2.170", |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { response, route } from '@stacksjs/router' |
| 2 | import { healthResponse } from '../app/Support/health' | |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * This file is the entry point for your application's API routes. |
| @@ -17,6 +18,14 @@ import { response, route } from '@stacksjs/router' | ||
| 17 | 18 | * @see https://docs.stacksjs.com/routing |
| 18 | 19 | */ |
| 19 | 20 | |
| 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(). | |
| 27 | route.get('/health', () => healthResponse()) | |
| 28 | ||
| 20 | 29 | // Your custom routes go here: |
| 21 | 30 | route.get('/', () => response.text('hello')) |
| 22 | 31 | route.get('/coming-soon', 'Controllers/ComingSoonController@index') |
| @@ -0,0 +1,143 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | ||
| 4 | interface SmokeContract { | |
| 5 | domain: string | |
| 6 | homeText: string | |
| 7 | } | |
| 8 | ||
| 9 | interface HealthPayload { | |
| 10 | app?: unknown | |
| 11 | status?: unknown | |
| 12 | checks?: { | |
| 13 | database?: { | |
| 14 | ok?: unknown | |
| 15 | message?: unknown | |
| 16 | } | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | const 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 | ||
| 30 | const projectRoot = resolve(import.meta.dir, '..') | |
| 31 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 32 | const packageName = String(packageJson.name || '') | |
| 33 | const directoryName = projectRoot.split('/').pop() || '' | |
| 34 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 35 | const contract = CONTRACTS[appName] | |
| 36 | ||
| 37 | if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`) | |
| 38 | ||
| 39 | const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '') | |
| 40 | const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10)) | |
| 41 | const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000)) | |
| 42 | ||
| 43 | async 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 | ||
| 71 | export 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 | */ | |
| 108 | function 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 | ||
| 115 | async 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 | ||
| 142 | if (import.meta.main) | |
| 143 | await run() | |
| @@ -0,0 +1,102 @@ | ||
| 1 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { dirname, join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const docsDir = resolve(root, 'docs') | |
| 6 | const configPath = resolve(root, 'config/docs.ts') | |
| 7 | const issues = new Set<string>() | |
| 8 | const incomingRoutes = new Set<string>() | |
| 9 | const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status'] | |
| 10 | ||
| 11 | function 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 | ||
| 18 | function 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 | ||
| 29 | function 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 | ||
| 35 | function 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 | ||
| 50 | function 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 | ||
| 60 | if (!existsSync(docsDir)) { | |
| 61 | console.error('Documentation directory does not exist: docs/') | |
| 62 | process.exit(1) | |
| 63 | } | |
| 64 | ||
| 65 | const files = markdownFiles(docsDir) | |
| 66 | for (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 | ||
| 86 | if (existsSync(configPath)) { | |
| 87 | const config = readFileSync(configPath, 'utf8') | |
| 88 | for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1]) | |
| 89 | } | |
| 90 | ||
| 91 | for (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 | ||
| 98 | console.log(`Validated ${files.length} documentation pages.`) | |
| 99 | if (issues.size > 0) { | |
| 100 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 101 | process.exit(1) | |
| 102 | } | |
| @@ -0,0 +1,214 @@ | ||
| 1 | import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' | |
| 2 | import { extname, join, resolve } from 'node:path' | |
| 3 | ||
| 4 | interface AppContract { | |
| 5 | domain: string | |
| 6 | minPages: number | |
| 7 | routes: Record<string, string[]> | |
| 8 | } | |
| 9 | ||
| 10 | const CONTRACTS: Record<string, AppContract> = { | |
| 11 | // 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 | ||
| 82 | const projectRoot = resolve(import.meta.dir, '..') | |
| 83 | const distRoot = resolve(projectRoot, 'dist') | |
| 84 | const ssrRoot = resolve(projectRoot, '.output') | |
| 85 | const ssrPages = resolve(ssrRoot, 'server/pages') | |
| 86 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 87 | const packageName = String(packageJson.name || '') | |
| 88 | const directoryName = projectRoot.split('/').pop() || '' | |
| 89 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 90 | const contract = CONTRACTS[appName] | |
| 91 | ||
| 92 | if (!contract) | |
| 93 | throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`) | |
| 94 | ||
| 95 | const isSsr = existsSync(ssrPages) | |
| 96 | if (!existsSync(distRoot) && !isSsr) | |
| 97 | throw new Error('No production artifact found. Run bun run build first.') | |
| 98 | ||
| 99 | function 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 | ||
| 110 | function 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 | ||
| 125 | const pageFiles = isSsr | |
| 126 | ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json')) | |
| 127 | : walkFiles(distRoot).filter(path => path.endsWith('.html')) | |
| 128 | ||
| 129 | const failures: string[] = [] | |
| 130 | if (pageFiles.length < contract.minPages) | |
| 131 | failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`) | |
| 132 | ||
| 133 | const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot | |
| 134 | for (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 | ||
| 170 | for (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 | ||
| 181 | const migrationsDir = resolve(projectRoot, 'database/migrations') | |
| 182 | if (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 | ||
| 192 | if (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 | ||
| 209 | if (failures.length) { | |
| 210 | for (const failure of failures) console.error(`- ${failure}`) | |
| 211 | process.exit(1) | |
| 212 | } | |
| 213 | ||
| 214 | console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`) | |
| @@ -0,0 +1,90 @@ | ||
| 1 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const resources = resolve(root, 'resources') | |
| 6 | const issues = new Set<string>() | |
| 7 | ||
| 8 | function 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 | ||
| 15 | function 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 | ||
| 24 | function lineAt(source: string, index: number): number { | |
| 25 | return source.slice(0, index).split('\n').length | |
| 26 | } | |
| 27 | ||
| 28 | function named(attributes: string): boolean { | |
| 29 | return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes) | |
| 30 | } | |
| 31 | ||
| 32 | function report(file: string, source: string, index: number, message: string): void { | |
| 33 | issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`) | |
| 34 | } | |
| 35 | ||
| 36 | const files = filesIn(resources) | |
| 37 | const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file)) | |
| 38 | const combined = files.map(file => readFileSync(file, 'utf8')).join('\n') | |
| 39 | ||
| 40 | for (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 | ||
| 81 | if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined)) | |
| 82 | issues.add('resources: no visible keyboard focus treatment found') | |
| 83 | if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined)) | |
| 84 | issues.add('resources: no responsive breakpoint treatment found') | |
| 85 | ||
| 86 | console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`) | |
| 87 | if (issues.size) { | |
| 88 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 89 | process.exit(1) | |
| 90 | } | |
| @@ -0,0 +1,469 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | ||
| 5 | interface Viewport { | |
| 6 | name: string | |
| 7 | width: number | |
| 8 | height: number | |
| 9 | mobile: boolean | |
| 10 | } | |
| 11 | ||
| 12 | interface PageAudit { | |
| 13 | contrast: string[] | |
| 14 | focusable: number | |
| 15 | imagesWithoutAlt: number | |
| 16 | overflow: number | |
| 17 | undersizedControls: string[] | |
| 18 | unresolved: boolean | |
| 19 | unnamedControls: string[] | |
| 20 | } | |
| 21 | ||
| 22 | interface Pending { | |
| 23 | reject: (error: Error) => void | |
| 24 | resolve: (value: any) => void | |
| 25 | timer: ReturnType<typeof setTimeout> | |
| 26 | } | |
| 27 | ||
| 28 | const DIST = resolve('dist') | |
| 29 | const SSR_OUTPUT = resolve('.output') | |
| 30 | const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages') | |
| 31 | const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public') | |
| 32 | const OUTPUT = resolve('storage/logs/visual-contract') | |
| 33 | const 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. | |
| 43 | const THEMES = ['light'] as const | |
| 44 | const 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 | */ | |
| 69 | const APP_ROUTE_PREFIXES = ['/profile', '/settings', '/my-reviews', '/admin', '/notifications'] | |
| 70 | ||
| 71 | ||
| 72 | function 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 | ||
| 86 | async 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 | */ | |
| 111 | async 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 | ||
| 139 | class 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 | ||
| 176 | async 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 | ||
| 185 | function routeName(route: string): string { | |
| 186 | return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') | |
| 187 | } | |
| 188 | ||
| 189 | function 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 | ||
| 202 | function 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 | ||
| 208 | const 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 | ||
| 333 | async 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 | ||
| 469 | await main() | |
| @@ -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 | ||
| 25 | export 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 | ||
| 46 | export 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 | ||
| 55 | const 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 | ||
| 57 | const url = 'https://benchreview.org' | |
| 58 | ||
| 59 | export 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 | |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | import { tsAnalytics } from '@stacksjs/ts-analytics/stx' |
| 15 | 15 | import { FONT_HEAD_TAGS } from './app/Helpers/fontHead' |
| 16 | 16 | import { TS_ANALYTICS_APP_ID } from './config/ts-analytics' |
| 17 | import site from './site.config' | |
| 17 | 18 | |
| 18 | 19 | export default { |
| 19 | 20 | componentsDir: 'components', |
| @@ -29,9 +30,11 @@ export default { | ||
| 29 | 30 | head: { |
| 30 | 31 | // Fallback title/description for pages that don't set their own via |
| 31 | 32 | // @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, | |
| 33 | 36 | 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 }, | |
| 35 | 38 | ], |
| 36 | 39 | // ts-analytics, added like a Nuxt module — just an App ID (Fathom-style), |
| 37 | 40 | // set once in config/ts-analytics.ts. The endpoint is baked into the |