#!/usr/bin/env node /** * verify-examples.mjs — 逐示例用法片段的静态验收(零依赖) * * 被验收的东西(P21「一个使用场景 = 一块预览 + 一块调用代码」): * 1. 每个组件都有 site/examples/.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/.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 = /]*?)\/?>/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(/]*>([\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} 个片段)`);