Files
aurora-admin/tools/gen-family-impl.mjs
T
aurora-admin eb25feedaf feat(S6-P21): 组件族参数化(13族/44成员/导航族15文件同源/79→48概念组件)
【本次核心 · S6-P21】
- 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams;
  data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。
- 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。
- 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 /
  变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。
- 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成
  TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed;
  三份 CSS md5 完全相同 = 一份样式表服务三个组件。
- 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、
  tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。

【同时清掉此前已完成但未提交的批次】
生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告)
跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交:
- Package:三端可 import(S5-P18)+ 发布到私有 npm 源
- Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性
- Security:生产部署链审计修复(2026-09-19)+ 线上部署
- Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性;
  S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程
- 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、
  安全审计修复与待决策项.md

【验收】
- node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变)
- node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44)
- node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时
- 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误
- 零运行时依赖 OK;build-site.ps1 ASCII-only OK

【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
2026-09-20 03:32:31 +08:00

218 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* gen-family-impl.mjs - 族实现生成器(实现层)
*
* 把「同一族里各自手写一遍的 N 个组件」变成「一份参数化模板 + N 组参数取值」。
*
* 工作机制:
* tools/lib/family-impl/<family>/menu.{html,css,jsx,vue2,vue3}.tpl ← 族模板(唯一真源)
* tools/lib/family-model.mjs ← 成员与参数取值
* → frameworks/<PascalPrefix>.{html,css,jsx,vue2.vue,vue3.vue} ← 生成物(原地覆盖)
*
* 为什么是「生成」而不是「抽共享模块」:
* tools/pack-deploy.mjs 硬断言 frameworks/ 恰好 395 文件(79 x 5),
* tools/precompute.mjs 硬断言 395 个源文件,build-dist 按文件打包 dist/。
* 增删文件或引入跨文件 import 都会破坏这三者。生成器让 15 个文件保持自包含,
* 同时把「3 份实现」收敛成「1 份模板」—— 改模板 + 重跑即三端同步。
*
* 用法:
* node tools/gen-family-impl.mjs # 生成 IMPLEMENTED_FAMILIES 全部族
* node tools/gen-family-impl.mjs --only=nav-menu
* node tools/gen-family-impl.mjs --dry-run # 只列出将写入的文件
* node tools/gen-family-impl.mjs --check # 校验磁盘与模板一致(回归用)
* node tools/gen-family-impl.mjs --force # 允许覆盖有未提交改动的文件
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { FAMILIES, IMPLEMENTED_FAMILIES } from './lib/family-model.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TPL_ROOT = path.join(ROOT, 'tools', 'lib', 'family-impl');
const COMPONENTS = path.join(ROOT, '.design_library', 'aurora-admin', 'components');
const argv = process.argv.slice(2);
const DRY = argv.includes('--dry-run');
const CHECK = argv.includes('--check');
const FORCE = argv.includes('--force');
const onlyArg = argv.find((a) => a.startsWith('--only='));
const targets = onlyArg ? [onlyArg.slice('--only='.length)] : IMPLEMENTED_FAMILIES;
/** 生成物标记:文件带此标记即视为生成器所有,可以安全重写 */
const GEN_MARKER = '@generated by tools/gen-family-impl.mjs';
/** 端 -> 文件后缀(与 build-site.ps1 / precompute.mjs 的 5 端约定一致) */
const KINDS = [
{ id: 'html', ext: '.html', tpl: 'menu.html.tpl' },
{ id: 'css', ext: '.css', tpl: 'menu.css.tpl' },
{ id: 'jsx', ext: '.jsx', tpl: 'menu.jsx.tpl' },
{ id: 'vue2', ext: '.vue2.vue', tpl: 'menu.vue2.tpl' },
{ id: 'vue3', ext: '.vue3.vue', tpl: 'menu.vue3.tpl' },
];
/** 演示页第三个用例:展示本 direction 的参数差异能力 */
const CASE3 = {
top: { title: '下拉展开态(订单 → 子项)', params: {}, initial: { active: 'order-all', open: 'order' } },
side: { title: '折叠态(collapsed=true)', params: { collapsed: true }, initial: { active: 'order-all', open: '' } },
mixed: { title: '二级联动(一级切到「商品」)', params: {}, initial: { active: 'goods-list', open: '' } },
};
const DIRECTION_ZH = { top: '顶部菜单', side: '侧边菜单', mixed: '混合导航' };
function readIndex() {
return JSON.parse(fs.readFileSync(path.join(COMPONENTS, 'index.json'), 'utf8'));
}
/** 目标文件是否有未提交改动(未跟踪文件不算) */
function gitDirty(relPath) {
try {
const out = execFileSync('git', ['status', '--porcelain', '--', relPath], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.trim().length > 0;
} catch {
return false; // 没有 git 或不是仓库时不做保护,避免误伤
}
}
function render(tpl, vars) {
return tpl.replace(/\{\{([A-Z0-9_]+)\}\}/g, (m, key) => {
if (!(key in vars)) throw new Error('模板占位符未提供:' + m);
return String(vars[key]);
});
}
function main() {
const index = readIndex();
const bySlug = new Map(index.components.map((c) => [c.slug, c]));
const report = [];
const failures = [];
let written = 0;
let unchanged = 0;
let skipped = 0;
for (const famId of targets) {
const fam = FAMILIES.find((f) => f.id === famId);
if (!fam) {
failures.push(`未定义族 ${famId}`);
continue;
}
const dir = path.join(TPL_ROOT, famId);
if (!fs.existsSync(dir)) {
failures.push(`族模板目录缺失:${path.relative(ROOT, dir)}`);
continue;
}
const members = fam.members.map((m) => m.slug);
for (const kind of KINDS) {
const tplPath = path.join(dir, kind.tpl);
if (!fs.existsSync(tplPath)) failures.push(`模板缺失 ${path.relative(ROOT, tplPath)}`);
}
if (failures.length) continue;
for (const m of fam.members) {
const meta = bySlug.get(m.slug);
if (!meta) {
failures.push(`族 ${famId} 成员 ${m.slug} 不在 index.json`);
continue;
}
const prefix = meta.frameworksPrefix;
const direction = m.params.direction || fam.paramSurface.find((p) => p.name === 'direction').default;
const c3 = CASE3[direction] || { title: '替代形态', params: {}, initial: {} };
/* 代码里只放实现真正消费的参数(direction / collapsed);完整参数集以纯文本
写进头注释。两点原因:不把没用到的数据带进产物;参数名与枚举值若以
{"k":"v"} 字面量出现,会被跨端 class 提取器当成类记号,污染四端一致性比对。
direction 单独用常量承载:对象字面量里的字符串值在 Vue 端会被扫描器当作
变体记号展开(实测:写成 { direction: 'side' } 时 vue2/vue3 多出 side 记号,
拆成 FAMILY_DIRECTION = 'side' 后消失)。 */
function toCode(obj) {
return (
'{ ' +
Object.entries(obj)
.map(([k, v]) =>
k === 'direction'
? `direction: FAMILY_DIRECTION`
: `${k}: ${typeof v === 'string' ? `'${v}'` : String(v)}`
)
.join(', ') +
' }'
);
}
const consumed = {};
for (const key of ['direction', 'collapsed']) {
if (m.params[key] !== undefined) consumed[key] = m.params[key];
}
const paramsText = Object.entries(m.params)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
const vars = {
PREFIX: prefix,
SLUG: m.slug,
DIRECTION: direction,
DIRECTION_ZH: DIRECTION_ZH[direction] || direction,
PARAMS_TEXT: paramsText,
PARAMS_DIRECTION: `'${direction}'`,
PARAMS_CODE: toCode(consumed),
MEMBERS: members.join(' / '),
CASE3_TITLE: c3.title,
CASE3_PARAMS_CODE: toCode(c3.params),
CASE3_INITIAL_CODE: toCode(c3.initial),
};
for (const kind of KINDS) {
const tpl = fs.readFileSync(path.join(dir, kind.tpl), 'utf8');
const out = render(tpl, vars);
const rel = path.join('frameworks', prefix + kind.ext);
const abs = path.join(ROOT, rel);
const prev = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
/* 护栏:不覆盖「有未提交改动且不是生成物」的文件(除非 --force)。
这条来自实测教训 —— 工作区里可能躺着别人没提交的手工调整。
生成物自带 GEN_MARKER,重跑生成不会撞自己的护栏。 */
const isGenerated = prev !== null && prev.includes(GEN_MARKER);
if (prev !== null && prev !== out && !isGenerated && gitDirty(rel) && !FORCE) {
failures.push(`${rel} 有未提交改动且非生成物,拒绝覆盖(确认后可加 --force)`);
skipped++;
continue;
}
if (prev === out) {
unchanged++;
continue;
}
if (CHECK) {
failures.push(`${rel} 与族模板不一致(需重跑生成器)`);
continue;
}
if (DRY) {
report.push(`~ ${rel} (${prev === null ? '新建' : '覆盖'} ${out.length} bytes)`);
written++;
continue;
}
fs.writeFileSync(abs, out, 'utf8');
report.push(`+ ${rel} ${out.length} bytes`);
written++;
}
}
}
for (const line of report) console.log(line);
const scope = targets.join(', ');
console.log(
`\n${CHECK ? '[check]' : DRY ? '[dry-run]' : '[write]'} 族 ${scope}:` +
`写入 ${written} / 已最新 ${unchanged} / 跳过 ${skipped}`
);
if (failures.length) {
console.error('\nFAIL:');
for (const f of failures) console.error(' - ' + f);
process.exit(1);
}
console.log('OK: 族实现与模板一致');
}
main();