// Style gate for the dashboard, following the Lightwell design language (see docs/design.md). // Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the // token block of web/index.html. Run with: node scripts/lint-design.mjs import { readdirSync, readFileSync, statSync } from "node:fs"; import { extname, join, relative, sep } from "node:path"; const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); const targets = ["web", "docs", "cmd", "internal", "scripts", "README.md", ".gitea", "."]; const skipDirs = new Set(["node_modules", ".git"]); const exts = new Set([".html", ".md", ".go", ".sh", ".mjs", ".json", ".yml", ".example"]); const dash = /[–—−]/; const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u; const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/; const seen = new Set(); function* walk(p, top) { const st = statSync(p, { throwIfNoEntry: false }); if (!st || seen.has(p)) return; if (st.isFile()) { seen.add(p); return yield p; } for (const name of readdirSync(p)) { if (skipDirs.has(name) || (top && name.startsWith(".") && name !== ".gitea")) continue; const full = join(p, name); // the "." target only contributes the files at the top level if (p === root && statSync(full).isDirectory()) continue; yield* walk(full, false); } } const problems = []; for (const t of targets) { for (const file of walk(join(root, t), true)) { if (!exts.has(extname(file)) && !file.endsWith(".env.example")) continue; const rel = relative(root, file).split(sep).join("/"); if (rel.endsWith("go.sum") || rel === "spinner.example.json" || rel.endsWith("_test.go") || rel === "scripts/lint-design.mjs") continue; const isPage = rel.startsWith("web/") && rel.endsWith(".html"); let inTokens = false; readFileSync(file, "utf8").split("\n").forEach((line, i) => { const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`); if (line.includes("tokens:begin")) inTokens = true; if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to."); if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon."); if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) { at("raw color outside the token block. Use a token."); } if (isPage && /(?:toast|textContent\s*=|class:'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy."); if (line.includes("tokens:end")) inTokens = false; }); } } if (problems.length) { console.error(problems.join("\n")); console.error(`\n${problems.length} style problem(s).`); process.exit(1); } console.log("Style checks passed.");