// scripts/check-tailwind-opacity.mjs // // Guards against Tailwind opacity modifiers that silently produce nothing. // // ui/tailwind.config.js maps our custom colors straight onto CSS variables // (`slate: { 900: 'var(--slate-900)' }`). A bare `var(...)` has no slot for an // alpha channel, so when Tailwind is asked for `bg-slate-900/50` it cannot // build the color and drops the whole declaration. No error, no warning: the // class name stays in the DOM, typecheck passes, tests pass, and the element // simply renders with no background. The only way to notice is to grep the // built CSS. // // This check reads the config, works out which color names lack an alpha slot, // and scans the sources Tailwind is configured to scan. Existing hits are // recorded in a baseline file so the check only fails on *new* ones. // // The scan is plain text, like Tailwind's own class extractor: a class name // written inside a comment counts too. Mention such a class as `bg-slate-900` // plus `/50` rather than as one token if you need to name it in prose. // // Usage: // node scripts/check-tailwind-opacity.mjs # verify against baseline // node scripts/check-tailwind-opacity.mjs --update # rewrite the baseline // node scripts/check-tailwind-opacity.mjs --ui --baseline import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { dirname, resolve, relative, join, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); /** `--flag value` / `--flag=value`; returns undefined when absent. */ function argValue(flag) { const argv = process.argv.slice(2); const i = argv.indexOf(`--${flag}`); if (i !== -1 && argv[i + 1]) return argv[i + 1]; const inline = argv.find((a) => a.startsWith(`--${flag}=`)); return inline ? inline.slice(flag.length + 3) : undefined; } // `--ui` / `--baseline` exist so the check can be exercised against a fixture // tree (see scripts/check-tailwind-opacity.test.ts). Normal runs take neither. const UI_DIR = resolve(ROOT, argValue('ui') ?? 'ui'); const CONFIG_PATH = resolve(UI_DIR, 'tailwind.config.js'); const BASELINE_PATH = argValue('baseline') ? resolve(ROOT, argValue('baseline')) : resolve(__dirname, 'tailwind-opacity-baseline.json'); const UPDATE = process.argv.includes('--update'); // --------------------------------------------------------------------------- // 1. Which colors have no place to put an opacity value? // --------------------------------------------------------------------------- /** * A color value can carry an opacity modifier when it is a function * (`({ opacityValue }) => ...`) or a string with an `` * placeholder. A plain string is fine too *as long as* Tailwind can parse it * as a color — hex, rgb(), hsl(). A bare `var(--x)` cannot be parsed, so the * utility is dropped. */ function lacksAlphaSlot(value) { if (typeof value === 'function') return false; if (typeof value !== 'string') return false; if (value.includes('')) return false; return value.includes('var('); } /** Walk theme.extend.colors and return the class fragments that are unsafe. */ function collectUnsafeColorNames(colors, prefix = []) { const names = []; for (const [key, value] of Object.entries(colors ?? {})) { const path = key === 'DEFAULT' ? prefix : [...prefix, key]; if (value && typeof value === 'object') { names.push(...collectUnsafeColorNames(value, path)); } else if (lacksAlphaSlot(value)) { names.push(path.join('-')); } } return names; } let config; try { config = (await import(pathToFileURL(CONFIG_PATH).href)).default; } catch (err) { console.error(`tailwind-opacity: cannot load ${relative(ROOT, CONFIG_PATH)} — run \`npm --prefix ui install\``); console.error(String(err?.message ?? err)); process.exit(1); } const unsafeNames = collectUnsafeColorNames(config?.theme?.extend?.colors); if (unsafeNames.length === 0) { // Either the config stopped using bare `var()` colors (the real fix) or the // shape changed under us. Either way, say so rather than passing silently. console.log('tailwind-opacity: no opacity-less colors in tailwind.config.js — nothing to guard.'); process.exit(0); } // Longest first so `accent-soft` wins over `accent`. unsafeNames.sort((a, b) => b.length - a.length); // A utility token ends with `-/`; `` is a number or an // arbitrary value. Variants (`dark:`, `hover:`, `group-hover:`) sit in front // and are matched by the leading run of class characters. const CLASS_RE = new RegExp( String.raw`(? /[*?{[]/.test(p)); const staticPrefix = parts.slice(0, firstMagic === -1 ? parts.length : firstMagic).join('/'); const rx = globToRegExp(clean); return { dir: resolve(UI_DIR, staticPrefix), test: (rel) => rx.test(rel) }; } /** Minimal glob -> RegExp: supports `**` `*` `?` and `{a,b}`. */ function globToRegExp(glob) { let out = ''; for (let i = 0; i < glob.length; i += 1) { const c = glob[i]; if (c === '*') { if (glob[i + 1] === '*') { i += 1; if (glob[i + 1] === '/') { i += 1; out += '(?:[^/]+/)*'; } else { out += '.*'; } } else { out += '[^/]*'; } } else if (c === '?') { out += '[^/]'; } else if (c === '{') { const end = glob.indexOf('}', i); if (end === -1) { out += escapeRe(c); } else { out += `(?:${glob.slice(i + 1, end).split(',').map(escapeRe).join('|')})`; i = end; } } else { out += escapeRe(c); } } return new RegExp(`^${out}$`); } function walk(dir, out = []) { if (!existsSync(dir)) return out; if (!statSync(dir).isDirectory()) { out.push(dir); return out; } for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; const full = join(dir, entry.name); if (entry.isDirectory()) walk(full, out); else if (entry.isFile()) out.push(full); } return out; } const contentPatterns = (Array.isArray(config.content) ? config.content : config.content?.files ?? []).filter( (p) => typeof p === 'string', ); if (contentPatterns.length === 0) { console.error('tailwind-opacity: tailwind.config.js has no string `content` globs to scan.'); process.exit(1); } const files = new Set(); for (const pattern of contentPatterns) { const { dir, test } = compileGlob(pattern); for (const file of walk(dir)) { const relToUi = relative(UI_DIR, file).split(sep).join('/'); if (test(relToUi)) files.add(file); } } // --------------------------------------------------------------------------- // 3. Scan // --------------------------------------------------------------------------- /** key -> { file, class, count, lines[] } */ const found = new Map(); for (const file of [...files].sort()) { const relPath = relative(ROOT, file).split(sep).join('/'); const lines = readFileSync(file, 'utf8').split('\n'); lines.forEach((line, i) => { for (const match of line.matchAll(CLASS_RE)) { const cls = match[1]; const key = `${relPath} ${cls}`; const hit = found.get(key) ?? { file: relPath, class: cls, count: 0, lines: [] }; hit.count += 1; hit.lines.push(i + 1); found.set(key, hit); } }); } const entries = [...found.values()] .sort((a, b) => a.file.localeCompare(b.file) || a.class.localeCompare(b.class)) .map(({ file, class: cls, count }) => ({ file, class: cls, count })); const total = entries.reduce((n, e) => n + e.count, 0); // --------------------------------------------------------------------------- // 4. Compare with the baseline (or write it) // --------------------------------------------------------------------------- if (UPDATE) { const payload = { _comment: [ 'Known uses of a Tailwind opacity modifier on a color that has no alpha slot', '(see scripts/check-tailwind-opacity.mjs). These produce no CSS at all.', 'This file exists so the check fails on NEW ones only. Do not add to it by', 'hand: fix the class, then run `npm run check:tailwind-opacity -- --update`.', ], total, files: entries.length === 0 ? 0 : new Set(entries.map((e) => e.file)).size, classes: entries.length === 0 ? 0 : new Set(entries.map((e) => e.class)).size, entries, }; writeFileSync(BASELINE_PATH, `${JSON.stringify(payload, null, 2)}\n`); console.log(`tailwind-opacity: baseline written — ${total} uses in ${payload.files} files (${payload.classes} distinct classes).`); process.exit(0); } if (!existsSync(BASELINE_PATH)) { console.error(`tailwind-opacity: ${relative(ROOT, BASELINE_PATH)} is missing. Run \`npm run check:tailwind-opacity -- --update\`.`); process.exit(1); } const baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); const baselineCounts = new Map((baseline.entries ?? []).map((e) => [`${e.file} ${e.class}`, e.count])); const added = []; for (const [key, hit] of found) { const allowed = baselineCounts.get(key) ?? 0; if (hit.count > allowed) added.push({ ...hit, allowed }); } const removed = []; for (const [key, count] of baselineCounts) { const current = found.get(key)?.count ?? 0; if (current < count) removed.push({ key, count, current }); } if (added.length > 0) { console.error('tailwind-opacity: opacity modifier on a color that cannot carry one — Tailwind drops these rules silently.\n'); for (const hit of added) { const where = hit.lines.map((n) => `${hit.file}:${n}`).join(', '); const extra = hit.allowed > 0 ? ` (baseline allows ${hit.allowed}, found ${hit.count})` : ''; console.error(` ${hit.class}${extra}\n ${where}`); } console.error(`\n${added.length} new use(s). These colors are CSS variables in ui/tailwind.config.js, so \`/50\` style`); console.error('modifiers compile to nothing. Write the color with its alpha inline instead, e.g.'); console.error(' bg-slate-900/50 -> bg-[rgb(15_23_42_/_0.5)]'); console.error('Colors without an alpha slot: ' + [...unsafeNames].sort().join(', ')); process.exit(1); } if (removed.length > 0) { console.log(`tailwind-opacity: ${removed.length} baselined use(s) are gone — thanks. Refresh with:`); console.log(' npm run check:tailwind-opacity -- --update'); for (const r of removed) console.log(` ${r.key} (baseline ${r.count}, now ${r.current})`); } console.log(`tailwind-opacity: OK — ${total} known use(s), no new ones.`);