Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」, 那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」, 移动端站已是「K」;移动端文档站则完全没有 favicon)。 - 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边; 描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」 - 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark); 顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28)) - 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg) - 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留) - 移动端 7 个模板补 favicon(此前计数 0) 验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/ theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致; 两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。 ## 并行会话成果(本次一并入库) - 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具 + IconPreview 预览页 + ICON-SPEC.md 冻结规格 - 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁 - PC 组件:103 个大后台组件 / 组件11 批次 - uni-app:PC 端试点 + 移动端端实现 + 真实编译验证 ## 工程 - .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库) - CHANGELOG 补品牌标识条目 - ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
700 lines
36 KiB
JavaScript
700 lines
36 KiB
JavaScript
// 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(/<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)`);
|