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