also looking at this
Expand BunPress guide and pre-E2E quality gates
#35
40 files
+1524
-373
| @@ -175,6 +175,53 @@ jobs: | ||
| 175 | 175 | - name: Design tokens |
| 176 | 176 | run: bun scripts/verify-tokens.ts |
| 177 | 177 | |
| 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 | ||
| 178 | 225 | # Push to deploy. |
| 179 | 226 | # |
| 180 | 227 | # This job lives in CI rather than in a workflow of its own so the gates are |
| @@ -185,7 +232,7 @@ jobs: | ||
| 185 | 232 | # |
| 186 | 233 | # A red build therefore means NO deploy, silently and by construction. |
| 187 | 234 | deploy: |
| 188 | needs: [lint, typecheck, test, test-postgres] | |
| 235 | needs: [lint, typecheck, test, test-postgres, build] | |
| 189 | 236 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' |
| 190 | 237 | runs-on: ubuntu-latest |
| 191 | 238 | |
| @@ -221,6 +268,12 @@ jobs: | ||
| 221 | 268 | - name: Build artifacts |
| 222 | 269 | run: bun run build:artifacts |
| 223 | 270 | |
| 271 | - name: Build application | |
| 272 | run: bun run build | |
| 273 | ||
| 274 | - name: Validate production artifact | |
| 275 | run: bun run release:validate | |
| 276 | ||
| 224 | 277 | # ts-cloud reads the PUBLIC key off disk to register it, so it is derived |
| 225 | 278 | # from the private one rather than stored as a second secret. Two secrets |
| 226 | 279 | # that have to agree eventually stop agreeing. |
| @@ -298,6 +351,9 @@ jobs: | ||
| 298 | 351 | |
| 299 | 352 | exit $fail |
| 300 | 353 | |
| 354 | - name: Verify public metadata | |
| 355 | run: bun run deployment:smoke | |
| 356 | ||
| 301 | 357 | # The same suite against Postgres, and a hard gate on the deploy. |
| 302 | 358 | # |
| 303 | 359 | # Production is Postgres and the suite defaults to SQLite, so every difference |
| @@ -42,14 +42,16 @@ const config: BunPressOptions = { | ||
| 42 | 42 | text: 'Getting started', |
| 43 | 43 | items: [ |
| 44 | 44 | { text: 'Quickstart', link: '/quickstart' }, |
| 45 | { text: 'Reporting concepts', link: '/concepts' }, | |
| 45 | 46 | { text: 'Limits', link: '/limits' }, |
| 46 | 47 | ], |
| 47 | 48 | }, |
| 48 | 49 | { |
| 49 | 50 | text: 'Installing it', |
| 50 | 51 | items: [ |
| 52 | { text: 'Stacks package', link: '/stacks' }, | |
| 51 | 53 | { text: 'Laravel package', link: '/laravel' }, |
| 52 | { text: 'Query API', link: '/api' }, | |
| 54 | { text: 'Configuration', link: '/configuration' }, | |
| 53 | 55 | ], |
| 54 | 56 | }, |
| 55 | 57 | { |
| @@ -66,12 +68,13 @@ const config: BunPressOptions = { | ||
| 66 | 68 | { text: 'Self-hosting', link: '/self-hosting' }, |
| 67 | 69 | { text: 'Deploying', link: '/deploy' }, |
| 68 | 70 | { text: 'Launch checklist', link: '/launch-checklist' }, |
| 69 | { text: 'API', link: '/api' }, | |
| 71 | { text: 'Troubleshooting', link: '/troubleshooting' }, | |
| 70 | 72 | ], |
| 71 | 73 | }, |
| 72 | 74 | { |
| 73 | 75 | text: 'Reference', |
| 74 | 76 | items: [ |
| 77 | { text: 'Query API', link: '/api' }, | |
| 75 | 78 | { text: 'Design brief', link: '/design-brief' }, |
| 76 | 79 | ], |
| 77 | 80 | }, |
| @@ -81,7 +81,9 @@ Layout is packed on the server as well as in the browser, and the response | ||
| 81 | 81 | carries the canonical positions back. What is stored and what you saw cannot |
| 82 | 82 | disagree. |
| 83 | 83 | |
| 84 | ## Sharing | |
| 84 | ## Laravel sharing | |
| 85 | ||
| 86 | The 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. | |
| 85 | 87 | |
| 86 | 88 | ```bash |
| 87 | 89 | curl -X POST .../api/reportshq/reports/commerce-overview/shares -d '{"label":"Board"}' |
| @@ -1,144 +1,50 @@ | ||
| 1 | 1 | # Report builder |
| 2 | 2 | |
| 3 | A report is a grid of blocks. Each block runs one query and draws one thing. | |
| 4 | ||
| 5 | The terms here are the terms the interface uses, because both come from the | |
| 6 | same validation schema in `app/Reports/schema.ts`. A test asserts this page | |
| 7 | lists exactly the values that schema accepts, so a new measure cannot appear in | |
| 8 | the 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 | ||
| 26 | Every block except `text` carries a query with these parts. | |
| 27 | ||
| 28 | ### events | |
| 29 | ||
| 30 | The event names the block reads. Empty means every event in the project. Names | |
| 31 | come from the models you described in `config/reportshq.php`. | |
| 32 | ||
| 33 | ### measure | |
| 34 | ||
| 35 | What 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` | |
| 47 | ignore one. | |
| 48 | ||
| 49 | ### field | |
| 50 | ||
| 51 | Which value the measure reads. One of `name`, `user_key`, `session_key`, | |
| 52 | `value`, `currency`, `occurred_at`, or `properties.<key>` for anything in your | |
| 53 | own property bag. | |
| 54 | ||
| 55 | This is an allowlist rather than a pattern. These names reach a query builder, | |
| 56 | and "anything that looks like an identifier" is how a column name becomes an | |
| 57 | injection point the day somebody builds the query with a template string. | |
| 58 | ||
| 59 | ### dimension | |
| 60 | ||
| 61 | Group the result into series by a field, using the same set as above. Bounded | |
| 62 | by **limit** with an explicit other bucket: when two hundred tags become the top | |
| 63 | ten, the report says how many went into Other rather than letting a reader | |
| 64 | assume the tail does not exist. | |
| 65 | ||
| 66 | ### filters | |
| 67 | ||
| 68 | Narrow what the block counts. Each filter is a field, an operator and usually a | |
| 69 | value: | |
| 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 | ||
| 86 | How time is bucketed: `hour`, `day`, `week` or `month`. | |
| 87 | ||
| 88 | ### compare | |
| 89 | ||
| 90 | Compare against the previous period of equal length. When there was no previous | |
| 91 | period, the block says so rather than inventing a percentage. This is how | |
| 92 | dashboards end up claiming a four thousand percent rise, and it is always | |
| 93 | because the previous period was zero. | |
| 94 | ||
| 95 | ### limit | |
| 96 | ||
| 97 | Top N series when a dimension is set. The rest become Other, and the count is | |
| 98 | stated. | |
| 99 | ||
| 100 | ### steps | |
| 101 | ||
| 102 | Ordered event names, for `funnel` blocks only. A funnel measures people, not | |
| 103 | events, so when data is sampled the sampling keeps whole subjects: somebody is | |
| 104 | either wholly in the sample or wholly out. Sampling events independently makes | |
| 105 | every conversion rate noise, because the steps stop belonging to the same | |
| 106 | people. | |
| 107 | ||
| 108 | ## The grid | |
| 109 | ||
| 110 | Twelve columns. Drag a block to move it, its corner to resize, and everything | |
| 111 | has a keyboard path. Blocks push each other out of the way while you hold one, | |
| 112 | and the server settles the layout before saving, so two blocks can never share | |
| 113 | a cell. That last part is on the server deliberately: a layout agreed by one | |
| 114 | browser is a layout that disagrees with itself when two people edit at once. | |
| 115 | ||
| 116 | ## Draft and published | |
| 117 | ||
| 118 | Edits 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 | |
| 120 | half finished experiment is never what somebody else walks into. | |
| 3 | A 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 | ||
| 7 | Both 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 | ||
| 21 | The 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 | ||
| 25 | Each 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 | ``` | |
| 121 | 37 | |
| 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. | |
| 123 | 39 | |
| 124 | Each block carries one sentence derived from the query it actually runs, not | |
| 125 | from a label somebody typed. Two charts both called Revenue can count different | |
| 126 | things, and a reader deserves to know which one is in front of them before they | |
| 127 | quote 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. | |
| 128 | 41 | |
| 129 | ## Report filters | |
| 42 | The 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. | |
| 130 | 43 | |
| 131 | A whole report can be narrowed from its URL: | |
| 44 | ## Editing and publishing | |
| 132 | 45 | |
| 133 | ``` | |
| 134 | /reports/my-report?f=properties.plan:is:pro | |
| 135 | ``` | |
| 46 | A 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. | |
| 136 | 47 | |
| 137 | The form is `f=field:operator:value`, repeated, up to five. It lives in the | |
| 138 | query string and nowhere else, so a narrowed view is linkable: paste the URL | |
| 139 | into a conversation and the reader sees what you saw. | |
| 48 | In 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. | |
| 140 | 49 | |
| 141 | Operators here are limited to `is`, `is_not` and `contains`. A malformed filter | |
| 142 | is dropped rather than erroring, so a link with one bad filter still shows the | |
| 143 | report. Funnels are left alone, because quietly narrowing their steps would | |
| 144 | change what the conversion rate measures without saying so. | |
| 50 | One 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. | |
| @@ -0,0 +1,44 @@ | ||
| 1 | # Reporting concepts | |
| 2 | ||
| 3 | ReportsHQ 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 | ||
| 7 | The 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 | ||
| 11 | A 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 | |
| 14 | measures: { | |
| 15 | revenue: { aggregate: 'sum', column: 'total_amount', unit: 'currency' }, | |
| 16 | orders: { aggregate: 'count' }, | |
| 17 | } | |
| 18 | ``` | |
| 19 | ||
| 20 | ## Dimension | |
| 21 | ||
| 22 | A 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 | ||
| 26 | A 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 | ||
| 30 | A 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 | ||
| 32 | For 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 | ||
| 36 | A 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 | ||
| 40 | In 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 | ||
| 44 | When 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. | |
| @@ -0,0 +1,82 @@ | ||
| 1 | # Configuration reference | |
| 2 | ||
| 3 | ReportsHQ 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 | |
| 9 | export 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 | ||
| 27 | The 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 | |
| 33 | return [ | |
| 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 | ||
| 51 | These 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 | ||
| 55 | Keep 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 | ||
| 65 | The 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 | ||
| 67 | An 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 | ||
| 71 | Common 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 | ||
| 82 | The 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. | |
| @@ -1,46 +1,49 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | layout: home |
| 3 | 3 | title: ReportsHQ Documentation |
| 4 | description: 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. | |
| 4 | description: In-process reporting source for Stacks and Laravel. Release status, model allowlists, query APIs, and builder behavior. | |
| 5 | 5 | hero: |
| 6 | 6 | 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." | |
| 9 | 9 | actions: |
| 10 | 10 | - theme: brand |
| 11 | 11 | text: Quickstart |
| 12 | 12 | link: /quickstart |
| 13 | 13 | - theme: alt |
| 14 | text: Ingestion API | |
| 15 | link: /quickstart | |
| 14 | text: Stacks package | |
| 15 | link: /stacks | |
| 16 | 16 | - theme: alt |
| 17 | 17 | text: View on GitHub |
| 18 | 18 | link: https://github.com/stacksjs/reportshq |
| 19 | 19 | features: |
| 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. | |
| 22 | 22 | - title: Describe once, query safely |
| 23 | 23 | 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. |
| 24 | 24 | - title: It refuses rather than guesses |
| 25 | 25 | 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. | |
| 30 | 30 | - 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. | |
| 32 | 32 | --- |
| 33 | 33 | |
| 34 | 34 | ## Start here |
| 35 | 35 | |
| 36 | 36 | | You want to | Read | |
| 37 | 37 | |---|---| |
| 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) | | |
| 41 | 43 | | Read the JSON the charts consume | [Query API](/api) | |
| 42 | 44 | | Build a report of your own | [Report builder](/builder) | |
| 43 | 45 | | Send a report to somebody outside | [Sharing](/sharing) | |
| 44 | 46 | | Get the numbers as a file or an email | [Schedules and exports](/schedules-exports) | |
| 45 | 47 | | Know what a licence covers | [Limits](/limits) | |
| 46 | 48 | | Run it on your own machines | [Self-hosting](/self-hosting) | |
| 49 | | Diagnose a report or deployment | [Troubleshooting](/troubleshooting) | | |
| @@ -4,20 +4,28 @@ | ||
| 4 | 4 | models you already have, queries them in place on the connection your |
| 5 | 5 | application already holds, and renders through the routes you already guard. |
| 6 | 6 | |
| 7 | Nothing leaves the application. There is no endpoint to send to, no connection | |
| 8 | to hand out, and the licence check is offline. | |
| 7 | The report engine does not send rows to a vendor endpoint and the licence check | |
| 8 | is offline. A separate optional event forwarder can send taxonomy events to a | |
| 9 | collector explicitly configured by the host; it is not required for reports. | |
| 9 | 10 | |
| 10 | 11 | ## Install |
| 11 | 12 | |
| 13 | Do not run that install sequence yet for the reporting product. Packagist | |
| 14 | currently serves v0.1.0, whose published metadata describes the earlier event | |
| 15 | forwarder. This repository's current source adds in-process reporting, but that | |
| 16 | source has not been released on Packagist. Verify a release containing it before | |
| 17 | following the install sequence. The current source loads migrations for | |
| 18 | reports, blocks, revisions, shares and schedules. Your own tables are only ever | |
| 19 | read by the report engine. | |
| 20 | ||
| 21 | After a reporting release exists, the host installation sequence is: | |
| 22 | ||
| 12 | 23 | ```bash |
| 13 | 24 | composer require reportshq/laravel |
| 14 | 25 | php artisan vendor:publish --tag=reportshq-config |
| 15 | 26 | php artisan migrate |
| 16 | 27 | ``` |
| 17 | 28 | |
| 18 | The migration creates the tables the reports live in: reports, blocks, | |
| 19 | revisions, shares and schedules. Your own tables are only ever read. | |
| 20 | ||
| 21 | 29 | ## Describing a model |
| 22 | 30 | |
| 23 | 31 | ```php |
| @@ -103,9 +111,9 @@ same compiled components in all three, so they cannot drift. | ||
| 103 | 111 | |
| 104 | 112 | ## Exports, sharing, schedules |
| 105 | 113 | |
| 106 | CSV and XLSX are generated on demand rather than stored and linked. The numbers | |
| 107 | are one query away, so there is nothing to clean up and no way to serve a stale | |
| 108 | copy. | |
| 114 | The current Laravel source generates CSV and XLSX on demand. Its CSV is one | |
| 115 | long table with block, point, series and value columns. Its XLSX uses one sheet | |
| 116 | per query-bearing block. The current Stacks package generates CSV only. | |
| 109 | 117 | |
| 110 | 118 | Sharing and scheduling are documented in [sharing](/docs/sharing) and |
| 111 | 119 | [schedules and exports](/docs/schedules-exports). |
| @@ -22,9 +22,12 @@ a good quarter. | ||
| 22 | 22 | | XLSX export | | Yes | Yes | |
| 23 | 23 | | Priority support | | | Yes | |
| 24 | 24 | |
| 25 | `app/Billing/limits.ts` is the only place these numbers live. The pricing page | |
| 26 | renders from it rather than restating it, because a marketing table maintained | |
| 27 | separately 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 | |
| 26 | or a proof that every listed capability exists. The current Stacks package has | |
| 27 | CSV but not XLSX, sharing or scheduled delivery. The Laravel source implements | |
| 28 | CSV, XLSX, sharing and schedules, but its reporting rewrite has not yet been | |
| 29 | published on Packagist. Check [release status](/docs/quickstart) before treating | |
| 30 | this table as an installation plan. | |
| 28 | 31 | |
| 29 | 32 | ## Reports are unlimited on every tier |
| 30 | 33 | |
| @@ -1,36 +1,28 @@ | ||
| 1 | 1 | # Quickstart |
| 2 | 2 | |
| 3 | Empty application to first report, in about five minutes. Nothing here talks to | |
| 4 | a service, because there is not one: everything below runs on your machine | |
| 5 | against your own database. | |
| 3 | ReportsHQ 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. | |
| 6 | 4 | |
| 7 | ## 1. Install the package | |
| 5 | ## Check the release before installing | |
| 8 | 6 | |
| 9 | Two ecosystems, one engine. Pick yours; everything after this step is the same | |
| 10 | report either way. | |
| 7 | As 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 | ||
| 9 | Do 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: | |
| 11 | 10 | |
| 12 | 11 | ```bash |
| 13 | # Stacks. No migration: the package ships no tables and asks your | |
| 14 | # application where reports are kept. See /docs/stacks. | |
| 15 | bun add @reportshq/stacks | |
| 12 | cd packages/stacks | |
| 13 | bun test ./tests | |
| 16 | 14 | ``` |
| 17 | 15 | |
| 18 | 16 | ```bash |
| 19 | # Laravel | |
| 20 | composer require reportshq/laravel | |
| 21 | php artisan vendor:publish --tag=reportshq-config | |
| 22 | php artisan migrate | |
| 17 | cd ../.. | |
| 18 | php packages/laravel/tests/run.php | |
| 23 | 19 | ``` |
| 24 | 20 | |
| 25 | In Laravel the migration creates the tables the reports themselves live in: the | |
| 26 | report, its blocks, its revisions, its share links and its schedules. In Stacks | |
| 27 | there is no migration, because the package keeps no tables of its own and asks | |
| 28 | the application where reports live instead. Either way your own tables are never | |
| 29 | touched, read-only or otherwise altered. | |
| 21 | The 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. | |
| 30 | 22 | |
| 31 | ## 2. Describe a model | |
| 23 | ## 1. Describe queryable models | |
| 32 | 24 | |
| 33 | Name something you already have. | |
| 25 | Name only the tables and columns a report may read. This is the security boundary for query identifiers. | |
| 34 | 26 | |
| 35 | 27 | ```ts |
| 36 | 28 | // Stacks: config/reportshq.ts |
| @@ -43,18 +35,14 @@ export const models: Record<string, ModelDescription> = { | ||
| 43 | 35 | revenue: { aggregate: 'sum', column: 'total_amount', unit: 'currency' }, |
| 44 | 36 | orders: { aggregate: 'count' }, |
| 45 | 37 | }, |
| 46 | time: { | |
| 47 | placed: 'created_at', | |
| 48 | }, | |
| 49 | dimensions: { | |
| 50 | status: 'status', | |
| 51 | }, | |
| 38 | time: { placed: 'created_at' }, | |
| 39 | dimensions: { status: 'status' }, | |
| 52 | 40 | }, |
| 53 | 41 | } |
| 54 | 42 | ``` |
| 55 | 43 | |
| 56 | 44 | ```php |
| 57 | // Laravel: config/reportshq.php | |
| 45 | // Laravel: config/reportshq.php, inside the returned array | |
| 58 | 46 | 'models' => [ |
| 59 | 47 | 'order' => [ |
| 60 | 48 | 'class' => App\Models\Order::class, |
| @@ -72,96 +60,32 @@ export const models: Record<string, ModelDescription> = { | ||
| 72 | 60 | ], |
| 73 | 61 | ``` |
| 74 | 62 | |
| 75 | That 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 | |
| 77 | you may group by. Where a relation would multiply rows, saying so is what lets | |
| 78 | the compiler refuse a question that would double-count rather than answer it | |
| 79 | approximately. | |
| 80 | ||
| 81 | **Only what you name is reachable.** The compiler will not touch a column that | |
| 82 | is not in this file, which is why a password hash cannot end up as a dimension | |
| 83 | by somebody typing its name into a URL. | |
| 84 | ||
| 85 | ## 3. Open the reports | |
| 86 | ||
| 87 | The routes are off until you say otherwise, because the package cannot know | |
| 88 | which of your users may see a total of everybody's orders. Turn them on: | |
| 89 | ||
| 90 | ```bash | |
| 91 | REPORTSHQ_ROUTES=true | |
| 92 | ``` | |
| 93 | ||
| 94 | ```bash | |
| 95 | php artisan serve | |
| 96 | ``` | |
| 97 | ||
| 98 | Visit `/reports`. It is empty -- nothing creates a report for you, and the page | |
| 99 | says as much. Make the first one: | |
| 63 | Compiled 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. | |
| 100 | 64 | |
| 101 | ```php | |
| 102 | use 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 | ||
| 108 | Or post the same thing from the page itself, which is what the "New report" | |
| 109 | form does. | |
| 65 | ## 2. Decide where reports are stored | |
| 110 | 66 | |
| 111 | ## 4. Build it | |
| 67 | The 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. | |
| 112 | 68 | |
| 113 | Visit `/reports/commerce/edit`. Drag a block from the palette onto the grid, | |
| 114 | pick a measure and a dimension, and it redraws as you change it. Drag a corner | |
| 115 | to resize, or nudge with the arrow keys. | |
| 69 | The 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). | |
| 116 | 70 | |
| 117 | There is no data to accumulate and nothing to wait for: a block reads rows that | |
| 118 | are already in your database, so the first one you drop covers everything you | |
| 119 | have ever sold. | |
| 71 | ## 3. Build a first report | |
| 120 | 72 | |
| 121 | Publish when it looks right. A draft stays yours until you do, so a teammate | |
| 122 | never opens a half arranged grid. | |
| 73 | A block names a model and one of that model's described measures. A time key or dimension is optional: | |
| 123 | 74 | |
| 124 | ## 5. Share it, or send it | |
| 125 | ||
| 126 | A published report can be given a link that works for somebody with no account: | |
| 127 | ||
| 128 | ```php | |
| 129 | use 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' } | |
| 137 | 77 | ``` |
| 138 | 78 | |
| 139 | Or emailed on a cadence, from your own queue, in the report's own timezone: | |
| 140 | ||
| 141 | ```php | |
| 142 | use ReportsHQ\Laravel\Reports\Schedule; | |
| 143 | ||
| 144 | Schedule::create([ | |
| 145 | 'report_id' => $report->id, | |
| 146 | 'cadence' => 'weekly', | |
| 147 | 'hour' => 8, | |
| 148 | 'recipients' => 'ops@example.com', | |
| 149 | 'format' => 'xlsx', | |
| 150 | ]); | |
| 151 | ``` | |
| 79 | In 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. | |
| 152 | 80 | |
| 153 | Add the runner to `routes/console.php` and the application does the rest: | |
| 81 | The 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. | |
| 154 | 82 | |
| 155 | ```php | |
| 156 | Schedule::command('reportshq:send')->hourly(); | |
| 157 | ``` | |
| 83 | ## 4. Export or distribute | |
| 158 | 84 | |
| 159 | It is registered by the package but scheduled by you, on purpose. A package that | |
| 160 | adds itself to your scheduler is a package that sends email nobody asked for. | |
| 85 | The 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. | |
| 161 | 86 | |
| 162 | 87 | ## What next |
| 163 | 88 | |
| 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. | |
| @@ -5,24 +5,25 @@ without anybody asking. | ||
| 5 | 5 | |
| 6 | 6 | ## Exports |
| 7 | 7 | |
| 8 | Export any report as CSV or XLSX. | |
| 8 | The current Stacks source exports CSV only. Its handler rejects XLSX. The | |
| 9 | current Laravel source exports both CSV and XLSX. The published package | |
| 10 | versions still predate this reporting source, so check [release status](/docs/quickstart) | |
| 11 | before using either as a customer installation. | |
| 9 | 12 | |
| 10 | Both come out in **long format**: one row per series per period, rather than a | |
| 11 | grid that looks right on screen and fights every pivot table. A wide export is | |
| 12 | easier to read and harder to use, and an export exists to be used. | |
| 13 | CSV is one long table with block, point, series and value columns. Laravel XLSX | |
| 14 | uses a separate sheet per query-bearing block, with point, series and value | |
| 15 | columns. The two formats are deliberately different. | |
| 13 | 16 | |
| 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 | | |
| 19 | 22 | |
| 20 | 23 | CSV suits anything that reads text. XLSX gives a workbook with the types |
| 21 | 24 | already correct, so dates are dates rather than strings that need coercing. |
| 22 | Both come from the same query the chart ran, so an exported number and a drawn | |
| 23 | number cannot disagree. | |
| 24 | ||
| 25 | XLSX is a paid-tier capability; CSV is on every plan. See [limits](/docs/limits). | |
| 25 | Both are generated from the rendered report result. The offline licence does | |
| 26 | not gate either format in the current source. See [limits](/docs/limits). | |
| 26 | 27 | |
| 27 | 28 | ## Nothing is stored to expire |
| 28 | 29 | |
| @@ -30,22 +31,24 @@ An export is generated on the request and streamed straight out. Nothing is | ||
| 30 | 31 | written to disk and nothing is linked. |
| 31 | 32 | |
| 32 | 33 | The hosted product did store a file, sign a URL for it and expire it after an |
| 33 | hour — all of which existed because the file lived on a different machine from | |
| 34 | hour - all of which existed because the file lived on a different machine from | |
| 34 | 35 | the reader. Here it does not: the numbers are one query away, so generating on |
| 35 | 36 | demand has nothing to clean up and cannot serve somebody a stale copy. |
| 36 | 37 | |
| 37 | 38 | ## Schedules |
| 38 | 39 | |
| 39 | A schedule sends a report's headline numbers by email, daily, weekly or | |
| 40 | monthly, at an hour you choose, optionally with a spreadsheet attached. | |
| 40 | The current Laravel source can send a report by email on a schedule, optionally | |
| 41 | with a spreadsheet. The Stacks package has no schedule runner. Laravel registers | |
| 42 | the `reportshq:send` command but the host must put it on its own scheduler and | |
| 43 | run a queue worker for queued mail. | |
| 41 | 44 | |
| 42 | 45 | ### The hour stays the hour |
| 43 | 46 | |
| 44 | The schedule is evaluated in your project's timezone rather than by adding a | |
| 47 | The schedule is evaluated in the report's timezone rather than by adding a | |
| 45 | 48 | fixed offset to UTC. That means 09:00 stays 09:00 after the clocks change, |
| 46 | 49 | which is the whole reason to store a timezone rather than an offset. |
| 47 | 50 | |
| 48 | ### Recipients are not validated — review them yourself | |
| 51 | ### Recipients are not validated - review them yourself | |
| 49 | 52 | |
| 50 | 53 | Be explicit about this one. `recipients` is a free-text column; |
| 51 | 54 | `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. | ||
| 58 | 61 | |
| 59 | 62 | ### What a run records |
| 60 | 63 | |
| 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 | |
| 62 | 65 | schedule is due, so it is written *before* the mail is queued. A send that |
| 63 | 66 | fails afterwards still leaves the schedule looking like it ran. There is no |
| 64 | 67 | failure log here: if you need one, watch your queue's failed jobs. |
| 65 | 68 | |
| 66 | Scheduled delivery is a paid-tier capability. See [limits](/docs/limits). | |
| 69 | The current offline licence does not gate scheduled delivery. Pricing copy may | |
| 70 | describe a commercial tier, but the host remains responsible for mail access, | |
| 71 | recipients and delivery monitoring. See [limits](/docs/limits). | |
| @@ -2,10 +2,13 @@ | ||
| 2 | 2 | |
| 3 | 3 | There is no other kind, and that is the whole point. |
| 4 | 4 | |
| 5 | The package runs inside your application, on your servers, against your | |
| 6 | database. Nothing is hosted on our side, nothing is sent anywhere, and the | |
| 7 | licence is checked offline. What follows is therefore not a special deployment | |
| 8 | mode: it is a description of what you already have once you install it. | |
| 5 | The reporting engines run inside your application, on your servers, against | |
| 6 | your database. Reporting sends no rows to ReportsHQ and the licence is checked | |
| 7 | offline. The Laravel package also contains a separate, optional event | |
| 8 | forwarder. That half sends events to an explicitly configured collector only | |
| 9 | when its endpoint, key and sample rate enable it. The collector's future is an | |
| 10 | unmade product decision, so do not assume it is either a required part of | |
| 11 | reporting or permanently retired. | |
| 9 | 12 | |
| 10 | 13 | If you are looking for how to deploy `reportshq.org`, the marketing site and |
| 11 | 14 | account pages, that is [deploying](/docs/deploy) and it has nothing to do with |
| @@ -13,27 +16,31 @@ running reports. | ||
| 13 | 16 | |
| 14 | 17 | ## What you are actually running |
| 15 | 18 | |
| 16 | Your application. That is it. | |
| 19 | Your application. The currently published package versions do not yet include | |
| 20 | the reporting source described here. Check [release status](/docs/quickstart) | |
| 21 | before installation. | |
| 17 | 22 | |
| 18 | 23 | ```bash |
| 19 | 24 | composer require reportshq/laravel |
| 20 | 25 | php artisan migrate |
| 21 | 26 | ``` |
| 22 | 27 | |
| 23 | The package adds routes, views, a console command and five tables. It does not | |
| 24 | add a service, a daemon, a queue worker of its own, or a port to open. If your | |
| 25 | application deploys, the reports deploy with it. | |
| 28 | After a reporting release exists, the Laravel package can load routes, views, | |
| 29 | a console command and five tables. Routes and the JSON API remain off until | |
| 30 | the host enables them. It does not add a service, daemon, queue worker of its | |
| 31 | own, or port to open. Scheduled email uses the host's worker and scheduler. | |
| 26 | 32 | |
| 27 | 33 | ## Requirements |
| 28 | 34 | |
| 29 | PHP 8.2+, Laravel 11+, and whatever database you already use. SQL is built for | |
| 30 | whatever driver your connection reports, so anything Laravel supports works, | |
| 31 | including SQLite. | |
| 35 | PHP 8.2+, Laravel 11+, and a supported SQLite, MySQL or Postgres connection. | |
| 36 | The current source has a query dialect for each of those three; do not assume | |
| 37 | every database driver Laravel supports has one. | |
| 32 | 38 | |
| 33 | 39 | ## Without a licence key |
| 34 | 40 | |
| 35 | Leave `REPORTSHQ_LICENSE` unset and every limit falls away. Unlimited | |
| 36 | applications, unlimited reports, everything the Pro tier lists. | |
| 41 | Leave `REPORTSHQ_LICENSE` unset and the current reporting engine still runs. | |
| 42 | The plans in pricing copy do not gate functionality or guarantee that every | |
| 43 | listed feature is implemented in both packages. | |
| 37 | 44 | |
| 38 | 45 | Nothing about the software changes. The pages say the installation is |
| 39 | 46 | unlicensed and that is the entire difference. This is deliberate: an offline |
| @@ -49,10 +56,10 @@ paying for, not because something will stop. | ||
| 49 | 56 | Worth stating explicitly, because it is the property most self-hosting is |
| 50 | 57 | chasing. |
| 51 | 58 | |
| 52 | Queries run in process, through your own ORM, against your own connection. There | |
| 53 | is no ingest endpoint, no export to a third party, and no telemetry. The licence | |
| 54 | class opens no sockets, and a test asserts that by reading its source for | |
| 55 | anything that could. | |
| 59 | Queries run in process on your own connection, not through ORM scopes. The | |
| 60 | report engine has no ingest endpoint or vendor telemetry. The separate Laravel | |
| 61 | event forwarder can POST taxonomy events to an endpoint the host explicitly | |
| 62 | configures. The licence class opens no sockets. | |
| 56 | 63 | |
| 57 | 64 | For anyone under a data processing agreement, this is usually the shortest |
| 58 | 65 | section of a security review you will ever write. |
| @@ -82,7 +89,8 @@ data was restored with them. | ||
| 82 | 89 | |
| 83 | 90 | ## Upgrades |
| 84 | 91 | |
| 85 | `composer update reportshq/laravel`, then `php artisan migrate`. Read the | |
| 92 | After a reporting release exists, `composer update reportshq/laravel`, then | |
| 93 | `php artisan migrate`. Read the | |
| 86 | 94 | changelog for anything with a `!` in the commit subject, which is how a breaking |
| 87 | 95 | change is marked. |
| 88 | 96 | |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | # Sharing |
| 2 | 2 | |
| 3 | A share link shows one published report and nothing around it: no navigation | |
| 4 | into the rest of your project, no sign-in wall, no account for the recipient to | |
| 5 | create. The people who most need to see a number are usually the least willing | |
| 6 | to make a login for it. | |
| 3 | The current Laravel source can serve one published report through a revocable | |
| 4 | token, without a recipient account. The current Stacks package has no share | |
| 5 | route in `reportRoutes`, and reportshq.org does not mount one. These instructions | |
| 6 | apply to a Laravel host after a reporting release containing this source exists. | |
| 7 | 7 | |
| 8 | 8 | ## Creating a link |
| 9 | 9 | |
| @@ -13,12 +13,12 @@ From a report, create a share. You get a URL of the form: | ||
| 13 | 13 | https://your-app.example.com/reports/shared/<token> |
| 14 | 14 | ``` |
| 15 | 15 | |
| 16 | The link is served by your own application, on your own domain — the route is | |
| 16 | The link is served by your own application, on your own domain - the route is | |
| 17 | 17 | `/reports/shared/{token}`, mounted alongside the rest of the package. Nothing |
| 18 | 18 | is hosted here. |
| 19 | 19 | |
| 20 | 20 | The token is the credential. Anyone holding it sees that one report, as |
| 21 | published. They cannot reach anything else in the project, and the link grants | |
| 21 | published. They cannot reach other reports through this route, and the link grants | |
| 22 | 22 | no ability to change anything. |
| 23 | 23 | |
| 24 | 24 | ## Revoking and rotating |
| @@ -26,10 +26,9 @@ no ability to change anything. | ||
| 26 | 26 | **Revoke** and the link stops working on the next request, not at the end of a |
| 27 | 27 | cache window. |
| 28 | 28 | |
| 29 | **Rotate** and the same report becomes available at a new token while the old | |
| 30 | one dies. That is the honest fix when a URL has been forwarded further than you | |
| 31 | meant: the people who should still have it get the new link, and everyone else | |
| 32 | is simply out. | |
| 29 | There is no dedicated rotate operation in the current API. Revoke the old share | |
| 30 | and create a new one, then distribute the new URL. The API list reveals only a | |
| 31 | token hint, not the complete token. | |
| 33 | 32 | |
| 34 | 33 | ## Views are recorded |
| 35 | 34 | |
| @@ -37,16 +36,17 @@ Each view is recorded against the link, so you know whether the weekly report | ||
| 37 | 36 | somebody asked for is actually being opened. It settles a lot of arguments |
| 38 | 37 | about what is worth continuing to produce. |
| 39 | 38 | |
| 40 | Embedding is available on the paid tiers, and the top tier removes our footer | |
| 41 | from shared and embedded reports so what you send a client looks like your work. | |
| 42 | See [limits](/docs/limits) for which tier carries what. | |
| 39 | The current reporting package has no embed route or tier-based share guard. | |
| 40 | Pricing copy is not an inventory of shipped link features. See [limits](/docs/limits) | |
| 41 | for how the offline licence behaves. | |
| 43 | 42 | |
| 44 | 43 | ## What a share is not |
| 45 | 44 | |
| 46 | 45 | A share link is shared with whoever holds it. It is not a login, there is no |
| 47 | 46 | per-recipient identity behind it, and it should not be treated as private |
| 48 | beyond the secrecy of the URL. `robots.txt` keeps `/s/` and `/embed` out of | |
| 49 | search indexes, but a link pasted into a public page is public. | |
| 47 | beyond the secrecy of the URL. The host application is responsible for cache, | |
| 48 | logging and crawler policy for `/reports/shared/{token}`. A link pasted into a | |
| 49 | public page is public. | |
| 50 | 50 | |
| 51 | If a report should only be seen by named people, add them to the project as | |
| 52 | members instead. | |
| 51 | If a report should be seen only by named people, protect a normal report route | |
| 52 | with the host application's authorization policy instead. | |
| @@ -7,20 +7,25 @@ application already holds, and renders through the routes you already guard. | ||
| 7 | 7 | Nothing leaves the application. There is no endpoint to send to, no connection |
| 8 | 8 | to hand out, and the licence check is offline. |
| 9 | 9 | |
| 10 | This is the same engine as [the Laravel package](/docs/laravel) and the same | |
| 11 | compiled chart components, so the two cannot drift on what a number means. What | |
| 12 | differs is only how you describe a model and where you mount it. | |
| 10 | The Stacks and [Laravel source](/docs/laravel) share a result contract and | |
| 11 | compiled chart components. Their query implementations are separate and their | |
| 12 | filter operator vocabularies currently differ. Verify semantics in each | |
| 13 | runtime instead of assuming they cannot drift. | |
| 13 | 14 | |
| 14 | 15 | ## Install |
| 15 | 16 | |
| 17 | Do not run that install command yet for the reporting product. npm currently | |
| 18 | serves `0.1.0`, the earlier event-forwarding SDK. The reporting source in this | |
| 19 | checkout is versioned `0.2.0` but has not been published. Verify that npm serves | |
| 20 | a release containing the reporting source before installing it. See the | |
| 21 | [quickstart release status](/docs/quickstart) for the current boundary. | |
| 22 | ||
| 23 | After a reporting release exists, install it with: | |
| 24 | ||
| 16 | 25 | ```bash |
| 17 | 26 | bun add @reportshq/stacks |
| 18 | 27 | ``` |
| 19 | 28 | |
| 20 | Take `0.2.0` or later. `0.1.0` carries the same name but is a different | |
| 21 | library: it was an event-forwarding SDK from before this package became the | |
| 22 | reporting engine, and none of the API below exists in it. | |
| 23 | ||
| 24 | 29 | **There is no migration, and that is deliberate.** This package ships no tables |
| 25 | 30 | of its own and never writes to yours: it reads the models you describe below, |
| 26 | 31 | and it asks the application where reports themselves are kept through a |
| @@ -86,9 +91,9 @@ rest of `ReportsHQConfig` is optional: | ||
| 86 | 91 | } |
| 87 | 92 | ``` |
| 88 | 93 | |
| 89 | `shareMiddleware` is empty on purpose. A share link is read by somebody with no | |
| 90 | account, so it does not inherit `routes.middleware`; adding a guard there means | |
| 91 | the link stops working for the people it was sent to. | |
| 94 | The config type includes `shareMiddleware`, but the current Stacks | |
| 95 | `reportRoutes` list has no share route. This setting does not publish a link by | |
| 96 | itself. The Laravel source has the share route and its own middleware decision. | |
| 92 | 97 | |
| 93 | 98 | ## Wiring it up |
| 94 | 99 | |
| @@ -217,12 +222,12 @@ Or [the JSON API](/docs/api), for a front end of your own. | ||
| 217 | 222 | |
| 218 | 223 | ## Exports, sharing, schedules |
| 219 | 224 | |
| 220 | CSV and XLSX are generated on demand rather than stored and linked. The numbers | |
| 221 | are one query away, so there is nothing to clean up and no way to serve a stale | |
| 222 | copy. | |
| 225 | The current Stacks package generates CSV on demand. Its download handler | |
| 226 | rejects XLSX. The Laravel source implements both formats. Neither format is | |
| 227 | stored as a report file by the Stacks handler. | |
| 223 | 228 | |
| 224 | Sharing and scheduling are documented in [sharing](/docs/sharing) and | |
| 225 | [schedules and exports](/docs/schedules-exports). | |
| 229 | Sharing and scheduling in the current source are Laravel features, documented | |
| 230 | in [sharing](/docs/sharing) and [schedules and exports](/docs/schedules-exports). | |
| 226 | 231 | |
| 227 | 232 | ## Requirements |
| 228 | 233 | |
| @@ -0,0 +1,54 @@ | ||
| 1 | # Troubleshooting | |
| 2 | ||
| 3 | ## A block shows an error but the page returns 200 | |
| 4 | ||
| 5 | This 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 | ||
| 7 | Fix 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 | ||
| 11 | The 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 | ||
| 15 | The 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 | ||
| 19 | Confirm 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 | ||
| 23 | The 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 | ||
| 27 | Session 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 | ||
| 31 | Authentication 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 | ||
| 35 | Confirm 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 | ||
| 39 | For 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 | ||
| 43 | The 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 | ||
| 47 | Run the same command as the static site deployment: | |
| 48 | ||
| 49 | ```bash | |
| 50 | PATH="$PWD/node_modules/.bin:$PATH" bun node_modules/@stacksjs/buddy/dist/cli.js build docs | |
| 51 | test -d dist/docs/.bunpress | |
| 52 | ``` | |
| 53 | ||
| 54 | The explicit path matters on a clean runner because the docs build launches the local `bunpress` executable by name. | |
| @@ -29,6 +29,11 @@ | ||
| 29 | 29 | "dev": "./buddy dev", |
| 30 | 30 | "development": "./buddy dev", |
| 31 | 31 | "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", | |
| 32 | 37 | "prod": "./buddy build", |
| 33 | 38 | "deploy": "./buddy deploy", |
| 34 | 39 | "lint": "./buddy lint", |
| @@ -54,7 +59,7 @@ | ||
| 54 | 59 | "changelog": "./buddy changelog", |
| 55 | 60 | "generate": "./buddy generate", |
| 56 | 61 | "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", | |
| 58 | 63 | "typecheck:app": "bun x --bun tsc --noEmit -p tsconfig.json --pretty false", |
| 59 | 64 | "typecheck:clean": "bunx --bun rimraf storage/framework/.cache/typescript", |
| 60 | 65 | "types:check": "bun run typecheck", |
| @@ -67,7 +67,7 @@ export function createHandlers( | ||
| 67 | 67 | return report |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | const writable = Boolean(store.addBlock && store.saveBlock && store.removeBlock) | |
| 70 | const writable = Boolean(store.addBlock && store.saveBlock && store.removeBlock && store.publish) | |
| 71 | 71 | |
| 72 | 72 | /** The block, if it belongs to this report. Both ids arrive from a browser. */ |
| 73 | 73 | const ownedBlock = async (slug: string, blockId: number) => { |
| @@ -125,6 +125,17 @@ describe('the compiler', () => { | ||
| 125 | 125 | expect(auckland.total).toBe(17_500) |
| 126 | 126 | }) |
| 127 | 127 | |
| 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 | ||
| 128 | 139 | it('groups by a dimension on the same model', async () => { |
| 129 | 140 | const { compiler } = fixture() |
| 130 | 141 | const result = await compiler.run({ |
| @@ -2,13 +2,13 @@ import { describe, expect, it } from 'bun:test' | ||
| 2 | 2 | import { Database } from 'bun:sqlite' |
| 3 | 3 | import { Compiler } from '../src/compiler' |
| 4 | 4 | import { elementHtml, tagFor } from '../src/elements' |
| 5 | import { createHandlers, NotFound, type ReportStore } from '../src/http/handlers' | |
| 6 | import { reportRoutes } from '../src/http/routes' | |
| 5 | import { createHandlers, NotFound, ReadOnly, readQuery, type ReportStore } from '../src/http/handlers' | |
| 6 | import { isNotFound, reportRoutes } from '../src/http/routes' | |
| 7 | 7 | import { Registry } from '../src/semantic' |
| 8 | 8 | import { Runner } from '../src/runner' |
| 9 | import type { RenderedBlock } from '../src/types' | |
| 9 | import type { Handlers, RenderedBlock } from '../src' | |
| 10 | 10 | |
| 11 | function harness() { | |
| 11 | function harness(overrides: Partial<ReportStore> = {}) { | |
| 12 | 12 | const sqlite = new Database(':memory:') |
| 13 | 13 | sqlite.run(`CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER, created_at TEXT)`) |
| 14 | 14 | 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() { | ||
| 33 | 33 | { id: 10, kind: 'big_number', title: 'Revenue', x: 0, y: 0, w: 4, h: 4, query: { model: 'order', measure: 'revenue' } }, |
| 34 | 34 | ], |
| 35 | 35 | saveLayout: async (_id, blocks) => { saved.push(...blocks) }, |
| 36 | ...overrides, | |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | return { handlers: createHandlers(store, runner, registry), saved } |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | describe('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 | ||
| 42 | 49 | it('renders a report', async () => { |
| 43 | 50 | const { blocks } = await harness().handlers.show('commerce') |
| 44 | 51 | |
| @@ -78,9 +85,89 @@ describe('handlers', () => { | ||
| 78 | 85 | expect(schema.models[0].measures).toEqual(['revenue']) |
| 79 | 86 | expect(schema.grains).toContain('day') |
| 80 | 87 | }) |
| 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 | }) | |
| 81 | 163 | }) |
| 82 | 164 | |
| 83 | 165 | describe('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 | ||
| 84 | 171 | it('puts the fixed paths before the slug', () => { |
| 85 | 172 | // Or a report called "schema" is unreachable and the schema endpoint is |
| 86 | 173 | // read as a slug. |
| @@ -98,6 +185,47 @@ describe('routes', () => { | ||
| 98 | 185 | expect(route.name.startsWith('reportshq.')).toBe(true) |
| 99 | 186 | } |
| 100 | 187 | }) |
| 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 | }) | |
| 101 | 229 | }) |
| 102 | 230 | |
| 103 | 231 | describe('elements', () => { |
| @@ -0,0 +1,66 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { Registry, SemanticError } from '../src/semantic' | |
| 3 | ||
| 4 | const 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 | ||
| 17 | describe('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 | }) | |
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | authenticate. The first says where to go; the second says to bin the stale |
| 18 | 18 | cookie on the way, which is what stops an expired token from bouncing |
| 19 | 19 | somebody back and forth forever. --}} |
| 20 | <span hidden aria-hidden="true"> | |
| 20 | 21 | <script> |
| 21 | 22 | (function () { |
| 22 | 23 | if (!document.getElementById('auth-required')) return |
| @@ -32,3 +33,4 @@ | ||
| 32 | 33 | window.location.replace('/login') |
| 33 | 34 | }()) |
| 34 | 35 | </script> |
| 36 | </span> | |
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | // than about a project: who they are, and how to stop being signed in. Team |
| 6 | 6 | // billing arrives with #17. |
| 7 | 7 | import { db as __db } from '@stacksjs/database' |
| 8 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 8 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 9 | 9 | |
| 10 | const session = await sessionFrom(cookies) | |
| 10 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 11 | 11 | const cookieName = sessionCookieName() |
| 12 | 12 | const user = session.user |
| 13 | 13 | const authPending = session.stale |
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | // Renders for a stranger, like /login. The response never says whether the |
| 5 | 5 | // address has an account, so this page cannot be used to find out who is a |
| 6 | 6 | // customer; the confirmation below is deliberately the same either way. |
| 7 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 7 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 8 | 8 | |
| 9 | const session = await sessionFrom(cookies) | |
| 9 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 10 | 10 | const cookieName = sessionCookieName() |
| 11 | 11 | const signedIn = session.user !== null |
| 12 | 12 | </script> |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | // The sample is stated as a sample. Numbers on a marketing page that imply a |
| 11 | 11 | // real customer's business are a lie with a chart on it. |
| 12 | 12 | import { useSeoMeta } from 'stx' |
| 13 | import { PLANS } from '../../app/Billing/limits' | |
| 13 | import { PLANS } from '../../../app/Billing/limits' | |
| 14 | 14 | |
| 15 | 15 | const siteUrl = 'https://reportshq.org' |
| 16 | 16 | const siteDescription = 'Reports that run inside your own Stacks or Laravel application, against your own database. Install a package, point it at the models you already have, and the numbers never leave your servers.' |
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | // AuthGuard and no app chrome. It does the opposite check instead: somebody who |
| 6 | 6 | // already has a session is sent to their reports rather than shown a form |
| 7 | 7 | // asking them to do something they have already done. |
| 8 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 8 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 9 | 9 | |
| 10 | const session = await sessionFrom(cookies) | |
| 10 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 11 | 11 | const cookieName = sessionCookieName() |
| 12 | 12 | const signedIn = session.user !== null |
| 13 | 13 | </script> |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | // |
| 10 | 10 | // If a limit changes, this page changes with it. Nothing here is typed twice. |
| 11 | 11 | import { useSeoMeta } from 'stx' |
| 12 | import { PLANS, TIERS } from '../../app/Billing/limits' | |
| 12 | import { PLANS, TIERS } from '../../../app/Billing/limits' | |
| 13 | 13 | |
| 14 | 14 | const siteUrl = 'https://reportshq.org' |
| 15 | 15 | const pricingDescription = 'Three plans, priced per installed application. Free covers one application with unlimited reports and no card. Your data never leaves your servers, and nothing here can stop a report rendering.' |
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | // session. The only account this creates is the person's own; teammates arrive |
| 6 | 6 | // through project invites instead, which is why there is no organisation step |
| 7 | 7 | // here. |
| 8 | import { sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 8 | import { sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 9 | 9 | |
| 10 | const session = await sessionFrom(cookies) | |
| 10 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 11 | 11 | const cookieName = sessionCookieName() |
| 12 | 12 | const signedIn = session.user !== null |
| 13 | 13 | </script> |
| @@ -15,7 +15,7 @@ import { elementHtml, License } from '@reportshq/stacks' | ||
| 15 | 15 | import { sessionFrom } from '../../../app/Support/session' |
| 16 | 16 | import { reportHandlers } from '../../../app/Support/reports' |
| 17 | 17 | |
| 18 | const session = await sessionFrom(cookies) | |
| 18 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 19 | 19 | const user = session.user |
| 20 | 20 | const authPending = session.stale |
| 21 | 21 | |
| @@ -10,7 +10,7 @@ import { sessionFrom } from '../../../app/Support/session' | ||
| 10 | 10 | import { reportHandlers } from '../../../app/Support/reports' |
| 11 | 11 | import { License } from '@reportshq/stacks' |
| 12 | 12 | |
| 13 | const session = await sessionFrom(cookies) | |
| 13 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 14 | 14 | const user = session.user |
| 15 | 15 | const authPending = session.stale |
| 16 | 16 | |
| @@ -4,18 +4,19 @@ | ||
| 4 | 4 | // The token and address come from the query string, which is where the email |
| 5 | 5 | // put them. They are read here only to hand back to the API: the page never |
| 6 | 6 | // decides whether a token is valid, because a page cannot be trusted to. |
| 7 | import { param, sessionCookieName, sessionFrom } from '../../app/Support/session' | |
| 7 | import { param, sessionCookieName, sessionFrom } from '../../../app/Support/session' | |
| 8 | 8 | |
| 9 | const session = await sessionFrom(cookies) | |
| 9 | const session = await sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined) | |
| 10 | 10 | const cookieName = sessionCookieName() |
| 11 | 11 | // Deliberately not bounced when signed in. Somebody resetting a password may |
| 12 | 12 | // well have a stale session open in another tab, and that is the case they are |
| 13 | 13 | // most likely trying to fix. |
| 14 | 14 | const signedIn = false |
| 15 | 15 | |
| 16 | const token = param(query, 'token') ?? '' | |
| 17 | const email = param(query, 'email') ?? '' | |
| 18 | const usable = token !== '' && email !== '' | |
| 16 | const pageQuery = typeof query !== 'undefined' ? query : undefined | |
| 17 | const __resetToken = param(pageQuery, 'token') ?? '' | |
| 18 | const email = param(pageQuery, 'email') ?? '' | |
| 19 | const usable = __resetToken !== '' && email !== '' | |
| 19 | 20 | </script> |
| 20 | 21 | |
| 21 | 22 | <template> |
| @@ -44,7 +45,7 @@ const usable = token !== '' && email !== '' | ||
| 44 | 45 | submits. The script intercepts it; without the script it still |
| 45 | 46 | posts somewhere sensible rather than nowhere. --}} |
| 46 | 47 | <form id="signin" method="post" action="/api/auth/reset" class="grid gap-4" @if (!usable) hidden @endif> |
| 47 | <input id="token" type="hidden" value="" /> | |
| 48 | <input id="token" type="hidden" value="" /> | |
| 48 | 49 | <input id="email" type="hidden" value="async function email() { return (await authUser())?.email; }" /> |
| 49 | 50 | |
| 50 | 51 | <label class="block"> |
| @@ -0,0 +1,63 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | ||
| 4 | interface SmokeContract { | |
| 5 | domain: string | |
| 6 | homeText: string | |
| 7 | } | |
| 8 | ||
| 9 | const CONTRACTS: Record<string, SmokeContract> = { | |
| 10 | analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' }, | |
| 11 | bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' }, | |
| 12 | commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' }, | |
| 13 | loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' }, | |
| 14 | reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' }, | |
| 15 | status: { domain: 'statushq.org', homeText: 'Know the moment' }, | |
| 16 | } | |
| 17 | ||
| 18 | const projectRoot = resolve(import.meta.dir, '..') | |
| 19 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 20 | const packageName = String(packageJson.name || '') | |
| 21 | const directoryName = projectRoot.split('/').pop() || '' | |
| 22 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 23 | const contract = CONTRACTS[appName] | |
| 24 | ||
| 25 | if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`) | |
| 26 | ||
| 27 | const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '') | |
| 28 | const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10)) | |
| 29 | const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000)) | |
| 30 | ||
| 31 | async function readWithRetry(path: string): Promise<string> { | |
| 32 | let lastFailure = 'no response' | |
| 33 | for (let attempt = 1; attempt <= attempts; attempt += 1) { | |
| 34 | try { | |
| 35 | const separator = path.includes('?') ? '&' : '?' | |
| 36 | const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, { | |
| 37 | headers: { accept: path === '/robots.txt' ? 'text/plain' : 'text/html' }, | |
| 38 | redirect: 'follow', | |
| 39 | signal: AbortSignal.timeout(10_000), | |
| 40 | }) | |
| 41 | const body = await response.text() | |
| 42 | if (response.ok) return body | |
| 43 | lastFailure = `HTTP ${response.status}` | |
| 44 | } | |
| 45 | catch (error) { | |
| 46 | lastFailure = error instanceof Error ? error.message : String(error) | |
| 47 | } | |
| 48 | if (attempt < attempts) await Bun.sleep(pauseMs) | |
| 49 | } | |
| 50 | throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`) | |
| 51 | } | |
| 52 | ||
| 53 | const home = await readWithRetry('/') | |
| 54 | if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`) | |
| 55 | ||
| 56 | const login = await readWithRetry('/login') | |
| 57 | if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.') | |
| 58 | ||
| 59 | const robots = await readWithRetry('/robots.txt') | |
| 60 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') | |
| 61 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) | |
| 62 | ||
| 63 | console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`) | |
| @@ -0,0 +1,102 @@ | ||
| 1 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { dirname, join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const docsDir = resolve(root, 'docs') | |
| 6 | const configPath = resolve(root, 'config/docs.ts') | |
| 7 | const issues = new Set<string>() | |
| 8 | const incomingRoutes = new Set<string>() | |
| 9 | const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status'] | |
| 10 | ||
| 11 | function markdownFiles(dir: string): string[] { | |
| 12 | return readdirSync(dir).flatMap((name) => { | |
| 13 | const path = join(dir, name) | |
| 14 | return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : [] | |
| 15 | }) | |
| 16 | } | |
| 17 | ||
| 18 | function proseOnly(text: string): string { | |
| 19 | let inFence = false | |
| 20 | return text.split('\n').filter((line) => { | |
| 21 | if (/^(?:```|~~~)/.test(line)) { | |
| 22 | inFence = !inFence | |
| 23 | return false | |
| 24 | } | |
| 25 | return !inFence | |
| 26 | }).join('\n') | |
| 27 | } | |
| 28 | ||
| 29 | function routeForFile(file: string): string { | |
| 30 | const path = relative(docsDir, file).replace(/\\/g, '/') | |
| 31 | if (path === 'index.md') return '/' | |
| 32 | return `/${path.replace(/(?:\/index)?\.md$/, '')}` | |
| 33 | } | |
| 34 | ||
| 35 | function resolveDocTarget(source: string, rawTarget: string): string | null { | |
| 36 | let target = rawTarget.replace(/^<|>$/g, '').split(/[?#]/, 1)[0] | |
| 37 | if (!target || /^(?:[a-z]+:|#|mailto:|tel:)/i.test(rawTarget)) return null | |
| 38 | ||
| 39 | if (target === '/docs') target = '/' | |
| 40 | else if (target.startsWith('/docs/')) target = target.slice('/docs'.length) | |
| 41 | ||
| 42 | const base = target.startsWith('/') ? resolve(docsDir, `.${target}`) : resolve(dirname(source), target) | |
| 43 | const candidates = [base, `${base}.md`, join(base, 'index.md')] | |
| 44 | const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile()) | |
| 45 | if (found) return found | |
| 46 | if (target.startsWith('/') && appRoutePrefixes.some(prefix => target === prefix || target.startsWith(`${prefix}/`))) return null | |
| 47 | return '' | |
| 48 | } | |
| 49 | ||
| 50 | function checkTarget(source: string, rawTarget: string): void { | |
| 51 | const target = resolveDocTarget(source, rawTarget) | |
| 52 | if (target === null) return | |
| 53 | if (target === '') { | |
| 54 | issues.add(`${relative(root, source)}: missing local target ${rawTarget}`) | |
| 55 | return | |
| 56 | } | |
| 57 | if (target.startsWith(docsDir)) incomingRoutes.add(routeForFile(target)) | |
| 58 | } | |
| 59 | ||
| 60 | if (!existsSync(docsDir)) { | |
| 61 | console.error('Documentation directory does not exist: docs/') | |
| 62 | process.exit(1) | |
| 63 | } | |
| 64 | ||
| 65 | const files = markdownFiles(docsDir) | |
| 66 | for (const file of files) { | |
| 67 | const text = readFileSync(file, 'utf8') | |
| 68 | const prose = proseOnly(text) | |
| 69 | const name = relative(root, file) | |
| 70 | const headings = prose.match(/^#\s+\S.*$/gm) ?? [] | |
| 71 | const homeLayout = /^layout:\s*home\s*$/m.test(text) | |
| 72 | ||
| 73 | if (headings.length !== 1 && !(homeLayout && headings.length === 0)) { | |
| 74 | issues.add(`${name}: expected one H1, found ${headings.length}`) | |
| 75 | } | |
| 76 | ||
| 77 | const fences = text.match(/^(?:```|~~~)/gm) ?? [] | |
| 78 | if (fences.length % 2 !== 0) issues.add(`${name}: unbalanced fenced code blocks`) | |
| 79 | if (/[—–]/.test(prose)) issues.add(`${name}: contains a prohibited long dash`) | |
| 80 | if (/\b(?:TODO|TBD|FIXME|XXX)\b/.test(prose)) issues.add(`${name}: contains an unfinished marker`) | |
| 81 | ||
| 82 | for (const match of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) checkTarget(file, match[1]) | |
| 83 | for (const match of prose.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) checkTarget(file, match[1]) | |
| 84 | } | |
| 85 | ||
| 86 | if (existsSync(configPath)) { | |
| 87 | const config = readFileSync(configPath, 'utf8') | |
| 88 | for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1]) | |
| 89 | } | |
| 90 | ||
| 91 | for (const file of files) { | |
| 92 | const route = routeForFile(file) | |
| 93 | if (route !== '/' && !incomingRoutes.has(route)) { | |
| 94 | issues.add(`${relative(root, file)}: not linked from navigation or another documentation page`) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | console.log(`Validated ${files.length} documentation pages.`) | |
| 99 | if (issues.size > 0) { | |
| 100 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 101 | process.exit(1) | |
| 102 | } | |
| @@ -0,0 +1,199 @@ | ||
| 1 | import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' | |
| 2 | import { extname, join, resolve } from 'node:path' | |
| 3 | ||
| 4 | interface AppContract { | |
| 5 | domain: string | |
| 6 | minPages: number | |
| 7 | routes: Record<string, string[]> | |
| 8 | } | |
| 9 | ||
| 10 | const CONTRACTS: Record<string, AppContract> = { | |
| 11 | analyticshq: { | |
| 12 | domain: 'analyticshq.org', | |
| 13 | minPages: 30, | |
| 14 | routes: { | |
| 15 | '/': ['analyticshq'], | |
| 16 | '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'], | |
| 17 | '/login': ['analyticshq'], | |
| 18 | }, | |
| 19 | }, | |
| 20 | bughq: { | |
| 21 | domain: 'bughq.org', | |
| 22 | minPages: 25, | |
| 23 | routes: { | |
| 24 | '/': ['Error tracking for people who ship.', 'From throw to triaged'], | |
| 25 | '/login': ['bughq'], | |
| 26 | '/dashboard': ['bughq'], | |
| 27 | }, | |
| 28 | }, | |
| 29 | commshq: { | |
| 30 | domain: 'commshq.org', | |
| 31 | minPages: 45, | |
| 32 | routes: { | |
| 33 | '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'], | |
| 34 | '/login': ['CommsHQ'], | |
| 35 | '/dashboard/commshq': ['CommsHQ'], | |
| 36 | }, | |
| 37 | }, | |
| 38 | loghq: { | |
| 39 | domain: 'loghq.org', | |
| 40 | minPages: 30, | |
| 41 | routes: { | |
| 42 | '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'], | |
| 43 | '/login': ['loghq'], | |
| 44 | '/compare/sentry': ['loghq', 'Sentry'], | |
| 45 | }, | |
| 46 | }, | |
| 47 | reportshq: { | |
| 48 | domain: 'reportshq.org', | |
| 49 | minPages: 20, | |
| 50 | routes: { | |
| 51 | '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'], | |
| 52 | '/login': ['ReportsHQ'], | |
| 53 | '/pricing': ['ReportsHQ'], | |
| 54 | }, | |
| 55 | }, | |
| 56 | status: { | |
| 57 | domain: 'statushq.org', | |
| 58 | minPages: 35, | |
| 59 | routes: { | |
| 60 | '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'], | |
| 61 | '/login': ['Welcome back'], | |
| 62 | '/register': ['Create your account'], | |
| 63 | }, | |
| 64 | }, | |
| 65 | } | |
| 66 | ||
| 67 | const projectRoot = resolve(import.meta.dir, '..') | |
| 68 | const distRoot = resolve(projectRoot, 'dist') | |
| 69 | const ssrRoot = resolve(projectRoot, '.output') | |
| 70 | const ssrPages = resolve(ssrRoot, 'server/pages') | |
| 71 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 72 | const packageName = String(packageJson.name || '') | |
| 73 | const directoryName = projectRoot.split('/').pop() || '' | |
| 74 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 75 | const contract = CONTRACTS[appName] | |
| 76 | ||
| 77 | if (!contract) | |
| 78 | throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`) | |
| 79 | ||
| 80 | const isSsr = existsSync(ssrPages) | |
| 81 | if (!existsSync(distRoot) && !isSsr) | |
| 82 | throw new Error('No production artifact found. Run bun run build first.') | |
| 83 | ||
| 84 | function walkFiles(directory: string): string[] { | |
| 85 | if (!existsSync(directory)) return [] | |
| 86 | const files: string[] = [] | |
| 87 | for (const entry of readdirSync(directory)) { | |
| 88 | const path = join(directory, entry) | |
| 89 | if (statSync(path).isDirectory()) files.push(...walkFiles(path)) | |
| 90 | else files.push(path) | |
| 91 | } | |
| 92 | return files | |
| 93 | } | |
| 94 | ||
| 95 | function routeArtifact(route: string): { html: string, source: string } | undefined { | |
| 96 | if (!isSsr) { | |
| 97 | const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`) | |
| 98 | const nested = join(distRoot, route.slice(1), 'index.html') | |
| 99 | const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined | |
| 100 | return source ? { html: readFileSync(source, 'utf8'), source } : undefined | |
| 101 | } | |
| 102 | ||
| 103 | const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-') | |
| 104 | const source = join(ssrPages, `${name}.compiled.json`) | |
| 105 | if (!existsSync(source)) return undefined | |
| 106 | const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string } | |
| 107 | return { html: compiled.html || '', source } | |
| 108 | } | |
| 109 | ||
| 110 | const pageFiles = isSsr | |
| 111 | ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json')) | |
| 112 | : walkFiles(distRoot).filter(path => path.endsWith('.html')) | |
| 113 | ||
| 114 | const failures: string[] = [] | |
| 115 | if (pageFiles.length < contract.minPages) | |
| 116 | failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`) | |
| 117 | ||
| 118 | const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot | |
| 119 | for (const [route, expectedText] of Object.entries(contract.routes)) { | |
| 120 | const artifact = routeArtifact(route) | |
| 121 | if (!artifact) { | |
| 122 | failures.push(`${route}: production artifact is missing`) | |
| 123 | continue | |
| 124 | } | |
| 125 | ||
| 126 | const withoutCode = artifact.html | |
| 127 | .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') | |
| 128 | .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') | |
| 129 | .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ') | |
| 130 | const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() | |
| 131 | ||
| 132 | for (const fragment of expectedText) { | |
| 133 | if (!visibleText.includes(fragment)) | |
| 134 | failures.push(`${route}: expected text is missing: ${fragment}`) | |
| 135 | } | |
| 136 | if (/@(if|foreach|forelse)\b/.test(visibleText)) | |
| 137 | failures.push(`${route}: unresolved template syntax is visible`) | |
| 138 | if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode)) | |
| 139 | failures.push(`${route}: an empty primary heading was emitted`) | |
| 140 | if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html)) | |
| 141 | failures.push(`${route}: a secret-shaped assignment was emitted`) | |
| 142 | ||
| 143 | const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi | |
| 144 | let match: RegExpExecArray | null | |
| 145 | while ((match = assetPattern.exec(artifact.html))) { | |
| 146 | const assetPath = match[1] | |
| 147 | const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/') | |
| 148 | if (!looksLikeAsset) continue | |
| 149 | const local = resolve(publicRoot, `.${assetPath}`) | |
| 150 | if (!local.startsWith(`${publicRoot}/`) || !existsSync(local)) | |
| 151 | failures.push(`${route}: referenced asset is missing: ${assetPath}`) | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | for (const metadataFile of ['sitemap.xml', 'robots.txt']) { | |
| 156 | const path = join(publicRoot, metadataFile) | |
| 157 | if (!existsSync(path)) { | |
| 158 | failures.push(`${metadataFile}: missing from production artifact`) | |
| 159 | continue | |
| 160 | } | |
| 161 | const contents = readFileSync(path, 'utf8') | |
| 162 | if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`) | |
| 163 | if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`) | |
| 164 | } | |
| 165 | ||
| 166 | const migrationsDir = resolve(projectRoot, 'database/migrations') | |
| 167 | if (existsSync(migrationsDir)) { | |
| 168 | for (const file of readdirSync(migrationsDir).sort()) { | |
| 169 | const path = join(migrationsDir, file) | |
| 170 | if (!statSync(path).isFile()) continue | |
| 171 | if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`) | |
| 172 | const sql = readFileSync(path, 'utf8') | |
| 173 | if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | if (isSsr && failures.length === 0) { | |
| 178 | const { startProductionServer } = await import('@stacksjs/stx') | |
| 179 | const port = 42_000 + Math.floor(Math.random() * 8_000) | |
| 180 | const server = await startProductionServer({ outputDir: ssrRoot, port }) | |
| 181 | try { | |
| 182 | for (const route of Object.keys(contract.routes)) { | |
| 183 | const response = await fetch(`http://127.0.0.1:${server.port}${route}`) | |
| 184 | const body = await response.text() | |
| 185 | if (!response.ok) failures.push(`${route}: production server returned ${response.status}`) | |
| 186 | if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`) | |
| 187 | } | |
| 188 | } | |
| 189 | finally { | |
| 190 | server.stop() | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | if (failures.length) { | |
| 195 | for (const failure of failures) console.error(`- ${failure}`) | |
| 196 | process.exit(1) | |
| 197 | } | |
| 198 | ||
| 199 | console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`) | |
| @@ -0,0 +1,90 @@ | ||
| 1 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const resources = resolve(root, 'resources') | |
| 6 | const issues = new Set<string>() | |
| 7 | ||
| 8 | function filesIn(directory: string): string[] { | |
| 9 | return readdirSync(directory).flatMap((name) => { | |
| 10 | const path = join(directory, name) | |
| 11 | return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : [] | |
| 12 | }) | |
| 13 | } | |
| 14 | ||
| 15 | function withoutComments(value: string): string { | |
| 16 | const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ') | |
| 17 | return value | |
| 18 | .replace(/<!--[^]*?-->/g, preserveLines) | |
| 19 | .replace(/\{\{--[^]*?--\}\}/g, preserveLines) | |
| 20 | .replace(/\/\*[^]*?\*\//g, preserveLines) | |
| 21 | .replace(/^\s*\/\/.*$/gm, preserveLines) | |
| 22 | } | |
| 23 | ||
| 24 | function lineAt(source: string, index: number): number { | |
| 25 | return source.slice(0, index).split('\n').length | |
| 26 | } | |
| 27 | ||
| 28 | function named(attributes: string): boolean { | |
| 29 | return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes) | |
| 30 | } | |
| 31 | ||
| 32 | function report(file: string, source: string, index: number, message: string): void { | |
| 33 | issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`) | |
| 34 | } | |
| 35 | ||
| 36 | const files = filesIn(resources) | |
| 37 | const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file)) | |
| 38 | const combined = files.map(file => readFileSync(file, 'utf8')).join('\n') | |
| 39 | ||
| 40 | for (const file of markupFiles) { | |
| 41 | const original = readFileSync(file, 'utf8') | |
| 42 | const source = withoutComments(original) | |
| 43 | ||
| 44 | for (const match of source.matchAll(/<img\b[^>]*>/gi)) { | |
| 45 | if (!/\b(?::?alt)=/.test(match[0])) | |
| 46 | report(file, source, match.index, 'image has no alt text') | |
| 47 | } | |
| 48 | ||
| 49 | for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) { | |
| 50 | const attributes = match[1] | |
| 51 | const content = match[2] | |
| 52 | .replace(/<[^>]+>/g, '') | |
| 53 | .replace(/\{\{[^}]+\}\}/g, 'dynamic') | |
| 54 | .replace(/@[a-z]+\([^)]*\)/gi, '') | |
| 55 | .trim() | |
| 56 | if (!content && !named(attributes)) | |
| 57 | report(file, source, match.index, 'icon-only button has no accessible name') | |
| 58 | } | |
| 59 | ||
| 60 | for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) { | |
| 61 | const attributes = match[2] | |
| 62 | if (/\btype=["']hidden["']/i.test(attributes) || named(attributes)) | |
| 63 | continue | |
| 64 | const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1] | |
| 65 | const before = source.slice(0, match.index) | |
| 66 | const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>') | |
| 67 | const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false | |
| 68 | if (!wrapped && !referenced) | |
| 69 | report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`) | |
| 70 | } | |
| 71 | ||
| 72 | for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi)) | |
| 73 | report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`) | |
| 74 | ||
| 75 | for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) { | |
| 76 | if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1])) | |
| 77 | report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible') | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined)) | |
| 82 | issues.add('resources: no visible keyboard focus treatment found') | |
| 83 | if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined)) | |
| 84 | issues.add('resources: no responsive breakpoint treatment found') | |
| 85 | ||
| 86 | console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`) | |
| 87 | if (issues.size) { | |
| 88 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 89 | process.exit(1) | |
| 90 | } | |
| @@ -0,0 +1,344 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | ||
| 5 | interface Viewport { | |
| 6 | name: string | |
| 7 | width: number | |
| 8 | height: number | |
| 9 | mobile: boolean | |
| 10 | } | |
| 11 | ||
| 12 | interface PageAudit { | |
| 13 | contrast: string[] | |
| 14 | focusable: number | |
| 15 | imagesWithoutAlt: number | |
| 16 | overflow: number | |
| 17 | unresolved: boolean | |
| 18 | unnamedControls: string[] | |
| 19 | visibleTextLength: number | |
| 20 | } | |
| 21 | ||
| 22 | interface Pending { | |
| 23 | reject: (error: Error) => void | |
| 24 | resolve: (value: any) => void | |
| 25 | timer: ReturnType<typeof setTimeout> | |
| 26 | } | |
| 27 | ||
| 28 | const DIST = resolve('dist') | |
| 29 | const SSR_OUTPUT = resolve('.output') | |
| 30 | const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages') | |
| 31 | const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public') | |
| 32 | const OUTPUT = resolve('storage/logs/visual-contract') | |
| 33 | const VIEWPORTS: Viewport[] = [ | |
| 34 | { name: 'desktop', width: 1440, height: 1000, mobile: false }, | |
| 35 | { name: 'tablet', width: 768, height: 1024, mobile: false }, | |
| 36 | { name: '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 | ] | |
| 40 | const THEMES = ['light', 'dark'] as const | |
| 41 | const ROUTE_CANDIDATES = [ | |
| 42 | '/', | |
| 43 | '/pricing', | |
| 44 | '/features', | |
| 45 | '/use-cases', | |
| 46 | '/login', | |
| 47 | '/register', | |
| 48 | '/reports', | |
| 49 | '/account', | |
| 50 | ] | |
| 51 | ||
| 52 | function 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 | ||
| 66 | async 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 | ||
| 77 | class 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 | ||
| 114 | async 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 | ||
| 123 | function routeName(route: string): string { | |
| 124 | return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') | |
| 125 | } | |
| 126 | ||
| 127 | function 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 | ||
| 140 | function 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 | ||
| 146 | const 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 | ||
| 223 | async 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 | ||
| 344 | await main() | |
| @@ -0,0 +1,17 @@ | ||
| 1 | const description = 'Build, schedule, export, and securely share reports from the data sources your team already uses.' | |
| 2 | ||
| 3 | export default { | |
| 4 | name: 'ReportsHQ', | |
| 5 | url: 'https://reportshq.org', | |
| 6 | description, | |
| 7 | seo: { | |
| 8 | siteName: 'ReportsHQ', | |
| 9 | title: 'ReportsHQ - Reports without the busywork', | |
| 10 | description, | |
| 11 | image: 'https://reportshq.org/og.png', | |
| 12 | favicon: '/favicon.svg', | |
| 13 | locale: 'en_US', | |
| 14 | type: 'website', | |
| 15 | }, | |
| 16 | pages: {}, | |
| 17 | } | |
| @@ -84,7 +84,7 @@ describe('the reports surface', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 84 | 84 | for (const page of ['resources/views/reports/index.stx', 'resources/views/reports/[slug].stx']) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 85 | 85 | const source = readFileSync(join(root, page), 'utf8') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 86 | 86 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 87 | expect(source).toContain('sessionFrom(cookies)') | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 87 | expect(source).toContain("sessionFrom(typeof cookies !== 'undefined' ? cookies : undefined)") | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 88 | 88 | 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'
[1m[31m╭──────────────────────────────────────────────────────────────╮[0m
[1m[31m│[0m [1m[41m ERROR [0m [31mInclude Error[0m [1m[31m│[0m
[1m[31m╰──────────────────────────────────────────────────────────────╯[0m
[36m[1mFile:[0m [2mapp.stx[0m [90m(line 3990, col 6427)[0m
[33m[1mMessage:[0m Error loading include file AuthGuard: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/AuthGuard.stx'
[34m[1mContext:[0m
[2m 3988:
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 89 | 89 | expect(source).toContain('auth-required') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 90 | 90 | // And the data is fetched only for an identified visitor. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @@ -0,0 +1,20 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | ||
| 3 | const NOW = '2026-09-12T12:00:00.000Z' | |
| 4 | const report = (index: number, status = 'published') => ({ id: index, slug: `report-${index}`, name: `Report ${index}`, status, blocks: 2 + index % 8, updatedAt: NOW }) | |
| 5 | ||
| 6 | export 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 | ||
| 14 | describe('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 | }) | |