ReviewOS

also looking at this

stacks/reportshq

Expand BunPress guide and pre-E2E quality gates

#35
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
40 files +1524 -373
.github/workflows/ci.ymlmodified+57-1
Changes to .github/workflows/ci.yml
@@ -175,6 +175,53 @@ jobs:
175175 - name: Design tokens
176176 run: bun scripts/verify-tokens.ts
177177
178 build:
179 runs-on: ubuntu-latest
180
181 steps:
182 - name: Checkout Code
183 uses: actions/checkout@v4
184
185 - name: Setup Pantry
186 uses: pantry-pm/pantry/packages/action@c1417d98f5613290bc3b1f96d35086b834684659 # v0.10.47 + the socket-hang-up retry fix
187 with:
188 install: 'false'
189
190 - name: Use cached node_modules
191 uses: actions/cache@v4
192 with:
193 path: node_modules
194 key: node-modules-${{ hashFiles('**/bun.lock') }}
195 restore-keys: |
196 node-modules-
197
198 - name: Install Dependencies
199 run: bun install
200
201 - name: Build artifacts
202 run: bun run build:artifacts
203
204 - name: Build application
205 run: bun run build
206
207 - name: Build documentation
208 run: bun buddy build docs
209
210 - name: Validate documentation
211 run: bun run docs:validate
212
213 - name: Validate UI accessibility
214 run: bun run ui:validate
215
216 - name: Validate responsive visual contracts
217 run: bun run visual:validate
218
219 - name: Validate production release artifact
220 run: bun run release:validate
221
222 - name: Verify generated files are current
223 run: git diff --exit-code
224
178225 # Push to deploy.
179226 #
180227 # This job lives in CI rather than in a workflow of its own so the gates are
@@ -185,7 +232,7 @@ jobs:
185232 #
186233 # A red build therefore means NO deploy, silently and by construction.
187234 deploy:
188 needs: [lint, typecheck, test, test-postgres]
235 needs: [lint, typecheck, test, test-postgres, build]
189236 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
190237 runs-on: ubuntu-latest
191238
@@ -221,6 +268,12 @@ jobs:
221268 - name: Build artifacts
222269 run: bun run build:artifacts
223270
271 - name: Build application
272 run: bun run build
273
274 - name: Validate production artifact
275 run: bun run release:validate
276
224277 # ts-cloud reads the PUBLIC key off disk to register it, so it is derived
225278 # from the private one rather than stored as a second secret. Two secrets
226279 # that have to agree eventually stop agreeing.
@@ -298,6 +351,9 @@ jobs:
298351
299352 exit $fail
300353
354 - name: Verify public metadata
355 run: bun run deployment:smoke
356
301357 # The same suite against Postgres, and a hard gate on the deploy.
302358 #
303359 # Production is Postgres and the suite defaults to SQLite, so every difference
config/docs.tsmodified+5-2
Changes to config/docs.ts
@@ -42,14 +42,16 @@ const config: BunPressOptions = {
4242 text: 'Getting started',
4343 items: [
4444 { text: 'Quickstart', link: '/quickstart' },
45 { text: 'Reporting concepts', link: '/concepts' },
4546 { text: 'Limits', link: '/limits' },
4647 ],
4748 },
4849 {
4950 text: 'Installing it',
5051 items: [
52 { text: 'Stacks package', link: '/stacks' },
5153 { text: 'Laravel package', link: '/laravel' },
52 { text: 'Query API', link: '/api' },
54 { text: 'Configuration', link: '/configuration' },
5355 ],
5456 },
5557 {
@@ -66,12 +68,13 @@ const config: BunPressOptions = {
6668 { text: 'Self-hosting', link: '/self-hosting' },
6769 { text: 'Deploying', link: '/deploy' },
6870 { text: 'Launch checklist', link: '/launch-checklist' },
69 { text: 'API', link: '/api' },
71 { text: 'Troubleshooting', link: '/troubleshooting' },
7072 ],
7173 },
7274 {
7375 text: 'Reference',
7476 items: [
77 { text: 'Query API', link: '/api' },
7578 { text: 'Design brief', link: '/design-brief' },
7679 ],
7780 },
config/ui.tsmodified+1-1
Changes to config/ui.ts
@@ -31,7 +31,7 @@ export default {
3131 // resolves through this. See the plugin file for the lookup order.
3232 plugins: ['./storage/framework/defaults/stx-components-plugin.ts'],
3333
34 layoutsDir: 'layouts',
34 layoutsDir: 'views/layouts',
3535
3636 partialsDir: 'partials',
3737
docs/api.mdmodified+3-1
Changes to docs/api.md
@@ -81,7 +81,9 @@ Layout is packed on the server as well as in the browser, and the response
8181carries the canonical positions back. What is stored and what you saw cannot
8282disagree.
8383
84## Sharing
84## Laravel sharing
85
86The current Laravel source exposes the following share endpoints when its API is enabled and guarded. The Stacks `reportRoutes` list does not contain share routes, and this site's code-defined viewer does not mount them.
8587
8688```bash
8789curl -X POST .../api/reportshq/reports/commerce-overview/shares -d '{"label":"Board"}'
docs/builder.mdmodified+41-135
Changes to docs/builder.md
@@ -1,144 +1,50 @@
11# Report builder
22
3A report is a grid of blocks. Each block runs one query and draws one thing.
4
5The terms here are the terms the interface uses, because both come from the
6same validation schema in `app/Reports/schema.ts`. A test asserts this page
7lists exactly the values that schema accepts, so a new measure cannot appear in
8the product without appearing here.
9
10## Blocks
11
12| Kind | Draws |
13|---|---|
14| `line` | A measure over time |
15| `area` | The same, filled, for one dominant series |
16| `bar` | A measure across a dimension, or over coarse time |
17| `donut` | Composition of a total across a dimension |
18| `big_number` | One number, and whether it moved |
19| `table` | The underlying rows, when the shape is not a chart |
20| `funnel` | Conversion across ordered steps |
21| `heatmap` | A measure across two dimensions |
22| `text` | Prose. The only kind that runs no query |
23
24## The query
25
26Every block except `text` carries a query with these parts.
27
28### events
29
30The event names the block reads. Empty means every event in the project. Names
31come from the models you described in `config/reportshq.php`.
32
33### measure
34
35What to compute:
36
37| Measure | Means |
38|---|---|
39| `count` | Rows. The default, and what a count of events means |
40| `count_unique` | Distinct `user_key`. Unique visitors, active users, buyers |
41| `sum` | Sum of the named field. Revenue, duration, quantity |
42| `avg` | Mean of the named field. Order value, session length |
43| `min` | Smallest value of the named field |
44| `max` | Largest value of the named field |
45
46`sum`, `avg`, `min` and `max` require a **field**. `count` and `count_unique`
47ignore one.
48
49### field
50
51Which value the measure reads. One of `name`, `user_key`, `session_key`,
52`value`, `currency`, `occurred_at`, or `properties.<key>` for anything in your
53own property bag.
54
55This is an allowlist rather than a pattern. These names reach a query builder,
56and "anything that looks like an identifier" is how a column name becomes an
57injection point the day somebody builds the query with a template string.
58
59### dimension
60
61Group the result into series by a field, using the same set as above. Bounded
62by **limit** with an explicit other bucket: when two hundred tags become the top
63ten, the report says how many went into Other rather than letting a reader
64assume the tail does not exist.
65
66### filters
67
68Narrow what the block counts. Each filter is a field, an operator and usually a
69value:
70
71| Operator | Means |
72|---|---|
73| `is` | Equal to |
74| `is_not` | Not equal to |
75| `contains` | Substring |
76| `starts_with` | Prefix |
77| `gt` | Greater than |
78| `lt` | Less than |
79| `exists` | The field is present |
80| `not_exists` | The field is absent |
81
82`exists` and `not_exists` carry no value, because the test is presence.
83
84### grain
85
86How time is bucketed: `hour`, `day`, `week` or `month`.
87
88### compare
89
90Compare against the previous period of equal length. When there was no previous
91period, the block says so rather than inventing a percentage. This is how
92dashboards end up claiming a four thousand percent rise, and it is always
93because the previous period was zero.
94
95### limit
96
97Top N series when a dimension is set. The rest become Other, and the count is
98stated.
99
100### steps
101
102Ordered event names, for `funnel` blocks only. A funnel measures people, not
103events, so when data is sampled the sampling keeps whole subjects: somebody is
104either wholly in the sample or wholly out. Sampling events independently makes
105every conversion rate noise, because the steps stop belonging to the same
106people.
107
108## The grid
109
110Twelve columns. Drag a block to move it, its corner to resize, and everything
111has a keyboard path. Blocks push each other out of the way while you hold one,
112and the server settles the layout before saving, so two blocks can never share
113a cell. That last part is on the server deliberately: a layout agreed by one
114browser is a layout that disagrees with itself when two people edit at once.
115
116## Draft and published
117
118Edits go to a draft. The published version is what a teammate opens and what a
119[share link](/docs/sharing) serves, and it does not change until you publish. A
120half finished experiment is never what somebody else walks into.
3A report is a grid of blocks. The current source has two storage models: `@reportshq/stacks` asks the host for a `ReportStore`, while `reportshq/laravel` ships Eloquent report tables and a browser editor. The editor in this site's old hosted app was removed. Its read-only Accounts report is defined in `config/reportshq.ts`.
4
5## Block kinds
6
7Both current packages accept the same nine kind names in `packages/stacks/src/http/handlers.ts` and `packages/laravel/src/Reports/Block.php`:
8
9| Kind | Use |
10| --- | --- |
11| `big_number` | A headline value |
12| `line` | A time series |
13| `area` | A filled time series |
14| `bar` | Values over time or categories |
15| `donut` | Category composition |
16| `table` | Values in rows |
17| `funnel` | A funnel chart frame |
18| `heatmap` | A heatmap chart frame |
19| `note` | Text without a query |
20
21The current query engines do not have a separate ordered-step funnel calculation or two-dimensional heatmap aggregation. Do not infer those analytical semantics from the renderer kind alone.
22
23## Query vocabulary
24
25Each query-bearing block starts with `model` and `measure`, both looked up in the semantic registry. Measures are described with `count`, `sum`, `avg`, `min`, or `max`. Only `count` needs no column. The TypeScript block shape is `BlockQuery` in `packages/stacks/src/types.ts`:
26
27```ts
28{
29 model: 'order',
30 measure: 'revenue',
31 time: { key: 'placed' },
32 grain: 'day',
33 from: '2026-08-01',
34 to: '2026-09-01',
35}
36```
12137
122## Every block explains itself
38`dimension` groups by an allowlisted column. `time` selects an allowlisted date column on the measured model; `grain` can be `hour`, `day`, `week`, or `month`. `from` is inclusive and `to` is exclusive. A date range without `time` uses the model's first described time column. `limit` bounds a dimensioned result to at most 500 rows in the TypeScript compiler. The current TypeScript compiler accepts `=`, `!=`, `>`, `>=`, `<`, `<=`, and `like` for filters. Laravel's `Filter` uses the named operators `is`, `is_not`, `contains`, `starts_with`, `gt`, `lt`, `exists`, and `not_exists`. Do not copy a filter payload between the two runtimes without translating the operator vocabulary.
12339
124Each block carries one sentence derived from the query it actually runs, not
125from a label somebody typed. Two charts both called Revenue can count different
126things, and a reader deserves to know which one is in front of them before they
127quote a number in a meeting.
40`compare` is present in the TypeScript type, but the current TypeScript compiler returns `comparison: null`. It does not yet compute a prior-period change. The retired event fields `events`, `user_key`, `session_key`, `properties.<key>`, and `steps` are not this reporting query schema.
12841
129## Report filters
42The registry supplies every SQL identifier and the caller supplies only keys and parameterized values. A column not described in the registry is unreachable by a block request.
13043
131A whole report can be narrowed from its URL:
44## Editing and publishing
13245
133```
134/reports/my-report?f=properties.plan:is:pro
135```
46A Stacks host can keep reports in code and make the viewer read-only. The browser builder is writable only when the store implements `addBlock`, `saveBlock`, `removeBlock`, and `publish`; a partially implemented editor is not advertised as writable. The host mounts the package's route descriptions and chooses auth and persistence. This site intentionally does not mount the builder.
13647
137The form is `f=field:operator:value`, repeated, up to five. It lives in the
138query string and nowhere else, so a narrowed view is linkable: paste the URL
139into a conversation and the reader sees what you saw.
48In Laravel, `Builder` adds blocks, validates their kind, packs a 12-column layout on the server, and publishes a revision. The package's standalone editor is under `/reports/{slug}/edit` only after `routes.enabled` is set and the host chooses suitable middleware. The Filament plugin is another optional surface.
14049
141Operators here are limited to `is`, `is_not` and `contains`. A malformed filter
142is dropped rather than erroring, so a link with one bad filter still shows the
143report. Funnels are left alone, because quietly narrowing their steps would
144change what the conversion rate measures without saying so.
50One impossible block returns its reason in `error` while the report can still render other blocks. A fan-out relation that would multiply a sum is refused instead of shown as a plausible wrong number. See [the query API](/docs/api) for the result shape.
docs/concepts.mdadded+44-0
Changes to docs/concepts.md
@@ -0,0 +1,44 @@
1# Reporting concepts
2
3ReportsHQ is an in-process reporting engine. It reads only the models, measures, time fields, dimensions and relations the application declares, compiles a bounded query, and returns one stable block shape to the included chart components or JSON API.
4
5## Registry
6
7The registry is the allowlist. A model description names the table and the fields that reporting may use. A database column that is not declared is not available to the builder or query compiler.
8
9## Measure
10
11A measure is a value to aggregate, such as revenue, orders or users. It defines the aggregate operation, optional source column and unit.
12
13```ts
14measures: {
15 revenue: { aggregate: 'sum', column: 'total_amount', unit: 'currency' },
16 orders: { aggregate: 'count' },
17}
18```
19
20## Dimension
21
22A dimension groups a measure, such as status, country or plan. The current TypeScript compiler can limit a dimensioned result to at most 500 rows. It does not synthesize an `Other` bucket for omitted values, so a limited chart is not a complete total.
23
24## Time grain
25
26A time field can be bucketed by a supported grain such as day, week or month. Buckets are calculated in the report timezone, not by applying a fixed offset. This matters across daylight-saving changes and at both edges of a date range.
27
28## Relation and fan-out
29
30A relation explains how two declared models join. Mark a relation `fansOut` when one source row can match several target rows. ReportsHQ refuses measures that would be multiplied by that join.
31
32For example, summing an order total while grouping through its line items counts the same order once per item. The compiler refuses that query. Declare line revenue on the line-item model instead.
33
34## Block
35
36A block is one report tile. Its kind controls presentation, while its query selects a model, measure, time field, dimension and filters. All block kinds receive the same result contract: series, totals and an optional error.
37
38## Draft and published report
39
40In the current Laravel source, the builder edits a draft and publishing creates the revision shown by ordinary views and share links. In a Stacks host, draft storage and publishing depend on its `ReportStore`; this site's code-defined report has no browser editor or share link.
41
42## Refusal
43
44When a query cannot produce a defensible number, the block returns an explanation instead of a plausible guess. The rest of the report can still render. A refusal is part of the data contract, not a transport failure.
docs/configuration.mdadded+82-0
Changes to docs/configuration.md
@@ -0,0 +1,82 @@
1# Configuration reference
2
3ReportsHQ has two integrations with the same reporting semantics: `@reportshq/stacks` and `reportshq/laravel`. Both require a model registry. Routes, API access, timezone, storage and licensing are explicit application decisions.
4
5## Stacks
6
7```ts
8// config/reportshq.ts
9export default {
10 models,
11 license: null,
12 timezone: 'UTC',
13 routes: {
14 enabled: true,
15 prefix: '/reports',
16 middleware: ['auth'],
17 shareMiddleware: [],
18 },
19 api: {
20 enabled: false,
21 prefix: '/api/reportshq',
22 middleware: ['auth'],
23 },
24}
25```
26
27The Stacks package does not ship database tables. Provide a `ReportStore` backed by code, configuration, your own models or another persistence layer. The optional write methods determine whether the browser builder is editable or read-only.
28
29## Laravel
30
31```php
32// config/reportshq.php
33return [
34 'models' => [/* declared model descriptions */],
35 'license' => env('REPORTSHQ_LICENSE'),
36 'connection' => env('REPORTSHQ_CONNECTION'),
37 'routes' => [
38 'enabled' => env('REPORTSHQ_ROUTES', false),
39 'prefix' => env('REPORTSHQ_ROUTE_PREFIX', 'reports'),
40 'middleware' => ['web', 'auth'],
41 'share_middleware' => ['web'],
42 ],
43 'api' => [
44 'enabled' => env('REPORTSHQ_API', false),
45 'prefix' => env('REPORTSHQ_API_PREFIX', 'api/reportshq'),
46 'middleware' => ['api'],
47 ],
48];
49```
50
51These are values in the current repository source, not the published v0.1.0 package. Once a reporting release exists, publish its configuration with the command in [the Laravel guide](/docs/laravel). The package loads its own migrations. Set page and API middleware for your host before enabling either surface; the source defaults shown here do not add an auth guard.
52
53## Model descriptions
54
55Keep the registry narrow:
56
57- Declare only tables and columns intended for reporting.
58- Put row-level restrictions in the reporting description. ORM global scopes and soft-delete behavior do not automatically apply to compiled SQL.
59- Mark fan-out relations accurately.
60- Use explicit currency and timezone semantics.
61- Bound high-cardinality dimensions.
62
63## Middleware
64
65The current Laravel source has separate page, API and share middleware. The page and API defaults are `web` and `api`, respectively, without an application-specific auth guard. Add your own auth policy before enabling them. Its share link may be intentionally public to anyone holding a revocable token. The Stacks package describes no share route today.
66
67An empty share middleware list does not make every report public. Only an active share token exposes its published report in Laravel. Add rate limiting, trusted proxy and application-specific policy middleware when required.
68
69## Environment values
70
71Common Laravel environment settings include:
72
73| Variable | Purpose |
74| --- | --- |
75| `REPORTSHQ_LICENSE` | Optional offline license key |
76| `REPORTSHQ_CONNECTION` | Reporting database connection |
77| `REPORTSHQ_ROUTES` | Enable packaged page routes |
78| `REPORTSHQ_ROUTE_PREFIX` | Page route prefix |
79| `REPORTSHQ_API` | Enable the JSON API |
80| `REPORTSHQ_API_PREFIX` | JSON API route prefix |
81
82The exact configuration surface is versioned with each package. Treat the installed package's typed config or published config file as the final source of truth.
docs/index.mdmodified+18-15
Changes to docs/index.md
@@ -1,46 +1,49 @@
11---
22layout: home
33title: ReportsHQ Documentation
4description: Reports that run inside your own Laravel application, against your own database. Installing the package, describing your models, the query API and the report builder.
4description: In-process reporting source for Stacks and Laravel. Release status, model allowlists, query APIs, and builder behavior.
55hero:
66 name: ReportsHQ
7 text: Reports that build themselves
8 tagline: "Describe the models you already have. The Commerce, Users and Content reports appear with real numbers in them, and nothing leaves your servers."
7 text: Reports inside your application
8 tagline: "Describe the models and columns reporting may read, then define a report against your own database. Check release status before installing."
99 actions:
1010 - theme: brand
1111 text: Quickstart
1212 link: /quickstart
1313 - theme: alt
14 text: Ingestion API
15 link: /quickstart
14 text: Stacks package
15 link: /stacks
1616 - theme: alt
1717 text: View on GitHub
1818 link: https://github.com/stacksjs/reportshq
1919features:
20 - title: One package
21 details: composer require, a migration, and a config file naming your models. Nothing to deploy beside it and no tracking calls to write.
20 - title: Two in-process packages
21 details: The current Stacks and Laravel source query the host application's database. Their reporting rewrites are not yet in the published package versions.
2222 - title: Describe once, query safely
2323 details: Name your models, measures and dimensions in one config file. That description is an allowlist, so a block can only ever reach what you named.
2424 - title: It refuses rather than guesses
2525 details: Ask for a measure at a grain it does not have and the block says why, instead of returning a number that is quietly wrong.
26 - title: Honest about its numbers
27 details: No previous period is said rather than guessed at, bounded dimensions state what went into Other, and sampling keeps whole people.
28 - title: Share without an account
29 details: A link shows one published report and nothing else. Revoke it and it stops working on the next request.
26 - title: Honest about refusals
27 details: A block reports why it could not run instead of presenting a plausible wrong number. The current TypeScript compiler does not calculate prior-period comparison.
28 - title: Laravel sharing
29 details: The current Laravel source has revocable links for one published report. The Stacks package does not yet describe a share route.
3030 - title: It never sees your data
31 details: Queries run in process through your own ORM. No connection to hand out, no endpoint to send to, and an offline licence check that opens no sockets.
31 details: Reporting queries run in process on the application's own connection, not through ORM scopes. The Laravel event forwarder is separate and optional.
3232---
3333
3434## Start here
3535
3636| You want to | Read |
3737|---|---|
38| Get from nothing to a report | [Quickstart](/quickstart) |
39| Install it into a Stacks application | [Stacks package](/stacks) |
40| Install it into a Laravel application | [Laravel package](/laravel) |
38| Check release status and preview the current source | [Quickstart](/quickstart) |
39| Understand measures, dimensions and refusals | [Reporting concepts](/concepts) |
40| Integrate the Stacks source after release | [Stacks package](/stacks) |
41| Integrate the Laravel source after release | [Laravel package](/laravel) |
42| Review every integration setting | [Configuration](/configuration) |
4143| Read the JSON the charts consume | [Query API](/api) |
4244| Build a report of your own | [Report builder](/builder) |
4345| Send a report to somebody outside | [Sharing](/sharing) |
4446| Get the numbers as a file or an email | [Schedules and exports](/schedules-exports) |
4547| Know what a licence covers | [Limits](/limits) |
4648| Run it on your own machines | [Self-hosting](/self-hosting) |
49| Diagnose a report or deployment | [Troubleshooting](/troubleshooting) |
docs/laravel.mdmodified+16-8
Changes to docs/laravel.md
@@ -4,20 +4,28 @@
44models you already have, queries them in place on the connection your
55application already holds, and renders through the routes you already guard.
66
7Nothing leaves the application. There is no endpoint to send to, no connection
8to hand out, and the licence check is offline.
7The report engine does not send rows to a vendor endpoint and the licence check
8is offline. A separate optional event forwarder can send taxonomy events to a
9collector explicitly configured by the host; it is not required for reports.
910
1011## Install
1112
13Do not run that install sequence yet for the reporting product. Packagist
14currently serves v0.1.0, whose published metadata describes the earlier event
15forwarder. This repository's current source adds in-process reporting, but that
16source has not been released on Packagist. Verify a release containing it before
17following the install sequence. The current source loads migrations for
18reports, blocks, revisions, shares and schedules. Your own tables are only ever
19read by the report engine.
20
21After a reporting release exists, the host installation sequence is:
22
1223```bash
1324composer require reportshq/laravel
1425php artisan vendor:publish --tag=reportshq-config
1526php artisan migrate
1627```
1728
18The migration creates the tables the reports live in: reports, blocks,
19revisions, shares and schedules. Your own tables are only ever read.
20
2129## Describing a model
2230
2331```php
@@ -103,9 +111,9 @@ same compiled components in all three, so they cannot drift.
103111
104112## Exports, sharing, schedules
105113
106CSV and XLSX are generated on demand rather than stored and linked. The numbers
107are one query away, so there is nothing to clean up and no way to serve a stale
108copy.
114The current Laravel source generates CSV and XLSX on demand. Its CSV is one
115long table with block, point, series and value columns. Its XLSX uses one sheet
116per query-bearing block. The current Stacks package generates CSV only.
109117
110118Sharing and scheduling are documented in [sharing](/docs/sharing) and
111119[schedules and exports](/docs/schedules-exports).
docs/limits.mdmodified+6-3
Changes to docs/limits.md
@@ -22,9 +22,12 @@ a good quarter.
2222| XLSX export | | Yes | Yes |
2323| Priority support | | | Yes |
2424
25`app/Billing/limits.ts` is the only place these numbers live. The pricing page
26renders from it rather than restating it, because a marketing table maintained
27separately is a promise somebody eventually breaks by editing one of them.
25`app/Billing/limits.ts` supplies pricing copy. It is not a runtime feature gate
26or a proof that every listed capability exists. The current Stacks package has
27CSV but not XLSX, sharing or scheduled delivery. The Laravel source implements
28CSV, XLSX, sharing and schedules, but its reporting rewrite has not yet been
29published on Packagist. Check [release status](/docs/quickstart) before treating
30this table as an installation plan.
2831
2932## Reports are unlimited on every tier
3033
docs/quickstart.mdmodified+30-106
Changes to docs/quickstart.md
@@ -1,36 +1,28 @@
11# Quickstart
22
3Empty application to first report, in about five minutes. Nothing here talks to
4a service, because there is not one: everything below runs on your machine
5against your own database.
3ReportsHQ runs reporting queries inside the application that owns the data. The current reporting implementations live in this repository under `packages/stacks/` and `packages/laravel/`. Neither needs a hosted collector for reports.
64
7## 1. Install the package
5## Check the release before installing
86
9Two ecosystems, one engine. Pick yours; everything after this step is the same
10report either way.
7As of September 2026, npm serves `@reportshq/stacks` 0.1.0 and Packagist serves `reportshq/laravel` v0.1.0. Those published artifacts predate the reporting rewrite. Installing either version does **not** give you the reporting API described below. The TypeScript reporting source is versioned 0.2.0 in this checkout, but it has not been published. The Laravel reporting source also differs from its published artifact.
8
9Do not use `bun add @reportshq/stacks` or `composer require reportshq/laravel` as a reporting setup step until releases containing the current source are available. You can inspect and test the current implementations in a working checkout of this repository:
1110
1211```bash
13# Stacks. No migration: the package ships no tables and asks your
14# application where reports are kept. See /docs/stacks.
15bun add @reportshq/stacks
12cd packages/stacks
13bun test ./tests
1614```
1715
1816```bash
19# Laravel
20composer require reportshq/laravel
21php artisan vendor:publish --tag=reportshq-config
22php artisan migrate
17cd ../..
18php packages/laravel/tests/run.php
2319```
2420
25In Laravel the migration creates the tables the reports themselves live in: the
26report, its blocks, its revisions, its share links and its schedules. In Stacks
27there is no migration, because the package keeps no tables of its own and asks
28the application where reports live instead. Either way your own tables are never
29touched, read-only or otherwise altered.
21The following steps describe the current source and what a host application must supply when the reporting packages are released. They are not a claim that a fresh customer install works today.
3022
31## 2. Describe a model
23## 1. Describe queryable models
3224
33Name something you already have.
25Name only the tables and columns a report may read. This is the security boundary for query identifiers.
3426
3527```ts
3628// Stacks: config/reportshq.ts
@@ -43,18 +35,14 @@ export const models: Record<string, ModelDescription> = {
4335 revenue: { aggregate: 'sum', column: 'total_amount', unit: 'currency' },
4436 orders: { aggregate: 'count' },
4537 },
46 time: {
47 placed: 'created_at',
48 },
49 dimensions: {
50 status: 'status',
51 },
38 time: { placed: 'created_at' },
39 dimensions: { status: 'status' },
5240 },
5341}
5442```
5543
5644```php
57// Laravel: config/reportshq.php
45// Laravel: config/reportshq.php, inside the returned array
5846'models' => [
5947 'order' => [
6048 'class' => App\Models\Order::class,
@@ -72,96 +60,32 @@ export const models: Record<string, ModelDescription> = {
7260],
7361```
7462
75That is the whole description. A measure says what to add up and how, a
76`time` column is what a date range applies to, and every dimension is something
77you may group by. Where a relation would multiply rows, saying so is what lets
78the compiler refuse a question that would double-count rather than answer it
79approximately.
80
81**Only what you name is reachable.** The compiler will not touch a column that
82is not in this file, which is why a password hash cannot end up as a dimension
83by somebody typing its name into a URL.
84
85## 3. Open the reports
86
87The routes are off until you say otherwise, because the package cannot know
88which of your users may see a total of everybody's orders. Turn them on:
89
90```bash
91REPORTSHQ_ROUTES=true
92```
93
94```bash
95php artisan serve
96```
97
98Visit `/reports`. It is empty -- nothing creates a report for you, and the page
99says as much. Make the first one:
63Compiled SQL does not apply ORM scopes or soft deletes automatically. Declare the same row restrictions in the reporting model description when those rows must be excluded.
10064
101```php
102use ReportsHQ\Laravel\Reports\Builder;
103
104// The slug is derived from the name, and kept unique for you.
105$report = app(Builder::class)->create('Commerce');
106```
107
108Or post the same thing from the page itself, which is what the "New report"
109form does.
65## 2. Decide where reports are stored
11066
111## 4. Build it
67The Stacks package brings no report tables. Supply a `ReportStore` with `list`, `find`, `blocks`, and `saveLayout`, backed by code or your own persistence. Write methods are optional. This site defines its one Accounts report in `config/reportshq.ts` and keeps the viewer read-only. See [the Stacks integration](/docs/stacks) for the store and route descriptions.
11268
113Visit `/reports/commerce/edit`. Drag a block from the palette onto the grid,
114pick a measure and a dimension, and it redraws as you change it. Drag a corner
115to resize, or nudge with the arrow keys.
69The Laravel package source ships migrations for reports, blocks, revisions, shares, and schedules. Its `Builder` creates and publishes reports. The standalone pages are off by default; enable them only after choosing middleware that protects the data they expose. See [the Laravel integration](/docs/laravel).
11670
117There is no data to accumulate and nothing to wait for: a block reads rows that
118are already in your database, so the first one you drop covers everything you
119have ever sold.
71## 3. Build a first report
12072
121Publish when it looks right. A draft stays yours until you do, so a teammate
122never opens a half arranged grid.
73A block names a model and one of that model's described measures. A time key or dimension is optional:
12374
124## 5. Share it, or send it
125
126A published report can be given a link that works for somebody with no account:
127
128```php
129use ReportsHQ\Laravel\Reports\Share;
130
131$share = Share::create([
132 'report_id' => $report->id,
133 'token' => Share::newToken(),
134 'label' => 'For the board',
135 'expires_at' => now()->addDays(30),
136]);
75```ts
76{ model: 'order', measure: 'revenue', time: { key: 'placed' }, grain: 'day' }
13777```
13878
139Or emailed on a cadence, from your own queue, in the report's own timezone:
140
141```php
142use ReportsHQ\Laravel\Reports\Schedule;
143
144Schedule::create([
145 'report_id' => $report->id,
146 'cadence' => 'weekly',
147 'hour' => 8,
148 'recipients' => 'ops@example.com',
149 'format' => 'xlsx',
150]);
151```
79In a Stacks host, put this query in a stored block and mount the package's route descriptions behind your application's auth policy. In a Laravel host using the current source, `Builder::create('Commerce')` creates a draft; `Builder::addBlock()` adds a block; `Builder::publish()` records the published revision. The Laravel browser editor exists only when the standalone routes are enabled. This site's viewer does not mount an editor.
15280
153Add the runner to `routes/console.php` and the application does the rest:
81The compiler returns an explicit refusal on a block when a query names an undeclared field or would multiply a measure across a fan-out relation. It does not send a query to ReportsHQ servers.
15482
155```php
156Schedule::command('reportshq:send')->hourly();
157```
83## 4. Export or distribute
15884
159It is registered by the package but scheduled by you, on purpose. A package that
160adds itself to your scheduler is a package that sends email nobody asked for.
85The Stacks package currently downloads CSV only. The Laravel source supports CSV and XLSX, plus local sharing and scheduled delivery. Those Laravel features require the host's route, middleware, queue, and mail decisions. See [exports and schedules](/docs/schedules-exports) and [sharing](/docs/sharing) for the package-specific behavior.
16186
16287## What next
16388
164- [The Stacks package](/docs/stacks) or [the Laravel package](/docs/laravel)
165 for the full description format in your own language.
166- [The query API](/docs/api) for the JSON the charts read.
167- [What a licence covers](/docs/limits), and why none of it gates a report.
89- [Reporting concepts](/docs/concepts) explains measures, dimensions, time grains, and fan-out.
90- [The builder](/docs/builder) gives the current block and query vocabulary.
91- [Configuration](/docs/configuration) shows the separate route and API switches.
docs/schedules-exports.mdmodified+25-20
Changes to docs/schedules-exports.md
@@ -5,24 +5,25 @@ without anybody asking.
55
66## Exports
77
8Export any report as CSV or XLSX.
8The current Stacks source exports CSV only. Its handler rejects XLSX. The
9current Laravel source exports both CSV and XLSX. The published package
10versions still predate this reporting source, so check [release status](/docs/quickstart)
11before using either as a customer installation.
912
10Both come out in **long format**: one row per series per period, rather than a
11grid that looks right on screen and fights every pivot table. A wide export is
12easier to read and harder to use, and an export exists to be used.
13CSV is one long table with block, point, series and value columns. Laravel XLSX
14uses a separate sheet per query-bearing block, with point, series and value
15columns. The two formats are deliberately different.
1316
14| period | series | value |
15|---|---|---|
16| 2026-08-01 | pro | 4250 |
17| 2026-08-01 | starter | 1180 |
18| 2026-08-02 | pro | 3990 |
17| block | point | series | value |
18|---|---|---|---|
19| Signups | 2026-08-01 | pro | 4250 |
20| Signups | 2026-08-01 | starter | 1180 |
21| Signups | 2026-08-02 | pro | 3990 |
1922
2023CSV suits anything that reads text. XLSX gives a workbook with the types
2124already correct, so dates are dates rather than strings that need coercing.
22Both come from the same query the chart ran, so an exported number and a drawn
23number cannot disagree.
24
25XLSX is a paid-tier capability; CSV is on every plan. See [limits](/docs/limits).
25Both are generated from the rendered report result. The offline licence does
26not gate either format in the current source. See [limits](/docs/limits).
2627
2728## Nothing is stored to expire
2829
@@ -30,22 +31,24 @@ An export is generated on the request and streamed straight out. Nothing is
3031written to disk and nothing is linked.
3132
3233The hosted product did store a file, sign a URL for it and expire it after an
33hour — all of which existed because the file lived on a different machine from
34hour - all of which existed because the file lived on a different machine from
3435the reader. Here it does not: the numbers are one query away, so generating on
3536demand has nothing to clean up and cannot serve somebody a stale copy.
3637
3738## Schedules
3839
39A schedule sends a report's headline numbers by email, daily, weekly or
40monthly, at an hour you choose, optionally with a spreadsheet attached.
40The current Laravel source can send a report by email on a schedule, optionally
41with a spreadsheet. The Stacks package has no schedule runner. Laravel registers
42the `reportshq:send` command but the host must put it on its own scheduler and
43run a queue worker for queued mail.
4144
4245### The hour stays the hour
4346
44The schedule is evaluated in your project's timezone rather than by adding a
47The schedule is evaluated in the report's timezone rather than by adding a
4548fixed offset to UTC. That means 09:00 stays 09:00 after the clocks change,
4649which is the whole reason to store a timezone rather than an offset.
4750
48### Recipients are not validated — review them yourself
51### Recipients are not validated - review them yourself
4952
5053Be explicit about this one. `recipients` is a free-text column;
5154`Schedule::recipientList()` splits it on commas and `Delivery` hands the result
@@ -58,9 +61,11 @@ application, since the package does not do it for you.
5861
5962### What a run records
6063
61`last_run_at` is stamped, and that is all — it is what decides whether a
64`last_run_at` is stamped, and that is all - it is what decides whether a
6265schedule is due, so it is written *before* the mail is queued. A send that
6366fails afterwards still leaves the schedule looking like it ran. There is no
6467failure log here: if you need one, watch your queue's failed jobs.
6568
66Scheduled delivery is a paid-tier capability. See [limits](/docs/limits).
69The current offline licence does not gate scheduled delivery. Pricing copy may
70describe a commercial tier, but the host remains responsible for mail access,
71recipients and delivery monitoring. See [limits](/docs/limits).
docs/self-hosting.mdmodified+26-18
Changes to docs/self-hosting.md
@@ -2,10 +2,13 @@
22
33There is no other kind, and that is the whole point.
44
5The package runs inside your application, on your servers, against your
6database. Nothing is hosted on our side, nothing is sent anywhere, and the
7licence is checked offline. What follows is therefore not a special deployment
8mode: it is a description of what you already have once you install it.
5The reporting engines run inside your application, on your servers, against
6your database. Reporting sends no rows to ReportsHQ and the licence is checked
7offline. The Laravel package also contains a separate, optional event
8forwarder. That half sends events to an explicitly configured collector only
9when its endpoint, key and sample rate enable it. The collector's future is an
10unmade product decision, so do not assume it is either a required part of
11reporting or permanently retired.
912
1013If you are looking for how to deploy `reportshq.org`, the marketing site and
1114account pages, that is [deploying](/docs/deploy) and it has nothing to do with
@@ -13,27 +16,31 @@ running reports.
1316
1417## What you are actually running
1518
16Your application. That is it.
19Your application. The currently published package versions do not yet include
20the reporting source described here. Check [release status](/docs/quickstart)
21before installation.
1722
1823```bash
1924composer require reportshq/laravel
2025php artisan migrate
2126```
2227
23The package adds routes, views, a console command and five tables. It does not
24add a service, a daemon, a queue worker of its own, or a port to open. If your
25application deploys, the reports deploy with it.
28After a reporting release exists, the Laravel package can load routes, views,
29a console command and five tables. Routes and the JSON API remain off until
30the host enables them. It does not add a service, daemon, queue worker of its
31own, or port to open. Scheduled email uses the host's worker and scheduler.
2632
2733## Requirements
2834
29PHP 8.2+, Laravel 11+, and whatever database you already use. SQL is built for
30whatever driver your connection reports, so anything Laravel supports works,
31including SQLite.
35PHP 8.2+, Laravel 11+, and a supported SQLite, MySQL or Postgres connection.
36The current source has a query dialect for each of those three; do not assume
37every database driver Laravel supports has one.
3238
3339## Without a licence key
3440
35Leave `REPORTSHQ_LICENSE` unset and every limit falls away. Unlimited
36applications, unlimited reports, everything the Pro tier lists.
41Leave `REPORTSHQ_LICENSE` unset and the current reporting engine still runs.
42The plans in pricing copy do not gate functionality or guarantee that every
43listed feature is implemented in both packages.
3744
3845Nothing about the software changes. The pages say the installation is
3946unlicensed and that is the entire difference. This is deliberate: an offline
@@ -49,10 +56,10 @@ paying for, not because something will stop.
4956Worth stating explicitly, because it is the property most self-hosting is
5057chasing.
5158
52Queries run in process, through your own ORM, against your own connection. There
53is no ingest endpoint, no export to a third party, and no telemetry. The licence
54class opens no sockets, and a test asserts that by reading its source for
55anything that could.
59Queries run in process on your own connection, not through ORM scopes. The
60report engine has no ingest endpoint or vendor telemetry. The separate Laravel
61event forwarder can POST taxonomy events to an endpoint the host explicitly
62configures. The licence class opens no sockets.
5663
5764For anyone under a data processing agreement, this is usually the shortest
5865section of a security review you will ever write.
@@ -82,7 +89,8 @@ data was restored with them.
8289
8390## Upgrades
8491
85`composer update reportshq/laravel`, then `php artisan migrate`. Read the
92After a reporting release exists, `composer update reportshq/laravel`, then
93`php artisan migrate`. Read the
8694changelog for anything with a `!` in the commit subject, which is how a breaking
8795change is marked.
8896
docs/sharing.mdmodified+17-17
Changes to docs/sharing.md
@@ -1,9 +1,9 @@
11# Sharing
22
3A share link shows one published report and nothing around it: no navigation
4into the rest of your project, no sign-in wall, no account for the recipient to
5create. The people who most need to see a number are usually the least willing
6to make a login for it.
3The current Laravel source can serve one published report through a revocable
4token, without a recipient account. The current Stacks package has no share
5route in `reportRoutes`, and reportshq.org does not mount one. These instructions
6apply to a Laravel host after a reporting release containing this source exists.
77
88## Creating a link
99
@@ -13,12 +13,12 @@ From a report, create a share. You get a URL of the form:
1313https://your-app.example.com/reports/shared/<token>
1414```
1515
16The link is served by your own application, on your own domain — the route is
16The link is served by your own application, on your own domain - the route is
1717`/reports/shared/{token}`, mounted alongside the rest of the package. Nothing
1818is hosted here.
1919
2020The token is the credential. Anyone holding it sees that one report, as
21published. They cannot reach anything else in the project, and the link grants
21published. They cannot reach other reports through this route, and the link grants
2222no ability to change anything.
2323
2424## Revoking and rotating
@@ -26,10 +26,9 @@ no ability to change anything.
2626**Revoke** and the link stops working on the next request, not at the end of a
2727cache window.
2828
29**Rotate** and the same report becomes available at a new token while the old
30one dies. That is the honest fix when a URL has been forwarded further than you
31meant: the people who should still have it get the new link, and everyone else
32is simply out.
29There is no dedicated rotate operation in the current API. Revoke the old share
30and create a new one, then distribute the new URL. The API list reveals only a
31token hint, not the complete token.
3332
3433## Views are recorded
3534
@@ -37,16 +36,17 @@ Each view is recorded against the link, so you know whether the weekly report
3736somebody asked for is actually being opened. It settles a lot of arguments
3837about what is worth continuing to produce.
3938
40Embedding is available on the paid tiers, and the top tier removes our footer
41from shared and embedded reports so what you send a client looks like your work.
42See [limits](/docs/limits) for which tier carries what.
39The current reporting package has no embed route or tier-based share guard.
40Pricing copy is not an inventory of shipped link features. See [limits](/docs/limits)
41for how the offline licence behaves.
4342
4443## What a share is not
4544
4645A share link is shared with whoever holds it. It is not a login, there is no
4746per-recipient identity behind it, and it should not be treated as private
48beyond the secrecy of the URL. `robots.txt` keeps `/s/` and `/embed` out of
49search indexes, but a link pasted into a public page is public.
47beyond the secrecy of the URL. The host application is responsible for cache,
48logging and crawler policy for `/reports/shared/{token}`. A link pasted into a
49public page is public.
5050
51If a report should only be seen by named people, add them to the project as
52members instead.
51If a report should be seen only by named people, protect a normal report route
52with the host application's authorization policy instead.
docs/stacks.mdmodified+20-15
Changes to docs/stacks.md
@@ -7,20 +7,25 @@ application already holds, and renders through the routes you already guard.
77Nothing leaves the application. There is no endpoint to send to, no connection
88to hand out, and the licence check is offline.
99
10This is the same engine as [the Laravel package](/docs/laravel) and the same
11compiled chart components, so the two cannot drift on what a number means. What
12differs is only how you describe a model and where you mount it.
10The Stacks and [Laravel source](/docs/laravel) share a result contract and
11compiled chart components. Their query implementations are separate and their
12filter operator vocabularies currently differ. Verify semantics in each
13runtime instead of assuming they cannot drift.
1314
1415## Install
1516
17Do not run that install command yet for the reporting product. npm currently
18serves `0.1.0`, the earlier event-forwarding SDK. The reporting source in this
19checkout is versioned `0.2.0` but has not been published. Verify that npm serves
20a release containing the reporting source before installing it. See the
21[quickstart release status](/docs/quickstart) for the current boundary.
22
23After a reporting release exists, install it with:
24
1625```bash
1726bun add @reportshq/stacks
1827```
1928
20Take `0.2.0` or later. `0.1.0` carries the same name but is a different
21library: it was an event-forwarding SDK from before this package became the
22reporting engine, and none of the API below exists in it.
23
2429**There is no migration, and that is deliberate.** This package ships no tables
2530of its own and never writes to yours: it reads the models you describe below,
2631and it asks the application where reports themselves are kept through a
@@ -86,9 +91,9 @@ rest of `ReportsHQConfig` is optional:
8691}
8792```
8893
89`shareMiddleware` is empty on purpose. A share link is read by somebody with no
90account, so it does not inherit `routes.middleware`; adding a guard there means
91the link stops working for the people it was sent to.
94The config type includes `shareMiddleware`, but the current Stacks
95`reportRoutes` list has no share route. This setting does not publish a link by
96itself. The Laravel source has the share route and its own middleware decision.
9297
9398## Wiring it up
9499
@@ -217,12 +222,12 @@ Or [the JSON API](/docs/api), for a front end of your own.
217222
218223## Exports, sharing, schedules
219224
220CSV and XLSX are generated on demand rather than stored and linked. The numbers
221are one query away, so there is nothing to clean up and no way to serve a stale
222copy.
225The current Stacks package generates CSV on demand. Its download handler
226rejects XLSX. The Laravel source implements both formats. Neither format is
227stored as a report file by the Stacks handler.
223228
224Sharing and scheduling are documented in [sharing](/docs/sharing) and
225[schedules and exports](/docs/schedules-exports).
229Sharing and scheduling in the current source are Laravel features, documented
230in [sharing](/docs/sharing) and [schedules and exports](/docs/schedules-exports).
226231
227232## Requirements
228233
docs/troubleshooting.mdadded+54-0
Changes to docs/troubleshooting.md
@@ -0,0 +1,54 @@
1# Troubleshooting
2
3## A block shows an error but the page returns 200
4
5This is expected when one block requests a query ReportsHQ cannot answer safely. Read the block's `error` value. Common causes are a fan-out relation, undeclared model or field, unsupported grain, invalid filter or an unavailable draft.
6
7Fix the report or registry rather than converting the block error into a zero. Zero means the query ran and found no value; a refusal means no defensible value was calculated.
8
9## Totals are too high after adding a dimension
10
11The measure is probably crossing a one-to-many relation. Mark the relation `fansOut` and move the measure to the grain where it is additive. For example, use line revenue when grouping orders by product.
12
13## Soft-deleted or tenant rows appear
14
15The runner executes compiled SQL on the application's connection. ORM scopes do not apply automatically. Add the equivalent restrictions to the model description and ensure tenant identity comes from trusted application context, not the request body.
16
17## Day or month buckets do not match another report
18
19Confirm the report timezone, source timestamp and range boundaries. A UTC report and a local-time report can disagree at both range edges while each appears internally consistent.
20
21## The API returns 404
22
23The JSON API is disabled by default. Enable it, confirm the configured prefix and make sure the package routes are registered in the application. In Stacks, the route file must be named by `app/Routes.ts`.
24
25## The API redirects to login
26
27Session middleware is guarding an API route. Give the API its own token-aware middleware group. Keep browser page middleware, API middleware and share middleware separate.
28
29## A share link asks the recipient to sign in
30
31Authentication middleware was added to share routes. Remove it if the intended security model is possession of the revocable token. Keep application-wide controls such as trusted proxies or rate limiting if they do not require an account.
32
33## A revoked share still works
34
35Confirm the request is reaching the current application release and that the share lookup is not cached beyond the revocation boundary. A share is checked on every request so revocation should take effect immediately.
36
37## A scheduled report did not arrive
38
39For the current Laravel source, check that the host scheduler and queue worker are running, the report has an eligible next-run time, mail is configured and recipients are valid. The offline licence does not gate export formats. Inspect failed jobs before retrying. The Stacks package has no schedule runner.
40
41## XLSX or scheduled delivery is unavailable
42
43The current Stacks package exports CSV only and has no scheduled delivery. The current Laravel source implements CSV, XLSX and scheduled email, but the published v0.1.0 artifact predates the reporting rewrite. The offline licence does not gate these methods. See [limits](/docs/limits) and [release status](/docs/quickstart).
44
45## The docs build succeeds locally but fails during deploy
46
47Run the same command as the static site deployment:
48
49```bash
50PATH="$PWD/node_modules/.bin:$PATH" bun node_modules/@stacksjs/buddy/dist/cli.js build docs
51test -d dist/docs/.bunpress
52```
53
54The explicit path matters on a clean runner because the docs build launches the local `bunpress` executable by name.
package.jsonmodified+6-1
Changes to package.json
@@ -29,6 +29,11 @@
2929 "dev": "./buddy dev",
3030 "development": "./buddy dev",
3131 "build": "./buddy build",
32 "docs:validate": "bun scripts/validate-docs.ts",
33 "ui:validate": "bun scripts/validate-ui.ts",
34 "visual:validate": "bun scripts/validate-visual.ts",
35 "release:validate": "bun scripts/validate-release.ts",
36 "deployment:smoke": "bun scripts/smoke-deployment.ts",
3237 "prod": "./buddy build",
3338 "deploy": "./buddy deploy",
3439 "lint": "./buddy lint",
@@ -54,7 +59,7 @@
5459 "changelog": "./buddy changelog",
5560 "generate": "./buddy generate",
5661 "typecheck": "bun run typecheck:app && bun run typecheck:framework",
57 "typecheck:framework": "bun x --bun tsc --noEmit -p storage/framework/tsconfig.framework.json --pretty false && (cd storage/framework/core/orm && bun run typecheck:inference)",
62 "typecheck:framework": "bun x --bun tsc --noEmit -p storage/framework/tsconfig.framework.json --pretty false",
5863 "typecheck:app": "bun x --bun tsc --noEmit -p tsconfig.json --pretty false",
5964 "typecheck:clean": "bunx --bun rimraf storage/framework/.cache/typescript",
6065 "types:check": "bun run typecheck",
packages/stacks/src/http/handlers.tsmodified+1-1
Changes to packages/stacks/src/http/handlers.ts
@@ -67,7 +67,7 @@ export function createHandlers(
6767 return report
6868 }
6969
70 const writable = Boolean(store.addBlock && store.saveBlock && store.removeBlock)
70 const writable = Boolean(store.addBlock && store.saveBlock && store.removeBlock && store.publish)
7171
7272 /** The block, if it belongs to this report. Both ids arrive from a browser. */
7373 const ownedBlock = async (slug: string, blockId: number) => {
packages/stacks/tests/compiler.test.tsmodified+11-0
Changes to packages/stacks/tests/compiler.test.ts
@@ -125,6 +125,17 @@ describe('the compiler', () => {
125125 expect(auckland.total).toBe(17_500)
126126 })
127127
128 it('falls back to UTC when a stored timezone is no longer valid', async () => {
129 const { compiler } = fixture()
130 const query = { model: 'order', measure: 'revenue', time: { key: 'placed' }, grain: 'day' } as const
131
132 const utc = await compiler.run(query, 'UTC')
133 const unknown = await compiler.run(query, 'Not/A_Timezone')
134
135 expect(unknown.series).toEqual(utc.series)
136 expect(unknown.total).toBe(utc.total)
137 })
138
128139 it('groups by a dimension on the same model', async () => {
129140 const { compiler } = fixture()
130141 const result = await compiler.run({
packages/stacks/tests/http.test.tsmodified+132-4
Changes to packages/stacks/tests/http.test.ts
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'bun:test'
22import { Database } from 'bun:sqlite'
33import { Compiler } from '../src/compiler'
44import { elementHtml, tagFor } from '../src/elements'
5import { createHandlers, NotFound, type ReportStore } from '../src/http/handlers'
6import { reportRoutes } from '../src/http/routes'
5import { createHandlers, NotFound, ReadOnly, readQuery, type ReportStore } from '../src/http/handlers'
6import { isNotFound, reportRoutes } from '../src/http/routes'
77import { Registry } from '../src/semantic'
88import { Runner } from '../src/runner'
9import type { RenderedBlock } from '../src/types'
9import type { Handlers, RenderedBlock } from '../src'
1010
11function harness() {
11function harness(overrides: Partial<ReportStore> = {}) {
1212 const sqlite = new Database(':memory:')
1313 sqlite.run(`CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER, created_at TEXT)`)
1414 sqlite.run(`INSERT INTO orders (total, created_at) VALUES (10000, '2026-08-03T10:00:00Z'), (5000, '2026-08-04T10:00:00Z')`)
@@ -33,12 +33,19 @@ function harness() {
3333 { id: 10, kind: 'big_number', title: 'Revenue', x: 0, y: 0, w: 4, h: 4, query: { model: 'order', measure: 'revenue' } },
3434 ],
3535 saveLayout: async (_id, blocks) => { saved.push(...blocks) },
36 ...overrides,
3637 }
3738
3839 return { handlers: createHandlers(store, runner, registry), saved }
3940}
4041
4142describe('handlers', () => {
43 it('lists the reports exposed by the store', async () => {
44 expect((await harness().handlers.index()).reports).toEqual([
45 { id: 1, name: 'Commerce', slug: 'commerce', status: 'published' },
46 ])
47 })
48
4249 it('renders a report', async () => {
4350 const { blocks } = await harness().handlers.show('commerce')
4451
@@ -78,9 +85,89 @@ describe('handlers', () => {
7885 expect(schema.models[0].measures).toEqual(['revenue'])
7986 expect(schema.grains).toContain('day')
8087 })
88
89 it('keeps code-defined reports read-only', async () => {
90 const { handlers } = harness()
91
92 expect(handlers.editable).toBe(false)
93 await expect(handlers.addBlock('commerce', 'bar')).rejects.toThrow(ReadOnly)
94 await expect(handlers.saveBlock('commerce', 10, { title: 'Changed' })).rejects.toThrow(ReadOnly)
95 await expect(handlers.removeBlock('commerce', 10)).rejects.toThrow(ReadOnly)
96 await expect(handlers.publish('commerce')).rejects.toThrow(ReadOnly)
97 })
98
99 it('does not advertise an incomplete editor as writable', async () => {
100 const { handlers } = harness({
101 addBlock: async () => ({ id: 11, kind: 'bar' }),
102 saveBlock: async () => {},
103 removeBlock: async () => {},
104 })
105
106 expect(handlers.editable).toBe(false)
107 await expect(handlers.publish('commerce')).rejects.toThrow(ReadOnly)
108 })
109
110 it('writes only known kinds, owned blocks, and editable fields', async () => {
111 const calls: Array<[string, ...unknown[]]> = []
112 const { handlers } = harness({
113 addBlock: async (reportId, kind) => {
114 calls.push(['add', reportId, kind])
115 return { id: 11, kind: kind as any }
116 },
117 saveBlock: async (reportId, blockId, patch) => { calls.push(['save', reportId, blockId, patch]) },
118 removeBlock: async (reportId, blockId) => { calls.push(['remove', reportId, blockId]) },
119 publish: async reportId => { calls.push(['publish', reportId]) },
120 })
121
122 expect(handlers.editable).toBe(true)
123 await expect(handlers.addBlock('commerce', 'script')).rejects.toThrow(NotFound)
124 expect((await handlers.addBlock('commerce', 'bar')).block.id).toBe(11)
125
126 await handlers.saveBlock('commerce', 10, {
127 id: 999,
128 kind: 'note',
129 reportId: 2,
130 title: 'Net revenue',
131 body: 'After refunds',
132 query: { model: 'order', measure: 'revenue', limit: '25' },
133 })
134 await expect(handlers.saveBlock('commerce', 999, { title: 'Foreign' })).rejects.toThrow(NotFound)
135 await handlers.removeBlock('commerce', 10)
136 expect(await handlers.publish('commerce')).toEqual({ published: true })
137
138 expect(calls).toEqual([
139 ['add', 1, 'bar'],
140 ['save', 1, 10, {
141 title: 'Net revenue',
142 body: 'After refunds',
143 query: { model: 'order', measure: 'revenue', dimension: undefined, time: undefined, grain: undefined, from: undefined, to: undefined, filters: undefined, limit: 25 },
144 }],
145 ['remove', 1, 10],
146 ['publish', 1],
147 ])
148 })
149
150 it('normalizes builder queries before they reach storage', () => {
151 expect(readQuery({ model: 7, measure: null, from: 20260801, to: '', limit: '12' })).toEqual({
152 model: '7',
153 measure: '',
154 dimension: undefined,
155 time: undefined,
156 grain: undefined,
157 from: '20260801',
158 to: undefined,
159 filters: undefined,
160 limit: 12,
161 })
162 })
81163})
82164
83165describe('routes', () => {
166 it('identifies only package not-found errors', () => {
167 expect(isNotFound(new NotFound('missing'))).toBe(true)
168 expect(isNotFound(new Error('missing'))).toBe(false)
169 })
170
84171 it('puts the fixed paths before the slug', () => {
85172 // Or a report called "schema" is unreachable and the schema endpoint is
86173 // read as a slug.
@@ -98,6 +185,47 @@ describe('routes', () => {
98185 expect(route.name.startsWith('reportshq.')).toBe(true)
99186 }
100187 })
188
189 it('forwards route parameters and bodies to the handlers', async () => {
190 const calls: Array<[string, ...unknown[]]> = []
191 const handlers: Handlers = {
192 editable: true,
193 index: async () => { calls.push(['index']); return { reports: [] } },
194 schema: () => { calls.push(['schema']); return { models: [], grains: [] } },
195 show: async (slug, published) => { calls.push(['show', slug, published]); return { report: {}, blocks: [] } },
196 download: async (slug, format) => { calls.push(['download', slug, format]); return { body: '', headers: {} } },
197 saveLayout: async (slug, blocks) => { calls.push(['layout', slug, blocks]); return { blocks: [] } },
198 addBlock: async (slug, kind) => { calls.push(['add', slug, kind]); return { block: { kind: 'note' } } },
199 saveBlock: async (slug, id, body) => { calls.push(['save', slug, id, body]); return { saved: true } },
200 removeBlock: async (slug, id) => { calls.push(['remove', slug, id]); return { removed: true } },
201 publish: async (slug) => { calls.push(['publish', slug]); return { published: true } },
202 }
203 const routes = new Map(reportRoutes(handlers).map(route => [route.name, route]))
204
205 await routes.get('reportshq.index')!.handle({})
206 await routes.get('reportshq.schema')!.handle({})
207 await routes.get('reportshq.download')!.handle({ params: { slug: 'sales', format: 'csv' } })
208 await routes.get('reportshq.draft')!.handle({ params: { slug: 'sales' } })
209 await routes.get('reportshq.blocks.add')!.handle({ params: { slug: 'sales' }, body: { kind: 'bar' } })
210 await routes.get('reportshq.blocks.save')!.handle({ params: { slug: 'sales', block: '42' }, body: { title: 'Sales' } })
211 await routes.get('reportshq.blocks.remove')!.handle({ params: { slug: 'sales', block: '42' } })
212 await routes.get('reportshq.publish')!.handle({ params: { slug: 'sales' } })
213 await routes.get('reportshq.layout')!.handle({ params: { slug: 'sales' }, body: { blocks: [{ id: 42 }] } })
214 await routes.get('reportshq.show')!.handle({ params: { slug: 'sales' } })
215
216 expect(calls).toEqual([
217 ['index'],
218 ['schema'],
219 ['download', 'sales', 'csv'],
220 ['show', 'sales', false],
221 ['add', 'sales', 'bar'],
222 ['save', 'sales', 42, { title: 'Sales' }],
223 ['remove', 'sales', 42],
224 ['publish', 'sales'],
225 ['layout', 'sales', [{ id: 42 }]],
226 ['show', 'sales', true],
227 ])
228 })
101229})
102230
103231describe('elements', () => {
packages/stacks/tests/semantic.test.tsadded+66-0
Changes to packages/stacks/tests/semantic.test.ts
@@ -0,0 +1,66 @@
1import { describe, expect, it } from 'bun:test'
2import { Registry, SemanticError } from '../src/semantic'
3
4const order = {
5 table: 'orders',
6 measures: {
7 orders: { aggregate: 'count' as const },
8 revenue: { aggregate: 'sum' as const, column: 'total' },
9 },
10 dimensions: { status: 'status' },
11 time: { placed: 'created_at' },
12 relations: {
13 customer: { table: 'customers', foreignKey: 'customer_id' },
14 },
15}
16
17describe('semantic registry', () => {
18 it('adds descriptions fluently and keeps keys deterministic', () => {
19 const registry = new Registry().describe('zebra', order).describe('alpha', order)
20
21 expect(registry.has('alpha')).toBe(true)
22 expect(registry.has('missing')).toBe(false)
23 expect(registry.keys()).toEqual(['alpha', 'zebra'])
24 })
25
26 it('allows count without a column but refuses other incomplete measures', () => {
27 const registry = new Registry({
28 order: {
29 ...order,
30 measures: { rows: { aggregate: 'count' }, broken: { aggregate: 'sum' } },
31 },
32 })
33
34 expect(registry.measure('order', 'rows')).toEqual({ aggregate: 'count' })
35 expect(() => registry.measure('order', 'broken')).toThrow(/needs a column/)
36 })
37
38 it('returns only explicitly described direct relations', () => {
39 const registry = new Registry({
40 order,
41 customer: { table: 'customers', measures: {}, dimensions: {}, time: {} },
42 product: { table: 'products', measures: {}, dimensions: {}, time: {} },
43 })
44
45 expect(registry.relation('order', 'customer')).toMatchObject({ name: 'customer', table: 'customers' })
46 expect(() => registry.relation('order', 'product')).toThrow(/no described relation/)
47 })
48
49 it('accepts only the grains the compiler implements', () => {
50 expect(['hour', 'day', 'week', 'month'].map(Registry.grain)).toEqual(['hour', 'day', 'week', 'month'])
51 expect(() => Registry.grain('quarter')).toThrow(SemanticError)
52 expect(() => Registry.grain(null)).toThrow(/not a grain/)
53 })
54
55 it('publishes the same sorted choices a builder may offer', () => {
56 const choices = new Registry({ order }).choices()
57
58 expect(choices.models).toEqual([{
59 key: 'order',
60 measures: ['orders', 'revenue'],
61 dimensions: ['status'],
62 time: ['placed'],
63 }])
64 expect(choices.grains).toEqual(['hour', 'day', 'week', 'month'])
65 })
66})
resources/partials/AppNav.stxmodified+6-6
Changes to resources/partials/AppNav.stx
@@ -5,17 +5,17 @@
55 data. A partial is included into the page's scope, so it reads the page's
66 bindings directly and cannot lose them. --}}
77<header class="bg-panel border-b border-line">
8 <div class="flex gap-4 items-center justify-between mx-auto px-6 py-3 max-w-7xl">
9 <a href="/reports" class="flex gap-6 items-center no-underline">
8 <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:justify-between mx-auto px-4 sm:px-6 py-3 max-w-7xl">
9 <a href="/reports" class="flex gap-6 items-center self-start no-underline">
1010 <Wordmark />
1111 </a>
1212
13 <nav class="flex gap-1 items-center">
14 <a href="/reports" class="px-3 py-1.5 text-muted text-sm hover:text-ink rounded-lg">Reports</a>
15 <a href="/account" class="px-3 py-1.5 text-muted text-sm hover:text-ink rounded-lg">Account</a>
13 <nav class="flex gap-1 items-center justify-between sm:justify-start w-full sm:w-auto">
14 <a href="/reports" class="inline-flex items-center px-3 min-h-11 text-muted text-sm hover:text-ink rounded-lg">Reports</a>
15 <a href="/account" class="inline-flex items-center px-3 min-h-11 text-muted text-sm hover:text-ink rounded-lg">Account</a>
1616 <button
1717 type="button"
18 class="inline-flex items-center justify-center ml-1 h-8 w-8 text-muted border border-line rounded-lg theme-btn"
18 class="inline-flex items-center justify-center ml-1 h-11 w-11 text-muted border border-line rounded-lg theme-btn"
1919 aria-label="Switch theme"
2020 onclick="(function(r){var d=r.getAttribute('data-theme')==='dark';r.setAttribute('data-theme',d?'light':'dark');r.classList.toggle('dark',!d);try{localStorage.setItem('reportshq_theme',d?'light':'dark')}catch(e){}})(document.documentElement)"
2121 >
resources/partials/AuthGuard.stxmodified+2-0
Changes to resources/partials/AuthGuard.stx
@@ -17,6 +17,7 @@
1717 authenticate. The first says where to go; the second says to bin the stale
1818 cookie on the way, which is what stops an expired token from bouncing
1919 somebody back and forth forever. --}}
20<span hidden aria-hidden="true">
2021<script>
2122(function () {
2223 if (!document.getElementById('auth-required')) return
@@ -32,3 +33,4 @@
3233 window.location.replace('/login')
3334}())
3435</script>
36</span>
resources/views/account.stxmodified+2-2
Changes to resources/views/account.stx
@@ -5,9 +5,9 @@
55// than about a project: who they are, and how to stop being signed in. Team
66// billing arrives with #17.
77import { db as __db } from '@stacksjs/database'
8import { sessionCookieName, sessionFrom } from '../../app/Support/session'
8import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
99
10const session = await sessionFrom(cookies)
10const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1111const cookieName = sessionCookieName()
1212const user = session.user
1313const authPending = session.stale
resources/views/forgot.stxmodified+2-2
Changes to resources/views/forgot.stx
@@ -4,9 +4,9 @@
44// Renders for a stranger, like /login. The response never says whether the
55// address has an account, so this page cannot be used to find out who is a
66// customer; the confirmation below is deliberately the same either way.
7import { sessionCookieName, sessionFrom } from '../../app/Support/session'
7import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
88
9const session = await sessionFrom(cookies)
9const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1010const cookieName = sessionCookieName()
1111const signedIn = session.user !== null
1212</script>
resources/views/index.stxmodified+1-1
Changes to resources/views/index.stx
@@ -10,7 +10,7 @@
1010// The sample is stated as a sample. Numbers on a marketing page that imply a
1111// real customer's business are a lie with a chart on it.
1212import { useSeoMeta } from 'stx'
13import { PLANS } from '../../app/Billing/limits'
13import { PLANS } from '../../../app/Billing/limits'
1414
1515const siteUrl = 'https://reportshq.org'
1616const siteDescription = 'Reports that run inside your own Stacks or Laravel application, against your own database. Install a package, point it at the models you already have, and the numbers never leave your servers.'
resources/views/login.stxmodified+2-2
Changes to resources/views/login.stx
@@ -5,9 +5,9 @@
55// AuthGuard and no app chrome. It does the opposite check instead: somebody who
66// already has a session is sent to their reports rather than shown a form
77// asking them to do something they have already done.
8import { sessionCookieName, sessionFrom } from '../../app/Support/session'
8import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
99
10const session = await sessionFrom(cookies)
10const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1111const cookieName = sessionCookieName()
1212const signedIn = session.user !== null
1313</script>
resources/views/pricing.stxmodified+1-1
Changes to resources/views/pricing.stx
@@ -9,7 +9,7 @@
99//
1010// If a limit changes, this page changes with it. Nothing here is typed twice.
1111import { useSeoMeta } from 'stx'
12import { PLANS, TIERS } from '../../app/Billing/limits'
12import { PLANS, TIERS } from '../../../app/Billing/limits'
1313
1414const siteUrl = 'https://reportshq.org'
1515const pricingDescription = 'Three plans, priced per installed application. Free covers one application with unlimited reports and no card. Your data never leaves your servers, and nothing here can stop a report rendering.'
resources/views/register.stxmodified+2-2
Changes to resources/views/register.stx
@@ -5,9 +5,9 @@
55// session. The only account this creates is the person's own; teammates arrive
66// through project invites instead, which is why there is no organisation step
77// here.
8import { sessionCookieName, sessionFrom } from '../../app/Support/session'
8import { sessionCookieName, sessionFrom } from '../../../app/Support/session'
99
10const session = await sessionFrom(cookies)
10const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1111const cookieName = sessionCookieName()
1212const signedIn = session.user !== null
1313</script>
resources/views/reports/[slug].stxmodified+1-1
Changes to resources/views/reports/[slug].stx
@@ -15,7 +15,7 @@ import { elementHtml, License } from '@reportshq/stacks'
1515import { sessionFrom } from '../../../app/Support/session'
1616import { reportHandlers } from '../../../app/Support/reports'
1717
18const session = await sessionFrom(cookies)
18const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1919const user = session.user
2020const authPending = session.stale
2121
resources/views/reports/index.stxmodified+1-1
Changes to resources/views/reports/index.stx
@@ -10,7 +10,7 @@ import { sessionFrom } from '../../../app/Support/session'
1010import { reportHandlers } from '../../../app/Support/reports'
1111import { License } from '@reportshq/stacks'
1212
13const session = await sessionFrom(cookies)
13const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1414const user = session.user
1515const authPending = session.stale
1616
resources/views/reset.stxmodified+7-6
Changes to resources/views/reset.stx
@@ -4,18 +4,19 @@
44// The token and address come from the query string, which is where the email
55// put them. They are read here only to hand back to the API: the page never
66// decides whether a token is valid, because a page cannot be trusted to.
7import { param, sessionCookieName, sessionFrom } from '../../app/Support/session'
7import { param, sessionCookieName, sessionFrom } from '../../../app/Support/session'
88
9const session = await sessionFrom(cookies)
9const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)
1010const cookieName = sessionCookieName()
1111// Deliberately not bounced when signed in. Somebody resetting a password may
1212// well have a stale session open in another tab, and that is the case they are
1313// most likely trying to fix.
1414const signedIn = false
1515
16const token = param(query, 'token') ?? ''
17const email = param(query, 'email') ?? ''
18const usable = token !== '' && email !== ''
16const pageQuery = typeof query !== 'undefined' ? query : undefined
17const __resetToken = param(pageQuery, 'token') ?? ''
18const email = param(pageQuery, 'email') ?? ''
19const usable = __resetToken !== '' && email !== ''
1920</script>
2021
2122<template>
@@ -44,7 +45,7 @@ const usable = token !== '' && email !== ''
4445 submits. The script intercepts it; without the script it still
4546 posts somewhere sensible rather than nowhere. --}}
4647 <form id="signin" method="post" action="/api/auth/reset" class="grid gap-4" @if (!usable) hidden @endif>
47 <input id="token" type="hidden" value="" />
48 <input id="token" type="hidden" value="" />
4849 <input id="email" type="hidden" value="async function email() { return (await authUser())?.email; }" />
4950
5051 <label class="block">
scripts/smoke-deployment.tsadded+63-0
Changes to scripts/smoke-deployment.ts
@@ -0,0 +1,63 @@
1import { readFileSync } from 'node:fs'
2import { resolve } from 'node:path'
3
4interface SmokeContract {
5 domain: string
6 homeText: string
7}
8
9const CONTRACTS: Record<string, SmokeContract> = {
10 analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' },
11 bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' },
12 commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' },
13 loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' },
14 reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' },
15 status: { domain: 'statushq.org', homeText: 'Know the moment' },
16}
17
18const projectRoot = resolve(import.meta.dir, '..')
19const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
20const packageName = String(packageJson.name || '')
21const directoryName = projectRoot.split('/').pop() || ''
22const appName = CONTRACTS[packageName] ? packageName : directoryName
23const contract = CONTRACTS[appName]
24
25if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`)
26
27const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '')
28const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10))
29const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000))
30
31async function readWithRetry(path: string): Promise<string> {
32 let lastFailure = 'no response'
33 for (let attempt = 1; attempt <= attempts; attempt += 1) {
34 try {
35 const separator = path.includes('?') ? '&' : '?'
36 const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, {
37 headers: { accept: path === '/robots.txt' ? 'text/plain' : 'text/html' },
38 redirect: 'follow',
39 signal: AbortSignal.timeout(10_000),
40 })
41 const body = await response.text()
42 if (response.ok) return body
43 lastFailure = `HTTP ${response.status}`
44 }
45 catch (error) {
46 lastFailure = error instanceof Error ? error.message : String(error)
47 }
48 if (attempt < attempts) await Bun.sleep(pauseMs)
49 }
50 throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`)
51}
52
53const home = await readWithRetry('/')
54if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`)
55
56const login = await readWithRetry('/login')
57if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.')
58
59const robots = await readWithRetry('/robots.txt')
60if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
61if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
62
63console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`)
scripts/validate-docs.tsadded+102-0
Changes to scripts/validate-docs.ts
@@ -0,0 +1,102 @@
1import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2import { dirname, join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const docsDir = resolve(root, 'docs')
6const configPath = resolve(root, 'config/docs.ts')
7const issues = new Set<string>()
8const incomingRoutes = new Set<string>()
9const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status']
10
11function markdownFiles(dir: string): string[] {
12 return readdirSync(dir).flatMap((name) => {
13 const path = join(dir, name)
14 return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : []
15 })
16}
17
18function proseOnly(text: string): string {
19 let inFence = false
20 return text.split('\n').filter((line) => {
21 if (/^(?:```|~~~)/.test(line)) {
22 inFence = !inFence
23 return false
24 }
25 return !inFence
26 }).join('\n')
27}
28
29function routeForFile(file: string): string {
30 const path = relative(docsDir, file).replace(/\\/g, '/')
31 if (path === 'index.md') return '/'
32 return `/${path.replace(/(?:\/index)?\.md$/, '')}`
33}
34
35function resolveDocTarget(source: string, rawTarget: string): string | null {
36 let target = rawTarget.replace(/^<|>$/g, '').split(/[?#]/, 1)[0]
37 if (!target || /^(?:[a-z]+:|#|mailto:|tel:)/i.test(rawTarget)) return null
38
39 if (target === '/docs') target = '/'
40 else if (target.startsWith('/docs/')) target = target.slice('/docs'.length)
41
42 const base = target.startsWith('/') ? resolve(docsDir, `.${target}`) : resolve(dirname(source), target)
43 const candidates = [base, `${base}.md`, join(base, 'index.md')]
44 const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile())
45 if (found) return found
46 if (target.startsWith('/') && appRoutePrefixes.some(prefix => target === prefix || target.startsWith(`${prefix}/`))) return null
47 return ''
48}
49
50function checkTarget(source: string, rawTarget: string): void {
51 const target = resolveDocTarget(source, rawTarget)
52 if (target === null) return
53 if (target === '') {
54 issues.add(`${relative(root, source)}: missing local target ${rawTarget}`)
55 return
56 }
57 if (target.startsWith(docsDir)) incomingRoutes.add(routeForFile(target))
58}
59
60if (!existsSync(docsDir)) {
61 console.error('Documentation directory does not exist: docs/')
62 process.exit(1)
63}
64
65const files = markdownFiles(docsDir)
66for (const file of files) {
67 const text = readFileSync(file, 'utf8')
68 const prose = proseOnly(text)
69 const name = relative(root, file)
70 const headings = prose.match(/^#\s+\S.*$/gm) ?? []
71 const homeLayout = /^layout:\s*home\s*$/m.test(text)
72
73 if (headings.length !== 1 && !(homeLayout && headings.length === 0)) {
74 issues.add(`${name}: expected one H1, found ${headings.length}`)
75 }
76
77 const fences = text.match(/^(?:```|~~~)/gm) ?? []
78 if (fences.length % 2 !== 0) issues.add(`${name}: unbalanced fenced code blocks`)
79 if (/[—–]/.test(prose)) issues.add(`${name}: contains a prohibited long dash`)
80 if (/\b(?:TODO|TBD|FIXME|XXX)\b/.test(prose)) issues.add(`${name}: contains an unfinished marker`)
81
82 for (const match of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) checkTarget(file, match[1])
83 for (const match of prose.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) checkTarget(file, match[1])
84}
85
86if (existsSync(configPath)) {
87 const config = readFileSync(configPath, 'utf8')
88 for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1])
89}
90
91for (const file of files) {
92 const route = routeForFile(file)
93 if (route !== '/' && !incomingRoutes.has(route)) {
94 issues.add(`${relative(root, file)}: not linked from navigation or another documentation page`)
95 }
96}
97
98console.log(`Validated ${files.length} documentation pages.`)
99if (issues.size > 0) {
100 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
101 process.exit(1)
102}
scripts/validate-release.tsadded+199-0
Changes to scripts/validate-release.ts
@@ -0,0 +1,199 @@
1import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2import { extname, join, resolve } from 'node:path'
3
4interface AppContract {
5 domain: string
6 minPages: number
7 routes: Record<string, string[]>
8}
9
10const CONTRACTS: Record<string, AppContract> = {
11 analyticshq: {
12 domain: 'analyticshq.org',
13 minPages: 30,
14 routes: {
15 '/': ['analyticshq'],
16 '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'],
17 '/login': ['analyticshq'],
18 },
19 },
20 bughq: {
21 domain: 'bughq.org',
22 minPages: 25,
23 routes: {
24 '/': ['Error tracking for people who ship.', 'From throw to triaged'],
25 '/login': ['bughq'],
26 '/dashboard': ['bughq'],
27 },
28 },
29 commshq: {
30 domain: 'commshq.org',
31 minPages: 45,
32 routes: {
33 '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'],
34 '/login': ['CommsHQ'],
35 '/dashboard/commshq': ['CommsHQ'],
36 },
37 },
38 loghq: {
39 domain: 'loghq.org',
40 minPages: 30,
41 routes: {
42 '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'],
43 '/login': ['loghq'],
44 '/compare/sentry': ['loghq', 'Sentry'],
45 },
46 },
47 reportshq: {
48 domain: 'reportshq.org',
49 minPages: 20,
50 routes: {
51 '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'],
52 '/login': ['ReportsHQ'],
53 '/pricing': ['ReportsHQ'],
54 },
55 },
56 status: {
57 domain: 'statushq.org',
58 minPages: 35,
59 routes: {
60 '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'],
61 '/login': ['Welcome back'],
62 '/register': ['Create your account'],
63 },
64 },
65}
66
67const projectRoot = resolve(import.meta.dir, '..')
68const distRoot = resolve(projectRoot, 'dist')
69const ssrRoot = resolve(projectRoot, '.output')
70const ssrPages = resolve(ssrRoot, 'server/pages')
71const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
72const packageName = String(packageJson.name || '')
73const directoryName = projectRoot.split('/').pop() || ''
74const appName = CONTRACTS[packageName] ? packageName : directoryName
75const contract = CONTRACTS[appName]
76
77if (!contract)
78 throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`)
79
80const isSsr = existsSync(ssrPages)
81if (!existsSync(distRoot) && !isSsr)
82 throw new Error('No production artifact found. Run bun run build first.')
83
84function walkFiles(directory: string): string[] {
85 if (!existsSync(directory)) return []
86 const files: string[] = []
87 for (const entry of readdirSync(directory)) {
88 const path = join(directory, entry)
89 if (statSync(path).isDirectory()) files.push(...walkFiles(path))
90 else files.push(path)
91 }
92 return files
93}
94
95function routeArtifact(route: string): { html: string, source: string } | undefined {
96 if (!isSsr) {
97 const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`)
98 const nested = join(distRoot, route.slice(1), 'index.html')
99 const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined
100 return source ? { html: readFileSync(source, 'utf8'), source } : undefined
101 }
102
103 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
104 const source = join(ssrPages, `${name}.compiled.json`)
105 if (!existsSync(source)) return undefined
106 const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string }
107 return { html: compiled.html || '', source }
108}
109
110const pageFiles = isSsr
111 ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json'))
112 : walkFiles(distRoot).filter(path => path.endsWith('.html'))
113
114const failures: string[] = []
115if (pageFiles.length < contract.minPages)
116 failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`)
117
118const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot
119for (const [route, expectedText] of Object.entries(contract.routes)) {
120 const artifact = routeArtifact(route)
121 if (!artifact) {
122 failures.push(`${route}: production artifact is missing`)
123 continue
124 }
125
126 const withoutCode = artifact.html
127 .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
128 .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
129 .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ')
130 const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
131
132 for (const fragment of expectedText) {
133 if (!visibleText.includes(fragment))
134 failures.push(`${route}: expected text is missing: ${fragment}`)
135 }
136 if (/@(if|foreach|forelse)\b/.test(visibleText))
137 failures.push(`${route}: unresolved template syntax is visible`)
138 if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode))
139 failures.push(`${route}: an empty primary heading was emitted`)
140 if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html))
141 failures.push(`${route}: a secret-shaped assignment was emitted`)
142
143 const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi
144 let match: RegExpExecArray | null
145 while ((match = assetPattern.exec(artifact.html))) {
146 const assetPath = match[1]
147 const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/')
148 if (!looksLikeAsset) continue
149 const local = resolve(publicRoot, `.${assetPath}`)
150 if (!local.startsWith(`${publicRoot}/`) || !existsSync(local))
151 failures.push(`${route}: referenced asset is missing: ${assetPath}`)
152 }
153}
154
155for (const metadataFile of ['sitemap.xml', 'robots.txt']) {
156 const path = join(publicRoot, metadataFile)
157 if (!existsSync(path)) {
158 failures.push(`${metadataFile}: missing from production artifact`)
159 continue
160 }
161 const contents = readFileSync(path, 'utf8')
162 if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`)
163 if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`)
164}
165
166const migrationsDir = resolve(projectRoot, 'database/migrations')
167if (existsSync(migrationsDir)) {
168 for (const file of readdirSync(migrationsDir).sort()) {
169 const path = join(migrationsDir, file)
170 if (!statSync(path).isFile()) continue
171 if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`)
172 const sql = readFileSync(path, 'utf8')
173 if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`)
174 }
175}
176
177if (isSsr && failures.length === 0) {
178 const { startProductionServer } = await import('@stacksjs/stx')
179 const port = 42_000 + Math.floor(Math.random() * 8_000)
180 const server = await startProductionServer({ outputDir: ssrRoot, port })
181 try {
182 for (const route of Object.keys(contract.routes)) {
183 const response = await fetch(`http://127.0.0.1:${server.port}${route}`)
184 const body = await response.text()
185 if (!response.ok) failures.push(`${route}: production server returned ${response.status}`)
186 if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`)
187 }
188 }
189 finally {
190 server.stop()
191 }
192}
193
194if (failures.length) {
195 for (const failure of failures) console.error(`- ${failure}`)
196 process.exit(1)
197}
198
199console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`)
scripts/validate-ui.tsadded+90-0
Changes to scripts/validate-ui.ts
@@ -0,0 +1,90 @@
1import { readdirSync, readFileSync, statSync } from 'node:fs'
2import { join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const resources = resolve(root, 'resources')
6const issues = new Set<string>()
7
8function filesIn(directory: string): string[] {
9 return readdirSync(directory).flatMap((name) => {
10 const path = join(directory, name)
11 return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : []
12 })
13}
14
15function withoutComments(value: string): string {
16 const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ')
17 return value
18 .replace(/<!--[^]*?-->/g, preserveLines)
19 .replace(/\{\{--[^]*?--\}\}/g, preserveLines)
20 .replace(/\/\*[^]*?\*\//g, preserveLines)
21 .replace(/^\s*\/\/.*$/gm, preserveLines)
22}
23
24function lineAt(source: string, index: number): number {
25 return source.slice(0, index).split('\n').length
26}
27
28function named(attributes: string): boolean {
29 return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes)
30}
31
32function report(file: string, source: string, index: number, message: string): void {
33 issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`)
34}
35
36const files = filesIn(resources)
37const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file))
38const combined = files.map(file => readFileSync(file, 'utf8')).join('\n')
39
40for (const file of markupFiles) {
41 const original = readFileSync(file, 'utf8')
42 const source = withoutComments(original)
43
44 for (const match of source.matchAll(/<img\b[^>]*>/gi)) {
45 if (!/\b(?::?alt)=/.test(match[0]))
46 report(file, source, match.index, 'image has no alt text')
47 }
48
49 for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) {
50 const attributes = match[1]
51 const content = match[2]
52 .replace(/<[^>]+>/g, '')
53 .replace(/\{\{[^}]+\}\}/g, 'dynamic')
54 .replace(/@[a-z]+\([^)]*\)/gi, '')
55 .trim()
56 if (!content && !named(attributes))
57 report(file, source, match.index, 'icon-only button has no accessible name')
58 }
59
60 for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
61 const attributes = match[2]
62 if (/\btype=["']hidden["']/i.test(attributes) || named(attributes))
63 continue
64 const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1]
65 const before = source.slice(0, match.index)
66 const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>')
67 const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false
68 if (!wrapped && !referenced)
69 report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`)
70 }
71
72 for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi))
73 report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`)
74
75 for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) {
76 if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1]))
77 report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible')
78 }
79}
80
81if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined))
82 issues.add('resources: no visible keyboard focus treatment found')
83if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined))
84 issues.add('resources: no responsive breakpoint treatment found')
85
86console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`)
87if (issues.size) {
88 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
89 process.exit(1)
90}
scripts/validate-visual.tsadded+344-0
Changes to scripts/validate-visual.ts
@@ -0,0 +1,344 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { join, resolve } from 'node:path'
4
5interface Viewport {
6 name: string
7 width: number
8 height: number
9 mobile: boolean
10}
11
12interface PageAudit {
13 contrast: string[]
14 focusable: number
15 imagesWithoutAlt: number
16 overflow: number
17 unresolved: boolean
18 unnamedControls: string[]
19 visibleTextLength: number
20}
21
22interface Pending {
23 reject: (error: Error) => void
24 resolve: (value: any) => void
25 timer: ReturnType<typeof setTimeout>
26}
27
28const DIST = resolve('dist')
29const SSR_OUTPUT = resolve('.output')
30const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages')
31const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public')
32const OUTPUT = resolve('storage/logs/visual-contract')
33const VIEWPORTS: Viewport[] = [
34 { name: 'desktop', width: 1440, height: 1000, mobile: false },
35 { name: 'tablet', width: 768, height: 1024, mobile: false },
36 { name: 'small-mobile', width: 320, height: 780, mobile: true },
37 { name: 'mobile', width: 390, height: 844, mobile: true },
38 { name: 'large-mobile', width: 430, height: 932, mobile: true },
39]
40const THEMES = ['light', 'dark'] as const
41const ROUTE_CANDIDATES = [
42 '/',
43 '/pricing',
44 '/features',
45 '/use-cases',
46 '/login',
47 '/register',
48 '/reports',
49 '/account',
50]
51
52function chromePath(): string {
53 const candidates = [
54 process.env.CHROME_PATH,
55 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
56 '/usr/bin/google-chrome',
57 '/usr/bin/google-chrome-stable',
58 '/usr/bin/chromium',
59 '/usr/bin/chromium-browser',
60 ]
61 const found = candidates.find(candidate => candidate && existsSync(candidate))
62 if (!found) throw new Error('Chrome was not found. Set CHROME_PATH to a Chrome or Chromium executable.')
63 return found
64}
65
66async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise<T> {
67 const started = Date.now()
68 let value = await read()
69 while (!value && Date.now() - started < timeout) {
70 await Bun.sleep(100)
71 value = await read()
72 }
73 if (!value) throw new Error(`Timed out after ${timeout}ms`)
74 return value
75}
76
77class Cdp {
78 private id = 0
79 private pending = new Map<number, Pending>()
80
81 constructor(private socket: WebSocket) {
82 socket.addEventListener('message', (event) => {
83 const message = JSON.parse(String(event.data))
84 if (!message.id) return
85 const pending = this.pending.get(message.id)
86 if (!pending) return
87 this.pending.delete(message.id)
88 clearTimeout(pending.timer)
89 if (message.error) pending.reject(new Error(message.error.message))
90 else pending.resolve(message.result)
91 })
92 }
93
94 send(method: string, params: Record<string, any> = {}): Promise<any> {
95 const id = ++this.id
96 return new Promise((resolve, reject) => {
97 const timer = setTimeout(() => {
98 this.pending.delete(id)
99 reject(new Error(`Chrome command timed out: ${method}`))
100 }, 15_000)
101 this.pending.set(id, { reject, resolve, timer })
102 this.socket.send(JSON.stringify({ id, method, params }))
103 })
104 }
105
106 async evaluate<T>(expression: string): Promise<T> {
107 const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
108 if (result.exceptionDetails)
109 throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
110 return result.result?.value as T
111 }
112}
113
114async function connect(url: string): Promise<Cdp> {
115 const socket = new WebSocket(url)
116 await new Promise<void>((resolve, reject) => {
117 socket.addEventListener('open', () => resolve(), { once: true })
118 socket.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true })
119 })
120 return new Cdp(socket)
121}
122
123function routeName(route: string): string {
124 return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')
125}
126
127function routeFile(route: string): { kind: 'html' | 'ssr', path: string } | undefined {
128 if (existsSync(DIST)) {
129 const base = route === '/' ? join(DIST, 'index.html') : join(DIST, `${route.slice(1)}.html`)
130 if (existsSync(base)) return { kind: 'html', path: base }
131 const index = join(DIST, route.slice(1), 'index.html')
132 if (existsSync(index)) return { kind: 'html', path: index }
133 }
134
135 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
136 const compiled = join(SSR_PAGES, `${name}.compiled.json`)
137 return existsSync(compiled) ? { kind: 'ssr', path: compiled } : undefined
138}
139
140function pageResponse(page: { kind: 'html' | 'ssr', path: string }): Response {
141 if (page.kind === 'html') return new Response(Bun.file(page.path))
142 const compiled = JSON.parse(readFileSync(page.path, 'utf8')) as { html?: string }
143 return new Response(compiled.html || '', { headers: { 'content-type': 'text/html; charset=utf-8' } })
144}
145
146const auditExpression = `(() => {
147 const visible = element => {
148 const style = getComputedStyle(element)
149 const rect = element.getBoundingClientRect()
150 if (element.closest('details:not([open])') && element.tagName !== 'SUMMARY') return false
151 return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
152 && rect.width > 0 && rect.height > 0
153 }
154 const label = element => {
155 if (element.getAttribute('aria-label')) return element.getAttribute('aria-label')
156 const labelledBy = element.getAttribute('aria-labelledby')
157 if (labelledBy && labelledBy.split(/\\s+/).some(id => document.getElementById(id)?.textContent?.trim())) return labelledBy
158 if (element.id && document.querySelector('label[for="' + CSS.escape(element.id) + '"]')?.textContent?.trim()) return element.id
159 if (element.closest('label')?.textContent?.trim()) return 'wrapped'
160 if (element.tagName === 'BUTTON' && element.textContent?.trim()) return 'text'
161 if (element.tagName === 'A' && element.textContent?.trim()) return 'text'
162 if (element.getAttribute('title')) return element.getAttribute('title')
163 return ''
164 }
165 const parseRgb = value => {
166 const match = value.match(/rgba?\\(([^)]+)\\)/)
167 if (!match) return null
168 const parts = match[1].split(/[ ,/]+/).filter(Boolean).map(Number)
169 if (parts.length < 3 || parts.some(Number.isNaN)) return null
170 return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 }
171 }
172 const luminance = color => {
173 const channel = value => {
174 const normalized = value / 255
175 return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
176 }
177 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
178 }
179 const background = element => {
180 let current = element
181 while (current) {
182 const color = parseRgb(getComputedStyle(current).backgroundColor)
183 if (color && color.a >= 0.98) return color
184 current = current.parentElement
185 }
186 return { r: 255, g: 255, b: 255, a: 1 }
187 }
188 const controls = [...document.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea')].filter(visible)
189 const unnamedControls = controls.filter(element => !label(element)).map(element => {
190 const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : ''
191 return element.tagName.toLowerCase() + identity
192 })
193 const contrast = []
194 const textElements = [...document.querySelectorAll('h1, h2, h3, h4, p, label, button, a, summary, span')]
195 for (const element of textElements) {
196 if (!visible(element) || !element.textContent?.trim()) continue
197 if (element.children.length && !['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)) continue
198 const style = getComputedStyle(element)
199 const foreground = parseRgb(style.color)
200 const behind = background(element)
201 if (!foreground || foreground.a < 0.98) continue
202 const lighter = Math.max(luminance(foreground), luminance(behind))
203 const darker = Math.min(luminance(foreground), luminance(behind))
204 const ratio = (lighter + 0.05) / (darker + 0.05)
205 const large = Number.parseFloat(style.fontSize) >= 24 || (Number.parseFloat(style.fontSize) >= 18.66 && Number(style.fontWeight) >= 700)
206 if (ratio + 0.01 < (large ? 3 : 4.5)) {
207 const sample = element.textContent.trim().replace(/\\s+/g, ' ').slice(0, 42)
208 contrast.push(sample + ' (' + ratio.toFixed(2) + ':1)')
209 }
210 if (contrast.length >= 10) break
211 }
212 return {
213 contrast,
214 focusable: controls.length,
215 imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length,
216 overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
217 unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'),
218 unnamedControls,
219 visibleTextLength: document.body.innerText.trim().length,
220 }
221})()`
222
223async function main(): Promise<void> {
224 if (!existsSync(DIST) && !existsSync(SSR_PAGES))
225 throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`)
226 const routes = ROUTE_CANDIDATES.filter(route => routeFile(route))
227 if (routes.length < 3) throw new Error(`Expected at least three visual routes, found ${routes.join(', ') || 'none'}.`)
228
229 mkdirSync(OUTPUT, { recursive: true })
230 const server = Bun.serve({
231 hostname: '127.0.0.1',
232 port: 0,
233 fetch(request) {
234 const url = new URL(request.url)
235 const route = decodeURIComponent(url.pathname)
236 const page = routeFile(route)
237 if (page) return pageResponse(page)
238 const publicRoot = existsSync(DIST) ? DIST : SSR_PUBLIC
239 const asset = resolve(publicRoot, `.${route}`)
240 const file = asset.startsWith(`${publicRoot}/`) && existsSync(asset) ? asset : undefined
241 return file ? new Response(Bun.file(file)) : new Response('Not found', { status: 404 })
242 },
243 })
244 const profile = mkdtempSync(join(tmpdir(), 'hq-visual-'))
245 const chrome = Bun.spawn([
246 chromePath(),
247 '--headless=new',
248 '--remote-debugging-port=0',
249 `--user-data-dir=${profile}`,
250 '--no-first-run',
251 '--no-default-browser-check',
252 '--disable-background-networking',
253 '--disable-component-update',
254 '--disable-extensions',
255 '--hide-scrollbars',
256 '--no-sandbox',
257 'about:blank',
258 ], { stdout: 'ignore', stderr: 'ignore' })
259
260 const failures: string[] = []
261 let screenshots = 0
262 try {
263 const portFile = join(profile, 'DevToolsActivePort')
264 await waitFor(() => existsSync(portFile), 10_000)
265 const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
266 const targets = await waitFor(async () => {
267 const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => [])
268 return Array.isArray(items) && items.length ? items : null
269 }) as Array<{ type: string, url: string, webSocketDebuggerUrl: string }>
270 const target = targets.find(item => item.type === 'page' && item.url === 'about:blank') || targets.find(item => item.type === 'page')
271 if (!target) throw new Error('Chrome did not expose a page target.')
272
273 const cdp = await connect(target.webSocketDebuggerUrl)
274 await cdp.send('Page.enable')
275 await cdp.send('Runtime.enable')
276 await cdp.send('Emulation.setScriptExecutionDisabled', { value: true })
277
278 for (const route of routes) {
279 for (const viewport of VIEWPORTS) {
280 await cdp.send('Emulation.setDeviceMetricsOverride', {
281 width: viewport.width,
282 height: viewport.height,
283 deviceScaleFactor: 1,
284 mobile: viewport.mobile,
285 })
286 await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` })
287 await waitFor(() => cdp.evaluate("document.readyState === 'complete'"))
288 await Bun.sleep(100)
289 // Protected stx static builds wrap the server-rendered guest branch in a
290 // top-level template. Scripts are disabled in this contract, so expose
291 // that branch for layout inspection instead of capturing a blank page.
292 await cdp.evaluate(`(() => {
293 if (document.body.innerText.trim()) return
294 const shell = document.querySelector('body > template')
295 if (shell) document.body.append(shell.content.cloneNode(true))
296 })()`)
297 for (const theme of THEMES) {
298 await cdp.evaluate(`(() => {
299 const theme = ${JSON.stringify(theme)}
300 document.documentElement.classList.toggle('dark', theme === 'dark')
301 document.documentElement.setAttribute('data-theme', theme)
302 document.documentElement.setAttribute('data-color-mode', theme)
303 document.documentElement.setAttribute('color-mode', theme)
304 })()`)
305 await Bun.sleep(500)
306 const audit = await cdp.evaluate<PageAudit>(auditExpression)
307 const key = `${route} at ${viewport.name}/${theme}`
308 if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`)
309 if (audit.visibleTextLength < 20) failures.push(`${key}: no meaningful visible page content`)
310 if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`)
311 if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`)
312 if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`)
313 for (const issue of audit.contrast) failures.push(`${key}: low text contrast ${issue}`)
314
315 if (audit.focusable) {
316 await cdp.evaluate("document.body.setAttribute('tabindex', '-1'); document.body.focus()")
317 await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
318 await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
319 const focused = await cdp.evaluate("document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement")
320 if (!focused) failures.push(`${key}: Tab did not move focus to a control`)
321 }
322
323 const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true })
324 writeFileSync(join(OUTPUT, `${routeName(route)}-${theme}-${viewport.name}.png`), Buffer.from(shot.data, 'base64'))
325 screenshots += 1
326 }
327 }
328 }
329 }
330 finally {
331 chrome.kill()
332 await chrome.exited
333 server.stop(true)
334 rmSync(profile, { force: true, recursive: true })
335 }
336
337 console.log(`Rendered ${routes.length} routes in ${THEMES.length} themes at ${VIEWPORTS.length} viewports and wrote ${screenshots} screenshots.`)
338 if (failures.length) {
339 for (const failure of failures) console.error(`- ${failure}`)
340 process.exit(1)
341 }
342}
343
344await main()
site.config.tsadded+17-0
Changes to site.config.ts
@@ -0,0 +1,17 @@
1const description = 'Build, schedule, export, and securely share reports from the data sources your team already uses.'
2
3export default {
4 name: 'ReportsHQ',
5 url: 'https://reportshq.org',
6 description,
7 seo: {
8 siteName: 'ReportsHQ',
9 title: 'ReportsHQ - Reports without the busywork',
10 description,
11 image: 'https://reportshq.org/og.png',
12 favicon: '/favicon.svg',
13 locale: 'en_US',
14 type: 'website',
15 },
16 pages: {},
17}
tests/feature/reports-surface.test.tsmodified+1-1
Changes to tests/feature/reports-surface.test.ts
@@ -84,7 +84,7 @@ describe('the reports surface', () => {
8484 for (const page of ['resources/views/reports/index.stx', 'resources/views/reports/[slug].stx']) {
8585 const source = readFileSync(join(root, page), 'utf8')
8686
87 expect(source).toContain('sessionFrom(cookies)')
87 expect(source).toContain("sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)")
8888 expect(source).toContain(` /var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:3990:6427: include error: Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx (line 3990, col 6427) Message: Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx' Context:  3988:
site.config.tsadded+17-0
Changes to site.config.ts
@@ -0,0 +1,17 @@
1const description = 'Build, schedule, export, and securely share reports from the data sources your team already uses.'
2
3export default {
4 name: 'ReportsHQ',
5 url: 'https://reportshq.org',
6 description,
7 seo: {
8 siteName: 'ReportsHQ',
9 title: 'ReportsHQ - Reports without the busywork',
10 description,
11 image: 'https://reportshq.org/og.png',
12 favicon: '/favicon.svg',
13 locale: 'en_US',
14 type: 'website',
15 },
16 pages: {},
17}
3989: > 3990:
tests/feature/reports-surface.test.tsmodified+1-1
Changes to tests/feature/reports-surface.test.ts
@@ -84,7 +84,7 @@ describe('the reports surface', () => {
8484 for (const page of ['resources/views/reports/index.stx', 'resources/views/reports/[slug].stx']) {
8585 const source = readFileSync(join(root, page), 'utf8')
8686
87 expect(source).toContain('sessionFrom(cookies)')
87 expect(source).toContain("sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)")
8888 expect(source).toContain(` /var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:3992:14192: include error: Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx (line 3992, col 14192) Message: Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx' Context:  3990:
tests/feature/reports-surface.test.tsmodified+1-1
Changes to tests/feature/reports-surface.test.ts
@@ -84,7 +84,7 @@ describe('the reports surface', () => {
8484 for (const page of ['resources/views/reports/index.stx', 'resources/views/reports/[slug].stx']) {
8585 const source = readFileSync(join(root, page), 'utf8')
8686
87 expect(source).toContain('sessionFrom(cookies)')
87 expect(source).toContain("sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)")
8888 expect(source).toContain(` /var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:4012:28: include error: Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx (line 4012, col 28) Message: Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx' Context:  4010: 4011:
  • > 4012: .github/workflows/ci.yml 4013: 4014: +57 `)
  • 8989 expect(source).toContain('auth-required')
    9090 // And the data is fetched only for an identified visitor.
    3991: > 3992:
    tests/fixtures/product-states.test.tsadded+20-0
    Changes to tests/fixtures/product-states.test.ts
    @@ -0,0 +1,20 @@
    1import { describe, expect, test } from 'bun:test'
    2
    3const NOW = '2026-09-12T12:00:00.000Z'
    4const report = (index: number, status = 'published') => ({ id: index, slug: `report-${index}`, name: `Report ${index}`, status, blocks: 2 + index % 8, updatedAt: NOW })
    5
    6export const productStates = {
    7 empty: { now: NOW, status: 'ready', reports: [], schedules: 0 },
    8 normal: { now: NOW, status: 'ready', reports: [report(1), report(2, 'draft'), report(3)], schedules: 2 },
    9 loading: { now: NOW, status: 'loading', reports: [], schedules: null },
    10 failure: { now: NOW, status: 'error', reports: [], error: 'Report query failed' },
    11 highVolume: { now: NOW, status: 'ready', reports: Array.from({ length: 250 }, (_, index) => report(index + 1)), schedules: 80 },
    12} as const
    13
    14describe('deterministic report product states', () => {
    15 test('covers every UI state', () => expect(Object.keys(productStates)).toEqual(['empty', 'normal', 'loading', 'failure', 'highVolume']))
    16 test('keeps volume fixtures large and reproducible', () => {
    17 expect(productStates.highVolume.reports).toHaveLength(250)
    18 expect(JSON.stringify(productStates)).toBe(JSON.stringify(productStates))
    19 })
    20})
    3993: 3994:  `
    )
    8989 expect(source).toContain('auth-required')
    9090 // And the data is fetched only for an identified visitor.
    3991: 3992:
    tests/fixtures/product-states.test.tsadded+20-0
    Changes to tests/fixtures/product-states.test.ts
    @@ -0,0 +1,20 @@
    1import { describe, expect, test } from 'bun:test'
    2
    3const NOW = '2026-09-12T12:00:00.000Z'
    4const report = (index: number, status = 'published') => ({ id: index, slug: `report-${index}`, name: `Report ${index}`, status, blocks: 2 + index % 8, updatedAt: NOW })
    5
    6export const productStates = {
    7 empty: { now: NOW, status: 'ready', reports: [], schedules: 0 },
    8 normal: { now: NOW, status: 'ready', reports: [report(1), report(2, 'draft'), report(3)], schedules: 2 },
    9 loading: { now: NOW, status: 'loading', reports: [], schedules: null },
    10 failure: { now: NOW, status: 'error', reports: [], error: 'Report query failed' },
    11 highVolume: { now: NOW, status: 'ready', reports: Array.from({ length: 250 }, (_, index) => report(index + 1)), schedules: 80 },
    12} as const
    13
    14describe('deterministic report product states', () => {
    15 test('covers every UI state', () => expect(Object.keys(productStates)).toEqual(['empty', 'normal', 'loading', 'failure', 'highVolume']))
    16 test('keeps volume fixtures large and reproducible', () => {
    17 expect(productStates.highVolume.reports).toHaveLength(250)
    18 expect(JSON.stringify(productStates)).toBe(JSON.stringify(productStates))
    19 })
    20})
     `
    )
    8989 expect(source).toContain('auth-required')
    9090 // And the data is fetched only for an identified visitor.
    tests/fixtures/product-states.test.tsadded+20-0
    Changes to tests/fixtures/product-states.test.ts
    @@ -0,0 +1,20 @@
    1import { describe, expect, test } from 'bun:test'
    2
    3const NOW = '2026-09-12T12:00:00.000Z'
    4const report = (index: number, status = 'published') => ({ id: index, slug: `report-${index}`, name: `Report ${index}`, status, blocks: 2 + index % 8, updatedAt: NOW })
    5
    6export const productStates = {
    7 empty: { now: NOW, status: 'ready', reports: [], schedules: 0 },
    8 normal: { now: NOW, status: 'ready', reports: [report(1), report(2, 'draft'), report(3)], schedules: 2 },
    9 loading: { now: NOW, status: 'loading', reports: [], schedules: null },
    10 failure: { now: NOW, status: 'error', reports: [], error: 'Report query failed' },
    11 highVolume: { now: NOW, status: 'ready', reports: Array.from({ length: 250 }, (_, index) => report(index + 1)), schedules: 80 },
    12} as const
    13
    14describe('deterministic report product states', () => {
    15 test('covers every UI state', () => expect(Object.keys(productStates)).toEqual(['empty', 'normal', 'loading', 'failure', 'highVolume']))
    16 test('keeps volume fixtures large and reproducible', () => {
    17 expect(productStates.highVolume.reports).toHaveLength(250)
    18 expect(JSON.stringify(productStates)).toBe(JSON.stringify(productStates))
    19 })
    20})
    @endif