All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and /community/ — plus the two scope items the phase table never assigned to anyone. Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as D20-D25: - D20 /features/ is the homepage's list with a `detail` line, not a second list. One data file, two renderings, so they cannot disagree about what exists. - D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per boundary. No endpoint tables, no config keys — those are phase 8's and stay canonical in docs/. - D22 The deliberate absences of §2 become one tagged data file, rendered on the three pages that promise them. - D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the header since phase 1, built by no phase) and checkLinks.mjs. - D24 `needsModule`: writing the Teams detail exposed a false claim phase 3 shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is no create route, sync is gated on providerModuleId() — so the Community group no longer says a bare core does all of it. - D25 The per-capability demo affordance brand.json had promised since phase 2 is a deep link, filled at boot from data-demo-path. checkLinks.mjs reads the built HTML rather than src/, because half these links are assembled from data files and template literals. Its PLANNED_ROUTES list is checked in both directions, so it cannot rot into a permanent exemption. applyBrand.mjs gained a pass that recomputes deep links from their immutable path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out and runs it against the stock markup so the two cannot drift. Both proved against a real mount, in both directions. Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped component class are both specificity 0,1,0, so .demo-link's `display` beat the hide rule and twelve links to a nonexistent demo rendered, each resolving to the current page. The rule is now !important. The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css. Verified from a clean checkout: npm ci, all five checks, astro check (0 errors), production build, and a live browser pass at desktop and 390px. Co-Authored-By: Claude <noreply@anthropic.com>
312 lines
12 KiB
JavaScript
312 lines
12 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/'];
|
|
|
|
/**
|
|
* 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([
|
|
['/app/', 'phase 5 — the Android app page'],
|
|
['/beta/', 'phase 5 — the closed-beta signup'],
|
|
['/privacy/', 'phase 6 — the privacy policy'],
|
|
['/terms/', 'phase 6 — the terms'],
|
|
]);
|
|
|
|
/** 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) {
|
|
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);
|
|
else if (path.extname(entry.name) === '.html') 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);
|
|
}
|
|
|
|
/* =======================================================================================
|
|
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;
|
|
|
|
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.`
|
|
);
|
|
}
|