Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 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 未做部分)
189 lines
9.5 KiB
JavaScript
189 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-examples.mjs — 逐示例用法片段的静态验收(零依赖)
|
||
*
|
||
* 被验收的东西(P21「一个使用场景 = 一块预览 + 一块调用代码」):
|
||
* 1. 每个组件都有 site/examples/<slug>.json,且与 data.json 的目录一致(数量/标题/出处)
|
||
* 2. 每个示例都有 H5 标记与四端片段,且 **不是空串**
|
||
* 3. 片段里出现的 prop 名必须能在该组件的 API 集合里找到
|
||
* (来源:契约 dims ∪ data.json 的 c.api.props ∪ 组件源码的参数表)
|
||
* 4. 片段里出现的 class 必须在该组件 CSS 里真实存在(脚手架类除外)
|
||
* 5. 预览隐藏计划的下标路径必须能在演示页 body 的元素序列里解析出来
|
||
* —— 演示页改了结构而构建产物没重跑,这条会红
|
||
* 6. data.js 里不得再内嵌示例正文(只允许 { id, title, source } 目录)
|
||
*
|
||
* 设计原则与构建端一致:判据只认「能指到源码某处」的事实,不做启发式打分。
|
||
* 用法:node tools/verify-examples.mjs [--verbose]
|
||
*/
|
||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { parseHtmlTree } from './lib/demo-examples.mjs';
|
||
|
||
const ROOT = join(import.meta.dirname, '..');
|
||
const VERBOSE = process.argv.includes('--verbose');
|
||
const checks = [];
|
||
const failures = [];
|
||
function check(name, ok, detail = '') {
|
||
checks.push({ name, ok, detail });
|
||
if (!ok) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
|
||
}
|
||
|
||
const dataPath = join(ROOT, 'site', 'data.json');
|
||
if (!existsSync(dataPath)) {
|
||
console.error('[examples] 缺少 site/data.json —— 先跑 node tools/precompute.mjs');
|
||
process.exit(1);
|
||
}
|
||
const data = JSON.parse(readFileSync(dataPath, 'utf8'));
|
||
const EXAMPLES_DIR = join(ROOT, 'site', 'examples');
|
||
|
||
/* ---------- 逐组件检查 ---------- */
|
||
|
||
const API_EXTRA = new Set(['className', 'class', 'style', 'onClick', 'onChange', 'onInput', 'onClose', 'onOk', 'onTabAdd', 'onTabRemove', 'children', 'text', 'key', 'slot']);
|
||
const FRAMEWORK_ATTRS = new Set(['className', 'class', 'style', 'role', 'tabindex', 'aria-label', 'aria-labelledby', 'aria-hidden', 'title', 'id']);
|
||
|
||
const problems = [];
|
||
let totalExamples = 0;
|
||
let totalSnippets = 0;
|
||
const badSnippets = [];
|
||
const unverifiedClasses = [];
|
||
const badPaths = [];
|
||
|
||
for (const c of data.components) {
|
||
const file = join(EXAMPLES_DIR, `${c.slug}.json`);
|
||
if (!existsSync(file)) { problems.push(`${c.slug}: 缺 examples/<slug>.json`); continue; }
|
||
const payload = JSON.parse(readFileSync(file, 'utf8'));
|
||
const list = payload.examples || [];
|
||
if (!list.length) { problems.push(`${c.slug}: 示例数为 0`); continue; }
|
||
|
||
/* 1. 与 data.json 目录一致 */
|
||
const dir = c.examples || [];
|
||
if (dir.length !== list.length) problems.push(`${c.slug}: data.json 目录 ${dir.length} 条 ≠ 正文 ${list.length} 条`);
|
||
const idSet = new Set(list.map((e) => e.id));
|
||
if (idSet.size !== list.length) problems.push(`${c.slug}: 示例 id 重复`);
|
||
|
||
/* 2/3/4. 片段内容检查 */
|
||
const cssClasses = new Set([...String(c.sources && c.sources.css || '').matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map((m) => m[1]));
|
||
const apiProps = new Set((((c.api || {}).props) || []).map((p) => p.name));
|
||
const dims = new Set((((c.contract || {}).dims) || []).map((d) => d.name));
|
||
const src = [
|
||
(c.sources && c.sources.jsx) || '',
|
||
(c.sources && c.sources.vue3) || '',
|
||
(c.sources && c.sources.vue2) || '',
|
||
].join('\n');
|
||
/* 源码里出现过的标识符:defineProps 键、props.X、解构参数——作为 prop 名的第三来源 */
|
||
const srcIdent = new Set();
|
||
for (const m of src.matchAll(/\bprops\.([A-Za-z0-9_$]+)/g)) srcIdent.add(m[1]);
|
||
for (const m of src.matchAll(/defineProps\s*\(\s*\{([\s\S]*?)\}\s*\)/g)) {
|
||
for (const k of m[1].matchAll(/^\s*([a-zA-Z_$][A-Za-z0-9_$]*)\s*:/gm)) srcIdent.add(k[1]);
|
||
}
|
||
for (const m of src.matchAll(/export default function\s+\w+\s*\(\s*\{([^}]+)\}/g)) {
|
||
m[1].split(',').forEach((p) => {
|
||
const n = p.trim().split(/[:=]/)[0].trim();
|
||
if (/^[a-zA-Z_$][A-Za-z0-9_$]*$/.test(n)) srcIdent.add(n);
|
||
});
|
||
}
|
||
const knownProp = (n) => apiProps.has(n) || dims.has(n) || srcIdent.has(n) || API_EXTRA.has(n);
|
||
|
||
list.forEach((ex) => {
|
||
totalExamples++;
|
||
const id = ex.id || '(no id)';
|
||
if (!ex.title) problems.push(`${c.slug}/${id}: 缺标题`);
|
||
if (!ex.html || !ex.html.trim()) problems.push(`${c.slug}/${id}: H5 标记为空`);
|
||
const code = ex.code || {};
|
||
['html', 'jsx', 'vue3', 'vue2'].forEach((end) => {
|
||
const text = code[end];
|
||
if (text == null) { badSnippets.push(`${c.slug}/${id}: 缺 ${end} 片段`); return; }
|
||
if (!String(text).trim()) { badSnippets.push(`${c.slug}/${id}: ${end} 片段为空`); return; }
|
||
totalSnippets++;
|
||
/* 3. prop 名可溯 */
|
||
if (end !== 'html') {
|
||
const tagRe = /<Kole[A-Za-z0-9]*([^>]*?)\/?>/g;
|
||
let m;
|
||
while ((m = tagRe.exec(String(text)))) {
|
||
const attrs = m[1] || '';
|
||
for (const a of attrs.matchAll(/(?:^|\s)(?::|v-model(?=[:=])|)([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:=|(?=\s|$))/g)) {
|
||
const name = a[1];
|
||
if (!name || name.startsWith('v-')) continue;
|
||
if (/^(aria|data)-/.test(name)) continue; // ARIA / data-* 是原生属性,不是组件 prop
|
||
if (/^on[A-Z]/.test(name)) continue; // 事件绑定(onClick/onChange)由 emits 表管
|
||
if (FRAMEWORK_ATTRS.has(name) || FRAMEWORK_ATTRS.has(name.toLowerCase())) continue;
|
||
/* kebab-case 也要按 camelCase 认(Vue 模板里 :model-value 对应 modelValue) */
|
||
const camel = name.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
|
||
if (!knownProp(name) && !knownProp(camel)) badSnippets.push(`${c.slug}/${id}: ${end} 片段出现未知 prop「${name}」`);
|
||
}
|
||
}
|
||
}
|
||
/* 4. class 可溯 */
|
||
const clsRe = /class(?:Name)?\s*=\s*"([^"]*)"/g;
|
||
let cm;
|
||
while ((cm = clsRe.exec(String(text)))) {
|
||
cm[1].split(/\s+/).filter(Boolean).forEach((cl) => {
|
||
if (cssClasses.has(cl)) return;
|
||
if (cl === 'row' || cl === 'group') return; // 演示页脚手架,允许出现在 H5 标记里
|
||
unverifiedClasses.push(`${c.slug}/${id}: ${end} 片段里的 class「${cl}」不在 ${c.slug}.css 中`);
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
/* 5. 预览隐藏路径可解析 */
|
||
const bodyHtml = (String((c.sources && c.sources.html) || '').match(/<body[^>]*>([\s\S]*)<\/body>/i) || [, ''])[1];
|
||
const bodyNodes = parseHtmlTree(bodyHtml).filter((n) => n.type === 'element');
|
||
list.forEach((ex) => {
|
||
const plan = ex.preview || {};
|
||
(plan.hide || []).concat(plan.show || []).forEach((path) => {
|
||
let node = { children: bodyNodes };
|
||
let ok = true;
|
||
for (const i of path) {
|
||
const kids = (node.children || []).filter((n) => n.type === 'element');
|
||
if (!kids[i]) { ok = false; break; }
|
||
node = kids[i];
|
||
}
|
||
if (!ok) badPaths.push(`${c.slug}/${ex.id}: 预览路径 [${path.join(',')}] 解析不到节点`);
|
||
});
|
||
/* demo 脚本钩子 id 不得出现在任何片段里(它是演示页的实现细节):
|
||
从演示页脚本里取 getElementById / querySelector('#x') 的引用名,逐个对照。 */
|
||
const ids = new Set();
|
||
for (const m of String(c.sources && c.sources.html || '').matchAll(/getElementById\s*\(\s*['"]([^'"]+)['"]|querySelector(?:All)?\s*\(\s*['"]#([A-Za-z0-9_-]+)['"]/g)) {
|
||
ids.add(m[1] || m[2]);
|
||
}
|
||
['html', 'jsx', 'vue3', 'vue2'].forEach((end) => {
|
||
const text = String((ex.code || {})[end] || '');
|
||
ids.forEach((id) => {
|
||
if (new RegExp('id="' + id + '"').test(text)) {
|
||
badSnippets.push(`${c.slug}/${ex.id}: ${end} 片段残留演示脚本钩子 id「${id}」`);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
check('每个组件都有 examples 文件', data.components.every((c) => existsSync(join(EXAMPLES_DIR, `${c.slug}.json`))),
|
||
`${data.components.length} 个组件`);
|
||
check('示例提取无结构性问题', problems.length === 0, problems.slice(0, 6).join(' | '));
|
||
check('示例正文非空', badSnippets.length === 0, badSnippets.slice(0, 6).join(' | '));
|
||
check('片段里的 class 都能指到组件 CSS', unverifiedClasses.length === 0, unverifiedClasses.slice(0, 6).join(' | '));
|
||
check('预览隐藏路径可解析', badPaths.length === 0, badPaths.slice(0, 6).join(' | '));
|
||
|
||
/* 6. data.js 不得内嵌示例正文 */
|
||
const dataJs = readFileSync(join(ROOT, 'site', 'data.js'), 'utf8');
|
||
const hasInlineCode = /"examples":\s*\[\{"id":[^}]*"code":/.test(dataJs);
|
||
check('data.js 只保留示例目录(正文按需加载)', !hasInlineCode);
|
||
check('data.js 记录了 examplesRef', /"examplesRef":"examples\//.test(dataJs));
|
||
|
||
/* 7. 示例文件数量与组件数一致(无孤儿) */
|
||
const files = readdirSync(EXAMPLES_DIR).filter((f) => f.endsWith('.json'));
|
||
check('examples 文件数与组件数一致', files.length === data.components.length, `${files.length} 个文件`);
|
||
check('examples 文件数与组件数一致(无孤儿 slug)',
|
||
files.every((f) => data.components.some((c) => c.slug === f.slice(0, -5))));
|
||
|
||
for (const r of checks) console.log(`[examples] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
|
||
if (VERBOSE) {
|
||
console.log(`[examples] 统计:${totalExamples} 个示例 / ${totalSnippets} 个片段`);
|
||
}
|
||
if (failures.length) {
|
||
console.error(`\n[examples] ${failures.length} check(s) failed`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`\n[examples] OK — ${checks.length} checks passed(${totalExamples} 个示例 / ${totalSnippets} 个片段)`);
|