Files
ukmesh/frontend/scripts/check-css-structure.mjs

177 lines
6.3 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import postcss from 'postcss';
const sourceRoot = path.resolve('src');
const globalsPath = path.join(sourceRoot, 'styles', 'globals.css');
const featureSelector = /^\.(?:owner-|stats-page|uk-feed|feed-|spam-|topology-|map-|node-dock|node-popup|mobile-controls|filter-panel|packet-feed)/;
async function cssFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) return cssFiles(target);
return entry.isFile() && entry.name.endsWith('.css') ? [target] : [];
}));
return files.flat().sort();
}
function splitSelectors(value) {
const selectors = [];
let start = 0;
let depth = 0;
let quote = '';
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (quote) {
if (character === '\\') index += 1;
else if (character === quote) quote = '';
continue;
}
if (character === '"' || character === "'") {
quote = character;
} else if (character === '(' || character === '[') {
depth += 1;
} else if (character === ')' || character === ']') {
depth = Math.max(0, depth - 1);
} else if (character === ',' && depth === 0) {
selectors.push(value.slice(start, index));
start = index + 1;
}
}
selectors.push(value.slice(start));
return selectors.map((selector) => selector.trim().replace(/\s+/g, ' ')).filter(Boolean);
}
function atRuleContext(rule) {
const context = [];
let parent = rule.parent;
while (parent) {
if (parent.type === 'atrule') {
context.unshift(`@${parent.name} ${parent.params}`.trim());
}
parent = parent.parent;
}
return context.join(' > ') || '<root>';
}
const files = await cssFiles(sourceRoot);
const occurrences = new Map();
const forbiddenGlobals = [];
const undersizedText = [];
for (const file of files) {
const css = await fs.readFile(file, 'utf8');
const root = postcss.parse(css, { from: file });
root.walkDecls((declaration) => {
let size = null;
if (declaration.prop === 'font-size') {
size = declaration.value.match(/^\s*([0-9.]+)(px|rem)\b/);
} else if (declaration.prop === 'font') {
size = declaration.value.match(/\b([0-9.]+)(px|rem)(?:\/[^\s]+)?(?=\s)/);
}
if (!size) return;
const pixels = Number(size[1]) * (size[2] === 'rem' ? 16 : 1);
if (pixels < 10) {
undersizedText.push({
file: path.relative(process.cwd(), file),
line: declaration.source?.start?.line ?? 0,
pixels,
});
}
});
root.walkRules((rule) => {
const context = atRuleContext(rule);
for (const selector of splitSelectors(rule.selector)) {
const key = `${context}\u0000${selector}`;
const list = occurrences.get(key) ?? [];
list.push({
file: path.relative(process.cwd(), file),
line: rule.source?.start?.line ?? 0,
});
occurrences.set(key, list);
if (file === globalsPath && featureSelector.test(selector)) {
forbiddenGlobals.push({ selector, ...list[list.length - 1] });
}
}
});
}
async function sourceFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(target);
return entry.isFile() && /\.(?:css|html|ts|tsx)$/.test(entry.name) ? [target] : [];
}));
return files.flat();
}
const customPropertyDefinitions = new Set();
const customPropertyUsages = new Map();
for (const file of await sourceFiles(sourceRoot)) {
const source = await fs.readFile(file, 'utf8');
for (const match of source.matchAll(/(^|[;{\s])(--[a-zA-Z0-9_-]+)\s*:/gm)) {
customPropertyDefinitions.add(match[2]);
}
for (const match of source.matchAll(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g)) {
if (match[2]) continue;
const usages = customPropertyUsages.get(match[1]) ?? [];
const line = source.slice(0, match.index).split('\n').length;
usages.push(`${path.relative(process.cwd(), file)}:${line}`);
customPropertyUsages.set(match[1], usages);
}
}
const runtimeCustomProperties = new Set(['--trigger-width']);
const undefinedCustomProperties = Array.from(customPropertyUsages.entries())
.filter(([name]) => !customPropertyDefinitions.has(name) && !runtimeCustomProperties.has(name))
.sort(([left], [right]) => left.localeCompare(right));
const duplicates = Array.from(occurrences.entries())
.filter(([, locations]) => locations.length > 1)
.map(([key, locations]) => {
const [context, selector] = key.split('\u0000');
return { context, selector, locations };
})
.sort((left, right) => left.selector.localeCompare(right.selector));
if (duplicates.length > 0) {
console.error(`Duplicate selectors in the same cascade context: ${duplicates.length}`);
for (const duplicate of duplicates) {
const locations = duplicate.locations
.map(({ file, line }) => `${file}:${line}`)
.join(', ');
console.error(` ${duplicate.selector} [${duplicate.context}] — ${locations}`);
}
}
if (forbiddenGlobals.length > 0) {
console.error(`Feature/dashboard selectors remaining in globals.css: ${forbiddenGlobals.length}`);
for (const entry of forbiddenGlobals) {
console.error(` ${entry.selector}${entry.file}:${entry.line}`);
}
}
if (undersizedText.length > 0) {
console.error(`Text declarations below the 10px floor: ${undersizedText.length}`);
for (const entry of undersizedText) {
console.error(` ${entry.pixels}px — ${entry.file}:${entry.line}`);
}
}
if (undefinedCustomProperties.length > 0) {
console.error(`Undefined CSS custom properties without fallbacks: ${undefinedCustomProperties.length}`);
for (const [name, usages] of undefinedCustomProperties) {
console.error(` ${name}${usages.join(', ')}`);
}
}
if (
duplicates.length > 0
|| forbiddenGlobals.length > 0
|| undersizedText.length > 0
|| undefinedCustomProperties.length > 0
) process.exit(1);
console.log(`CSS structure OK: ${files.length} files, zero duplicate selectors, no feature selectors in globals.css, no text below 10px, and no undefined custom properties.`);