Initial commit

This commit is contained in:
mats committed 2026-10-19 16:04:17 +02:00
commit afed023152
8152 files changed
+2086461

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env node
/* Token contract guard — runs as part of `npm run check`.
Bans hardcoded colors/fonts so every visual change goes through src/tokens.css.
What it checks:
• every CSS file under src/ (except tokens.css): hex colors anywhere, named
colors, rgb()/rgba(), hsl()/oklch()/lab()/…, px font sizes — including
values split across multiple lines.
• every .ts/.tsx file under src/: the same color rules inside inline
`style={{ ... }}` objects.
• every `var(--x)` reference (CSS + inline styles) must be defined in some CSS
file, so a typo'd token fails the check instead of silently rendering nothing.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
const ROOTS = process.argv.length > 2 ? process.argv.slice(2) : ['src']
const EXEMPT = new Set(['tokens.css'])
// Neutral shine/shadow literals stay allowed (they are surfaces, not palette).
const RGB_ALLOW = [/rgba\(\s*0\s*,\s*0\s*,\s*0/, /rgba\(\s*255\s*,\s*255\s*,\s*255/]
// CSS named colors (CSS Color 4 list; 'transparent'/'currentColor' are keywords, not colors).
const NAMED_COLORS = (
'aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,' +
'burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,' +
'darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,' +
'darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,' +
'deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,' +
'ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,' +
'lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,' +
'lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,' +
'lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,' +
'mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,' +
'mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,' +
'orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,' +
'pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,' +
'seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,' +
'teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen'
).split(',')
const NAMED_COLOR_RE = new RegExp(`\\b(${NAMED_COLORS.join('|')})\\b`, 'gi')
// Functions whose *arguments* may legitimately contain colors only via tokens/color-mix.
const COLOR_FN_RE = /\b(rgb|rgba|hsl|hsla|hwb|oklch|oklcha|lab|lch|lcha|color|color-mix)\(/i
const violations = []
const definedTokens = new Set()
const usedTokens = new Map() // token name -> first usage site
function* walk(dir) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
yield* walk(full)
} else {
yield full
}
}
}
function stripComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, '')
}
function lineOf(text, index) {
let line = 1
for (let i = 0; i < index && i < text.length; i++) {
if (text[i] === '\n') line++
}
return line
}
function noteTokens(value, site) {
for (const m of value.matchAll(/var\(\s*(--[\w-]+)/g)) {
if (!usedTokens.has(m[1])) usedTokens.set(m[1], site)
}
}
/** Colors are only allowed as var() references or color-mix/light-dark over tokens.
Strategy: strip every token-driven construct, then flag any color literal left. */
function stripTokenColors(value) {
let v = value
for (let i = 0; i < 6; i++) {
const next = v
.replace(/var\([^()]*\)/gi, ' ')
.replace(/color-mix\([^()]*\)/gi, ' ')
.replace(/light-dark\([^()]*\)/gi, ' ')
if (next === v) return v
v = next
}
return v
}
function isTokenOnlyValue(value) {
if (RGB_ALLOW.some(re => re.test(value))) return true
const residue = stripTokenColors(value)
return !COLOR_FN_RE.test(residue) && !NAMED_COLOR_RE.test(residue) && !/#[0-9a-fA-F]{3,8}\b/.test(residue)
}
function checkColorValue(value, site, what) {
const hex = value.match(/#[0-9a-fA-F]{3,8}\b/)
if (hex) {
violations.push(`${site} hex color ${hex[0]} in ${what} — add a token in tokens.css instead`)
return
}
if (isTokenOnlyValue(value)) return
const fn = value.match(COLOR_FN_RE)
if (fn) {
violations.push(
`${site} ${fn[1]}() color in ${what} — use var(--token) or color-mix(in srgb, var(--token) N%, transparent)`,
)
return
}
const named = value.match(NAMED_COLOR_RE)
if (named) {
violations.push(`${site} named color "${named[0]}" in ${what} — add a token in tokens.css instead`)
}
}
function checkFontPx(value, site, what) {
const px = value.match(/-?\d+(\.\d+)?px/i)
if (px) violations.push(`${site} px font size (${px[0]}) in ${what} — use var(--text-*)`)
}
const COLOR_PROP_RE =
/(^|-)(color|background|border|fill|stroke|shadow|outline|caret|accent|text-decoration|column-rule)/i
const FONT_PROP_RE = /^font(-?size)?$/i
/* ── CSS files ──────────────────────────────────────────────────── */
function checkCssFile(file, css) {
const text = stripComments(css)
// Token definitions are collected from every CSS file (tokens.css included).
for (const m of text.matchAll(/--[\w-]+\s*:/g)) {
definedTokens.add(m[0].replace(/\s*:.*$/s, '').trim())
}
// tokens.css is the source of truth — collect definitions, skip value checks.
if (EXEMPT.has(file.split('/').pop())) return
// Hex anywhere (same contract as the original line-based guard, but comment-safe).
for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) {
violations.push(
`${file}:${lineOf(css, m.index ?? 0)} hex color ${m[0]} — add a token in tokens.css instead`,
)
}
// Declarations may span lines: `prop: value;` up to the next `;` or `}`.
// Lookbehind keeps the anchor zero-width so every declaration in a block matches.
for (const m of text.matchAll(/(^|(?<=[{;]))\s*([-a-zA-Z]+)\s*:\s*([^;{}]+)[;}]/g)) {
const prop = m[2].trim().toLowerCase()
const value = m[3].trim()
const site = `${file}:${lineOf(css, m.index ?? 0)}`
const what = `"${prop}" declaration`
if (COLOR_PROP_RE.test(prop)) checkColorValue(value, site, what)
if (FONT_PROP_RE.test(prop)) checkFontPx(value, site, what)
noteTokens(value, site)
}
// Named colors can also hide inside longhand gradients/shadows — sweep the whole file,
// skipping property-name positions (e.g. `white-space`).
for (const m of text.matchAll(NAMED_COLOR_RE)) {
const idx = m.index ?? 0
const declStart =
Math.max(text.lastIndexOf(';', idx), text.lastIndexOf('{', idx), text.lastIndexOf('}', idx)) + 1
const segment = text.slice(declStart, idx)
const colon = segment.lastIndexOf(':')
if (colon === -1) continue // match is in a selector, not a value
const prop = segment.slice(0, colon).trim().toLowerCase()
if (prop.startsWith('--')) continue
if (!isTokenOnlyValue(text.slice(idx - 2, idx + m[0].length + 20))) {
violations.push(
`${file}:${lineOf(css, idx)} named color "${m[0]}" in "${prop}" — add a token in tokens.css instead`,
)
}
}
}
/* ── TS/TSX inline styles ───────────────────────────────────────── */
/** Find balanced `style={{ ... }}` object literals; returns their bodies. */
function extractStyleObjects(text) {
const objects = []
const re = /style\s*=\s*\{\s*\{/g
let m
while ((m = re.exec(text))) {
let depth = 2
let i = re.lastIndex
let inString = null
for (; i < text.length && depth > 0; i++) {
const ch = text[i]
if (inString) {
if (ch === '\\') i++
else if (ch === inString) inString = null
} else if (ch === "'" || ch === '"' || ch === '`') {
inString = ch
} else if (ch === '{') {
depth++
} else if (ch === '}') {
depth--
}
}
objects.push({ body: text.slice(re.lastIndex, i - 1), index: re.lastIndex })
re.lastIndex = i
}
return objects
}
function checkTsxFile(file, source) {
const text = stripComments(source)
for (const { body, index } of extractStyleObjects(text)) {
// Split `key: value` pairs on commas that are followed by another `key:`
// (whitespace/newlines allowed between them).
for (const m of body.matchAll(/([-a-zA-Z]+)\s*:\s*((?:[^,]|\,(?!\s*[a-zA-Z-]+\s*:))*)/g)) {
const prop = m[1].trim().toLowerCase()
const value = m[2].trim()
if (!value || value === "'{" || prop.startsWith('--')) continue
const site = `${file}:${lineOf(source, index)}`
const what = `style={{ ${prop} }}`
if (COLOR_PROP_RE.test(prop)) checkColorValue(value, site, what)
if (FONT_PROP_RE.test(prop)) checkFontPx(value, site, what)
noteTokens(value, site)
}
}
}
/* ── Run ────────────────────────────────────────────────────────── */
const cssFiles = []
const tsFiles = []
for (const root of ROOTS) {
for (const f of walk(root)) {
if (f.endsWith('.css')) cssFiles.push(f)
else if (f.endsWith('.tsx') || f.endsWith('.ts')) tsFiles.push(f)
}
}
for (const f of cssFiles) checkCssFile(f, readFileSync(f, 'utf8'))
for (const f of tsFiles) checkTsxFile(f, readFileSync(f, 'utf8'))
// Cross-check: every referenced token must exist (catches typos that silently render nothing).
for (const [token, site] of usedTokens) {
if (!definedTokens.has(token)) {
violations.push(`${site} var(${token}) is never defined — typo, or add it to tokens.css`)
}
}
if (violations.length) {
console.error(`\n✗ token contract violations:\n\n${[...new Set(violations)].join('\n')}\n`)
process.exit(1)
} else {
console.log(
`✓ token contract OK — ${cssFiles.length} css + ${tsFiles.length} ts/tsx files clean, ` +
`${usedTokens.size} var() references all defined`,
)
}