// 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';
/* 逐示例用法片段(构建期;运行时只读结果):
extractExamples 切场景 / extractClassPropMap 提 class↔prop / buildSnippets 生成四端用法 */
import {
extractExamples, extractClassPropMap, buildSnippets, extractDemoData, extractDemoValue, extractDemoIds,
} from './lib/demo-examples.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_INDEX = path.join(ROOT, '.design_library', 'kole-ui', 'components', 'index.json');
const COMPONENT_COUNT = JSON.parse(readFileSync(COMPONENT_INDEX, 'utf8')).components.length;
const SOURCE_FILE_COUNT = COMPONENT_COUNT * 5;
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(/
]*>([\s\S]*)<\/body>/i);
const body = bodyM ? bodyM[1] : html;
const out = [];
const re = /]*>([\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 ----------
注意(P21 起):这一层是 data.json 的既有字段(对外承诺不改),运行时已不再消费 ——
组件页的「一个使用场景一块代码」改由阶段 F 的 examples 提供(更完整:带标记/预览计划/四端片段)。 */
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;
}
/* ---------- API 表「说明」的出处 ----------
规范系统里没有 Element 那样的运行时枚举(可选值列),实测:源码比较字面量只覆盖 5% 的
prop 且多为 `1`/`number` 这类噪声,规格里真正枚举取值的只有 2 条。硬填等于发明,因此
这里只做「说明」:每条都带出处,无出处的一律标注未明示。
出处优先级:spec(规格原文分条,逐字引用进 cite)> name(API 命名释义)>
contract(契约 dims 同名)> code(源码注释)。
spec 命中且分条是枚举时,额外把取值放进 opts(如「类型:主按钮、次按钮…」)。 */
const PROP_DESC = {
type: '类型', variant: '变体', size: '尺寸', state: '状态', status: '状态', mode: '模式',
disabled: '是否禁用', loading: '是否加载中', readonly: '是否只读', required: '是否必填',
placeholder: '占位提示文本', value: '绑定值', modelValue: '双向绑定值', options: '选项数据',
data: '数据源', items: '数据项', multiple: '是否多选', clearable: '是否可清空',
searchable: '是否可搜索', filterable: '是否可筛选', maxLength: '最大字符数', minLength: '最小字符数',
icon: '图标', title: '标题', description: '描述文本', desc: '描述文本', content: '内容',
align: '对齐方式', width: '宽度', height: '高度', columns: '列配置', cols: '列数', rows: '行数据',
rowKey: '行唯一键', pagination: '分页配置', total: '总数', page: '当前页码', pageSize: '每页条数',
checked: '是否选中', selectedKeys: '已选中项', activeKey: '当前激活项', active: '是否激活',
open: '是否展开', visible: '是否可见', label: '标签文本', labelText: '标签文本', labelKey: '标签字段',
name: '名称', id: '标识', idKey: '标识字段', key: '唯一键', level: '层级', format: '格式',
min: '最小值', max: '最大值', step: '步长', precision: '精度(小数位)', prefix: '前缀',
suffix: '后缀', error: '错误提示', errorText: '错误提示文本', emptyText: '空态提示文本',
helper: '辅助说明', tooltip: '提示浮层内容', duration: '持续时间', closable: '是否可关闭',
bordered: '是否显示边框', striped: '是否斑马纹', scrollable: '是否可滚动', placement: '弹出位置',
trigger: '触发方式', panel: '面板配置', selection: '行选择配置', expandable: '是否可展开',
fixed: '固定列', sortable: '是否可排序', filter: '筛选配置', divider: '分割样式', theme: '主题',
tone: '色调', color: '颜色', count: '数量', progress: '进度值', percent: '百分比', rating: '评分值',
spin: '是否持续旋转', label: '标签文本',
avatar: '头像地址', src: '资源地址', href: '链接地址', target: '打开方式', collapsed: '是否折叠',
direction: '方向', orientation: '方向', summary: '汇总配置', summaryColumn: '汇总列',
selectable: '是否可选择', steps: '步骤数据', presets: '快捷预设', range: '取值范围',
allowHalf: '是否允许半选', showTicks: '是否显示刻度', showInput: '是否显示输入框',
showText: '是否显示文本', texts: '文案映射', users: '用户列表', autoplay: '是否自动播放',
interval: '轮播间隔', images: '图片列表', index: '索引', fontSize: '字号', rotate: '旋转角度',
gap: '间距', padding: '内边距', fullscreen: '是否全屏', provinces: '省份数据',
maskClosable: '点击遮罩是否可关闭', hideFooter: '是否隐藏底部操作区', addable: '是否可新增',
clickable: '是否可点击', collapsible: '是否可折叠', selectedKeysText: '已选文案',
okText: '确定按钮文本', cancelText: '取消按钮文本', actionText: '操作文本',
secondaryText: '次要文本', text: '文本', shadowed: '是否带阴影', defaultExpandedKeys: '默认展开项',
defaultActiveKey: '默认激活项', disabledKeys: '禁用项', loadingText: '加载文案',
trend: '趋势值', trendText: '趋势文案', threshold: '触发阈值', showRing: '是否显示环形进度',
separator: '分隔符', selectAll: '是否显示全选', length: '字符长度', masked: '是否掩码显示',
tip: '提示文案', hasError: '是否处于错误态', showStrength: '是否显示强度', codes: '国际区号列表',
showInfo: '是否显示数值文案', block: '骨架块配置', inline: '是否行内展示', dark: '是否深色外观',
emoji: '表情图标', confirmType: '确认按钮样式', confirmText: '确认按钮文案', dontAsk: '是否提供不再询问',
fields: '字段配置', brand: '品牌标识', employee: '员工数据',
};
/* 规格分条里与该 prop 相关的关键词(用于把规格原文逐字引用进 cite) */
const PROP_SPEC_KEYWORDS = {
type: /类\s*型|种类|变体/, variant: /变体|类型/, size: /尺寸/, state: /状\s*态/, status: /状\s*态/,
mode: /模\s*式/, disabled: /禁用/, loading: /加载/, readonly: /只读/, required: /必填/,
placeholder: /占位/, value: /默认值|值/, modelValue: /默认值|值/, options: /选项|候选/,
multiple: /多选/, clearable: /清空|清除/, searchable: /搜索/, maxLength: /字数|长度/,
icon: /图标/, align: /对齐/, width: /宽/, height: /高/, radius: /圆角/, color: /颜色/,
theme: /主题|配色/, title: /标题/, description: /描述/, pagination: /分页/, total: /总数/,
password: /密码/, error: /错误|校验/, label: /标签/, text: /文案|文字/, content: /内容/,
};
const EMIT_DESC = {
click: '点击时触发', change: '值变化时触发', input: '输入内容变化时触发', focus: '获得焦点时触发',
blur: '失去焦点时触发', close: '关闭时触发', open: '展开时触发', confirm: '确认操作时触发',
cancel: '取消操作时触发', select: '选中某一项时触发', remove: '移除条目时触发', clear: '清空内容时触发',
scroll: '滚动时触发', expand: '展开时触发', collapse: '收起时触发', submit: '提交时触发',
reset: '重置时触发', search: '搜索时触发', sortchange: '排序变化时触发',
selectionchange: '选中项变化时触发', currentchange: '当前页或当前项变化时触发',
sizechange: '每页条数变化时触发', refresh: '刷新时触发', retry: '重试时触发', download: '下载时触发',
upload: '上传时触发', success: '校验通过时触发', fail: '校验失败时触发', error: '出错时触发',
show: '显示时触发', hide: '隐藏时触发', next: '前进到下一步时触发', prev: '返回到上一步时触发',
finish: '流程完成时触发', add: '新增条目时触发', edit: '进入编辑时触发', delete: '删除时触发',
pagechange: '页码变化时触发', toggle: '切换状态时触发', load: '加载时触发', drop: '拖放完成时触发',
start: '开始时触发', end: '结束时触发', timeupdate: '进度更新时触发', play: '播放时触发',
pause: '暂停时触发', ended: '播放结束时触发', copy: '复制时触发', apply: '应用配置时触发',
action: '点击操作按钮时触发', secondary: '点击次要操作按钮时触发', check: '勾选状态变化时触发',
rowclick: '点击数据行时触发', stepclick: '点击步骤节点时触发', export: '点击导出按钮时触发',
home: '点击「返回首页」时触发', message: '点击「发消息」时触发', view: '点击「查看详情」时触发',
tabadd: '点击新增标签时触发', tabremove: '移除标签页时触发',
};
/* 事件名看不出语义时(ok / action …)按 emit 调用点的绑定写法判断:
alertmodal 的 ok 绑在遮罩上,modal / fullscreenmodal 的 ok 绑在确认按钮上——
同一个事件名在不同组件里语义不同,不能一刀切。 */
function annotateEmits(emits, code) {
emits.forEach((e) => {
if (String(e.name).indexOf('update:') === 0) { e.descZh = '双向绑定更新事件 (v-model)'; return; }
const named = emitDescFor(e.name);
if (named) { e.descZh = named; return; }
const single = code.indexOf("emit('" + e.name + "'");
const dollar = code.indexOf("$emit('" + e.name + "'");
const at = single >= 0 ? single : dollar;
const near = at >= 0 ? code.slice(Math.max(0, at - 180), at) : '';
if (/@click\.self/.test(near)) e.descZh = '点击遮罩时触发';
else if (/primary|确定|保存/.test(near)) e.descZh = '点击确认按钮时触发';
else if (/@click|onClick/.test(near)) e.descZh = '点击时触发';
else e.descZh = '组件交互触发事件';
});
return emits;
}
function emitDescFor(name) {
if (String(name).indexOf('update:') === 0) return '双向绑定更新事件 (v-model)';
const key = String(name).replace(/[-_]/g, '').toLowerCase();
return EMIT_DESC[key] || '';
}
function specClauseList(c) {
/* specLines 绝大多数是数组,但见过单条字符串的情况(breadcrumb)——统一归一化 */
const raw = Array.isArray(c.specLines) ? c.specLines : String(c.specLines || '').split(/\r?\n/);
return raw.map((line) => String(line).replace(/^\s*[-•*]\s*/, '').trim()).filter(Boolean);
}
/* 规格分条去掉前缀后的枚举取值:「类型:主按钮、次按钮、文字按钮」→ ['主按钮','次按钮','文字按钮']
两道闸门(实测出来的):
1) 由调用方校验「分条标签 === 该 prop 的说明」—— card.title / modal.title 会被「结构:标题区、内容区、操作区」
这类**结构描述**匹配上,但它枚举的是卡片分区,不是 title 的取值;
2) 过滤不像取值的片段 —— tag.color 的「颜色:浅色背景+深色文字(如蓝#F0F5FF/#2F54EB…)」会切出
'绿#F6FFED' / '#52C41A等)' 这种碎片。 */
function enumerationAfterColon(clause) {
const m = clause.match(/[::](.+)$/);
if (!m) return [];
const parts = m[1].split(/[、,,//]| 或 /).map((s) => s.trim()).filter((s) => s && s.length <= 8);
if (parts.length < 2) return [];
const valueLike = parts.every((s) => !/[#()()\[\]{}]|\d+px|\//.test(s));
return valueLike ? parts : [];
}
function clauseLabel(clause) {
return String(clause).split(/[::]/)[0].trim();
}
/* 给 props 补 desc(短说明)/ descSrc(出处)/ cite(引用原文)/ opts(规格枚举取值) */
function annotateProps(c, props) {
const clauses = specClauseList(c);
const dims = (c.contract && c.contract.dims) || [];
props.forEach((p) => {
const kw = PROP_SPEC_KEYWORDS[p.name];
let clause = '';
if (kw) clause = clauses.find((L) => kw.test(L)) || '';
let desc = clause ? (PROP_DESC[p.name] || clause.split(/[::]/)[0].trim()).slice(0, 12) : '';
let src = desc ? 'spec' : '';
if (!desc && PROP_DESC[p.name]) { desc = PROP_DESC[p.name]; src = 'name'; }
if (!desc) {
const dim = dims.find((d) => String(d.name || '').toLowerCase() === String(p.name).toLowerCase());
if (dim) { desc = String(dim.name); src = 'contract'; }
}
if (!desc && p.desc && p.desc !== '—') { desc = p.desc; src = 'code'; }
p.desc = desc || '—';
p.descSrc = src || 'none';
if (clause) p.cite = clause.slice(0, 120);
/* 取值副行的前提:分条标签就是这个 prop 的说明(否则是「结构」这类别的维度在枚举) */
if (clause && desc && (clauseLabel(clause) === desc || clauseLabel(clause).indexOf(desc) === 0)) {
const vals = enumerationAfterColon(clause);
if (vals.length) p.opts = vals.slice(0, 8);
}
});
return props;
}
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 = / x.name === sName)) {
slots.push(sName === 'default'
? { name: sName, descZh: '默认插槽(组件文本或主体内容)', custom: false }
: { name: sName, descZh: '', custom: true });
}
}
/* 说明与出处:统一在收集完成后补,避免三处 emit/prop 创建路径各写一遍 */
annotateProps(c, props);
annotateEmits(emits, [v3, v2, jsx, (c.sources && c.sources.html) || ''].join('\n'));
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})`);
/* ---------- 阶段 F:逐示例用法片段(demo-examples)----------
需求:一个使用场景 = 一块预览 + **一块调用代码**(Element 文档的形态)。
产物:
site/examples/.json 该组件全部示例(标题/说明/标记/预览隐藏计划/四端用法片段)
data.json 的 c.examples 完整示例(对外承诺:一次请求拿到全部)
data.js 的 c.examples 只留 { id, title, source }(约 70KB→20KB),正文按需 fetch
预览不重排:示例预览仍加载**原始演示页**,运行时只把其它场景 display:none
(演示脚本对节点有索引依赖,切分标记会渲染出错的内容 —— 见 lib 头部说明)。 */
const EXAMPLES_DIR = path.join(ROOT, 'site', 'examples');
if (!existsSync(EXAMPLES_DIR)) mkdirSync(EXAMPLES_DIR, { recursive: true });
const frameworkPrefixOf = {};
for (const c of data.components) {
/* frameworksPrefix 不在 data.json 里(那是给站点的精简数据),单独从设计库索引取 */
frameworkPrefixOf[c.slug] = null;
}
try {
const libIndex = JSON.parse(readFileSync(path.join(ROOT, '.design_library', 'kole-ui', 'components', 'index.json'), 'utf8'));
for (const it of libIndex.components || []) frameworkPrefixOf[it.slug] = it.frameworksPrefix;
} catch (e) { /* 索引缺失时退化为用 slug 推导(下面兜底) */ }
let exampleCount = 0;
let mappedCount = 0;
let unsafeCount = 0;
const exampleProblems = [];
for (const c of data.components) {
const prefix = frameworkPrefixOf[c.slug] || (c.files && c.files.jsx ? path.basename(c.files.jsx, '.jsx') : c.slug);
let examples = [];
try {
const res = extractExamples({ html: c.sources.html, slug: c.slug, nameZh: c.name, contract: c.contract });
const map = extractClassPropMap({
jsx: c.sources.jsx, vue3: c.sources.vue3, vue2: c.sources.vue2,
css: c.sources.css, api: c.api, contract: c.contract,
});
const demoData = extractDemoData(c.sources.html);
const demoValue = extractDemoValue(c.sources.html);
const demoIds = extractDemoIds(c.sources.html);
examples = res.examples.map((ex) => {
const snip = buildSnippets({ example: ex, map, api: c.api, prefix, demoData, demoValue, demoIds });
return {
id: ex.id,
title: ex.title,
titleSrc: ex.titleSrc || '',
desc: ex.desc,
html: ex.html,
preview: ex.preview,
previewSafe: ex.previewSafe,
previewUnsafeReasons: ex.previewUnsafeReasons,
source: snip.source,
code: snip.ends,
notes: snip.notes || [],
};
});
c.examplesMode = res.mode;
} catch (e) {
exampleProblems.push(`${c.slug}: ${e.message}`);
examples = [];
}
c.examples = examples;
exampleCount += examples.length;
mappedCount += examples.filter((e) => e.source !== 'api-derived').length;
unsafeCount += examples.filter((e) => !e.previewSafe).length;
writeFileSync(path.join(EXAMPLES_DIR, `${c.slug}.json`), JSON.stringify({ slug: c.slug, mode: c.examplesMode || 'single', examples }) + '\n');
}
console.log(`[precompute] 阶段F · examples:${exampleCount} 个场景 / ${data.components.length} 个组件(逐字映射 ${mappedCount},预览可隔离 ${exampleCount - unsafeCount})`);
if (exampleProblems.length) {
console.error('[FATAL] 示例提取失败:');
exampleProblems.forEach((p) => console.error(' - ' + p));
process.exit(1);
}
/* 清理 examples 下的孤儿文件(slug 已不存在时删除) */
{
const valid = new Set(data.components.map((c) => c.slug));
let orphans = 0;
for (const f of readdirSync(EXAMPLES_DIR)) {
if (!f.endsWith('.json')) continue;
if (!valid.has(f.slice(0, -5))) { unlinkSync(path.join(EXAMPLES_DIR, f)); orphans++; }
}
if (orphans) console.log(`[precompute] 阶段F · 清理孤儿 examples:${orphans} 个`);
}
/* ---------- 阶段 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,运行时已不消费)
- 逐示例卡片 → site/examples/.json(阶段 F,按组件懒加载)
剩余消费全是异步安全(代码展示/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//{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//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];
}
}
/* 示例正文(标记 + 四端片段)约 237KB,data.js 只留目录信息,正文走 examples/.json */
if (Array.isArray(c.examples) && c.examples.length) {
c.examplesRef = `examples/${c.slug}.json`;
c.examples = c.examples.map((ex) => ({ id: ex.id, title: ex.title, source: ex.source }));
}
}
/* ---------- 阶段 D:分离 contract/specLines/changelog ----------
data.js 体积 ord:contract 38KB + changelog 11.6KB + specLines 11KB ≈ 60KB。
外壳只留 hasContract 布尔;详情写 site/details/.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/.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} 个`);
/* ---------- 阶段E · 版本清单(文档站版本切换用,见 site/app.js 的 loadVersionManifest) ----------
两个来源合并:
a) 站点根 /site/ —— 本次构建的版本(data.meta.version),永远存在,path 记作 '..';
b) 归档快照 //site/ —— 由 tools/snapshot-site.mjs 产出,落在**仓库根**
(URL 是 //site/…),且**只有目录真在磁盘上**才写入清单:
没归档的版本不列,避免版本菜单点进去 404。
path 一律是「相对站点根的路径」,与 siteDir 无关(站点目录必须继续叫 site:
SITE_BASE 取 pathname 里第一段 '/site/',改名即全站 404)。 */
(function writeVersionManifest() {
const SITE_DIR = path.join(ROOT, 'site');
const dataForCount = dataForJs;
const chlogPath = path.join(ROOT, 'CHANGELOG.md');
const changelog = existsSync(chlogPath) ? readFileSync(chlogPath, 'utf8') : '';
const dated = {};
for (const m of changelog.matchAll(/^## \[(\d+\.\d+\.\d+)\]\s*-\s*(\S+)/gm)) dated[m[1]] = m[2];
const snapshots = readdirSync(ROOT, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
.filter((e) => existsSync(path.join(ROOT, e.name, 'site', 'index.html')))
.map((e) => e.name)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
const buildVersion = String((dataForCount.meta && dataForCount.meta.version) || '0.0.0');
/* 根站点永远代表「本次构建的版本」,且永远在首位(版本菜单的默认项)。
快照里若有一份与构建版本同号(本次构建自身也归档了),它是冗余项 —— 跳过,
否则菜单里会出现两个同名版本,一个指向根站点、一个指向快照。 */
const versions = [{ version: buildVersion, path: '..', date: dated[buildVersion] || '', latest: false }];
snapshots
.filter((v) => v !== buildVersion)
.forEach((v) => versions.push({ version: v, path: v, date: dated[v] || '', latest: false }));
if (versions.length) versions[0].latest = true;
/* 本地日期(与 build-site.ps1 的 generated 同口径,不用 UTC,避免跨时区差一天) */
const now = new Date();
const localDay = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const doc = {
siteDir: path.basename(SITE_DIR),
generatedAt: localDay,
latest: versions.length ? versions[0].version : null,
versions
};
writeFileSync(path.join(SITE_DIR, 'versions.json'), JSON.stringify(doc, null, 2) + '\n');
/* 仓库根也放一份:站点根在部署前缀下(Pages 的 //site/、nginx 的 /prefix/site/)
时清单 URL 是 /versions.json,对应磁盘上的根目录这一份。两份内容必须一致。 */
writeFileSync(path.join(ROOT, 'versions.json'), JSON.stringify(doc, null, 2) + '\n');
console.log(
`[precompute] 阶段E · 版本清单:${versions.length} 个在线版本(根站点 = v${buildVersion},归档快照 ${snapshots.length} 个)→ site/versions.json`
);})();
/* ---------- 写回 ---------- */
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.KOLE_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)`);