apps/glyph Public The code is public; the app answers only to 1 person who can change it and the crew.
Files 20b002cea95af30a05955adee40ddec8a6b7120a
| 1 | /** |
| 2 | * Every colour comes from `design/tokens.json`. This refuses a raw one — |
| 3 | * `#hex`, `rgb(`, `hsl(` — anywhere else the app draws from: `static/` |
| 4 | * and `src/`, tests aside. A colour spelled out in a stylesheet is a colour |
| 5 | * the dark theme never hears about. It refuses a `var(--…)` in a stylesheet |
| 6 | * that names no token, too: a typo there is a page quietly unstyled. |
| 7 | * |
| 8 | * bun run lint:design exit 1 and one line per raw colour, or silence |
| 9 | */ |
| 10 | |
| 11 | import { readdirSync, readFileSync } from "node:fs"; |
| 12 | import { join } from "node:path"; |
| 13 | import { tokens } from "../src/tokens.ts"; |
| 14 | |
| 15 | const RAW = |
| 16 | /(?<![\w&])#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])|\b(?:rgba?|hsla?)\(/gi; |
| 17 | |
| 18 | /** The raw colours in one file's text: `path:line: …`, one per find. */ |
| 19 | export function rawColours(path: string, text: string): string[] { |
| 20 | return text |
| 21 | .split("\n") |
| 22 | .flatMap((line, i) => |
| 23 | [...line.matchAll(RAW)].map( |
| 24 | (m) => |
| 25 | `${path}:${i + 1}: raw colour ${m[0]} — name it in design/tokens.json and use var(--…)`, |
| 26 | ), |
| 27 | ); |
| 28 | } |
| 29 | |
| 30 | /** The custom properties a stylesheet uses that no token defines. */ |
| 31 | export function unknownTokens( |
| 32 | path: string, |
| 33 | text: string, |
| 34 | known = new Set(tokens.map((t) => t.name)), |
| 35 | ): string[] { |
| 36 | return text |
| 37 | .split("\n") |
| 38 | .flatMap((line, i) => |
| 39 | [...line.matchAll(/var\((--[\w-]+)/g)] |
| 40 | .filter((m) => !known.has(m[1]!)) |
| 41 | .map( |
| 42 | (m) => |
| 43 | `${path}:${i + 1}: ${m[1]} is not a token in design/tokens.json`, |
| 44 | ), |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | /** The files the lint reads: what the page is drawn from, never the tokens themselves or the tests. */ |
| 49 | export function drawnFrom(root: string): string[] { |
| 50 | const walk = (dir: string): string[] => |
| 51 | readdirSync(join(root, dir), { withFileTypes: true }).flatMap((entry) => { |
| 52 | const path = join(dir, entry.name); |
| 53 | if (entry.isDirectory()) return walk(path); |
| 54 | return /\.(css|js|ts|html)$/.test(entry.name) && |
| 55 | !/\.test\.ts$/.test(entry.name) |
| 56 | ? [path] |
| 57 | : []; |
| 58 | }); |
| 59 | return ["static", "src"].flatMap(walk); |
| 60 | } |
| 61 | |
| 62 | export function lint(root: string): string[] { |
| 63 | return drawnFrom(root).flatMap((path) => { |
| 64 | const text = readFileSync(join(root, path), "utf8"); |
| 65 | return [ |
| 66 | ...rawColours(path, text), |
| 67 | ...(path.endsWith(".css") ? unknownTokens(path, text) : []), |
| 68 | ]; |
| 69 | }); |
| 70 | } |
| 71 | |
| 72 | if (import.meta.main) { |
| 73 | const problems = lint(join(import.meta.dir, "..")); |
| 74 | // Generated app's lint CLI: stderr is its output. |
| 75 | for (const problem of problems) console.error(problem); |
| 76 | process.exit(problems.length === 0 ? 0 : 1); |
| 77 | } |