Files
bitvid/scripts/check-design-tokens.mjs
T

169 lines
4.1 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
const TARGET_DIRECTORIES = ["js/ui"];
const TARGET_FILES = ["js/channelProfile.js"];
const IGNORED_EXTENSIONS = new Set([".min.js", ".map"]);
const ALLOWED_FILES = new Set(["js/ui/components/RevertModal.js"]);
const LENGTH_PATTERN = /\d+(?:\.\d+)?(?:px|rem)\b/gim;
function toPosix(relPath) {
return relPath.split(path.sep).join("/");
}
async function collectTargetFiles() {
const results = new Set();
for (const target of TARGET_DIRECTORIES) {
const absolute = path.resolve(repoRoot, target);
await walkDirectory(absolute, results);
}
for (const file of TARGET_FILES) {
const absolute = path.resolve(repoRoot, file);
results.add(absolute);
}
return [...results];
}
async function walkDirectory(dir, results) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (error) {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walkDirectory(fullPath, results);
continue;
}
if (!entry.isFile()) {
continue;
}
const ext = path.extname(entry.name).toLowerCase();
if (!ext || IGNORED_EXTENSIONS.has(ext)) {
continue;
}
results.add(fullPath);
}
}
function buildLineBreaks(content) {
const breaks = [];
let index = content.indexOf("\n");
while (index !== -1) {
breaks.push(index);
index = content.indexOf("\n", index + 1);
}
return breaks;
}
function getLineNumber(lineBreaks, index) {
let low = 0;
let high = lineBreaks.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (index <= lineBreaks[mid]) {
high = mid;
} else {
low = mid + 1;
}
}
return low + 1;
}
function getLineSnippet(content, index) {
const start = content.lastIndexOf("\n", index - 1) + 1;
let end = content.indexOf("\n", index);
if (end === -1) {
end = content.length;
}
return content.slice(start, end).trim();
}
function shouldSkipMatch(content, index) {
const previousChar = index > 0 ? content[index - 1] : "";
if (previousChar === "[") {
return true;
}
return false;
}
function collectViolations(filePath, content) {
const violations = [];
const lineBreaks = buildLineBreaks(content);
let match;
LENGTH_PATTERN.lastIndex = 0;
while ((match = LENGTH_PATTERN.exec(content))) {
const index = match.index;
if (shouldSkipMatch(content, index)) {
continue;
}
const value = match[0];
if (/^0+(?:\.0+)?(?:px|rem)$/i.test(value)) {
continue;
}
violations.push({
file: toPosix(path.relative(repoRoot, filePath)),
line: getLineNumber(lineBreaks, index),
value,
snippet: getLineSnippet(content, index),
});
}
return violations;
}
async function main() {
const files = await collectTargetFiles();
const violations = [];
for (const filePath of files) {
const rel = toPosix(path.relative(repoRoot, filePath));
if (ALLOWED_FILES.has(rel)) {
continue;
}
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
continue;
}
const fileViolations = collectViolations(filePath, content);
violations.push(...fileViolations);
}
if (violations.length > 0) {
console.error("Design token lint failed: raw measurements detected outside tokens.\n");
for (const violation of violations) {
console.error(
`${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
);
}
console.error("\nMove these measurements into css/tokens.css or use the metrics helper.");
process.exit(1);
}
process.exit(0);
}
main().catch((error) => {
console.error("Failed to check design tokens.");
console.error(error);
process.exit(1);
});