eigent/scripts/check-design-token-usage.mjs
Tong Chen f3e1110ac0
Feat/eigent new design (#1577)
Co-authored-by: Douglas <douglas.ym.lai@gmail.com>
Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
2026-05-01 11:04:33 +08:00

196 lines
6 KiB
JavaScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* Fails if UI source contains hard-coded colors instead of design tokens
* (CSS variables such as var(--ds-...), var(--colors-...), component vars, or
* Tailwind classes that map to those vars — not raw #hex / rgb() / hsl()).
*
* Usage:
* node scripts/check-design-token-usage.mjs
* node scripts/check-design-token-usage.mjs src/a.tsx # lint-staged (one or more files)
*
* Exemptions:
* - End-of-line comment: // ds:allow-hardcoded-color
* - scripts/design-token-usage.allowlist — repo-relative paths, one per line (# comments ok)
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const EXT = new Set(['.ts', '.tsx', '.js', '.jsx']);
const SKIP_PREFIXES = ['src/lib/themeTokens/'];
const SKIP_FILE_RE =
/\.(test|spec)\.(ts|tsx|js|jsx)$|vite-env\.d\.ts$|\.stories\.(ts|tsx)$/;
const HEX_RE = /#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
const RGB_NUM_RE = /\brgba?\(\s*[\d.]/;
const HSL_NUM_RE = /\bhsla?\(\s*[\d.]/;
const ARBITRARY_HEX_RE =
/\[[^\]]*#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b[^\]]*\]/;
const ARBITRARY_RGB_RE = /\[[^\]]*rgba?\([^\]]*\]/;
function loadAllowlist() {
const path = join(REPO_ROOT, 'scripts/design-token-usage.allowlist');
const set = new Set();
if (!existsSync(path)) return set;
const raw = readFileSync(path, 'utf8');
for (const line of raw.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
set.add(t.replaceAll('\\', '/'));
}
return set;
}
function shouldSkipPath(relPosix, allowlist) {
const norm = relPosix.replaceAll('\\', '/');
if (allowlist.has(norm)) return true;
for (const prefix of SKIP_PREFIXES) {
if (norm.startsWith(prefix)) return true;
}
if (SKIP_FILE_RE.test(norm)) return true;
return false;
}
function* walkSrcFiles(dir) {
const entries = readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === 'dist') continue;
yield* walkSrcFiles(p);
} else {
const ext = e.name.slice(e.name.lastIndexOf('.'));
if (EXT.has(ext)) yield p;
}
}
}
function stripTrailingLineComment(line) {
const idx = line.indexOf('//');
if (idx === -1) return line;
return line.slice(0, idx);
}
function lineHasExemption(line) {
return line.includes('//') && line.includes('ds:allow-hardcoded-color');
}
function checkLine(rawLine, lineNum, fileRel, out) {
if (lineHasExemption(rawLine)) return;
const line = stripTrailingLineComment(rawLine);
if (RGB_NUM_RE.test(line) || HSL_NUM_RE.test(line)) {
out.push({
file: fileRel,
line: lineNum,
message:
'Use design tokens (e.g. var(--ds-...), Tailwind semantic colors) instead of raw rgb/hsl.',
snippet: rawLine.trim(),
});
return;
}
HEX_RE.lastIndex = 0;
let m;
while ((m = HEX_RE.exec(line)) !== null) {
const start = m.index;
const before = line.slice(Math.max(0, start - 4), start);
if (/url\s*\(\s*$/i.test(before)) continue;
out.push({
file: fileRel,
line: lineNum,
message: `Hard-coded hex "${m[0]}" — use a design token or Tailwind color that maps to var(--...).`,
snippet: rawLine.trim(),
});
return;
}
if (ARBITRARY_HEX_RE.test(line) || ARBITRARY_RGB_RE.test(line)) {
out.push({
file: fileRel,
line: lineNum,
message:
'Tailwind arbitrary color value ([#...] / [rgb(...)]) — use ds-* or semantic utilities.',
snippet: rawLine.trim(),
});
}
}
function checkFile(absPath, allowlist) {
const rel = relative(REPO_ROOT, absPath);
const relPosix = rel.replaceAll('\\', '/');
if (shouldSkipPath(relPosix, allowlist)) return [];
const text = readFileSync(absPath, 'utf8');
const lines = text.split(/\r?\n/);
const out = [];
lines.forEach((ln, i) => checkLine(ln, i + 1, relPosix, out));
return out;
}
function resolveCliFiles(argv) {
const files = [];
for (const a of argv) {
if (a.startsWith('-')) continue;
const abs = resolve(REPO_ROOT, a);
if (existsSync(abs) && statSync(abs).isFile()) files.push(abs);
}
return files;
}
function main() {
const allowlist = loadAllowlist();
const argv = process.argv.slice(2).filter((a) => a !== '--');
const explicit = resolveCliFiles(argv);
const targets =
explicit.length > 0
? explicit
: [...walkSrcFiles(join(REPO_ROOT, 'src'))];
const violations = [];
for (const abs of targets) {
violations.push(...checkFile(abs, allowlist));
}
if (violations.length === 0) {
console.log('check-design-token-usage: OK (no hard-coded colors found).');
process.exit(0);
}
console.error('check-design-token-usage: hard-coded colors detected:\n');
for (const v of violations) {
console.error(` ${v.file}:${v.line}`);
console.error(` ${v.message}`);
console.error(
` ${v.snippet.slice(0, 200)}${v.snippet.length > 200 ? '…' : ''}\n`
);
}
console.error(
`Total: ${violations.length} finding(s). Fix or add // ds:allow-hardcoded-color on the line, or list the file in scripts/design-token-usage.allowlist (one path per line).`
);
process.exit(1);
}
main();