This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
// 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.`);
|
||||
Reference in New Issue
Block a user