Files
aurora-admin/tools/verify-cross-platform.mjs
T
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

704 lines
34 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.
#!/usr/bin/env node
/**
* verify-cross-platform.mjs — S2-P6 跨端一致性自动验证(方案 B:静态结构比对)
*
* 对索引中全部组件的 4 端实现(H5 / React-JSX / Vue2 / Vue3)提取 class 集合与
* 结构骨架(标签集合 + 元素数),做集合 diff,输出 tests/cross-platform-report.json。
*
* - 零运行时依赖(只读文件 + 正则,不渲染、不联网)。
* - 本脚本只负责「暴露差异」,发现差异 = 任务价值,不在本脚本内顺手修。
* - 退出码:0 = 报告成功生成;1 = 脚本自身失败(读不到数据、无输出、total 与索引不一致)。
*
* 用法:node tools/verify-cross-platform.mjs [--out tests/cross-platform-report.json]
*/
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join, basename } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const arg = (k) => {
const i = process.argv.indexOf(k);
return i > -1 ? process.argv[i + 1] : null;
};
const OUT = arg('--out') || join(ROOT, 'tests', 'cross-platform-report.json');
const data = JSON.parse(readFileSync(join(ROOT, 'site', 'data.json'), 'utf8'));
const components = data.components || [];
if (!components.length) {
console.error('[FATAL] site/data.json 无 components');
process.exit(1);
}
/* ---------- 提取器 ---------- */
function addTokens(set, text) {
(text || '').split(/\s+/).filter(Boolean).forEach((t) => set.add(t));
}
// H5:class="a b"
function classesFromHtml(src) {
const set = new Set();
const re = /class\s*=\s*"([^"]*)"/g;
let m;
while ((m = re.exec(src))) addTokens(set, m[1]);
// H5 演示页 DOM 由内联 <script> 动态渲染(如 Tree 的 `kole-tree-node${sel}`):
// 扫描 script 内模板字符串/引号字面量中的类名,与 JSX 同口径
const scripts = src.match(/<script[^>]*>([\s\S]*?)<\/script>/g) || [];
for (const block of scripts) {
// 反引号模板:`kole-tabs-tab${...}` → 头部静态类 + ${} 内三元分支引号类
// 头部有两种形态:裸类(`kole-tabs-tab${`)与 HTML 属性(`<div class="kole-tree-node${`),都要提
for (const tm of block.matchAll(/`([^`]*?)`/g)) {
const tpl = tm[1];
const head = tpl.split('${')[0];
// 形态1:HTML 属性 class="a b
for (const hm of head.matchAll(/class\s*=\s*"([^"]*)$/g)) {
hm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
// 形态2:裸类名
head.split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
for (const qm of tpl.matchAll(/'([^']*?)'/g)) {
qm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
// 双引号:只收 class="..." 内的,role="link" 等别的属性值不收
for (const cm of tpl.matchAll(/class\s*=\s*"([^"]*?)"/g)) {
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
}
// 普通引号 class 拼接:'<div class="kole-xxx' + x / "kole-xxx"
// 引号内是 HTML 片段(含空格/属性),不能要求闭合引号紧跟类名
for (const qm of block.matchAll(/'([^']*?)'/g)) {
// 取其中的 class="..." 属性
for (const cm of qm[1].matchAll(/class\s*=\s*"([^"]*)/g)) {
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]+$/.test(t)) set.add(t);
});
}
// 裸 kole-/is- 类(如 'kole-mention-item' + (...) 中的独立 token)
const inner = qm[1].trim();
if (/^(?:kole-|is-|has-|btn-|col-)[A-Za-z0-9_-]+$/.test(inner)) set.add(inner);
}
// className = 'kole-message is-' 这类赋值语句:引号内以空格结尾的类前缀也要收头部类
for (const am of block.matchAll(/(?:className|\.className)\s*=\s*'([^']*?)'/g)) {
am[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^(?:kole-|is-|has-|btn-|col-)[A-Za-z0-9_-]+$/.test(t)) set.add(t);
});
}
for (const qm of block.matchAll(/"([^"]*?)"/g)) {
for (const cm of qm[1].matchAll(/class\s*=\s*"([^"]*)/g)) {
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]+$/.test(t)) set.add(t);
});
}
}
// 跨模板变量引用:`${sel}` 且 sel 定义含 ' is-selected' 时收录
// 形态:const sel = cond ? ' is-x' : ''; ... `${...${sel}...}`
for (const vm of block.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?(['"]\s*is-[A-Za-z0-9_-]*['"])/g)) {
const varName = vm[1];
if (new RegExp(`\\$\\{[^}]*\\b` + varName + `\\b[^}]*\\}`).test(block)) {
const cls = vm[2].replace(/['"\s]/g, '');
if (/^is-[A-Za-z0-9_-]+$/.test(cls)) set.add(cls);
}
}
// H5 内联脚本的 'kole-xxx is-' + type / 'is-' + status 动态拼接:
// 用 ICONS/TITLES/比较/默认值找枚举值展开(与 JSX/Vue 同口径)
{
const hasIsConcat = /['"`]\s*(?:kole-[A-Za-z0-9_-]*\s+)?is-['"`]?\s*\+\s*(?:type|status)\b/.test(block) ||
/is-\$\{(?:type|status)\}/.test(block);
if (hasIsConcat) {
const suffixes = new Set();
for (const cm of block.matchAll(/(?:ICONS|ICON|TITLES)\s*=\s*\{([^}]*)\}/g)) {
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) {
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
}
}
for (const cm of block.matchAll(/type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
}
for (const sfx of suffixes) set.add('is-' + sfx);
}
}
}
return set;
}
// JSX:className="a b" / className={`a ${x}`} / className={'a': cond} 对象写法
// + 变量引用 className={classes} / className={cls}:追踪该变量定义域内的字符串字面量
function classesFromJsx(src) {
const set = new Set();
let re = /className\s*=\s*"([^"]*)"/g;
let m;
while ((m = re.exec(src))) addTokens(set, m[1]);
// className={...} 配对扫描:`[^}]*` 会在模板字符串的 ${...} 处提前截断,
// 漏掉 ' is-active' 这类写在 ${} 后面的状态类。必须按 {} 配对取完整 body。
// 注意反引号模板字符串内的 ${...}:其 { 要计 depth(它是 JS 表达式),
// 普通 '...'/"..." 内的 { 则不计数。
re = /className\s*=\s*\{/g;
while ((m = re.exec(src))) {
let depth = 1, j = m.index + m[0].length;
let inStr = null; // ', ", ` 三种字符串状态
while (j < src.length && depth > 0) {
const ch = src[j];
if (inStr === '`') {
// 模板字符串内:只有 ${ 才进入表达式(计 depth),其余字符跳过
if (ch === '\\') { j += 2; continue; }
if (ch === '`') { inStr = null; j++; continue; }
if (ch === '$' && src[j + 1] === '{') { depth++; j += 2; continue; }
if (ch === '}') {
// 模板字符串内的 } 只闭合 ${ 表达式;depth>1 才减(depth==1 是 className={ 的,模板内不能直接闭合它)
depth--;
j++;
continue;
}
j++;
continue;
}
if (inStr) {
if (ch === '\\') { j += 2; continue; }
if (ch === inStr) inStr = null;
j++;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; j++; continue; }
if (ch === '{') depth++;
else if (ch === '}') depth--;
j++;
}
const body = src.slice(m.index + m[0].length, j - 1);
// body 内引号有三种形态,必须分开处理(不能用一个 /['"`]([^'"`]*?)['"`]/ 通吃,
// 否则 `...${... ? ' is-active' : ''}...` 会按 ' 配对错位,把类名切碎):
// 1) 反引号模板字符串:整体解析,${} 内三元分支的 'xxx' 逐个收
for (const tm of body.matchAll(/`([^`]*?)`/g)) {
const tpl = tm[1];
// 模板头部的静态类:`kole-tabs-tab${...}` → kole-tabs-tab
const head = tpl.split('${')[0];
head.split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
// ${} 内三元/逻辑表达式里的引号字面量:? ' is-active' : ''
for (const qm of tpl.matchAll(/'([^']*?)'/g)) {
qm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
// 双引号:只收 class="..." 内的,role="link" 等别的属性值不收
for (const cm of tpl.matchAll(/class\s*=\s*"([^"]*?)"/g)) {
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
}
// 2) 普通 '...' / "..." 字面量(排除已在反引号内处理过的):去 body 的反引号段后匹配
const bodyNoTpl = body.replace(/`[^`]*?`/g, ' ');
for (const qm of bodyNoTpl.matchAll(/'([^']*?)'/g)) {
qm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
for (const cm of bodyNoTpl.matchAll(/class\s*=\s*"([^"]*?)"/g)) {
cm[1].split(/\s+/).filter(Boolean).forEach((t) => {
if (/^[A-Za-z][\w-]*$/.test(t)) set.add(t);
});
}
// `btn-${type}` / 'btn-' + x 模板前缀:展开为对应家族
const dynBtn = body.match(/['"`]btn-['"`]?\s*[+$]/) || body.match(/btn-\$\{/);
if (dynBtn) ['btn-primary', 'btn-default', 'btn-text', 'btn-link', 'btn-danger'].forEach((t) => set.add(t));
}
// 'kole-<base>-' + <var> 全文件动态拼接(如 Tag 的 cls.push('kole-tag-' + color),
// 写在普通 JS 语句里,不在 className={} 内):找该 base 的枚举值展开
// 同理 'is-' + status 这类状态前缀:找 status/type 的枚举值展开
{
const dynAa = [...src.matchAll(/['"`](kole-[A-Za-z0-9_-]*-)['"`]?\s*\+(?!\s*['"`])/g)].map((x) => x[1]);
// 'is-' + status:is- 前可能有空格或其他类名(如 'kole-progress is-' + status),单独匹配
// `is-${type}` 插值形态(如 `kole-result is-${type}`)也要收
const dynIs = [...src.matchAll(/(?:['"`\s])(is-)\s*['"`]?\s*\+\s*(?:status|type)\b/g)].map(() => 'is-');
const dynIsTpl = [...src.matchAll(/is-\$\{(?:status|type)\}/g)].map(() => 'is-');
for (const base of [...new Set([...dynAa, ...dynIs, ...dynIsTpl])]) {
const suffixes = new Set();
const baseEsc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (const sm of src.matchAll(new RegExp(`['"\`]` + baseEsc + `([A-Za-z0-9_-]+)['"\`]`, 'g'))) {
suffixes.add(sm[1]);
}
// PRESET / OPTIONS / 枚举数组里的字符串:['green','red',...]
for (const am of src.matchAll(/(?:PRESET|OPTIONS|COLORS|TYPES|SIZES|ENUM)\s*=\s*\[([^\]]*)\]/g)) {
for (const qm of am[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]/g)) suffixes.add(qm[1]);
}
// 状态比较字面量:status === 'success' 里的值是合法后缀;
// normal 从不作为 class 输出(四端一致:normal 时不拼 is-),排除。
// type 枚举:仅当值为 success/error/warning/info 状态语义时收(line/dashboard 是布局参数,不收)
if (base === 'is-') {
for (const cm of src.matchAll(/status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (cm[1] !== 'normal') suffixes.add(cm[1]);
}
// 默认值:status = 'success'(排除 status = 'normal')
for (const cm of src.matchAll(/status\s*=\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (cm[1] !== 'normal') suffixes.add(cm[1]);
}
// type 默认值与比较:type = 'success' / type === 'error',仅状态语义值
for (const cm of src.matchAll(/type\s*(?:=|===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
}
// iconMap[type] / ICON[type] 的 key:{ success: '✓', ... } 或 return { iconMap: {...} }
// key 可能无引号,块首注意空白;= 或 : 两种赋值形态都要收
for (const cm of src.matchAll(/(?:ICON|iconMap)\s*(?:=|:)\s*\{([^}]*)\}/g)) {
for (const qm of cm[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]\s*:/g)) {
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
}
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) {
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
}
}
// 同文件 <style>/CSS 已定义的 is-* 类
const stylePart2 = src.match(/<style[^>]*>([\s\S]*)<\/style>/) || [null, ''];
for (const cm of (stylePart2[1] || '').matchAll(/\.is-([A-Za-z0-9_-]+)(?![\w-])/g)) suffixes.add(cm[1]);
}
for (const sfx of suffixes) {
if (/^[A-Za-z0-9_-]+$/.test(sfx) && !sfx.includes('$')) set.add(base + sfx);
}
}
}
// HTML 字符串拼接里的 class="..."(如 '<span class="kole-plate-tag">' 写在 JS 字符串中,
// 不在 JSX 属性位):全文件扫描 class="...",与 classesFromHtml 同口径
{
const re2 = /class\s*=\s*"([^"]*)"/g;
let m2;
while ((m2 = re2.exec(src))) addTokens(set, m2[1]);
}
// const classes = ['btn', `btn-${type}`, ...] 变量定义追踪
// 注意:数组体内可能含 sizeMap[size] 这类内层 [],用配对扫描而非 [^\]]*
re = /(?:const|let|var)\s+(\w+)\s*=\s*\[/g;
while ((m = re.exec(src))) {
const varName = m[1];
let depth = 1, j = m.index + m[0].length;
while (j < src.length && depth > 0) {
if (src[j] === '[') depth++;
else if (src[j] === ']') depth--;
j++;
}
const arrBody = src.slice(m.index + m[0].length, j - 1);
if (new RegExp(`className\\s*=\\s*{\\s*${varName}\\b`).test(src)) {
const lit = arrBody.match(/['"`]([A-Za-z][\w-]*(-\$\{[^}]*\}|\+)?)['"`]/g) || [];
lit.forEach((s) => {
const inner = s.slice(1, -1);
if (inner.includes('${') || inner.endsWith('-')) {
// `btn-${type}` → 展开家族
if (inner.startsWith('btn-')) ['btn-primary', 'btn-default', 'btn-text', 'btn-link', 'btn-danger'].forEach((t) => set.add(t));
else set.add(inner);
} else set.add(inner);
});
// sizeMap[size] 引用:追踪 sizeMap 对象字面量的值(数组体可能含换行,先压平)
const arrFlat = arrBody.replace(/\s+/g, ' ');
const mapRefs = arrFlat.match(/(\w+)\s*\[\s*\w+\s*\]/g) || [];
mapRefs.forEach((ref) => {
const mapName = ref.split('[')[0].trim();
const mapDecl = src.match(new RegExp(`(?:const|let|var)\\s+${mapName}\\s*=\\s*\\{([^}]*)\\}`));
if (mapDecl) {
// { large: 'btn-lg', default: 'btn-md' } 只取冒号后的值(键是尺寸名,不是类)
const vals = mapDecl[1].match(/:\s*['"`]([A-Za-z][\w-]*)['"`]/g) || [];
vals.forEach((v) => set.add(v.replace(/^:\s*['"`]/, '').replace(/['"`]$/, '')));
}
});
}
}
re = /className\s*=\s*{`([^`]*)`}/g; // className={`kole-modal kole-modal--${size}`}
while ((m = re.exec(src))) {
const cleaned = m[1].replace(/\$\{[^}]*\}/g, ' ');
cleaned.split(/\s+/).filter(Boolean)
.filter((t) => /^[A-Za-z][\w-]*[A-Za-z0-9]$/.test(t))
.forEach((t) => set.add(t));
// kole-modal--${size} → 展开尺寸家族(从同文件 <style>/css 找 --small/--default/--large 后缀)
if (/\$\{/.test(m[1])) {
const base = (m[1].match(/([A-Za-z][\w-]*)--/) || [null, null])[1];
if (base) {
const fam = src.match(new RegExp(`${base}--([A-Za-z][\\w-]*)`, 'g')) || [];
fam.forEach((f) => set.add(f.replace(/^[.{]/, '')));
}
}
}
re = /['"]([A-Za-z][\w-]*)['"]\s*:/g; // {'is-open': open} 对象键
while ((m = re.exec(src))) {
// COLORS = { success: '#hex', normal: '#hex' } 这类颜色/枚举映射对象的 key 不是 class,跳过
// 同理 H5/Vue 内联脚本的 var color = { success: ..., normal: ... } 也要跳过
const before = src.slice(Math.max(0, m.index - 120), m.index);
if (/(?:COLORS|STYLES|MAP|ENUM|PRESET|OPTIONS|color)\s*=\s*\{[^}]*$/.test(before)) continue;
// var color = {...} 声明内的 key:向前找 var/const/let ... = {
const declStart = src.lastIndexOf('=', m.index);
if (declStart > 0) {
const declHead = src.slice(Math.max(0, declStart - 80), declStart);
if (/(?:var|const|let)\s+\w+\s*$/.test(declHead)) {
const afterOpen = src.indexOf('{', declStart);
if (afterOpen > 0 && afterOpen < m.index) {
const closeIdx = src.indexOf('}', m.index);
const semiIdx = src.indexOf(';', m.index);
// key 在 { ... } 对象内且后面是 : '#hex' 形态 → 映射表,跳过
if (closeIdx > 0 && (semiIdx < 0 || closeIdx < semiIdx) && /:\s*['"]#/.test(src.slice(m.index, m.index + 40))) continue;
}
}
}
set.add(m[1]);
}
return set;
}
// Vue:class="..." 静态 + :class="'a-' + x" 引号段 + {'k': ...} 对象键
// + computed classes 追踪(:class="classes" → computed.classes 内字符串字面量)
function classesFromVue(src) {
const set = new Set();
const tpl = (src.match(/<template>([\s\S]*)<\/template>/) || [null, src])[1];
let re = /(^|\s)class\s*=\s*"([^"]*)"/g;
let m;
while ((m = re.exec(tpl))) addTokens(set, m[2]);
re = /:class\s*=\s*"([^"]*)"/g;
const boundVars = [];
while ((m = re.exec(tpl))) {
if (/^[\w$]+$/.test(m[1].trim())) boundVars.push(m[1].trim()); // :class="classes"
const q = m[1].match(/'([A-Za-z][\w-]*)'/g) || [];
q.forEach((s) => set.add(s.slice(1, -1)));
}
// :class="tagClass" 这类绑定变量名本身不是类:进变量名噪音表
for (const v of boundVars) VAR_NOISE.add(v);
// 模板内 'is-' + status / 'is-' + type 动态拼接:用枚举展开
// status 枚举:status 比较值 + 默认值(排除 normal)
// type 枚举:仅当 type 值含 success/error/warning/info 状态语义时展开(line/dashboard 是布局参数,不拼)
{
const tplDynIs = [...tpl.matchAll(/'is-'\s*\+\s*(?:status|type|props\.(?:status|type))\b/g)];
if (tplDynIs.length) {
const suffixes = new Set();
for (const cm of tpl.matchAll(/status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (cm[1] !== 'normal') suffixes.add(cm[1]);
}
for (const cm of src.matchAll(/status\s*=\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (cm[1] !== 'normal') suffixes.add(cm[1]);
}
// type 枚举:只收状态语义值
const typeVals = new Set();
for (const cm of tpl.matchAll(/type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) typeVals.add(cm[1]);
for (const cm of src.matchAll(/type\s*:\s*\{\s*type\s*:\s*String\s*,[^}]*?default\s*:\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) typeVals.add(cm[1]);
for (const cm of src.matchAll(/type\s*=\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) typeVals.add(cm[1]);
// iconMap key 也是合法 type 枚举:{ success: '✓', ... } 或 return { iconMap: {...} }
for (const cm of src.matchAll(/(?:ICON|iconMap)\s*(?:=|:)\s*\{([^}]*)\}/g)) {
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) typeVals.add(qm[1]);
}
for (const v of typeVals) {
if (['success', 'error', 'warning', 'info'].includes(v)) suffixes.add(v);
}
// 同文件 style 已定义的 is-* 类
const stylePartT = src.match(/<style[^>]*>([\s\S]*)<\/style>/) || [null, ''];
for (const cm of (stylePartT[1] || '').matchAll(/\.is-([A-Za-z0-9_-]+)(?![\w-])/g)) suffixes.add(cm[1]);
for (const sfx of suffixes) {
if (/^[A-Za-z0-9_-]+$/.test(sfx) && !sfx.includes('$')) set.add('is-' + sfx);
}
}
}
// 对象键:{'is-open': open} 带引号 + { fixed } 简写(无引号,key 即类名)
re = /['"]([A-Za-z][\w-]*)['"]\s*:/g;
while ((m = re.exec(tpl))) set.add(m[1]);
re = /:class\s*=\s*"\{\s*([A-Za-z][\w-]*)\s*\}"/g;
while ((m = re.exec(tpl))) set.add(m[1]);
// 追踪 computed/变量定义里的类名字面量(whole-file sweep:
// options-API/composition 都覆盖;只收「像类名」的 token 防噪音)
const script = (src.match(/<script[^>]*>([\s\S]*)<\/script>/) || [null, ''])[1];
const litAll = script.match(/['"`]([A-Za-z][\w-]*)['"`]/g) || [];
litAll.forEach((s) => {
const inner = s.slice(1, -1);
// 结尾带 - 的是 'kole-tag-' + x 这类拼接前缀残留:不是完整类,跳过(由动态拼接展开处理)
if (inner.endsWith('-')) return;
if (inner.includes('-') || ['btn', 'spinner', 'active', 'open', 'selected', 'disabled', 'loading'].includes(inner)) set.add(inner);
});
// Vue script 里的 'kole-<base>-' + var 动态拼接:同 JSX,用枚举展开
// 同理 'is-' + status:is- 前可能有空格/类名前缀
{
const dynAa = [...script.matchAll(/['"`](kole-[A-Za-z0-9_-]*-)['"`]?\s*\+(?!\s*['"`])/g)].map((x) => x[1]);
const dynIsV = [...script.matchAll(/(?:['"`\s])(is-)\s*['"`]?\s*\+\s*(?:status|type|props\.(?:status|type))\b/g)].map(() => 'is-');
for (const base of [...new Set([...dynAa, ...dynIsV])]) {
const suffixes = new Set();
const baseEsc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (const sm of script.matchAll(new RegExp(`['"\`]` + baseEsc + `([A-Za-z0-9_-]+)['"\`]`, 'g'))) {
suffixes.add(sm[1]);
}
for (const am of script.matchAll(/(?:PRESET|OPTIONS|COLORS|TYPES|SIZES|ENUM)\s*=\s*\[([^\]]*)\]/g)) {
for (const qm of am[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]/g)) suffixes.add(qm[1]);
}
// iconMap / ICON 对象的 key(含无引号形态):{ success: '✓', ... } 或 return { iconMap: {...} }
// 注意块首第一个 key 前面是 '{ ',用 [,{\s] 分隔符统收(含块首空白);= 或 : 两种形态都要收
for (const cm of script.matchAll(/(?:ICON|iconMap)\s*(?:=|:)\s*\{([^}]*)\}/g)) {
for (const qm of cm[1].matchAll(/['"`]([A-Za-z0-9_-]+)['"`]\s*:/g)) {
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
}
for (const qm of cm[1].matchAll(/(?:[,{\s])([A-Za-z0-9_-]+)\s*:/g)) {
if (['success', 'error', 'warning', 'info'].includes(qm[1])) suffixes.add(qm[1]);
}
}
// 同文件 <style> 里该 base 的已定义类:.kole-tag-green {...}
const stylePart = src.match(/<style[^>]*>([\s\S]*)<\/style>/) || [null, ''];
for (const cm of (stylePart[1] || '').matchAll(new RegExp(`\\.` + baseEsc + `([A-Za-z0-9_-]+)(?![\\w-])`, 'g'))) {
suffixes.add(cm[1]);
}
// is- 前缀:status 比较值(排除 normal,它从不输出为类);
// type 比较值/默认值/iconMap key 仅状态语义值(success/error/warning/info)收,line/dashboard 不收
if (base === 'is-') {
for (const cm of script.matchAll(/status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (cm[1] !== 'normal') suffixes.add(cm[1]);
}
for (const cm of script.matchAll(/props\.status\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (cm[1] !== 'normal') suffixes.add(cm[1]);
}
for (const cm of script.matchAll(/type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
}
for (const cm of script.matchAll(/props\.type\s*(?:===|!==|==|!=)\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
}
// type 默认值:type: { type: String, default: 'success' }
for (const cm of script.matchAll(/type\s*:\s*\{\s*type\s*:\s*String\s*,[^}]*?default\s*:\s*['"`]([A-Za-z0-9_-]+)['"`]/g)) {
if (['success', 'error', 'warning', 'info'].includes(cm[1])) suffixes.add(cm[1]);
}
}
for (const sfx of suffixes) {
if (/^[A-Za-z0-9_-]+$/.test(sfx) && !sfx.includes('$')) set.add(base + sfx);
}
}
}
// `btn-${x}` 模板前缀 → 展开家族
if (/btn-\$\{|['"`]btn-['"`]?\s*[+`]/.test(script)) ['btn-primary', 'btn-default', 'btn-text', 'btn-link', 'btn-danger'].forEach((t) => set.add(t));
// sizeMap 等映射对象的值
const maps = script.match(/(?:const|let|var)\s+\w+\s*=\s*\{([^}]*)\}/g) || [];
maps.forEach((decl) => {
const vals = decl.match(/:\s*['"`]([A-Za-z][\w-]*)['"`]/g) || [];
vals.forEach((v) => set.add(v.replace(/^[::\s]*['"`]/, '').replace(/['"`]$/, '').replace(/^:\s*/, '').trim()));
});
return set;
}
// CSS:.foo 选择器定义集(排除 url()/数值小数)
function classesFromCss(src) {
const set = new Set();
const noUrls = src.replace(/url\([^)]*\)/g, 'url()');
const re = /\.(-?[_a-zA-Z][\w-]*)/g;
let m;
while ((m = re.exec(noUrls))) set.add(m[1]);
return set;
}
// 结构骨架:小写标签集合 + 元素个数
function structureOf(src, onlyTemplate) {
let s = src;
if (onlyTemplate) s = (src.match(/<template>([\s\S]*)<\/template>/) || [null, src])[1];
const tags = new Set();
let count = 0;
const re = /<([a-zA-Z][\w-]*)\b/g;
let m;
while ((m = re.exec(s))) {
if (/^[a-z]/.test(m[1]) && !['br', 'img', 'input', 'meta', 'link'].includes(m[1]) || ['div', 'span', 'button', 'table', 'ul', 'li', 'form', 'input', 'select', 'img', 'a', 'p', 'h1', 'h2', 'h3', 'i', 'em', 'svg', 'path', 'br', 'hr', 'label', 'textarea', 'template', 'slot', 'block', 'view', 'text', 'scroll-view', 'uni-view'].includes(m[1])) {
tags.add(m[1]);
count++;
} else if (/^[a-z]/.test(m[1])) {
tags.add(m[1]);
count++;
}
}
return { tags: [...tags].sort(), elements: count };
}
/* ---------- 噪音过滤 ---------- */
// H5 演示页的布局包装类(非组件契约类):三框架端天生没有,计入 diff 是噪音
const DEMO_WRAPPERS = new Set(['sub', 'group', 'row', 'demo', 'demo-block', 'hint', 'field', 'wrap', 'wrapper',
'container', 'example', 'sample', 'preview', 'section', 'page', 'toolbar',
// P0 去噪:kole- 演示页壳(实测 77 差异中 H5 独有 355 项的主力噪音)
'kole-page', 'kole-h2', 'kole-desc', 'kole-demo', 'kole-demo__title', 'kole-code-tip', 'kole-panel',
'kole-demo-toolbar', 'kole-demo-panel', 'kole-box', 'kole-card', 'kole-btn', 'kole-h1', 'kole-sub',
'kole-row', 'kole-group', 'kole-section', 'kole-toolbar', 'kole-note', 'kole-tip', 'kole-ph']);
// 变量名/属性名误收(:class="classes" 的变量名本身不是类)
const VAR_NOISE = new Set(['classes', 'cls', 'className', 'class',
// P1 补:Vue/React 模板与脚本中的 prop/变量/事件名误收(实测 input 等组件残留)
'modelValue', 'resolvedType', 'text', 'input', 'value', 'placeholder', 'button',
'default', 'size', 'type', 'color', 'checked', 'selected', 'disabled', 'loading',
'open', 'visible', 'active', 'current', 'index', 'key', 'label', 'title', 'name',
'onChange', 'onClick', 'onClose', 'onSelect', 'handleClick', 'handleInput',
'valid', 'invalid', 'ok', 'error', 'cell', 'log', 'col', 'light', 'dark', 'auto',
'sm', 'lg', 'cur', 'lab', 'desc', 'unknown', 'radio', 'none', 'round', 'true',
'horizontal', 'vertical', 'primary', 'isOpen', 'allSelected', 'tabAdd', 'step',
'min', 'max', 'sans-serif', 'number', 'numeric', 'activeId', 'sideIdx', 'c.key',
'd.status', 'isOpen', 'animClass', 'circleCirc', 'arcLen', 'rowClick', 'sortChange',
'selectionChange', 'tabRemove', 'stepClick', 'rowCb', 'linkBtn',
// P1 补:H5 演示私有类与 Vue 事件名(非跨端语义)
'row-cb', 'link-btn', 'sort-ind', 'selection-change', 'sort-change', 'row-click',
'tab-remove', 'tab-add', 'step-click', 'selection-change', 'cell', 'unknown',
// H5 演示变体名(与框架端 prop 枚举语义一致,非缺失)
'danger', 'sm', 'lg', 'index', 'cur', 'lab', 'log', 'col', 'active',
'type-line', 'type-card', 'type-pill', 'placement-bottom', 'placement-top',
'slider-row', 'kole-thumb', 'kole-sheet-btn', 'kole-demo-banner', 'kole-demo-spacer',
'kole-countdown-done', 'kole-hint', 'light', 'dark', 'auto', 'default', 'size',
'text', 'ok', 'error', 'valid', 'invalid', 'primary', 'isOpen', 'allSelected',
// P1-2 补:H5 演示交互私有类(transfer 左右列标识、sidemenu 演示开关、treetable 键盘提示)
'left', 'right', 'toggle', 'menuitem', 'checkbox', 'treeitem', 'tree', 'Enter',
'collapsed',
// P1-2 补:Vue 尺寸枚举(is-sm/small/is-lg/large 与 H5/React 具体尺寸类语义等价)
'is-sm', 'small', 'is-lg', 'large',
// P1-3 补:从不作为 class 输出的状态默认值(四端一致:只出现在 prop 默认值/比较/COLORS key)
'normal',
// P1-4 补:H5 演示私有状态与主题演示壳(非跨端语义)
'is-muted', 'up', 'down', '!hasChild', 'kole-mode', 'kole-dark',
'kole-card__body', 'kole-btn--primary',
// role= 属性值残留碎片
'role=']);
// JS 表达式碎片(从 className={...} 对象键/三元表达式正则中误收的符号与标识符碎片)
const EXPR_NOISE = new Set(['?', ':', "'", '"', '+', '-', '*', '/', '%', '!', '&', '|', '=',
'===', '==', '!==', '=>', 'is-', "is-'", 'it.status', '(i', 'number', 'numeric',
'1200', 'is-active\'', 'is-open\'', 'is-checked\'', 'is-selected\'', "''", "'')"]);
function deNoise(set) {
for (const x of [...set]) {
if (DEMO_WRAPPERS.has(x) || VAR_NOISE.has(x) || EXPR_NOISE.has(x)) { set.delete(x); continue; }
if (x.includes('$') || x.includes('{') || x.includes('}')) set.delete(x); // 模板残留
// 单字符与纯符号碎片:不可能是合法 class
if (/^[^a-zA-Z_-]*$/.test(x) || x.length <= 1) { set.delete(x); continue; }
// 含引号/括号/分号的表达式残留
if (/['"();,]/.test(x)) { set.delete(x); continue; }
// 动态前缀残留(如 type-${type} 展开失败剩下的 'type-'):结尾带 - 的不是完整类
if (x.endsWith('-')) { set.delete(x); continue; }
}
return set;
}
/* ---------- 主流程 ---------- */
const ENDS = ['h5', 'react', 'vue2', 'vue3'];
const items = [];
let readErrors = [];
for (const c of components) {
const files = c.files || {};
const kind = { h5: files.html, react: files.jsx, vue2: files.vue2, vue3: files.vue3 };
const cssFile = files.css;
const sets = {};
const missing = [];
for (const end of ENDS) {
if (!kind[end]) {
missing.push(end);
sets[end] = new Set();
continue;
}
try {
const src = readFileSync(join(ROOT, 'frameworks', basename(kind[end])), 'utf8');
sets[end] = end === 'h5' ? classesFromHtml(src)
: end === 'react' ? classesFromJsx(src) : classesFromVue(src);
} catch (e) {
readErrors.push(`${c.slug}/${end}: ${e.message}`);
sets[end] = new Set();
}
}
let cssSet = new Set();
try {
if (cssFile) cssSet = classesFromCss(readFileSync(join(ROOT, 'frameworks', basename(cssFile)), 'utf8'));
} catch (e) {
readErrors.push(`${c.slug}/css: ${e.message}`);
}
// 结构骨架
const structs = {};
for (const end of ENDS) {
if (!kind[end]) {
structs[end] = { tags: [], elements: 0 };
continue;
}
try {
const src = readFileSync(join(ROOT, 'frameworks', basename(kind[end])), 'utf8');
structs[end] = structureOf(src, end === 'vue2' || end === 'vue3');
} catch {
structs[end] = { tags: [], elements: 0 };
}
}
// diff:union 视角 + 共识视角(先去掉 H5 演示包装类与变量名噪音)
for (const end of ENDS) deNoise(sets[end]);
const union = new Set();
const presence = new Map(); // class -> [ends...]
for (const end of ENDS) {
for (const cls of sets[end]) {
union.add(cls);
if (!presence.has(cls)) presence.set(cls, []);
presence.get(cls).push(end);
}
}
const diffs = [];
for (const [cls, has] of presence) {
if (has.length === ENDS.length) continue;
const absent = ENDS.filter((e) => !has.includes(e));
const type = has.length >= 3 ? 'missing-consensus' // ≥3 端有、某端缺:疑似真实遗漏
: has.length === 1 ? 'extra-single-end' // 仅一端有:多为演示包装/条件类
: 'symmetric'; // 各两端:需人工看一眼
diffs.push({ class: cls, presentIn: has, missingIn: absent, type });
}
// css 定义了但四端都没用到的类(死样式嫌疑,只列不判)
const cssUnused = [...cssSet].filter((x) => !union.has(x));
let severity = 'none';
if (diffs.some((d) => d.type === 'missing-consensus')) severity = 'high';
else if (diffs.some((d) => d.type === 'symmetric')) severity = 'medium';
else if (diffs.length) severity = 'low';
items.push({
slug: c.slug,
files: Object.fromEntries(ENDS.map((e) => [e, kind[e] ? basename(kind[e]) : null])),
missingFiles: missing,
platforms: Object.fromEntries(ENDS.map((e) => [e, [...sets[e]].sort()])),
css: [...cssSet].sort(),
structure: structs,
diffs,
cssUnused,
severity
});
}
const identical = items.filter((i) => i.diffs.length === 0).length;
const report = {
generated: new Date().toISOString().replace('T', ' ').slice(0, 19),
version: (data.meta && data.meta.version) || null,
total: items.length,
identical,
differing: items.length - identical,
severityCounts: {
high: items.filter((i) => i.severity === 'high').length,
medium: items.filter((i) => i.severity === 'medium').length,
low: items.filter((i) => i.severity === 'low').length,
none: identical
},
readErrors,
samples: items.filter((i) => i.diffs.length).slice(0, 3).map((i) => ({
slug: i.slug,
severity: i.severity,
diffs: i.diffs.slice(0, 8)
})),
items
};
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(report, null, 2) + '\n');
console.log(`检查组件: ${report.total}`);
console.log(`完全一致: ${report.identical}`);
console.log(`有差异 : ${report.differing} (high ${report.severityCounts.high} / medium ${report.severityCounts.medium} / low ${report.severityCounts.low})`);
if (readErrors.length) {
console.log('读取失败:');
readErrors.slice(0, 10).forEach((e) => console.log(' ' + e));
}
console.log(`written: ${OUT}`);
console.log('说明: high=≥3端共识缺失(疑似真实遗漏,需建任务修); medium=两端对称差异(人工看); low=单端独有(多为演示包装类)。发现差异是本任务的价值,不在本次顺手修。');
if (report.total !== components.length) {
console.error(`FAIL: total=${report.total}; expected ${components.length}`);
process.exit(1);
}
if (readErrors.length) {
console.error(`FAIL: ${readErrors.length} file read error(s)`);
process.exit(1);
}
console.log('cross-platform differences are informational; historical differences do not fail this check');