#!/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: /(?/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