#!/usr/bin/env node /** * Theme Validation Script * Ensures no hardcoded colors are used in the codebase */ import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Color patterns to detect const FORBIDDEN_PATTERNS = [ // Hardcoded hex colors /#[0-9a-fA-F]{3,6}/g, // Tailwind color classes we don't want /\b(slate|gray|zinc|neutral|stone)-\d+/g, /\b(red|green|blue|yellow|purple|pink|indigo|cyan|teal|orange|amber|lime|emerald|sky|violet|fuchsia|rose)-\d+/g, // Generic color names in class names /\b(text|bg|border)-(white|black)\b/g, // RGB/RGBA functions /rgba?\s*\([^)]+\)/g, // HSL functions /hsla?\s*\([^)]+\)/g, // Gradient utilities with hardcoded colors /\bfrom-(slate|gray|white|black|red|green|blue|yellow|purple|pink|indigo|cyan|teal|orange|amber|lime|emerald|sky|violet|fuchsia|rose)-\d+/g, /\bto-(slate|gray|white|black|red|green|blue|yellow|purple|pink|indigo|cyan|teal|orange|amber|lime|emerald|sky|violet|fuchsia|rose)-\d+/g, ]; // Files to check const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.css']; const EXCLUDED_DIRS = ['node_modules', '.git', 'dist', 'build']; const EXCLUDED_FILES = [ 'tailwind.config.js', // Allow CSS variables in config 'tokens.css', // Allow generated CSS variables 'validate-theme.js', // Allow this script itself ]; // Allowed exceptions (for documentation, comments, etc.) const ALLOWED_EXCEPTIONS = [ // Comments about colors /\/\*.*?(#[0-9a-fA-F]{3,6}|slate-\d+).*?\*\//g, /\/\/.*?(#[0-9a-fA-F]{3,6}|slate-\d+)/g, // String literals (not class names) /"[^"]*?(#[0-9a-fA-F]{3,6}|slate-\d+)[^"]*?"/g, /'[^']*?(#[0-9a-fA-F]{3,6}|slate-\d+)[^']*?'/g, // Console.log and similar /console\.(log|warn|error).*?(#[0-9a-fA-F]{3,6}|slate-\d+)/g, ]; function getAllFiles(dir, extensions = EXTENSIONS) { let results = []; const list = fs.readdirSync(dir); list.forEach(file => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat && stat.isDirectory()) { if (!EXCLUDED_DIRS.includes(file)) { results = results.concat(getAllFiles(filePath, extensions)); } } else { const ext = path.extname(file); const basename = path.basename(file); if (extensions.includes(ext) && !EXCLUDED_FILES.includes(basename)) { results.push(filePath); } } }); return results; } function removeAllowedExceptions(content) { let cleaned = content; ALLOWED_EXCEPTIONS.forEach(pattern => { cleaned = cleaned.replace(pattern, ''); }); return cleaned; } function validateFile(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const cleanedContent = removeAllowedExceptions(content); const violations = []; FORBIDDEN_PATTERNS.forEach(pattern => { const matches = [...cleanedContent.matchAll(pattern)]; matches.forEach(match => { const lineNumber = content.substring(0, match.index).split('\n').length; violations.push({ file: filePath, line: lineNumber, match: match[0], pattern: pattern.toString(), }); }); }); return violations; } function main() { console.log('🎨 Validating theme system...\n'); const projectRoot = path.join(__dirname, '..'); const srcDir = path.join(projectRoot, 'src'); const files = getAllFiles(srcDir); console.log(`Checking ${files.length} files for hardcoded colors...\n`); let totalViolations = 0; const violationsByFile = {}; files.forEach(file => { const violations = validateFile(file); if (violations.length > 0) { violationsByFile[file] = violations; totalViolations += violations.length; } }); if (totalViolations === 0) { console.log('✅ No hardcoded colors found! Theme system is clean.\n'); // Additional check: ensure semantic classes are being used console.log('🔍 Checking for proper semantic token usage...\n'); const semanticPatterns = [ /\btext-text-(primary|secondary|muted|inverse|disabled)\b/g, /\bbg-bg-(primary|secondary|tertiary|elevated|overlay)\b/g, /\bborder-border\b/g, /\baccent-(primary|secondary|gold)-\d+/g, ]; let semanticUsageCount = 0; files.forEach(file => { const content = fs.readFileSync(file, 'utf8'); semanticPatterns.forEach(pattern => { const matches = content.match(pattern); if (matches) { semanticUsageCount += matches.length; } }); }); console.log(`✅ Found ${semanticUsageCount} instances of semantic token usage.\n`); return process.exit(0); } console.log(`❌ Found ${totalViolations} hardcoded color violations:\n`); Object.entries(violationsByFile).forEach(([file, violations]) => { console.log(`📄 ${file}:`); violations.forEach(violation => { console.log(` Line ${violation.line}: "${violation.match}"`); }); console.log(''); }); console.log('🚨 VALIDATION FAILED!\n'); console.log('Please replace hardcoded colors with semantic tokens from the design system.'); console.log('See THEMING.md for guidance on proper token usage.\n'); console.log('Common replacements:'); console.log(' text-slate-900 → text-text-primary'); console.log(' text-slate-600 → text-text-secondary'); console.log(' bg-white → bg-bg-primary'); console.log(' border-slate-200 → border-border'); console.log(' #3b82f6 → Use accent-primary-500 or similar\n'); process.exit(1); } main();