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 未做部分)
481 lines
22 KiB
JavaScript
481 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-mobile-docs.mjs — 移动端文档站**完整性**门禁
|
||
*
|
||
* 为什么需要它:本站是生成物(build-mobile.mjs 产出)。生成器的 bug 不会报错,只会让页面
|
||
* **悄悄少一块** —— 少一张 API 表、少一个端源码块、侧栏漏一个组件,页面看上去都正常。
|
||
* 这类"缺内容"必须靠断言抓,不能靠人翻 5 个页面。
|
||
*
|
||
* 断言分组:
|
||
* E1 页面齐全 index / guide / platform / tokens / 组件页 / 测试总览
|
||
* E2 组件页必备小节 14 个小节锚点(缺一即失败)
|
||
* E3 侧栏完整且高亮正确 每页列出全部组件;恰好 1 个 is-active
|
||
* E4 契约 API ↔ 源码一致 props / events 在各端源码里逐名可提取(双向:契约不漏、源码不多)
|
||
* E5 6 端代码块齐全 每个声明端在页面里有自己的源码块
|
||
* E6 变体类名真实存在 contract.variantClasses 的类/变量必须出现在该组件 CSS 里
|
||
* E7 页面壳与交互 左栏 <details class="m-side"> + 复制脚本 + 左栏平台入口
|
||
*
|
||
* 零依赖;退出码 0 = 全部通过。
|
||
*/
|
||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const LIB = join(ROOT, '.design_library', 'kole-ui-mobile');
|
||
const SITE = join(ROOT, 'site', 'm');
|
||
const SRC = join(ROOT, 'frameworks-mobile');
|
||
const read = (p) => readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
|
||
|
||
let pass = 0;
|
||
const failures = [];
|
||
function check(ok, id, detail) {
|
||
if (ok) {
|
||
pass++;
|
||
console.log(` PASS ${id}${detail ? ' — ' + detail : ''}`);
|
||
} else {
|
||
failures.push(id + (detail ? ' — ' + detail : ''));
|
||
console.log(` FAIL ${id}${detail ? ' — ' + detail : ''}`);
|
||
}
|
||
}
|
||
|
||
const index = JSON.parse(read(join(LIB, 'components', 'index.json')));
|
||
const components = index.components;
|
||
const ENDS = index.ends;
|
||
const contracts = new Map(components.map((c) => [c.slug, JSON.parse(read(join(LIB, c.contract)))]));
|
||
const pageOf = (c) => read(join(SITE, 'component', c.slug + '.html'));
|
||
|
||
console.log('移动端文档站完整性门禁');
|
||
console.log(`组件 ${components.length} 个 × ${ENDS.length} 端 | 页面 ${components.length + 5} 个`);
|
||
|
||
/* ---------- E1 页面齐全 ---------- */
|
||
|
||
const PAGES = [
|
||
'index.html',
|
||
'guide.html',
|
||
'design.html',
|
||
'faq.html',
|
||
'changelog.html',
|
||
'platform.html',
|
||
...components.map((c) => `component/${c.slug}.html`),
|
||
];
|
||
const missingPages = PAGES.filter((p) => !existsSync(join(SITE, p)));
|
||
check(missingPages.length === 0, `E1 文档页齐全(${PAGES.length} 页)`, missingPages.join(', ') || null);
|
||
check(
|
||
existsSync(join(ROOT, 'tests', 'mobile', 'index.html')) && existsSync(join(ROOT, 'tests', 'mobile', '_collect.html')),
|
||
'E1b 移动端测试总览与收集器存在'
|
||
);
|
||
|
||
/* ---------- E2 组件页必备小节 ---------- */
|
||
|
||
const REQUIRED_SECTIONS = [
|
||
['demo', '演示'],
|
||
['api', 'API'],
|
||
['usage', '何时使用'],
|
||
['interaction', '交互与触控'],
|
||
['a11y', '无障碍'],
|
||
['related', '相似组件'],
|
||
['gaps', '规格未定 / 禁止发明'],
|
||
['anatomy', '结构(anatomy)'],
|
||
['variants', '变体维度与类名映射'],
|
||
['rep-variants', '代表变体'],
|
||
['tokens', '用到的令牌'],
|
||
['sources', '6 端源码'],
|
||
['tests', '测试与回归'],
|
||
['contract', '设计契约'],
|
||
];
|
||
const sectionProblems = [];
|
||
for (const c of components) {
|
||
const html = pageOf(c);
|
||
for (const [id, title] of REQUIRED_SECTIONS) {
|
||
if (!html.includes(`<h2 id="${id}">${title}</h2>`)) sectionProblems.push(`${c.slug}: 缺小节 ${id}`);
|
||
}
|
||
/* 每个 API 表都要有行(props 至少 1 行,避免空表演进) */
|
||
const ct = contracts.get(c.slug);
|
||
if (!/<tbody>\s*<tr>/.test(html.slice(html.indexOf('id="api"')))) sectionProblems.push(`${c.slug}: API 表为空`);
|
||
if ((ct.api?.props || []).length && !html.includes(`<code>${ct.api.props[0].name}</code>`)) {
|
||
sectionProblems.push(`${c.slug}: API props 未渲染`);
|
||
}
|
||
}
|
||
check(sectionProblems.length === 0, `E2 组件页 ${REQUIRED_SECTIONS.length} 个小节齐全`, sectionProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E3 侧栏与顶栏:与 PC 同结构 ---------- */
|
||
|
||
const sidebarProblems = [];
|
||
const rootPages = ['index.html', 'guide.html', 'design.html', 'faq.html', 'changelog.html', 'platform.html'];
|
||
/* 开发指南条目:与 PC 侧栏同名同序(前五项),移动端特有项置后 */
|
||
const GUIDE_LABELS = ['组件总览', '快速开始', '设计规范', '常见问题', '更新日志'];
|
||
const checkSidebar = (file, activeExpect) => {
|
||
const html = read(join(SITE, file));
|
||
if (!html.includes('<details class="m-side" open>')) sidebarProblems.push(`${file}: 缺左栏(或未默认展开)`);
|
||
/* ① 平台组:两条 + 「当前」角标(PC 侧栏同款) */
|
||
if (!html.includes('>平台</span>')) sidebarProblems.push(`${file}: 左栏缺「平台」分组`);
|
||
if (!html.includes('m-side-platform')) sidebarProblems.push(`${file}: 左栏缺平台入口(m-side-platform)`);
|
||
if (!html.includes('sp-badge">当前')) sidebarProblems.push(`${file}: 平台入口缺「当前」角标`);
|
||
/* ② 开发指南:与 PC 同名同序 */
|
||
const guideOrder = GUIDE_LABELS.map((l) => html.indexOf(`>${l}<`));
|
||
if (guideOrder.some((i) => i < 0)) {
|
||
sidebarProblems.push(`${file}: 开发指南缺条目(${GUIDE_LABELS.filter((l) => html.indexOf(`>${l}<`) < 0).join(',')})`);
|
||
} else if (guideOrder.some((i, k) => k > 0 && i < guideOrder[k - 1])) {
|
||
sidebarProblems.push(`${file}: 开发指南条目顺序与 PC 不一致`);
|
||
}
|
||
/* ③ 组件:列全 + 恰好一个高亮 */
|
||
const compLinks = components.filter((c) => {
|
||
const href = file.startsWith('component/') ? `${c.slug}.html` : `component/${c.slug}.html`;
|
||
return html.includes(`<a class="m-side-link" href="${href}"`) || html.includes(`is-active" href="${href}"`);
|
||
}).length;
|
||
if (compLinks !== components.length) sidebarProblems.push(`${file}: 侧栏组件链接 ${compLinks}/${components.length}`);
|
||
const active = (html.match(/m-side-link is-active/g) || []).length;
|
||
if (active !== 1) sidebarProblems.push(`${file}: 高亮项 ${active} 个(期望 1)`);
|
||
if (activeExpect && !html.includes(`is-active" href="${activeExpect}"`)) {
|
||
sidebarProblems.push(`${file}: 高亮项不是 ${activeExpect}`);
|
||
}
|
||
/* 顶栏(与 PC 同结构):logo + 版本控件 + 主导航 + 主题模式。
|
||
平台切换**不在顶栏**(2026-09-20 去重):顶栏那份与左栏「平台」组指向同一跳转
|
||
(实测两条链接 href 均为 ../index.html),是重复入口;PC 顶栏也没有它。
|
||
平台跳转的唯一入口是左栏 —— 上面的 ① 已断言它在、且带「当前」角标。 */
|
||
for (const [need, why] of [
|
||
['class="m-logo"', '顶栏缺 logo'],
|
||
['class="m-logo-mark"', '顶栏 logo 缺标记块'],
|
||
['id="m-ver-trigger"', '顶栏缺版本控件(PC 同款:可点下拉 / 清单拿不到时 data-single=1 退回角标)'],
|
||
['id="m-ver-menu"', '顶栏缺版本菜单'],
|
||
['class="m-nav"', '顶栏缺主导航'],
|
||
['id="m-mode-trigger"', '顶栏缺主题模式触发器'],
|
||
['id="m-mode-menu"', '顶栏缺主题模式菜单'],
|
||
]) {
|
||
if (!html.includes(need)) sidebarProblems.push(`${file}: ${why}`);
|
||
}
|
||
/* 顶栏主导航的 5 项:与 PC 同名同序 */
|
||
const topnav = html.slice(html.indexOf('class="m-nav"'), html.indexOf('</nav>', html.indexOf('class="m-nav"')));
|
||
const topnavOrder = GUIDE_LABELS.map((l) => topnav.indexOf(`>${l}<`));
|
||
if (topnavOrder.some((i) => i < 0)) sidebarProblems.push(`${file}: 顶栏缺主导航项`);
|
||
};
|
||
for (const p of rootPages) checkSidebar(p, null);
|
||
for (const c of components) {
|
||
checkSidebar(`component/${c.slug}.html`, c.slug + '.html');
|
||
}
|
||
check(sidebarProblems.length === 0, 'E3 每页顶栏与左栏与 PC 同结构(平台入口在左栏 / 开发指南 / 组件 N)', sidebarProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E4 契约 API ↔ 各端源码逐名一致 ---------- *
|
||
* 提取方式按端分别实现(都只依赖本仓库的写法约定):
|
||
* jsx export default function X({ a, b = 1 }) → 形参解构名
|
||
* vue3/uniapp defineProps({ ... }) 顶层键
|
||
* vue2 props: { ... } 顶层键
|
||
* 事件:vue3/uniapp 看 defineEmits([...]);vue2 看 $emit('x');jsx 看 onXxx 属性/调用。 */
|
||
|
||
function braceSlice(src, startIdx) {
|
||
/* 从 startIdx 处的 '{' 开始,返回配对的内容(不含外层花括号) */
|
||
let depth = 0;
|
||
for (let i = startIdx; i < src.length; i++) {
|
||
if (src[i] === '{') depth++;
|
||
else if (src[i] === '}') {
|
||
depth--;
|
||
if (depth === 0) return src.slice(startIdx + 1, i);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function topLevelEntries(objSrc) {
|
||
/* 只取深度 1 的 `key: value`;value 截到深度 0 的下一个逗号为止(用于判断有没有 default) */
|
||
const out = [];
|
||
let depth = 0;
|
||
let keyStart = 0;
|
||
for (let i = 0; i < objSrc.length; i++) {
|
||
const ch = objSrc[i];
|
||
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
||
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
||
else if (ch === ':' && depth === 0) {
|
||
const key = (objSrc.slice(keyStart, i).match(/([A-Za-z_$][\w$]*)\s*$/) || [])[1];
|
||
if (key) {
|
||
let j = i + 1;
|
||
let d2 = 0;
|
||
for (; j < objSrc.length; j++) {
|
||
const c2 = objSrc[j];
|
||
if (c2 === '{' || c2 === '[' || c2 === '(') d2++;
|
||
else if (c2 === '}' || c2 === ']' || c2 === ')') d2--;
|
||
else if (c2 === ',' && d2 === 0) break;
|
||
}
|
||
out.push({ name: key, value: objSrc.slice(i + 1, j) });
|
||
keyStart = j;
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function topLevelKeys(objSrc) {
|
||
return topLevelEntries(objSrc).map((e) => e.name);
|
||
}
|
||
|
||
/** 各端 props 声明:返回 [{ name, hasDefault }](css / html 端无 props 概念时返回 null) */
|
||
function propsOfEnd(slug, end, code) {
|
||
if (end === 'jsx') {
|
||
const m = code.match(/export default function \w+\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
||
if (!m) return null;
|
||
return m[1]
|
||
.split(',')
|
||
.map((seg) => seg.trim())
|
||
.filter(Boolean)
|
||
.map((seg) => {
|
||
const name = seg.split('=')[0].split(':')[0].trim();
|
||
return { name, hasDefault: seg.includes('=') };
|
||
})
|
||
.filter((p) => p.name);
|
||
}
|
||
if (end === 'vue3' || end === 'uniapp') {
|
||
const i = code.indexOf('defineProps(');
|
||
if (i < 0) return null;
|
||
const brace = code.indexOf('{', i);
|
||
if (brace < 0) return [];
|
||
return topLevelEntries(braceSlice(code, brace)).map((e) => ({
|
||
name: e.name,
|
||
hasDefault: /(^|[{,\s])default\s*:/.test(e.value),
|
||
}));
|
||
}
|
||
if (end === 'vue2') {
|
||
const i = code.search(/props\s*:\s*\{/);
|
||
if (i < 0) return null;
|
||
const brace = code.indexOf('{', i);
|
||
return topLevelEntries(braceSlice(code, brace)).map((e) => ({
|
||
name: e.name,
|
||
hasDefault: /(^|[{,\s])default\s*:/.test(e.value),
|
||
}));
|
||
}
|
||
return null; /* css / html 端无 props 概念 */
|
||
}
|
||
|
||
function eventsOfEnd(end, code) {
|
||
if (end === 'vue3' || end === 'uniapp') {
|
||
const m = code.match(/defineEmits\(\s*\[([^\]]*)\]/);
|
||
return m ? m[1].split(',').map((s) => s.replace(/['"\s]/g, '')).filter(Boolean) : [];
|
||
}
|
||
if (end === 'vue2') {
|
||
return [...code.matchAll(/\$emit\(\s*'([\w-]+)'/g)].map((m) => m[1]);
|
||
}
|
||
if (end === 'jsx') {
|
||
return [...code.matchAll(/\b(on[A-Z][A-Za-z]*)\b/g)].map((m) => m[1]);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/** React 端的「契约 → 形参」映射:props 直接对上;events 按 React 惯例写成 onXxx;
|
||
slots 的默认插槽在 React 里就是 children,具名插槽写成同名 prop(如 actions)。 */
|
||
function expectedJsxParams(ct) {
|
||
const api = ct.api || {};
|
||
const set = new Set((api.props || []).map((p) => p.name));
|
||
(api.events || []).forEach((e) => set.add('on' + e.name.charAt(0).toUpperCase() + e.name.slice(1)));
|
||
set.add('children');
|
||
(api.slots || []).forEach((s) => {
|
||
if (s.name && s.name !== 'default' && s.name !== '—') set.add(s.name);
|
||
});
|
||
return set;
|
||
}
|
||
|
||
const apiProblems = [];
|
||
const requiredProblems = [];
|
||
for (const c of components) {
|
||
const ct = contracts.get(c.slug);
|
||
const api = ct.api || {};
|
||
for (const p of api.props || []) {
|
||
for (const end of ENDS) {
|
||
if (end === 'css' || end === 'html') continue;
|
||
const code = read(join(SRC, c.files[end]));
|
||
const got = propsOfEnd(c.slug, end, code);
|
||
if (got === null) {
|
||
apiProblems.push(`${c.slug}/${end}: 未能提取 props 声明(契约里有 ${p.name})`);
|
||
continue;
|
||
}
|
||
const hit = got.find((g) => g.name === p.name);
|
||
if (!hit) {
|
||
apiProblems.push(`${c.slug}/${end}: 契约声明了 prop ${p.name},源码里没有`);
|
||
continue;
|
||
}
|
||
/* 必传列的严格定义:实现里没有默认值 ⇔ required=true(4 个框架端必须一致) */
|
||
if (!!p.required === !!hit.hasDefault) {
|
||
requiredProblems.push(
|
||
`${c.slug}/${end}: ${p.name} required=${!!p.required} 但源码${hit.hasDefault ? '有' : '无'}默认值(应相反)`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
/* 反向:源码里多出来的形参也要能对上契约(props / events 的 onXxx 形式 / slots),
|
||
对不上说明契约漏登记。 */
|
||
for (const end of ENDS) {
|
||
if (end === 'css' || end === 'html') continue;
|
||
const got = (propsOfEnd(c.slug, end, read(join(SRC, c.files[end]))) || []).map((p) => p.name);
|
||
const expected = end === 'jsx' ? expectedJsxParams(ct) : new Set((api.props || []).map((p) => p.name));
|
||
for (const name of got) {
|
||
if (!expected.has(name)) apiProblems.push(`${c.slug}/${end}: 源码形参 ${name} 在契约里没有对应登记`);
|
||
}
|
||
}
|
||
/* 事件:契约里的事件在 vue3 / vue2 端能读到 emit */
|
||
for (const e of api.events || []) {
|
||
if (e.name === '—') continue;
|
||
for (const end of ['vue3', 'vue2']) {
|
||
const got = eventsOfEnd(end, read(join(SRC, c.files[end])));
|
||
if (!got.includes(e.name)) apiProblems.push(`${c.slug}/${end}: 契约事件 ${e.name} 未在源码 emit`);
|
||
}
|
||
/* jsx 用 onXxx 回调:契约事件名转成 onXxx 后应能在源码里找到 */
|
||
{
|
||
const camel = 'on' + e.name.charAt(0).toUpperCase() + e.name.slice(1);
|
||
const jsx = read(join(SRC, c.files.jsx));
|
||
if (!new RegExp(`\\b${camel}\\b`).test(jsx)) apiProblems.push(`${c.slug}/jsx: 契约事件 ${e.name} → ${camel} 未在源码出现`);
|
||
}
|
||
}
|
||
}
|
||
check(apiProblems.length === 0, 'E4 契约 API 与 4 个框架端源码逐名一致(含反向)', apiProblems.slice(0, 8).join('; ') || null);
|
||
check(requiredProblems.length === 0, 'E4b 必传列与实现默认值一致(无默认值 ⇔ Y)', requiredProblems.slice(0, 8).join('; ') || null);
|
||
|
||
/* ---------- E5 每个声明的端都有代码块 + 每个演示块都有"真实预览 + 原文代码" ---------- */
|
||
|
||
const srcProblems = [];
|
||
for (const c of components) {
|
||
const html = pageOf(c);
|
||
const demoHtml = read(join(SRC, c.files.html));
|
||
const ct = contracts.get(c.slug);
|
||
|
||
for (const end of ENDS) {
|
||
const file = c.files[end];
|
||
if (!html.includes(`frameworks-mobile/${file}`)) srcProblems.push(`${c.slug}: 缺 ${end} 端代码块(${file})`);
|
||
}
|
||
|
||
/* 演示块 ↔ 演示页 data-demo:双向都要对得上(契约漏登记 / 演示页漏加属性都会被抓) */
|
||
const contractIds = (ct.demos || []).map((d) => d.id);
|
||
const pageIds = [...demoHtml.matchAll(/<section class="demo-block" data-demo="([a-z0-9-]+)">/g)].map((m) => m[1]);
|
||
for (const id of contractIds) {
|
||
if (!pageIds.includes(id)) srcProblems.push(`${c.slug}: 契约演示 ${id} 在演示页里没有对应 data-demo 块`);
|
||
if (!html.includes(`?demo=${id}`)) srcProblems.push(`${c.slug}: 演示块 ${id} 的预览帧未带 ?demo=`);
|
||
/* 代码区是**转义后**的原文(<code> 里 " 变成 "),判据按转义形态写 */
|
||
if (!html.includes(`data-demo="${id}"`)) {
|
||
srcProblems.push(`${c.slug}: 演示块 ${id} 的代码区没有演示页原文(应含 data-demo="${id}")`);
|
||
}
|
||
}
|
||
for (const id of pageIds) {
|
||
if (!contractIds.includes(id)) srcProblems.push(`${c.slug}: 演示页有 data-demo="${id}",但契约 demos 未登记`);
|
||
}
|
||
|
||
/* 每个演示块要有非空代码(<pre> 里至少 80 字符的原文) */
|
||
const demoCodeLens = [...html.matchAll(/<pre id="code-demo-\d+"><code>([\s\S]*?)<\/code><\/pre>/g)].map((m) => m[1].length);
|
||
if (demoCodeLens.length !== contractIds.length) {
|
||
srcProblems.push(`${c.slug}: 演示代码块 ${demoCodeLens.length} 个 ≠ 演示 ${contractIds.length} 个`);
|
||
}
|
||
if (demoCodeLens.some((n) => n < 80)) srcProblems.push(`${c.slug}: 存在过短的演示代码块(<80 字符)`);
|
||
}
|
||
check(srcProblems.length === 0, `E5 每端源码块 + 每个演示块的预览/原文代码齐全(${ENDS.length} 端)`, srcProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E6 变体类名在 CSS 里真实存在 ---------- */
|
||
|
||
const clsProblems = [];
|
||
for (const c of components) {
|
||
const ct = contracts.get(c.slug);
|
||
const css = read(join(SRC, c.files.css));
|
||
const vc = ct.variantClasses || {};
|
||
Object.entries(vc).forEach(([dim, map]) => {
|
||
Object.entries(map).forEach(([val, list]) => {
|
||
list.forEach((token) => {
|
||
if (token.startsWith('(')) return; /* 数据驱动的占位写法,不检查 */
|
||
if (token.startsWith('--')) {
|
||
if (!new RegExp(`${token}\\s*:`).test(css)) clsProblems.push(`${c.slug}: ${dim}=${val} 的变量 ${token} 未在 CSS 定义`);
|
||
} else {
|
||
const bare = token.replace(/^\./, '');
|
||
if (!new RegExp(`\\.${bare}(?![\\w-])`).test(css)) clsProblems.push(`${c.slug}: ${dim}=${val} 的类 ${token} 未在 CSS 出现`);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
check(clsProblems.length === 0, 'E6 契约 variantClasses 的类名/变量在组件 CSS 里真实存在', clsProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E6b CSS 变量表 ↔ 组件 CSS 里定义的组件级变量(双向) ---------- */
|
||
|
||
const cssVarProblems = [];
|
||
for (const c of components) {
|
||
const html = pageOf(c);
|
||
const css = read(join(SRC, c.files.css));
|
||
const defined = [...css.matchAll(/^\s*(--kole-m-[a-z0-9-]+)\s*:/gm)]
|
||
.map((m) => m[1])
|
||
.filter((n) => !/^--kole-m-(touch-target|navbar-height|tabbar-height|action-height|gutter|hit-slack|safe-|font-size-|duration-|ease-)/.test(n));
|
||
for (const v of defined) {
|
||
if (!html.includes(`<code>${v}</code>`)) cssVarProblems.push(`${c.slug}: CSS 变量 ${v} 未进「CSS 变量」表`);
|
||
}
|
||
/* 表里出现的 --kole-m-* 也必须真的是本组件定义的组件级变量(不是随便列的) */
|
||
const tableIdx = html.indexOf('CSS 变量');
|
||
const table = html.slice(tableIdx, tableIdx + 4000);
|
||
for (const m of table.matchAll(/<code>(--kole-m-[a-z0-9-]+)<\/code>/g)) {
|
||
if (!defined.includes(m[1])) cssVarProblems.push(`${c.slug}: CSS 变量表列了未定义的 ${m[1]}`);
|
||
}
|
||
}
|
||
check(cssVarProblems.length === 0, 'E6b CSS 变量表与组件 CSS 定义双向一致', cssVarProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E6c 相似组件表引用的 slug 必须存在 ---------- */
|
||
|
||
const relProblems = [];
|
||
const allSlugs = new Set(components.map((c) => c.slug));
|
||
for (const c of components) {
|
||
const ct = contracts.get(c.slug);
|
||
const list = ct.related || [];
|
||
if (!list.length) relProblems.push(`${c.slug}: 相似组件表为空`);
|
||
for (const r of list) {
|
||
if (!allSlugs.has(r.slug)) relProblems.push(`${c.slug}: 相似组件 ${r.slug} 不存在`);
|
||
if (!r.why || r.why.length < 8) relProblems.push(`${c.slug}: 相似组件 ${r.slug} 缺少「何时使用」说明`);
|
||
}
|
||
}
|
||
check(relProblems.length === 0, 'E6c 相似组件表引用真实组件且有区分说明', relProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E7 页面壳与交互 ---------- */
|
||
|
||
const shellProblems = [];
|
||
const allPages = [...rootPages, ...components.map((c) => `component/${c.slug}.html`)];
|
||
for (const p of allPages) {
|
||
const html = read(join(SITE, p));
|
||
/* 平台切换已从顶栏移除(重复入口,见 E3 注释):这里改为断言它在左栏存在,
|
||
防的是「去重时把两个入口都删了」这种反向缺陷。注意类名是
|
||
class="m-side-link m-side-platform" —— 别按 class="m-side-platform" 匹配(不成立)。 */
|
||
if (!html.includes('m-side-platform')) shellProblems.push(`${p}: 左栏缺平台入口`);
|
||
if (!html.includes("closest('.m-copy')")) shellProblems.push(`${p}: 缺复制脚本`);
|
||
if (!html.includes('class="m-body"')) shellProblems.push(`${p}: 缺两列布局容器`);
|
||
if (/__[A-Z_]+__/.test(html)) {
|
||
const left = [...new Set(html.match(/__[A-Z_]+__/g) || [])].join(',');
|
||
shellProblems.push(`${p}: 未替换占位符 ${left}`);
|
||
}
|
||
}
|
||
check(shellProblems.length === 0, 'E7 页面壳完整(左栏平台入口 / 复制脚本 / 无残留占位符)', shellProblems.slice(0, 6).join('; ') || null);
|
||
|
||
/* ---------- E8 站内文件链接全部可解析 ----------
|
||
实测价值:把 tokens.html 改名成 design.html 后,快速开始卡片与页脚还指向旧路径;
|
||
更新日志页的测试链接少退一级(../ 应为 ../../)。这两类都只有"点进去才发现",
|
||
静态断言能一次性抓完。排除项:模板(_*.html,路径按生成后的目录算)、代码块内容
|
||
(<pre> 里的 href="…" 是示例文本)、无扩展名地址(SPA 路由,交给 nginx 回落)。 */
|
||
|
||
const linkProblems = [];
|
||
{
|
||
const SITE_DIR = SITE;
|
||
const pages = [
|
||
...readdirSync(SITE_DIR).filter((x) => x.endsWith('.html') && !x.startsWith('_')),
|
||
...readdirSync(join(SITE_DIR, 'component')).map((x) => 'component/' + x),
|
||
];
|
||
let checked = 0;
|
||
for (const f of pages) {
|
||
const abs = join(SITE_DIR, f);
|
||
const html = read(abs).replace(/<pre[\s\S]*?<\/pre>/g, '');
|
||
for (const m of html.matchAll(/(?:href|src)="([^"]+)"/g)) {
|
||
const raw = m[1];
|
||
if (/^(https?:|data:|#|mailto:)/.test(raw)) continue;
|
||
const clean = raw.split(/[?#]/)[0];
|
||
if (!clean || !/\.[a-z0-9]+$/i.test(clean)) continue;
|
||
checked++;
|
||
if (!existsSync(resolve(dirname(abs), clean))) linkProblems.push(`${f} → ${raw}`);
|
||
}
|
||
}
|
||
check(linkProblems.length === 0, `E8 站内文件链接全部可解析(${checked} 条)`, linkProblems.slice(0, 6).join('; ') || null);
|
||
}
|
||
|
||
console.log('\n────────────────────────────');
|
||
if (failures.length) {
|
||
console.error(`[FAIL] ${failures.length} 条文档完整性断言失败(通过 ${pass} 条):`);
|
||
failures.forEach((f) => console.error(' - ' + f));
|
||
process.exit(1);
|
||
}
|
||
console.log(`[OK] 文档完整性门禁全部通过(${pass} 条断言 · ${allPages.length} 页)`);
|