// 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)`);