UNPKG

@johnmusans/tailwindcss-auto-contrast

Version:

Tailwind CSS plugin for automatic contrast color generation with Arcadia UI

366 lines (299 loc) 9.52 kB
// TODO: This plugin needs a refactor. it's still wip const plugin = require("tailwindcss/plugin"); const fg = require("fast-glob"); const fs = require("fs"); const path = require("path"); const postcss = require("postcss"); const PROJECT_SHARED_IGNORE = [ "**/node_modules/**", ".next", "public", "dist", "build", ]; function getTailwindCssFile(cwd) { const files = fg.globSync(["**/*.css", "**/*.scss"], { cwd, deep: 5, ignore: PROJECT_SHARED_IGNORE, }); if (!files.length) { console.log("No css files found in", cwd); return null; } for (const file of files) { const contents = fs.readFileSync(path.resolve(cwd, file), "utf8"); if ( contents.includes(`@import "tailwindcss"`) || contents.includes(`@import 'tailwindcss'`) ) { return file; } } return null; } function isColor(value) { if (!value || typeof value !== "string") return false; const trimmed = value.trim().toLowerCase(); // Check for hex colors: #fff, #ffffff, #123abc if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) return true; // Check for rgb/rgba: rgb(255, 255, 255), rgba(255, 255, 255, 0.5) if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[\d.]+)?\s*\)$/i.test(trimmed)) return true; // Check for hsl/hsla: hsl(360, 100%, 50%), hsla(360, 100%, 50%, 0.5) if ( /^hsla?\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*(,\s*[\d.]+)?\s*\)$/i.test(trimmed) ) return true; // Check for newer CSS color functions: lab(), lch(), color(), etc. if (/^(lab|lch|color|oklab|oklch|hwb)\s*\(/i.test(trimmed)) return true; // Check for CSS variables that might contain colors: var(--some-color) if (/^var\s*\(\s*--[\w-]+\s*(,.*?)?\s*\)$/i.test(trimmed)) return true; return false; } function parseColorToRgb(colorValue) { if (!colorValue || typeof colorValue !== "string") return null; const trimmed = colorValue.trim(); // Parse hex colors const hexMatch = trimmed.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); if (hexMatch) { let hex = hexMatch[1]; if (hex.length === 3) { hex = hex .split("") .map((char) => char + char) .join(""); } return { r: parseInt(hex.substr(0, 2), 16), g: parseInt(hex.substr(2, 2), 16), b: parseInt(hex.substr(4, 2), 16), }; } // Parse rgb/rgba const rgbMatch = trimmed.match( /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)$/i, ); if (rgbMatch) { return { r: parseInt(rgbMatch[1]), g: parseInt(rgbMatch[2]), b: parseInt(rgbMatch[3]), }; } // Parse hsl/hsla const hslMatch = trimmed.match( /^hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*[\d.]+)?\s*\)$/i, ); if (hslMatch) { const h = parseInt(hslMatch[1]) / 360; const s = parseInt(hslMatch[2]) / 100; const l = parseInt(hslMatch[3]) / 100; // Convert HSL to RGB const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h * 6) % 2) - 1)); const m = l - c / 2; let r, g, b; if (h >= 0 && h < 1 / 6) { r = c; g = x; b = 0; } else if (h >= 1 / 6 && h < 1 / 3) { r = x; g = c; b = 0; } else if (h >= 1 / 3 && h < 1 / 2) { r = 0; g = c; b = x; } else if (h >= 1 / 2 && h < 2 / 3) { r = 0; g = x; b = c; } else if (h >= 2 / 3 && h < 5 / 6) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } return { r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), b: Math.round((b + m) * 255), }; } return null; } function calculateLuminance(r, g, b) { // Convert to sRGB const rsRGB = r / 255; const gsRGB = g / 255; const bsRGB = b / 255; // Apply gamma correction const rLinear = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4); const gLinear = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4); const bLinear = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4); // Calculate relative luminance return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear; } function calculateContrastRatio(luminance1, luminance2) { // WCAG contrast ratio formula: (L1 + 0.05) / (L2 + 0.05) // where L1 is the lighter color and L2 is the darker color const lighter = Math.max(luminance1, luminance2); const darker = Math.min(luminance1, luminance2); return (lighter + 0.05) / (darker + 0.05); } function getContrastColor(colorValue) { const rgb = parseColorToRgb(colorValue); if (!rgb) { // Fallback for unparseable colors (like CSS variables) return "black"; } const colorLuminance = calculateLuminance(rgb.r, rgb.g, rgb.b); // Calculate contrast ratios with black and white const blackLuminance = 0; // Pure black const whiteLuminance = 1; // Pure white const contrastWithBlack = calculateContrastRatio( colorLuminance, blackLuminance, ); const contrastWithWhite = calculateContrastRatio( colorLuminance, whiteLuminance, ); // Return the color that gives higher contrast return contrastWithBlack > contrastWithWhite ? "black" : "white"; } function readCssVars(rawCss) { const result = {}; try { const root = postcss.parse(rawCss); root.walkRules((rule) => { const selector = rule.selector.trim(); let theme = null; if (selector === ":root") { theme = "light"; } else if (selector === ".dark" || selector === ":root.dark") { theme = "dark"; } else if (selector.startsWith(".")) { theme = selector.slice(1); } if (theme) { if (!result[theme]) { result[theme] = {}; } rule.walkDecls((decl) => { if (decl.prop.startsWith("--")) { const varName = decl.prop.slice(2); result[theme][varName] = decl.value.trim(); } }); } }); root.walkAtRules("layer", (atRule) => { if (atRule.params === "base") { atRule.walkRules((rule) => { const selector = rule.selector.trim(); let theme = null; if (selector === ":root") { theme = "light"; } else if (selector === ".dark" || selector === ":root.dark") { theme = "dark"; } else if (selector.startsWith(".")) { theme = selector.slice(1); } if (theme) { if (!result[theme]) { result[theme] = {}; } rule.walkDecls((decl) => { if (decl.prop.startsWith("--")) { const varName = decl.prop.slice(2); result[theme][varName] = decl.value.trim(); } }); } }); } }); } catch (error) { return null; } return result; } const getContrastCssVars = (cssVars) => { const result = {}; // Common Tailwind shade numbers const validShades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; // Regex to match color-shade pattern: ends with dash + number const colorShadePattern = /^(.+)-(\d+)$/; Object.keys(cssVars).forEach((themeName) => { const themeVars = cssVars[themeName]; const colorVars = {}; Object.keys(themeVars).forEach((varName) => { const match = varName.match(colorShadePattern); if (match) { const [, colorName, shadeStr] = match; const shade = parseInt(shadeStr, 10); const value = themeVars[varName]; // Only include if it's a valid Tailwind shade AND the value is actually a color if (validShades.includes(shade) && isColor(value)) { colorVars[varName] = value; } } }); result[themeName] = colorVars; }); return result; }; function generateContrastColors(colorShadeVars) { const result = {}; Object.keys(colorShadeVars).forEach((themeName) => { const themeColors = colorShadeVars[themeName]; const contrastVars = {}; Object.keys(themeColors).forEach((varName) => { const colorValue = themeColors[varName]; const contrastColor = getContrastColor(colorValue); // Create the contrast variable name: neutral-50 -> on-neutral-50 contrastVars[`on-${varName}`] = contrastColor; }); result[themeName] = contrastVars; }); return result; } const autoContrast = plugin(function ({ addBase, theme }) { // TODO: temp fix for monorepo // const cwd = process.cwd(); const cwd = path.resolve(process.cwd(), "../packages/ui"); const cssFilePath = getTailwindCssFile(cwd); const cssFilePathRelative = path.resolve(cwd, cssFilePath); const rawCss = fs.readFileSync(cssFilePathRelative, "utf8"); const cssVars = readCssVars(rawCss); const colorShadeVars = getContrastCssVars(cssVars); const contrastColors = generateContrastColors(colorShadeVars); Object.keys(contrastColors).forEach((themeName) => { const themeContrastVars = contrastColors[themeName]; if (Object.keys(themeContrastVars).length > 0) { const cssVarsWithPrefix = {}; Object.entries(themeContrastVars).forEach(([varName, value]) => { cssVarsWithPrefix[`--${varName}`] = value; }); if (themeName === "light") { addBase({ ":root": cssVarsWithPrefix, }); } else { addBase({ [`.${themeName}`]: cssVarsWithPrefix, }); } } }); }); module.exports = autoContrast;