Files
aurora-admin b59247231a
Regression / regression (push) Canceled after 0s
feat(安全+品牌): 对外产物发布脱敏(3注入点/2465文件0泄露) + Kole Cup 定稿
安全:CHANGELOG.md 是仓库内部变更流水(含服务器目录、镜像回滚标签、内网网段、
部署时序、AI 工作流用语),此前被 build-site.ps1 / precompute.mjs / build-mobile.mjs
原样注入站点,而站点是公网可下载的静态文件 —— 抓一次 /site/m/changelog.html
即可拿到内网地址段与服务器目录布局。

- 新增 tools/lib/redact-publish.mjs(零依赖,构建期过滤,不改 CHANGELOG.md,
  内部可追溯性完整保留)
- 接入 precompute.mjs(PC data.json/changelog.json)与 build-mobile.mjs
  (移动端更新日志页);build-site.ps1 不碰(ASCII-only 铁律)
- 只处理 changelog 字段:components[].sources 是规范实现源码,逐字保真
  (详情页代码区主动高亮注释,剥注释会破坏该功能)
- 实测消除:/opt/aurora-admin.prev-*、kole-ui-showcase:pre-*、docker compose、
  192.168.5.7、16 位产物指纹、1531/1531、并发会话/本会话/派子 agent
- settings.html 演示占位 IP 192.168.5.0/24(= 真实网段)改为 RFC 5737 的 192.0.2.0/24

品牌:Kole Cup 饮料杯标记定稿(几何 K → 圆角杯盖 + 杯身负空间 K,无吸管),
brand-mark.json 升 schemaVersion 3(paths 支持 { d, evenodd }),
verify:brand 增至 25 条(新增 B9b:负空间必须带 fill-rule)。

验证:发布集 2465 文件全量扫描 0 泄露;PC 回归 100%(1464/1464) ·
移动端 100%(807/807);门禁品牌 25 / 隔离 31 / 移动文档 12 / 示例 9 / 版本 40 / i18n 17 全绿;
已按 AGENTS §九 发布公网,2461/2461 逐字节一致,五项验收全过。

已知未处理(既有缺口,ROADMAP S7-P28 已记录):data.mobile.json 的
meta.generated 为墙上时钟,会让 CI 的「生成物可复现」断言在重跑构建后永远非空;
该 CI 流水线本身亦从未通过(无 runner)。
2026-09-23 07:31:06 +08:00

708 lines
36 KiB
JavaScript
Raw Permalink 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';
/* 发布脱敏:CHANGELOG 段会被原样注入对外产物(data.json / changelog.json),
而它是仓库内部变更流水,含服务器目录、镜像回滚标签、内网网段与部署时序。
过滤只发生在「写产物」这一步,CHANGELOG.md 自身保持完整(见该模块头注释)。 */
import { redactText, redactDeep } from './lib/redact-publish.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);
/* 变更日志对外脱敏:它是从 CHANGELOG.md 抽的仓库内部流水,含部署目录、镜像标签、
内网网段与部署时序;产物是公网可下载的静态文件。
只处理 changelog —— components[].sources 是规范实现源码,必须逐字保真。 */
if (Array.isArray(data.changelog)) data.changelog = redactDeep(data.changelog);
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 ----------
注意(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 = /<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 });
}
}
/* 说明与出处:统一在收集完成后补,避免三处 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/<slug>.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/<slug>.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/<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];
}
}
/* 示例正文(标记 + 四端片段)约 237KB,data.js 只留目录信息,正文走 examples/<slug>.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/<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} 个`);
/* ---------- 阶段E · 版本清单(文档站版本切换用,见 site/app.js 的 loadVersionManifest) ----------
两个来源合并:
a) 站点根 /site/ —— 本次构建的版本(data.meta.version),永远存在,path 记作 '..';
b) 归档快照 /<x.y.z>/site/ —— 由 tools/snapshot-site.mjs 产出,落在**仓库根**
(URL 是 /<x.y.z>/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 的 /<repo>/site/、nginx 的 /prefix/site/)
时清单 URL 是 <prefix>/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)`);