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>
174 lines
6.1 KiB
JavaScript
174 lines
6.1 KiB
JavaScript
#!/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);
|