/* 通用合并脚本(保留在 tools/ 供后续批次复用): * node tools/merge-mobile-batch.mjs # 合并 spec/parts/*.md + 未登记的 frameworks-mobile 组件进索引 * * 做三件事,全部幂等: * 1) 把 spec/parts/*.md 的规格片段按编号拼进 spec/移动端规格.md(已存在同编号则跳过) * 2) 把 frameworks-mobile 里**未登记**的组件写进 components/index.json * (slug 缺省 = kebab-case(prefix),可用 --slug-prefix 调整;分类由 --category 指定或按启发式) * 3) 已拼接的片段移入 spec/parts/_merged/,避免下次重复拼接 * * 为什么需要它:子 agent 只写自己的文件(不改 index.json,避免并发写同一文件), * 合并由主 agent 统一做;但每个批次都手写一遍合并脚本容易漏步骤(上一轮就漏了「已存在同编号」的幂等判断)。 * 用法:批次完成后 `node tools/merge-mobile-batch.mjs`,再跑 build + 门禁 + 回归。 */ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const LIB = join(ROOT, '.design_library', 'kole-ui-mobile'); const PARTS = join(LIB, 'spec', 'parts'); const SPEC = join(LIB, 'spec', '移动端规格.md'); const INDEX = join(LIB, 'components', 'index.json'); const SRC = join(ROOT, 'frameworks-mobile'); const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; }; const CATEGORY_HINT = { input: ['input', 'search', 'textarea', 'stepper', 'switch', 'numberkeyboard', 'datepicker', 'picker', 'radio', 'checkbox', 'slider', 'rate', 'upload', 'form'], navigation: ['navbar', 'tabbar', 'grid', 'steps', 'tabs', 'drawer', 'indexes', 'sidebar', 'menu', 'fab', 'backtop', 'sticky', 'guide'], display: ['cell', 'list', 'avatar', 'badge', 'tag', 'collapse', 'table', 'image', 'swiper', 'progress', 'countdown', 'qrcode', 'watermark', 'skeleton', 'typography', 'icon', 'link', 'layout'], feedback: ['toast', 'dialog', 'popup', 'loading', 'noticebar', 'message', 'actionsheet', 'pullrefresh', 'swipecell', 'drawer', 'result', 'empty', 'overlay', 'progress'], general: ['button', 'divider', 'config'], }; function guessCategory(slug) { const bare = slug.replace(/^mobile-/, ''); for (const [cat, keys] of Object.entries(CATEGORY_HINT)) { if (keys.some((k) => bare === k)) return cat; } for (const [cat, keys] of Object.entries(CATEGORY_HINT)) { if (keys.some((k) => bare.includes(k))) return cat; } return 'general'; } /* ---------- 0. 磁盘扫描:哪些组件还没登记 ---------- */ const index = JSON.parse(readFileSync(INDEX, 'utf8')); const haveSlug = new Set(index.components.map((c) => c.slug)); const havePrefix = new Set(index.components.map((c) => c.frameworksPrefix)); const ENDS = index.ends; const SUFFIX = { css: '.css', html: '.html', jsx: '.jsx', vue2: '.vue2.vue', vue3: '.vue3.vue', uniapp: '.uniapp.vue', }; const prefixesOnDisk = [...new Set(readdirSync(SRC).map((f) => f.split('.')[0]))]; const unregistered = prefixesOnDisk.filter((p) => !havePrefix.has(p)); /* ---------- 1. 规格片段拼接 ---------- */ const partFiles = existsSync(PARTS) ? readdirSync(PARTS).filter((f) => /\.md$/.test(f) && !f.startsWith('_')) : []; const parts = partFiles .map((f) => { const text = readFileSync(join(PARTS, f), 'utf8').trim(); const m = text.match(/^## (\d+) · /); return { file: f, text, num: m ? Number(m[1]) : 999 }; }) .sort((a, b) => a.num - b.num); let spec = readFileSync(SPEC, 'utf8').replace(/\s+$/, ''); let specAdded = 0; for (const p of parts) { if (new RegExp(`^## ${p.num} · `, 'm').test(spec)) { console.log(`规格 §${p.num} 已存在,归档而不拼接(${p.file})`); } else { spec += `\n\n---\n\n${p.text}\n`; specAdded++; console.log(`规格 ← §${p.num}(${p.file})`); } } if (specAdded) writeFileSync(SPEC, spec + '\n', 'utf8'); if (parts.length) { const merged = join(PARTS, '_merged'); if (!existsSync(merged)) mkdirSync(merged, { recursive: true }); for (const p of parts) { const from = join(PARTS, p.file); if (existsSync(from)) renameSync(from, join(merged, p.file)); } console.log(`parts 片段已归档 ${parts.length} 个 → spec/parts/_merged/`); } /* ---------- 2. 未登记组件写入索引 ---------- */ let added = 0; const skipped = []; for (const prefix of unregistered.sort()) { /* slug:优先用契约里登记的(按 specSection 反查),否则 kebab-case(prefix) */ let slug = 'mobile-' + prefix.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); /* 若已有同名契约(例如 Cell.txt 场景),直接用契约文件名 */ const candidates = readdirSync(join(LIB, 'components')) .filter((f) => f.endsWith('.json') && f !== 'index.json') .map((f) => f.replace(/\.json$/, '')); const bySection = candidates.find((s) => { const ct = JSON.parse(readFileSync(join(LIB, 'components', s + '.json'), 'utf8')); return new RegExp(`\\b${prefix}\\b`).test(String(ct.specSection || '')) && !haveSlug.has(s); }); if (bySection) slug = bySection; if (haveSlug.has(slug)) { skipped.push(`${slug}(索引已存在)`); continue; } const contractPath = join(LIB, 'components', slug + '.json'); if (!existsSync(contractPath)) { skipped.push(`${prefix}(缺契约 ${slug}.json,跳过)`); continue; } const files = {}; const missing = []; for (const end of ENDS) { const f = prefix + SUFFIX[end]; if (existsSync(join(SRC, f))) files[end] = f; else missing.push(end); } if (missing.length) { skipped.push(`${prefix}(缺 ${missing.join('/')} 端文件,跳过)`); continue; } const ct = JSON.parse(readFileSync(contractPath, 'utf8')); const category = arg('--category') || guessCategory(slug); index.components.push({ slug, name: ct.name, tier: 'mobile', category, confidence: ct.confidence || 'high', specSection: ct.specSection, contract: 'components/' + slug + '.json', frameworksPrefix: prefix, files, variantDimensions: (ct.variantDimensions || []).map((d) => d.name), behaviors: [], }); haveSlug.add(slug); havePrefix.add(prefix); added++; console.log(`索引 ← ${slug}(${ct.name} · ${category} · prefix=${prefix} · ${Object.keys(files).length} 端)`); } if (added) { writeFileSync(INDEX, JSON.stringify(index, null, 2) + '\n', 'utf8'); } if (skipped.length) console.log('跳过:' + skipped.join('; ')); console.log(`索引组件数: ${index.components.length}(新增 ${added})· 规格新增 ${specAdded} 节`);