284 lines
11 KiB
JavaScript
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.`);
|