Files
aurora-admin/tools/verify-mobile-docs.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

481 lines
22 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-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> 里 " 变成 &quot;),判据按转义形态写 */
if (!html.includes(`data-demo=&quot;${id}&quot;`)) {
srcProblems.push(`${c.slug}: 演示块 ${id} 的代码区没有演示页原文(应含 data-demo=&quot;${id}&quot;)`);
}
}
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} 页)`);