thread-master/apps/web/scripts/check-tokens.mjs

106 lines
4.1 KiB
JavaScript
Raw Permalink Normal View History

2026-07-30 01:25:34 +00:00
/**
* 樣式把關顏色與字級只能來自 tokens.css
*
* 會擋下的事
* 1. css 寫死 font-size 數值em 例外那是刻意的相對縮放
* 2. tsx inline style 寫死 fontSize 或色碼
* 3. css 出現任何 css 檔都沒定義的 --hb-*--hb-warn 這種拼錯永遠吃 fallback很難發現
* 4. css 出現寫死的顏色
* 5. 把兩個有彩度的 token 互相 color-mix藍配橘是補色混出來一定是濁褐
*
* 4 條放過兩種情形純黑純白只用於陰影與高光的 color-mix本身不是主題色
* 以及 global.css 主題預覽色塊那 12 個值 那裡必須同時顯示淺色與深色
* 不能吃會隨主題切換的 var()
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const STYLE_DIR = "src/styles";
const NEUTRAL = /^#(fff|000|ffffff|000000)$/i;
/**
* 有彩度的 token這些彼此不能混主色在 H187暖色在 H32是補色關係
* 任何比例混出來都是濁褐要調淡就跟 surfacelinetransparent 要同一顏色
* 的深淺兩階就用 X X-deep
*/
const CHROMATIC =
/^--hb-(brand|brand-hover|brand-deep|brand-dim|magic|accent-warm|warning|warning-deep|danger|danger-deep|success|gold|gold-bright)$/;
const MIX = /color-mix\(in srgb,\s*var\((--hb-[a-z0-9-]+)\)[^,]*,\s*var\((--hb-[a-z0-9-]+)\)\s*\)/g;
/** 主題預覽色塊:見 global.css 的 .hb-theme-picker__swatch--* */
const HEX_BUDGET = { "global.css": 12, "layout.css": 0, "ui.css": 0 };
const errors = [];
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) out.push(...walk(path));
else out.push(path);
}
return out;
}
const cssFiles = readdirSync(STYLE_DIR).filter((f) => f.endsWith(".css"));
// 除了 tokens.css 的全域 token元件內宣告的區域變數如 --hb-top-ctrl也算已定義。
const defined = new Set(
cssFiles.flatMap((f) => [
...readFileSync(join(STYLE_DIR, f), "utf8").matchAll(/^\s*(--hb-[a-z0-9-]+)\s*:/gm),
].map((m) => m[1])),
);
for (const file of readdirSync(STYLE_DIR)) {
if (!file.endsWith(".css") || file === "tokens.css") continue;
const path = join(STYLE_DIR, file);
const text = readFileSync(path, "utf8");
text.split("\n").forEach((line, i) => {
const at = `${path}:${i + 1}`;
if (/font-size:\s*[0-9.]+(rem|px|pt)/.test(line)) {
errors.push(`${at} 寫死 font-size請改用 var(--hb-text-*)${line.trim()}`);
}
for (const [, name] of line.matchAll(/var\((--hb-[a-z0-9-]+)/g)) {
if (!defined.has(name)) {
errors.push(`${at} 用了 tokens.css 沒有的 ${name}(拼錯?)`);
}
}
for (const [, a, b] of line.matchAll(MIX)) {
if (CHROMATIC.test(a) && CHROMATIC.test(b)) {
errors.push(
`${at} 混了兩個有彩度的顏色(${a} + ${b}),會變濁。` +
`要調淡請跟 surfacelinetransparent 混,要深一階請用 -deep。`,
);
}
}
});
const hex = (text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).filter((h) => !NEUTRAL.test(h));
const budget = HEX_BUDGET[file] ?? 0;
if (hex.length > budget) {
errors.push(
`${path} 寫死顏色 ${hex.length} 處(上限 ${budget}${[...new Set(hex)].join(" ")}` +
`顏色請走 tokens.css否則深淺模式會有一半畫面不跟著切。`,
);
}
}
for (const path of walk("src")) {
if (!path.endsWith(".tsx")) continue;
const text = readFileSync(path, "utf8");
text.split("\n").forEach((line, i) => {
const at = `${path}:${i + 1}`;
if (/fontSize:\s*"[0-9.]/.test(line)) {
errors.push(`${at} inline fontSize 寫死,請改用 var(--hb-text-*)`);
}
if (/(color|background|borderColor):\s*"#[0-9a-fA-F]{3,8}"/.test(line)) {
errors.push(`${at} inline 色碼寫死,請改用 var(--hb-*)`);
}
});
}
if (errors.length) {
console.error(`樣式把關失敗(${errors.length}`);
for (const e of errors) console.error(" " + e);
process.exit(1);
}
console.log("樣式把關通過:顏色與字級都來自 tokens.css");