fix(footer): send each documentation link to the section its label names #20
@@ -27,7 +27,7 @@
|
|||||||
"check:csp": "node scripts/checkCsp.mjs",
|
"check:csp": "node scripts/checkCsp.mjs",
|
||||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||||
"beta": "node scripts/beta.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",
|
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||||
"screens:capture": "node scripts/captureScreens.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",
|
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { renderBrand } from '../lib/brand.mjs';
|
import { renderBrand } from '../lib/brand.mjs';
|
||||||
import { legal } from '../data/legal.mjs';
|
import { legal } from '../data/legal.mjs';
|
||||||
|
import { footerColumns } from '../data/footer.mjs';
|
||||||
import platform from '../data/platform.json';
|
import platform from '../data/platform.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,33 +20,9 @@ const brand = renderBrand(Astro);
|
|||||||
|
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const columns = [
|
// 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).
|
||||||
heading: 'Product',
|
const columns = footerColumns(brand);
|
||||||
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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const isExternal = (href: string) => href.startsWith('http');
|
const isExternal = (href: string) => href.startsWith('http');
|
||||||
---
|
---
|
||||||
|
|||||||
57
src/data/footer.mjs
Normal file
57
src/data/footer.mjs
Normal 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
97
test/footer.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user