feat(P4): precompute移植+app.js sources双向回填+回归1003/1003+验收体积/文件/data.json
Deploy to GitHub Pages / deploy (push) Failing after 16s
Regression / regression (push) Failing after 15m2s

This commit is contained in:
aurora-admin
2026-09-12 14:18:41 +08:00
parent 16f365a045
commit c65a69c34e
401 changed files with 22932 additions and 140 deletions
+247 -9
View File
@@ -10,9 +10,9 @@
// css → 不参与任何解析(可安全分离)
//
// 策略:
// 阶段 A(本文件)—— 预计算 scenarios,验证「构建期预计算 + 运行时回退」链路可行
// 阶段 B(后续)—— 分离 css 到独立文件(零风险,不参与解析)
// 阶段 C(后续)—— 预计算 API 表,再分离 4 端源码
// 阶段 A —— 预计算 scenarios,验证「构建期预计算 + 运行时回退」链路可行
// 阶段 B —— 分离 css 到独立文件(零风险,不参与解析)
// 阶段 C —— 预计算 API 表(props/emits/slots),再分离 html/jsx/vue2/vue3
//
// 设计原则:运行时保留回退路径,预计算缺失时功能不受影响。
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
@@ -57,6 +57,183 @@ for (const c of data.components) {
}
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: '—' });
}
});
}
}
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 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。
@@ -64,6 +241,27 @@ console.log(`[precompute] 阶段A · scenarios:${totalScenarios} 条 / ${withS
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 = `${SRC_DIR}/${c.slug}`;
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(`${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;
@@ -74,17 +272,58 @@ for (const c of data.components) {
}
console.log(`[precompute] 阶段B · css 已分离:${cssSplit} 个文件 → site/sources/<slug>/css.txt`);
/* 在 data.js 里把 css 换成引用(data.json 保持完整) */
/* 在 data.js 里把全部 5 端换成引用(data.json 保持完整) */
const dataForJs = JSON.parse(JSON.stringify(data));
for (const c of dataForJs.components) {
if (c.sources && c.sources.css) {
c.sourcesRef = c.sourcesRef || {};
c.sourcesRef.css = `sources/${c.slug}/css.txt`;
delete c.sources.css;
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 = `${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 ?? [],
};
writeFileSync(`${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(`${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)) {
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');
@@ -95,4 +334,3 @@ 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)`);