feat(marketing): phase 4 — the marketing pages
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>
This commit is contained in:
2026-08-24 01:53:11 -05:00
parent d9d7a8d47f
commit 2d19ee4220
21 changed files with 3332 additions and 119 deletions

View File

@@ -175,6 +175,34 @@ if (demoFrom !== demoTo) {
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
}
/**
* The demo's DEEP links (§15 / D25), which `/features/` writes one of per capability that
* has a stable public route:
*
* <a class="demo-link" href="" data-demo-url="" data-demo-path="/uo/market">see it live</a>
*
* The slot above cannot express these. It is a literal string swap of a whole URL, so it
* can only ever put the demo's root in an `href` — and reversing it would not even find a
* deep link, whose `href` is the root plus a path and therefore matches no literal the
* script knows.
*
* This pass is a different shape on purpose: it does not replace a previous value, it
* RECOMPUTES both attributes from `data-demo-path`, which never changes. That makes it
* idempotent and exactly reversible, so it runs unconditionally in the loop below rather
* than only when the demo URL moved. `data-demo-url` is still filled with the bare root
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
* both kinds of link, and only the `href` differs.
*/
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
const deepLinkTo = (demoPath) => {
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
return (
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
`data-demo-path="${demoPath}"`
);
};
if (!replacements.length) {
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
process.exit(0);
@@ -198,6 +226,7 @@ function* walk(dir) {
}
const counts = new Map(replacements.map((r) => [r.field, 0]));
counts.set('demoDeep', 0);
let filesTouched = 0;
for (const file of walk(CLIENT)) {
@@ -210,6 +239,15 @@ for (const file of walk(CLIENT)) {
after = after.split(from).join(to);
}
// After the literal swaps, never before: the plain-slot replacement also matches the
// first two attributes of a deep link, so it runs first and this pass corrects the
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
after = after.replace(DEEP_LINK, (whole, demoPath) => {
const rebuilt = deepLinkTo(demoPath);
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
return rebuilt;
});
if (after !== before) {
writeFileSync(file, after);
filesTouched++;
@@ -226,6 +264,11 @@ for (const { field, from, to } of replacements) {
if (demoFrom !== demoTo) {
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
}
if (counts.get('demoDeep')) {
console.log(
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
);
}
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
// applied here reaches the pages but not the search results. Worth fixing when search

View File

@@ -290,6 +290,87 @@ if (!attrTemplate) {
}
}
/* =======================================================================================
5. The demo DEEP-link contract (§15 / D25)
=======================================================================================
`/features/` links individual capabilities into the demo, which the slot in §4 cannot
express — it swaps a whole URL, so it can only ever produce the demo's root. Those links
carry a third attribute and `applyBrand.mjs` recomputes all three from it.
Same failure mode as §4 and the same reason to check it: a template and a script with no
shared code, agreeing on an exact byte sequence, where disagreement is silent. This one
is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the
stock build hides every demo link. It would first appear on the day the org lead sets
`demoUrl` and finds the new links pointing at the demo's front page, or at nothing.
The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run
against the stock literal, so this fails if the script's pattern stops matching what the
templates write — whichever side moved. */
const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/);
const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" ';
let deepLinkCount = 0;
if (!deepPattern) {
fail(
'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' +
' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' +
' match the new shape rather than deleting it.'
);
} else {
// Does the script's own pattern still match what a template writes in a stock build?
const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`;
let matches = false;
try {
matches = new RegExp(deepPattern[1]).test(sample);
} catch (error) {
fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`);
}
if (!matches) {
fail(
`applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` +
' Every per-capability demo link would be left empty and hidden, on a deployment\n' +
' that has a demo configured — which is the one place nobody would look.'
);
}
const deepStrays = [];
let deepLinks = 0;
for await (const file of walk(path.join(ROOT, 'src'))) {
if (path.extname(file) !== '.astro') continue;
// Blanked, not stripped — same reason as §4: the line numbers reported have to be the
// ones in the file.
const blank = (match) => match.replace(/[^\n]/g, ' ');
const source = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/<!--[\s\S]*?-->/g, blank);
const relative = path.relative(ROOT, file);
for (const match of source.matchAll(/data-demo-path/g)) {
deepLinks++;
const start = match.index - EMPTY_DEEP_PREFIX.length;
if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) {
deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
}
}
}
for (const site of deepStrays) {
fail(
`${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` +
' applyBrand.mjs matches all three attributes together and in that order; anything\n' +
' else is invisible to it and the link will never point anywhere.'
);
}
deepLinkCount = deepLinks;
}
/* ======================================================================================= */
if (failures.length) {
@@ -301,5 +382,6 @@ if (failures.length) {
console.log(
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
`every rewritable string is safe to replace, and the demo slot matches its contract.`
`every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` +
`deep link(s) match their contracts.`
);

311
scripts/checkLinks.mjs Normal file
View File

@@ -0,0 +1,311 @@
#!/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.`
);
}