#!/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 页面壳与交互 左栏
+ 复制脚本 + 左栏平台入口 * * 零依赖;退出码 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(`

${title}

`)) sectionProblems.push(`${c.slug}: 缺小节 ${id}`); } /* 每个 API 表都要有行(props 至少 1 行,避免空表演进) */ const ct = contracts.get(c.slug); if (!/\s*/.test(html.slice(html.indexOf('id="api"')))) sectionProblems.push(`${c.slug}: API 表为空`); if ((ct.api?.props || []).length && !html.includes(`${ct.api.props[0].name}`)) { 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('
')) sidebarProblems.push(`${file}: 缺左栏(或未默认展开)`); /* ① 平台组:两条 + 「当前」角标(PC 侧栏同款) */ if (!html.includes('>平台')) 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(`', 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(/
/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=`); /* 代码区是**转义后**的原文( 里 " 变成 "),判据按转义形态写 */ 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 未登记`); } /* 每个演示块要有非空代码(
 里至少 80 字符的原文) */
  const demoCodeLens = [...html.matchAll(/
([\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(`${v}`)) 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(/(--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,路径按生成后的目录算)、代码块内容
   (
 里的 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(//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} 页)`);