258 lines
10 KiB
JavaScript
258 lines
10 KiB
JavaScript
#!/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`,
|
|
)
|
|
}
|