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], +}; +--- + +