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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user