mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-11 04:38:18 +00:00
Tailwind's --minify (Lightning CSS) randomly drops rules from the CDN .min.css files (parcel-bundler/lightningcss#1235). Minify with esbuild using Tailwind v4's browser targets instead, fail the build on esbuild warnings, and have npm run check fail if a .min.css file loses any selector from its unminified file.
174 lines
7.4 KiB
JavaScript
174 lines
7.4 KiB
JavaScript
// Cheap sanity checks. Run after a build.
|
|
// node scripts/check.js package: every export target exists, JS parses, min CSS keeps all selectors
|
|
// node scripts/check.js site docs site: site/dist built, llms.txt links to .md exports
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const errors = [];
|
|
|
|
function checkPackage() {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
|
|
for (const [key, target] of Object.entries(pkg.exports)) {
|
|
const file = path.join(root, target);
|
|
if (target.includes('*')) {
|
|
const dir = path.dirname(file.slice(0, file.indexOf('*')) + 'x');
|
|
if (!fs.existsSync(dir) || fs.readdirSync(dir).length === 0) {
|
|
errors.push(`export "${key}": no files in ${path.relative(root, dir)}`);
|
|
}
|
|
} else if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
errors.push(`export "${key}": ${target} is missing or empty`);
|
|
}
|
|
}
|
|
|
|
const jsDir = path.join(root, 'dist', 'js');
|
|
for (const name of fs.existsSync(jsDir) ? fs.readdirSync(jsDir) : []) {
|
|
if (!name.endsWith('.js')) continue;
|
|
try {
|
|
execFileSync(process.execPath, ['--check', path.join(jsDir, name)], { stdio: 'pipe' });
|
|
} catch (error) {
|
|
const message = error.stderr.toString().split('\n').find((line) => /Error/.test(line));
|
|
errors.push(`dist/js/${name} does not parse: ${message || 'syntax error'}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Minified CDN CSS must keep every selector of its unminified source.
|
|
// Selectors are flattened (nesting resolved, :is() lists expanded) and normalized
|
|
// (::after/:after, attribute quotes, combinator spacing, from/0%) before comparing.
|
|
function splitTop(str) {
|
|
const out = [];
|
|
let depth = 0, quote = null, cur = '';
|
|
for (let i = 0; i < str.length; i++) {
|
|
const c = str[i];
|
|
if (quote) { cur += c; if (c === '\\') cur += str[++i] ?? ''; else if (c === quote) quote = null; continue; }
|
|
if (c === '"' || c === "'") { quote = c; cur += c; continue; }
|
|
if (c === '(' || c === '[') depth++;
|
|
else if (c === ')' || c === ']') depth--;
|
|
if (c === ',' && depth === 0) { out.push(cur); cur = ''; } else cur += c;
|
|
}
|
|
out.push(cur);
|
|
return out.map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
function parseCss(css) {
|
|
css = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
const root = { children: [] };
|
|
const stack = [root];
|
|
let buf = '', quote = null, paren = 0;
|
|
for (let i = 0; i < css.length; i++) {
|
|
const c = css[i];
|
|
if (quote) { buf += c; if (c === '\\') buf += css[++i] ?? ''; else if (c === quote) quote = null; continue; }
|
|
if (c === '"' || c === "'") { quote = c; buf += c; continue; }
|
|
if (c === '(') paren++;
|
|
else if (c === ')') paren--;
|
|
if (paren > 0) { buf += c; continue; }
|
|
if (c === '{') { const node = { prelude: buf.trim(), children: [] }; stack.at(-1).children.push(node); stack.push(node); buf = ''; }
|
|
else if (c === '}') { if (buf.trim()) stack.at(-1).children.push({ decl: true }); stack.pop(); buf = ''; }
|
|
else if (c === ';') { if (buf.trim()) stack.at(-1).children.push({ decl: true }); buf = ''; }
|
|
else buf += c;
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function normalizeSelector(s) {
|
|
return s
|
|
.replace(/::(before|after|first-line|first-letter)\b/g, ':$1')
|
|
.replace(/\[\s*([^\]=~|^$*\s]+)\s*([~|^$*]?=)\s*(["'])((?:\\.|(?!\3).)*)\3\s*(\s[is])?\s*\]/g, '[$1$2$4$5]')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/\s*([>+~,])\s*/g, '$1')
|
|
.replace(/\(\s+/g, '(')
|
|
.replace(/\s+\)/g, ')')
|
|
.replace(/^from$/, '0%')
|
|
.replace(/^to$/, '100%')
|
|
.trim();
|
|
}
|
|
|
|
function expandIs(sel) {
|
|
const i = sel.indexOf(':is(');
|
|
if (i < 0) return [sel];
|
|
let depth = 0, j = i + 3;
|
|
for (; j < sel.length; j++) { if (sel[j] === '(') depth++; else if (sel[j] === ')' && --depth === 0) break; }
|
|
const inner = splitTop(sel.slice(i + 4, j));
|
|
const attached = i > 0 && !/[\s>+~(,]/.test(sel[i - 1]);
|
|
if (attached && inner.some((part) => /[\s>+~]/.test(part.replace(/\([^)]*\)/g, '')))) {
|
|
return expandIs(sel.slice(j + 1)).map((rest) => sel.slice(0, j + 1) + rest);
|
|
}
|
|
return inner.flatMap((part) => expandIs(sel.slice(0, i) + part + sel.slice(j + 1)));
|
|
}
|
|
|
|
function flatSelectors(css) {
|
|
const keys = new Set();
|
|
const normParams = (p) => p.replace(/\s+/g, ' ').replace(/\s*([():,<>=]+)\s*/g, '$1').replace(/"/g, "'").trim().toLowerCase();
|
|
const walk = (node, ctx, parents) => {
|
|
for (const child of node.children) {
|
|
if (!child.children) continue;
|
|
const pre = child.prelude;
|
|
if (pre.startsWith('@')) {
|
|
const [, name, params] = pre.match(/^@([\w-]+)\s*([\s\S]*)$/);
|
|
const at = `@${name} ${normParams(params)}`;
|
|
if (/^(media|supports|layer|container|keyframes|-webkit-keyframes|scope|starting-style)$/.test(name)) {
|
|
// Declarations directly inside a nested at-rule belong to the parent selectors.
|
|
if (parents && child.children.some((c) => c.decl)) for (const p of parents) keys.add(`${[...ctx, at].join(' | ')} || ${p}`);
|
|
walk(child, [...ctx, at], name.endsWith('keyframes') ? null : parents);
|
|
} else keys.add([...ctx, at].join(' | '));
|
|
continue;
|
|
}
|
|
const own = splitTop(pre);
|
|
const sels = (parents ? parents.flatMap((p) => own.map((s) => (s.includes('&') ? s.replace(/&/g, p) : `${p} ${s}`))) : own)
|
|
.map(normalizeSelector).flatMap(expandIs).map(normalizeSelector);
|
|
if (child.children.some((c) => c.decl)) for (const s of sels) keys.add(`${ctx.join(' | ')} || ${s}`);
|
|
walk(child, ctx, sels);
|
|
}
|
|
};
|
|
walk(parseCss(css), [], null);
|
|
return keys;
|
|
}
|
|
|
|
function checkMinifiedCss() {
|
|
const dist = path.join(root, 'dist');
|
|
for (const name of fs.existsSync(dist) ? fs.readdirSync(dist) : []) {
|
|
if (!name.endsWith('.cdn.css')) continue;
|
|
const minName = name.replace(/\.css$/, '.min.css');
|
|
const minFile = path.join(dist, minName);
|
|
if (!fs.existsSync(minFile)) { errors.push(`dist/${minName} is missing`); continue; }
|
|
const min = flatSelectors(fs.readFileSync(minFile, 'utf8'));
|
|
const missing = [...flatSelectors(fs.readFileSync(path.join(dist, name), 'utf8'))].filter((key) => !min.has(key));
|
|
if (missing.length) errors.push(`dist/${minName} dropped ${missing.length} selector(s) from dist/${name}, e.g. ${missing.slice(0, Number(process.env.SHOW || 3)).join('; ')}`);
|
|
}
|
|
}
|
|
|
|
function checkSite() {
|
|
const dist = path.join(root, 'site', 'dist');
|
|
for (const file of ['index.html', 'llms.txt', 'llms-full.txt']) {
|
|
if (!fs.existsSync(path.join(dist, file))) errors.push(`site/dist/${file} is missing`);
|
|
}
|
|
const llms = path.join(dist, 'llms.txt');
|
|
if (!fs.existsSync(llms)) return;
|
|
const links = [...fs.readFileSync(llms, 'utf8').matchAll(/^- \[[^\]]*\]\(([^)]+)\)/gm)].map((m) => m[1]);
|
|
if (links.length === 0) errors.push('site/dist/llms.txt has no links');
|
|
for (const link of links) {
|
|
if (!link.endsWith('.md')) errors.push(`site/dist/llms.txt links to ${link}, not a .md export`);
|
|
}
|
|
}
|
|
|
|
const target = process.argv[2] || 'package';
|
|
if (target === 'package') {
|
|
checkPackage();
|
|
checkMinifiedCss();
|
|
}
|
|
else if (target === 'site') checkSite();
|
|
else {
|
|
console.error(`Unknown target "${target}". Use "package" or "site".`);
|
|
process.exit(2);
|
|
}
|
|
|
|
if (errors.length) {
|
|
console.error(`${target} check failed:\n- ${errors.join('\n- ')}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`${target} check passed.`);
|