From e71ff4acd40274eed1a8f6704bdf682e17993d42 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 25 Aug 2026 14:14:53 -0500 Subject: [PATCH] =?UTF-8?q?feat(polish):=20phase=2010=20=E2=80=94=20search?= =?UTF-8?q?,=20accessibility,=20SEO=20and=20a=20real=20CSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
as a Pagefind body, so all ten join the index the docs already query, and Search.astro opens it in a . 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

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