2189 lines
118 KiB
JavaScript
2189 lines
118 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';
|
||
import { visualAudit } from '../lib/visual-audit.mjs';
|
||
import { auditContrast, formatReport, PAIRS } from '../lib/contrast-audit.mjs';
|
||
import { shellPalette } from '../lib/shell-palette.mjs';
|
||
import { auditMotion, formatMotionReport } from '../lib/motion-audit.mjs';
|
||
import { auditSurface, formatSurfaceReport, LADDER } from '../lib/surface-audit.mjs';
|
||
import { SYNONYMS, synonymIds } from '../lib/synonyms.mjs';
|
||
|
||
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('--json')) { // 机器可读:包 + 组件 id 清单,脚本 / Agent 不必解析文本
|
||
console.log(JSON.stringify(packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description, components: (p.docs?.components ?? []).map((c) => c.id) })), null, 2));
|
||
return;
|
||
}
|
||
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');
|
||
}
|
||
|
||
/*** 作用:把组件渲染成可直接贴进 issue / 文档 / 回复的 Markdown 片段(标题 + 参数表 + 用法 + 示例)
|
||
* 与文本版共用同一份清单数据:Agent 拿到片段就能贴,不必自己从参数表里再抄一遍格式。 ***/
|
||
function formatComponentMd(pkg, comp) {
|
||
const cell = (v) => String(v ?? '').replace(/\|/g, '\\|'); // 联合类型里的竖线会截断表格,必须转义
|
||
const lines = [`### ${comp.id} · ${comp.title ?? comp.id}`, '', `\`${pkg.name}\`${comp.demo ? ` · 演示锚点 \`${comp.demo}\`` : ''}`, ''];
|
||
if ((comp.props ?? []).length) {
|
||
lines.push('| 参数 | 类型 | 必填 | 说明 |', '| --- | --- | --- | --- |');
|
||
for (const x of comp.props) lines.push(`| \`${cell(x.n)}\` | \`${cell(x.t)}\` | ${x.r ? '是' : '否'} | ${cell(x.d)} |`);
|
||
lines.push('');
|
||
}
|
||
if ((comp.steps ?? []).length) {
|
||
lines.push('**用法**', '');
|
||
comp.steps.forEach((t, i) => lines.push(`${i + 1}. ${t}`));
|
||
lines.push('');
|
||
}
|
||
if (comp.example) lines.push('```ts', 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);
|
||
}
|
||
|
||
/*** 名字 → 包或组件条目:包名 / @ui-arch 前缀 / 组件 id 三种写法都认;CLI info 与 MCP get_component 共用,防止两处解析口径漂移 ***/
|
||
function resolveEntry(packages, n) {
|
||
const pkg = packages.find((x) => x.name === n || x.name === `@ui-arch/${n}`);
|
||
if (pkg) return { kind: 'pkg', pkg };
|
||
const hit = collectComponents(packages).find((x) => x.comp.id === n);
|
||
return hit ? { kind: 'comp', hit } : null;
|
||
}
|
||
|
||
/*** 组件的可检索字段:权重从高到低(id > 标题 > 参数名 > 参数说明 > 步骤),search 与 MCP 共用一份 ***/
|
||
function searchFields(comp) {
|
||
const props = comp.props ?? [];
|
||
return [
|
||
{ label: 'id', weight: 3, text: String(comp.id ?? '') },
|
||
{ label: '标题', weight: 2, text: String(comp.title ?? '') },
|
||
{ label: '参数', weight: 1, text: props.map((p) => String(p.n ?? '')).join(' ') },
|
||
{ label: '参数说明', weight: 0.8, text: props.map((p) => String(p.d ?? '')).join(' ') },
|
||
{ label: '步骤', weight: 0.5, text: (comp.steps ?? []).join(' ') },
|
||
].map((f) => ({ label: f.label, weight: f.weight, text: f.text.toLowerCase() }));
|
||
}
|
||
|
||
/*** 单个关键词在组件上的最佳命中:返回最高档字段(id 全等额外抬到最前),未命中 score 为 0 ***/
|
||
function matchOf(kw, comp) {
|
||
let best = { score: 0, label: '' };
|
||
for (const f of searchFields(comp)) {
|
||
if (!f.text.includes(kw)) continue;
|
||
const score = f.label === 'id' && f.text === kw ? 4 : f.weight; // id 全等单独一档,避免被 id 含稀释
|
||
if (score > best.score) best = { score, label: f.label };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/*** 同义回退:整组关键词在 id / 标题 / 参数 / 步骤与配方里都落空时,按同义词表换算出候选组件
|
||
* CLI search 与 MCP search_components 共用这一份,避免两处口径漂移;返回空数组表示「回退也无解」 ***/
|
||
function fallbackBySynonym(kws) {
|
||
const sets = kws.map((kw) => synonymIds(kw));
|
||
if (sets.some((s) => !s)) return []; // 有词未被同义词表覆盖:整组回退无从谈起
|
||
const all = collectComponents();
|
||
let cands = all.filter((x) => sets.every((s) => s.has(x.comp.id))); // 多词取交集,与搜索的 AND 语义保持一致
|
||
if (!cands.length) { // 交集为空(两个词各指不同组件)退化为并集:说了两个诉求就把相关项都给到
|
||
const union = new Set(sets.flatMap((s) => [...s]));
|
||
cands = all.filter((x) => union.has(x.comp.id));
|
||
}
|
||
return cands;
|
||
}
|
||
|
||
/*** 按关键词搜组件:多词取最弱命中(AND 语义),并标出该词命中的字段,便于判断为何被召回 ***/
|
||
/*** 单个配方对多关键词的命中:标题 3 / 组件 2 / 要点 1,返回最弱档(与组件搜索同一排序口径),未命中返回 null ***/
|
||
function recipeMatch(kws, r) {
|
||
const fields = [
|
||
{ label: '配方标题', weight: 3, text: String(r.title ?? '').toLowerCase() },
|
||
{ label: '配方组件', weight: 2, text: recipeComps(r).join(' ').toLowerCase() },
|
||
{ label: '配方要点', weight: 1, text: (r.notes ?? []).join(' ').toLowerCase() },
|
||
];
|
||
let weakest = null;
|
||
for (const kw of kws) {
|
||
let best = null;
|
||
for (const f of fields) if (f.text.includes(kw) && (!best || f.weight > best.weight)) best = { ...f, keyword: kw };
|
||
if (!best) return null; // 任一词不中即淘汰
|
||
if (!weakest || best.weight < weakest.weight) weakest = best;
|
||
}
|
||
return weakest;
|
||
}
|
||
|
||
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 hits = collectComponents()
|
||
.map(({ pkg, comp }) => {
|
||
const ms = kws.map((kw) => ({ kw, ...matchOf(kw, comp) }));
|
||
if (ms.some((m) => !m.score)) return null; // 任一词不中即淘汰
|
||
const weakest = ms.reduce((a, b) => (b.score < a.score ? b : a)); // 排序与提示都看最弱那一词
|
||
return { pkg, comp, score: weakest.score, field: weakest.label, keyword: weakest.kw };
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => b.score - a.score || String(a.comp.id).localeCompare(String(b.comp.id)));
|
||
const recipeHits = parseRecipes() // 配方域一并搜:跨组件诉求(怎么拼)的直达载体
|
||
.map((r) => ({ r, m: recipeMatch(kws, r) }))
|
||
.filter((x) => x.m)
|
||
.sort((a, b) => b.m.weight - a.m.weight || Number(a.r.id) - Number(b.r.id));
|
||
// 常规两域全空才动用同义词:命中正常时不掺水,免得「搜 select 冒出一堆 drawer」
|
||
if (!hits.length && !recipeHits.length) {
|
||
for (const x of fallbackBySynonym(kws)) hits.push({ pkg: x.pkg, comp: x.comp, score: 1, field: '同义词', keyword: kws.join(' ') });
|
||
}
|
||
if (!hits.length && !recipeHits.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(
|
||
{
|
||
components: hits.map((h) => ({ package: h.pkg.name, ...h.comp, matched: { field: h.field, keyword: h.keyword } })),
|
||
recipes: recipeHits.map(({ r, m }) => ({ id: r.id, title: r.title, components: recipeComps(r), matched: { field: m.label, keyword: m.keyword } })),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
}
|
||
for (const h of hits) {
|
||
const why = h.field === '同义词' ? ` ← 同义词「${h.keyword}」` : h.field === 'id' || h.field === '标题' ? '' : ` ← ${h.field}含「${h.keyword}」`;
|
||
console.log(`${String(h.comp.id).padEnd(16)} ${h.comp.title ?? ''} (${h.pkg.name})${why}`);
|
||
}
|
||
for (const { r, m } of recipeHits) {
|
||
const why = m.label === '配方标题' ? '' : ` ← ${m.label}含「${m.keyword}」`;
|
||
console.log(`配方 ${r.id.padEnd(10)} ${r.title}${why} → 看骨架:ua recipes ${r.id}`);
|
||
}
|
||
const parts = [];
|
||
if (hits.length) parts.push(`${hits.length} 个组件`);
|
||
if (recipeHits.length) parts.push(`${recipeHits.length} 条配方`);
|
||
const viaSyn = hits.length && hits.every((h) => h.field === '同义词'); // 全靠同义回退才算「口语命中」
|
||
console.log(`\n共 ${parts.join('、')}命中${viaSyn ? '(常规字段无命中,按同义词表回退)' : ''};查参数:ua info <组件id>,看骨架:ua recipes <编号>`);
|
||
}
|
||
|
||
/*** 输出包或组件信息:默认人读摘要,--json 输出原始 JSON;name 支持逗号分隔一次查多个(单值保持原输出结构不破坏脚本) ***/
|
||
function cmdInfo(name, args = []) {
|
||
const { packages } = loadIndex();
|
||
const asJson = args.includes('--json');
|
||
const asMd = args.includes('--md');
|
||
const names = name.split(',').map((s) => s.trim()).filter(Boolean); // 批量:逗号分隔多组件一次拿全,省 Agent 往返
|
||
const resolve = (n) => resolveEntry(packages, n); // 单个名字 → 包或组件条目;都没命中返回 null 交由调用方报错
|
||
if (names.length === 1) {
|
||
const r = resolve(names[0]);
|
||
if (!r) {
|
||
const near = nearIds(names[0]);
|
||
console.error(`not found: ${names[0]}(包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`);
|
||
return process.exit(1);
|
||
}
|
||
if (r.kind === 'pkg') {
|
||
if (asJson) return console.log(JSON.stringify(r.pkg, null, 2));
|
||
return console.log(
|
||
`${r.pkg.name} v${r.pkg.version} [${r.pkg.type}] ${r.pkg.description}\n(--json 看完整清单;按组件查:ua info <组件id>)`,
|
||
);
|
||
}
|
||
const { hit } = r;
|
||
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 recipeTail = related.length ? `\n\n出现在配方:${related.map((x) => `${x.id}(${x.title})`).join('、')}(ua mcp 的 get_recipe 可取骨架)` : '';
|
||
if (asMd) return console.log(formatComponentMd(hit.pkg, hit.comp) + recipeTail); // Markdown 片段:贴进文档 / issue / 回复时不用手抄参数表
|
||
return console.log(formatComponent(hit.pkg, hit.comp) + recipeTail);
|
||
}
|
||
const resolved = names.map((n) => resolve(n)); // 批量路径:先全量解析,任一失败整条拒绝(输出一半再报错会让脚本误判成功)
|
||
const bad = names.filter((n, i) => !resolved[i]);
|
||
if (bad.length) {
|
||
const near = nearIds(bad[0]);
|
||
console.error(`not found: ${bad.join(' / ')}(第 ${names.indexOf(bad[0]) + 1} 个,包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`);
|
||
return process.exit(1);
|
||
}
|
||
const compMd = (r) => { // 批量输出统一用组件片段格式:包条目退化为标题行
|
||
if (r.kind === 'pkg') return `## ${r.pkg.name}(${r.pkg.type})\n\n${r.pkg.description}`;
|
||
const related = recipesFor(r.hit.comp.id);
|
||
const tail = related.length ? `\n\n> 出现在配方:${related.map((x) => `${x.id}(${x.title})`).join('、')}` : '';
|
||
return formatComponentMd(r.hit.pkg, r.hit.comp) + tail;
|
||
};
|
||
if (asJson) {
|
||
return console.log(JSON.stringify(resolved.map((r) => (r.kind === 'pkg' ? r.pkg : { package: r.hit.pkg.name, ...r.hit.comp, recipes: recipesFor(r.hit.comp.id) })), null, 2));
|
||
}
|
||
console.log(resolved.map(compMd).join('\n\n---\n\n')); // 片段间以水平线分隔,方便人读与脚本按线切块
|
||
}
|
||
|
||
// ---- 静态文档站生成 ----
|
||
|
||
/*** 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 recipeData = JSON.stringify(parseRecipes().map((r) => ({ id: r.id, title: r.title, components: recipeComps(r), code: r.code, notes: r.notes }))).replace(/</g, '\\u003c'); // 配方随文档站一起生成:人类也能在浏览器里翻骨架与要点
|
||
const generatedAt = esc(index.generatedAt);
|
||
// 文档站外壳配色从 tokens.json 派生:手写十六进制会跟令牌脱节(改暗色 accent 后这里还留着旧值,
|
||
// 页面与演示 iframe 两个蓝各显示一种),派生后单一来源,改令牌即全站跟随。
|
||
const shell = shellPalette(tokens);
|
||
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:${shell.bg};--surface:${shell.surface};--text:${shell.text};--muted:${shell.muted};--border:${shell.border};--accent:${shell.accent};--code-bg:${shell.codeBg};--code-text:${shell.codeText}}
|
||
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:${shell.darkBg};--surface:${shell.darkSurface};--text:${shell.darkText};--muted:${shell.darkMuted};--border:${shell.darkBorder};--accent:${shell.darkAccent}}}
|
||
:root[data-theme=dark]{--bg:${shell.darkBg};--surface:${shell.darkSurface};--text:${shell.darkText};--muted:${shell.darkMuted};--border:${shell.darkBorder};--accent:${shell.darkAccent}}
|
||
*{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)}
|
||
#theme{flex:none;padding:5px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:12px;cursor:pointer}
|
||
#theme:hover{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}
|
||
details.recipe{border:1px dashed var(--border);border-radius:8px;margin:8px 0;background:var(--bg)}
|
||
details.recipe summary{cursor:pointer;padding:8px 12px;font-weight:500;font-size:13px}
|
||
details.recipe[open] summary{border-bottom:1px dashed var(--border)}
|
||
details.recipe .body{padding:10px 12px}
|
||
details.recipe .tag{margin:0 6px 4px 0}
|
||
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}
|
||
.comp h4{display:flex;align-items:center;gap:8px;justify-content:space-between}
|
||
.copy{margin-left:auto;flex:none;padding:3px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--muted);font:inherit;font-size:12px;cursor:pointer;transition:color .15s,border-color .15s}
|
||
.copy:hover{color:var(--text);border-color:var(--accent)}
|
||
.copy:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.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"><button id="theme" type="button" aria-label="切换亮暗主题">亮色</button></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 RECIPES=${recipeData};
|
||
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>';
|
||
}
|
||
var ALLCOMPS=[];
|
||
DATA.forEach(function(p){ (p.docs&&p.docs.components||[]).forEach(function(c){ ALLCOMPS.push(c); }); });
|
||
var TICK=String.fromCharCode(96); // 反引号:生成器本身是模板串,这里不写裸反引号,避免把模板串提前截断
|
||
function mdOf(c){
|
||
var bar=String.fromCharCode(124); // 竖线:Markdown 表格分隔符的转义源
|
||
var cell=function(v){ return String(v||'').split(bar).join('\\\\'+bar); }; // 输出 \\| 给 Markdown 表格转义
|
||
var out='### '+c.id+' · '+(c.title||c.id)+'\\n\\n';
|
||
if(c.props&&c.props.length){
|
||
out+='| 参数 | 类型 | 必填 | 说明 |\\n| --- | --- | --- | --- |\\n';
|
||
c.props.forEach(function(p){ out+='| '+TICK+cell(p.n)+TICK+' | '+TICK+cell(p.t)+TICK+' | '+(p.r?'是':'否')+' | '+cell(p.d)+' |\\n'; });
|
||
out+='\\n';
|
||
}
|
||
if(c.steps&&c.steps.length){
|
||
out+='**用法**\\n\\n';
|
||
c.steps.forEach(function(s,i){ out+=(i+1)+'. '+s+'\\n'; });
|
||
out+='\\n';
|
||
}
|
||
if(c.example) out+=TICK+TICK+TICK+'ts\\n'+c.example+'\\n'+TICK+TICK+TICK+'\\n';
|
||
return out;
|
||
}
|
||
function legacyCopy(text){
|
||
var ta=document.createElement('textarea');
|
||
ta.value=text; ta.setAttribute('readonly','');
|
||
ta.style.position='fixed'; ta.style.opacity='0';
|
||
document.body.appendChild(ta); ta.select();
|
||
try { document.execCommand('copy'); } catch(e) {}
|
||
document.body.removeChild(ta);
|
||
}
|
||
document.addEventListener('click',function(e){
|
||
var btn=e.target.closest('[data-copy]');
|
||
if(!btn) return;
|
||
var c=ALLCOMPS.filter(function(x){return x.id===btn.getAttribute('data-copy')})[0];
|
||
if(!c) return;
|
||
var done=function(){ var old=btn.textContent; btn.textContent='已复制'; setTimeout(function(){ btn.textContent=old; },1200); };
|
||
// 剪贴板 API 在 file:// 或未授权时会 reject,退回 textarea + execCommand
|
||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(mdOf(c)).then(done).catch(function(){ legacyCopy(mdOf(c)); done(); });
|
||
} else { legacyCopy(mdOf(c)); done(); }
|
||
});
|
||
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||'')
|
||
+'<button class="copy" type="button" data-copy="'+esc(c.id)+'" title="复制为 Markdown 参数表,可直接贴进文档或 issue">复制参数表</button></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 recipesCard(){
|
||
if(!RECIPES.length)return '';
|
||
var items=RECIPES.map(function(r){
|
||
var chips=(r.components||[]).map(function(c){return '<span class="tag">'+esc(c)+'</span>'}).join('');
|
||
var notes=(r.notes||[]).map(function(n){return '<li>'+esc(n)+'</li>'}).join('');
|
||
return '<details class="recipe"><summary>配方 '+esc(r.id)+':'+esc(r.title)+'</summary><div class="body"><div>'+chips+'</div><pre><code>'+esc(r.code)+'</code></pre>'+(notes?'<ul>'+notes+'</ul>':'')+'</div></details>';
|
||
}).join('');
|
||
return '<section class="card" id="recipes"><h2>组合配方 <span class="badge">recipes</span></h2><p class="desc">跨组件拼装的最小可运行骨架与要点,展开即看代码;Agent 侧对应 MCP get_recipe / ua recipes。</p>'+items+'</section>';
|
||
}
|
||
function render(list,q){
|
||
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())+(q?'':recipesCard()); // 搜索态不渲染配方卡:它不属于任何包,避免过滤结果被无关区块稀释
|
||
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="#recipes">配方</a><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,q);
|
||
});
|
||
// —— 主题:默认跟随系统(不写 data-theme),用户选择后持久化;iframe 演示带参重载联动 ——
|
||
var THEME_KEY='ua-docs-theme';
|
||
var themeBtn=document.getElementById('theme');
|
||
function paintTheme(t){ // t: 'light'|'dark'|null,null 表示跟随系统
|
||
if(t)document.documentElement.setAttribute('data-theme',t);else document.documentElement.removeAttribute('data-theme');
|
||
themeBtn.textContent=t==='dark'?'暗色':'亮色';
|
||
}
|
||
var savedTheme=null;try{savedTheme=localStorage.getItem(THEME_KEY)}catch(e){}
|
||
paintTheme(savedTheme==='dark'||savedTheme==='light'?savedTheme:null);
|
||
themeBtn.addEventListener('click',function(){
|
||
var cur=document.documentElement.getAttribute('data-theme')||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');
|
||
var next=cur==='dark'?'light':'dark';
|
||
try{localStorage.setItem(THEME_KEY,next)}catch(e){}
|
||
paintTheme(next);
|
||
document.querySelectorAll('iframe.demo').forEach(function(f){ // 带参重载:demo 读 ?theme= 切换
|
||
var parts=f.src.split('#');var anchor=parts.length>1?'#'+parts.slice(1).join('#'):'';
|
||
f.src=parts[0].split('?')[0]+'?theme='+next+anchor;
|
||
});
|
||
});
|
||
</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;
|
||
}
|
||
|
||
/*** 从配方正文里认出用到的组件 id(词边界匹配,避免 toast 命中 toastError),CLI 与 MCP 共用一份 ***/
|
||
function recipeComps(r) {
|
||
const ids = collectComponents().map((x) => x.comp.id);
|
||
const body = `${r.code}\n${r.text}`; // 代码块 + 正文一起扫:要点里也会点名组件
|
||
return ids.filter((id) => new RegExp('\\b' + id + '\\b').test(body)).sort();
|
||
}
|
||
|
||
/*** 列出组合配方(编号 / 标题 / 用到的组件),或按编号打印单条配方全文;MCP 没注册时也能查配方 ***/
|
||
function cmdRecipes(args = []) {
|
||
const id = args.find((a) => !a.startsWith('--'));
|
||
const list = parseRecipes();
|
||
if (!list.length) return console.error('recipes.md 里没有配方');
|
||
const asJson = args.includes('--json');
|
||
if (id) {
|
||
const hit = list.find((r) => r.id === id);
|
||
if (!hit) {
|
||
console.error(`recipe not found: ${id}(可用 ${list.map((r) => r.id).join(' / ')})`);
|
||
return process.exit(1);
|
||
}
|
||
if (asJson) return console.log(JSON.stringify({ id: hit.id, title: hit.title, components: recipeComps(hit), code: hit.code, notes: hit.notes }, null, 2));
|
||
return console.log(`# 配方 ${hit.id}:${hit.title}
|
||
用到的组件:${recipeComps(hit).join(' / ')}
|
||
|
||
${hit.text}`);
|
||
}
|
||
if (asJson) return console.log(JSON.stringify(list.map((r) => ({ id: r.id, title: r.title, components: recipeComps(r) })), null, 2));
|
||
for (const r of list) console.log(`${String(r.id).padEnd(4)} ${r.title} [${recipeComps(r).join(' / ')}]`);
|
||
console.log(`
|
||
共 ${list.length} 条配方;看骨架与要点:ua recipes <编号>(MCP 里对应 get_recipe)`);
|
||
}
|
||
|
||
/*** 渲染 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('--unused')) {
|
||
// 反向视图:令牌定义了但没有任何组件 / 样式引用它 —— 令牌面越铺越大时靠这个收口,
|
||
// 阈值口径只看皮肤样式表(ui.css 自身定义的自设变量也算消费),演示页与文档站不属于组件消费面。
|
||
// 消费面 = 皮肤样式表 + 令牌自身的派生引用(--ua-shadow-highlight 引用 --ua-color-highlight 也算消费)
|
||
const sources = [path.join(ROOT, 'packages', 'skin', 'ui', 'ui.css'), path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css')];
|
||
const used = new Set(sources
|
||
.filter((file) => fs.existsSync(file))
|
||
.flatMap((file) => fs.readFileSync(file, 'utf8').match(/var\(\s*(--[\w-]+)/g) ?? [])
|
||
.map((m) => m.replace(/var\(\s*/, '')));
|
||
const dead = list.filter((t) => !used.has(t.cssVar));
|
||
if (args.includes('--json')) {
|
||
console.log(JSON.stringify({ total: list.length, unused: dead.map((t) => t.cssVar) }, null, 2));
|
||
} else {
|
||
for (const t of dead) console.log(`${t.cssVar} ${t.value}`);
|
||
console.log(`共 ${list.length} 个令牌,其中 ${dead.length} 个没被皮肤包引用${dead.length ? '(确认是否还需要,删的时候亮暗与 tokens.json 一起改)' : ''}`);
|
||
}
|
||
return;
|
||
}
|
||
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 or more components by comma-separated ids (e.g. "button,select"); format=md returns paste-ready Markdown blocks (multi-query blocks are separated by horizontal rules)',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string', description: 'component id (e.g. inputTag), package name (e.g. @ui-arch/table-core), or comma-separated ids for batch query (e.g. button,select)' },
|
||
format: { type: 'string', enum: ['json', 'md'], description: 'json (default) returns the manifest entry (array for batch); md returns a paste-ready Markdown block (title + props table + usage + example)' },
|
||
},
|
||
required: ['name'],
|
||
},
|
||
},
|
||
{
|
||
name: 'search_components',
|
||
description: 'Search by keyword: returns matched packages, matched components (id/title/prop names/prop descriptions/steps) and matched recipes (title/components/notes)',
|
||
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(',')}`); // 常量是文档校验的基准,先自证
|
||
}
|
||
|
||
const recipeComponents = (r) => recipeComps(r).filter((id) => compIds.includes(id)); // 与 CLI recipes 共用同一抽取逻辑
|
||
|
||
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 names = String(args.name).split(',').map((s) => s.trim()).filter(Boolean); // 批量:逗号分隔一次拿全多个组件,与 ua info 同口径
|
||
const resolved = names.map((n) => resolveEntry(packages, n));
|
||
const bad = names.filter((n, i) => !resolved[i]);
|
||
if (bad.length) throw new Error(`not found: ${bad.join(' / ')}(第 ${names.indexOf(bad[0]) + 1} 个,包名与组件 id 都没命中)`);
|
||
const compMd = (r) => { // md 模式组件片段 + 配方尾注;包条目退化为标题行
|
||
if (r.kind === 'pkg') return `## ${r.pkg.name}(${r.pkg.type})\n\n${r.pkg.description}`;
|
||
const related = recipesFor(r.hit.comp.id);
|
||
const tail = related.length ? `\n\n出现在配方:${related.map((x) => `${x.id}(${x.title})`).join('、')}` : '';
|
||
return formatComponentMd(r.hit.pkg, r.hit.comp) + tail;
|
||
};
|
||
if (resolved.length > 1) {
|
||
if (args.format === 'md') return resolved.map(compMd).join('\n\n---\n\n'); // 多段片段以水平线分隔
|
||
return resolved.map((r) => (r.kind === 'pkg' ? r.pkg : { package: r.hit.pkg.name, ...r.hit.comp, recipes: recipesFor(r.hit.comp.id) }));
|
||
}
|
||
const r = resolved[0];
|
||
if (r.kind === 'pkg') return r.pkg;
|
||
if (args.format === 'md') return compMd(r); // 与 ua info --md 共用同一渲染:Agent 拿到就能直接贴进回复 / 文档,不必自己排参数表
|
||
return { package: r.hit.pkg.name, ...r.hit.comp, recipes: recipesFor(r.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 }) => searchFields(comp).some((f) => f.text.includes(q))) // 与 CLI search 同一份字段表
|
||
.map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title }));
|
||
const recs = parseRecipes() // 配方域:跨组件诉求(怎么拼)的直达载体,与 CLI search 同一套三层字段
|
||
.map((r) => ({ r, m: recipeMatch([q], r) }))
|
||
.filter((x) => x.m)
|
||
.map(({ r, m }) => ({ id: r.id, title: r.title, components: recipeComps(r), matched: { field: m.label, keyword: m.keyword } }));
|
||
if (!pkgs.length && !comps.length && !recs.length) {
|
||
// 三域全空时与 CLI 同源回退:Agent 常拿到中文口语词(「弹窗」),不兜底就只能反复试错
|
||
const syn = fallbackBySynonym([q]).map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title, matched: { field: '同义词', keyword: q } }));
|
||
if (!syn.length) throw new Error(`no match: ${args.query}`);
|
||
return { packages: pkgs, components: syn, recipes: recs };
|
||
}
|
||
return { packages: pkgs, components: comps, recipes: recs };
|
||
}
|
||
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,
|
||
// 字符串结果原样返回:format=md 拿到的应是可读 Markdown,而不是被 JSON 转义成一行的
|
||
|
||
result: { content: [{ type: 'text', text: typeof result === 'string' ? result : 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;
|
||
}
|
||
|
||
/*** 滚动条覆盖校验:凡是 overflow: auto|scroll 的 .ua-* 容器,都必须出现在「scrollbar-width」那条统一规则里 ***/
|
||
function checkScrollCoverage(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 css = fs.readFileSync(path.join(dir, name), 'utf8').replace(/\r\n/g, '\n').replace(/\/\*[\s\S]*?\*\//g, '');
|
||
const rules = []; // 括号配平扫出的「选择器 + 规则体」;正则切不了嵌套(@media 里有花括号)
|
||
let i = 0;
|
||
let sel = '';
|
||
while (i < css.length) {
|
||
if (css[i] === '{') {
|
||
const start = i + 1;
|
||
let depth = 1;
|
||
i += 1;
|
||
while (i < css.length && depth > 0) {
|
||
if (css[i] === '{') depth += 1;
|
||
else if (css[i] === '}') depth -= 1;
|
||
i += 1;
|
||
}
|
||
rules.push({ sel: sel.trim(), body: css.slice(start, i - 1) });
|
||
sel = '';
|
||
continue;
|
||
}
|
||
if (css[i] === '}') { sel = ''; i += 1; continue; } // 顶层多余的右括号:重置选择器
|
||
sel += css[i];
|
||
i += 1;
|
||
}
|
||
const containers = new Set(); // 滚得动的单类容器
|
||
for (const rule of rules) {
|
||
if (!/overflow(-[xy])?:\s*(auto|scroll)/.test(rule.body)) continue;
|
||
for (const part of rule.sel.split(',')) {
|
||
const hit = part.trim().match(/^\.(ua-[a-z0-9-]+)$/);
|
||
if (hit) containers.add(hit[1]);
|
||
}
|
||
}
|
||
if (!containers.size) continue;
|
||
const group = rules.find((rule) => rule.body.includes('scrollbar-width')); // 细条 + 令牌拇指那条统一规则
|
||
if (!group) {
|
||
issues.push(`${pkg.dir}/${name}: 未找到滚动条统一规则(scrollbar-width),新增滚动容器会退回系统原生粗条`);
|
||
continue;
|
||
}
|
||
const covered = new Set([...group.sel.matchAll(/\.(ua-[a-z0-9-]+)/g)].map((m) => m[1]));
|
||
for (const cls of containers) {
|
||
if (covered.has(cls)) continue;
|
||
issues.push(`${pkg.dir}/${name}: 滚动容器 .${cls} 未纳入滚动条统一规则(暗色下会露出原生亮条)`);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 重复关键帧校验:两份 @keyframes 的帧体完全一致 = 复制粘贴的动效,语义上只应存在一份(名字不同也算) ***/
|
||
function checkDuplicateKeyframes(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 src = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||
const seen = new Map(); // 帧体(去空白归一)-> 首个声明它的动画名
|
||
for (const hit of src.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
|
||
const [, anim, body] = hit;
|
||
const norm = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim(); // 注释与空白不参与比对
|
||
const first = seen.get(norm);
|
||
if (first) {
|
||
issues.push(`${pkg.dir}/${name}: @keyframes ${anim} 与 ${first} 帧体完全相同(复制粘贴的动效,合并成一个再复用)`);
|
||
continue;
|
||
}
|
||
seen.set(norm, anim);
|
||
}
|
||
}
|
||
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} 个文件`);
|
||
}
|
||
|
||
/*** 令牌双源一致性:tokens.json 的 color 键(camelCase)与 tokens.css 的 --ua-color-* 必须同名同值(亮/暗两套都比) ***/
|
||
function checkTokenParity(issues) {
|
||
const jsonPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json');
|
||
const cssPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css');
|
||
if (!fs.existsSync(jsonPath) || !fs.existsSync(cssPath)) return; // 卫语句:任一缺失交给其它校验兜底
|
||
const tokens = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||
if (!tokens.color) return; // 卫语句:没有颜色分组(旧版本令牌表)不参与本校验
|
||
const src = fs.readFileSync(cssPath, '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 root = grab((src.match(/:root\s*\{([\s\S]*?)\}/) ?? [, ''])[1]);
|
||
const dark = grab((src.match(/\[data-theme='dark'\]\s*\{([\s\S]*?)\}/) ?? [, ''])[1]);
|
||
const kebab = (key) => key.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/([a-zA-Z])(\d)/g, '$1-$2').toLowerCase();
|
||
const jsonVars = new Set(); // JSON 声明的颜色变量名,用于反查 CSS 里多出来的
|
||
for (const [key, pair] of Object.entries(tokens.color)) {
|
||
const name = `--ua-color-${kebab(key)}`;
|
||
jsonVars.add(name);
|
||
if (!root.has(name)) {
|
||
issues.push(`tokens: tokens.json 的 color.${key} 在 tokens.css :root 里没有 ${name}(文档色板会列出不存在的令牌)`);
|
||
continue; // 名字都不存在,值比对没意义
|
||
}
|
||
if (root.get(name) !== pair.light) issues.push(`tokens: ${name} 亮色值不一致(tokens.json "${pair.light}" vs tokens.css "${root.get(name)}")`);
|
||
if (dark.has(name) && dark.get(name) !== pair.dark) issues.push(`tokens: ${name} 暗色值不一致(tokens.json "${pair.dark}" vs tokens.css "${dark.get(name)}")`);
|
||
}
|
||
for (const name of root.keys()) {
|
||
if (!name.startsWith('--ua-color-')) continue; // 卫语句:只对颜色分组做双向比对
|
||
if (!jsonVars.has(name)) issues.push(`tokens: tokens.css 的 ${name} 未在 tokens.json 的 color 里登记(文档色板与 ua tokens 会漏项)`);
|
||
}
|
||
}
|
||
|
||
/*** 暗色令牌齐全校验:每个 --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 rootNames) {
|
||
if (!/^--ua-(shadow|focus)/.test(n)) 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
|
||
const seen = new Set(); // 条目 id 去重集合:复制粘贴条目忘了改 id 时会撞上
|
||
for (const item of items) {
|
||
if (seen.has(item.id)) issues.push(`backlog: id ${item.id} 重复(多半是复制条目时忘了改 id)`);
|
||
seen.add(item.id);
|
||
}
|
||
const seq = items.map((i) => (typeof i.id === 'string' && /^g\d+$/.test(i.id) ? Number(i.id.slice(1)) : NaN)); // g 编号序列
|
||
for (let k = 1; k < seq.length; k++) {
|
||
if (Number.isNaN(seq[k]) || Number.isNaN(seq[k - 1])) continue; // 非 g 编号的条目不参与顺序校验
|
||
if (seq[k] <= seq[k - 1]) issues.push(`backlog: ${items[k].id} 排在 ${items[k - 1].id} 后面(编号必须严格递增,逆序一般是插错了位置)`);
|
||
}
|
||
let prevTs = ''; // 上一条留痕时间,用于单调性比对
|
||
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') {
|
||
if (done.has(row.id)) issues.push(`iterations.log: ${row.id} 有两条 done 留痕(一条迭代只该留一次痕迹)`);
|
||
done.add(row.id);
|
||
}
|
||
const ts = String(row.ts ?? '');
|
||
if (!/\+08:00$/.test(ts)) issues.push(`iterations.log: ${row.id} 的 ts 缺少 +08:00 偏移(必须取 date 实测的本地时间)`);
|
||
else if (prevTs && ts < prevTs) issues.push(`iterations.log: ${row.id} 的 ts 早于上一条(时间倒流,多半是手写或按 UTC 换算)`);
|
||
else if (Date.parse(ts) > Date.now() + 300000) issues.push(`iterations.log: ${row.id} 的 ts 落在未来(超出 5 分钟容差)`);
|
||
prevTs = ts;
|
||
}
|
||
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 (r.code && !recipeComps(r).length) {
|
||
issues.push(`配方 ${r.id}(${r.title})认不出任何已注册组件:组件名可能拼错,MCP 的 list_recipes 会把它列成空组件表`);
|
||
}
|
||
});
|
||
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;
|
||
}
|
||
|
||
/*** 视觉回归抽查清单校验:ITERATION.md 的清单必须十八个代表组件齐全、每条带合法维度标签、条目数达标 ***/
|
||
function checkVisualChecklist() {
|
||
const DIMS = ['边框', '阴影', '过渡', '焦点环', '暗色']; // 五个必覆盖维度
|
||
const REQUIRED = ['button', 'textField', 'select', 'modal', 'dataTable', 'toast', 'layout', 'jsonViewer', 'diffView', 'codeBlock', 'signaturePad', 'heatmap', 'sparkline', 'commandPalette', 'noticeBar', 'progressRing', 'sortableList', 'masonry']; // 十八个代表组件:高频六个 + 容器/数据展示/表单录入三类 + 后期新增的浮层类(命令面板)与反馈类(通知条、环形进度)+ 交互类(拖拽排序)与布局类(瀑布流)
|
||
const MIN_PER_GROUP = 3; // 每个组件至少三条可勾选要点
|
||
const MIN_TOTAL = REQUIRED.length * MIN_PER_GROUP; // 总条目下限由组件数推导:加抽查位不用再手改这个数
|
||
const file = path.join(ROOT, 'ITERATION.md');
|
||
if (!fs.existsSync(file)) return ['视觉回归抽查清单:找不到 ITERATION.md'];
|
||
const lines = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n');
|
||
const start = lines.findIndex((l) => /^##\s+视觉回归抽查清单/.test(l));
|
||
if (start === -1) return ['视觉回归抽查清单:ITERATION.md 缺少《视觉回归抽查清单》章节'];
|
||
const rest = lines.slice(start + 1);
|
||
const stop = rest.findIndex((l) => /^##\s+/.test(l)); // 下一个二级标题即章节结束
|
||
const body = stop === -1 ? rest : rest.slice(0, stop);
|
||
const issues = [];
|
||
const groups = new Map(); // 组件 id → 该段的维度标签列表
|
||
let current = null;
|
||
for (const line of body) {
|
||
const head = line.match(/^###\s+(\S+)/);
|
||
if (head) {
|
||
current = head[1];
|
||
if (!groups.has(current)) groups.set(current, []);
|
||
continue;
|
||
}
|
||
const item = line.match(/^-\s+\[[ x]\]\s+【([^】]+)】/);
|
||
if (!item) continue;
|
||
if (!current) {
|
||
issues.push(`视觉回归抽查清单:条目「${line.trim().slice(0, 24)}」没有归到任何 ### 组件段下`);
|
||
continue;
|
||
}
|
||
if (!DIMS.includes(item[1])) issues.push(`视觉回归抽查清单:${current} 的条目维度「${item[1]}」不在五类之内(${DIMS.join(' / ')})`);
|
||
groups.get(current).push(item[1]);
|
||
}
|
||
for (const id of REQUIRED) {
|
||
const items = groups.get(id);
|
||
if (!items) issues.push(`视觉回归抽查清单:缺少 ### ${id} 段`);
|
||
else if (items.length < MIN_PER_GROUP) issues.push(`视觉回归抽查清单:${id} 只有 ${items.length} 条要点,至少 ${MIN_PER_GROUP} 条`);
|
||
}
|
||
const total = [...groups.values()].reduce((sum, items) => sum + items.length, 0);
|
||
if (total < MIN_TOTAL) issues.push(`视觉回归抽查清单:合计 ${total} 条要点,至少 ${MIN_TOTAL} 条`);
|
||
const covered = new Set([...groups.values()].flat());
|
||
const missed = DIMS.filter((d) => !covered.has(d));
|
||
if (missed.length) issues.push(`视觉回归抽查清单:维度未覆盖 ${missed.join(' / ')}(五类都要有)`);
|
||
return issues;
|
||
}
|
||
|
||
/*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/
|
||
/*** 校验输入控件的可访问名:role=combobox / slider 与原生 range 不自带名字,缺 aria-label 时读屏只念「编辑框 / 滑块」
|
||
* 只查这三类「必须有名字」的控件,包裹式 <label> 与 aria-labelledby 都算通过,避免误伤复用标签的写法 ***/
|
||
function checkAriaNames(pkgs) {
|
||
const issues = [];
|
||
for (const pkg of pkgs) {
|
||
if (pkg.type !== 'skin-dom') continue; // 只有皮肤包会产生 DOM
|
||
const srcDir = path.join(ROOT, pkg.dir, 'src');
|
||
if (!fs.existsSync(srcDir)) continue;
|
||
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) {
|
||
const lines = fs.readFileSync(path.join(srcDir, file), 'utf8').split(/\r?\n/); // 归一换行:CRLF 文件不会把一行拆成两半
|
||
lines.forEach((line, i) => {
|
||
if (!/role: '(combobox|slider)'|type: 'range'/.test(line)) return; // 卫语句:只关心这三类无名即失声的控件
|
||
let head = i; // 回退到 h('input' / h('textarea' / h('select' 那一行:一处控件的属性只在同一次调用里
|
||
while (head > 0 && !/h\('(input|textarea|select)'/.test(lines[head])) head--;
|
||
let tail = i; // 前进到这次调用的收尾行(`})` 或 `}) as ...`),窗口只取这一个创建块
|
||
while (tail < lines.length - 1 && !/\}\)/.test(lines[tail])) tail++;
|
||
const block = lines.slice(head, tail + 1).join(' ');
|
||
if (/aria-label|aria-labelledby/.test(block)) return; // 同名两法都算过(后者由外部 label 提供)
|
||
if (/setAttribute\('aria-label'/.test(lines.slice(tail + 1, tail + 4).join(' '))) return; // 也认「建完立刻 setAttribute」的写法
|
||
issues.push(`${pkg.name}/${file}:${i + 1} 的 ${line.trim().slice(0, 40)} 缺可访问名(role=combobox / slider 与 range 都不会从内容里取名,读屏只念控件类型)`);
|
||
});
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 校验 toast 的 live 区域:动态插入的通知读屏听不到容器外的变化,缺 aria-live / alert 抢占等于哑巴播报 ***/
|
||
function checkToastLive(pkgs) {
|
||
const issues = [];
|
||
for (const pkg of pkgs) {
|
||
if (pkg.type !== 'skin-dom') continue;
|
||
const toastPath = path.join(ROOT, pkg.dir, 'src', 'toast.ts');
|
||
if (!fs.existsSync(toastPath)) continue;
|
||
const src = fs.readFileSync(toastPath, 'utf8');
|
||
if (!/aria-live/.test(src)) issues.push(`${pkg.name}/src/toast.ts 缺 aria-live 容器:toast 是动态插入 DOM 的,没有 live 区域读屏用户感知不到通知出现`);
|
||
if (!/role: props\.variant === 'danger' \? 'alert' : 'status'/.test(src)) issues.push(`${pkg.name}/src/toast.ts 缺 alert 抢占播报:error 级别必须用 role=alert 打断读屏,其余才降级 polite`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 校验表单错误关联:只标 aria-invalid 时读屏只说「无效」,原因(哪条规则没过)听不到 ***/
|
||
function checkFormErrorLink(pkgs) {
|
||
const issues = [];
|
||
for (const pkg of pkgs) {
|
||
if (pkg.type !== 'skin-dom') continue;
|
||
const formPath = path.join(ROOT, pkg.dir, 'src', 'form.ts');
|
||
if (!fs.existsSync(formPath)) continue;
|
||
const src = fs.readFileSync(formPath, 'utf8');
|
||
if (!/id: `\$\{fid\}-error`/.test(src)) issues.push(`${pkg.name}/src/form.ts 的错误区缺派生 id:没有 id 就无法被 aria-describedby 指过来`);
|
||
if (!/setAttribute\('aria-describedby', described\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 aria-describedby 落笔:校验失败时读屏只会念「无效」,念不出「至少 6 个字符」这类原因`);
|
||
if (!/removeAttribute\('aria-describedby'\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 aria-describedby 清除:校验通过后关联还指着错误区,读屏会把空节点当说明`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
|
||
function checkSynonyms(ids) {
|
||
const issues = [];
|
||
const seen = new Map(); // 词 → 已登记的组件 id(用于报重复)
|
||
for (const group of SYNONYMS) {
|
||
const tag = `同义词「${group.words[0]}」`;
|
||
if (!(group.words ?? []).length) issues.push('同义词表有一组缺少 words');
|
||
if (!(group.ids ?? []).length) issues.push(`${tag}: 缺少 ids`);
|
||
for (const id of group.ids ?? []) if (!ids.has(id)) issues.push(`${tag}: 目标组件 ${id} 不在清单里(改名后忘改同义词表)`);
|
||
for (const w of group.words ?? []) {
|
||
if (seen.has(w)) issues.push(`${tag}: 词「${w}」已归入同义词「${seen.get(w)}」(一词只许一组,否则回退结果随表序变化)`);
|
||
seen.set(w, group.words[0]);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
/*** 校验清单 ↔ 文档 ↔ 演示一致,任一项不符即退出码 1(口径见 AGENTS.md 「门禁一条龙」) ***/
|
||
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) ?? []); // 已定义的区块锚点
|
||
for (const raw of demoSource.match(/sec-[A-Za-z0-9-]+/g) ?? []) {
|
||
if (/[A-Z]/.test(raw)) issues.push(`demo 锚点 ${raw} 含大写字母:锚点口径只认 sec-[a-z0-9-](大写会被锚点集合静默吞掉, manifests 登记后会误报「缺 section」)`); // 防大写锚点静默失踪(g234 实踩)
|
||
}
|
||
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(...checkDuplicateKeyframes(pkg));
|
||
issues.push(...checkScrollCoverage(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 < 4) issues.push(`${tag}: steps 少于 4 条(每件至少给 4 步:怎么传 / 怎么联动 / 边界行为 / 什么时候别用它)`);
|
||
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 锚点不算演示)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 反向校验:演示里定义的每个 section 都得被某个组件认领,否则就是「孤儿演示」——区块加了、清单没登记
|
||
// 只认 section( 的定义处,不认 href: '#sec-x' 这类引用(anchor 演示里就有故意指向不存在区块的链接)
|
||
const claimedDemos = new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.demo)).filter(Boolean));
|
||
for (const [, secId] of demoSource.matchAll(/section\(\s*'(sec-[a-z0-9-]+)'/g)) {
|
||
if (!claimedDemos.has(secId)) issues.push(`apps/demo/main.ts: 演示区块 ${secId} 没有任何组件登记为 demo(孤儿演示:补 docs.components 的 demo,或删掉废弃 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));
|
||
issues.push(...checkAriaNames(pkgs)); // 输入控件必须有可访问名
|
||
issues.push(...checkToastLive(pkgs)); // toast 必须有 live 区域与 alert 抢占
|
||
issues.push(...checkFormErrorLink(pkgs)); // 表单错误文案必须被控件关联
|
||
issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在
|
||
checkDarkTokens(issues);
|
||
checkTokenParity(issues);
|
||
checkLlmsFull(pkgs, issues);
|
||
issues.push(...checkIterations());
|
||
issues.push(...checkVisualChecklist());
|
||
|
||
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 ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕双向一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00,每个 iter 提交都有留痕),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,combobox / slider / range 控件都有可访问名,toast 有 live 区域与 alert 抢占播报,视觉回归抽查清单十八组件 × 五维度齐全)');
|
||
}
|
||
|
||
/*** 校验 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;
|
||
}
|
||
|
||
/*** 对比度审计:把令牌的「文字色 × 底色」配对算成 WCAG 比值,低于阈值即退出码 1
|
||
* --json 出机器可读明细;--list 只列配对表(看一眼设计契约,不必读代码)
|
||
*/
|
||
function cmdAuditContrast(args) {
|
||
if (args.includes('--list')) {
|
||
for (const p of PAIRS) console.log(`${p.label}:--ua-color-${p.fg} on --ua-color-${p.bg},阈值 ${p.min}`);
|
||
return;
|
||
}
|
||
let result;
|
||
try {
|
||
result = auditContrast(ROOT);
|
||
} catch (err) {
|
||
console.error(`audit:contrast 失败:${err.message}`); // 求值不了就红:静默跳过会让人误以为全站达标
|
||
process.exit(1);
|
||
}
|
||
if (args.includes('--json')) {
|
||
const pairs = result.rows.map((r) => ({
|
||
label: r.label, fg: r.fg, bg: r.bg, min: r.min,
|
||
light: Number(r.light.toFixed(2)), dark: Number(r.dark.toFixed(2)),
|
||
contrastLight: Number(r.contrastLight.toFixed(2)), contrastDark: Number(r.contrastDark.toFixed(2)), bad: r.bad,
|
||
}));
|
||
console.log(JSON.stringify({ ok: result.failures.length === 0, total: pairs.length, failing: result.failures.length, pairs }, null, 2));
|
||
} else {
|
||
console.log(formatReport(result));
|
||
}
|
||
process.exit(result.failures.length === 0 ? 0 : 1);
|
||
}
|
||
|
||
/*** 动效兜底审计:带 infinite 的动画必须在 prefers-reduced-motion 下被显式关掉(--json 出明细)
|
||
* 只盯无限循环动画:有限时长的过渡不会持续刺激,把它也纳入只会让门禁变吵而没人看。
|
||
*/
|
||
function cmdAuditMotion(args) {
|
||
let result;
|
||
try {
|
||
result = auditMotion(ROOT);
|
||
} catch (err) {
|
||
console.error(`audit:motion 失败:${err.message}`);
|
||
process.exit(1);
|
||
}
|
||
const failing = result.gaps.length + result.shift.gaps.length + result.spatial.gaps.length; // 三类缺口都算失败:无限动画 + 悬停/按压位移 + 旋转/缩放过渡
|
||
if (args.includes('--json')) {
|
||
console.log(JSON.stringify({ ok: failing === 0, total: result.total, failing, entries: result.entries, shift: result.shift, spatial: result.spatial }, null, 2));
|
||
} else {
|
||
console.log(formatMotionReport(result));
|
||
}
|
||
process.exit(failing === 0 ? 0 : 1);
|
||
}
|
||
|
||
/*** 表面阶梯审计:底色对底色(悬停 / 按下 / 凹陷)差得看不出就是没做(--json 出明细、--list 只看阶梯表) ***/
|
||
function cmdAuditSurface(args) {
|
||
if (args.includes('--list')) {
|
||
for (const s of LADDER) console.log(`${s.label}:--ua-color-${s.from} → --ua-color-${s.to},亮度比阈值 ${s.min}`);
|
||
return;
|
||
}
|
||
let result;
|
||
try {
|
||
result = auditSurface(ROOT);
|
||
} catch (err) {
|
||
console.error(`audit:surface 失败:${err.message}`); // 求值不了就红:静默跳过等于默许阶梯糊在一起
|
||
process.exit(1);
|
||
}
|
||
if (args.includes('--json')) {
|
||
const steps = result.rows.map((r) => ({ ...r, ratio: Number(r.ratio.toFixed(3)) }));
|
||
console.log(JSON.stringify({ ok: result.failures.length === 0, total: steps.length, failing: result.failures.length, steps }, null, 2));
|
||
} else {
|
||
console.log(formatSurfaceReport(result));
|
||
}
|
||
process.exit(result.failures.length === 0 ? 0 : 1);
|
||
}
|
||
|
||
/*** 迭代循环诊断:锁心跳 / 上次留痕 / 待办数一眼看清,多实例接力时不必翻调度器库或靠猜(--json 给脚本用) ***/
|
||
function cmdLoop(args) {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const lockFile = path.join(INDEX_DIR, 'loop.lock');
|
||
let lock = null;
|
||
if (fs.existsSync(lockFile)) {
|
||
const raw = Number(fs.readFileSync(lockFile, 'utf8').trim()); // 锁内是点火时的 epoch 秒
|
||
const stamp = raw || Math.floor(fs.statSync(lockFile).mtimeMs / 1000); // 取不到数字就用 mtime 兜底
|
||
const age = now - stamp;
|
||
lock = { stamp, age, fresh: age < 900 }; // 15 分钟内的心跳算活着,与 ITERATION.md 的抢锁判据同口径
|
||
}
|
||
const logFile = path.join(INDEX_DIR, 'iterations.log');
|
||
const text = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
|
||
const lines = text.split('\n').filter((l) => l.trim()); // 按行切即可:JSON.parse 容忍行尾 \r,不必额外归一化
|
||
let last = null;
|
||
try { last = lines.length ? JSON.parse(lines[lines.length - 1]) : null; } catch { last = null; } // 末行坏了不该让诊断命令崩,lint-iterations 会单独报
|
||
const data = JSON.parse(fs.readFileSync(path.join(INDEX_DIR, 'backlog.json'), 'utf8'));
|
||
const items = Array.isArray(data) ? data : data.items;
|
||
const open = items.filter((i) => i.status === 'open');
|
||
const pending = open.filter((i) => i.domain === 'visual');
|
||
const nextId = `g${Math.max(0, ...items.map((i) => Number(String(i.id).replace(/^g/, '')) || 0)) + 1}`; // 下一条可用编号:自补条目直接照抄,省得撞号后回头 rebase
|
||
if (args.includes('--json')) {
|
||
const out = { now, lock, entries: lines.length, last, total: items.length, open: open.length, openVisual: pending.length, nextId };
|
||
console.log(JSON.stringify(out, null, 2));
|
||
return;
|
||
}
|
||
console.log(`当前时间:${new Date(now * 1000).toLocaleString('zh-CN', { hour12: false })}`);
|
||
if (!lock) console.log('循环锁:无 → 可直接点火(写 .ua/loop.lock 后进连续迭代)');
|
||
else if (lock.fresh) console.log(`循环锁:心跳 ${lock.age} 秒前(< 15 分钟)→ 已有实例在跑,本轮不要抢锁`);
|
||
else console.log(`循环锁:心跳 ${lock.age} 秒前(≥ 15 分钟)→ 疑似中断,可删锁重新点火`);
|
||
console.log(last ? `上次留痕:${last.id} ${last.result} @ ${last.ts}(${last.commit})` : '上次留痕:无');
|
||
console.log(`累计留痕:${lines.length} 条|backlog:${items.length} 条,open ${open.length} 条(视觉欠账 ${pending.length} 条)`);
|
||
if (!open.length) console.log(`选题:无 open 条目 → 本轮需按《选题规则》自补一条(下一条可用编号 ${nextId})`);
|
||
else console.log(`选题:下一条取 ${pending[0]?.id ?? open[0].id}(${pending[0] ? '视觉欠账优先' : '按数组顺序'})`);
|
||
}
|
||
|
||
/*** 迭代留痕校验:.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)`);
|
||
}
|
||
|
||
/*** 环境自检:新 Agent 接手前一条命令确认 node / 依赖 / git 钩子 / .ua 元数据就绪,任一失败退出码 1 ***/
|
||
/*** 作用:解析本文件的命令面——dispatch 的 case 集合 vs usage 串里列的命令,两者必须相互覆盖
|
||
* 新增命令时只加 case 不改 usage(或反之)会让 Agent 查不到入口:usage 是 Agent 唯一能看到的命令清单。 ***/
|
||
function commandSurface() {
|
||
const self = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8');
|
||
const cases = new Set([...self.matchAll(/^ {2}case '([^']+)':/gm)].map((m) => m[1]));
|
||
const usage = /console\.log\('usage: ua <(.+)>'\)/.exec(self); // 贪婪到行尾括号:usage 里本身含 <kw> 这类尖括号
|
||
// 先剔掉 [--json|--baseline] 这类选项组再按 | 切:否则选项里的竖线会被当成命令分隔符
|
||
const listed = usage ? usage[1].replace(/\[[^\]]*\]/g, '').split('|').map((part) => part.trim().split(/\s+/)[0]).filter(Boolean) : [];
|
||
return { cases, listed };
|
||
}
|
||
|
||
function cmdDoctor() {
|
||
const checks = []; // 逐项结果:名称 + 是否通过 + 可选失败详情
|
||
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
||
checks.push({ name: `node >= 18(当前 ${process.versions.node})`, pass: nodeMajor >= 18 });
|
||
checks.push({ name: 'pnpm-lock.yaml 存在', pass: fs.existsSync(path.join(ROOT, 'pnpm-lock.yaml')) });
|
||
checks.push({ name: '依赖已安装(node_modules/)', pass: fs.existsSync(path.join(ROOT, 'node_modules')) });
|
||
const hooksPath = spawnSync('git', ['config', 'core.hooksPath'], { cwd: ROOT }).stdout?.toString().trim(); // 显式 hooksPath 优先,默认取 .git/hooks
|
||
const hookDir = hooksPath ? path.resolve(ROOT, hooksPath) : path.join(ROOT, '.git', 'hooks');
|
||
checks.push({ name: 'commit-msg 钩子在位', pass: fs.existsSync(path.join(hookDir, 'commit-msg')) });
|
||
const gitCfg = (k) => spawnSync('git', ['config', k], { cwd: ROOT }).stdout?.toString().trim() ?? '';
|
||
const gitIdOk = !!gitCfg('user.name') && !!gitCfg('user.email');
|
||
checks.push({ name: 'git 身份已配置(user.name / user.email)', pass: gitIdOk, detail: gitIdOk ? '' : '先执行 git config --global user.name/user.email,否则提交被拒' });
|
||
const tryJson = (rel) => { // 存在且能 JSON.parse 才算过
|
||
const p = path.join(ROOT, rel);
|
||
if (!fs.existsSync(p)) return `文件不存在`;
|
||
try { JSON.parse(fs.readFileSync(p, 'utf8')); return ''; } catch (e) { return `解析失败:${e.message}`; }
|
||
};
|
||
for (const rel of ['.ua/backlog.json', '.ua/index.json']) {
|
||
const bad = tryJson(rel);
|
||
checks.push({ name: `${rel} 可解析`, pass: !bad, detail: bad });
|
||
}
|
||
const logFile = path.join(INDEX_DIR, 'iterations.log');
|
||
if (fs.existsSync(logFile)) {
|
||
const badLines = fs.readFileSync(logFile, 'utf8').split(/\r?\n/).filter((l) => l.trim()).filter((l) => { try { JSON.parse(l); return false; } catch { return true; } });
|
||
checks.push({ name: '.ua/iterations.log 每行合法 JSON', pass: !badLines.length, detail: badLines.length ? `${badLines.length} 行非法(首行:${badLines[0].slice(0, 40)}…)` : '' });
|
||
} else checks.push({ name: '.ua/iterations.log 存在', pass: false, detail: '尚未生成,跑一轮迭代或 ua index' });
|
||
// 命令面自检:usage ↔ dispatch ↔ CLI 清单三方一致,防止新命令只落一处
|
||
const { cases, listed } = commandSurface();
|
||
const missingInUsage = [...cases].filter((c) => !listed.includes(c));
|
||
const missingInCode = listed.filter((c) => !cases.has(c));
|
||
checks.push({
|
||
name: 'usage 串 ↔ dispatch 一致',
|
||
pass: !missingInUsage.length && !missingInCode.length,
|
||
detail: [missingInUsage.length ? `usage 缺 ${missingInUsage.join('/')}` : '', missingInCode.length ? `实现缺 ${missingInCode.join('/')}` : ''].filter(Boolean).join(';'),
|
||
});
|
||
// 门禁链自检:package.json 的 check 必须跑齐所有门禁步骤(新增门禁忘了接进链等于没加)
|
||
const needSteps = ['build', 'demo', 'style', 'typecheck', 'smoke', 'index', 'verify', 'lint-iterations', 'audit:visual', 'audit:contrast', 'audit:motion', 'audit:surface'];
|
||
const chain = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).scripts?.check ?? '');
|
||
const lacking = needSteps.filter((step) => !chain.includes(`npm run ${step}`));
|
||
checks.push({ name: 'check 链覆盖全部门禁步骤', pass: !lacking.length, detail: lacking.length ? `缺 ${lacking.join(' / ')}` : '' });
|
||
// 清单自检:ua.manifest.json 里写进 api.exports 的命令必须真的存在(文档承诺 ≠ 实现)
|
||
const cliManifestPath = path.join(ROOT, 'packages', 'tools', 'cli', 'ua.manifest.json');
|
||
const declared = fs.existsSync(cliManifestPath)
|
||
? (JSON.parse(fs.readFileSync(cliManifestPath, 'utf8')).api?.exports ?? [])
|
||
.filter((line) => /^ua \S/.test(line))
|
||
.map((line) => line.replace(/^ua\s+/, '').split(/[\s:]/)[0]) // 说明用全角冒号接在后面,按空白与冒号一起切
|
||
: [];
|
||
const ghost = declared.filter((name) => !cases.has(name));
|
||
checks.push({ name: 'CLI 清单声明的命令都有实现', pass: !ghost.length, detail: ghost.length ? `清单有实现无:${ghost.join('/')}` : '' });
|
||
|
||
for (const c of checks) console.log(`${c.pass ? 'pass' : 'FAIL'} ${c.name}${c.detail ? ':' + c.detail : ''}`);
|
||
const fails = checks.filter((c) => !c.pass).length;
|
||
if (fails) { console.error(`doctor: ${fails} 项未通过,先修复再跑 npm run check`); process.exit(1); }
|
||
console.log(`doctor OK(${checks.length} 项全过)`);
|
||
}
|
||
|
||
/*** 作用:把「改 backlog + 追加 iterations.log」收成一条命令——字段顺序与 +08:00 时间格式由代码保证,
|
||
* 省掉每轮手搓临时脚本(历史踩过:ts 手写、字段顺序自创、状态忘了改、哈希抄错)。 ***/
|
||
const localStamp = () => {
|
||
const d = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const off = -d.getTimezoneOffset(); // 时区偏移(分钟),东八区为 +480
|
||
const sign = off >= 0 ? '+' : '-';
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`;
|
||
};
|
||
|
||
function cmdIter(args) {
|
||
const [sub, ...rest] = args;
|
||
const backlogFile = path.join(INDEX_DIR, 'backlog.json');
|
||
const logFile = path.join(INDEX_DIR, 'iterations.log');
|
||
const load = () => JSON.parse(fs.readFileSync(backlogFile, 'utf8'));
|
||
const itemsOf = (data) => (Array.isArray(data) ? data : data.items);
|
||
const save = (data, items) => {
|
||
if (Array.isArray(data)) fs.writeFileSync(backlogFile, JSON.stringify(items, null, 2) + '\n', 'utf8');
|
||
else { data.items = items; fs.writeFileSync(backlogFile, JSON.stringify(data, null, 2) + '\n', 'utf8'); }
|
||
};
|
||
if (sub === 'done') {
|
||
const [id, commit, summary, notes] = rest;
|
||
if (!id || !commit || !summary) {
|
||
console.error('usage: ua iter done <id> <commit> <summary> [notes]');
|
||
process.exit(2);
|
||
}
|
||
if (!/^[0-9a-f]{7,40}$/.test(commit)) { console.error(`commit 须为 7-40 位短哈希:${commit}`); process.exit(2); }
|
||
const data = load();
|
||
const items = itemsOf(data);
|
||
const item = items.find((i) => i.id === id);
|
||
if (!item) { console.error(`backlog 里没有 ${id}(自补条目先用 ua iter add)`); process.exit(2); }
|
||
item.status = 'done';
|
||
if (notes) item.notes = notes; // 备注缺省保留原值:只有显式传了才覆盖
|
||
save(data, items);
|
||
const line = { ts: localStamp(), id, result: 'done', commit, summary }; // 字段顺序与 ITERATION.md 一致
|
||
fs.appendFileSync(logFile, JSON.stringify(line) + '\n', 'utf8');
|
||
console.log(`iter done ${id} → backlog 置 done,留痕 ${line.ts}(${commit})`);
|
||
console.log('接着提交元数据:git add -A && git commit -m "chore(backlog): 记录 ' + id + ' 完成"');
|
||
return;
|
||
}
|
||
if (sub === 'add') {
|
||
const [file] = rest;
|
||
if (!file) { console.error('usage: ua iter add <条目json文件>'); process.exit(2); }
|
||
const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
const missing = ['id', 'domain', 'title', 'motivation', 'acceptance'].filter((k) => !entry[k]); // 选题规则要求三件套齐全
|
||
if (missing.length) { console.error(`条目缺字段:${missing.join(' / ')}`); process.exit(2); }
|
||
const data = load();
|
||
const items = itemsOf(data);
|
||
if (items.some((i) => i.id === entry.id)) { console.error(`id 已存在:${entry.id}`); process.exit(2); }
|
||
items.push({ ...entry, status: entry.status ?? 'open', notes: entry.notes ?? '' });
|
||
save(data, items);
|
||
console.log(`iter add ${entry.id}(${entry.domain}):${entry.title}`);
|
||
return;
|
||
}
|
||
console.error('usage: ua iter <done <id> <commit> <summary> [notes] | add <条目json文件>>');
|
||
process.exit(2);
|
||
}
|
||
|
||
// ---- 入口 ----
|
||
|
||
const [cmd, ...args] = process.argv.slice(2);
|
||
switch (cmd) {
|
||
case 'doctor':
|
||
cmdDoctor();
|
||
break;
|
||
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 'recipes':
|
||
cmdRecipes(args);
|
||
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 'audit:visual':
|
||
visualAudit(args, ROOT);
|
||
break;
|
||
case 'audit:contrast':
|
||
cmdAuditContrast(args);
|
||
break;
|
||
case 'audit:motion':
|
||
cmdAuditMotion(args);
|
||
break;
|
||
case 'audit:surface':
|
||
cmdAuditSurface(args);
|
||
break;
|
||
case 'loop':
|
||
cmdLoop(args);
|
||
break;
|
||
case 'iter':
|
||
cmdIter(args);
|
||
break;
|
||
case 'mcp':
|
||
cmdMcp();
|
||
break;
|
||
default:
|
||
console.log('usage: ua <index|list|search <kw>|info <name[,name…]> [--json|--md]|recipes [编号] [--json]|docs|tokens [--json|--unused]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|iter|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|audit:motion [--json]|audit:surface [--json|--list]|loop [--json]|changelog|mcp>');
|
||
process.exit(cmd ? 1 : 0);
|
||
}
|