Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f49ca0a
fix(invite): report a rewritten landing as found, not as 404
TaprootFreakAI Sep 9, 2026
d9f3796
docs(invite): describe the status promotion where the rules are written
TaprootFreakAI Sep 9, 2026
aeaf101
test(invite): anchor the status promotion to the shipped pages
TaprootFreakAI Sep 9, 2026
8fbde3f
fix(invite): answer HEAD like GET, and prove the headers survive
TaprootFreakAI Sep 9, 2026
2400e37
test(invite): assert the HEAD response's headers and its absent body
TaprootFreakAI Sep 9, 2026
fa92acd
docs(invite): carry the status promotion into the README too
TaprootFreakAI Sep 9, 2026
46affc4
fix(invite): decide the HEAD status from a GET-equivalent lookup
TaprootFreakAI Sep 9, 2026
924c78c
docs(invite): make the comments say what is actually true
TaprootFreakAI Sep 9, 2026
2b88f0a
test(invite): take the header values from public/_headers, and cover …
TaprootFreakAI Sep 9, 2026
4f9ea62
fix(invite): strip Range from the GET-equivalent a HEAD triggers
TaprootFreakAI Sep 9, 2026
276223d
docs(invite): stop repeating a phrase that has no referent
TaprootFreakAI Sep 9, 2026
6516f51
test(invite): pin the promotion to 404 and nothing near it
TaprootFreakAI Sep 9, 2026
15ae445
fix(invite): drop Range for a plain GET too, not only for HEAD
TaprootFreakAI Sep 9, 2026
bc1ac93
fix(invite): drop the headers that described the bytes before the rew…
TaprootFreakAI Sep 9, 2026
a7373b4
fix(invite): make the GET-equivalent unconditional, and never pair a …
TaprootFreakAI Sep 9, 2026
265fd0a
fix(invite): drop the stale Content-Encoding, and stop saying "everyt…
TaprootFreakAI Sep 9, 2026
853aca8
docs(invite): stop crediting the dev-server with the middleware's beh…
TaprootFreakAI Sep 9, 2026
8b1ab3b
docs(invite): say what the rewrite invalidates, not what the headers are
TaprootFreakAI Sep 9, 2026
b6ada74
fix(invite): declare UTF-8, refuse to rewrite a partial answer, clean…
TaprootFreakAI Sep 9, 2026
8e8ecaf
docs(invite): separate the two claims about Content-Encoding, and ask…
TaprootFreakAI Sep 9, 2026
384bdd7
fix(invite): let a body-less answer keep the length it correctly had
TaprootFreakAI Sep 9, 2026
4191217
fix(invite): hand on what this pass has no business rewriting
TaprootFreakAI Sep 9, 2026
1b98e52
fix(invite): read the media type as a media type, and leave other enc…
TaprootFreakAI Sep 9, 2026
981f783
fix(invite): hand an untouched answer on as it came, not rebuilt from…
TaprootFreakAI Sep 9, 2026
fb01bdd
fix(invite): read the Content-Type parameters with quotes respected
TaprootFreakAI Sep 9, 2026
b38326a
test(invite): make the untouched-404 cases tell the fix from the bug
TaprootFreakAI Sep 9, 2026
5069fc5
test(invite): prove the foreign encoding survives, with real bytes
TaprootFreakAI Sep 9, 2026
9ef5048
fix(invite): honour a quoted-pair when splitting the Content-Type
TaprootFreakAI Sep 9, 2026
efcea6a
test(invite): count bytes as bytes, pin og:url, and finish the identi…
TaprootFreakAI Sep 9, 2026
f856798
test(invite): pin the parameter name's case and the marker's exact shape
TaprootFreakAI Sep 9, 2026
a4ad822
test(invite): require the origin's own answer on every pass-through b…
TaprootFreakAI Sep 9, 2026
f308684
docs(invite): state the precondition deviation instead of leaving it …
TaprootFreakAI Sep 9, 2026
744c77f
test(invite): the last pass-through branch, and the third URL tag
TaprootFreakAI Sep 9, 2026
4c6e283
docs(invite): put the RFC deviation where contributors read, not only…
TaprootFreakAI Sep 9, 2026
ba50630
docs(invite): separate the two kinds of precondition in the reasoning
TaprootFreakAI Sep 9, 2026
d2c840c
test(invite): pin the two hand-off URLs and the path boundary
TaprootFreakAI Sep 9, 2026
880311e
fix(invite): recognise a landing shell by two marks, not one
TaprootFreakAI Sep 9, 2026
1011da3
docs(invite): say two marks everywhere the code now needs two
TaprootFreakAI Sep 9, 2026
88d9379
docs(invite): the body is never stale; it is the status that diverges
TaprootFreakAI Sep 9, 2026
2467a05
test(invite): the marks are attributes, not words
TaprootFreakAI Sep 9, 2026
35d93c1
docs(invite): a comment that its own next line disproves
TaprootFreakAI Sep 9, 2026
c15b3e5
docs(invite): say what the marker check does, and pin what makes it e…
TaprootFreakAI Sep 9, 2026
4de6a38
docs(invite): correct three claims, and pin the inventory the new tes…
TaprootFreakAI Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ This repo is the **realunit.app** website — public, static. See the
`public/` ships verbatim to Cloudflare Pages — what you commit is what gets
served. The one exception is the invite/promo HTML: `functions/_middleware.js`
rewrites those bytes on the way out so crawlers see the code in
`apple-itunes-app`, `og:*` and the App Links before any script runs. Nothing
else is transformed, and there is no server-side rendering. The dev dependencies exist **only** for the quality gates below
`apple-itunes-app`, `og:*` and the App Links before any script runs. It also
reports a rewritten landing as `200`: Pages resolves `/invite/<code>` to the
code-less shell through the `_redirects` rewrite but keeps the not-found
status of the path that was asked for, and a crawler drops a `404` before it
reads the tags. The promotion is guarded on the two landing marks — `id="state-loading"` and `aria-busy="true"`, which both landings carry together in one tag and no other page the site ships carries together — so the site's
own 404 page keeps saying 404, and HEAD answers with the same status as GET.
Both methods are resolved internally as one full GET, without `Range` /
`If-Range` and without the conditional request headers, because the whole document
is rewritten and the status is decided from its body. The conditional request headers are dropped rather than evaluated, which is a deliberate deviation from RFC 9110 §13.1: a rewritten landing emits no validator to condition on, so the answer is always the current representation and never a 304 or a 412. A pass-through answer keeps the origin's own headers, validator included. `scripts/dev-server.mjs` shares the injection and answers HEAD without a body, but has no promotion, no marker guard and no header stripping: its own routing serves the landings as `200` and never produces the not-found status the promotion exists to correct. Nothing else is transformed, and there is no server-side
rendering. The dev dependencies exist **only** for the quality gates below
(formatting, HTML validation, unit tests, screenshots); nothing compiles or
bundles the site.
- **Invite/promo HTML rewrite is banner, canonical, and store handoff.** Safari,
Expand Down Expand Up @@ -45,8 +53,9 @@ This repo is the **realunit.app** website — public, static. See the
adding any other host to that allowlist needs a reason in the PR.
- Inline `style="…"` attributes and `<style>` blocks are fine (`style-src`
allows `'unsafe-inline'`).
- **Put the reusable, side-effect-free JS in `public/js/lib/`.** That is the only
code with a unit-coverage gate (see below); DOM/network glue stays in the
- **Put the reusable, side-effect-free JS in `public/js/lib/`.** It carries a
unit-coverage gate (see below), as do `functions/lib/**` and the Function
entry point `functions/_middleware.js`; DOM/network glue stays in the
page-level scripts and is covered by the Playwright suite.
- **Don't put mutable files under `public/assets/`.** That path has an immutable,
one-year cache header — only content-hashed or otherwise stable-named assets
Expand All @@ -73,14 +82,14 @@ sanity-check anything touching scripts/images in the dev deployment.
Every pull request must pass the gates below; CI runs them as required status
checks.

| Gate | Command | What it enforces |
| ----------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Formatting | `npm run format:check` | Prettier formatting of the maintained code (the hand-written HTML pages are validated, not reformatted) |
| HTML validity | `npm run validate:html` | Valid markup on every page under `public/` |
| Site completeness | `npm run check:site` | Every `<html lang>`, every internal link/asset resolves, and each glue script loads its `js/lib` core first |
| Unit coverage | `npm run test:coverage` | 100% line/branch/function/statement coverage of the extracted browser logic (`public/js/lib/**`) |
| Functional | `npm run test:e2e` | Playwright smoke + behavior suite (every page loads, platform detection, the full confirm flow) |
| Visual regression | `npm run e2e:docker` | Every view in the visual matrix (page × viewport × language × state) matches its committed baseline, then `check:visual` |
| Gate | Command | What it enforces |
| ----------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Formatting | `npm run format:check` | Prettier formatting of the maintained code (the hand-written HTML pages are validated, not reformatted) |
| HTML validity | `npm run validate:html` | Valid markup on every page under `public/` |
| Site completeness | `npm run check:site` | Every `<html lang>`, every internal link/asset resolves, and each glue script loads its `js/lib` core first |
| Unit coverage | `npm run test:coverage` | 100% line/branch/function/statement coverage of `public/js/lib/**` and `functions/_middleware.js`; `functions/lib/**` at its ratchet in `vitest.config.mjs` |
| Functional | `npm run test:e2e` | Playwright smoke + behavior suite (every page loads, platform detection, the full confirm flow) |
| Visual regression | `npm run e2e:docker` | Every view in the visual matrix (page × viewport × language × state) matches its committed baseline, then `check:visual` |

`npm run check` runs the first four locally in one go. The Playwright suites run
against a local dev server (`test:e2e`); the visual gate runs in a pinned
Expand All @@ -101,9 +110,12 @@ The shipped page scripts (`public/platform.js`,
than chase 100% coverage through the DOM, the **pure** logic — platform
detection, language resolution, API-base derivation, response→state mapping, and
the i18n copy — is extracted into `public/js/lib/` (side-effect free, exposed on
a `window.*` global) and unit-tested to 100% with Vitest + jsdom. Everything else
is covered end-to-end by the Playwright functional suite
(`tests/behavior.spec.mjs`).
a `window.*` global) and unit-tested to 100% with Vitest + jsdom. The rest of
those page scripts is covered end-to-end by the Playwright functional suite
(`tests/behavior.spec.mjs`). Two more surfaces carry their own unit-coverage
gate and are not page scripts at all: `functions/lib/**` at the ratchet set in
`vitest.config.mjs`, and the Pages Function entry point
`functions/_middleware.js` at 100% — see the quality-gates table above.

If you add a file under `public/js/lib/`, it must reach 100% coverage or the
Quality gate fails (the threshold reports every matched file, tested or not). A
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ uploaded to Cloudflare Pages.
custom scheme; `twitter:app:country` is CH) are injected
into the HTML bytes from the request URL (`functions/_middleware.js` on
Cloudflare Pages, and the local dev-server) so Safari, Play, WhatsApp, X,
and share crawlers can snapshot them before JS. `og:title`, `og:description`,
and share crawlers can snapshot them before JS. On Cloudflare Pages, and only
there, `functions/_middleware.js` also reports a rewritten landing as `200`:
Pages resolves `/invite/<code>` to the code-less shell through the
`_redirects` rewrite but keeps the not-found status of the path that was asked
for, and a crawler drops a `404` before it reads the tags. The promotion is
guarded on two marks the landings carry and the site's 404 page does not, and
HEAD answers with the same status as GET. Both methods are resolved internally
as one full GET, without `Range` / `If-Range` and without the conditional
request headers, because the whole document is rewritten and the status is decided
from its body. The conditional request headers are dropped rather than evaluated, which is a deliberate deviation from RFC 9110 §13.1: a rewritten landing emits no validator to condition on, so the answer is always the current representation and never a 304 or a 412. A pass-through answer keeps the origin's own headers, validator included. `scripts/dev-server.mjs` shares the injection and answers HEAD without a body, but has no promotion, no marker guard and no header stripping: its own routing serves the landings as `200` and never produces the not-found status the promotion exists to correct.
`og:title`, `og:description`,
and image alt name the campaign code; `?lang=en` sets English copy and `og:locale=en_GB`;
invitee names wait for lookup JS. `/js/invite-banner.js` in `<head>`
is the CSP-safe JS fallback — Cloudflare Pages CSP blocks inline `<script>`.
Expand Down Expand Up @@ -92,7 +102,9 @@ From v2 a build toolchain (Astro) is introduced; the plain-image landing stays t
## Testing

The site still ships verbatim — the tooling is dev-only. Pure browser logic lives
in `public/js/lib/**` and is unit-tested to 100% (Vitest + jsdom); the pages,
in `public/js/lib/**` and is unit-tested to 100% (Vitest + jsdom), as is the
Pages Function entry point `functions/_middleware.js`; `functions/lib/**` is
measured at its ratchet in `vitest.config.mjs`. The pages,
platform detection and the full confirm flow are covered by Playwright
(functional + screenshot regression). See [CONTRIBUTING](CONTRIBUTING.md#quality-gates)
for the gate list and commands (`npm run check`, `npm run test:e2e`,
Expand Down
Loading
Loading