feat(site): phase 1 — the foundation
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s
Some checks failed
PR checks / checks (pull_request) Failing after 4m19s
Astro 7 with the Node adapter, Starlight mounted at /docs, the token file, both self-hosted typefaces, the layout shell, and the two build-time checks from §12. The palette's gold and cyan are sampled from runic-emblem.png rather than guessed, per §11: 494,059 opaque pixels binned by hue, each value annotated with its measured contrast against the ground, and restricted rather than brightened where a ratio fails. - checkTokens.mjs fails the build on any colour literal outside tokens.css, which is what keeps §7's "recolouring is a file copy" promise true. - checkFacts.mjs re-reads all 14 externally-sourced facts from their authorities over the Gitea API and fails on disagreement. It also enforces D13: no email address in the source outside brand-default/brand.json. - Both were negative-tested; neither has ever been allowed to pass by default. §6 asks for output:'server' with per-page prerender=true. Astro 7 expresses the same runtime shape as output:'static' with an adapter, opting individual routes out — so the default is static rather than accidentally server-rendered. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
305
scripts/checkFacts.mjs
Normal file
305
scripts/checkFacts.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkFacts.mjs — PLAN.md §12
|
||||
*
|
||||
* The site quotes versions, protocol numbers and capability lists. §1 records why that
|
||||
* needs a mechanism rather than diligence: an earlier draft of the plan confidently stated
|
||||
* the platform was on protocol 3, because every checkout in the workspace sat on a feature
|
||||
* branch whose local `main` ref had never been fetched, and `git show main:<path>` answered
|
||||
* from a months-old blob.
|
||||
*
|
||||
* So: every fact in `src/data/platform.json` is re-read here from its authority over the
|
||||
* Gitea API — never from a working tree — and any disagreement fails the build. When the
|
||||
* platform moves, this repo goes red so someone updates the site. That failure is the
|
||||
* feature, the same as the Integration Kit's checkCoreApi.js.
|
||||
*
|
||||
* It also enforces one rule that is not a version: no email address may appear in the
|
||||
* source outside `brand-default/brand.json` (D13). The published contact is a personal
|
||||
* address that is meant to stay replaceable by a file copy, and that promise survives
|
||||
* exactly as long as nobody types the address into a paragraph.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkFacts.mjs
|
||||
*
|
||||
* Anonymous raw fetches fail on this instance, so the token is required rather than
|
||||
* optional. A check that silently skips itself is worse than no check.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
const BASE = platform.gitea.base;
|
||||
const ORG = platform.gitea.org;
|
||||
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||
|
||||
const failures = [];
|
||||
const checked = [];
|
||||
|
||||
function record(label, expected, actual, authority) {
|
||||
const ok = String(expected) === String(actual);
|
||||
(ok ? checked : failures).push({
|
||||
label,
|
||||
expected,
|
||||
actual,
|
||||
expectedLabel: 'platform.json says',
|
||||
authority: `${authority} says`,
|
||||
ok,
|
||||
});
|
||||
}
|
||||
|
||||
async function api(pathname) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${pathname}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const raw = async (repo, filePath, ref) =>
|
||||
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
||||
|
||||
const json = async (pathname) => (await api(pathname)).json();
|
||||
|
||||
/** The first capture group of `re` in `text`, or a thrown error naming what was looked for. */
|
||||
function extract(text, re, what, authority) {
|
||||
const m = text.match(re);
|
||||
if (!m) throw new Error(`Could not find ${what} in ${authority} — the file's shape changed.`);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. The wire protocol, from the sidecar itself
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkProtocol() {
|
||||
const authority = 'link main:sidecar/src/main.rs';
|
||||
const src = await raw('link', 'sidecar/src/main.rs', 'main');
|
||||
const value = extract(
|
||||
src,
|
||||
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
|
||||
'PROTOCOL_VERSION',
|
||||
authority
|
||||
);
|
||||
record('protocol (sidecar)', platform.protocol, Number(value), authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. The overlay's declared protocol — the third declaration site
|
||||
//
|
||||
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
|
||||
// or the installer refuses to pair the sidecar with the overlay. If these two ever
|
||||
// disagree, the site must not print either number.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkOverlayProtocol() {
|
||||
const authority = 'servuo-plugins main:overlay.toml';
|
||||
const toml = await raw('servuo-plugins', 'overlay.toml', 'main');
|
||||
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
|
||||
record('protocol (overlay)', platform.protocol, Number(value), authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. The Module API version
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkModuleApi() {
|
||||
const authority = 'website main:server/src/modules/version.js';
|
||||
const src = await raw('website', 'server/src/modules/version.js', 'main');
|
||||
const value = extract(
|
||||
src,
|
||||
/MODULE_API_VERSION\s*=\s*['"]([^'"]+)['"]/,
|
||||
'MODULE_API_VERSION',
|
||||
authority
|
||||
);
|
||||
record('moduleApi', platform.moduleApi, value, authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The current bundle
|
||||
//
|
||||
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
|
||||
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkBundle() {
|
||||
const authority = 'installer bundles:current.json';
|
||||
const current = JSON.parse(await raw('installer', 'current.json', 'bundles'));
|
||||
|
||||
record('bundle.tag', platform.bundle.tag, current.bundle, authority);
|
||||
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority);
|
||||
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
|
||||
record(
|
||||
'bundle.servuoMin',
|
||||
platform.bundle.servuoMin,
|
||||
current.overlay?.servuo?.min_version,
|
||||
authority
|
||||
);
|
||||
// The bundle is protocol-checked by CI when it is published, so this is a third
|
||||
// independent read of the same number rather than a duplicate of check 1.
|
||||
record('protocol (bundle)', platform.protocol, current.protocol, authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Release versions, per repo
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkReleases() {
|
||||
for (const [repo, expected] of Object.entries(platform.releases)) {
|
||||
const authority = `${repo} releases/latest`;
|
||||
const release = await json(`${repo}/releases/latest`);
|
||||
record(`release ${repo}`, expected, release.tag_name, authority);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. `website` still publishes nothing
|
||||
//
|
||||
// It ships as container images and is never tagged, so the site refers to the platform by
|
||||
// bundle tag and Module API version instead. The day that changes, this repo should notice
|
||||
// rather than keep quietly omitting a version that now exists.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function checkWebsiteHasNoReleases() {
|
||||
const authority = 'website releases (expected empty)';
|
||||
const releases = await json('website/releases');
|
||||
record('websiteHasReleases', platform.websiteHasReleases, releases.length > 0, authority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. D13 — the contact address lives in exactly one file
|
||||
// ---------------------------------------------------------------------------
|
||||
const CONTACT_CHECK = 'contact address (D13)';
|
||||
|
||||
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
||||
|
||||
const SCAN_DIRS = ['src', 'scripts'];
|
||||
const SCAN_EXT = new Set([
|
||||
'.astro', '.css', '.js', '.mjs', '.ts', '.tsx', '.json', '.md', '.mdx', '.svg', '.html',
|
||||
]);
|
||||
// Addresses that are not a contact route: the AI-disclosure trailer the org requires on
|
||||
// every commit, and the noreply Gitea uses for the bot identity.
|
||||
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
|
||||
|
||||
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()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
yield* walk(full);
|
||||
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkContactAddressIsIsolated() {
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for await (const file of walk(path.join(ROOT, dir))) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const match of text.matchAll(EMAIL_RE)) {
|
||||
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
|
||||
const line = text.slice(0, match.index).split('\n').length;
|
||||
offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offenders.length) {
|
||||
failures.push({
|
||||
label: CONTACT_CHECK,
|
||||
expectedLabel: 'PLAN.md D13 requires',
|
||||
expected: 'no email address in src/ or scripts/',
|
||||
authority: ' found',
|
||||
actual: `${offenders.length}:\n ` + offenders.join('\n '),
|
||||
ok: false,
|
||||
});
|
||||
} else {
|
||||
checked.push({ label: CONTACT_CHECK, expected: 'none', actual: 'none', ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
if (!TOKEN) {
|
||||
console.error(
|
||||
'checkFacts: GITEA_TOKEN is not set.\n\n' +
|
||||
' Anonymous raw fetches fail on this Gitea instance, and a fact check that skips\n' +
|
||||
' itself is worse than no fact check — a stale version would ship silently.\n\n' +
|
||||
' Locally: GITEA_TOKEN=$(grep -o "[^=]*$" ~/.gitea_token_claude) npm run check:facts\n' +
|
||||
' In CI: set GITEA_TOKEN from the repository secret.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
await checkContactAddressIsIsolated();
|
||||
|
||||
const network = [
|
||||
checkProtocol,
|
||||
checkOverlayProtocol,
|
||||
checkModuleApi,
|
||||
checkBundle,
|
||||
checkReleases,
|
||||
checkWebsiteHasNoReleases,
|
||||
];
|
||||
|
||||
for (const check of network) {
|
||||
try {
|
||||
await check();
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
label: check.name,
|
||||
expected: 'a readable authority',
|
||||
actual: error.message,
|
||||
authority: 'Gitea API',
|
||||
ok: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of checked) {
|
||||
console.log(` ok ${row.label.padEnd(28)} ${row.actual}`);
|
||||
}
|
||||
|
||||
if (!failures.length) {
|
||||
console.log(
|
||||
`\ncheckFacts: ${checked.length} facts agree with their authorities ` +
|
||||
`(platform.json verified ${platform.verifiedOn}).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('\ncheckFacts: the platform has moved, or the site is wrong.\n');
|
||||
for (const row of failures) {
|
||||
console.error(` FAIL ${row.label}`);
|
||||
console.error(` ${row.expectedLabel} : ${row.expected}`);
|
||||
console.error(` ${row.authority} : ${row.actual}`);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
if (failures.some((row) => row.label === CONTACT_CHECK)) {
|
||||
console.error(
|
||||
'The contact address belongs in brand-default/brand.json and nowhere else. D13\n' +
|
||||
'publishes a personal address on the promise that replacing it later costs one\n' +
|
||||
'file copy, and an address typed into a page is a copy no mount can reach.\n' +
|
||||
'Read it through src/lib/brand.mjs instead.\n'
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.some((row) => row.label !== CONTACT_CHECK)) {
|
||||
console.error(
|
||||
'Update src/data/platform.json AND re-read every page that quotes the changed value.\n' +
|
||||
'Do not edit a value here to make this pass — §1 exists because that is how the\n' +
|
||||
'wrong protocol number got written down in the first place.\n'
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
173
scripts/checkTokens.mjs
Normal file
173
scripts/checkTokens.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkTokens.mjs — PLAN.md §7
|
||||
*
|
||||
* The promise: recolouring the site is a file copy and a container restart. Drop a
|
||||
* `theme.css` into the brand mount, redefine some custom properties, restart — never a
|
||||
* rebuild, never an image push.
|
||||
*
|
||||
* That holds only while every colour, radius, shadow and font in the stylesheet is a
|
||||
* custom property defined in `src/styles/tokens.css`, because a mounted `theme.css` can
|
||||
* only redefine properties — it cannot reach a value that was written directly into a
|
||||
* rule. Without a check, "one CSS file changes the appearance" decays into "one CSS file
|
||||
* changes most of the appearance, and then there is a hardcoded #0e1318 in the footer".
|
||||
*
|
||||
* So this fails the build when a colour literal appears anywhere in `src/` outside the
|
||||
* token file. The check is the mechanism; diligence is not.
|
||||
*
|
||||
* node scripts/checkTokens.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync } 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));
|
||||
|
||||
/** The one file allowed to hold literals — it is the definition site. */
|
||||
const TOKEN_FILE = path.join('src', 'styles', 'tokens.css');
|
||||
|
||||
const SCAN_DIRS = ['src'];
|
||||
const SCAN_EXT = new Set(['.css', '.astro', '.svg', '.ts', '.tsx', '.js', '.mjs', '.html']);
|
||||
|
||||
/**
|
||||
* Deliberately excludes `.md` and `.mdx`: documentation pages quote hex values as prose
|
||||
* (an env var's default, a token's stock value in the theming guide) and that is content,
|
||||
* not styling. If a docs page ever carries a real inline style, it is doing something the
|
||||
* design system should own instead.
|
||||
*/
|
||||
|
||||
const PATTERNS = [
|
||||
{ name: 'hex colour', re: /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b(?![0-9a-z_-])/gi },
|
||||
{ name: 'rgb()/rgba()', re: /\brgba?\s*\(/gi },
|
||||
{ name: 'hsl()/hsla()', re: /\bhsla?\s*\(/gi },
|
||||
{ name: 'modern colour function', re: /\b(?:oklch|oklab|lab|lch|color)\s*\(/gi },
|
||||
{
|
||||
name: 'named colour',
|
||||
// Only the ones a developer actually reaches for by accident. `transparent`,
|
||||
// `currentColor` and `inherit` are keywords, not colours, and stay legal.
|
||||
re: /(?<![\w-])(?:white|black|red|green|blue|gray|grey|silver|gold|orange|yellow|purple|navy|teal|cyan|magenta)(?![\w-])/gi,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Strip comments before scanning. The token file's own rationale, and this script's, both
|
||||
* quote `#0e1318` in prose — a checker that flagged its own explanation would be its own
|
||||
* first false positive.
|
||||
*/
|
||||
function stripComments(source, ext) {
|
||||
let out = source.replace(/\/\*[\s\S]*?\*\//g, ' '); // CSS + JS block comments
|
||||
out = out.replace(/<!--[\s\S]*?-->/g, ' '); // HTML/Astro/SVG comments
|
||||
if (ext !== '.css' && ext !== '.svg') {
|
||||
// Line comments, but not the `//` inside a URL.
|
||||
out = out.replace(/(^|[^:\w])\/\/[^\n]*/g, '$1 ');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Astro frontmatter and component script are JavaScript, where a bare word like `black`
|
||||
* is usually an identifier or a string of prose rather than a colour. Named-colour
|
||||
* matching is therefore restricted to files and regions that are actually CSS.
|
||||
*/
|
||||
function scanText(text, isStyleRegion) {
|
||||
const hits = [];
|
||||
for (const { name, re } of PATTERNS) {
|
||||
if (name === 'named colour' && !isStyleRegion) continue;
|
||||
re.lastIndex = 0;
|
||||
for (const match of text.matchAll(re)) {
|
||||
hits.push({ name, value: match[0], index: match.index });
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function lineOf(source, index) {
|
||||
return source.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
yield* walk(full);
|
||||
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const offenders = [];
|
||||
let scanned = 0;
|
||||
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for await (const file of walk(path.join(ROOT, dir))) {
|
||||
const relative = path.relative(ROOT, file);
|
||||
if (relative === TOKEN_FILE) continue;
|
||||
|
||||
const ext = path.extname(file);
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const stripped = stripComments(source, ext);
|
||||
scanned++;
|
||||
|
||||
const isCssFile = ext === '.css';
|
||||
let hits = [];
|
||||
|
||||
if (ext === '.astro') {
|
||||
// Only the <style> blocks are CSS; the rest is a component script and markup.
|
||||
hits = hits.concat(scanText(stripped, false));
|
||||
for (const block of stripped.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
|
||||
const offset = block.index + block[0].indexOf(block[1]);
|
||||
for (const hit of scanText(block[1], true)) {
|
||||
hits.push({ ...hit, index: hit.index + offset });
|
||||
}
|
||||
}
|
||||
// De-duplicate: the non-CSS pass already saw the style block's hex values.
|
||||
const seen = new Set();
|
||||
hits = hits.filter((hit) => {
|
||||
const key = `${hit.index}:${hit.value}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
hits = scanText(stripped, isCssFile || ext === '.svg');
|
||||
}
|
||||
|
||||
for (const hit of hits) {
|
||||
offenders.push({
|
||||
file: relative,
|
||||
line: lineOf(stripped, hit.index),
|
||||
kind: hit.name,
|
||||
value: hit.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!offenders.length) {
|
||||
console.log(
|
||||
`checkTokens: ${scanned} files scanned, every colour comes from ${TOKEN_FILE}.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error('\ncheckTokens: colour literals found outside the token file.\n');
|
||||
for (const offender of offenders) {
|
||||
console.error(` ${offender.file}:${offender.line} ${offender.kind} ${offender.value}`);
|
||||
}
|
||||
console.error(
|
||||
`\nDefine the colour as a custom property in ${TOKEN_FILE} and reference it with var().\n` +
|
||||
'PLAN.md §7: a bind-mounted theme.css can only redefine properties, so a literal here\n' +
|
||||
'is a piece of the site that an operator can never recolour. Use currentColor in SVG,\n' +
|
||||
'and src/lib/tokens.mjs where a value genuinely has to reach JavaScript.\n'
|
||||
);
|
||||
process.exit(1);
|
||||
Reference in New Issue
Block a user