106 lines
4.1 KiB
JavaScript
106 lines
4.1 KiB
JavaScript
|
|
/**
|
|||
|
|
* 樣式把關:顏色與字級只能來自 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,是補色關係,
|
|||
|
|
* 任何比例混出來都是濁褐。要調淡就跟 surface/line/transparent 混,要同一顏色
|
|||
|
|
* 的深淺兩階就用 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}),會變濁。` +
|
|||
|
|
`要調淡請跟 surface/line/transparent 混,要深一階請用 -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");
|