Files
maestro/scripts/check-tailwind-opacity.mjs
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

284 lines
11 KiB
JavaScript

// 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 <dir> --baseline <file>
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 `<alpha-value>`
* 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('<alpha-value>')) 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 `-<color>/<alpha>`; `<alpha>` 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`(?<![\w./-])((?:[a-z][\w.:-]*?)-(?:${unsafeNames.map(escapeRe).join('|')})/(?:\d+(?:\.\d+)?|\[[^\]\s]+\]))`,
'g',
);
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// 2. Which files does Tailwind scan?
// ---------------------------------------------------------------------------
/** Turn a Tailwind content glob into { dir, test } we can walk. */
function compileGlob(pattern) {
const clean = pattern.replace(/^\.\//, '');
const parts = clean.split('/');
const firstMagic = parts.findIndex((p) => /[*?{[]/.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.`);