Initial commit
This commit is contained in:
commit
89053153cc
8152 files changed
+2086461
No files matched your search
@@ -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`,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user