Files
runicgateway.com/scripts/checkLinks.mjs
wtclaude a2faf07104
All checks were successful
PR checks / checks (pull_request) Successful in 55s
feat(legal): phase 6 — the privacy policy and the terms
PLAN.md §9. Builds /privacy and /terms, links them from the footer on every page,
and generates the Play Data Safety notes from the same inventory the policy renders.

Four decisions taken by the org lead before either page was written, recorded in
§9 under "How phase 6 built the legal pages":

  D30  DNS-only records, so the reverse proxy on the host keeps the only access
       log. Described qualitatively — the retention belongs to the proxy, and a
       policy that quotes a number the deployment does not enforce is worse than
       one that does not.
  D31  Eighteen or older. Above the children's-consent threshold everywhere in the
       EEA, so consent works with no parental-consent machinery this form could not
       honestly operate. Four surfaces render it from src/data/legal.mjs, and every
       one says plainly that nothing verifies it.
  D32  No governing-law clause. Nothing of value is contracted for here.
  D33  PLAY_DATA_SAFETY.md is generated from src/data/collection.mjs and checked in
       CI, so the published policy and the answers given to Google cannot drift.

/privacy is three separately-scoped sections because "we" means three different
parties: this site (one form, no cookies, no third-party requests), the Android app
(we operate no server it talks to — the rows are what the DEVICE holds), and a
self-hosted deployment (the operator is the controller, not us). Every row names the
file it was read out of, because a policy is the document most likely to be written
from a template and least likely to be re-read against the software.

/terms governs only what we run: this site, the beta list, and the APK we publish.
The software is governed by its licence, and a community's deployment by that
community — a terms page claiming authority over every install of a GPL program is
the thing a generated template gets wrong.

Also here:
  - the age clause changed CONSENT_TEXT, so CONSENT_VERSION gained a suffix; rows
    written from now on carry the new sentence and older rows keep theirs
  - PLANNED_ROUTES is now empty — these were its last two entries, and its reverse
    check is what forced the deletion; the list stays for phases 7 and 8
  - test/legal.test.mjs asserts the structural promises no build check can see,
    including that every mapped Play row still answers "not collected, not shared"
  - --check normalises line endings: the repo has no .gitattributes and Windows
    checkouts are CRLF, so a byte comparison would fail for every Windows developer
    while passing in CI

Verified: npm run verify green end to end (tokens, brand, data safety, astro check,
36 tests, build, 214 links, 19 facts), both pages walked in a browser, and neither
overflows at 390px. One defect the checks could not see and a look could: the
retention line was being pushed to the foot of the tallest card in its row, opening
a void in the middle of the short ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 04:21:47 -05:00

365 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* checkLinks.mjs — PLAN.md §12
*
* Two rules, both of which §12 states and neither of which had a check until phase 4:
*
* 1. Every internal link resolves.
* 2. Every outbound link into a RunicGateway repository points at a BRANCH path, never a
* commit permalink.
*
* ---------------------------------------------------------------------------------------
* WHY IT READS THE BUILD AND NOT THE SOURCE
* ---------------------------------------------------------------------------------------
* The obvious implementation greps `href="…"` out of `src/**` and resolves it against the
* file tree. It would have missed most of what phase 4 added. Half the links on these pages
* are built from data — `capabilityGroups`, `notBuilt.mjs`, a template literal over
* `platform.gitea.base` — and a source scan sees an expression rather than a URL. A link
* that is wrong in a data file is exactly as broken as one that is wrong in markup, and it
* is harder to spot by eye, so it is the one that most needs checking.
*
* So this runs against `dist/client` after a build, where every link is a real string. The
* cost is that the check needs a build first, which is why it sits after `npm run build` in
* `verify` and in CI. A stale `dist` would check stale links, and that is the one failure
* mode worth knowing about — running it by hand after editing a page means building first.
*
* ---------------------------------------------------------------------------------------
* WHAT IT DELIBERATELY DOES NOT CHECK
* ---------------------------------------------------------------------------------------
* `/brand/*` — those URLs are served by a route that derives them on request from whatever
* is mounted, so nothing corresponding exists in `dist/client` to point at. They are not
* unchecked: `scripts/checkBrand.mjs` already resolves every one of them against that
* route's own allowlist, which is a stronger check than file existence.
*
* Off-site URLs are not fetched. A build that fails because gnu.org is slow is a build
* that teaches people to ignore this check. The one outbound rule here is about the SHAPE
* of a URL, which is decidable without the network.
*
* In-page fragments (`#main`) are not resolved against the ids on the page. It would be a
* fair check to add; it is not one §12 asks for, and the site has exactly one of them.
*
* node scripts/checkLinks.mjs [--dist <path>]
*/
import { readFileSync, existsSync, statSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const distArg = process.argv.indexOf('--dist');
const DIST =
distArg !== -1 && process.argv[distArg + 1]
? path.resolve(process.argv[distArg + 1])
: path.join(ROOT, 'dist', 'client');
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
/** `gitea.whitlocktech.com`, from the same place every page reads it. */
const GITEA_HOST = new URL(platform.gitea.base).host;
/**
* Prefixes served by a route rather than by a file in the build. A link starting with one
* of these is somebody else's check — see the header.
*/
const RUNTIME_PREFIXES = ['/brand/'];
/**
* Pages that render per request, and therefore have no file in `dist/client` to resolve
* against — discovered from the source rather than listed here.
*
* Phase 5 is what made this necessary. Until then the only on-demand route was `/brand/*`,
* which is an asset route with its own checker and is skipped by prefix above; `/beta/` is
* the first on-demand PAGE, and it is linked from `/app/`, the header and the footer like
* any other. Rule 1 read `dist/client`, saw nothing at `beta/index.html`, and failed a link
* that is perfectly good.
*
* The tempting fix — an entry in `PLANNED_ROUTES` — would be wrong, and wrong in the exact
* way that list's own comment warns about. Its reverse check fires when a route HAS been
* built, and an on-demand route never produces a file, so the entry could never rot out. It
* would become the permanent exemption the two-way check exists to prevent.
*
* So the route is derived instead: a file under `src/pages/` that exports `prerender =
* false` IS an on-demand route, and its path maps to a URL by Astro's own file-routing
* rules. That is a fact about the source, checkable at the same moment, and it cannot go
* stale — delete `beta.astro` and the links to `/beta/` start failing again immediately,
* which is the behaviour rule 1 is there to provide.
*
* Dynamic segments (`[...file].ts`) are deliberately not handled: the only one is the brand
* route, already covered by prefix, and inventing a matcher for a case that does not exist
* would be guessing at a shape nobody has written yet.
*/
async function findOnDemandRoutes() {
const pagesDir = path.join(ROOT, 'src', 'pages');
const routes = new Set();
for await (const file of walk(pagesDir, ['.astro', '.ts', '.js'])) {
const source = readFileSync(file, 'utf8');
if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue;
const relative = path.relative(pagesDir, file).split(path.sep).join('/');
if (relative.includes('[')) continue;
const withoutExt = relative.replace(/\.(astro|ts|js)$/, '');
const name = withoutExt.replace(/(^|\/)index$/, '');
routes.add(name ? `/${name}/` : '/');
}
return routes;
}
/**
* Routes the site links today that a later phase builds.
*
* This exists because of a convention phase 3 recorded and phase 1 started: the header,
* the footer and the homepage link the FINAL routes of §10 rather than growing links phase
* by phase. Nothing is deployed until phase 12, so no visitor ever meets one of these
* 404s, and no page has to be revisited later to add a link that was always going to be
* there. That convention and rule 1 of this check are in direct tension, and this is where
* the tension is resolved — explicitly, with a phase against each entry, rather than by
* weakening the rule.
*
* It is self-cleaning in both directions, which is the only reason it is safe to have:
*
* - a link to a route that is neither built nor listed here FAILS, so the list cannot be
* used by accident;
* - an entry here whose route HAS since been built also fails, so the list cannot rot
* into a permanent exemption after the page arrives.
*
* Adding to it is a deliberate act. If a route is not in §10, it does not belong here.
*/
const PLANNED_ROUTES = new Map([
// Empty as of phase 6, which built `/privacy/` and `/terms/` — the last two routes §10
// named that no page served. The Map stays because §10 is not finished: phases 7 and 8
// add the documentation journey, and the convention above (link the final route, not the
// route that exists today) is what the list exists to make safe.
//
// An empty list is not a dormant one. Rule 3 below still runs, so adding an entry for a
// route that has since been built fails immediately rather than sitting here unread.
]);
/** Planned routes actually linked from somewhere, so the reverse check can be reported. */
const plannedSeen = new Set();
const failures = [];
let linksChecked = 0;
let outboundChecked = 0;
function fail(file, line, message) {
failures.push({ file, line, message });
}
async function* walk(dir, extensions = ['.html']) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full, extensions);
else if (extensions.includes(path.extname(entry.name))) yield full;
}
}
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
/**
* Does a site-absolute path correspond to something the build will serve?
*
* Astro is configured with `format: 'directory'`, so `/features/` is
* `dist/client/features/index.html`. The other shapes are accepted because a route can
* legitimately be a file — `/manifest.webmanifest` is one, and `/404.html` is another.
*/
function resolvesInBuild(pathname) {
const clean = pathname.replace(/[?#].*$/, '');
const relative = decodeURIComponent(clean).replace(/^\/+/, '');
const base = path.join(DIST, relative);
const candidates = [
path.join(base, 'index.html'),
`${base.replace(/[\\/]+$/, '')}.html`,
base.replace(/[\\/]+$/, ''),
];
return candidates.some((candidate) => {
if (!existsSync(candidate)) return false;
// A bare directory that has no index.html is not a page anybody can open.
return statSync(candidate).isFile();
});
}
if (!existsSync(DIST)) {
console.error(
`\ncheckLinks: no build at ${path.relative(ROOT, DIST)}.\n\n` +
' This check reads the built HTML rather than the source, so that links written by\n' +
' data files and template literals are checked as the strings they become. Run\n' +
' `npm run build` first — `npm run verify` already does.\n'
);
process.exit(1);
}
const onDemandRoutes = await findOnDemandRoutes();
/* =======================================================================================
1. Internal links resolve
======================================================================================= */
for await (const file of walk(DIST)) {
const relative = path.relative(ROOT, file);
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(/(?:href|src)="([^"]*)"/g)) {
const value = match[1];
// Off-site, protocol-relative, and the non-navigational schemes. `mailto:` addresses
// are checkFacts.mjs's business (D13) and are not links to anywhere on this site.
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value)) continue;
// Fragments and query-only links stay on the page they are already on.
if (!value || value.startsWith('#') || value.startsWith('?')) continue;
// Relative links. Astro emits site-absolute paths for everything the site itself
// writes; a relative one is almost certainly a mistake, but resolving it correctly
// needs the emitting page's directory, so it is reported rather than guessed at.
if (!value.startsWith('/')) {
fail(
relative,
lineOf(source, match.index),
`relative link "${value}" — write it site-absolute, starting with "/", so it means ` +
`the same thing from every page that renders the component`
);
continue;
}
if (RUNTIME_PREFIXES.some((prefix) => value.startsWith(prefix))) continue;
// The demo slot and its deep links ship empty and hidden in a stock build (§15/D25);
// `href=""` is the contract, not a broken link. checkBrand.mjs owns their shape.
if (value === '') continue;
linksChecked++;
if (resolvesInBuild(value)) continue;
// A page that renders per request has no file to find. Checked here rather than as a
// prefix skip, so an on-demand route still has to EXIST — see findOnDemandRoutes.
if (onDemandRoutes.has(value.replace(/[?#].*$/, ''))) continue;
const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, ''));
if (planned) {
plannedSeen.add(value.replace(/[?#].*$/, ''));
continue;
}
fail(
relative,
lineOf(source, match.index),
`"${value}" does not resolve — nothing in the build serves it.\n` +
` If a later phase builds it, add it to PLANNED_ROUTES in this script with the\n` +
` phase that does. If not, the link is wrong.`
);
}
/* =====================================================================================
2. Outbound repository links point at a branch, not a commit
=====================================================================================
§12's rule, and the reason for it: a commit permalink is a fact frozen at a sha while
the document it names keeps moving. Every link on this site into one of these
repositories is meant to show a reader the CURRENT state of something — the module
contract, the operator guide, the protocol — and a permalink quietly stops doing that
the day after it is written, without ever 404ing. It is the failure mode a link
checker would otherwise call healthy.
Gitea writes both shapes as `/<owner>/<repo>/src/<kind>/<ref>/…`, so the kind segment
is what decides it, and a 40-character hex ref is caught even when the kind segment
says branch — which is what a "branch" named after a sha actually is. */
for (const match of source.matchAll(/https?:\/\/[^\s"'<>)]+/g)) {
const raw = match[1] ?? match[0];
let url;
try {
url = new URL(raw);
} catch {
continue;
}
if (url.host !== GITEA_HOST) continue;
outboundChecked++;
const segments = url.pathname.split('/').filter(Boolean);
// <owner>/<repo>/<kind>/<refkind>/<ref>/…
const kind = segments[2];
const refKind = segments[3];
const ref = segments[4];
if (!['src', 'raw', 'media'].includes(kind)) continue;
if (refKind === 'commit' || refKind === 'tag') {
fail(
relative,
lineOf(source, match.index),
`${raw}\n points at a ${refKind}, not a branch. §12 requires branch paths, so a ` +
`reader always\n sees the document as it is now rather than as it was.`
);
continue;
}
if (ref && /^[0-9a-f]{40}$/i.test(ref)) {
fail(
relative,
lineOf(source, match.index),
`${raw}\n names a commit sha as its ref. Use a branch name — "main" for anything ` +
`canonical.`
);
}
}
}
/* =======================================================================================
3. The planned-route list has not rotted
=======================================================================================
The half that makes an exemption list safe. Once a phase builds one of these, the entry
stops being a promise and starts being a hole in rule 1 — so the build fails until it is
deleted. Reported per route, with the phase that was waiting for it, because the person
who just built the page is the person who should remove the line. */
const selfSource = readFileSync(path.join(ROOT, 'scripts/checkLinks.mjs'), 'utf8');
for (const [route, owner] of PLANNED_ROUTES) {
if (!resolvesInBuild(route)) continue;
const entry = selfSource.indexOf(`['${route}'`);
fail(
'scripts/checkLinks.mjs',
entry === -1 ? 1 : lineOf(selfSource, entry),
`PLANNED_ROUTES still lists "${route}" (${owner}), but the build now serves it.\n` +
` Delete the entry: every link to it is checked properly from here on.`
);
}
if (failures.length) {
console.error('\ncheckLinks: broken or non-canonical links.\n');
for (const failure of failures) {
console.error(` ${failure.file}:${failure.line}\n ${failure.message}\n`);
}
process.exit(1);
}
const pending = [...plannedSeen].sort();
console.log(
`checkLinks: ${linksChecked} internal link(s) resolve and ${outboundChecked} repository ` +
`link(s) point at a branch.`
);
if (pending.length) {
console.log(
` ${pending.length} link(s) point at a planned route: ` +
`${pending.join(', ')} — allowed until the phase that builds it.`
);
}