clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / check-css.ts
check-css.ts 10.6K
1// The design rules (DESIGN.md), checked: CI runs this over the forge's CSS,2// which is site/assets/style.css, the {% css %} blocks and style=""3// attributes in site/'s templates, and the guide's guide.css. Outside a4// stylesheet's token block (between "== tokens ==" and "== end tokens =="5// comments) a declaration may not name a colour, a font size, weight or6// family: those are tokens. Nowhere may anything be rounded, capitalised7// by CSS, or letter-spaced. And no file in site/, lib/ or guide/ holds a8// middle dot (U+00B7).9//10// node lib/check-css.ts the forge's files11// node lib/check-css.ts <path>... these files, or what is in these12// directories (.css, .njk; other files13// are checked for the middle dot only)14import fs from 'node:fs';15import path from 'node:path';16import { fileURLToPath } from 'node:url';1718const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');1920export interface Problem {21 file: string;22 line: number;23 message: string;24}2526// CSS named colours; transparent, currentColor and system colours are fine.27const NAMED = new Set(28 ('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral ' +29 'cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange ' +30 'darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey ' +31 'dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ' +32 'ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink ' +33 'lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine ' +34 'mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose ' +35 'moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru ' +36 'pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue ' +37 'slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '),38);39const COLOUR_FN = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i;40const HEX = /#[0-9a-f]{3,8}\b/i;41const DOT = String.fromCharCode(0xb7);4243// var(...) taken out, nested ones too, so what is left is what the44// declaration says itself.45const withoutVars = (v: string): string => {46 let out = v;47 for (let was = ''; was !== out; ) {48 was = out;49 out = out.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ' ');50 }51 return out;52};5354const namesColour = (v: string): string | null => {55 const bare = v.replace(/url\([^)]*\)/g, ' ');56 const m = HEX.exec(bare) ?? COLOUR_FN.exec(bare);57 if (m) return m[0];58 for (const w of bare.toLowerCase().match(/[a-z-]+/g) ?? []) if (NAMED.has(w)) return w;59 return null;60};6162// One declaration's problems. tokens: it is in a token block.63function declaration(prop: string, value: string, tokens: boolean): string[] {64 const p = prop.toLowerCase();65 const v = value.trim();66 const bad: string[] = [];67 if (p === 'letter-spacing') bad.push(`letter-spacing (${v}): no tracking; hierarchy comes from weight, size and colour`);68 if (p === 'text-transform' && /\b(uppercase|capitalize)\b/i.test(v)) bad.push(`text-transform: ${v}: write chrome lowercase instead`);69 if (/(^|-)radius$/.test(p) && !/^(0(px)?|var\(--radius\))$/.test(v)) bad.push(`${p}: ${v}: corners are square`);70 if (tokens) return bad;71 const c = namesColour(v);72 if (c) bad.push(`${p}: a colour literal (${c}) outside the tokens; use a colour token`);73 const own = withoutVars(v).trim();74 if (p === 'font-size' && own && own !== 'inherit') bad.push(`font-size: ${v}: use a --text-* token`);75 if (p === 'font-weight' && own && own !== 'inherit') bad.push(`font-weight: ${v}: use a --fw-* token`);76 if (p === 'font-family' && own && own !== 'inherit') bad.push(`font-family: ${v}: use --sans, --mono or --serif`);77 if (p === 'font') {78 // What may be left of the shorthand: a line height after the slash.79 const rest = own.replace(/\/\s*[\d.]+(?:px|em|rem|%)?/g, ' ').trim();80 if (rest && rest !== 'inherit') bad.push(`font: ${v}: name the weight, size and family with tokens`);81 }82 return bad;83}8485// A stylesheet's problems. offset: the line it starts on in its file;86// tokensAllowed: it may have a token block (a stylesheet, not a template).87export function checkCss(css: string, file: string, offset = 1, tokensAllowed = true): Problem[] {88 const out: Problem[] = [];89 // Comments become spaces (newlines kept, for line numbers); the token90 // block is where its markers say.91 let text = '';92 const inTokens: boolean[] = [];93 let tokens = false;94 for (let i = 0; i < css.length; ) {95 if (css.startsWith('/*', i)) {96 const end = css.indexOf('*/', i + 2);97 const c = css.slice(i, end < 0 ? css.length : end + 2);98 if (/==\s*end tokens\s*==/.test(c)) tokens = false;99 else if (/==\s*tokens\s*==/.test(c)) {100 if (!tokensAllowed) out.push({ file, line: offset + (css.slice(0, i).match(/\n/g)?.length ?? 0), message: 'a token block belongs in style.css, not a template' });101 tokens = tokensAllowed;102 }103 for (const ch of c) {104 text += ch === '\n' ? '\n' : ' ';105 inTokens.push(tokens);106 }107 i += c.length;108 } else {109 text += css[i];110 inTokens.push(tokens);111 i++;112 }113 }114 // Declarations are what comes before a ; or }, after the last { ; or };115 // what comes before a { is a selector or an at-rule's prelude.116 let start = 0;117 let quote = '';118 let paren = 0;119 for (let i = 0; i < text.length; i++) {120 const ch = text[i];121 if (quote) {122 if (ch === '\\') i++;123 else if (ch === quote) quote = '';124 continue;125 }126 if (ch === '"' || ch === "'") quote = ch;127 else if (ch === '(') paren++;128 else if (ch === ')') paren--;129 else if (paren === 0 && (ch === '{' || ch === '}' || ch === ';')) {130 const seg = text.slice(start, i);131 if (ch !== '{') {132 const m = /^\s*(-?-?[a-zA-Z][\w-]*)\s*:([\s\S]*)$/.exec(seg);133 if (m) {134 const at = start + seg.search(/\S/);135 const line = offset + (text.slice(0, at).match(/\n/g)?.length ?? 0);136 for (const message of declaration(m[1], m[2], inTokens[at])) out.push({ file, line, message });137 }138 }139 start = i + 1;140 }141 }142 return out;143}144145// A template's CSS: its {% css %} blocks and style="" attributes.146export function checkTemplate(src: string, file: string): Problem[] {147 const out: Problem[] = [];148 const lineAt = (i: number): number => 1 + (src.slice(0, i).match(/\n/g)?.length ?? 0);149 for (const m of src.matchAll(/\{%-?\s*css\s*-?%\}([\s\S]*?)\{%-?\s*endcss\s*-?%\}/g)) {150 const body = m[1];151 out.push(...checkCss(body, file, lineAt(m.index + m[0].indexOf(body)), false));152 }153 for (const m of src.matchAll(/\sstyle="([^"]*)"/g)) out.push(...checkCss(m[1] + ';', file, lineAt(m.index), false));154 return out;155}156157export function checkDot(text: string, file: string): Problem[] {158 const out: Problem[] = [];159 text.split('\n').forEach((l, n) => {160 if (l.includes(DOT)) out.push({ file, line: n + 1, message: 'a middle dot (U+00B7): use a hyphen, comma or pipe' });161 });162 return out;163}164165export interface Token {166 name: string;167 light: string;168 dark: string | null;169}170171// The tokens in a stylesheet's token block, as the style guide (/design/)172// shows them: in groups, each titled by the comment above it, with each173// token's value and its dark-mode value if it has one.174export function tokens(css: string): { title: string; tokens: Token[] }[] {175 const block = /==\s*tokens\s*==[\s\S]*?\*\/([\s\S]*?)\/\*\s*==\s*end tokens\s*==/.exec(css)?.[1] ?? '';176 const light = /(?:^|\n):root\s*\{([\s\S]*?)\n\}/.exec(block)?.[1] ?? '';177 const darkBlock = /prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([\s\S]*?)\}/.exec(block)?.[1] ?? '';178 const dark = new Map([...darkBlock.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));179 const groups: { title: string; tokens: Token[] }[] = [];180 for (const m of light.matchAll(/\/\*([\s\S]*?)\*\/|(--[\w-]+)\s*:\s*([^;]+);/g)) {181 if (m[1] != null) groups.push({ title: m[1].replace(/\s+/g, ' ').trim(), tokens: [] });182 else {183 if (!groups.length) groups.push({ title: '', tokens: [] });184 groups[groups.length - 1].tokens.push({ name: m[2], light: m[3].trim(), dark: dark.get(m[2]) ?? null });185 }186 }187 return groups.filter((g) => g.tokens.length);188}189190// The forge's own, from style.css.191export const forgeTokens = (): { title: string; tokens: Token[] }[] => tokens(fs.readFileSync(path.join(root, 'site', 'assets', 'style.css'), 'utf8'));192193const BINARY = /\.(woff2?|ttf|otf|webp|png|jpe?g|gif|avif|ico|wasm|db|parquet)$/i;194195function walk(dir: string): string[] {196 return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {197 const p = path.join(dir, e.name);198 return e.isDirectory() ? (e.name === 'node_modules' ? [] : walk(p)) : [p];199 });200}201202export function check(files: string[]): Problem[] {203 const out: Problem[] = [];204 for (const f of files) {205 if (BINARY.test(f)) continue;206 const text = fs.readFileSync(f, 'utf8');207 const name = path.relative(root, f) || f;208 out.push(...checkDot(text, name));209 if (f.endsWith('.css')) out.push(...checkCss(text, name));210 else if (f.endsWith('.njk')) out.push(...checkTemplate(text, name));211 }212 return out;213}214215if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {216 const args = process.argv.slice(2);217 const files = (args.length ? args : ['site', 'lib', 'guide'].map((d) => path.join(root, d))).flatMap((a) => (fs.statSync(a).isDirectory() ? walk(path.resolve(a)) : [path.resolve(a)]));218 const problems = check(files);219 for (const p of problems) console.error(`${p.file}:${p.line}: ${p.message}`);220 if (problems.length) {221 console.error(`${problems.length} problem${problems.length === 1 ? '' : 's'}; the rules are in DESIGN.md`);222 process.exit(1);223 }224 console.log(`css: ${files.filter((f) => /\.(css|njk)$/.test(f)).length} stylesheets and templates keep to DESIGN.md`);225}