diff --git a/package.json b/package.json index 89dfbbb..8a03975 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "check:csp": "node scripts/checkCsp.mjs", "play:datasafety": "node scripts/playDataSafety.mjs", "beta": "node scripts/beta.mjs", - "test": "node --test test/beta.test.mjs test/legal.test.mjs", + "test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs", "screens:capture": "node scripts/captureScreens.mjs", "csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs", diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 4a998a7..54a2b9e 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,6 +1,7 @@ --- import { renderBrand } from '../lib/brand.mjs'; import { legal } from '../data/legal.mjs'; +import { footerColumns } from '../data/footer.mjs'; import platform from '../data/platform.json'; /** @@ -19,33 +20,9 @@ const brand = renderBrand(Astro); const year = new Date().getFullYear(); -const columns = [ - { - heading: 'Product', - links: [ - { href: '/features/', label: 'Features' }, - { href: '/architecture/', label: 'Architecture' }, - { href: '/modules/', label: 'Modules' }, - { href: '/app/', label: 'Android app' }, - ], - }, - { - heading: 'Documentation', - links: [ - { href: '/docs/', label: 'Getting started' }, - { href: '/docs/', label: 'Administration' }, - { href: '/docs/', label: 'Building a module' }, - ], - }, - { - heading: 'Project', - links: [ - { href: brand.giteaOrg, label: 'Source' }, - { href: brand.discordInvite, label: 'Discord' }, - { href: '/community/', label: 'Community' }, - ], - }, -]; +// The columns live in src/data/footer.mjs so a test can read them — see the note there, +// and test/footer.test.mjs. The two Project links come from the mounted brand (§7). +const columns = footerColumns(brand); const isExternal = (href: string) => href.startsWith('http'); --- diff --git a/src/data/footer.mjs b/src/data/footer.mjs new file mode 100644 index 0000000..686d982 --- /dev/null +++ b/src/data/footer.mjs @@ -0,0 +1,57 @@ +/** + * The footer's link columns. + * + * Data rather than markup, and in `src/data/` beside `legal.mjs` and `collection.mjs`, for + * one reason: the links shipped wrong. All three entries under "Documentation" pointed at + * `/docs/`, so the column rendered three different labels that went to the same page — and + * every check passed, because each href resolved perfectly well. `checkLinks.mjs` asks + * whether a link is broken; nothing asked whether a link goes where its label says. + * + * `test/footer.test.mjs` asks that now, which it can only do because the columns are + * importable. That is the whole reason this file exists. + * + * The two Project links are brand-supplied (§7, D13), so this is a function of the rendered + * brand rather than a constant — the mounted `brand.json` decides them, and `/beta` renders + * per request, so they cannot be baked at build time. + */ + +/** Documentation sections a footer link may point into, and where each one starts. */ +export const docsEntryPoints = { + 'getting-started': '/docs/getting-started/requirements/', + administration: '/docs/administration/configuration/', + modules: '/docs/modules/building-a-module/', +}; + +export function footerColumns(brand) { + return [ + { + heading: 'Product', + links: [ + { href: '/features/', label: 'Features' }, + { href: '/architecture/', label: 'Architecture' }, + { href: '/modules/', label: 'Modules' }, + { href: '/app/', label: 'Android app' }, + ], + }, + { + // Each of these lands INSIDE the section it names. The docs home — "What is Runic + // Gateway?", the first page of Getting started — is the header's `Docs` link, so a + // footer entry pointing there as well would be a fourth way to the same page rather + // than a way into the section. + heading: 'Documentation', + links: [ + { href: docsEntryPoints['getting-started'], label: 'Getting started' }, + { href: docsEntryPoints.administration, label: 'Administration' }, + { href: docsEntryPoints.modules, label: 'Building a module' }, + ], + }, + { + heading: 'Project', + links: [ + { href: brand.giteaOrg, label: 'Source' }, + { href: brand.discordInvite, label: 'Discord' }, + { href: '/community/', label: 'Community' }, + ], + }, + ]; +} diff --git a/test/footer.test.mjs b/test/footer.test.mjs new file mode 100644 index 0000000..0cbc738 --- /dev/null +++ b/test/footer.test.mjs @@ -0,0 +1,97 @@ +/** + * The footer's links, tested where a mistake is invisible to every other check. + * + * --------------------------------------------------------------------------------------- + * WHY THIS FILE EXISTS + * --------------------------------------------------------------------------------------- + * The footer shipped with all three "Documentation" links pointing at `/docs/`. Three + * labels — Getting started, Administration, Building a module — and one destination. It + * reached production and stayed there through eleven checks and two test suites, because + * none of them could see it: + * + * - `checkLinks.mjs` resolves every internal link against the build. `/docs/` resolves. + * Three links to a page that exists are three valid links. + * - `checkSidebar.mjs` compares the docs tree to the planned tree. The footer is not the + * sidebar and was never in scope. + * - `checkA11y.mjs` checks structure. Three correctly-marked-up links are correct markup. + * + * The bug is not a broken link. It is a link that goes somewhere other than where its label + * says, which is the one property nothing was asserting. So that is what this file asserts, + * and the reason `src/data/footer.mjs` exists at all — a column list inside an `.astro` + * component cannot be imported by a test. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { footerColumns, docsEntryPoints } from '../src/data/footer.mjs'; + +/** A stand-in for the rendered brand; only the two Project links read it. */ +const brand = { + giteaOrg: 'https://gitea.example.com/Org', + discordInvite: 'https://discord.gg/example', +}; + +const columns = footerColumns(brand); +const documentation = columns.find((c) => c.heading === 'Documentation'); + +test('every link in a column has its own destination', () => { + for (const column of columns) { + const hrefs = column.links.map((l) => l.href); + assert.equal( + new Set(hrefs).size, + hrefs.length, + `the ${column.heading} column has two links pointing at the same page: ${hrefs.join(', ')}`, + ); + } +}); + +test('no two columns offer the same destination twice', () => { + const all = columns.flatMap((c) => c.links.map((l) => l.href)); + assert.equal(new Set(all).size, all.length, `a footer destination is repeated: ${all.join(', ')}`); +}); + +test('each documentation link lands inside the section its label names', () => { + // The exact failure that shipped: `/docs/` under all three labels satisfies "starts with + // /docs/" but names no section, so the prefixes below are section prefixes, not `/docs/`. + const expected = [ + ['Getting started', '/docs/getting-started/'], + ['Administration', '/docs/administration/'], + ['Building a module', '/docs/modules/'], + ]; + + for (const [label, prefix] of expected) { + const link = documentation.links.find((l) => l.label === label); + assert.ok(link, `the Documentation column no longer has a "${label}" link`); + assert.ok( + link.href.startsWith(prefix), + `"${label}" points at ${link.href}, which is not inside ${prefix}`, + ); + } +}); + +test('no documentation link is the docs home, which the header already carries', () => { + for (const link of documentation.links) { + assert.notEqual( + link.href, + '/docs/', + `"${link.label}" points at the docs home; the header's Docs link is that page`, + ); + } +}); + +test('every documentation entry point is a directory URL', () => { + // Astro builds these as directories with an index.html; a missing trailing slash costs a + // redirect on every click and reads as a broken path in the status bar. + for (const [section, href] of Object.entries(docsEntryPoints)) { + assert.ok(href.endsWith('/'), `the ${section} entry point (${href}) needs a trailing slash`); + assert.ok(href.startsWith('/docs/'), `the ${section} entry point (${href}) is not under /docs/`); + } +}); + +test('the brand supplies the two Project links rather than the code', () => { + const project = columns.find((c) => c.heading === 'Project'); + const hrefs = project.links.map((l) => l.href); + assert.ok(hrefs.includes(brand.giteaOrg), 'the Source link no longer reads brand.giteaOrg'); + assert.ok(hrefs.includes(brand.discordInvite), 'the Discord link no longer reads brand.discordInvite'); +});