feat(polish): phase 10 — search, accessibility, SEO and a real CSP
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.
D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.
D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:
* Astro does not hash <script is:inline>, and Starlight ships six per docs
page, so the first build with CSP on had a strict header and a dead theme
switcher. The hashes are now generated into src/config/cspHashes.mjs and
checkCsp.mjs verifies every inline block against its own page's policy.
* Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
script-src is untouched.
* @astrojs/node matched a request to a policy with pathname.includes(), a
substring test: /modules/ was served /docs/modules/building-a-module's
policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
the same _headers.json and matches by equality; test/headers.test.mjs starts
the server and reads the responses, because nothing that reads dist/ can see
this.
D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.
checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.
npm run verify is green: fourteen steps, both test suites, all eleven checks.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -158,3 +158,45 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:reference
|
||||
|
||||
- name: The headers the server actually sends
|
||||
# PLAN.md §6 / D48, phase 10. Every other check reads dist/; this one starts
|
||||
# scripts/serve.mjs and reads the responses, because the defect it exists for
|
||||
# happened after the build was already correct. @astrojs/node matched a request to a
|
||||
# policy with a SUBSTRING test, so /modules/ was served the policy built for
|
||||
# /docs/modules/building-a-module — every file on disk right, the bytes on the wire
|
||||
# wrong, and the page rendered with its own stylesheet refused.
|
||||
#
|
||||
# It needs the build, so it cannot live in the "Unit tests" step above.
|
||||
run: npm run test:served
|
||||
|
||||
- name: Accessibility
|
||||
# PLAN.md §13, phase 10. Seven structural rules over every built page: one <h1> and
|
||||
# no skipped heading level, an alt attribute on every image, a label on every form
|
||||
# control, an accessible name on every link and button, <html lang>, one <main> with
|
||||
# a skip link that reaches it, and no positive tabindex.
|
||||
#
|
||||
# Structural on purpose. A static check cannot measure contrast on a rendered page
|
||||
# or find a focus trap, and a check that pretended to would be trusted for things it
|
||||
# cannot see. What it does catch is the class of defect that is invisible to a
|
||||
# sighted author and permanent once shipped — and it covers Starlight's forty pages
|
||||
# too, so a dependency upgrade that loses a label is a red build rather than a
|
||||
# discovery.
|
||||
#
|
||||
# After the build, because it reads dist/client. No token and no network.
|
||||
run: npm run check:a11y
|
||||
|
||||
- name: Content-Security-Policy
|
||||
# PLAN.md §6 / D48. The policy is a real response header — the Node adapter's
|
||||
# staticHeaders writes dist/_headers.json and the standalone server sends it — so
|
||||
# frame-ancestors applies and the operator's proxy needs no CSP config.
|
||||
#
|
||||
# The check that matters is the second one: every inline script and style must be
|
||||
# covered by a hash in ITS OWN page's policy. Astro does not hash <script is:inline>,
|
||||
# and Starlight ships six of them per documentation page, so the first build with CSP
|
||||
# enabled had a strict, correct header and a dead theme switcher — a failure with no
|
||||
# symptom except a console message. A Starlight upgrade can reintroduce it at any
|
||||
# time, which is why this runs on every PR rather than once.
|
||||
#
|
||||
# After the build, because it reads dist/. No token and no network.
|
||||
run: npm run check:csp
|
||||
|
||||
127
PLAN.md
127
PLAN.md
@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
|
||||
|
||||
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
|
||||
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
|
||||
somewhere other than the thing it decided. The count of record is **forty-six**:
|
||||
somewhere other than the thing it decided. The count of record is **fifty**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
@@ -248,6 +248,7 @@ somewhere other than the thing it decided. The count of record is **forty-six**:
|
||||
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
||||
| D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
|
||||
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
||||
| D47–D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
|
||||
|
||||
---
|
||||
|
||||
@@ -295,6 +296,126 @@ inside the org's existing tooling family. Node 22 LTS.
|
||||
- **No authenticated surface exists on the site at all.** The CSV export is a CLI run against the
|
||||
bind mount, not an HTTP route — see §8.
|
||||
|
||||
### How phase 10 polished it
|
||||
|
||||
Four decisions, D47–D50, taken 2026-08-25. Three of them were straightforward; the fourth turned
|
||||
into the phase's real work, because the thing that was supposed to be a configuration flag was
|
||||
broken in a dependency and broken *silently*.
|
||||
|
||||
**D47 — search reaches the marketing pages, and the marketing header gets a box.** The
|
||||
documentation had search from phase 1: Starlight builds a Pagefind index at the end of every build.
|
||||
The marketing pages were outside it twice over — not indexed, so a reader searching "Teams" in the
|
||||
docs found the architecture page and never the feature page; and with no box, so a reader who
|
||||
arrived on the homepage had a four-item nav and no way to ask a question. `Base.astro` now marks its
|
||||
`<main>` as a Pagefind body, which puts all ten in the index the docs already query, and a
|
||||
`Search.astro` in the header opens the same index in a `<dialog>`.
|
||||
|
||||
Three things about the build are worth keeping. **Nothing is fetched until the dialog is opened** —
|
||||
Pagefind's UI bundle is 120 kB before the index and the WASM, and these pages otherwise ship almost
|
||||
no JavaScript, so the button is inert markup and the first open injects the script. **`<dialog>`
|
||||
rather than a hand-built overlay**, because the browser supplies the focus trap, the inert
|
||||
background, Escape-to-close and the top layer, and every one of those is something an accessibility
|
||||
pass would otherwise find missing. And **the index needed an explicit title**: Pagefind titles a
|
||||
result from the first `<h1>`, and these pages have editorial ones — `/app/`'s is "The app for a
|
||||
deployment you already use", `/terms/`'s is "Short, and only about what we run". Correct on the page
|
||||
under an eyebrow that names the section; unscannable as four rows in a result list, which is exactly
|
||||
what the first walk of the finished search produced. `data-pagefind-meta` now carries the page's
|
||||
short name, the one already in the nav and the browser tab.
|
||||
|
||||
**Two things about styling somebody else's widget.** Pagefind's UI takes a `resetStyles`
|
||||
option; setting it to `false` — on the reasoning that the site's own type and colour should
|
||||
show through — is wrong, because that reset is what styles Pagefind's own input and buttons.
|
||||
Without it they fall back to user-agent defaults, which on this ground meant black text typed
|
||||
into a dark field and a Clear button with an `outset` border. The palette is bound through
|
||||
Pagefind's custom properties instead. And the match highlight needed one extra class in the
|
||||
selector: the reset declares `.pagefind-ui--reset mark { all: revert }`, same specificity as a
|
||||
plain descendant rule and injected after our stylesheet, so it won on order and put the
|
||||
user-agent yellow back on every result.
|
||||
|
||||
It also closed a note phase 2 left here. Pagefind indexes at build time, so the boot rewrite
|
||||
(§7, D15) reached the pages and not the search results: a site renamed through the mount would
|
||||
answer a search for its own name with the stock one. `applyBrand.mjs` now re-indexes after a rewrite
|
||||
— only when it actually rewrote something, so a stock deployment still pays nothing.
|
||||
|
||||
**D48 — the CSP is a real response header, sent by the container.** The alternatives were a
|
||||
`<meta http-equiv>`, which is what Astro emits by default and which silently ignores
|
||||
`frame-ancestors` — the one directive that stops the site being framed — and writing the headers
|
||||
into an operator's reverse-proxy configuration, which puts the strictest promise in §6 outside the
|
||||
artifact this repository builds and tests. Neither is good enough for a security boundary, so the
|
||||
Node adapter's `staticHeaders` is on: the build writes one policy per prerendered route into
|
||||
`dist/_headers.json` and the server sends it.
|
||||
|
||||
**Three things fought this, and each is the same shape: correct build, broken page, no error.**
|
||||
|
||||
1. **Astro does not hash `<script is:inline>`.** It hashes what it processes; an inline script is
|
||||
the author's own text, which it never parses. Starlight ships six per documentation page — the
|
||||
theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore. The first
|
||||
build with CSP enabled produced a strict, correct header and a documentation site whose theme
|
||||
switch and mobile sidebar did nothing, with the explanation only in a console. `'unsafe-inline'`
|
||||
would have fixed all six and given up the single directive CSP exists to enforce, so instead the
|
||||
hashes are enumerated in a generated `src/config/cspHashes.mjs` and `scripts/checkCsp.mjs`
|
||||
verifies, per page, that every inline block is covered by *that page's own* policy. A Starlight
|
||||
upgrade that edits one byte turns the build red; `npm run csp:hashes` re-harvests it.
|
||||
|
||||
2. **Expressive Code cannot be hashed at all.** Around 3,700 inline `style` **attributes** across
|
||||
the documentation carry every syntax colour, and CSP hashes cover `<style>` elements, never
|
||||
attributes — Astro's own documentation records Shiki as incompatible with CSP for this reason.
|
||||
The policy therefore carries `style-src-attr 'unsafe-inline'`, scoped to that directive: a style
|
||||
attribute cannot execute script, so `script-src` is untouched. The marketing pages emit none.
|
||||
|
||||
3. **`@astrojs/node` served the wrong page's policy.** Its per-request lookup is
|
||||
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a substring test taking the
|
||||
first match. `/modules/` was served the policy built for `/docs/modules/building-a-module`;
|
||||
`/architecture/` got a docs page's; and `/`, a substring of every path in the file, got whichever
|
||||
record came first, which was `/404`. Since each policy is a list of per-page hashes, the browser
|
||||
refused each page's own stylesheet: `/modules/` and `/architecture/` were rendering unstyled,
|
||||
and the homepage looked perfect only because it happened to share a hash with the 404 page.
|
||||
`scripts/serve.mjs` — a thin wrapper `npm start` now runs instead of the adapter's entry — keeps
|
||||
the same `_headers.json` and matches by equality. It is small on purpose so it can be deleted
|
||||
whole when upstream is fixed, and it is where the non-CSP security headers live too.
|
||||
|
||||
**This is why `test/headers.test.mjs` exists.** Every other check in this repository reads
|
||||
`dist/`, and every file on disk was right — the bytes on the wire were not. It starts the server
|
||||
and reads the responses, and reverting the wrapper to the substring lookup fails it.
|
||||
|
||||
**D49 — `robots.txt` allows everything and names the sitemap.** The sitemap has covered all fifty
|
||||
URLs since phase 1 (Starlight bundles `@astrojs/sitemap`) and nothing pointed at it; a crawler finds
|
||||
one either from this file or from a search console, and D9's posture extends to not having an
|
||||
account with anyone. Nothing is disallowed: there is no authenticated surface (§6), `/brand/*` is
|
||||
derived images with no text, and `/beta/` is a page a person is meant to find. The 404 is kept out
|
||||
of the *search index* instead, with `data-pagefind-ignore`, which is the right layer for it.
|
||||
|
||||
**D50 — two blocks of structured data, and no more.** `Organization` so the project's name resolves
|
||||
to an entity rather than to whichever page ranks, and `SoftwareApplication` because what the site
|
||||
describes is software someone installs. No ratings, no counts, no invented `aggregateRating` — §11's
|
||||
understated honesty applies to markup a reader never sees, and inventing a rating is what gets
|
||||
structured data ignored. Breadcrumb and `Article` markup on the forty documentation pages was
|
||||
rejected: Starlight already renders breadcrumbs a reader can see, and it would be forty more places
|
||||
for a fact to go stale. Every value is read from `brand.json` or `platform.json`, so `checkFacts.mjs`
|
||||
already guards them.
|
||||
|
||||
It is a `<script type="application/ld+json">`, which is a data block: no browser executes it and no
|
||||
CSP hash covers it. **Both `checkCsp.mjs` and `applyBrand.mjs` had to be taught that explicitly** —
|
||||
the first would have demanded a hash for text that changes whenever a fact does, and the second
|
||||
would have refused to rewrite the homepage at all, which is §7 failing on the page that matters
|
||||
most.
|
||||
|
||||
**What the walk found.** Ten marketing pages and a documentation sample, at 390, 768 and 1280 in
|
||||
real Chrome. No horizontal overflow at any width, on any page — the responsive work of phases 3 and
|
||||
4 held, including with a search button added to the header. The CSP violations above. The consent
|
||||
checkbox on `/beta` measured 17×17 against WCAG 2.2 SC 2.5.8's 24px minimum, and is now 24 — the one
|
||||
control on the site a person must hit precisely, on the page a phone is most likely to arrive at.
|
||||
And following the skip link moved the scroll but not the focus, because a `<main>` is not focusable;
|
||||
Chrome papers over that and not every browser does, so it now carries `tabindex="-1"`.
|
||||
|
||||
**`checkA11y.mjs` is the eleventh check**, and the eighth in CI. Seven structural rules over every
|
||||
built page, ours and Starlight's forty. Structural on purpose: a static check cannot measure
|
||||
contrast on a rendered page or find a focus trap, and one that pretended to would be trusted for
|
||||
things it cannot see. Its own first run reported every marketing page as having two `<main>`
|
||||
landmarks — this repository comments its markup heavily, and one of those comments quotes the tag it
|
||||
is explaining, so comments are stripped before anything is counted. It was then verified by breaking
|
||||
each of its rules in turn.
|
||||
|
||||
---
|
||||
|
||||
## 7. Branding is bind-mounted data
|
||||
@@ -1269,8 +1390,8 @@ a mechanism rather than diligence:
|
||||
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||||
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers. See D47–D50 — the CSP was the work, because `@astrojs/node` served every page another page's policy |
|
||||
| **11** | Validation: `astro check`, production build, **all eleven check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens, a11y, CSP) plus both test suites, mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||
|
||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||
|
||||
68
README.md
68
README.md
@@ -35,17 +35,24 @@ Node 22 LTS or newer.
|
||||
|
||||
## The checks, and why they are not optional
|
||||
|
||||
Two of them, both from `PLAN.md` §12. Neither is a linter; each one enforces a promise the site
|
||||
makes that would otherwise decay quietly.
|
||||
Eleven of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
|
||||
that would otherwise decay quietly.
|
||||
|
||||
```bash
|
||||
npm run check:sidebar # the rendered docs tree still matches the planned one
|
||||
npm run check:screens # every screenshot has an entry, at the size declared
|
||||
npm run check:tokens # no colour literal outside the token file
|
||||
npm run check:brand # the branding pipeline's two quiet failures
|
||||
npm run check:datasafety # the Play declaration still matches /privacy
|
||||
npm run check # astro check
|
||||
npm test # the beta signup's decision path, and the policy data
|
||||
npm run build && npm run check:links # every internal link resolves (reads the build)
|
||||
npm run build # everything below reads the build
|
||||
npm run check:links # every internal link resolves
|
||||
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
|
||||
GITEA_TOKEN=<token> npm run check:quickstart # the install page still matches website's own files
|
||||
GITEA_TOKEN=<token> npm run check:reference # every name the Reference lists still exists
|
||||
npm run check:a11y # seven structural accessibility rules, every page
|
||||
npm run check:csp # every inline script and style is hashed in its policy
|
||||
npm run verify # all of the above, in that order
|
||||
```
|
||||
|
||||
@@ -81,6 +88,22 @@ carry are assembled from data files and template literals and a source scan sees
|
||||
also refuses a commit permalink into any org repository — those stop tracking the document they name
|
||||
without ever 404ing, which is the failure a link checker would otherwise call healthy.
|
||||
|
||||
**`checkA11y.mjs`** applies seven structural rules to every built page — one `<h1>` and no skipped
|
||||
heading level, an `alt` on every image, a label on every form control, an accessible name on every
|
||||
link and button, `<html lang>`, one `<main>` with a skip link that reaches it, and no positive
|
||||
`tabindex`. Structural on purpose: a static check cannot measure contrast on a rendered page or find
|
||||
a focus trap, and one that pretended to would be trusted for things it cannot see. It covers
|
||||
Starlight's forty pages as well as our ten, so a dependency upgrade that loses a label turns the
|
||||
build red rather than becoming a discovery.
|
||||
|
||||
**`checkCsp.mjs`** verifies that every route has a policy and that **every inline script and style is
|
||||
covered by a hash in its own page's policy**. That second rule is the one that earns its keep: Astro
|
||||
does not hash `<script is:inline>`, and Starlight ships six of them per documentation page, so the
|
||||
first build with CSP enabled had a strict, correct header and a dead theme switcher — a failure whose
|
||||
only symptom is a console message. When Starlight is upgraded and a hash stops matching,
|
||||
`npm run csp:hashes` rebuilds, re-harvests `src/config/cspHashes.mjs` and rebuilds again; read the
|
||||
diff before committing it, because that file is a list of scripts allowed to run.
|
||||
|
||||
**`npm test`** is the one check that reads none of the above. Everything else inspects built output,
|
||||
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
|
||||
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
|
||||
@@ -147,6 +170,40 @@ Regenerating the stock assets is a separate, manual step — `npm run brand:asse
|
||||
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
|
||||
committed so that CI never needs either.
|
||||
|
||||
## Security headers, and the one workaround in the server
|
||||
|
||||
`npm start` runs `scripts/applyBrand.mjs` and then `scripts/serve.mjs` — not
|
||||
`dist/server/entry.mjs` directly. `serve.mjs` is a thin wrapper around the adapter's own handler,
|
||||
and it exists for two reasons.
|
||||
|
||||
The first is a bug in `@astrojs/node`. Its `staticHeaders` option writes one Content-Security-Policy
|
||||
per prerendered route into `dist/_headers.json`, then looks the right one up per request with
|
||||
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a **substring** test taking the
|
||||
first match. So `/modules/` was served the policy built for `/docs/modules/building-a-module`,
|
||||
`/architecture/` got a docs page's, and `/`, being a substring of every path in the file, got
|
||||
whichever record came first. Because each policy is a list of per-page hashes, that is not a
|
||||
cosmetic mismatch: the browser refused the page's own stylesheet, and `/modules/` and
|
||||
`/architecture/` rendered unstyled with `Refused to apply inline style` in a console. The wrapper
|
||||
keeps the same `_headers.json` and matches by **equality**. It is deliberately small so it can be
|
||||
deleted whole once the upstream `find` is fixed; the test for that is whether `/modules/` and
|
||||
`/docs/modules/building-a-module` are served different policies.
|
||||
|
||||
The second is the handful of headers that have nothing to do with Astro: `X-Content-Type-Options`,
|
||||
`Referrer-Policy`, `X-Frame-Options` and a `Permissions-Policy` that turns off hardware this site has
|
||||
no reason to ask for. They are set in the container rather than written into an operator's
|
||||
reverse-proxy configuration, because the image should be correct on its own and a proxy somebody
|
||||
else configures is a promise this repository cannot check. The two routes that render per request —
|
||||
`/beta` and `/brand/*` — have no prerendered policy, so they get `frame-ancestors 'none'` on its own:
|
||||
the one directive a `<meta>` CSP cannot express, and therefore the one thing Astro's per-page meta
|
||||
tag leaves them missing.
|
||||
|
||||
`style-src-attr 'unsafe-inline'` is the single relaxation in the policy, and it is scoped to that
|
||||
directive. Starlight and Expressive Code write around 3,700 inline `style` attributes into the
|
||||
documentation — icon sizes, the theme select's width, and every syntax colour — which cannot be
|
||||
hashed, because CSP hashes cover `<style>` elements and never attributes. A style attribute cannot
|
||||
execute script, so this leaves `script-src`, the directive CSP exists for, untouched. The marketing
|
||||
pages emit no inline style attributes at all.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -170,9 +227,10 @@ src/
|
||||
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
|
||||
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
|
||||
config/sidebar.mjs The documentation journey, and the planned tree behind it.
|
||||
config/cspHashes.mjs GENERATED. Starlight's inline scripts, which Astro does not hash.
|
||||
brand-default/ The stock brand, baked into the image and always complete.
|
||||
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
|
||||
and beta.mjs (the tester-list CLI).
|
||||
scripts/ The build-time checks, plus applyBrand and serve (boot),
|
||||
brand:assets (manual) and beta.mjs (the tester-list CLI).
|
||||
test/ node --test. The logic the other checks cannot see.
|
||||
PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from
|
||||
src/data/collection.mjs. Edit the data, run npm run play:datasafety.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import starlight from '@astrojs/starlight';
|
||||
|
||||
import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs';
|
||||
import { docsSidebar } from './src/config/sidebar.mjs';
|
||||
|
||||
/**
|
||||
@@ -20,13 +21,72 @@ import { docsSidebar } from './src/config/sidebar.mjs';
|
||||
export default defineConfig({
|
||||
site: 'https://runicgateway.com',
|
||||
output: 'static',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
// `staticHeaders` is what turns §6's CSP from a promise into a response header (D48).
|
||||
// Without it the policy ships as a `<meta http-equiv>`, and a meta CSP silently ignores
|
||||
// `frame-ancestors` — the one directive that stops the site being framed. With it, the
|
||||
// build writes `_headers.json` next to the server entry and the standalone server sends
|
||||
// the policy as a real header on every prerendered route, so the operator's reverse proxy
|
||||
// needs no CSP configuration at all and cannot get it wrong.
|
||||
adapter: node({ mode: 'standalone', staticHeaders: true }),
|
||||
|
||||
build: {
|
||||
// Directory-style URLs, so every link in prose can end in a slash and mean it.
|
||||
format: 'directory',
|
||||
},
|
||||
|
||||
security: {
|
||||
csp: {
|
||||
directives: [
|
||||
// The whole posture in one line: nothing loads from anywhere but this origin.
|
||||
// §6 could promise this without exceptions because the fonts are self-hosted and
|
||||
// D9 rules out analytics — there is no CDN to whitelist and no beacon to allow.
|
||||
"default-src 'self'",
|
||||
// Not covered by `default-src`, and each one closes a specific door: no injected
|
||||
// `<base>` can re-point every relative URL on the page, the signup form can only
|
||||
// post to us, no plugin content at all, and the site cannot be framed. The last
|
||||
// of those is the reason `staticHeaders` is on.
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
// One `url(data:image/svg+xml)` survives bundling into the stylesheet. Data URLs
|
||||
// are a real (if small) exfiltration-free risk surface, so this is the only
|
||||
// relaxation of `default-src` on the image directive and it is scoped to images.
|
||||
"img-src 'self' data:",
|
||||
],
|
||||
scriptDirective: {
|
||||
resources: [
|
||||
"'self'",
|
||||
// Pagefind (D47) compiles its index with `WebAssembly.instantiate`, which a
|
||||
// strict `script-src` blocks outright — search silently returns nothing. This
|
||||
// permits WASM compilation *only*; it does not restore `eval`.
|
||||
"'wasm-unsafe-eval'",
|
||||
],
|
||||
// Starlight's own `is:inline` scripts, which Astro does not hash because it never
|
||||
// parses them. Generated — see src/config/cspHashes.mjs and `npm run check:csp`.
|
||||
hashes: inlineScriptHashes,
|
||||
},
|
||||
styleDirective: {
|
||||
// No `'self'` here, though `style-src` needs it and gets it: Astro's default
|
||||
// already supplies it, and naming it alongside an `attribute`-kind resource makes
|
||||
// the build warn — browsers do not fall back from `style-src-attr` to `style-src`,
|
||||
// so a `'self'` written here would apply to neither scope the author meant.
|
||||
resources: [
|
||||
// Starlight and Expressive Code write ~3,700 inline `style` attributes into the
|
||||
// documentation — icon sizing, the theme select's width, and every syntax
|
||||
// colour, which Expressive Code emits as custom properties on the element. They
|
||||
// cannot be hashed (CSP hashes cover `<style>` elements, never attributes), and
|
||||
// Astro's own docs record Shiki as incompatible with CSP for exactly this
|
||||
// reason. Scoped to `style-src-attr` deliberately: a style attribute cannot
|
||||
// execute script, so this leaves the directive CSP exists for — `script-src` —
|
||||
// untouched. The marketing pages emit zero inline style attributes.
|
||||
{ resource: "'unsafe-inline'", kind: 'attribute' },
|
||||
],
|
||||
hashes: inlineStyleHashes,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'Runic Gateway',
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"astro": "^7.2.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"pagefind": "^1.5.2",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node scripts/applyBrand.mjs && node ./dist/server/entry.mjs",
|
||||
"start": "node scripts/applyBrand.mjs && node scripts/serve.mjs",
|
||||
"check": "astro check",
|
||||
"check:facts": "node scripts/checkFacts.mjs",
|
||||
"check:tokens": "node scripts/checkTokens.mjs",
|
||||
@@ -23,12 +23,16 @@
|
||||
"check:reference": "node scripts/checkReference.mjs",
|
||||
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||
"check:screens": "node scripts/checkScreens.mjs",
|
||||
"check:a11y": "node scripts/checkA11y.mjs",
|
||||
"check:csp": "node scripts/checkCsp.mjs",
|
||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||
"beta": "node scripts/beta.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"screens:capture": "node scripts/captureScreens.mjs",
|
||||
"verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference"
|
||||
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
|
||||
"verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp",
|
||||
"test:served": "node --test test/headers.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.4",
|
||||
@@ -37,6 +41,7 @@
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"astro": "^7.2.4",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"pagefind": "^1.5.2",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -229,10 +229,71 @@ const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||
counts.set('demoDeep', 0);
|
||||
let filesTouched = 0;
|
||||
|
||||
/**
|
||||
* The CSP (§6, D48) hashes every inline `<script>` and `<style>` in the build. This script
|
||||
* runs after that hashing and rewrites the same files, so a brand value that happened to
|
||||
* sit inside an inline block would change its bytes, invalidate its hash and get the block
|
||||
* refused by the browser — with no error anywhere except a console nobody has open. The
|
||||
* page would render perfectly and the script simply would not run.
|
||||
*
|
||||
* Nothing puts brand text in an inline script today, and the replacements are guarded by
|
||||
* MIN_REWRITABLE_LENGTH so they are unlikely to collide by accident. "Unlikely" is not the
|
||||
* standard for a failure this quiet, so the collision is checked rather than reasoned
|
||||
* about: if a rewrite ever lands inside an inline block, this refuses to write that file
|
||||
* and says so, and the page keeps its stock text instead of losing its behaviour.
|
||||
*/
|
||||
const INLINE_BLOCK = /<(script|style)(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
|
||||
/**
|
||||
* The structured-data block (D50) is a `<script>` that no browser executes and no CSP hash
|
||||
* covers, so it is not one of the blocks this guard protects — and it MUST NOT be, because
|
||||
* it contains the site's name. Treating it as a script would make the guard refuse to
|
||||
* rewrite the homepage, which is §7 failing on the one page that matters most.
|
||||
*/
|
||||
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
|
||||
|
||||
const inlineRanges = (html) => {
|
||||
const ranges = [];
|
||||
INLINE_BLOCK.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = INLINE_BLOCK.exec(html))) {
|
||||
if (match[1] === 'script' && DATA_BLOCK.test(match[2])) continue;
|
||||
// Where the block's CONTENT starts — measured back from the end of the whole match, so
|
||||
// the opening tag's attributes cannot throw the offset off: `</script>` is the tag name
|
||||
// plus three characters.
|
||||
const closing = match[1].length + 3;
|
||||
const start = match.index + match[0].length - closing - match[3].length;
|
||||
ranges.push([start, start + match[3].length]);
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
const hitsInlineBlock = (html, needle) => {
|
||||
if (!needle || !html.includes(needle)) return false;
|
||||
const ranges = inlineRanges(html);
|
||||
if (ranges.length === 0) return false;
|
||||
for (let at = html.indexOf(needle); at !== -1; at = html.indexOf(needle, at + 1)) {
|
||||
const end = at + needle.length;
|
||||
if (ranges.some(([from, to]) => at < to && end > from)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const inlineCollisions = [];
|
||||
|
||||
for (const file of walk(CLIENT)) {
|
||||
const before = readFileSync(file, 'utf8');
|
||||
let after = before;
|
||||
|
||||
if (path.extname(file) === '.html') {
|
||||
const colliding = replacements.filter(({ from }) => hitsInlineBlock(before, from));
|
||||
if (colliding.length) {
|
||||
inlineCollisions.push({
|
||||
file: path.relative(CLIENT, file),
|
||||
fields: [...new Set(colliding.map((c) => c.field))],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { field, from, to } of replacements) {
|
||||
if (!after.includes(from)) continue;
|
||||
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
||||
@@ -270,6 +331,47 @@ if (counts.get('demoDeep')) {
|
||||
);
|
||||
}
|
||||
|
||||
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||
// lands; recorded here rather than in a plan section nobody will re-read.
|
||||
if (inlineCollisions.length) {
|
||||
console.error(
|
||||
`\n[brand] ${inlineCollisions.length} file(s) were LEFT UNCHANGED: a brand value occurs ` +
|
||||
`inside an inline <script> or <style>, and rewriting it would break that block's CSP ` +
|
||||
`hash (§6, D48) — the page would render and the script would silently not run.\n`
|
||||
);
|
||||
for (const { file, fields } of inlineCollisions) {
|
||||
console.error(` ! ${file} (${fields.join(', ')})`);
|
||||
}
|
||||
console.error(
|
||||
`\n Those pages keep the stock text. Fix it by taking the brand value out of the inline\n` +
|
||||
` block — move it into markup the CSP does not hash, or into /brand/theme.css.\n`
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Search
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Pagefind builds its index from the built HTML at BUILD time, so everything above reaches
|
||||
* the pages and none of it reaches the search results: a site renamed through the mount
|
||||
* would answer a search for its own name with the stock one, and every result title would
|
||||
* still carry the old suffix. Phase 2 recorded that and left it for this phase, when
|
||||
* search became site-wide (D47).
|
||||
*
|
||||
* The fix is to re-index, which is cheap and needs nothing the container does not already
|
||||
* have — Pagefind is what Starlight ran at build. It only runs when a rewrite actually
|
||||
* happened, so the stock deployment, which is the common case, still pays nothing.
|
||||
*/
|
||||
if (filesTouched > 0) {
|
||||
const pagefind = await import('pagefind');
|
||||
try {
|
||||
const { index } = await pagefind.createIndex();
|
||||
const { page_count } = await index.addDirectory({ path: CLIENT });
|
||||
await index.writeFiles({ outputPath: path.join(CLIENT, 'pagefind') });
|
||||
console.log(`[brand] re-indexed ${page_count} page(s) for search so results agree with it.`);
|
||||
} catch (error) {
|
||||
// Search degrading to stale titles is not a reason to refuse to serve the site.
|
||||
console.error(`[brand] could not rebuild the search index; it keeps the built one: ${error.message}`);
|
||||
} finally {
|
||||
await pagefind.close();
|
||||
}
|
||||
}
|
||||
|
||||
278
scripts/checkA11y.mjs
Normal file
278
scripts/checkA11y.mjs
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkA11y.mjs — PLAN.md §13 phase 10.
|
||||
*
|
||||
* Every other rule this repository cares about is enforced by a script — the facts, the
|
||||
* links, the tokens, the sidebar, the screenshots, the CSP. Accessibility was the exception:
|
||||
* it was a thing someone checked once, by hand, on the pages they happened to open. This
|
||||
* makes it the eleventh check so a regression fails a build instead of waiting for a reader
|
||||
* who cannot use the page and will not file an issue.
|
||||
*
|
||||
* node scripts/checkA11y.mjs
|
||||
*
|
||||
* ── What it checks, and why each one ────────────────────────────────────────
|
||||
* A static check cannot measure contrast against a rendered page or find a focus trap, and
|
||||
* pretending otherwise would be worse than not checking. What it CAN do is catch the class
|
||||
* of defect that is invisible to a sighted author and permanent once shipped:
|
||||
*
|
||||
* 1. **One `<h1>` per page, and no skipped heading level.** The heading tree is the
|
||||
* document outline a screen-reader user navigates by. Two `<h1>`s or an `<h2>` under
|
||||
* nothing reads as a page with no structure at all.
|
||||
* 2. **Every `<img>` has an `alt`.** Not "a non-empty alt": `alt=""` is correct and
|
||||
* deliberate for the header mark, which sits inside a link that already says the
|
||||
* product's name. A MISSING attribute is what makes a screen reader read the filename.
|
||||
* 3. **Every form control has a label.** `<label for>`, a wrapping `<label>`,
|
||||
* `aria-label` or `aria-labelledby`. The signup form is the only place on this site
|
||||
* where a person is asked to type something, so it is the one place this must hold.
|
||||
* 4. **Every link and button has an accessible name.** An icon-only control with no text
|
||||
* and no `aria-label` is announced as "link", which is no name at all. The search
|
||||
* button is icon-only under 46rem, which is exactly this hazard.
|
||||
* 5. **`<html lang>` is set**, or a screen reader reads English prose with whatever voice
|
||||
* the reader last used.
|
||||
* 6. **One `<main>` per page and a skip link that points at it.** The site's header is a
|
||||
* lockup, four links and a search box in front of every page; without a working skip
|
||||
* link a keyboard user walks all six on every navigation.
|
||||
* 7. **No positive `tabindex`.** It reorders the tab sequence away from the visual one
|
||||
* and is almost never what the author meant.
|
||||
*
|
||||
* Both chromes are checked — the marketing pages and Starlight's forty. Starlight is
|
||||
* generally careful, so the docs half is a regression alarm on a dependency rather than a
|
||||
* review of our own markup, and it has already earned its place once: it is what would have
|
||||
* caught the `<h2>`-without-`<h1>` shape if a docs page had ever lost its title.
|
||||
*
|
||||
* No token and no network: everything read here is in `dist/`.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const clientDir = path.join(root, 'dist', 'client');
|
||||
|
||||
if (!fs.existsSync(clientDir)) {
|
||||
console.error('\ncheckA11y: dist/client does not exist. Run `npm run build` first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const fail = (page, what, detail) => failures.push({ page, what, detail });
|
||||
|
||||
const pages = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.html')) pages.push(full);
|
||||
}
|
||||
};
|
||||
walk(clientDir);
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
A very small amount of HTML reading
|
||||
|
||||
Not a parser. Everything below is a tag-level question — does this element carry this
|
||||
attribute, what text sits between these two tags — and a regex answers those on
|
||||
generated, well-formed output. A DOM parser would be a dependency, and this repository's
|
||||
checks are dependency-free on purpose (§12): the reader runs them the same way CI does.
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
/** Takes the ATTRIBUTE STRING — what is between the tag name and the `>` — not the tag. */
|
||||
const attrs = (attrString) => {
|
||||
const found = new Map();
|
||||
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString))) {
|
||||
found.set(m[1].toLowerCase(), m[2] ?? m[3] ?? m[4] ?? '');
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Text a screen reader would announce: markup and comments stripped, entities loosened. */
|
||||
const textOf = (html) =>
|
||||
html
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/&[a-zA-Z#0-9]+;/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** An element's own accessible name, near enough for "is there one at all". */
|
||||
const named = (tag, inner) => {
|
||||
const a = attrs(tag);
|
||||
if (a.get('aria-label')?.trim()) return true;
|
||||
if (a.get('aria-labelledby')?.trim()) return true;
|
||||
if (a.get('title')?.trim()) return true;
|
||||
if (textOf(inner)) return true;
|
||||
// An image child with alt text names the control.
|
||||
for (const img of inner.matchAll(/<img\b([^>]*)>/gi)) {
|
||||
if (attrs(img[1]).get('alt')?.trim()) return true;
|
||||
}
|
||||
// An SVG with a title element does too.
|
||||
if (/<svg\b[^>]*>[\s\S]*?<title\b[^>]*>[^<]+<\/title>/i.test(inner)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const file of pages) {
|
||||
const page = '/' + path.relative(clientDir, file).replace(/\\/g, '/');
|
||||
|
||||
/**
|
||||
* Comments are stripped before anything is counted, and that is not a nicety: this
|
||||
* repository comments its markup heavily, and several of those comments quote the tags
|
||||
* they are explaining. `Base.astro`'s note about `data-pagefind-body` contains the text
|
||||
* "<main>", and the first run of this check reported every marketing page as having two
|
||||
* `<main>` landmarks because of it. Stripping once, up front, also keeps every offset
|
||||
* below measured against the same string.
|
||||
*/
|
||||
const html = fs.readFileSync(file, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// ── 5. lang ───────────────────────────────────────────────────────────────
|
||||
const htmlTag = /<html\b([^>]*)>/i.exec(html);
|
||||
if (!htmlTag) fail(page, '<html>', 'has no <html> element');
|
||||
else if (!attrs(htmlTag[1]).get('lang')?.trim()) fail(page, '<html>', 'has no lang attribute');
|
||||
|
||||
// ── 1. headings ───────────────────────────────────────────────────────────
|
||||
const headings = [...html.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)]
|
||||
// `aria-hidden` headings are decorative and out of the outline by definition.
|
||||
.filter((m) => attrs(m[2]).get('aria-hidden') !== 'true')
|
||||
.map((m) => ({ level: Number(m[1]), text: textOf(m[3]) }));
|
||||
|
||||
const h1s = headings.filter((h) => h.level === 1);
|
||||
if (h1s.length === 0) fail(page, 'headings', 'has no <h1>');
|
||||
if (h1s.length > 1) {
|
||||
fail(page, 'headings', `has ${h1s.length} <h1>s: ${h1s.map((h) => JSON.stringify(h.text)).join(', ')}`);
|
||||
}
|
||||
|
||||
let previous = 0;
|
||||
for (const heading of headings) {
|
||||
if (previous && heading.level > previous + 1) {
|
||||
fail(
|
||||
page,
|
||||
'headings',
|
||||
`jumps from h${previous} to h${heading.level} at ${JSON.stringify(heading.text.slice(0, 50))}`,
|
||||
);
|
||||
}
|
||||
previous = heading.level;
|
||||
}
|
||||
|
||||
// ── 2. images ─────────────────────────────────────────────────────────────
|
||||
for (const img of html.matchAll(/<img\b([^>]*)>/gi)) {
|
||||
const a = attrs(img[1]);
|
||||
if (!a.has('alt')) {
|
||||
fail(page, '<img>', `has no alt attribute: src=${a.get('src') ?? '(none)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. form controls ──────────────────────────────────────────────────────
|
||||
const labelledIds = new Set(
|
||||
[...html.matchAll(/<label\b([^>]*)>/gi)]
|
||||
.map((m) => attrs(m[1]).get('for'))
|
||||
.filter(Boolean),
|
||||
);
|
||||
/**
|
||||
* A control wrapped in its own `<label>` needs no `for` and — this is the part that took
|
||||
* a wrong answer to find — needs no `id` either, so it cannot be recorded by id. Starlight
|
||||
* labels its theme and language selects exactly this way. What is recorded instead is the
|
||||
* character offset of each wrapped control, which identifies it uniquely without
|
||||
* requiring it to have any attributes at all.
|
||||
*/
|
||||
const wrappedAt = new Set();
|
||||
for (const label of html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/gi)) {
|
||||
const base = label.index + label[0].indexOf(label[1]);
|
||||
for (const control of label[1].matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
|
||||
wrappedAt.add(base + control.index);
|
||||
}
|
||||
}
|
||||
|
||||
for (const control of html.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
|
||||
const a = attrs(control[2]);
|
||||
const type = (a.get('type') ?? 'text').toLowerCase();
|
||||
// These are not things a person types into and are named by other means.
|
||||
if (['hidden', 'submit', 'button', 'reset', 'image'].includes(type)) continue;
|
||||
|
||||
const id = a.get('id');
|
||||
const hasLabel =
|
||||
wrappedAt.has(control.index) ||
|
||||
(id && labelledIds.has(id)) ||
|
||||
a.get('aria-label')?.trim() ||
|
||||
a.get('aria-labelledby')?.trim() ||
|
||||
a.get('title')?.trim();
|
||||
|
||||
if (!hasLabel) {
|
||||
fail(
|
||||
page,
|
||||
`<${control[1]}>`,
|
||||
`has no label: ${id ? `id="${id}"` : `name="${a.get('name') ?? '(none)'}"`} — ` +
|
||||
'a placeholder is not a label',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. link and button names ──────────────────────────────────────────────
|
||||
for (const [, tag, attrString, inner] of html.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
||||
const a = attrs(attrString);
|
||||
if (a.get('aria-hidden') === 'true') continue;
|
||||
// An <a> with no href is not a link; it is a target for one.
|
||||
if (tag.toLowerCase() === 'a' && !a.has('href')) continue;
|
||||
if (named(attrString, inner)) continue;
|
||||
|
||||
fail(
|
||||
page,
|
||||
`<${tag}>`,
|
||||
`has no accessible name: ${a.get('href') ? `href="${a.get('href')}"` : `class="${a.get('class') ?? ''}"`}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 6. main and the skip link ─────────────────────────────────────────────
|
||||
const mains = [...html.matchAll(/<main\b([^>]*)>/gi)];
|
||||
if (mains.length === 0) fail(page, '<main>', 'has no <main> landmark');
|
||||
if (mains.length > 1) fail(page, '<main>', `has ${mains.length} <main> elements`);
|
||||
|
||||
const skip = /<a\b([^>]*class="[^"]*skip-link[^"]*"[^>]*)>/i.exec(html);
|
||||
if (skip) {
|
||||
const target = attrs(skip[1]).get('href') ?? '';
|
||||
if (!target.startsWith('#')) {
|
||||
fail(page, 'skip link', `points at ${JSON.stringify(target)}, which is not an in-page anchor`);
|
||||
} else {
|
||||
const id = target.slice(1);
|
||||
if (!new RegExp(`\\bid=["']${id}["']`).test(html)) {
|
||||
fail(page, 'skip link', `points at #${id}, and nothing on the page has that id`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. positive tabindex ──────────────────────────────────────────────────
|
||||
for (const m of html.matchAll(/\btabindex\s*=\s*["']?(-?\d+)/gi)) {
|
||||
if (Number(m[1]) > 0) {
|
||||
fail(page, 'tabindex', `is ${m[1]} — a positive tabindex reorders the tab sequence`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Report
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkA11y: ${pages.length} built pages pass all seven structural checks.`);
|
||||
} else {
|
||||
// Grouped by page: a shared component's defect otherwise prints fifty times and buries
|
||||
// the one page that has a real problem of its own.
|
||||
const byPage = new Map();
|
||||
for (const f of failures) {
|
||||
if (!byPage.has(f.page)) byPage.set(f.page, []);
|
||||
byPage.get(f.page).push(f);
|
||||
}
|
||||
|
||||
console.error(`\ncheckA11y: ${failures.length} problem(s) across ${byPage.size} page(s):\n`);
|
||||
for (const [page, items] of byPage) {
|
||||
console.error(` ${page}`);
|
||||
for (const item of items) console.error(` ✗ ${item.what}: ${item.detail}`);
|
||||
}
|
||||
console.error(`
|
||||
These are structural, so they are the same in every browser and for every reader. A defect
|
||||
repeated across many pages is usually one shared component — fix it there rather than on
|
||||
each page.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
263
scripts/checkCsp.mjs
Normal file
263
scripts/checkCsp.mjs
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkCsp.mjs — PLAN.md §6 and D48, added in phase 10.
|
||||
*
|
||||
* §6 promises "a strict CSP with no external origins". D48 decided that promise should be
|
||||
* a real response header sent by the container itself, not a `<meta>` (which ignores
|
||||
* `frame-ancestors`) and not advice in an operator's proxy config (which lives outside the
|
||||
* artifact we ship and test). `astro.config.mjs` sets it up; this checks it arrived.
|
||||
*
|
||||
* node scripts/checkCsp.mjs # verify the built output
|
||||
* node scripts/checkCsp.mjs --write # rewrite src/config/cspHashes.mjs from the build
|
||||
* node scripts/checkCsp.mjs --reset # empty it, so the next harvest starts from nothing
|
||||
*
|
||||
* Three things are checked, and each one has already been wrong once:
|
||||
*
|
||||
* 1. **Every built route has a policy.** `staticHeaders` writes `dist/_headers.json`; a
|
||||
* route missing from it is a page served with no CSP at all, which is the failure mode
|
||||
* nobody notices because the page looks perfect.
|
||||
*
|
||||
* 2. **Every inline script and style is covered by its page's own policy.** This is the
|
||||
* real check. Astro does not hash `<script is:inline>`, and Starlight ships six of
|
||||
* them per documentation page — so the first build with CSP on had a strict, correct
|
||||
* header and a dead theme switcher. Hashing is verified per page against that page's
|
||||
* header, not against a global list, because that is what the browser does.
|
||||
*
|
||||
* 3. **The directives §6 actually promised are present.** A policy that lost
|
||||
* `frame-ancestors` in a refactor still passes checks 1 and 2 while no longer stopping
|
||||
* anything.
|
||||
*
|
||||
* `--write` harvests the hashes from check 2 into `src/config/cspHashes.mjs`, which
|
||||
* `astro.config.mjs` feeds back into the next build. So the sequence is reset → build →
|
||||
* write → build → verify, which is what `npm run csp:hashes` runs. It resets first because
|
||||
* harvesting only ever collects what the build did NOT cover — see `--reset` below.
|
||||
*
|
||||
* No token and no network: everything read here is in `dist/`.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const headersFile = path.join(root, 'dist', '_headers.json');
|
||||
const clientDir = path.join(root, 'dist', 'client');
|
||||
const hashesFile = path.join(root, 'src', 'config', 'cspHashes.mjs');
|
||||
|
||||
const write = process.argv.includes('--write');
|
||||
const reset = process.argv.includes('--reset');
|
||||
|
||||
const failures = [];
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/**
|
||||
* Rewrites the two exported arrays in `src/config/cspHashes.mjs`, leaving every comment and
|
||||
* the JSDoc types above them untouched.
|
||||
*/
|
||||
const writeHashes = (script, style) => {
|
||||
const source = fs.readFileSync(hashesFile, 'utf8');
|
||||
const list = (hashes) =>
|
||||
hashes.size === 0 ? '[]' : `[\n${[...hashes].sort().map((h) => ` '${h}',`).join('\n')}\n]`;
|
||||
|
||||
fs.writeFileSync(
|
||||
hashesFile,
|
||||
source
|
||||
.replace(
|
||||
/export const inlineScriptHashes = [\s\S]*?;\n/,
|
||||
`export const inlineScriptHashes = ${list(script)};\n`,
|
||||
)
|
||||
.replace(
|
||||
/export const inlineStyleHashes = [\s\S]*?;\n/,
|
||||
`export const inlineStyleHashes = ${list(style)};\n`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* `--reset` empties the generated file, and `npm run csp:hashes` runs it FIRST.
|
||||
*
|
||||
* Without it the regeneration is not idempotent, and its failure mode is the worst
|
||||
* available: harvesting collects the blocks the build did not cover, so running it against
|
||||
* a build that is already correct finds nothing, writes two empty arrays and produces a
|
||||
* build with no hashes at all. Emptying first means the harvest always sees the same thing
|
||||
* — every inline block Astro does not hash on its own — whatever state the file was in.
|
||||
*/
|
||||
if (reset) {
|
||||
writeHashes(new Set(), new Set());
|
||||
console.log('checkCsp --reset: src/config/cspHashes.mjs emptied, ready to re-harvest.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── The build has to be there ───────────────────────────────────────────────
|
||||
if (!fs.existsSync(headersFile)) {
|
||||
console.error(`
|
||||
checkCsp: dist/_headers.json does not exist.
|
||||
|
||||
That file is written by the Node adapter's \`staticHeaders\` option, so either the build
|
||||
has not run (\`npm run build\`) or \`staticHeaders\` was turned off in astro.config.mjs —
|
||||
in which case the CSP is a <meta> tag and \`frame-ancestors\` is being ignored (D48).
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `_headers.json` is keyed by an internal route id, so the pathname lives in the record.
|
||||
* Normalised without a trailing slash: the file says `/docs/first-run`, the built page is
|
||||
* at `docs/first-run/index.html`, and `build.format: 'directory'` serves it at
|
||||
* `/docs/first-run/`.
|
||||
*/
|
||||
const byPath = new Map();
|
||||
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
|
||||
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
|
||||
byPath.set(record.pathname.replace(/\/$/, '') || '/', csp?.value ?? null);
|
||||
}
|
||||
|
||||
// ── Walk the built HTML ─────────────────────────────────────────────────────
|
||||
const pages = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name === 'index.html' || entry.name.endsWith('.html')) pages.push(full);
|
||||
}
|
||||
};
|
||||
walk(clientDir);
|
||||
|
||||
/**
|
||||
* Inline only: anything with a `src` is a fetched file and is covered by `'self'`.
|
||||
* The body is hashed exactly as written, because that is what the browser hashes — one
|
||||
* byte of whitespace either side changes the digest.
|
||||
*/
|
||||
const INLINE_SCRIPT = /<script(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/g;
|
||||
const INLINE_STYLE = /<style([^>]*)>([\s\S]*?)<\/style>/g;
|
||||
|
||||
/**
|
||||
* `<script type="application/ld+json">` (D50) is a data block, not code: the browser never
|
||||
* executes it, and CSP's script-src is not enforced against it. Demanding a hash for one
|
||||
* would be wrong twice over — it would add the structured data's own text to the list of
|
||||
* scripts allowed to run, and that text changes whenever a fact or the brand name does, so
|
||||
* the generated hash file would churn on edits that cannot affect security.
|
||||
*/
|
||||
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
|
||||
|
||||
const sha256 = (body) => `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
|
||||
|
||||
const harvested = { script: new Set(), style: new Set() };
|
||||
let inlineScripts = 0;
|
||||
let inlineStyles = 0;
|
||||
let uncovered = 0;
|
||||
|
||||
for (const file of pages) {
|
||||
const rel = path.relative(clientDir, file).replace(/\\/g, '/');
|
||||
const pathname = '/' + rel.replace(/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
|
||||
const csp = byPath.get(pathname === '/' ? '/' : pathname.replace(/\/$/, ''));
|
||||
|
||||
if (csp === undefined) {
|
||||
fail(pathname, 'is a built page with no entry in dist/_headers.json — it ships with no CSP');
|
||||
continue;
|
||||
}
|
||||
if (csp === null) {
|
||||
fail(pathname, 'has an entry in dist/_headers.json but no Content-Security-Policy header');
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
|
||||
for (const [kind, re, counter] of [
|
||||
['script', INLINE_SCRIPT, 'inlineScripts'],
|
||||
['style', INLINE_STYLE, 'inlineStyles'],
|
||||
]) {
|
||||
re.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(html))) {
|
||||
const [, attrs, body] = match;
|
||||
if (kind === 'script' && DATA_BLOCK.test(attrs)) continue;
|
||||
// An empty inline block needs no hash; browsers do not enforce one.
|
||||
if (body.trim() === '') continue;
|
||||
if (counter === 'inlineScripts') inlineScripts++;
|
||||
else inlineStyles++;
|
||||
|
||||
const hash = sha256(body);
|
||||
if (csp.includes(hash)) continue;
|
||||
|
||||
uncovered++;
|
||||
harvested[kind].add(hash);
|
||||
if (!write) {
|
||||
fail(
|
||||
`${pathname} (inline <${kind}>)`,
|
||||
`${hash} is not in that page's policy — ${JSON.stringify(body.trim().slice(0, 60))}…`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The directives §6 promised, on a page that has to have them ─────────────
|
||||
const REQUIRED = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
];
|
||||
const home = byPath.get('/');
|
||||
if (!home) {
|
||||
fail('/', 'the homepage has no CSP header at all');
|
||||
} else {
|
||||
for (const directive of REQUIRED) {
|
||||
if (!home.includes(directive)) fail('/ policy', `is missing "${directive}" (PLAN.md §6)`);
|
||||
}
|
||||
// The point of the whole exercise: a hash and 'unsafe-inline' in the same script
|
||||
// directive means browsers ignore 'unsafe-inline' — but if the hashes ever went away it
|
||||
// would quietly start applying.
|
||||
const scriptSrc = /script-src ([^;]*)/.exec(home)?.[1] ?? '';
|
||||
if (scriptSrc.includes("'unsafe-inline'")) {
|
||||
fail('/ policy', "script-src contains 'unsafe-inline' — D48 says the hashes carry this");
|
||||
}
|
||||
if (scriptSrc.includes("'unsafe-eval'")) {
|
||||
fail('/ policy', "script-src contains 'unsafe-eval' ('wasm-unsafe-eval' is the intended one)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── --write: regenerate the hash file ───────────────────────────────────────
|
||||
if (write) {
|
||||
writeHashes(harvested.script, harvested.style);
|
||||
console.log(
|
||||
`checkCsp --write: harvested ${harvested.script.size} script and ${harvested.style.size} ` +
|
||||
`style hash(es) from ${pages.length} pages into src/config/cspHashes.mjs.`,
|
||||
);
|
||||
if (failures.length) {
|
||||
console.error('\ncheckCsp --write: the build is still wrong in ways hashes cannot fix:\n');
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Now rebuild so the next build embeds them (npm run csp:hashes does both).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(
|
||||
`checkCsp: ${pages.length} pages carry a policy; ` +
|
||||
`${inlineScripts} inline script(s) and ${inlineStyles} inline style(s) are all hashed.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`\ncheckCsp: ${failures.length} problem(s) with the Content-Security-Policy:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
if (uncovered) {
|
||||
console.error(`
|
||||
${uncovered} inline block(s) are not covered by a hash. In a browser this is silent: the
|
||||
page renders and the script simply never runs — Starlight's theme switch and mobile
|
||||
sidebar are inline scripts, so this is how the documentation loses them.
|
||||
|
||||
If the inline block is legitimate (usually: Starlight was upgraded), run
|
||||
|
||||
npm run csp:hashes
|
||||
|
||||
which rebuilds, harvests the hashes into src/config/cspHashes.mjs and rebuilds again.
|
||||
Read what changed before committing it — that file is a list of scripts allowed to run.
|
||||
`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
145
scripts/serve.mjs
Normal file
145
scripts/serve.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* serve.mjs — the production entry point (PLAN.md §6, D48).
|
||||
*
|
||||
* `npm start` runs `applyBrand.mjs` and then this, instead of `dist/server/entry.mjs`
|
||||
* directly. It is a thin wrapper around the adapter's own handler and exists for two
|
||||
* reasons, one of them a bug in a dependency.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. THE ADAPTER SERVES THE WRONG PAGE'S CONTENT-SECURITY-POLICY
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `@astrojs/node`'s `staticHeaders` writes one policy per prerendered route into
|
||||
* `dist/_headers.json` and looks the right one up per request. The lookup, in
|
||||
* `dist/serve-static.js`, is:
|
||||
*
|
||||
* headersMap.find((header) => header.pathname.includes(baselessPathname))
|
||||
*
|
||||
* `String.includes` — a SUBSTRING test, not equality, taking the first match. So:
|
||||
*
|
||||
* - `/modules/` matches the record for `/docs/modules/building-a-module`,
|
||||
* - `/architecture/` matches `/docs/architecture/...`,
|
||||
* - and `/`, which is a substring of every path in the file, matches whichever record
|
||||
* happens to be first — here `/404`.
|
||||
*
|
||||
* Every prerendered page was therefore served some other page's policy. Because the
|
||||
* policies are per-page hash lists, that is not a cosmetic mismatch: the browser refused
|
||||
* the page's own stylesheet. `/modules/` and `/architecture/` rendered unstyled sections
|
||||
* with `Refused to apply inline style` in a console, and the homepage only looked fine
|
||||
* because it happens to share a hash with the 404 page.
|
||||
*
|
||||
* Astro's static-header machinery is otherwise exactly what §6 wants, so this replaces the
|
||||
* lookup rather than the mechanism: the same `_headers.json`, matched by pathname
|
||||
* EQUALITY. The workaround is deliberately small and obvious so it can be deleted whole
|
||||
* when the upstream `find` is fixed — the check for that is whether `/modules/` and
|
||||
* `/docs/modules/building-a-module` are served different policies.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 2. THE HEADERS THAT ARE NOT CSP
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A few security headers have nothing to do with Astro and no other place to live. They
|
||||
* are set here rather than written into an operator's reverse-proxy configuration (D48,
|
||||
* again): the container should be correct on its own, and a proxy someone else configures
|
||||
* is a promise this repository cannot check.
|
||||
*
|
||||
* The two routes that render per request — `/beta` and `/brand/*` — have no entry in
|
||||
* `_headers.json`, because nothing prerendered them. They get `frame-ancestors 'none'` on
|
||||
* its own, which is the one directive a `<meta>` CSP cannot express and therefore the one
|
||||
* thing Astro's per-page meta tag leaves them missing.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.join(here, '..');
|
||||
|
||||
const port = Number(process.env.PORT ?? 4321);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
The policies, matched exactly
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
const normalise = (pathname) => {
|
||||
const clean = pathname.split('?')[0].split('#')[0];
|
||||
const trimmed = clean.replace(/\/+$/, '');
|
||||
return trimmed === '' ? '/' : trimmed;
|
||||
};
|
||||
|
||||
const policies = new Map();
|
||||
const headersFile = path.join(root, 'dist', '_headers.json');
|
||||
|
||||
if (fs.existsSync(headersFile)) {
|
||||
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
|
||||
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
|
||||
if (csp) policies.set(normalise(record.pathname), csp.value);
|
||||
}
|
||||
} else {
|
||||
// Not fatal: the site still serves, with the per-page <meta> policy Astro also emits.
|
||||
// Loud, because a deployment silently losing its response-header CSP is exactly what §6
|
||||
// is trying to prevent.
|
||||
console.error(
|
||||
'[serve] dist/_headers.json is missing — pages will be served without a CSP response\n' +
|
||||
' header. Check that astro.config.mjs still sets `staticHeaders: true`.'
|
||||
);
|
||||
}
|
||||
|
||||
const FRAME_ONLY = "frame-ancestors 'none'";
|
||||
|
||||
/**
|
||||
* Headers with no page-by-page component. Each is the browser default made explicit, and
|
||||
* each closes something the CSP does not:
|
||||
*
|
||||
* - `X-Content-Type-Options` stops a browser guessing that a .txt is HTML.
|
||||
* - `Referrer-Policy` keeps the path of the page a reader came from out of requests to
|
||||
* other origins — there are none today (D9), and this is what keeps that true if a
|
||||
* link is ever followed off-site.
|
||||
* - `X-Frame-Options` says again, for anything too old to honour `frame-ancestors`.
|
||||
* - `Permissions-Policy` turns off hardware this site has no reason to ask for. A
|
||||
* marketing page requesting a camera should be impossible, not merely unlikely.
|
||||
*/
|
||||
const STATIC_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
The server
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
// The adapter's entry starts its own listener on import unless this is set.
|
||||
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
|
||||
|
||||
// `pathToFileURL`, not the bare path: on Windows an absolute path starts with a drive
|
||||
// letter, and Node's ESM loader reads `c:` as an unsupported URL scheme.
|
||||
const { handler } = await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry.mjs')).href);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const policy = policies.get(normalise(req.url ?? '/'));
|
||||
|
||||
for (const [key, value] of Object.entries(STATIC_HEADERS)) res.setHeader(key, value);
|
||||
res.setHeader('Content-Security-Policy', policy ?? FRAME_ONLY);
|
||||
|
||||
/**
|
||||
* The adapter will set its own (wrong) `Content-Security-Policy` from inside the static
|
||||
* handler, overwriting what was just set. Rather than race it, every later attempt to
|
||||
* set that one header is ignored — the correct value is already on the response, and
|
||||
* this request's policy cannot change halfway through serving it.
|
||||
*/
|
||||
const setHeader = res.setHeader.bind(res);
|
||||
res.setHeader = (name, value) => {
|
||||
if (String(name).toLowerCase() === 'content-security-policy') return res;
|
||||
return setHeader(name, value);
|
||||
};
|
||||
|
||||
handler(req, res);
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[serve] listening on http://${host}:${port} — ${policies.size} prerendered policies`);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import Search from './Search.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,8 @@ const isCurrent = (href: string) =>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<Search />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
279
src/components/Search.astro
Normal file
279
src/components/Search.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
/**
|
||||
* Site search for the marketing pages (D47).
|
||||
*
|
||||
* The documentation has had search since phase 1 — Starlight builds a Pagefind index at the
|
||||
* end of every build and puts a box in its own header. The marketing pages were outside it
|
||||
* twice over: not indexed, so a reader searching "Teams" in the docs found the architecture
|
||||
* page and never the feature page; and with no box, so a reader who arrived on the homepage
|
||||
* had a four-item nav and no way to ask a question.
|
||||
*
|
||||
* D47 closed both. `Base.astro` marks its `<main>` as a Pagefind body, which puts the ten
|
||||
* marketing pages in the same index the docs already query, and this is the box.
|
||||
*
|
||||
* ── Why it is built this way ────────────────────────────────────────────────
|
||||
* The marketing pages ship almost no JavaScript, and Pagefind's own UI bundle is 120 kB
|
||||
* before the index and the WASM. Loading that on a homepage so that some visitors can
|
||||
* search would be a poor trade, so **nothing is fetched until the dialog is opened** —
|
||||
* the button is inert markup, and the first open injects the stylesheet and the script.
|
||||
* Opening search a second time costs nothing more.
|
||||
*
|
||||
* `<dialog>` rather than a hand-built overlay: the browser gives us the focus trap, the
|
||||
* inert background, Escape-to-close and the top layer for free, and every one of those is
|
||||
* a thing an accessibility pass would otherwise have to find missing.
|
||||
*
|
||||
* In `astro dev` there is no `/pagefind/` — the index is written by the build. Rather than
|
||||
* fail silently, the dialog says so.
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="site-search">
|
||||
<button type="button" class="site-search__open" data-search-open aria-haspopup="dialog">
|
||||
<svg aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16">
|
||||
<circle cx="9" cy="9" r="6" fill="none" stroke="currentColor" stroke-width="2"></circle>
|
||||
<line x1="13.5" y1="13.5" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</button>
|
||||
|
||||
<dialog class="site-search__dialog" data-search-dialog aria-label="Search this site">
|
||||
<div class="site-search__panel">
|
||||
<div class="site-search__head">
|
||||
<h2 class="site-search__title">Search</h2>
|
||||
<button type="button" class="site-search__close" data-search-close>Close</button>
|
||||
</div>
|
||||
<div data-search-mount></div>
|
||||
<p class="site-search__note" data-search-note hidden>
|
||||
Search is built with the site, so it is not available in the dev server. Run
|
||||
<code>npm run build && npm start</code> to try it.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-search-dialog]');
|
||||
const mount = document.querySelector<HTMLElement>('[data-search-mount]');
|
||||
const note = document.querySelector<HTMLElement>('[data-search-note]');
|
||||
|
||||
if (dialog && mount) {
|
||||
let loaded: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Pagefind's UI bundle is an IIFE that hangs `PagefindUI` off `window`, so it is a
|
||||
* `<script src>` and not a dynamic `import()`. Both are covered by `script-src 'self'`
|
||||
* (D48); the WASM the index needs is why that directive also carries
|
||||
* `'wasm-unsafe-eval'`.
|
||||
*/
|
||||
const load = () =>
|
||||
(loaded ??= new Promise<void>((resolve, reject) => {
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = '/pagefind/pagefind-ui.css';
|
||||
document.head.append(css);
|
||||
|
||||
const js = document.createElement('script');
|
||||
js.src = '/pagefind/pagefind-ui.js';
|
||||
js.onload = () => {
|
||||
new (window as any).PagefindUI({
|
||||
element: mount,
|
||||
showSubResults: true,
|
||||
showImages: false,
|
||||
// `resetStyles: false` was tried and is wrong here. Pagefind's reset is what
|
||||
// styles its own input and buttons; without it they fall back to user-agent
|
||||
// defaults, which on this ground meant black text typed into a dark field and
|
||||
// a Clear button with a 1990s `outset` border. The palette is bound to our
|
||||
// tokens below instead, which is the supported way round.
|
||||
translations: {
|
||||
placeholder: 'Search the site and documentation',
|
||||
zero_results: 'Nothing found for [SEARCH_TERM]',
|
||||
},
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
js.onerror = () => reject(new Error('pagefind-ui.js did not load'));
|
||||
document.head.append(js);
|
||||
}).catch((error) => {
|
||||
// A dev server, or a build served without its index. Say which.
|
||||
if (note) note.hidden = false;
|
||||
loaded = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
const open = () => {
|
||||
// Deliberately not awaited: the dialog should appear at once and fill in, rather
|
||||
// than the button seeming dead for as long as the bundle takes.
|
||||
load().catch(() => {});
|
||||
if (!dialog.open) dialog.showModal();
|
||||
window.setTimeout(() => {
|
||||
dialog.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('[data-search-open]')
|
||||
.forEach((button) => button.addEventListener('click', open));
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('[data-search-close]')
|
||||
.forEach((button) => button.addEventListener('click', () => dialog.close()));
|
||||
|
||||
// Clicking the backdrop closes it. `<dialog>` reports backdrop clicks as clicks on the
|
||||
// dialog itself, so the test is whether the click landed outside the panel's box.
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target !== dialog) return;
|
||||
const box = dialog.getBoundingClientRect();
|
||||
const outside =
|
||||
event.clientX < box.left ||
|
||||
event.clientX > box.right ||
|
||||
event.clientY < box.top ||
|
||||
event.clientY > box.bottom;
|
||||
if (outside) dialog.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* `/` and Ctrl/⌘-K, the two the documentation already answers to — the shortcut a
|
||||
* reader learns in the docs should work on the way back out.
|
||||
*/
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (dialog.open) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing =
|
||||
target?.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '');
|
||||
if (typing) return;
|
||||
|
||||
if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.site-search__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-search__open:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
.site-search__open kbd {
|
||||
padding: 0 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Narrow viewports get the icon alone: the header has a lockup and four links to fit,
|
||||
and "Search" beside a magnifier is the word the icon already says. */
|
||||
@media (max-width: 46rem) {
|
||||
.site-search__open span,
|
||||
.site-search__open kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-search__open {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.site-search__dialog {
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
margin-inline: auto;
|
||||
margin-block-start: min(12vh, 6rem);
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-search__dialog::backdrop {
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.site-search__panel {
|
||||
padding: 1.1rem 1.25rem 1.4rem;
|
||||
}
|
||||
|
||||
.site-search__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.site-search__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.site-search__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-search__close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-search__note {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Pagefind ships its own palette; these bind it to the site's tokens so the dialog is
|
||||
not a differently-coloured window sitting on the page. */
|
||||
.site-search__panel :global(.pagefind-ui) {
|
||||
--pagefind-ui-primary: var(--ink);
|
||||
--pagefind-ui-text: var(--ink);
|
||||
--pagefind-ui-background: var(--panel-flat);
|
||||
--pagefind-ui-border: var(--line);
|
||||
--pagefind-ui-tag: var(--bg);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
--pagefind-ui-border-radius: var(--radius-input);
|
||||
--pagefind-ui-font: inherit;
|
||||
}
|
||||
|
||||
.site-search__panel :global(.pagefind-ui__result-link) {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* The match highlight. Pagefind marks matched terms with <mark>, and the user-agent
|
||||
default for that is black on pure yellow — legible, and a hole punched through the
|
||||
palette on every result. Gold at low opacity reads as a highlight against this ground
|
||||
without becoming the loudest thing on the page.
|
||||
|
||||
`.pagefind-ui--reset` is in the selector because Pagefind's reset declares
|
||||
`.pagefind-ui--reset mark { all: revert }`, which is the same specificity as a plain
|
||||
descendant rule and is injected after this stylesheet — so it won on order and put the
|
||||
yellow back. One more class is enough; `!important` is not needed and would be a worse
|
||||
way to say the same thing. */
|
||||
.site-search__panel :global(.pagefind-ui--reset mark) {
|
||||
background: color-mix(in srgb, var(--gold) 26%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
73
src/components/StructuredData.astro
Normal file
73
src/components/StructuredData.astro
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
import platform from '../data/platform.json';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* Structured data for the homepage (D50, phase 10).
|
||||
*
|
||||
* Two blocks and no more. `Organization` so the project's name resolves to an entity with a
|
||||
* mark and a support channel rather than to whichever page happens to rank; and
|
||||
* `SoftwareApplication` because what the site describes is software someone installs, and
|
||||
* the licence and platform are facts a search result can usefully carry.
|
||||
*
|
||||
* ── What this deliberately is not ───────────────────────────────────────────
|
||||
* It carries no ratings, no counts, no price, no `aggregateRating` — the vocabulary is
|
||||
* full of fields that turn a result into an advert, and every one of them here would be
|
||||
* invented. §11's "understated honesty" applies to markup a reader never sees as much as to
|
||||
* the prose, and inventing a rating is the exact thing that gets structured data ignored.
|
||||
*
|
||||
* Breadcrumb and Article markup for the forty documentation pages was considered and
|
||||
* rejected: Starlight already renders breadcrumbs a reader can see, and forty more blocks
|
||||
* would be forty more places for a fact to go stale.
|
||||
*
|
||||
* ── Where the values come from ──────────────────────────────────────────────
|
||||
* Every one is read — `brand.mjs` for text, `platform.json` for the platform's facts —
|
||||
* so `checkFacts.mjs` already guards them and the mount already reaches them. Nothing here
|
||||
* is typed twice. It is a data block, not code: no browser executes it, no CSP hash covers
|
||||
* it, and `applyBrand.mjs` is free to rewrite the name inside it at boot (both scripts know
|
||||
* about `application/ld+json` explicitly, because both would otherwise get it wrong).
|
||||
*/
|
||||
const site = Astro.site!;
|
||||
const url = (p: string) => new URL(p, site).href;
|
||||
|
||||
const organization = {
|
||||
'@type': 'Organization',
|
||||
'@id': url('/#organization'),
|
||||
name: brand.siteName,
|
||||
url: url('/'),
|
||||
logo: url('/brand/icon-512.png'),
|
||||
description: brand.tagline,
|
||||
// The support front door (D10). The Gitea org is where the code is; Discord is where a
|
||||
// person gets an answer, so both are listed and neither is described as the other.
|
||||
sameAs: [brand.giteaOrg, brand.discordInvite].filter(Boolean),
|
||||
};
|
||||
|
||||
const application = {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': url('/#software'),
|
||||
name: brand.siteName,
|
||||
url: url('/'),
|
||||
description: brand.tagline,
|
||||
applicationCategory: 'WebApplication',
|
||||
// What an operator actually runs it on: a container on their own host, and an Android
|
||||
// client. Not "Windows" — the installer runs there, the platform does not require it.
|
||||
operatingSystem: 'Linux, Windows, Android',
|
||||
license: 'https://www.gnu.org/licenses/gpl-3.0.html',
|
||||
softwareVersion: platform.bundle.tag,
|
||||
publisher: { '@id': url('/#organization') },
|
||||
// Self-hosted and free, and `offers` is the only way the vocabulary can say so. Omitting
|
||||
// it reads as "price unknown"; stating zero is simply true.
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
};
|
||||
|
||||
const graph = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [organization, application],
|
||||
};
|
||||
---
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(graph)} is:inline />
|
||||
41
src/config/cspHashes.mjs
Normal file
41
src/config/cspHashes.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* cspHashes.mjs — GENERATED. Do not edit by hand.
|
||||
*
|
||||
* Regenerate with `npm run csp:hashes` (which builds, harvests and rebuilds).
|
||||
* `npm run check:csp` fails if this file no longer covers what the build emits.
|
||||
*
|
||||
* ── Why this file exists ────────────────────────────────────────────────────
|
||||
* Astro's `security.csp` (D48) hashes the scripts and styles it processes itself. It does
|
||||
* not hash `<script is:inline>` — by design, because an inline script is the author's own
|
||||
* text and Astro never parses it. Starlight ships six of them on every documentation page:
|
||||
* the theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore.
|
||||
*
|
||||
* That combination fails in the worst way available. The build succeeds, the header is
|
||||
* strict and correct, every page renders — and the theme switch, the mobile sidebar and
|
||||
* the sidebar's scroll position are dead, with the explanation only in a browser console
|
||||
* nobody opens. `'unsafe-inline'` would fix all six and give up the single directive CSP
|
||||
* exists to enforce, so instead the hashes are enumerated here and checked.
|
||||
*
|
||||
* These are Starlight's, not ours: a Starlight upgrade that edits one byte of one of those
|
||||
* scripts invalidates a hash. `check:csp` is what turns that from a silent breakage into a
|
||||
* red build, and regenerating this file is the acknowledgement that the upgrade was read.
|
||||
*/
|
||||
|
||||
/**
|
||||
* SHA-256 hashes of inline `<script>` bodies Astro does not hash for us.
|
||||
*
|
||||
* The template-literal type is not decoration: Astro types this option as `CspHashEntry[]`,
|
||||
* so a plain `string[]` fails `astro check`.
|
||||
*
|
||||
* @type {`sha256-${string}`[]}
|
||||
*/
|
||||
export const inlineScriptHashes = [
|
||||
'sha256-7eCV4jtsr4t4knb3c4FCRPeu7GGZeOUGE3XvWix0XOQ=',
|
||||
'sha256-GkZBRnvSuhtx/cvzvukVkX2JJZW+DdPlVr7BX8Tefqo=',
|
||||
'sha256-VWo5Wp4aqSj6nSgMpeAp9cKieaoIfwFUAunAVugI5gA=',
|
||||
'sha256-f/zAUE74ucc3JYp4r4QQvkJofoQdkOIhHYK+jeZ6eko=',
|
||||
'sha256-wX2yOADeV+NMngflD5uYi3vl50SHC4sfM1EmylVjlX4=',
|
||||
];
|
||||
|
||||
/** @type {`sha256-${string}`[]} SHA-256 hashes of inline `<style>` bodies Astro does not hash. */
|
||||
export const inlineStyleHashes = [];
|
||||
@@ -85,7 +85,37 @@ const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
<div class="site">
|
||||
<Header />
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
`data-pagefind-body` is what puts the marketing pages into the search index the
|
||||
documentation already had (D47). It is on `<main>` and not on `<body>` deliberately:
|
||||
the header and footer are on all ten pages, so indexing them would make every page
|
||||
a result for "Discord", "Privacy" and the product's own name.
|
||||
|
||||
Pagefind indexes a page only if it finds this attribute, which is why the docs were
|
||||
the whole index before now — Starlight marks its own content and nothing else did.
|
||||
|
||||
The explicit title is worth the second attribute. Pagefind titles a result from the
|
||||
first <h1> it finds, and these pages have EDITORIAL h1s — /app/'s is "The app for a
|
||||
deployment you already use", /terms/'s is "Short, and only about what we run". Read
|
||||
on the page under an eyebrow that says "Android app" those are right; read as four
|
||||
rows in a result list they are unscannable, and the first walk of this search turned
|
||||
up exactly that. The page's short name — the one in the nav and the browser tab —
|
||||
is what a reader is looking for in a list.
|
||||
-->
|
||||
<!--
|
||||
`tabindex="-1"` is what makes the skip link above actually skip. Following it moves
|
||||
the SCROLL to this element, but a container is not focusable, so focus stays where
|
||||
it was; Chrome papers over that by moving its sequential-navigation point, and not
|
||||
every browser or screen reader does. `-1` makes the element focusable by script and
|
||||
fragment only — never by Tab — so the link lands here for everyone and the tab order
|
||||
is unchanged. (Negative, so it is not the positive `tabindex` checkA11y refuses.)
|
||||
-->
|
||||
<main
|
||||
id="main"
|
||||
tabindex="-1"
|
||||
data-pagefind-body
|
||||
data-pagefind-meta={`title:${bareTitle ? brand.siteName : title}`}
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import Base from '../layouts/Base.astro';
|
||||
---
|
||||
|
||||
<Base title="Page not found" description="That page does not exist on this site.">
|
||||
<section class="page notfound">
|
||||
<!-- The one page `Base`'s `data-pagefind-body` should not reach (D47): a search result
|
||||
reading "That page does not exist" would be a small cruelty. -->
|
||||
<section class="page notfound" data-pagefind-ignore>
|
||||
<p class="eyebrow">404</p>
|
||||
<h1>That page does not exist</h1>
|
||||
<p class="prose">
|
||||
|
||||
@@ -596,9 +596,13 @@ const formToken = issueFormToken();
|
||||
|
||||
.beta-form__consent input {
|
||||
flex: none;
|
||||
margin-top: 0.2rem;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
/* 24px exactly — WCAG 2.2 SC 2.5.8's minimum target size, which this box missed at
|
||||
1.05rem (17px). It is the only control on the site a person has to hit precisely,
|
||||
and it is on the page a phone is most likely to arrive at, so the phase 10 walk
|
||||
measuring it at 17x17 on a 390px viewport was worth acting on rather than
|
||||
explaining away. */
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
accent-color: var(--portal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import StructuredData from '../components/StructuredData.astro';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
@@ -27,6 +28,8 @@ import GetStarted from '../components/home/GetStarted.astro';
|
||||
---
|
||||
|
||||
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||
<StructuredData slot="head" />
|
||||
|
||||
<Hero />
|
||||
<DataPath />
|
||||
<WhatItLooksLike />
|
||||
|
||||
40
src/pages/robots.txt.ts
Normal file
40
src/pages/robots.txt.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
/**
|
||||
* `/robots.txt` (D49, phase 10).
|
||||
*
|
||||
* The site had a sitemap covering all fifty URLs — Starlight bundles `@astrojs/sitemap`, so
|
||||
* it has been written on every build since phase 1 — and nothing that pointed at it. A
|
||||
* crawler finds a sitemap two ways: submitted by hand in a search console, or named here.
|
||||
* There is no search console for this project (D9's no-analytics posture extends to not
|
||||
* having accounts with anyone), so this file is the only way it is ever found.
|
||||
*
|
||||
* **Everything is allowed.** The site has no authenticated surface at all (§6), so there is
|
||||
* no private area to keep out of an index, and the two exclusions worth arguing about were
|
||||
* both rejected:
|
||||
*
|
||||
* - `/brand/*` is derived images and a stylesheet. Nothing links them as pages and they
|
||||
* carry no text; disallowing them would only stop an image crawler fetching the OG card
|
||||
* that exists to be fetched.
|
||||
* - `/beta/` is a live page a person is meant to find. It is the closed test's front door
|
||||
* and the nearest real deadline this project has — hiding it from search to keep the
|
||||
* signup list small would be solving a problem nobody has.
|
||||
*
|
||||
* The 404 page is excluded from the *search index* instead (`data-pagefind-ignore`), which
|
||||
* is the right layer for it: it is never a URL a crawler is given.
|
||||
*
|
||||
* Written as a route rather than a file in `public/` so the sitemap URL is derived from
|
||||
* `site` in `astro.config.mjs`. A hand-written copy would be one more place the domain is
|
||||
* spelled out, and the first thing to go stale if it ever changes.
|
||||
*/
|
||||
export const GET: APIRoute = ({ site }) =>
|
||||
new Response(
|
||||
[
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
`Sitemap: ${new URL('sitemap-index.xml', site)}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
|
||||
);
|
||||
@@ -243,6 +243,20 @@ svg {
|
||||
padding-inline: 0.45rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Search (D47) shares the second row with the links rather than taking a third. `order`
|
||||
was the obvious way to lift it beside the lockup instead, and it was rejected: the tab
|
||||
order follows the DOM, so a keyboard user would tab from the lockup down to the links
|
||||
and back up to a button above them. Keeping the visual order the same as the focus
|
||||
order is worth more here than the row it saves. */
|
||||
.site-nav {
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.site-search {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Footer ------------------------------------------------------------- */
|
||||
|
||||
@@ -113,6 +113,11 @@
|
||||
--shadow-raised: 0 22px 48px rgb(0 0 0 / 38%);
|
||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||
--glow-portal: 0 0 32px rgb(21 180 222 / 22%);
|
||||
/* Behind the search dialog (phase 10, D47). A token rather than a literal for the same
|
||||
reason as everything else here: a mounted theme.css can only redefine properties, so
|
||||
a scrim written into the component is a piece of the site an operator can never
|
||||
recolour — and a light theme would need this one lighter, not merely less opaque. */
|
||||
--scrim: rgb(0 0 0 / 60%);
|
||||
|
||||
/* ---- Layout ------------------------------------------------------------
|
||||
Here rather than in global.css so a theme can widen the measure without
|
||||
|
||||
136
test/headers.test.mjs
Normal file
136
test/headers.test.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The headers the built site actually sends. PLAN.md §6 / D48, phase 10.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS A TEST AND NOT A CHECK SCRIPT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `scripts/checkCsp.mjs` reads `dist/_headers.json` and proves the build computed the right
|
||||
* policy for every route. That is necessary and it is not sufficient, because the defect
|
||||
* this file exists for happened entirely *after* the build was correct: `@astrojs/node`
|
||||
* matched a request to a policy with `pathname.includes(...)`, a substring test, and served
|
||||
* `/modules/` the policy built for `/docs/modules/building-a-module`. Every file on disk was
|
||||
* right. The bytes on the wire were not.
|
||||
*
|
||||
* Nothing that reads `dist/` can see that. The only way to know what a reader receives is
|
||||
* to start the server and ask it, so this starts `scripts/serve.mjs` on an ephemeral port
|
||||
* and reads the responses.
|
||||
*
|
||||
* The symptom is worth restating, because it is what makes this worth a test rather than a
|
||||
* comment: a page served another page's hash list renders with its own stylesheet REFUSED.
|
||||
* `/modules/` and `/architecture/` were shipping unstyled sections, and the only trace was
|
||||
* a console message. The homepage looked perfect throughout — it happened to share a hash
|
||||
* with the 404 page it was being given.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* IT NEEDS A BUILD
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `dist/` is an input here, so this file is NOT in `npm test` — that runs before the build,
|
||||
* both locally and in CI. It is `npm run test:served`, which `npm run verify` runs after
|
||||
* `npm run build`. With no build present it skips rather than fails, so that a developer
|
||||
* running the whole file by hand gets an explanation instead of a stack trace.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const built = fs.existsSync(path.join(root, 'dist', 'server', 'entry.mjs'));
|
||||
|
||||
const PORT = 41732;
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
let server;
|
||||
|
||||
/** Wait for the port to answer rather than sleeping a guessed number of milliseconds. */
|
||||
async function waitForServer(timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fetch(base + '/', { signal: AbortSignal.timeout(1000) });
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
throw new Error(`serve.mjs did not answer on ${base} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
describe('the headers the server sends', { skip: built ? false : 'no build in dist/ — run npm run build first' }, () => {
|
||||
before(async () => {
|
||||
server = spawn(process.execPath, [path.join(root, 'scripts', 'serve.mjs')], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
await waitForServer();
|
||||
});
|
||||
|
||||
after(() => server?.kill());
|
||||
|
||||
const cspOf = async (route) => {
|
||||
const res = await fetch(base + route);
|
||||
assert.equal(res.status, route === '/404' ? 404 : 200, `${route} status`);
|
||||
const csp = res.headers.get('content-security-policy');
|
||||
assert.ok(csp, `${route} has no Content-Security-Policy header`);
|
||||
return csp;
|
||||
};
|
||||
|
||||
it('gives each prerendered route its OWN policy, not a substring match', async () => {
|
||||
// The exact pair the upstream bug confused: one is a substring of the other.
|
||||
const marketing = await cspOf('/modules/');
|
||||
const docs = await cspOf('/docs/modules/building-a-module/');
|
||||
assert.notEqual(marketing, docs, '/modules/ was served the docs page\'s policy');
|
||||
|
||||
// And the homepage, which matched whichever record came first in the file.
|
||||
const home = await cspOf('/');
|
||||
const notFound = await cspOf('/404');
|
||||
assert.notEqual(home, notFound, '/ was served the 404 page\'s policy');
|
||||
});
|
||||
|
||||
it("covers a page's own inline styles with hashes in the policy it is served", async () => {
|
||||
for (const route of ['/', '/modules/', '/architecture/', '/docs/']) {
|
||||
const csp = await cspOf(route);
|
||||
const html = await (await fetch(base + route)).text();
|
||||
|
||||
let counted = 0;
|
||||
for (const [, body] of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
|
||||
if (body.trim() === '') continue;
|
||||
counted++;
|
||||
const hash = `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
|
||||
assert.ok(csp.includes(hash), `${route}: an inline <style> is not hashed in its own policy`);
|
||||
}
|
||||
assert.ok(counted > 0, `${route}: expected at least one inline style to check`);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses framing everywhere, including the routes that render per request', async () => {
|
||||
for (const route of ['/', '/docs/', '/beta/', '/brand/theme.css']) {
|
||||
const res = await fetch(base + route);
|
||||
const csp = res.headers.get('content-security-policy') ?? '';
|
||||
assert.match(csp, /frame-ancestors 'none'/, `${route} can be framed`);
|
||||
}
|
||||
});
|
||||
|
||||
it('sends the non-CSP security headers on every response', async () => {
|
||||
for (const route of ['/', '/docs/', '/beta/']) {
|
||||
const res = await fetch(base + route);
|
||||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff', route);
|
||||
assert.equal(res.headers.get('x-frame-options'), 'DENY', route);
|
||||
assert.match(res.headers.get('referrer-policy') ?? '', /strict-origin/, route);
|
||||
assert.match(res.headers.get('permissions-policy') ?? '', /camera=\(\)/, route);
|
||||
}
|
||||
});
|
||||
|
||||
it("never falls back to 'unsafe-inline' for scripts", async () => {
|
||||
for (const route of ['/', '/docs/', '/beta/']) {
|
||||
const csp = (await fetch(base + route)).headers.get('content-security-policy') ?? '';
|
||||
const scriptSrc = /script-src ([^;]*)/.exec(csp)?.[1] ?? '';
|
||||
assert.ok(!scriptSrc.includes("'unsafe-inline'"), `${route} script-src allows unsafe-inline`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user