#!/usr/bin/env node /** * verify-api-docs.mjs — 组件页 API 表「说明」的出处可溯性验收(零依赖)。 * * 背景:API 表的说明列在 S6-P37 之前 235 条里 230 条是 `—`。补说明时立了两条硬规矩, * 这个脚本就是把它们变成可复跑的判据 —— 说明可以来自规格原文、API 命名释义或源码注释, * 但**每一条都必须能被指认出来源**,且「规格」类必须是规格原文的逐字引用: * * 1) 每条 prop 都有说明,且 descSrc ∈ {spec, name, code, contract}(none 即失败) * 2) descSrc=spec 的 cite 必须逐字出现在该组件的规格原文分条里(防「看起来像规格」的编造) * 3) 事件说明不得回落到模板句(「组件交互触发事件」),update:* 必须标注 v-model * 4) 说明与事件文案必须都在 site/i18n.js 的英文字典里(否则英文模式回退中文) * 5) 规格取值副行只在「分条标签 === 该 prop 的说明」时出现,且片段可回查规格原文 * (反例:card.title 曾被「结构:标题区、内容区、操作区」误配、tag.color 曾切出 `绿#F6FFED`) * * 前置:site/data.json 已是当前构建产物(node tools/precompute.mjs 产出)。 */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; const ROOT = join(import.meta.dirname, '..'); const failures = []; const checks = []; 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('[api-docs] 缺少 site/data.json —— 先跑 node tools/precompute.mjs'); process.exit(2); } const data = JSON.parse(readFileSync(dataPath, 'utf8')); const i18nSrc = readFileSync(join(ROOT, 'site', 'i18n.js'), 'utf8'); const unesc = (s) => s.replace(/\\(['"])/g, '$1'); const dict = new Set(); for (const m of i18nSrc.matchAll(/^\s*(['"])((?:\\.|(?!\1)[\s\S])*?)\1\s*:/gm)) dict.add(unesc(m[2])); const props = []; const emits = []; for (const c of data.components) { for (const p of (c.api && c.api.props) || []) props.push({ slug: c.slug, c, p }); for (const e of (c.api && c.api.emits) || []) emits.push({ slug: c.slug, e }); } const specLines = (c) => { const raw = Array.isArray(c.specLines) ? c.specLines : String(c.specLines || '').split(/\r?\n/); return raw.map((l) => String(l).replace(/^\s*[-•*]\s*/, '').trim()).filter(Boolean); }; /* 1) 说明覆盖与出处 */ const srcCount = {}; for (const { p } of props) srcCount[p.descSrc || 'none'] = (srcCount[p.descSrc || 'none'] || 0) + 1; const emptyDesc = props.filter(({ p }) => !p.desc || p.desc === '—'); check('prop 说明非空', emptyDesc.length === 0, `${props.length - emptyDesc.length}/${props.length} 有说明(出处 ${JSON.stringify(srcCount)})`); check('无 none 出处', !srcCount.none, srcCount.none ? `${srcCount.none} 条没有出处` : '全部可溯源'); /* 2) spec 类必须是规格原文逐字引用 */ const specProps = props.filter(({ p }) => p.descSrc === 'spec'); const badCite = specProps.filter(({ c, p }) => !specLines(c).some((l) => l.startsWith(p.cite) || p.cite.startsWith(l))); check('spec 出处 cite 逐字命中规格原文', badCite.length === 0, `${specProps.length} 条 spec${badCite.length ? `,异常 ${badCite.length}:${badCite.slice(0, 3).map((x) => `${x.slug}.${x.p.name}`).join(', ')}` : ''}`); /* 3) 事件说明 */ const TEMPLATES = ['组件交互触发事件', '交互回调触发事件']; const genericEmits = emits.filter(({ e }) => !e.descZh || TEMPLATES.includes(e.descZh)); check('事件说明无模板句', genericEmits.length === 0, `${emits.length - genericEmits.length}/${emits.length} 具体化${genericEmits.length ? `,异常:${genericEmits.slice(0, 3).map((x) => `${x.slug}.${x.e.name}`).join(', ')}` : ''}`); const vmodel = emits.filter(({ e }) => String(e.name).startsWith('update:')); check('update:* 标注 v-model', vmodel.every(({ e }) => e.descZh.includes('v-model')), `${vmodel.length} 条`); /* 4) 英文词条覆盖(否则英文模式回退中文) */ const descVals = [...new Set(props.map((x) => x.p.desc))].filter((d) => d && d !== '—'); const missDesc = descVals.filter((d) => !dict.has(d)); check('属性说明词条齐全', missDesc.length === 0, `${descVals.length} 个唯一值${missDesc.length ? `,缺失 ${missDesc.slice(0, 6).join(' / ')}` : ''}`); const emitVals = [...new Set(emits.map((x) => x.e.descZh))].filter(Boolean); const missEmit = emitVals.filter((d) => !dict.has(d)); check('事件说明词条齐全', missEmit.length === 0, `${emitVals.length} 个唯一值${missEmit.length ? `,缺失 ${missEmit.slice(0, 6).join(' / ')}` : ''}`); /* 5) 规格取值副行:标签一致 + 可回查 */ const withOpts = props.filter((x) => x.p.opts && x.p.opts.length); const badOpts = withOpts.filter(({ c, p }) => { const label = String(p.cite || '').split(/[::]/)[0].trim(); if (label !== p.desc && label.indexOf(p.desc) !== 0) return true; return !specLines(c).some((l) => l.includes(p.opts[0])); }); check('规格取值副行可回查且标签一致', badOpts.length === 0, `${withOpts.length} 条${withOpts.length ? `(${withOpts.map((x) => `${x.slug}.${x.p.name}×${x.p.opts.length}`).join(', ')})` : ''}${badOpts.length ? `,异常 ${badOpts.slice(0, 3).map((x) => `${x.slug}.${x.p.name}`).join(', ')}` : ''}`); /* 6) 覆盖面(有 API 的组件数不应无故缩水) */ const withApi = data.components.filter((c) => { const a = c.api || {}; return (a.props || []).length || (a.emits || []).length || (a.slots || []).length; }); const allowedWithoutApi = new Set(['enhancedtabnav', 'dragupload', 'signaturepad']); const withoutApi = data.components.filter((c) => !withApi.includes(c)); check('有 API 表的组件数', withoutApi.every((c) => allowedWithoutApi.has(c.slug)), `${withApi.length}/${data.components.length};无 API:${withoutApi.map((c) => c.slug).join(', ') || '无'}`); check('无 API 组件为已知例外', withoutApi.length === [...allowedWithoutApi].filter((slug) => data.components.some((c) => c.slug === slug)).length, `${withoutApi.length} 个`); for (const r of checks) console.log(`[api-docs] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); if (failures.length) { console.error(`\n[api-docs] ${failures.length} check(s) failed`); process.exit(1); } console.log(`\n[api-docs] OK — ${checks.length} checks passed`);