fix(footer): send each documentation link to the section its label names
All checks were successful
PR checks / checks (pull_request) Successful in 9m38s
All checks were successful
PR checks / checks (pull_request) Successful in 9m38s
All three links under "Documentation" pointed at /docs/. Three labels — Getting started, Administration, Building a module — and one destination, which is the docs home and also what the header's Docs link already opens. They now land inside the section they name: Getting started /docs/getting-started/requirements/ Administration /docs/administration/configuration/ Building a module /docs/modules/building-a-module/ The docs home stays the header's link rather than becoming a fourth route to the same page. Eleven checks and two suites could not see this, and the reason is worth keeping: the bug is not a broken link. checkLinks resolves every internal href against the build and /docs/ resolves — three links to a page that exists are three valid links. checkSidebar compares the docs tree to the planned tree and never looks at the footer. checkA11y checks structure, and three correctly marked-up anchors are correct markup. Nothing asserted that a link goes where its label says. So the columns move to src/data/footer.mjs, beside legal.mjs and collection.mjs, and test/footer.test.mjs asserts it: every destination in a column distinct, no destination repeated across columns, each documentation link inside its own section prefix, none of them the docs home, and the two Project links still read from the brand. A column list inside an .astro component cannot be imported by a test, which is the whole reason for the move. Verified by reintroducing the bug: the suite fails with "Administration points at /docs/, which is not inside /docs/administration/". Restored, npm run verify is green — 42 tests, eleven checks, and the built index.html renders three distinct hrefs. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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