mirror of
https://github.com/zuiidea/antd-admin
synced 2026-10-23 16:43:49 +00:00
feat: add verify:ui scripts for admin templates
Local/Agent gate for literal colors, parallel UI libs, and CSS allowlist. Co-authored-by: zuiidea <zuiiidea@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"fmt": "vp fmt",
|
||||
"lint": "vp lint",
|
||||
"check": "vp check --no-fmt",
|
||||
"verify:ui": "node ./scripts/verify-ui.mjs",
|
||||
"test:unit": "vp test run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Thin local/Agent UI gate for antd-admin templates.
|
||||
* Rules: R1 no-literal-color, R2 no-parallel-ui, R3 css-allowlist; W1 prefer-flex-layout (warn).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const srcRoot = path.join(appRoot, "src");
|
||||
|
||||
const CSS_ALLOWLIST = new Set([
|
||||
"src/index.css",
|
||||
"src/components/Aurora/index.css",
|
||||
"src/components/DataTable/index.css",
|
||||
"src/components/Layout/Sidebar/index.css",
|
||||
"src/components/Layout/UserMenu/index.css",
|
||||
"src/routes/login/index.css",
|
||||
"src/routes/_auth/dashboard/index.css",
|
||||
]);
|
||||
|
||||
const PARALLEL_UI = [
|
||||
"@mui/",
|
||||
"@chakra-ui/",
|
||||
"@mantine/",
|
||||
"react-bootstrap",
|
||||
"@headlessui/",
|
||||
"vuetify",
|
||||
];
|
||||
|
||||
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(/;
|
||||
const FLEX_DISPLAY_RE = /display:\s*['"]flex['"]|display:\s*['"]grid['"]/;
|
||||
const IMPORT_RE = /from\s+['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
|
||||
/** @type {{ kind: "error" | "warn"; rule: string; file: string; line: number; snippet: string }[]} */
|
||||
const findings = [];
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @param {(abs: string, rel: string) => void} visit
|
||||
*/
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs, visit);
|
||||
continue;
|
||||
}
|
||||
const rel = path.relative(appRoot, abs).split(path.sep).join("/");
|
||||
visit(abs, rel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} abs
|
||||
* @param {string} rel
|
||||
* @param {RegExp} re
|
||||
* @param {string} rule
|
||||
* @param {"error" | "warn"} kind
|
||||
*/
|
||||
function scanLines(abs, rel, re, rule, kind) {
|
||||
const text = fs.readFileSync(abs, "utf8");
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!re.test(line)) continue;
|
||||
findings.push({
|
||||
kind,
|
||||
rule,
|
||||
file: rel,
|
||||
line: i + 1,
|
||||
snippet: line.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} abs
|
||||
* @param {string} rel
|
||||
*/
|
||||
function scanParallelUi(abs, rel) {
|
||||
const text = fs.readFileSync(abs, "utf8");
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
IMPORT_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = IMPORT_RE.exec(line)) !== null) {
|
||||
const source = match[1] ?? match[2] ?? "";
|
||||
if (PARALLEL_UI.some((p) => source === p || source.startsWith(p))) {
|
||||
findings.push({
|
||||
kind: "error",
|
||||
rule: "no-parallel-ui",
|
||||
file: rel,
|
||||
line: i + 1,
|
||||
snippet: line.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isColorExcluded(rel) {
|
||||
if (rel.startsWith("src/theme/")) return true;
|
||||
if (rel.endsWith("/tokenBuilders.ts") || rel.endsWith("tokenBuilders.ts")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
walk(srcRoot, (abs, rel) => {
|
||||
if (rel.endsWith(".css")) {
|
||||
if (!CSS_ALLOWLIST.has(rel)) {
|
||||
findings.push({
|
||||
kind: "error",
|
||||
rule: "css-allowlist",
|
||||
file: rel,
|
||||
line: 1,
|
||||
snippet: `CSS file not in allowlist: ${rel}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rel.endsWith(".ts") && !rel.endsWith(".tsx")) return;
|
||||
|
||||
if (!isColorExcluded(rel)) {
|
||||
scanLines(abs, rel, COLOR_RE, "no-literal-color", "error");
|
||||
}
|
||||
scanParallelUi(abs, rel);
|
||||
scanLines(abs, rel, FLEX_DISPLAY_RE, "prefer-flex-layout", "warn");
|
||||
});
|
||||
|
||||
const errors = findings.filter((f) => f.kind === "error");
|
||||
const warns = findings.filter((f) => f.kind === "warn");
|
||||
|
||||
for (const f of findings) {
|
||||
const tag = f.kind === "error" ? "error" : "warn";
|
||||
console.log(`[${tag}] ${f.rule} ${f.file}:${f.line}: ${f.snippet}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`verify:ui failed with ${errors.length} error(s), ${warns.length} warning(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`verify:ui OK (${warns.length} warning(s))`);
|
||||
@@ -11,6 +11,7 @@
|
||||
"fmt": "vp fmt",
|
||||
"lint": "vp lint",
|
||||
"check": "vp check --no-fmt",
|
||||
"verify:ui": "node ./scripts/verify-ui.mjs",
|
||||
"test:unit": "vp test run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Thin local/Agent UI gate for antd-admin templates.
|
||||
* Rules: R1 no-literal-color, R2 no-parallel-ui, R3 css-allowlist; W1 prefer-flex-layout (warn).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const srcRoot = path.join(appRoot, "src");
|
||||
|
||||
const CSS_ALLOWLIST = new Set([
|
||||
"src/index.css",
|
||||
"src/components/Aurora/index.css",
|
||||
"src/components/DataTable/index.css",
|
||||
"src/components/Layout/Sidebar/index.css",
|
||||
"src/components/Layout/UserMenu/index.css",
|
||||
"src/routes/login/index.css",
|
||||
"src/routes/_auth/dashboard/index.css",
|
||||
]);
|
||||
|
||||
const PARALLEL_UI = [
|
||||
"@mui/",
|
||||
"@chakra-ui/",
|
||||
"@mantine/",
|
||||
"react-bootstrap",
|
||||
"@headlessui/",
|
||||
"vuetify",
|
||||
];
|
||||
|
||||
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(/;
|
||||
const FLEX_DISPLAY_RE = /display:\s*['"]flex['"]|display:\s*['"]grid['"]/;
|
||||
const IMPORT_RE = /from\s+['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
|
||||
/** @type {{ kind: "error" | "warn"; rule: string; file: string; line: number; snippet: string }[]} */
|
||||
const findings = [];
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @param {(abs: string, rel: string) => void} visit
|
||||
*/
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs, visit);
|
||||
continue;
|
||||
}
|
||||
const rel = path.relative(appRoot, abs).split(path.sep).join("/");
|
||||
visit(abs, rel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} abs
|
||||
* @param {string} rel
|
||||
* @param {RegExp} re
|
||||
* @param {string} rule
|
||||
* @param {"error" | "warn"} kind
|
||||
*/
|
||||
function scanLines(abs, rel, re, rule, kind) {
|
||||
const text = fs.readFileSync(abs, "utf8");
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!re.test(line)) continue;
|
||||
findings.push({
|
||||
kind,
|
||||
rule,
|
||||
file: rel,
|
||||
line: i + 1,
|
||||
snippet: line.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} abs
|
||||
* @param {string} rel
|
||||
*/
|
||||
function scanParallelUi(abs, rel) {
|
||||
const text = fs.readFileSync(abs, "utf8");
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
IMPORT_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = IMPORT_RE.exec(line)) !== null) {
|
||||
const source = match[1] ?? match[2] ?? "";
|
||||
if (PARALLEL_UI.some((p) => source === p || source.startsWith(p))) {
|
||||
findings.push({
|
||||
kind: "error",
|
||||
rule: "no-parallel-ui",
|
||||
file: rel,
|
||||
line: i + 1,
|
||||
snippet: line.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isColorExcluded(rel) {
|
||||
if (rel.startsWith("src/theme/")) return true;
|
||||
if (rel.endsWith("/tokenBuilders.ts") || rel.endsWith("tokenBuilders.ts")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
walk(srcRoot, (abs, rel) => {
|
||||
if (rel.endsWith(".css")) {
|
||||
if (!CSS_ALLOWLIST.has(rel)) {
|
||||
findings.push({
|
||||
kind: "error",
|
||||
rule: "css-allowlist",
|
||||
file: rel,
|
||||
line: 1,
|
||||
snippet: `CSS file not in allowlist: ${rel}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rel.endsWith(".ts") && !rel.endsWith(".tsx")) return;
|
||||
|
||||
if (!isColorExcluded(rel)) {
|
||||
scanLines(abs, rel, COLOR_RE, "no-literal-color", "error");
|
||||
}
|
||||
scanParallelUi(abs, rel);
|
||||
scanLines(abs, rel, FLEX_DISPLAY_RE, "prefer-flex-layout", "warn");
|
||||
});
|
||||
|
||||
const errors = findings.filter((f) => f.kind === "error");
|
||||
const warns = findings.filter((f) => f.kind === "warn");
|
||||
|
||||
for (const f of findings) {
|
||||
const tag = f.kind === "error" ? "error" : "warn";
|
||||
console.log(`[${tag}] ${f.rule} ${f.file}:${f.line}: ${f.snippet}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`verify:ui failed with ${errors.length} error(s), ${warns.length} warning(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`verify:ui OK (${warns.length} warning(s))`);
|
||||
Reference in New Issue
Block a user