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

106 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 樣式把關:顏色與字級只能來自 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。這些彼此不能混主色在 H≈187、暖色在 H≈32是補色關係
* 任何比例混出來都是濁褐。要調淡就跟 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");