Files
aurora-admin/tools/precompute.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

389 lines
16 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.
// P4:`data.js` 瘦身 —— 分步降低运行时对源码全文的依赖
//
// 背景(实测):
// data.json 991KB,其中 components[].sources 占 888KB(89.6%)
// 各端源码量:html 245KB / vue2 184KB / vue3 181KB / jsx 155KB / css 119KB
//
// 哪些 sources 被【渲染期同步】消费(改造难点):
// extractComponentAPI → vue3 / vue2 / jsx(3 端)
// extractScenarios → html(1 端)
// css → 不参与任何解析(可安全分离)
//
// 策略:
// 阶段 A —— 预计算 scenarios,验证「构建期预计算 + 运行时回退」链路可行
// 阶段 B —— 分离 css 到独立文件(零风险,不参与解析)
// 阶段 C —— 预计算 API 表(props/emits/slots),再分离 html/jsx/vue2/vue3
//
// 设计原则:运行时保留回退路径,预计算缺失时功能不受影响。
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 = 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');
process.exit(1);
}
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) {
const html = (c.sources && c.sources.html) || '';
const bodyM = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const body = bodyM ? bodyM[1] : html;
const out = [];
const re = /<h2[^>]*>([\s\S]*?)<\/h2>/gi;
let m;
while ((m = re.exec(body)) !== null) {
let text = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
text = text.replace(/^\d+[.、]\s*/, '');
if (text) out.push(text);
}
return out;
}
/* ---------- 阶段 A:预计算 scenarios ---------- */
let withScenarios = 0;
let totalScenarios = 0;
for (const c of data.components) {
const s = extractScenarios(c);
c.scenarios = s;
totalScenarios += s.length;
if (s.length >= 2) withScenarios++; // app.js 的 buildScenarioCard 要求 >= 2
}
console.log(`[precompute] 阶段A · scenarios:${totalScenarios} 条 / ${withScenarios} 个组件有 ≥2 条`);
/* ---------- 与 app.js 的 extractComponentAPI 逐行一致 ----------
唯一差别:desc 存中文源串(不调 T),运行时由 app.js 包 T() 做中英映射。
这样中文模式输出与今天逐字节一致,英文模式仍能查字典翻译。
slots 额外存 custom 标记:自定义插槽的 desc 需按 name + 后缀动态拼接,
不能存拼接后的整串(否则英文模式查不到字典)。 */
function scanObjectAt(src, openIdx) {
let depth = 0, inStr = null, escCh = false, end = -1;
for (let i = openIdx; i < src.length; i++) {
const ch = src.charAt(i);
if (inStr) {
if (escCh) { escCh = false; continue; }
if (ch === '\\') { escCh = true; continue; }
if (ch === inStr) inStr = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
if (ch === '{') depth++;
else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
}
return end < 0 ? null : src.slice(openIdx, end + 1);
}
function stripOuterBraces(s) { return s.replace(/^\s*\{/, '').replace(/\}\s*$/, ''); }
function splitTopLevel(body) {
const segs = [];
let cur = '', depth = 0, inStr = null, escCh = false;
for (let i = 0; i < body.length; i++) {
const ch = body.charAt(i);
if (inStr) {
cur += ch;
if (escCh) { escCh = false; continue; }
if (ch === '\\') { escCh = true; continue; }
if (ch === inStr) inStr = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; cur += ch; continue; }
if (ch === '{' || ch === '[' || ch === '(') { depth++; cur += ch; continue; }
if (ch === '}' || ch === ']' || ch === ')') { depth--; cur += ch; continue; }
if (ch === ',' && depth === 0) { segs.push(cur); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) segs.push(cur);
return segs;
}
function scanTopLevelValue(str) {
let depth = 0, inStr = null, escCh = false;
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (inStr) {
if (escCh) { escCh = false; continue; }
if (ch === '\\') { escCh = true; continue; }
if (ch === inStr) inStr = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
if (ch === '{' || ch === '[' || ch === '(') depth++;
else if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) return str.slice(0, i); depth--; }
else if (ch === ',' && depth === 0) return str.slice(0, i);
}
return str;
}
function finalizeProp(p) {
let type = 'any';
let def = '—';
let req = false;
const typeM = p.raw.match(/type\s*:\s*(\[[^\]]+\]|[a-zA-Z0-9_$]+)/);
if (typeM) type = typeM[1].replace(/\[|\]/g, '').replace(/,\s*/g, ' | ');
const defM = /default\s*:/.exec(p.raw);
if (defM) {
let val = scanTopLevelValue(p.raw.slice(defM.index + defM[0].length)).trim();
def = val.replace(/\(\)\s*=>\s*/, '') || '—';
if (def === '()') def = '—';
def = def.replace(/\s*\n\s*/g, ' ').replace(/\s{2,}/g, ' ');
if (def.length > 48) def = def.slice(0, 45) + '…';
}
if (/required\s*:\s*true/.test(p.raw)) req = true;
return { name: p.name, type, def, req, desc: p.comment || '—' };
}
function extractComponentAPI(c) {
const v3 = (c.sources && c.sources.vue3) || '';
const v2 = (c.sources && c.sources.vue2) || '';
const jsx = (c.sources && c.sources.jsx) || '';
const code = v3 || v2;
const props = [];
const emits = [];
const slots = [];
let propsBody = null;
const dpM = /defineProps\s*\(/.exec(code);
if (dpM) {
const openIdx = code.indexOf('{', dpM.index);
if (openIdx >= 0) propsBody = stripOuterBraces(scanObjectAt(code, openIdx) || '');
}
if (!propsBody) {
const prM = /props\s*:\s*\{/.exec(code);
if (prM) {
const openIdx2 = prM.index + prM[0].length - 1;
propsBody = stripOuterBraces(scanObjectAt(code, openIdx2) || '');
}
}
if (propsBody && propsBody.trim()) {
splitTopLevel(propsBody).forEach((seg) => {
const m = seg.match(/^\s*([a-zA-Z0-9_$]+)\s*:\s*([\s\S]*)$/);
if (!m) return;
const cM = m[2].match(/\/\/\s*([^\n]+)\s*$/);
props.push(finalizeProp({ name: m[1], comment: cM ? cM[1].trim() : '', raw: m[2] }));
});
} else if (jsx) {
const fnM = jsx.match(/export default function\s+[A-Za-z0-9_$]+\s*\(\s*\{([^}]+)\}/);
if (fnM) {
fnM[1].split(',').forEach((param) => {
const p = param.trim();
if (!p || p.indexOf('...') === 0) return;
const parts = p.split('=');
const name = parts[0].trim();
const def = parts[1] ? parts[1].trim() : '—';
if (name.indexOf('on') === 0 && /[A-Z]/.test(name.charAt(2))) {
emits.push({ name, descZh: '回调触发事件 (Event callback)', params: '(event: any) => void' });
} else if (name === 'children') {
slots.push({ name: 'default', descZh: '子节点内容 (children)', custom: false });
} else {
props.push({ name, type: 'any', def, req: false, desc: '—' });
}
});
}
}
/* 只用 parseStringLiteralArray 解析字符串字面量数组,绝不求值。
旧实现把数组字面量交给函数构造器求值,投毒的 .vue 会在构建期执行任意 JS(已实测)。 */
const mEmits = code.match(/defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/);
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;
while ((eMatch = emitRegex.exec(code)) !== null) {
const eName = eMatch[1];
if (!emits.some((x) => x.name === eName)) {
emits.push({ name: eName, descZh: '交互回调触发事件', params: '—' });
}
}
const slotRegex = /<slot(?:\s+name=["']([^"']+)["'])?/g;
let sMatch;
while ((sMatch = slotRegex.exec(code)) !== null) {
const sName = sMatch[1] || 'default';
if (!slots.some((x) => x.name === sName)) {
slots.push(sName === 'default'
? { name: sName, descZh: '默认插槽(组件文本或主体内容)', custom: false }
: { name: sName, descZh: '', custom: true });
}
}
return { props, emits, slots };
}
/* ---------- 阶段 C:预计算 API 表 ---------- */
let apiProps = 0, apiEmits = 0, apiSlots = 0, apiComps = 0;
for (const c of data.components) {
const api = extractComponentAPI(c);
c.api = api;
apiProps += api.props.length;
apiEmits += api.emits.length;
apiSlots += api.slots.length;
if (api.props.length || api.emits.length || api.slots.length) apiComps++;
}
console.log(`[precompute] 阶段C · api:${apiComps} 个组件有 API 表(props ${apiProps} / emits ${apiEmits} / slots ${apiSlots})`);
/* ---------- 阶段 B:分离 css 源码到独立文件 ----------
css 不参与任何解析(extractComponentAPI 只读 vue3/vue2/jsx,extractScenarios 只读 html),
因此可安全移出 data.js,改为按需 fetch。
data.json 保持完整(For Agents 承诺不变)。 */
if (!existsSync(SRC_DIR)) mkdirSync(SRC_DIR, { recursive: true });
/* ---------- 阶段 C-2:分离 html/jsx/vue2/vue3 ----------
渲染期同步消费已全部被预计算替代:
- extractComponentAPI → c.api(本文件阶段 C)
- extractScenarios → c.scenarios(阶段 A)
剩余消费全是异步安全(代码展示/srcOf、Playground),由 app.js 按需 fetch。 */
const SPLIT_KINDS = ['html', 'jsx', 'vue2', 'vue3'];
let splitFiles = 0;
for (const c of data.components) {
if (!c.sources) continue;
for (const kind of SPLIT_KINDS) {
if (!c.sources[kind]) continue;
const dir = path.join(SRC_DIR, c.slug);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, `${kind}.txt`), c.sources[kind]);
splitFiles++;
}
}
console.log(`[precompute] 阶段C-2 · 源码已分离:${splitFiles} 个文件 → site/sources/<slug>/{html,jsx,vue2,vue3}.txt`);
/* css 分离(阶段 B 保留) */
let cssSplit = 0;
for (const c of data.components) {
if (!c.sources || !c.sources.css) continue;
const dir = path.join(SRC_DIR, c.slug);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
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));
for (const c of dataForJs.components) {
if (!c.sources) continue;
for (const kind of ['html', 'css', 'jsx', 'vue2', 'vue3']) {
if (c.sources[kind] != null) {
c.sourcesRef = c.sourcesRef || {};
c.sourcesRef[kind] = `sources/${c.slug}/${kind}.txt`;
delete c.sources[kind];
}
}
}
/* ---------- 阶段 D:分离 contract/specLines/changelog ----------
data.js 体积 ord:contract 38KB + changelog 11.6KB + specLines 11KB ≈ 60KB。
外壳只留 hasContract 布尔;详情写 site/details/<slug>.json,日志写 site/changelog.json。
data.json 保持全量完整(对外承诺不变)。 */
const DETAILS_DIR = path.join(ROOT, 'site', 'details');
if (!existsSync(DETAILS_DIR)) mkdirSync(DETAILS_DIR, { recursive: true });
let detailFiles = 0;
for (const c of dataForJs.components) {
const detail = {
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(path.join(DETAILS_DIR, `${c.slug}.json`), JSON.stringify(detail) + '\n');
detailFiles++;
c.hasContract = c.contract != null;
delete c.contract;
delete c.specLines;
}
console.log(`[precompute] 阶段D · details:${detailFiles} 个文件 → site/details/<slug>.json`);
const changelogData = { changelog: dataForJs.changelog || [] };
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 已不存在时删除) */
const validSlugs = new Set(dataForJs.components.map((c) => c.slug));
let orphans = 0;
for (const f of readdirSync(DETAILS_DIR)) {
if (!f.endsWith('.json')) continue;
if (!validSlugs.has(f.slice(0, -5))) { unlinkSync(`${DETAILS_DIR}/${f}`); orphans++; }
}
if (orphans) console.log(`[precompute] 阶段D · 清理孤儿 details:${orphans} 个`);
/* ---------- 写回 ---------- */
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 afterKb = (Buffer.byteLength(js) / 1024).toFixed(0);
console.log(`[precompute] data.js: ${beforeKb} KB → ${afterKb} KB(−${beforeKb - afterKb} KB)`);