diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml
index 1e547dd..ec57fa7 100644
--- a/.gitea/workflows/pr-checks.yml
+++ b/.gitea/workflows/pr-checks.yml
@@ -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
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, , one 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 ` 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
+
+
diff --git a/src/components/StructuredData.astro b/src/components/StructuredData.astro
new file mode 100644
index 0000000..00bde27
--- /dev/null
+++ b/src/components/StructuredData.astro
@@ -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],
+};
+---
+
+
diff --git a/src/config/cspHashes.mjs b/src/config/cspHashes.mjs
new file mode 100644
index 0000000..e44acd2
--- /dev/null
+++ b/src/config/cspHashes.mjs
@@ -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 `