Merge pull request 'fix(footer): send each documentation link to the section its label names' (#20) from fix/footer-docs-links into main
All checks were successful
PR checks / checks (push) Successful in 1m35s
Build and publish the image / build (push) Successful in 1m43s
Build and publish the image / deploy (push) Successful in 31s

Reviewed-on: #20
This commit is contained in:
2026-08-27 05:31:35 +00:00
4 changed files with 159 additions and 28 deletions

View File

@@ -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",

View File

@@ -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');
---

57
src/data/footer.mjs Normal file
View File

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

97
test/footer.test.mjs Normal file
View File

@@ -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');
});