f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
289 lines
9.4 KiB
JavaScript
289 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 预设 CSS 主题契约校验 / Preset CSS theme contract checker
|
||
* 校验 ui/src/renderer/pages/settings/DisplaySettings/presets/*.css(default.css 除外)
|
||
* 是否符合 presets/README.md 的主题契约:
|
||
* - 双块结构(:root,body 亮块 + [data-theme='dark'],[data-theme='dark'] body 暗块,暗块在后)
|
||
* - A 表 + B 表变量全量覆盖,且亮/暗两块变量集合对称
|
||
* - --primary-rgb / --primary-1..7 为 RGB 三元组
|
||
* - 无布局属性、无 @import / 外联 url、变量不进 @media、大括号配平
|
||
* - 内容弹层背景不过透明、消息排版外层不套主题背景
|
||
* - 预览缩略图取色键存在
|
||
*
|
||
* 用法 / Usage: node scripts/check-theme-contract.mjs (或 bun)
|
||
*/
|
||
import { readdirSync, readFileSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const PRESETS_DIR = join(
|
||
dirname(fileURLToPath(import.meta.url)),
|
||
'..',
|
||
'ui/src/renderer/pages/settings/DisplaySettings/presets'
|
||
);
|
||
|
||
const range = (prefix, from, to) => Array.from({ length: to - from + 1 }, (_, i) => `${prefix}${from + i}`);
|
||
|
||
/** 契约 A 表:App 变量 / Contract table A: app variables */
|
||
const APP_VARS = [
|
||
'color-primary',
|
||
'primary',
|
||
...range('color-primary-light-', 1, 3),
|
||
'color-primary-dark-1',
|
||
'primary-rgb',
|
||
'brand',
|
||
'brand-light',
|
||
'brand-hover',
|
||
'color-brand-fill',
|
||
'color-brand-bg',
|
||
...range('aou-', 1, 10),
|
||
'bg-base',
|
||
...range('bg-', 1, 6),
|
||
'bg-8',
|
||
'bg-9',
|
||
'bg-10',
|
||
'bg-hover',
|
||
'bg-active',
|
||
'fill',
|
||
'color-fill',
|
||
'fill-0',
|
||
'fill-white-to-black',
|
||
'dialog-fill-0',
|
||
'inverse',
|
||
'text-primary',
|
||
'text-secondary',
|
||
'text-disabled',
|
||
'text-0',
|
||
'text-white',
|
||
'border-base',
|
||
'border-light',
|
||
'border-special',
|
||
'success',
|
||
'warning',
|
||
'danger',
|
||
'info',
|
||
'message-user-bg',
|
||
'message-tips-bg',
|
||
'workspace-btn-bg',
|
||
'color-guid-agent-bar',
|
||
'terminal-surface-bg',
|
||
'terminal-border',
|
||
];
|
||
|
||
/** 契约 B 表:Arco token(必须打在含 body 的选择器组) / Contract table B: Arco tokens */
|
||
const ARCO_VARS = [
|
||
...range('color-bg-', 1, 5),
|
||
'color-bg-popup',
|
||
'color-bg-white',
|
||
...range('color-text-', 1, 4),
|
||
...range('color-fill-', 1, 4),
|
||
'color-border',
|
||
...range('color-border-', 1, 4),
|
||
'color-primary-light-4',
|
||
...range('primary-', 1, 7),
|
||
'color-secondary',
|
||
'color-secondary-hover',
|
||
'color-secondary-active',
|
||
'color-secondary-disabled',
|
||
'color-tooltip-bg',
|
||
'color-mask-bg',
|
||
'color-spin-layer-bg',
|
||
'color-menu-light-bg',
|
||
'color-menu-dark-bg',
|
||
];
|
||
|
||
const REQUIRED = [...APP_VARS, ...ARCO_VARS];
|
||
const TRIPLET_VARS = ['primary-rgb', ...range('primary-', 1, 7)];
|
||
const PREVIEW_KEYS = ['bg-1', 'bg-2', 'bg-3', 'color-primary', 'color-text-3', 'color-fill-2', 'color-primary-light-3'];
|
||
const LAYOUT_PROPS = new Set([
|
||
'display',
|
||
'position',
|
||
'overflow',
|
||
'overflow-x',
|
||
'overflow-y',
|
||
'z-index',
|
||
'width',
|
||
'height',
|
||
'min-width',
|
||
'max-width',
|
||
'min-height',
|
||
'max-height',
|
||
'margin',
|
||
'margin-top',
|
||
'margin-right',
|
||
'margin-bottom',
|
||
'margin-left',
|
||
'padding',
|
||
'padding-top',
|
||
'padding-right',
|
||
'padding-bottom',
|
||
'padding-left',
|
||
]);
|
||
|
||
const MESSAGE_ITEM_FORBIDDEN_PROPS = new Set([
|
||
'background',
|
||
'background-color',
|
||
'background-image',
|
||
'backdrop-filter',
|
||
'-webkit-backdrop-filter',
|
||
]);
|
||
const CONTENT_POPOVER_SELECTORS = ['.arco-popover-content', '.arco-dropdown-menu', '.arco-select-popup'];
|
||
const MIN_CONTENT_SURFACE_ALPHA = 0.86;
|
||
|
||
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||
|
||
/** 顶层块扫描(@media/@keyframes 整体视作一个块) / Top-level block scan */
|
||
const topLevelBlocks = (css) => {
|
||
const blocks = [];
|
||
let i = 0;
|
||
while (i < css.length) {
|
||
const open = css.indexOf('{', i);
|
||
if (open === -1) break;
|
||
const prevClose = css.lastIndexOf('}', open);
|
||
const selector = css
|
||
.slice(prevClose === -1 ? 0 : prevClose + 1, open)
|
||
.trim()
|
||
.replace(/\s+/g, ' ');
|
||
let depth = 1;
|
||
let j = open + 1;
|
||
while (j < css.length && depth > 0) {
|
||
if (css[j] === '{') depth++;
|
||
else if (css[j] === '}') depth--;
|
||
j++;
|
||
}
|
||
blocks.push({ selector, body: css.slice(open + 1, j - 1), start: open, end: j });
|
||
i = j;
|
||
}
|
||
return blocks;
|
||
};
|
||
|
||
const collectVars = (body) => {
|
||
const map = new Map();
|
||
const re = /--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);/g;
|
||
let m;
|
||
while ((m = re.exec(body)) !== null) map.set(m[1], m[2].trim());
|
||
return map;
|
||
};
|
||
|
||
const isTriplet = (value) => /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(value.replace(/\s*!important\s*/i, ''));
|
||
|
||
const declarationEntries = (body) => {
|
||
const entries = [];
|
||
const re = /(?:^|[{;])\s*([-\w]+)\s*:\s*([^;{}]+);/g;
|
||
let m;
|
||
while ((m = re.exec(body)) !== null) entries.push({ prop: m[1], value: m[2].trim() });
|
||
return entries;
|
||
};
|
||
|
||
const rgbaAlphaValues = (value) => {
|
||
const alphas = [];
|
||
const re = /rgba\(\s*[^,]+\s*,\s*[^,]+\s*,\s*[^,]+\s*,\s*([0-9.]+)\s*\)/gi;
|
||
let m;
|
||
while ((m = re.exec(value)) !== null) alphas.push(Number(m[1]));
|
||
return alphas.filter((n) => Number.isFinite(n));
|
||
};
|
||
|
||
const checkTheme = (file, css) => {
|
||
const problems = [];
|
||
const cleaned = stripComments(css);
|
||
|
||
// 大括号配平 / brace balance
|
||
const opens = (cleaned.match(/\{/g) || []).length;
|
||
const closes = (cleaned.match(/\}/g) || []).length;
|
||
if (opens !== closes) problems.push(`大括号不配平: { x${opens} vs } x${closes}`);
|
||
|
||
// 外联资源 / external resources
|
||
if (/@import\b/.test(cleaned)) problems.push('包含 @import');
|
||
if (/url\(\s*['"]?https?:/i.test(cleaned)) problems.push('包含外联 url(http...)');
|
||
|
||
const blocks = topLevelBlocks(cleaned);
|
||
const lightBlocks = blocks.filter((b) => /:root/.test(b.selector) && /(^|[,\s])body\b/.test(b.selector));
|
||
const darkBlocks = blocks.filter(
|
||
(b) => /\[data-theme=["']?dark["']?\]/.test(b.selector) && /\[data-theme=["']?dark["']?\]\s+body\b/.test(b.selector)
|
||
);
|
||
|
||
if (lightBlocks.length === 0) problems.push('缺少亮色块(选择器需同时含 :root 与 body)');
|
||
if (darkBlocks.length === 0) problems.push("缺少暗色块(选择器需含 [data-theme='dark'] 与 [data-theme='dark'] body)");
|
||
if (lightBlocks.length && darkBlocks.length && darkBlocks[0].start < lightBlocks[0].start) {
|
||
problems.push('暗色块出现在亮色块之前');
|
||
}
|
||
|
||
const lightVars = new Map();
|
||
for (const b of lightBlocks) for (const [k, v] of collectVars(b.body)) lightVars.set(k, v);
|
||
const darkVars = new Map();
|
||
for (const b of darkBlocks) for (const [k, v] of collectVars(b.body)) darkVars.set(k, v);
|
||
|
||
for (const v of REQUIRED) {
|
||
if (!lightVars.has(v)) problems.push(`亮色块缺变量 --${v}`);
|
||
if (!darkVars.has(v)) problems.push(`暗色块缺变量 --${v}`);
|
||
}
|
||
// 对称性(除契约清单外的自定义变量也要求对称,--sider-section-title-color 例外)
|
||
const symmetricExempt = new Set(['sider-section-title-color']);
|
||
for (const k of lightVars.keys()) {
|
||
if (!darkVars.has(k) && !symmetricExempt.has(k)) problems.push(`变量 --${k} 只在亮色块出现(不对称)`);
|
||
}
|
||
for (const k of darkVars.keys()) {
|
||
if (!lightVars.has(k) && !symmetricExempt.has(k)) problems.push(`变量 --${k} 只在暗色块出现(不对称)`);
|
||
}
|
||
|
||
for (const v of TRIPLET_VARS) {
|
||
for (const [mode, vars] of [
|
||
['亮', lightVars],
|
||
['暗', darkVars],
|
||
]) {
|
||
const value = vars.get(v);
|
||
if (value && !isTriplet(value)) problems.push(`${mode}色块 --${v} 不是 RGB 三元组: "${value}"`);
|
||
}
|
||
}
|
||
|
||
for (const key of PREVIEW_KEYS) {
|
||
if (!lightVars.has(key)) problems.push(`预览取色键 --${key} 在亮色块缺失`);
|
||
}
|
||
|
||
// 布局属性(@keyframes 块内豁免——其内是动画帧)/ layout props outside keyframes
|
||
for (const b of blocks) {
|
||
if (/@(?:-webkit-)?keyframes\b/.test(b.selector)) continue;
|
||
for (const { prop, value } of declarationEntries(b.body)) {
|
||
if (LAYOUT_PROPS.has(prop)) problems.push(`布局属性 "${prop}" 出现在选择器 "${b.selector.slice(0, 60)}"`);
|
||
|
||
if (b.selector.includes('.message-item') && MESSAGE_ITEM_FORBIDDEN_PROPS.has(prop)) {
|
||
problems.push(`禁止给 .message-item 设置 "${prop}"(消息排版外层不能套主题背景)`);
|
||
}
|
||
|
||
const isContentPopover = CONTENT_POPOVER_SELECTORS.some((selector) => b.selector.includes(selector));
|
||
if (isContentPopover && (prop === 'background' || prop === 'background-color')) {
|
||
const lowAlpha = rgbaAlphaValues(value).find((alpha) => alpha < MIN_CONTENT_SURFACE_ALPHA);
|
||
if (lowAlpha != null) {
|
||
problems.push(
|
||
`内容弹层 "${b.selector.slice(0, 60)}" 的 ${prop} 透明度 ${lowAlpha} 过低(需 >= ${MIN_CONTENT_SURFACE_ALPHA})`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// 变量不进 @media / vars must not live inside @media
|
||
if (/^@media\b/.test(b.selector) && /--[a-zA-Z0-9-_]+\s*:/.test(b.body)) {
|
||
problems.push('@media 块内定义了 CSS 变量(预览解析不到)');
|
||
}
|
||
}
|
||
|
||
return problems;
|
||
};
|
||
|
||
const files = readdirSync(PRESETS_DIR).filter((f) => f.endsWith('.css') && f !== 'default.css');
|
||
let failed = false;
|
||
for (const file of files) {
|
||
const css = readFileSync(join(PRESETS_DIR, file), 'utf8');
|
||
const problems = checkTheme(file, css);
|
||
if (problems.length) {
|
||
failed = true;
|
||
console.log(`✗ ${file}`);
|
||
for (const p of problems) console.log(` - ${p}`);
|
||
} else {
|
||
console.log(`✓ ${file}`);
|
||
}
|
||
}
|
||
if (!files.length) {
|
||
console.log('(presets 目录下没有非 default 主题)');
|
||
}
|
||
process.exit(failed ? 1 : 0);
|