1372 lines
66 KiB
JavaScript
1372 lines
66 KiB
JavaScript
#!/usr/bin/env node
|
||
/*** @ui-arch CLI:组件库注册表命令行 + 内置 MCP 服务(零依赖,Node >= 18) ***/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { spawnSync } from 'node:child_process';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
/*** 自底向上查找仓库根(以 pnpm-workspace.yaml 为界) ***/
|
||
function findRepoRoot(start) {
|
||
let dir = start;
|
||
for (let i = 0; i < 10; i++) {
|
||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir;
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) return process.cwd();
|
||
dir = parent;
|
||
}
|
||
return process.cwd();
|
||
}
|
||
|
||
const ROOT = findRepoRoot(__dirname);
|
||
const PKG_DIR = path.join(ROOT, 'packages');
|
||
const INDEX_DIR = path.join(ROOT, '.ua');
|
||
const INDEX_FILE = path.join(INDEX_DIR, 'index.json');
|
||
const LLMS_FILE = path.join(ROOT, 'llms.txt');
|
||
const LLMS_FULL = path.join(ROOT, 'llms-full.txt');
|
||
const RECIPES_FILE = path.join(ROOT, 'packages', 'tools', 'cli', 'recipes.md'); // 组合配方单一来源(人类版与 MCP 共用)
|
||
const MCP_TOOL_NAMES = ['list_components', 'get_component', 'search_components', 'get_tokens', 'list_recipes', 'get_recipe']; // MCP 工具单一来源:注册表与三处文档都对它校验
|
||
|
||
/*** 汇总各包 ua.manifest.json 与 package.json 版本(扫描 packages/ 两层目录) ***/
|
||
function collectManifests() {
|
||
const out = [];
|
||
if (!fs.existsSync(PKG_DIR)) return out;
|
||
const readManifest = (dir) => {
|
||
const manifestPath = path.join(dir, 'ua.manifest.json');
|
||
if (!fs.existsSync(manifestPath)) return; // 卫语句:无清单不入索引
|
||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
const pkgPath = path.join(dir, 'package.json');
|
||
let version = '0.0.0';
|
||
if (fs.existsSync(pkgPath)) {
|
||
version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? version;
|
||
}
|
||
out.push({
|
||
...manifest,
|
||
version,
|
||
dir: path.relative(ROOT, dir).split(path.sep).join('/'),
|
||
});
|
||
};
|
||
for (const layer of fs.readdirSync(PKG_DIR)) {
|
||
const layerDir = path.join(PKG_DIR, layer);
|
||
if (!fs.statSync(layerDir).isDirectory()) continue;
|
||
readManifest(layerDir); // 兼容单层直放
|
||
for (const name of fs.readdirSync(layerDir)) {
|
||
const dir = path.join(layerDir, name);
|
||
if (fs.statSync(dir).isDirectory()) readManifest(dir);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/*** 内容无变化则跳过写入(幂等,行尾归一化防 CRLF 误判;norm 可剔除时间戳后再比对),避免弄脏工作区 ***/
|
||
function writeIfChanged(file, content, norm = (s) => s) {
|
||
if (fs.existsSync(file) && norm(fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')) === norm(content)) return false;
|
||
fs.writeFileSync(file, content);
|
||
return true;
|
||
}
|
||
|
||
/*** 聚合清单,生成 .ua/index.json 与 llms.txt ***/
|
||
function cmdIndex() {
|
||
const packages = collectManifests();
|
||
fs.mkdirSync(INDEX_DIR, { recursive: true });
|
||
const index = {
|
||
$schema: 'ui-arch-registry-index/0.1',
|
||
generatedAt: new Date().toISOString(),
|
||
repoRoot: '.',
|
||
packages,
|
||
};
|
||
// 幂等:剔除 generatedAt 后内容一致即不写入,连续 check 不弄脏工作区
|
||
const json = JSON.stringify(index, null, 2) + '\n';
|
||
const stripTs = (s) => s.replace(/"generatedAt": "[^"]*"/, '"generatedAt": ""');
|
||
const changed = !fs.existsSync(INDEX_FILE) || stripTs(fs.readFileSync(INDEX_FILE, 'utf8').replace(/\r\n/g, '\n')) !== stripTs(json);
|
||
if (changed) fs.writeFileSync(INDEX_FILE, json);
|
||
|
||
const lines = [
|
||
'# @ui-arch 私人组件库索引',
|
||
'',
|
||
'> 本文件由 `ua index` 自动生成,请勿手改。机器可读版:`.ua/index.json`。',
|
||
'> Agent 可通过 `ua mcp`(stdio MCP server)直接查询注册表。',
|
||
'',
|
||
];
|
||
for (const p of packages) {
|
||
lines.push(`## ${p.name} (v${p.version})`, '');
|
||
lines.push(`- 类型: ${p.type} · 目录: ${p.dir}`);
|
||
lines.push(`- 描述: ${p.description}`);
|
||
if (p.api?.exports?.length) {
|
||
lines.push('- API:');
|
||
for (const e of p.api.exports) lines.push(` - ${e}`);
|
||
}
|
||
if (p.usage) lines.push(`- 用法: \`${p.usage}\``);
|
||
for (const c of p.conventions ?? []) lines.push(`- 约定: ${c}`);
|
||
lines.push('');
|
||
}
|
||
const recipes = parseRecipes(); // 速览也要给出配方目录:Agent 读 llms.txt 时才知道有跨组件拼装可查
|
||
if (recipes.length) {
|
||
lines.push('## 组合配方(跨组件拼装)', '');
|
||
lines.push('> 全文见 `packages/tools/cli/recipes.md` 或 llms-full.txt;MCP 用 `list_recipes` / `get_recipe` 取骨架与要点。', '');
|
||
for (const r of recipes) lines.push(`- 配方 ${r.id}:${r.title}(${r.notes.length} 条要点)`);
|
||
lines.push('');
|
||
}
|
||
writeIfChanged(LLMS_FILE, lines.join('\n'));
|
||
console.log(`indexed ${packages.length} package(s) -> .ua/index.json, llms.txt${changed ? '' : ' (unchanged, skip)'}`);
|
||
}
|
||
|
||
/*** 读取注册表索引;缺失则报错退出 ***/
|
||
function loadIndex() {
|
||
if (!fs.existsSync(INDEX_FILE)) {
|
||
console.error('index not found, run: ua index');
|
||
process.exit(1);
|
||
}
|
||
return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
|
||
}
|
||
|
||
/*** 列出已注册内容:默认列包,--components 改为按包分组列全部组件(id + 标题 + 现场演示锚点) ***/
|
||
function cmdList(args = []) {
|
||
const { packages } = loadIndex();
|
||
if (!packages.length) return console.log('(no packages indexed)');
|
||
if (args.includes('--components')) {
|
||
let total = 0;
|
||
for (const p of packages) {
|
||
const comps = p.docs?.components ?? [];
|
||
if (!comps.length) continue;
|
||
total += comps.length;
|
||
console.log(`\n${p.name} (${comps.length})`);
|
||
for (const c of comps) console.log(` ${String(c.id).padEnd(16)} ${c.title ?? ''}${c.demo ? ` [${c.demo}]` : ''}`);
|
||
}
|
||
return console.log(`\n共 ${total} 个组件;查参数:ua info <组件id>,模糊找:ua search <关键词>`);
|
||
}
|
||
for (const p of packages) {
|
||
const n = p.docs?.components?.length ?? 0;
|
||
console.log(`${p.name} v${p.version} [${p.type}] ${n} 个组件 ${p.description}`);
|
||
}
|
||
}
|
||
|
||
/*** 摊平组件清单(带所属包),供 info / MCP 按组件 id 查询 ***/
|
||
function collectComponents(packages) {
|
||
const list = packages ?? loadIndex().packages;
|
||
return list.flatMap((pkg) => (pkg.docs?.components ?? []).map((comp) => ({ pkg, comp })));
|
||
}
|
||
|
||
/*** 渲染组件的人类可读说明:props 表 + 步骤 + 示例,便于直接抄进代码 ***/
|
||
function formatComponent(pkg, comp) {
|
||
const lines = [`${comp.id} — ${comp.title ?? ''} (${pkg.name}${comp.demo ? `, demo: ${comp.demo}` : ''})`, ''];
|
||
if ((comp.props ?? []).length) {
|
||
lines.push('props:');
|
||
const width = Math.max(...comp.props.map((x) => (x.n ?? '').length));
|
||
for (const x of comp.props) lines.push(` ${(x.n ?? '').padEnd(width)} ${(x.t ?? '').padEnd(30)} ${x.r ? '必填 ' : ' '}${x.d ?? ''}`);
|
||
lines.push('');
|
||
}
|
||
if ((comp.steps ?? []).length) {
|
||
lines.push('steps:');
|
||
comp.steps.forEach((t, i) => lines.push(` ${i + 1}. ${t}`));
|
||
lines.push('');
|
||
}
|
||
if (comp.example) lines.push('example:', ` ${comp.example}`);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/*** 反查某组件出现在哪些配方里:查参数时顺带给出「它通常和谁一起用」的组合线索 ***/
|
||
function recipesFor(compId) {
|
||
const re = new RegExp(`\\b${compId}\\b`);
|
||
return parseRecipes()
|
||
.filter((r) => re.test(`${r.code}\n${r.text}`))
|
||
.map((r) => ({ id: r.id, title: r.title }));
|
||
}
|
||
|
||
/*** 编辑距离(截断版):只用来给拼错的名字找相近候选,超过 3 直接当无建议 ***/
|
||
function editDistance(a, b) {
|
||
const prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
||
for (let i = 1; i <= a.length; i++) {
|
||
let diag = prev[0];
|
||
prev[0] = i;
|
||
for (let j = 1; j <= b.length; j++) {
|
||
const tmp = prev[j];
|
||
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||
diag = tmp;
|
||
}
|
||
}
|
||
return prev[b.length];
|
||
}
|
||
|
||
/*** 未命中时给相近组件 id(编辑距离 ≤ 3),避免只回一句 not found ***/
|
||
function nearIds(name) {
|
||
return collectComponents()
|
||
.map((x) => [x.comp.id, editDistance(String(name).toLowerCase(), x.comp.id.toLowerCase())])
|
||
.filter(([, d]) => d <= 3)
|
||
.sort((x, y) => x[1] - y[1])
|
||
.slice(0, 3)
|
||
.map(([id]) => id);
|
||
}
|
||
|
||
/*** 按关键词搜组件:多词取最弱命中(AND 语义),id 命中优先于 title,其次 props 名与步骤文案;未命中退出码 1 ***/
|
||
function cmdSearch(words = [], args = []) {
|
||
const kws = [...new Set(words.map((w) => String(w).toLowerCase().trim()).filter(Boolean))];
|
||
if (!kws.length) return console.error('usage: ua search <关键词...> [--json]');
|
||
const scoreOf = (kw, comp) => {
|
||
const id = String(comp.id ?? '').toLowerCase();
|
||
const title = String(comp.title ?? '').toLowerCase();
|
||
const props = (comp.props ?? []).map((p) => String(p.n ?? '').toLowerCase()).join(' ');
|
||
const steps = (comp.steps ?? []).join(' ').toLowerCase();
|
||
if (id === kw) return 4; // 分档只用于排序:id 全等 > id 含 > 标题 > props 名 > 步骤
|
||
if (id.includes(kw)) return 3;
|
||
if (title.includes(kw)) return 2;
|
||
if (props.includes(kw)) return 1;
|
||
return steps.includes(kw) ? 0.5 : 0;
|
||
};
|
||
const hits = collectComponents()
|
||
.map(({ pkg, comp }) => {
|
||
const scores = kws.map((kw) => scoreOf(kw, comp));
|
||
return scores.every((s) => s > 0) ? { pkg, comp, score: Math.min(...scores) } : null; // 任一词不中即淘汰
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => b.score - a.score || String(a.comp.id).localeCompare(String(b.comp.id)));
|
||
if (!hits.length) {
|
||
const near = nearIds(kws.join(''));
|
||
console.error(`no match: ${words.join(' ')}${near.length ? `(相近:${near.join(' / ')})` : ''}`);
|
||
return process.exit(1);
|
||
}
|
||
if (args.includes('--json')) {
|
||
return console.log(JSON.stringify(hits.map((h) => ({ package: h.pkg.name, ...h.comp })), null, 2));
|
||
}
|
||
for (const h of hits) console.log(`${String(h.comp.id).padEnd(16)} ${h.comp.title ?? ''} (${h.pkg.name})`);
|
||
console.log(`\n共 ${hits.length} 个组件命中;查参数:ua info <组件id>`);
|
||
}
|
||
|
||
/*** 输出单包或单组件信息:默认人读摘要,--json 输出原始 JSON ***/
|
||
function cmdInfo(name, args = []) {
|
||
const { packages } = loadIndex();
|
||
const asJson = args.includes('--json');
|
||
const pkg = packages.find((x) => x.name === name || x.name === `@ui-arch/${name}`);
|
||
if (pkg) {
|
||
if (asJson) return console.log(JSON.stringify(pkg, null, 2));
|
||
return console.log(
|
||
`${pkg.name} v${pkg.version} [${pkg.type}] ${pkg.description}\n(--json 看完整清单;按组件查:ua info <组件id>)`,
|
||
);
|
||
}
|
||
const hit = collectComponents(packages).find((x) => x.comp.id === name);
|
||
if (!hit) {
|
||
const near = nearIds(name);
|
||
console.error(`not found: ${name}(包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`);
|
||
return process.exit(1);
|
||
}
|
||
if (asJson) return console.log(JSON.stringify({ package: hit.pkg.name, ...hit.comp, recipes: recipesFor(hit.comp.id) }, null, 2));
|
||
const related = recipesFor(hit.comp.id);
|
||
const tail = related.length ? `\n\n出现在配方:${related.map((r) => `${r.id}(${r.title})`).join('、')}(ua mcp 的 get_recipe 可取骨架)` : '';
|
||
console.log(formatComponent(hit.pkg, hit.comp) + tail);
|
||
}
|
||
|
||
// ---- 静态文档站生成 ----
|
||
|
||
/*** HTML 转义:& < > ***/
|
||
function esc(s) {
|
||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/*** 渲染人类文档站:注册表 + 逐组件 props/步骤 + 现场演示 iframe + 令牌,数据内嵌支持搜索 ***/
|
||
function renderDocs(index, tokens) {
|
||
const data = JSON.stringify(index.packages).replace(/</g, '\\u003c');
|
||
const tokData = JSON.stringify(tokens).replace(/</g, '\\u003c');
|
||
const generatedAt = esc(index.generatedAt);
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>@ui-arch 组件库文档</title>
|
||
<style>
|
||
:root{--bg:#ffffff;--surface:#f6f7f9;--text:#1c1e21;--muted:#6b7280;--border:rgba(0,0,0,.14);--accent:#185fa5;--code-bg:#101216;--code-text:#e7e9ec}
|
||
@media (prefers-color-scheme:dark){:root{--bg:#101216;--surface:#1a1c22;--text:#e7e9ec;--muted:#888780;--border:rgba(255,255,255,.15);--accent:#378add}}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.65 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
||
header{position:sticky;top:0;z-index:2;display:flex;gap:16px;align-items:center;flex-wrap:wrap;padding:14px 24px;background:var(--bg);border-bottom:1px solid var(--border)}
|
||
header h1{margin:0;font-size:16px;font-weight:500}
|
||
header h1 span{color:var(--muted);font-weight:400;font-size:14px;margin-left:8px}
|
||
#q{flex:1;min-width:220px;max-width:420px;margin-left:auto;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;outline:none}
|
||
#q:focus{border-color:var(--accent)}
|
||
nav.pkgnav{max-width:960px;margin:18px auto 0;padding:0 20px;display:flex;gap:8px;flex-wrap:wrap}
|
||
nav.pkgnav a{font-size:12px;color:var(--accent);text-decoration:none;border:1px solid var(--border);border-radius:999px;padding:3px 12px}
|
||
main{max-width:960px;margin:0 auto;padding:20px 20px 60px}
|
||
.card{border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:22px 24px;margin-bottom:22px}
|
||
.card h2{margin:0;font-size:16px;font-weight:500;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.ver{font-size:12px;color:var(--muted)}
|
||
.badge{font-size:11px;padding:2px 8px;border-radius:999px;background:var(--accent);color:#fff}
|
||
.desc{color:var(--muted);margin:10px 0 0}
|
||
.tag{display:inline-block;font-size:11px;padding:2px 9px;margin:10px 6px 0 0;border:1px solid var(--border);border-radius:999px;color:var(--muted)}
|
||
.card h3{font-size:13px;font-weight:500;margin:18px 0 8px}
|
||
.card ul,.card ol{margin:0;padding-left:18px}
|
||
.card li{margin:4px 0}
|
||
.comp{border:1px dashed var(--border);border-radius:10px;padding:14px 16px;margin:14px 0 0;background:var(--bg)}
|
||
.comp h4{margin:0 0 8px;font-size:14px;font-weight:500;font-family:Consolas,Menlo,monospace}
|
||
.comp h5{margin:12px 0 6px;font-size:12px;color:var(--muted);font-weight:500}
|
||
table.props{width:100%;border-collapse:collapse;font-size:12.5px;margin:8px 0}
|
||
table.props th,table.props td{border:1px solid var(--border);padding:5px 9px;text-align:left;vertical-align:top}
|
||
table.props th{background:var(--surface);font-weight:500;color:var(--muted)}
|
||
pre{background:var(--code-bg);color:var(--code-text);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12.5px/1.6 Consolas,Menlo,monospace;margin:8px 0}
|
||
code{font-family:Consolas,Menlo,monospace;font-size:12.5px}
|
||
li code,td code{background:var(--code-bg);color:var(--code-text);padding:1px 6px;border-radius:4px}
|
||
iframe.demo{width:100%;height:430px;border:1px dashed var(--border);border-radius:10px;margin-top:10px;background:var(--surface)}
|
||
.sw{display:flex;align-items:center;gap:10px;margin:6px 0}
|
||
.sw i{width:26px;height:26px;border-radius:6px;border:1px solid var(--border);flex:none}
|
||
.sw code{color:var(--muted)}
|
||
.sw span{font-size:12px;color:var(--muted)}
|
||
.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:4px 20px}
|
||
.empty{color:var(--muted);text-align:center;padding:40px 0}
|
||
.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
|
||
footer{max-width:960px;margin:0 auto;padding:0 20px 40px;color:var(--muted);font-size:12px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><h1>@ui-arch<span>私人组件库</span></h1><input id="q" type="search" aria-label="搜索组件、参数或 API" placeholder="搜索组件、参数、API…" autocomplete="off"></header>
|
||
<nav class="pkgnav" id="nav" aria-label="仓库导航"></nav>
|
||
<p class="sr-only" id="hit" role="status" aria-live="polite"></p>
|
||
<main id="app"></main>
|
||
<footer>由 <code>ua docs</code> 生成于 ${generatedAt} · AI 版全文:<code>llms-full.txt</code> · 重新生成:npm run docs</footer>
|
||
<script>
|
||
var DATA=${data};
|
||
var TOKENS=${tokData};
|
||
var ORDER={skin-dom:0,'headless-core':1,'design-tokens':2};
|
||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||
function propsTable(c){
|
||
var rows=(c.props||[]).map(function(p){
|
||
return '<tr><td><code>'+esc(p.n)+'</code></td><td><code>'+esc(p.t)+'</code></td><td>'+(p.r?'必填':'否')+'</td><td>'+(p.df?'<code>'+esc(p.df)+'</code>':'—')+'</td><td>'+esc(p.d||'')+(p.k?'(回调)':'')+'</td></tr>';
|
||
}).join('');
|
||
return '<table class="props"><tr><th>参数</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr>'+rows+'</table>';
|
||
}
|
||
function compBlock(c){
|
||
var steps=(c.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||
return '<div class="comp" id="c-'+esc(c.id)+'"><h4>'+esc(c.id)+' · '+esc(c.title||'')+'</h4>'
|
||
+(c.props?propsTable(c):'')
|
||
+(c.example?'<pre><code>'+esc(c.example)+'</code></pre>':'')
|
||
+(steps?'<h5>手把手</h5><ol>'+steps+'</ol>':'')
|
||
+(c.demo?'<iframe class="demo" src="demo.html#'+esc(c.demo)+'" loading="lazy" title="'+esc(c.id)+' 现场演示"></iframe>':'')
|
||
+'</div>';
|
||
}
|
||
function pkgCard(p){
|
||
var steps=(p.docs&&p.docs.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||
var comps=(p.docs&&p.docs.components||[]).map(compBlock).join('');
|
||
var ex=(p.api&&p.api.exports||[]).map(function(e){return '<li><code>'+esc(e)+'</code></li>'}).join('');
|
||
var conv=(p.conventions||[]).map(function(c){return '<li>'+esc(c)+'</li>'}).join('');
|
||
var tags=(p.tags||[]).map(function(t){return '<span class="tag">'+esc(t)+'</span>'}).join('');
|
||
return '<section class="card" id="p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'
|
||
+'<h2>'+esc(p.name)+' <span class="ver">v'+esc(p.version)+'</span> <span class="badge">'+esc(p.type)+'</span></h2>'
|
||
+'<p class="desc">'+esc((p.docs&&p.docs.intro)||p.description)+'</p><div>'+tags+'</div>'
|
||
+(steps?'<h3>手把手接入</h3><ol>'+steps+'</ol>':'')
|
||
+(comps?'<h3>组件明细</h3>'+comps:'')
|
||
+(ex?'<h3>API 速览</h3><ul>'+ex+'</ul>':'')
|
||
+(p.usage?'<h3>一行用法</h3><pre><code>'+esc(p.usage)+'</code></pre>':'')
|
||
+(conv?'<h3>约定</h3><ul>'+conv+'</ul>':'')
|
||
+'</section>';
|
||
}
|
||
function quickstart(){
|
||
var ui=DATA.filter(function(p){return p.docs&&p.docs.steps}).sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
|
||
if(!ui.length)return '';
|
||
var blocks=ui.map(function(p){
|
||
var steps=p.docs.steps.map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
|
||
return '<p class="desc" style="margin:10px 0 4px"><b>'+esc(p.name)+'</b></p><ol>'+steps+'</ol>';
|
||
}).join('');
|
||
return '<section class="card"><h2>快速上手 <span class="badge">guide</span></h2>'+blocks+'</section>';
|
||
}
|
||
function tokensCard(){
|
||
if(!TOKENS||!TOKENS.color)return '';
|
||
var sw=Object.keys(TOKENS.color).map(function(k){
|
||
var v=TOKENS.color[k];
|
||
var light=typeof v==='string'?v:v.light;
|
||
var dark=typeof v==='string'?v:v.dark;
|
||
var extra=dark&&dark!==light?' / '+dark:'';
|
||
return '<div class="sw"><i style="background:'+light+'"></i><code>--ua-'+esc(k)+'</code><span>'+light+extra+'</span></div>';
|
||
}).join('');
|
||
return '<section class="card"><h2>@ui-arch/tokens <span class="badge">design-tokens</span></h2><p class="desc">皮肤包只允许消费 --ua-* CSS 变量;暗色主题覆盖值不换名(light / dark)。</p><h3>颜色令牌</h3><div class="swatches">'+sw+'</div></section>';
|
||
}
|
||
function render(list){
|
||
var app=document.getElementById('app');
|
||
if(!list.length){app.innerHTML='<p class="empty">无匹配结果</p>';var none=document.getElementById('hit');if(none)none.textContent='无匹配结果';return}
|
||
app.innerHTML=quickstart()+list.map(pkgCard).join('')+(list.some(function(p){return p.name.indexOf('tokens')>=0})?'':tokensCard());
|
||
var hit=document.getElementById('hit'); // 搜索结果对读屏播报
|
||
if(hit)hit.textContent='匹配 '+list.length+' 个包';
|
||
}
|
||
function norm(p){
|
||
var parts=[p.name,p.description,p.type,(p.tags||[]).join(' '),(p.api&&p.api.exports||[]).join(' ')];
|
||
(p.docs&&p.docs.components||[]).forEach(function(c){parts.push(c.id,c.title||'',(c.props||[]).map(function(x){return x.n}).join(' '))});
|
||
return parts.join(' ').toLowerCase();
|
||
}
|
||
var ordered=DATA.slice().sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
|
||
document.getElementById('nav').innerHTML=ordered.map(function(p){return '<a href="#p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'+esc(p.name)+'</a>'}).join('')+'<a href="#top-tokens">令牌</a>';
|
||
render(ordered);
|
||
document.getElementById('q').addEventListener('input',function(e){
|
||
var q=e.target.value.trim().toLowerCase();
|
||
render(q?ordered.filter(function(p){return norm(p).indexOf(q)>=0}):ordered);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
}
|
||
|
||
/*** 读取组合配方(与 CLI 同目录的 recipes.md),缺失时返回空串 ***/
|
||
function loadRecipes() {
|
||
return fs.existsSync(RECIPES_FILE) ? fs.readFileSync(RECIPES_FILE, 'utf8').trim() : '';
|
||
}
|
||
|
||
/*** 把 recipes.md 拆成结构化配方:每个「## 配方 N:标题」一段,切出首个代码块与要点行 ***/
|
||
function parseRecipes() {
|
||
const text = loadRecipes().replace(/\r\n?/g, '\n'); // 归一化行尾:CRLF 的 \r 会让 `.+\$` 与标题目录匹配整体失效
|
||
if (!text) return [];
|
||
const out = [];
|
||
const chunks = text.split(/^## /m).slice(1); // 首段是文件标题与引言,无编号直接跳过
|
||
for (const chunk of chunks) {
|
||
const [headLine, ...rest] = chunk.split('\n');
|
||
const head = headLine.match(/^配方\s*(\d+)\s*[::]\s*(.+)$/); // 只认编号配方,「通用约定」等说明段不算
|
||
if (!head) continue;
|
||
const body = rest.join('\n').trim();
|
||
const code = body.match(/```[a-z]*\n([\s\S]*?)```/); // 首个代码块即最小可运行骨架
|
||
const notes = body
|
||
.split('\n')
|
||
.filter((line) => /^[-*]\s+/.test(line))
|
||
.map((line) => line.replace(/^[-*]\s+/, '').trim());
|
||
out.push({ id: head[1], title: head[2].trim(), code: code ? code[1].trim() : '', notes, text: body });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/*** 渲染 Agent 文档(llms-full.txt):无图形纯文本,逐组件参数 / 回调 / 步骤 / 约定全量收录 ***/
|
||
function renderAgentDocs(index, tokens) {
|
||
const lines = [
|
||
'# @ui-arch 组件库 Agent 文档(无图形版)',
|
||
'',
|
||
`> 自动生成于 ${index.generatedAt},数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。`,
|
||
'> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。',
|
||
'',
|
||
'## 快速上手',
|
||
'',
|
||
];
|
||
for (const p of index.packages) {
|
||
if (!p.docs?.steps?.length) continue;
|
||
lines.push(`### ${p.name}`, '');
|
||
p.docs.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
|
||
lines.push('');
|
||
}
|
||
const order = { 'skin-dom': 0, 'headless-core': 1, 'design-tokens': 2 };
|
||
for (const p of [...index.packages].sort((a, b) => (order[a.type] ?? 9) - (order[b.type] ?? 9))) {
|
||
lines.push(`## ${p.name}(${p.type})v${p.version}`, '');
|
||
lines.push((p.docs?.intro ?? p.description) + '', '');
|
||
for (const c of p.docs?.components ?? []) {
|
||
lines.push(`### ${c.id} — ${c.title ?? ''}`, '');
|
||
if (c.props?.length) {
|
||
lines.push('参数:', '');
|
||
for (const prop of c.props) {
|
||
const marks = [prop.r ? '必填' : '', prop.k ? '回调' : '', prop.df ? `默认 ${prop.df}` : ''].filter(Boolean).join(',');
|
||
lines.push(`- ${prop.n}${marks ? `(${marks})` : ''}:${prop.t}${prop.d ? ` —— ${prop.d}` : ''}`);
|
||
}
|
||
lines.push('');
|
||
}
|
||
if (c.example) lines.push('示例:', '', '```', c.example, '```', '');
|
||
if (c.steps?.length) {
|
||
lines.push('手把手:', '');
|
||
c.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
|
||
lines.push('');
|
||
}
|
||
if (c.demo) lines.push(`现场演示:apps/docs/demo.html#${c.demo}`, '');
|
||
}
|
||
if (p.api?.exports?.length) {
|
||
lines.push('API 速览:', '');
|
||
for (const e of p.api.exports) lines.push(`- ${e}`);
|
||
lines.push('');
|
||
}
|
||
if (p.usage) lines.push('一行用法:', '', '```', p.usage, '```', '');
|
||
if (p.conventions?.length) {
|
||
lines.push('约定:', '');
|
||
for (const cv of p.conventions) lines.push(`- ${cv}`);
|
||
lines.push('');
|
||
}
|
||
lines.push('---', '');
|
||
}
|
||
const tokenList = loadTokenList(); // 令牌唯一来源:tokens.css(含字号、间距复合值)
|
||
if (tokenList.length) {
|
||
lines.push('## 设计令牌(--ua-*)', '');
|
||
for (const t of tokenList) lines.push(`- ${t.cssVar}: ${t.value}${t.dark && t.dark !== t.value ? `(暗色 ${t.dark})` : ''}`);
|
||
lines.push('', '皮肤包只允许消费这些变量:写死 #hex 会被 verify 拦截,拼错变量名会被令牌引用校验拦截。', '');
|
||
}
|
||
|
||
const recipes = loadRecipes(); // 组合配方:跨组件的拼装指导,清单参数表表达不了
|
||
if (recipes) lines.push('', recipes, '');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/*** 生成双轨文档:人类版 apps/docs/index.html(含演示 iframe)+ Agent 版 llms-full.txt ***/
|
||
function cmdDocs() {
|
||
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
|
||
const index = loadIndex();
|
||
const tokensPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json');
|
||
const tokens = fs.existsSync(tokensPath)
|
||
? JSON.parse(fs.readFileSync(tokensPath, 'utf8'))
|
||
: null;
|
||
const outDir = path.join(ROOT, 'apps', 'docs');
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
fs.writeFileSync(path.join(outDir, 'index.html'), renderDocs(index, tokens));
|
||
const demoSrc = path.join(ROOT, 'apps', 'demo', 'dist', 'demo.html'); // 演示页拷入文档站供 iframe
|
||
if (fs.existsSync(demoSrc)) fs.copyFileSync(demoSrc, path.join(outDir, 'demo.html'));
|
||
// llms-full 头部时间戳仅生成时刻之差视为无变化(与 index 幂等化同方案)
|
||
const normTs = (s) => s.replace(/自动生成于 [^,]*/, '自动生成于 X');
|
||
writeIfChanged(LLMS_FULL, renderAgentDocs(index, tokens), normTs);
|
||
console.log(
|
||
`docs generated -> apps/docs/index.html + demo.html, llms-full.txt (${index.packages.length} packages)`,
|
||
);
|
||
}
|
||
|
||
// ---- 令牌查询:CLI 与 MCP 共用同一份拍平结果 ----
|
||
|
||
/*** 读取 tokens.css 拍平为令牌清单(:root 为基准值,[data-theme='dark'] 覆盖为暗色值;名字即 ui.css 实际变量名) ***/
|
||
function loadTokenList() {
|
||
const p = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css');
|
||
if (!fs.existsSync(p)) return [];
|
||
const src = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
|
||
const grab = (block) => {
|
||
const out = new Map(); // cssVar -> 值
|
||
for (const m of block.matchAll(/(--ua-[\w-]+)\s*:\s*([^;]+);/g)) out.set(m[1], m[2].trim());
|
||
return out;
|
||
};
|
||
const rootBlock = (src.match(/:root\s*\{([\s\S]*?)\}/) ?? [, ''])[1]; // 亮色基准
|
||
const darkBlock = (src.match(/\[data-theme='dark'\]\s*\{([\s\S]*?)\}/) ?? [, ''])[1]; // 暗色覆盖
|
||
const dark = grab(darkBlock);
|
||
return [...grab(rootBlock).entries()].map(([cssVar, value]) =>
|
||
dark.has(cssVar) ? { cssVar, value, dark: dark.get(cssVar) } : { cssVar, value },
|
||
);
|
||
}
|
||
|
||
/*** ua tokens:打印令牌清单;--json 输出结构化数组供脚本消费 ***/
|
||
function cmdTokens(args) {
|
||
const list = loadTokenList();
|
||
if (!list.length) {
|
||
console.error('未找到 packages/design/tokens/tokens.json');
|
||
process.exit(1);
|
||
}
|
||
const keyword = args.find((a) => !a.startsWith('--'))?.toLowerCase(); // 带名字参数时按子串过滤,写样式时不必翻全表
|
||
const picked = keyword ? list.filter((t) => t.cssVar.toLowerCase().includes(keyword)) : list;
|
||
if (keyword && !picked.length) {
|
||
console.error(`no token match: ${keyword}(共 ${list.length} 个令牌,ua tokens 看全表)`);
|
||
process.exit(1);
|
||
}
|
||
if (args.includes('--json')) {
|
||
console.log(JSON.stringify(picked, null, 2));
|
||
return;
|
||
}
|
||
for (const t of picked) console.log(`${t.cssVar.padEnd(26)} ${t.value}${t.dark && t.dark !== t.value ? ' / 暗色 ' + t.dark : t.cssVar.startsWith('--ua-color-') && !t.dark ? ' / 缺暗色' : ''}`);
|
||
console.log(`共 ${picked.length} 个令牌${keyword ? `(匹配 ${keyword})` : ''}`);
|
||
}
|
||
|
||
// ---- 内置 stdio MCP server(JSON-RPC 2.0,按行分帧) ----
|
||
|
||
/*** 启动 MCP server:向 Agent 暴露 list / get / search / tokens / recipes 六类查询工具 ***/
|
||
function cmdMcp() {
|
||
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
|
||
const { packages } = loadIndex();
|
||
|
||
const tools = [
|
||
{
|
||
name: 'list_components',
|
||
description: 'List all packages/components in the @ui-arch private library',
|
||
inputSchema: { type: 'object', properties: {} },
|
||
},
|
||
{
|
||
name: 'get_component',
|
||
description: 'Get one package by name, or one component by its id (returns props/steps/example)',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { name: { type: 'string', description: 'package name, e.g. @ui-arch/table-core or table-core' } },
|
||
required: ['name'],
|
||
},
|
||
},
|
||
{
|
||
name: 'search_components',
|
||
description: 'Search by keyword: returns matched packages plus matched components (id/title/props)',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { query: { type: 'string' } },
|
||
required: ['query'],
|
||
},
|
||
},
|
||
{
|
||
name: 'get_tokens',
|
||
description: 'Get the --ua-* design token list (cssVar / value / dark), so styles never hardcode colors or spacing',
|
||
inputSchema: { type: 'object', properties: {} },
|
||
},
|
||
{
|
||
name: 'list_recipes',
|
||
description: 'List multi-component recipes (how to compose components together) with id/title/components',
|
||
inputSchema: { type: 'object', properties: {} },
|
||
},
|
||
{
|
||
name: 'get_recipe',
|
||
description: 'Get one recipe by id: full markdown body, the minimal code skeleton and the components it uses',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { id: { type: 'string', description: 'recipe id, e.g. 3(对应「配方 3」)' } },
|
||
required: ['id'],
|
||
},
|
||
},
|
||
];
|
||
|
||
const recipes = parseRecipes(); // 配方清单:MCP 侧只读一次
|
||
const compIds = collectComponents(packages).map((x) => x.comp.id); // 组件全集:把配方里的组件名回指到注册表
|
||
if (tools.map((t) => t.name).join() !== MCP_TOOL_NAMES.join()) {
|
||
throw new Error(`MCP 工具注册表与 MCP_TOOL_NAMES 不一致:${tools.map((t) => t.name).join(',')}`); // 常量是文档校验的基准,先自证
|
||
}
|
||
|
||
/*** 从配方正文里认出用到的组件 id(按词边界匹配,避免 toast 命中 toastError) ***/
|
||
const recipeComponents = (r) =>
|
||
compIds.filter((id) => new RegExp(`\\b${id}\\b`).test(`${r.code}\n${r.text}`)).sort();
|
||
|
||
const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
||
|
||
/*** 执行 MCP 工具调用 ***/
|
||
function callTool(name, args) {
|
||
if (name === 'list_components') {
|
||
return packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
|
||
}
|
||
if (name === 'get_component') {
|
||
const p = packages.find((x) => x.name === args.name || x.name === `@ui-arch/${args.name}`);
|
||
if (p) return p;
|
||
const hit = collectComponents(packages).find((x) => x.comp.id === args.name); // 退化到组件级查询
|
||
if (!hit) throw new Error(`not found: ${args.name}`);
|
||
return { package: hit.pkg.name, ...hit.comp, recipes: recipesFor(hit.comp.id) };
|
||
}
|
||
if (name === 'search_components') {
|
||
const q = String(args.query ?? '').toLowerCase().trim();
|
||
if (!q) throw new Error('query 不能为空');
|
||
const pkgs = packages
|
||
.filter((p) =>
|
||
[p.name, p.description, p.type, ...(p.tags ?? []), ...(p.api?.exports ?? [])]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q),
|
||
)
|
||
.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
|
||
const comps = collectComponents(packages) // 组件级命中:包级 exports 只给出包名,Agent 还得再查一次
|
||
.filter(({ comp }) =>
|
||
[comp.id, comp.title, ...(comp.props ?? []).map((p) => p.n)].join(' ').toLowerCase().includes(q),
|
||
)
|
||
.map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title }));
|
||
if (!pkgs.length && !comps.length) throw new Error(`no match: ${args.query}`);
|
||
return { packages: pkgs, components: comps };
|
||
}
|
||
if (name === 'get_tokens') {
|
||
const list = loadTokenList();
|
||
if (!list.length) throw new Error('tokens.json not found');
|
||
return list;
|
||
}
|
||
if (name === 'list_recipes') {
|
||
return recipes.map((r) => ({ id: r.id, title: r.title, components: recipeComponents(r) }));
|
||
}
|
||
if (name === 'get_recipe') {
|
||
const hit = recipes.find((r) => r.id === String(args.id ?? '').trim());
|
||
if (!hit) throw new Error(`recipe not found: ${args.id}(可用 ${recipes.map((r) => r.id).join('/')})`);
|
||
return { id: hit.id, title: hit.title, components: recipeComponents(hit), code: hit.code, notes: hit.notes, markdown: hit.text };
|
||
}
|
||
throw new Error(`unknown tool: ${name}`);
|
||
}
|
||
|
||
/*** 处理单条 JSON-RPC 消息;通知类消息不回包 ***/
|
||
function handle(msg) {
|
||
const { id, method, params } = msg;
|
||
if (method === 'initialize') {
|
||
send({
|
||
jsonrpc: '2.0',
|
||
id,
|
||
result: {
|
||
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
||
capabilities: { tools: {} },
|
||
serverInfo: { name: 'ui-arch-registry', version: '0.1.0' },
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
if (method === 'tools/list') {
|
||
send({ jsonrpc: '2.0', id, result: { tools } });
|
||
return;
|
||
}
|
||
if (method === 'tools/call') {
|
||
try {
|
||
const result = callTool(params?.name, params?.arguments ?? {});
|
||
send({
|
||
jsonrpc: '2.0',
|
||
id,
|
||
result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] },
|
||
});
|
||
} catch (err) {
|
||
send({
|
||
jsonrpc: '2.0',
|
||
id,
|
||
result: { isError: true, content: [{ type: 'text', text: String(err.message) }] },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (id != null) {
|
||
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||
}
|
||
}
|
||
|
||
let buf = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
buf += chunk;
|
||
let idx;
|
||
while ((idx = buf.indexOf('\n')) >= 0) {
|
||
const line = buf.slice(0, idx).trim();
|
||
buf = buf.slice(idx + 1);
|
||
if (!line) continue;
|
||
let msg;
|
||
try {
|
||
msg = JSON.parse(line);
|
||
} catch {
|
||
continue;
|
||
}
|
||
handle(msg);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- 风格检查(CODING.md 的机器执行版) ----
|
||
|
||
/*** 递归收集仓库源码文件(packages + apps),跳过产物与工具目录 ***/
|
||
function collectSourceFiles() {
|
||
const exts = new Set(['.ts', '.mts', '.cts', '.mjs', '.js']);
|
||
const skip = new Set(['node_modules', 'dist', '.ua', '.git', '.workbuddy']);
|
||
const out = [];
|
||
const walk = (dir) => {
|
||
for (const name of fs.readdirSync(dir)) {
|
||
if (skip.has(name)) continue;
|
||
const full = path.join(dir, name);
|
||
const st = fs.statSync(full);
|
||
if (st.isDirectory()) walk(full);
|
||
else if (exts.has(path.extname(name))) out.push(full);
|
||
}
|
||
};
|
||
walk(ROOT);
|
||
return out;
|
||
}
|
||
|
||
/*** 把字符串 / 模板字面量整体替换为等长空格,便于按位置定位真实注释(避开 URL、正则里的 //) ***/
|
||
function blankStrings(line) {
|
||
return line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, (m) => ' '.repeat(m.length));
|
||
}
|
||
|
||
/*** 检查单文件:导出缺注释块、接口字段缺行尾注释、行注释非中文 ***/
|
||
function styleCheckFile(file, src) {
|
||
const issues = [];
|
||
const lines = src.split(/\r?\n/);
|
||
let iface = null; // 当前追踪中的接口名
|
||
let depth = 0; // 接口体花括号深度
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const raw = lines[i];
|
||
const line = raw.trim();
|
||
|
||
if (iface) {
|
||
for (const ch of raw) { // 花括号计数进出接口体
|
||
if (ch === '{') depth++;
|
||
else if (ch === '}') depth--;
|
||
}
|
||
if (/^[A-Za-z_$][\w$]*\??:/.test(line) && !line.includes('//')) {
|
||
issues.push(`${file}:${i + 1} 接口 ${iface} 字段缺少行尾注释`);
|
||
}
|
||
if (depth <= 0) iface = null;
|
||
} else {
|
||
const im = raw.match(/\bexport\s+interface\s+(\w+)/);
|
||
if (im) {
|
||
depth = (raw.match(/\{/g) || []).length - (raw.match(/\}/g) || []).length;
|
||
iface = im[1];
|
||
}
|
||
}
|
||
|
||
const decl = line.match(/^export\s+(?:async\s+)?(?:function|interface|type|class)\s+([A-Za-z_$][\w$]*)/);
|
||
if (decl) {
|
||
let j = i - 1;
|
||
while (j >= 0 && lines[j].trim() === '') j--; // 找上一个非空行
|
||
if (j < 0 || !lines[j].includes('/***')) {
|
||
// 上一行是注释续行(以 ***/ 结尾却没有 /***)说明写成了多行,给出可直接照做的修法
|
||
const cont = j >= 0 && lines[j].includes('***/') && !lines[j].includes('/***');
|
||
const hint = cont ? '(注释被拆成多行:压成单行 /*** 作用 ***/ 并紧贴导出上一行)' : '';
|
||
issues.push(`${file}:${i + 1} 导出 ${decl[1]} 缺少 /*** 作用 ***/ 注释${hint}`);
|
||
}
|
||
}
|
||
|
||
const hash = blankStrings(raw).indexOf('//'); // 注释定位:先剥离字面量,'https://' 不算注释
|
||
if (hash >= 0) {
|
||
const text = raw.slice(hash + 2);
|
||
const isPragma = /^[\s*/]*(eslint|@ts-|prettier-ignore|istanbul|c8|node:)/i.test(text);
|
||
if (!isPragma && /[A-Za-z]{2,}/.test(text) && !/[\u4e00-\u9fff]/.test(text)) {
|
||
issues.push(`${file}:${i + 1} 行注释需使用中文`);
|
||
}
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
// 单文件行数上限:CODING.md「单文件尽量 < 200 行」,只约束包内实现文件
|
||
const MAX_LINES = 200;
|
||
// 不受行数限制的汇总文件:类型集中文件与导出入口天然会长
|
||
const SIZE_EXEMPT = new Set(['types.ts', 'index.ts']);
|
||
|
||
/*** 行数检查:只查分层包 src 目录下的实现文件,超出上限即违规 ***/
|
||
function sizeCheckFile(file) {
|
||
const rel = path.relative(ROOT, file).replace(/\\/g, '/');
|
||
if (!/^packages\/[^/]+\/[^/]+\/src\//.test(rel)) return []; // 只约束分层包源码
|
||
if (SIZE_EXEMPT.has(path.basename(file))) return []; // 类型 / 导出汇总豁免
|
||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).length;
|
||
return lines > MAX_LINES ? [`${rel} 共 ${lines} 行,超过单文件 ${MAX_LINES} 行上限(CODING.md)`] : [];
|
||
}
|
||
|
||
/*** 全仓风格检查:注释规范 + 单文件行数,有违规退出码 1,全绿输出 OK ***/
|
||
function cmdStyle() {
|
||
const files = collectSourceFiles();
|
||
const issues = [];
|
||
for (const f of files) {
|
||
issues.push(...styleCheckFile(f, fs.readFileSync(f, 'utf8')));
|
||
issues.push(...sizeCheckFile(f));
|
||
}
|
||
if (issues.length) {
|
||
for (const it of issues) console.error('style: ' + it);
|
||
console.error(`${issues.length} violation(s)`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`style OK (${files.length} files, 实现文件 ≤ ${MAX_LINES} 行)`);
|
||
}
|
||
|
||
/*** 皮肤包硬编码颜色检查:#hex 字面量一律禁止,颜色必须走 --ua-* 令牌(rgba 仅限阴影/遮罩的透明度叠加) ***/
|
||
function checkSkinCss(pkg) {
|
||
const issues = [];
|
||
const dir = path.join(ROOT, pkg.dir);
|
||
if (!fs.existsSync(dir)) return issues;
|
||
for (const name of fs.readdirSync(dir)) {
|
||
if (!name.endsWith('.css')) continue; // 只查样式表
|
||
const file = path.join(dir, name);
|
||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||
lines.forEach((line, i) => {
|
||
if (/#[0-9a-fA-F]{3,8}\b/.test(line)) issues.push(`${pkg.dir}/${name}:${i + 1} 皮肤包禁止硬编码颜色,改用 --ua-* 令牌`);
|
||
});
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 皮肤包令牌引用校验:var(--ua-*) 必须是 tokens.css 令牌、同文件自定义变量,或组件脚本里 setProperty 的动态变量(防拼错名字静默失效) ***/
|
||
function checkTokenRefs(pkg, allowed) {
|
||
const issues = [];
|
||
const dir = path.join(ROOT, pkg.dir);
|
||
if (!fs.existsSync(dir)) return issues;
|
||
const local = new Set(); // 组件自身定义/写入的 --ua-*:CSS 定义 + TS 里 setProperty 的动态变量
|
||
const srcDir = path.join(dir, 'src');
|
||
if (fs.existsSync(srcDir)) {
|
||
for (const f of fs.readdirSync(srcDir)) {
|
||
if (!f.endsWith('.ts')) continue;
|
||
for (const m of fs.readFileSync(path.join(srcDir, f), 'utf8').matchAll(/--ua-[\w-]+/g)) local.add(m[0]);
|
||
}
|
||
}
|
||
for (const name of fs.readdirSync(dir)) {
|
||
if (!name.endsWith('.css')) continue; // 只查样式表引用
|
||
const src = fs.readFileSync(path.join(dir, name), 'utf8');
|
||
for (const m of src.matchAll(/(--ua-[\w-]+)\s*:/g)) local.add(m[1]); // 样式表内自定义变量
|
||
for (const hit of src.matchAll(/var\(\s*(--ua-[\w-]+)/g)) {
|
||
const ref = hit[1];
|
||
if (!allowed.has(ref) && !local.has(ref)) issues.push(`${pkg.dir}/${name}: 引用未定义令牌 ${ref}`);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** README 组件区块校验:marker 之间的 id 集合必须与全部清单的 docs.components 完全一致(防文档漂移) ***/
|
||
function checkReadme(pkgs) {
|
||
const issues = [];
|
||
const file = path.join(ROOT, 'README.md');
|
||
if (!fs.existsSync(file)) return issues; // 卫语句:没有 README 就不校验
|
||
const block = fs.readFileSync(file, 'utf8').match(/<!-- ua:components -->([\s\S]*?)<!-- \/ua:components -->/);
|
||
if (!block) {
|
||
issues.push('README.md 缺少组件区块标记(<!-- ua:components --> … <!-- /ua:components -->)');
|
||
return issues;
|
||
}
|
||
const blockIds = block[1].split('·').map((s) => s.trim()).filter(Boolean); // 保留原始顺序,供重复检测
|
||
const listed = new Set(blockIds);
|
||
const dupInBlock = blockIds.filter((id, i) => blockIds.indexOf(id) !== i); // 同一 id 出现多次:文档漂移
|
||
for (const id of new Set(dupInBlock)) issues.push(`README.md 组件区块重复列出 ${id}`);
|
||
const actual = new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id)));
|
||
for (const id of actual) if (!listed.has(id)) issues.push(`README.md 组件区块缺少 ${id}`);
|
||
for (const id of listed) if (!actual.has(id)) issues.push(`README.md 组件区块多出 ${id}(清单里没有)`);
|
||
// 分组行同一份清单的另一种表述:漏写/多写同样算文档漂移
|
||
const groupLine = fs.readFileSync(file, 'utf8').split(/\r?\n/).find((line) => line.startsWith('分组:'));
|
||
if (!groupLine) {
|
||
issues.push('README.md 缺少「分组:」行(组件分类导航)');
|
||
return issues;
|
||
}
|
||
const grouped = new Set(groupLine.match(/[a-zA-Z][A-Za-z0-9]*/g) ?? []);
|
||
const alias = new Set(['formItem']); // formItem 与 form 共用一条文档,分组行里会单独出现
|
||
for (const id of actual) {
|
||
const hits = groupLine.match(new RegExp(`(?<![A-Za-z0-9])${id}(?![A-Za-z0-9])`, 'g')) ?? []; // 整词计数,避开 formItem 命中 form
|
||
if (!hits.length) issues.push(`README.md 分组行缺少 ${id}`);
|
||
else if (hits.length > 1) issues.push(`README.md 分组行重复列出 ${id}(${hits.length} 次)`);
|
||
}
|
||
for (const id of grouped) if (!actual.has(id) && !alias.has(id)) issues.push(`README.md 分组行多出 ${id}(清单里没有)`);
|
||
return issues;
|
||
}
|
||
|
||
/*** 组件数漂移校验:AGENTS.md / ARCHITECTURE.md 中的「N 个组件」必须与 skin-dom 清单实际总数一致 ***/
|
||
// 组件计数的三种写法:「N 个组件」「N 个 DOM 组件」「共 N 个」
|
||
const COUNT_RE = /(\d+)\s*个\s*(?:DOM\s*)?组件|共\s*(\d+)\s*个/g;
|
||
|
||
/*** 逐行扫文档里的组件计数,返回 { line, value, text } 便于校验与改写共用 ***/
|
||
function scanCountLines(text) {
|
||
const hits = [];
|
||
text.split(/\r?\n/).forEach((line, i) => {
|
||
for (const m of line.matchAll(COUNT_RE)) hits.push({ line: i + 1, value: Number(m[1] ?? m[2]), text: line });
|
||
});
|
||
return hits;
|
||
}
|
||
|
||
function checkCountDocs(actual) {
|
||
const issues = [];
|
||
for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'README.md']) {
|
||
const file = path.join(ROOT, name);
|
||
if (!fs.existsSync(file)) continue; // 卫语句:文档缺失不校验
|
||
for (const hit of scanCountLines(fs.readFileSync(file, 'utf8'))) {
|
||
if (hit.value !== actual) issues.push(`${name}:${hit.line} 组件数「${hit.value}」与清单实际 ${actual} 不一致,跑 ua sync-counts 自动同步`);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 组件计数同步:把三份文档里的「N 个组件 / 共 N 个」一律改成清单实际数量(幂等,无变化不写盘) ***/
|
||
function cmdSyncCounts() {
|
||
const actual = collectComponents(collectManifests()).length;
|
||
let changed = 0;
|
||
for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'README.md']) {
|
||
const file = path.join(ROOT, name);
|
||
if (!fs.existsSync(file)) continue; // 卫语句:文档缺失跳过
|
||
const before = fs.readFileSync(file, 'utf8');
|
||
const after = before.replace(COUNT_RE, (m, a, b) => (a ? m.replace(a, String(actual)) : m.replace(b, String(actual))));
|
||
if (after === before) continue;
|
||
fs.writeFileSync(file, after, 'utf8');
|
||
changed += 1;
|
||
}
|
||
console.log(`sync-counts: 清单实际 ${actual} 个组件,改写 ${changed} 个文件`);
|
||
}
|
||
|
||
/*** 暗色令牌齐全校验:每个 --ua-color-* 都得在 [data-theme='dark'] 里有覆盖值,反向也查暗色块里的变量先在 :root 定义过(防改名漏改) ***/
|
||
function checkDarkTokens(issues) {
|
||
const p = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css');
|
||
if (!fs.existsSync(p)) return; // 卫语句:令牌文件缺失交给其它校验兜底
|
||
const src = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
|
||
const names = (block) => [...block.matchAll(/(--ua-[\w-]+)\s*:/g)].map((m) => m[1]); // 块内变量名,按出现顺序
|
||
const rootNames = names((src.match(/:root\s*\{([\s\S]*?)\}/) ?? [, ''])[1]);
|
||
const darkNames = names((src.match(/\[data-theme='dark'\]\s*\{([\s\S]*?)\}/) ?? [, ''])[1]);
|
||
const darkSet = new Set(darkNames);
|
||
const rootSet = new Set(rootNames);
|
||
for (const n of rootNames) {
|
||
if (!n.startsWith('--ua-color-')) continue; // 卫语句:只有颜色需要暗色重定值,间距/字号/圆角通用
|
||
if (!darkSet.has(n)) issues.push(`tokens: 颜色令牌 ${n} 缺 [data-theme='dark'] 覆盖(暗色下会沿用亮色值)`);
|
||
}
|
||
for (const n of darkNames) if (!rootSet.has(n)) issues.push(`tokens: 暗色块里的 ${n} 未在 :root 定义(改名后忘了同步)`);
|
||
}
|
||
/*** llms-full.txt 同步校验:内联重渲染 Agent 文档(时间戳归一化)与落盘产物比对,防 manifest 改后忘跑 docs ***/
|
||
function checkLlmsFull(pkgs, issues) {
|
||
if (!fs.existsSync(LLMS_FULL)) return; // 卫语句:产物缺失不算漂移(可能是新仓库)
|
||
const tokensPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json');
|
||
const tokens = fs.existsSync(tokensPath) ? JSON.parse(fs.readFileSync(tokensPath, 'utf8')) : null;
|
||
const norm = (s) => s.replace(/\r\n/g, '\n').replace(/自动生成于 [^,]*/, '自动生成于 X');
|
||
const expected = renderAgentDocs({ generatedAt: '', packages: pkgs }, tokens);
|
||
if (norm(fs.readFileSync(LLMS_FULL, 'utf8')) !== norm(expected)) {
|
||
issues.push('llms-full.txt 与清单不一致,请运行 npm run docs 重新生成');
|
||
}
|
||
}
|
||
|
||
/*** 生成 CHANGELOG.md:按提交类型前缀分组渲染 git 史(输出不含时间戳,可幂等比对);--check 只校验不写盘 ***/
|
||
function cmdChangelog(args) {
|
||
const check = args.includes('--check');
|
||
const res = spawnSync('git', ['log', '-n', '400', '--pretty=format:%h\t%s'], { cwd: ROOT, encoding: 'utf8' });
|
||
if (res.status !== 0) { console.error('changelog: 读取 git log 失败(是否在 git 仓库内?)'); process.exit(1); }
|
||
const rows = res.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
|
||
const [hash, ...rest] = line.split('\t'); // 第一段为短哈希,其余为标题(标题可能含制表符)
|
||
return { hash, subject: rest.join('\t') };
|
||
});
|
||
const groups = [
|
||
{ title: '迭代', test: /^iter\(/ }, // 自动化迭代提交
|
||
{ title: '功能', test: /^feat[(:]/ }, // 新功能
|
||
{ title: '修复', test: /^fix[(:]/ }, // 缺陷修复
|
||
{ title: '文档', test: /^docs[(:]/ }, // 文档变更
|
||
{ title: '维护', test: /^(chore|refactor|test|build|ci)[(:]/ }, // 杂项维护
|
||
];
|
||
const buckets = new Map(groups.map((g) => [g.title, []]));
|
||
buckets.set('其他', []); // 未匹配前缀的提交兜底
|
||
for (const row of rows) {
|
||
const hit = groups.find((g) => g.test.test(row.subject));
|
||
buckets.get(hit ? hit.title : '其他').push(row);
|
||
}
|
||
const parts = ['# 变更日志', '', '> 由 `ua changelog` 依据 git 提交史生成,请勿手改;条目按提交时间倒序。', ''];
|
||
for (const [title, list] of buckets) {
|
||
if (!list.length) continue; // 空分组不渲染
|
||
parts.push(`## ${title}`, '');
|
||
for (const row of list) parts.push(`- \`${row.hash}\` ${row.subject}`);
|
||
parts.push('');
|
||
}
|
||
const next = parts.join('\n');
|
||
const file = path.join(ROOT, 'CHANGELOG.md');
|
||
const prev = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
||
const same = prev.replace(/\r\n/g, '\n') === next;
|
||
if (check) {
|
||
if (!same) { console.error('changelog: CHANGELOG.md 与提交史不一致,请运行 ua changelog 重新生成'); process.exit(1); }
|
||
console.log('changelog OK (与提交史一致)');
|
||
return;
|
||
}
|
||
if (same) { console.log('changelog unchanged, skip'); return; } // 无变化不写盘,避免空提交
|
||
fs.writeFileSync(file, next);
|
||
console.log(`changelog written -> CHANGELOG.md (${rows.length} commits)`);
|
||
}
|
||
|
||
/*** 交叉校验留痕与条目:done 条目必须有一条 done 留痕,留痕 id 必须存在于 backlog(防「标完成无记录 / 记录指向幽灵条目」) ***/
|
||
function checkIterations() {
|
||
const issues = [];
|
||
const backlogPath = path.join(ROOT, '.ua', 'backlog.json');
|
||
const logPath = path.join(ROOT, '.ua', 'iterations.log');
|
||
if (!fs.existsSync(backlogPath) || !fs.existsSync(logPath)) return issues; // 卫语句:产物缺失不校验
|
||
const items = JSON.parse(fs.readFileSync(backlogPath, 'utf8')).items ?? [];
|
||
const ids = new Set(items.map((i) => i.id));
|
||
const done = new Set(); // 有 done 留痕的条目 id
|
||
for (const line of fs.readFileSync(logPath, 'utf8').split(/\r?\n/)) {
|
||
if (!line.trim()) continue; // 空行跳过
|
||
let row;
|
||
try { row = JSON.parse(line); } catch { continue; } // 行格式归 lint-iterations 管,这里不重复报
|
||
if (!ids.has(row.id)) issues.push(`iterations.log: 留痕 id ${row.id} 在 backlog 中不存在`);
|
||
if (row.result === 'done') done.add(row.id);
|
||
}
|
||
for (const item of items) {
|
||
if (item.status === 'done' && !done.has(item.id)) issues.push(`backlog: ${item.id} 已置 done 但 iterations.log 无 done 留痕`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** MCP 工具清单校验:README / AGENTS / CLI 清单必须逐个写出实际注册的工具名(新增工具漏改文档会命中) ***/
|
||
function checkMcpDocs() {
|
||
const issues = [];
|
||
const files = [
|
||
['README.md', path.join(ROOT, 'README.md')],
|
||
['AGENTS.md', path.join(ROOT, 'AGENTS.md')],
|
||
['packages/tools/cli/ua.manifest.json', path.join(ROOT, 'packages', 'tools', 'cli', 'ua.manifest.json')],
|
||
];
|
||
for (const [label, file] of files) {
|
||
if (!fs.existsSync(file)) {
|
||
issues.push(`${label} 不存在:MCP 工具清单无处登记`);
|
||
continue;
|
||
}
|
||
const text = fs.readFileSync(file, 'utf8');
|
||
const miss = MCP_TOOL_NAMES.filter((n) => !text.includes(n));
|
||
if (miss.length) issues.push(`${label} 的 MCP 工具清单缺少:${miss.join(' / ')}(改了 ua mcp 的工具就要同步这三处)`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 配方结构校验:编号连续不重复、每条都有最小代码块与要点、通用约定段存在(MCP 与 llms-full 都按这个结构取用) ***/
|
||
function checkRecipes() {
|
||
const issues = [];
|
||
const text = loadRecipes();
|
||
if (!text) return ['recipes.md 缺失或为空:组合配方是 Agent 侧的主要拼装依据'];
|
||
const list = parseRecipes();
|
||
if (!list.length) issues.push('recipes.md 里没有任何「## 配方 N:标题」段落,MCP 的 list_recipes 会返回空表');
|
||
list.forEach((r, i) => {
|
||
if (r.id !== String(i + 1)) issues.push(`配方编号不连续:第 ${i + 1} 条是「配方 ${r.id}」,应为「配方 ${i + 1}」`);
|
||
if (!r.code) issues.push(`配方 ${r.id}(${r.title})缺少 ts 代码块:Agent 拿不到最小骨架`);
|
||
if (!r.notes.length) issues.push(`配方 ${r.id}(${r.title})缺少要点行(以 - 开头的说明)`);
|
||
});
|
||
if (!/^## 通用约定/m.test(text)) issues.push('recipes.md 缺少「## 通用约定」段:跨配方的共同约束没地方落');
|
||
return issues;
|
||
}
|
||
|
||
/*** 演示产物校验:打包后的 demo.html 必须含每个区块锚点,证明演示代码真的编进了产物(防「只改源码、产物过期」) ***/
|
||
function checkDemoBundle(anchors) {
|
||
const issues = [];
|
||
const file = path.join(ROOT, 'apps', 'demo', 'dist', 'demo.html');
|
||
if (!fs.existsSync(file)) return issues; // 卫语句:产物未构建时不判定(check 链已保证有 demo 步骤)
|
||
const html = fs.readFileSync(file, 'utf8');
|
||
for (const a of anchors) {
|
||
if (!html.includes(a)) issues.push(`demo 产物缺少区块锚点 ${a},请重跑 npm run demo`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 文档字段漂移校验:props 名必须在实现源码里真实出现(改了参数名忘了改文档就会命中) ***/
|
||
function checkPropsImpl(pkg, comps) {
|
||
const issues = [];
|
||
const srcDir = path.join(ROOT, pkg.dir, 'src'); // 实现源码目录
|
||
if (!fs.existsSync(srcDir)) return issues;
|
||
const sources = new Map(); // 文件名 → 源码文本
|
||
for (const name of fs.readdirSync(srcDir)) {
|
||
if (name.endsWith('.ts')) sources.set(name, fs.readFileSync(path.join(srcDir, name), 'utf8'));
|
||
}
|
||
const findImpl = (id) => {
|
||
const own = sources.get(`${id}.ts`);
|
||
if (own !== undefined) return own; // 同名文件优先
|
||
const decl = new RegExp(`export function ${id}\\(`); // 兜底:按导出函数名反查文件
|
||
for (const text of sources.values()) if (decl.test(text)) return text;
|
||
return null;
|
||
};
|
||
for (const c of comps) {
|
||
if (!c.id) continue; // 缺 id 由主循环报,这里跳过
|
||
const tag = `${pkg.name}#${c.id}`;
|
||
const impl = findImpl(c.id);
|
||
if (impl === null) {
|
||
issues.push(`${tag}: 在 ${pkg.dir}/src 下找不到实现(既无同名文件,也没有 export function ${c.id})`);
|
||
continue;
|
||
}
|
||
for (const p of c.props ?? []) {
|
||
const name = String(p.n ?? '');
|
||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue; // 说明性条目(...children、带括号注记)不参与
|
||
if (!impl.includes(name)) issues.push(`${tag}: 文档声明了 props「${name}」,实现里找不到该名字(改名后文档未同步)`);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 反向漂移校验:入参接口的每个字段都必须在文档 props 表登记(新加字段忘了写文档会命中) ***/
|
||
function checkPropsDoc(pkg, comps) {
|
||
const issues = [];
|
||
const typesPath = path.join(ROOT, pkg.dir, 'src', 'types.ts'); // 约定:类型集中在 src/types.ts
|
||
if (!fs.existsSync(typesPath)) return issues;
|
||
const src = fs.readFileSync(typesPath, 'utf8');
|
||
const ifaces = new Map(); // 接口基名(已去掉 Props 后缀)→ 字段名数组
|
||
for (const m of src.matchAll(/export interface (\w+)Props(?:<[^>]*>)?\s*\{([\s\S]*?)\n\}/g)) {
|
||
const fields = [];
|
||
for (const line of m[2].split(/\r?\n/)) {
|
||
const hit = /^\s{2}(\w+)(\?|:)/.exec(line); // 缩进两格的字段声明
|
||
if (hit) fields.push(hit[1]);
|
||
}
|
||
ifaces.set(m[1], fields);
|
||
}
|
||
if (!ifaces.size) return issues;
|
||
const pascal = (id) => id[0].toUpperCase() + id.slice(1);
|
||
for (const c of comps) {
|
||
if (!c.id || c.propsType === false) continue; // propsType: false 表示该组件没有入参接口
|
||
const tag = `${pkg.name}#${c.id}`;
|
||
const base = String(c.propsType ?? '').replace(/Props$/, '') || pascal(c.id); // 清单可覆盖接口名
|
||
const fields = ifaces.get(base);
|
||
if (!fields) {
|
||
issues.push(`${tag}: 找不到入参接口 ${base}Props(命名不同请在清单用 propsType 指定,无入参写 false)`);
|
||
continue;
|
||
}
|
||
const documented = new Set((c.props ?? []).map((p) => String(p.n ?? '').replace(/^([^)]*)/, '').replace(/^\.\.\./, '').trim())); // 去掉前缀注记与展开符
|
||
for (const f of fields) {
|
||
if (!documented.has(f)) issues.push(`${tag}: 入参接口 ${base}Props 的字段「${f}」未在文档 props 表登记`);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/
|
||
function cmdVerify() {
|
||
const issues = [];
|
||
const pkgs = collectManifests(); // 一次收集,供组件校验与 README 区块校验共用
|
||
const demoPath = path.join(ROOT, 'apps', 'demo', 'main.ts'); // 演示区块定义在源码里(产物是打包后的 JS)
|
||
const demoSource = fs.existsSync(demoPath) ? fs.readFileSync(demoPath, 'utf8') : '';
|
||
const anchors = new Set(demoSource.match(/sec-[a-z0-9-]+/g) ?? []); // 已定义的区块锚点
|
||
const tokenVars = new Set(loadTokenList().map((t) => t.cssVar)); // 令牌白名单:tokens.json 拍平结果
|
||
if (demoSource) issues.push(...checkDemoBundle(anchors)); // 产物与源码同步校验
|
||
if (!demoSource) console.log('verify: 未找到 apps/demo/main.ts,跳过 demo 锚点校验');
|
||
issues.push(...checkRecipes()); // 配方结构:编号连续 + 骨架 + 要点,保证 MCP 取得到
|
||
issues.push(...checkMcpDocs()); // MCP 工具清单:改注册表必须同步 README / AGENTS / CLI 清单
|
||
|
||
for (const pkg of pkgs) {
|
||
const comps = pkg.docs?.components ?? []; // 清单已被 collectManifests 摊平
|
||
const exports = pkg.api?.exports ?? [];
|
||
if (pkg.type === 'skin-dom') {
|
||
issues.push(...checkSkinCss(pkg));
|
||
issues.push(...checkTokenRefs(pkg, tokenVars));
|
||
issues.push(...checkPropsImpl(pkg, comps));
|
||
issues.push(...checkPropsDoc(pkg, comps));
|
||
}
|
||
if (comps.length && !exports.length) issues.push(`${pkg.name}: docs.components 有 ${comps.length} 项但 api.exports 为空`);
|
||
|
||
const seen = new Set(); // 组件 id 去重(同一清单内不允许重复)
|
||
for (const c of comps) {
|
||
const tag = `${pkg.name}#${c.id ?? '?'}`;
|
||
if (!c.id || !c.title) issues.push(`${tag}: 缺少 id 或 title`);
|
||
if (seen.has(c.id)) issues.push(`${tag}: id 重复`);
|
||
seen.add(c.id);
|
||
if (!c.example) issues.push(`${tag}: 缺少 example`);
|
||
if ((c.steps ?? []).length < 3) issues.push(`${tag}: steps 少于 3 条`);
|
||
if (!exports.some((e) => e.startsWith(`${c.id}(`) || e.startsWith(`${c.id}.`))) {
|
||
issues.push(`${tag}: api.exports 中没有以「${c.id}(」或「${c.id}.」开头的条目`);
|
||
}
|
||
for (const prop of c.props ?? []) {
|
||
if (!prop.n || !prop.t || !prop.d) issues.push(`${tag}: props 条目 ${prop.n ?? '?'} 缺 n/t/d`);
|
||
}
|
||
if (c.demo && demoSource && !anchors.has(c.demo)) {
|
||
issues.push(`${tag}: demo 锚点 ${c.demo} 在 apps/demo/main.ts 中不存在(缺对应 section)`);
|
||
}
|
||
if (demoSource && !demoSource.includes(`${c.id}(`) && !demoSource.includes(`${c.id}.`)) {
|
||
issues.push(`${tag}: apps/demo/main.ts 未实际调用 ${c.id}()(只有 section 锚点不算演示)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const skinTotal = pkgs // skin-dom 清单组件总数,供文档数字漂移校验
|
||
.filter((p) => p.type === 'skin-dom')
|
||
.flatMap((p) => p.docs?.components ?? []).length;
|
||
issues.push(...checkCountDocs(skinTotal));
|
||
issues.push(...checkReadme(pkgs));
|
||
checkDarkTokens(issues);
|
||
checkLlmsFull(pkgs, issues);
|
||
issues.push(...checkIterations());
|
||
|
||
if (issues.length) {
|
||
for (const it of issues) console.error('verify: ' + it);
|
||
console.error(`${issues.length} violation(s)`);
|
||
process.exit(1);
|
||
}
|
||
console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全,README 组件清单与分组同步,backlog ↔ 留痕一致,组合配方结构完整,MCP 工具清单三处一致)');
|
||
}
|
||
|
||
/*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/
|
||
function cmdCommitLint(args) {
|
||
// 输入来源:文件路径(commit-msg 钩子传参)或参数拼接
|
||
let msg = '';
|
||
if (args[0] && fs.existsSync(args[0])) msg = fs.readFileSync(args[0], 'utf8');
|
||
else msg = args.join(' ');
|
||
if (!msg.trim()) { console.error('commit-lint: 提交信息为空'); process.exit(1); }
|
||
|
||
const head = msg.split('\n')[0].trimEnd();
|
||
// git 自动生成的合并/回滚信息直接放行
|
||
if (/^(Merge |Revert |fixup!|squash!)/.test(head)) return console.log('commit-lint OK (git 自动信息,跳过校验)');
|
||
|
||
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'iter'];
|
||
const m = head.match(/^(\w+)(?:\(([^)]*)\))?: (.+)$/);
|
||
if (!m) { console.error('commit-lint: 首行须为 <type>(<scope>): <subject>,规范见 COMMITTING.md'); process.exit(1); }
|
||
const [, type, scope, subject] = m;
|
||
const problems = [];
|
||
// type 枚举
|
||
if (!TYPES.includes(type)) problems.push(`type「${type}」不在枚举:${TYPES.join('/')}`);
|
||
// iter 提交 scope 必须是 backlog 条目 id
|
||
if (type === 'iter' && !/^[a-z]\d{2,}$/.test(scope ?? '')) problems.push('iter 提交的 scope 必须是 backlog 条目 id(如 b02/e03)');
|
||
// 非 iter 提交 scope 必填(杜绝 chore:/docs: 裸 type 放行),且仅允许小写字母与连字符
|
||
if (type !== 'iter' && !scope) problems.push(`type「${type}」必须带 scope,如 chore(backlog):`);
|
||
if (type !== 'iter' && scope && !/^[a-z][a-z-]*$/.test(scope)) problems.push(`scope「${scope}」仅允许小写字母与连字符`);
|
||
// subject 长度与标点
|
||
if ([...subject].length > 50) problems.push(`subject 超过 50 字符(当前 ${[...subject].length})`);
|
||
if (/[。..!!??]$/.test(subject.trim())) problems.push('subject 结尾不加句号');
|
||
// body 行宽
|
||
for (const line of msg.split('\n').slice(1)) {
|
||
if (line.length > 80) problems.push(`body 行超过 80 字符:${line.slice(0, 20)}…`);
|
||
}
|
||
if (problems.length) { for (const p of problems) console.error('commit-lint: ' + p); process.exit(1); }
|
||
console.log(`commit-lint OK (${type}${scope ? `(${scope})` : ''})`);
|
||
}
|
||
|
||
/*** 提交主题索引:一次 git log 换「短哈希 → 主题」表,供留痕回查,避免逐行起进程 ***/
|
||
function commitSubjects() {
|
||
const r = spawnSync('git', ['log', '--all', '--pretty=%h|%s'], { cwd: ROOT, encoding: 'utf8' });
|
||
if (r.status !== 0) return null; // 非 git 环境(打包分发等)跳过该维度,不让整条门禁失效
|
||
const pairs = [];
|
||
for (const line of String(r.stdout ?? '').split('\n')) {
|
||
const cut = line.indexOf('|');
|
||
if (cut > 0) pairs.push([line.slice(0, cut), line.slice(cut + 1)]);
|
||
}
|
||
return pairs;
|
||
}
|
||
|
||
/*** 迭代留痕校验:.ua/iterations.log 每行 JSON 字段与格式必须符合 ITERATION.md 规范 ***/
|
||
function cmdLintIterations() {
|
||
const file = path.join(INDEX_DIR, 'iterations.log');
|
||
if (!fs.existsSync(file)) return console.log('lint-iterations OK (no log)');
|
||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((l) => l.trim());
|
||
const issues = [];
|
||
lines.forEach((line, i) => {
|
||
const tag = `iterations.log:${i + 1}`;
|
||
let o;
|
||
try { o = JSON.parse(line); } catch { issues.push(`${tag} 不是合法 JSON`); return; }
|
||
const keys = Object.keys(o);
|
||
if (keys.length !== 5 || !['ts', 'id', 'result', 'commit', 'summary'].every((k) => keys.includes(k))) {
|
||
issues.push(`${tag} 字段必须恰为 {ts,id,result,commit,summary}(当前 ${keys.join(',')})`);
|
||
}
|
||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/.test(String(o.ts ?? '')) || Number.isNaN(Date.parse(o.ts))) {
|
||
issues.push(`${tag} ts 须为可解析的本地 +08:00 ISO 时间`);
|
||
}
|
||
if (!/^[a-z]\d{2,}$/.test(String(o.id ?? ''))) issues.push(`${tag} id 须为 backlog 条目 id`);
|
||
if (o.result !== 'done' && o.result !== 'aborted') issues.push(`${tag} result 须为 done|aborted`);
|
||
const commit = String(o.commit ?? '');
|
||
if (o.result === 'done' && !/^[0-9a-f]{7,40}$/.test(commit)) issues.push(`${tag} done 条目 commit 须为 7-40 位哈希`);
|
||
if (o.result === 'aborted' && commit && !/^[0-9a-f]{7,40}$/.test(commit)) issues.push(`${tag} aborted 条目 commit 须为空或哈希`);
|
||
if (!String(o.summary ?? '').trim()) issues.push(`${tag} summary 不能为空`);
|
||
});
|
||
// —— 留痕 ↔ 提交双向核对:哈希必须真实存在,且主题要指向同一条目(防伪造 / 防抄错哈希)——
|
||
const pairs = commitSubjects();
|
||
if (pairs) {
|
||
lines.forEach((line, i) => {
|
||
const tag = `iterations.log:${i + 1}`;
|
||
let o;
|
||
try { o = JSON.parse(line); } catch { return; } // 行格式问题已在上轮报过
|
||
const hash = String(o.commit ?? '');
|
||
if (!hash) return; // aborted 允许留空
|
||
const hit = pairs.find(([h]) => h.startsWith(hash));
|
||
if (!hit) { issues.push(`${tag} commit ${hash} 不在仓库历史中`); return; }
|
||
const legacy = `iter: ${o.id}`; // 早期提交用「iter: <id> 描述」,无 scope
|
||
if (!hit[1].startsWith(`iter(${o.id})`) && !hit[1].startsWith(legacy)) {
|
||
issues.push(`${tag} commit ${hash} 的主题「${hit[1]}」没有指向条目 ${o.id}`);
|
||
}
|
||
});
|
||
}
|
||
if (issues.length) {
|
||
for (const it of issues) console.error('lint-iterations: ' + it);
|
||
console.error(`${issues.length} violation(s)`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`lint-iterations OK (${lines.length} entries)`);
|
||
}
|
||
|
||
// ---- 入口 ----
|
||
|
||
const [cmd, ...args] = process.argv.slice(2);
|
||
switch (cmd) {
|
||
case 'index':
|
||
cmdIndex();
|
||
break;
|
||
case 'list':
|
||
cmdList(args);
|
||
break;
|
||
case 'search':
|
||
cmdSearch(args.filter((a) => !a.startsWith('--')), args);
|
||
break;
|
||
case 'info':
|
||
if (!args[0]) {
|
||
console.error('usage: ua info <包名|组件id> [--json]');
|
||
process.exit(1);
|
||
}
|
||
cmdInfo(args[0], args.slice(1));
|
||
break;
|
||
case 'docs':
|
||
cmdDocs();
|
||
break;
|
||
case 'style':
|
||
cmdStyle();
|
||
break;
|
||
case 'sync-counts':
|
||
cmdSyncCounts();
|
||
break;
|
||
case 'verify':
|
||
cmdVerify();
|
||
break;
|
||
case 'commit-lint':
|
||
cmdCommitLint(args);
|
||
break;
|
||
case 'changelog':
|
||
cmdChangelog(args);
|
||
break;
|
||
case 'tokens':
|
||
cmdTokens(args);
|
||
break;
|
||
case 'lint-iterations':
|
||
cmdLintIterations();
|
||
break;
|
||
case 'mcp':
|
||
cmdMcp();
|
||
break;
|
||
default:
|
||
console.log('usage: ua <index|list|search <kw>|info <name>|docs|tokens [--json]|style|sync-counts|verify|commit-lint|lint-iterations|changelog|mcp>');
|
||
process.exit(cmd ? 1 : 0);
|
||
}
|