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 个 —— 历史从未跟踪且属构建产物,保持现状。
This commit is contained in:
+76
-24
@@ -15,12 +15,28 @@
|
||||
// 阶段 C —— 预计算 API 表(props/emits/slots),再分离 html/jsx/vue2/vue3
|
||||
//
|
||||
// 设计原则:运行时保留回退路径,预计算缺失时功能不受影响。
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
statSync,
|
||||
} from 'fs';
|
||||
import path from 'path';
|
||||
/* defineEmits 数组字面量的安全解析(与 site/app.js 内联实现同源,见模块头注释)。
|
||||
构建期绝不对 frameworks/ 里的内容做动态求值 —— 旧实现把数组字面量交给函数构造器求值,
|
||||
投毒的 .vue 因此可在构建机上执行任意 JS。 */
|
||||
import { parseStringLiteralArray } from './lib/parse-string-literal-array.mjs';
|
||||
|
||||
const ROOT = 'C:/Users/12914/Desktop/组件规范第一套';
|
||||
const DATA_JSON = `${ROOT}/site/data.json`;
|
||||
const DATA_JS = `${ROOT}/site/data.js`;
|
||||
const SRC_DIR = `${ROOT}/site/sources`;
|
||||
const ROOT = path.resolve(path.join(path.dirname(fileURLToPath(import.meta.url)), '..'));
|
||||
const DATA_JSON = path.join(ROOT, 'site', 'data.json');
|
||||
const DATA_JS = path.join(ROOT, 'site', 'data.js');
|
||||
const SRC_DIR = path.join(ROOT, 'site', 'sources');
|
||||
const COMPONENT_COUNT = 79;
|
||||
const SOURCE_FILE_COUNT = 395;
|
||||
|
||||
if (!existsSync(DATA_JSON)) {
|
||||
console.error('[FATAL] 找不到 site/data.json,请先运行 build-site.ps1');
|
||||
@@ -28,6 +44,32 @@ if (!existsSync(DATA_JSON)) {
|
||||
}
|
||||
|
||||
const data = JSON.parse(readFileSync(DATA_JSON, 'utf8'));
|
||||
const beforeKb = Math.round(statSync(DATA_JSON).size / 1024);
|
||||
const SOURCE_KINDS = ['html', 'css', 'jsx', 'vue2', 'vue3'];
|
||||
const expectedSourcePaths = [];
|
||||
|
||||
if (!data || !Array.isArray(data.components) || data.components.length !== COMPONENT_COUNT) {
|
||||
const actualCount = data && Array.isArray(data.components) ? data.components.length : 0;
|
||||
console.error(`[FATAL] 组件完整性校验失败:期望 ${COMPONENT_COUNT} 个组件,实际 ${actualCount} 个`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const c of data.components) {
|
||||
if (!c || typeof c.slug !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(c.slug)) {
|
||||
console.error(`[FATAL] 组件完整性校验失败:非法 slug ${String(c && c.slug)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const kind of SOURCE_KINDS) {
|
||||
if (!c.sources || typeof c.sources[kind] !== 'string') {
|
||||
console.error(`[FATAL] 源码完整性校验失败:${c.slug} 缺少 ${kind} 源码`);
|
||||
process.exit(1);
|
||||
}
|
||||
expectedSourcePaths.push(path.join(SRC_DIR, c.slug, `${kind}.txt`));
|
||||
}
|
||||
}
|
||||
if (expectedSourcePaths.length !== SOURCE_FILE_COUNT) {
|
||||
console.error(`[FATAL] 源文件完整性校验失败:期望 ${SOURCE_FILE_COUNT} 个,实际 ${expectedSourcePaths.length} 个`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* 与 app.js 的 extractScenarios 完全一致(保证结果可复现) */
|
||||
function extractScenarios(c) {
|
||||
@@ -186,17 +228,17 @@ function extractComponentAPI(c) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 只用 parseStringLiteralArray 解析字符串字面量数组,绝不求值。
|
||||
旧实现把数组字面量交给函数构造器求值,投毒的 .vue 会在构建期执行任意 JS(已实测)。 */
|
||||
const mEmits = code.match(/defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/);
|
||||
if (mEmits) {
|
||||
try {
|
||||
const emitArr = (new Function('return ' + mEmits[1]))();
|
||||
emitArr.forEach((e) => {
|
||||
const descZh = e.indexOf('update:') === 0 ? '双向绑定更新事件 (v-model)' : '组件交互触发事件';
|
||||
if (!emits.some((x) => x.name === e)) {
|
||||
emits.push({ name: e, descZh, params: '(val: any) => void' });
|
||||
}
|
||||
});
|
||||
} catch (err) { /* ignore */ }
|
||||
const emitArr = mEmits ? parseStringLiteralArray(mEmits[1]) : null;
|
||||
if (emitArr) {
|
||||
emitArr.forEach((e) => {
|
||||
const descZh = e.indexOf('update:') === 0 ? '双向绑定更新事件 (v-model)' : '组件交互触发事件';
|
||||
if (!emits.some((x) => x.name === e)) {
|
||||
emits.push({ name: e, descZh, params: '(val: any) => void' });
|
||||
}
|
||||
});
|
||||
}
|
||||
const emitRegex = /\$emit\(\s*['"]([a-zA-Z0-9_:-]+)['"]/g;
|
||||
let eMatch;
|
||||
@@ -253,9 +295,9 @@ for (const c of data.components) {
|
||||
if (!c.sources) continue;
|
||||
for (const kind of SPLIT_KINDS) {
|
||||
if (!c.sources[kind]) continue;
|
||||
const dir = `${SRC_DIR}/${c.slug}`;
|
||||
const dir = path.join(SRC_DIR, c.slug);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(`${dir}/${kind}.txt`, c.sources[kind]);
|
||||
writeFileSync(path.join(dir, `${kind}.txt`), c.sources[kind]);
|
||||
splitFiles++;
|
||||
}
|
||||
}
|
||||
@@ -265,13 +307,20 @@ console.log(`[precompute] 阶段C-2 · 源码已分离:${splitFiles} 个文件
|
||||
let cssSplit = 0;
|
||||
for (const c of data.components) {
|
||||
if (!c.sources || !c.sources.css) continue;
|
||||
const dir = `${SRC_DIR}/${c.slug}`;
|
||||
const dir = path.join(SRC_DIR, c.slug);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(`${dir}/css.txt`, c.sources.css);
|
||||
writeFileSync(path.join(dir, 'css.txt'), c.sources.css);
|
||||
cssSplit++;
|
||||
}
|
||||
console.log(`[precompute] 阶段B · css 已分离:${cssSplit} 个文件 → site/sources/<slug>/css.txt`);
|
||||
|
||||
const missingOutputSources = expectedSourcePaths.filter((filePath) => !existsSync(filePath));
|
||||
if (splitFiles + cssSplit !== SOURCE_FILE_COUNT || missingOutputSources.length > 0) {
|
||||
console.error(`[FATAL] 源文件输出完整性校验失败:期望 ${SOURCE_FILE_COUNT} 个,生成 ${splitFiles + cssSplit} 个,缺失 ${missingOutputSources.length} 个`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[precompute] 完整性校验:${data.components.length}/${COMPONENT_COUNT} 个组件,${splitFiles + cssSplit}/${SOURCE_FILE_COUNT} 个源文件`);
|
||||
|
||||
/* 在 data.js 里把全部 5 端换成引用(data.json 保持完整) */
|
||||
|
||||
const dataForJs = JSON.parse(JSON.stringify(data));
|
||||
@@ -291,7 +340,7 @@ for (const c of dataForJs.components) {
|
||||
外壳只留 hasContract 布尔;详情写 site/details/<slug>.json,日志写 site/changelog.json。
|
||||
data.json 保持全量完整(对外承诺不变)。 */
|
||||
|
||||
const DETAILS_DIR = `${ROOT}/site/details`;
|
||||
const DETAILS_DIR = path.join(ROOT, 'site', 'details');
|
||||
if (!existsSync(DETAILS_DIR)) mkdirSync(DETAILS_DIR, { recursive: true });
|
||||
|
||||
let detailFiles = 0;
|
||||
@@ -300,8 +349,13 @@ for (const c of dataForJs.components) {
|
||||
slug: c.slug,
|
||||
contract: c.contract ?? null,
|
||||
specLines: c.specLines ?? [],
|
||||
/* 族层:该组件属于哪个族、以什么角色参与、被哪些参数值钉成这一成员。
|
||||
不属任何族时为 null(79 个组件里 35 个是独立组件)。 */
|
||||
family: c.family ?? null,
|
||||
familyRole: c.familyRole ?? null,
|
||||
familyParams: c.familyParams ?? null,
|
||||
};
|
||||
writeFileSync(`${DETAILS_DIR}/${c.slug}.json`, JSON.stringify(detail) + '\n');
|
||||
writeFileSync(path.join(DETAILS_DIR, `${c.slug}.json`), JSON.stringify(detail) + '\n');
|
||||
detailFiles++;
|
||||
c.hasContract = c.contract != null;
|
||||
delete c.contract;
|
||||
@@ -310,12 +364,11 @@ for (const c of dataForJs.components) {
|
||||
console.log(`[precompute] 阶段D · details:${detailFiles} 个文件 → site/details/<slug>.json`);
|
||||
|
||||
const changelogData = { changelog: dataForJs.changelog || [] };
|
||||
writeFileSync(`${ROOT}/site/changelog.json`, JSON.stringify(changelogData) + '\n');
|
||||
writeFileSync(path.join(ROOT, 'site', 'changelog.json'), JSON.stringify(changelogData) + '\n');
|
||||
delete dataForJs.changelog;
|
||||
console.log(`[precompute] 阶段D · changelog:${changelogData.changelog.length} 条 → site/changelog.json`);
|
||||
|
||||
/* 清理 details 下的孤儿文件(slug 已不存在时删除) */
|
||||
import { readdirSync, unlinkSync } from 'fs';
|
||||
const validSlugs = new Set(dataForJs.components.map((c) => c.slug));
|
||||
let orphans = 0;
|
||||
for (const f of readdirSync(DETAILS_DIR)) {
|
||||
@@ -331,6 +384,5 @@ writeFileSync(DATA_JSON, JSON.stringify(data, null, 2) + '\n');
|
||||
const js = '/* AUTO-GENERATED by build-site.ps1 + tools/precompute.mjs - do not edit by hand */\r\nwindow.AA_DATA = ' + JSON.stringify(dataForJs) + ';\r\n';
|
||||
writeFileSync(DATA_JS, js);
|
||||
|
||||
const beforeKb = 1058;
|
||||
const afterKb = (Buffer.byteLength(js) / 1024).toFixed(0);
|
||||
console.log(`[precompute] data.js: ${beforeKb} KB → ${afterKb} KB(−${beforeKb - afterKb} KB)`);
|
||||
|
||||
Reference in New Issue
Block a user