Files
aurora-admin/tools/lib/demo-examples.mjs
T
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 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 未做部分)
2026-09-21 10:05:48 +08:00

1222 lines
57 KiB
JavaScript
Raw 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.
/**
* demo-examples.mjs — 把演示页拆成「单个使用场景 ⇄ 单个用法代码块」(零运行时依赖)
*
* 需求(用户 2026-09-20 反馈,对齐 Element *Radio 单选框* 文档页形态):
* 一个使用场景 = 一块可交互预览 + **一块调用代码**
* (如 `<el-radio disabled v-model="radio" label="选中且禁用">备选项</el-radio>`)。
* 旧文档每组件只有一个 iframe 整页演示,「显示代码」展开的是**整份实现文件**
* (Button.html 117 行 / Button.jsx 66 行)—— 那是组件内部实现,不是使用者的调用写法。
*
* 本模块只在**构建期**运行(运行时只读结果,不做 HTML 解析):
* 1. extractExamples() 从 H5 演示页切出有序场景(标题 / 说明 / 本场景标记 / 预览隐藏计划)
* 2. extractClassPropMap() 从组件源码提「class ↔ prop」映射(只认源码里真实存在的对应)
* 3. buildSnippets() 用 1+2 生成 H5 / React / Vue 2 / Vue 3 四端**用法片段**
*
* 四条不可越线的原则(tools/verify-examples.mjs 逐条复核):
* - 不发明:片段里的 prop 名必须出现在该组件的 prop 集合里(API 表 ∪ 各端参数表),
* 且每个 prop 都要有出处(类名映射 / 属性直传 / 数据属性 / 样式变量 / 条件渲染)。
* 片段里出现的类名必须在该组件 CSS 里真实存在。
* 对不上就换策略,并在 source 里标明出处:demo-mapped / demo-data / api-derived。
* - 预览不重排:场景预览加载的仍是**原始演示页**,只把其它场景换成 `display:none`。
* 不能靠切分 HTML 造预览——演示页脚本对节点有索引依赖(ResultVariants 的 `DATA[i]`、
* Modal 的 `#mount`),切分会渲染出**错的内容**。隐藏不删除:脚本仍看到全量 DOM。
* - 可复核:H5 片段与演示页对应节点逐条对应(只删测试钩子 data-behavior/data-assert),
* 因此「演示页改了、片段没跟」会被 verify-examples 抓到。
* - 零依赖:只用正则与手写小解析器,不引任何 npm 包。
*/
/* 页面脚手架类名:演示页自己的布局类,不属于任何组件 API。
仅当该 class **不在**组件 CSS 里时才剔除(Card 的 .kole-card 是组件本体,不能误删)。 */
const SCAFFOLD_CLASSES = new Set([
'group', 'row', 'col', 'sub', 'hint', 'field', 'demo', 'demo-block', 'demo-item',
'demo-title', 'demo-note', 'demo-label', 'demo-caption', 'demo-shell', 'demo-grid',
'kole-page', 'kole-demo-toolbar', 'kole-demo-panel', 'kole-demo-banner', 'kole-demo-spacer',
'kole-demo-hint', 'kole-demo-switch', 'toolbar',
]);
/* 组件内部零件类:组件自己会渲染出来,使用者写法里不需要出现(也不会映射成 prop) */
const INTERNAL_PART_CLASSES = new Set([
'affix', 'prefix', 'suffix', 'action', 'clear', 'toggle', 'spinner', 'btn-icon', 'btn-label',
'error-text', 'kole-tag-close', 'kole-input-inner', 'check', 'x', 'arrow', 'caret', 'icon',
]);
const BOILERPLATE_TAGS = new Set(['h1', 'hr']);
const BOILERPLATE_CLASS_RE = /\b(sub|demo-note|demo-title|page-desc|demo-head)\b/;
const VOID_TAGS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
'param', 'source', 'track', 'wbr',
]);
/* 自身就是「场景标签」的节点:它开启一个新场景 */
const LABEL_TAGS = new Set(['h2', 'h3', 'h4', 'legend']);
const LABEL_CLASS_RE = /\b(hint|demo-hint|label|demo-label|group-title|demo-caption)\b/;
/* 工具栏 / 切换器:与紧随其后的内容块同属一个场景(timelinelist / cardlist / steplist 形态) */
const TOOLBAR_CLASS_RE = /\b(kole-demo-toolbar|demo-toolbar|toolbar|demo-switch|demo-controls)\b/;
const GROUP_CLASS_RE = /\b(demo-block|group|section|demo-item|demo-cell|demo-row|demo-card)\b/;
/* 测试钩子属性:不进用法代码(本仓库测试体系的写法,不是组件 API) */
const DROP_ATTR_RE = /^(data-behavior|data-assert|data-snapshot|data-status)$/;
/* 用法片段里可原样保留的原生属性(不属于组件 API,但属于 HTML/框架常识) */
const NATIVE_ATTRS = new Set(['id', 'role', 'aria-label', 'aria-labelledby', 'aria-hidden', 'tabindex', 'title', 'style', 'width', 'height', 'alt', 'href', 'target', 'type', 'name', 'placeholder', 'disabled', 'readonly', 'checked', 'colspan', 'rowspan', 'scope', 'src']);
/* ============================ 1. 极简 HTML 解析 ============================ */
function parseAttrs(raw) {
const attrs = {};
const re = /([a-zA-Z_:@][a-zA-Z0-9_:.-]*)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
let m;
while ((m = re.exec(raw))) {
const name = m[1];
if (!name) continue;
const value = m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : m[5] !== undefined ? m[5] : '';
attrs[name] = value;
}
return attrs;
}
/** HTML → 节点树。script/style 内容整段收进 text 节点(不再当结构解析)。 */
export function parseHtmlTree(html) {
const nodes = [];
const stack = [{ children: nodes }];
const re = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
let last = 0;
let m;
const pushText = (text) => {
if (!text || !text.trim()) return;
stack[stack.length - 1].children.push({ type: 'text', text });
};
while ((m = re.exec(html))) {
pushText(html.slice(last, m.index));
last = re.lastIndex;
if (m[0].charAt(0) === '<' && m[0].charAt(1) === '!') continue;
const closing = m[1] === '/';
const tag = m[2].toLowerCase();
const selfClosing = m[4] === '/' || VOID_TAGS.has(tag);
if (closing) {
for (let i = stack.length - 1; i > 0; i--) {
if (stack[i].tag === tag) { stack.length = i; break; }
}
continue;
}
let raw = '';
if (tag === 'script' || tag === 'style') {
const closeRe = new RegExp('</' + tag + '\\s*>', 'i');
const rest = html.slice(last);
const cm = closeRe.exec(rest);
raw = cm ? rest.slice(0, cm.index) : rest;
if (cm) { last = last + cm.index + cm[0].length; re.lastIndex = last; }
else { last = html.length; re.lastIndex = last; }
}
const node = { type: 'element', tag, attrs: parseAttrs(m[3] || ''), children: [], selfClosing };
stack[stack.length - 1].children.push(node);
if (raw) node.children = [{ type: 'text', text: raw }];
else if (!selfClosing) stack.push(node);
}
pushText(html.slice(last));
return nodes;
}
export function classList(node) {
return String((node && node.attrs && node.attrs.class) || '').split(/\s+/).filter(Boolean);
}
function clsStr(node) { return String((node && node.attrs && node.attrs.class) || ''); }
function elementChildren(node) {
return ((node && node.children) || []).filter((c) => c.type === 'element');
}
function elementCount(node) {
let n = 1;
elementChildren(node).forEach((c) => { n += elementCount(c); });
return n;
}
function depthOf(node, limit = 12) {
let best = 0;
const walk = (n, d) => {
if (d > limit) return;
best = Math.max(best, d);
elementChildren(n).forEach((c) => walk(c, d + 1));
};
walk(node, 0);
return best;
}
export function textOf(node, opts = {}) {
if (!node) return '';
let out = '';
const walk = (n) => {
if (n.type === 'text') { out += (out ? ' ' : '') + (n.text || ''); return; }
if (n.tag === 'script' || n.tag === 'style') return;
if (opts.skip && opts.skip(n)) return;
(n.children || []).forEach(walk);
};
walk(node);
return out.replace(/\s+/g, ' ').trim();
}
function linkParents(root) {
elementChildren(root).forEach((c) => { c.__parent = root; linkParents(c); });
}
/** 节点在树中的「元素下标路径」(重建 DOM 后下标稳定,运行时据此定位) */
function pathOf(node, root) {
const stack = [];
let cur = node;
while (cur && cur !== root) {
const parent = cur.__parent;
if (!parent) return null;
const idx = elementChildren(parent).indexOf(cur);
if (idx < 0) return null;
stack.unshift(idx);
cur = parent;
}
return cur === root ? stack : null;
}
/* ============================ 2. 序列化 ============================ */
const ATTR_ORDER = ['class', 'id', 'type', 'role', 'aria-label', 'tabindex', 'style', 'width', 'height'];
const WRAPPER_TAGS = /^(div|span|section|main|article|p|ul|ol|li|dl|dd|dt)$/;
function keepClass(cls, ctx) {
if (!cls) return false;
if (ctx.keepAll) return true;
if (ctx.cssClasses.has(cls)) return true;
return false;
}
export function serializeHtml(node, indent = 0, ctx = { cssClasses: new Set(), keepAll: true }) {
const pad = ' '.repeat(indent);
if (node.type === 'text') {
const t = (node.text || '').replace(/\s+/g, ' ').trim();
return t ? pad + t : '';
}
if (node.tag === 'script' || node.tag === 'style') return '';
const cls = classList(node).filter((c) => keepClass(c, ctx));
const attrs = [];
if (cls.length) attrs.push('class="' + cls.join(' ') + '"');
/* data-* 的取舍:H5 端的「API」常常就是 data-*(data-type / data-value → type / value prop),
但演示脚本自己的钩子(data-act / data-pw / data-i / data-behavior …)必须剔除 ——
判据只有一条:camelCase 之后是不是该组件真实存在的 prop。 */
const keys = Object.keys(node.attrs || {}).filter((k) => {
if (k === 'class' || DROP_ATTR_RE.test(k)) return false;
/* 演示脚本引用过的 id 是演示钩子(`#demo-toggle`、`#app`):H5 片段同样不留 */
if (k === 'id' && ctx.demoIds && ctx.demoIds.has(node.attrs.id)) return false;
const dm = /^data-([a-z0-9-]+)$/.exec(k);
if (!dm) return true;
if (ctx.keepAll) return true; // 原样语境(演示标记):整段保留
if (!ctx.propNames) return false;
const camel = dm[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return ctx.propNames.has(camel);
});
[...ATTR_ORDER.filter((k) => keys.includes(k)), ...keys.filter((k) => !ATTR_ORDER.includes(k)).sort()]
.forEach((k) => {
const v = node.attrs[k];
attrs.push(v === '' ? k : k + '="' + v + '"');
});
const kids = (node.children || []).filter((c) => !(c.type === 'element' && (c.tag === 'script' || c.tag === 'style')));
const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
/* 脚手架外壳(class 被剔干净、没别的属性、容器标签)→ 拆掉壳保留内容,
否则使用者的用法代码里会夹一层 `<div class="kole-page">` 这种无关嵌套。 */
if (!attrs.length && !kids.some((c) => c.type === 'text' && c.text.trim()) && WRAPPER_TAGS.test(node.tag) && ctx.unwrapScaffold !== false) {
const inner = kids.map((c) => serializeHtml(c, indent, ctx)).filter(Boolean);
if (inner.length) return inner.join('\n');
}
if (!kids.length) {
/* void 元素(input/img/br…)没有闭合标签:写成 `</input>` 是非法 HTML,
复制进页面虽然浏览器会容错,但用法代码不该带这个错。 */
return VOID_TAGS.has(node.tag) ? pad + '<' + node.tag + attrStr + ' />' : pad + '<' + node.tag + attrStr + '></' + node.tag + '>';
}
const onlyText = kids.every((c) => c.type === 'text');
if (onlyText) {
const text = kids.map((c) => (c.text || '').replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
return pad + '<' + node.tag + attrStr + '>' + text + '</' + node.tag + '>';
}
const inner = kids.map((c) => serializeHtml(c, indent + 1, ctx)).filter(Boolean).join('\n');
if (!inner) return '';
return pad + '<' + node.tag + attrStr + '>\n' + inner + '\n' + pad + '</' + node.tag + '>';
}
/** 场景标记(H5 端用法原样,含脚手架——那是演示页里的真实写法) */
function payloadBody(payload, ctx) {
return payload.map((p) => serializeHtml(p, 0, ctx)).filter(Boolean).join('\n');
}
/* ============================ 3. 场景切分 ============================ */
/** 文本是否「像场景标签」而不是数据内容(用于演示页没有标题时的兜底命名) */
function isLabelLike(text) {
if (!text) return false;
const t = text.trim();
if (t.length < 2 || t.length > 22) return false;
if (/[::;;、|]/.test(t)) return false; // 「工号:E1024」
if (/^[^\w\u4e00-\u9fa5]+$/.test(t)) return false; // 「✕」「‹」「↕」
if (/\d{3,}/.test(t)) return false; // 长数字(金额、编号)
if (/\s/.test(t)) return false; // 带空格 = 多半是数据串(「李 李娜 离职」「B端 数据」)
return true;
}
/**
* 独立成段的短标签:`<p>默认态(选中:全部订单)</p>` 后面跟内容块——
* 这是本仓库演示页写「场景标签」的第二种形态(第一种是 h2/h3 与 .hint 类)。
* 只认**文本型**短段落(≤ 30 字、不含其它元素),避免把正文段落当标题。
*/
function isShortLabelParagraph(node) {
if (!node || node.type !== 'element' || node.tag !== 'p') return false;
if (elementChildren(node).length) return false;
const t = textOf(node);
return t.length >= 2 && t.length <= 30;
}
function isLabelNode(node) {
if (!node || node.type !== 'element') return false;
if (LABEL_TAGS.has(node.tag)) return true;
return LABEL_CLASS_RE.test(clsStr(node));
}
function isDroppable(node) {
if (!node || node.type !== 'element') return true;
if (node.tag === 'script' || node.tag === 'style') return true;
if (BOILERPLATE_TAGS.has(node.tag)) return true;
return BOILERPLATE_CLASS_RE.test(clsStr(node));
}
function isContent(node) {
return node && node.type === 'element' && !isDroppable(node) && elementCount(node) >= 1;
}
/* 结构标签:表格/列表的「行」不是使用场景,拆到这一层只会得到「客户名称 / 负责人」这种列名 */
const STRUCTURAL_TAGS = new Set(['table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'select', 'optgroup', 'nav', 'form']);
/* 演示控制条:演示页上的开关(`<label><input type="checkbox">模拟加载态</label>`),不是组件用法 */
function isDemoControl(node) {
if (!node || node.type !== 'element') return false;
if (!/^(label|button|select)$/.test(node.tag)) return false;
const text = textOf(node);
if (text.length > 40) return false;
return elementChildren(node).some((c) => /^(input|select|button)$/.test(c.tag));
}
/** 找一个「能切出 ≥2 块」的容器 */
function findSplitContainer(root, depth = 0) {
if (!root || STRUCTURAL_TAGS.has(root.tag)) return null;
const blocks = elementChildren(root).filter(isContent).filter((b) => !isDemoControl(b));
if (blocks.length < 2) {
if (blocks.length === 1 && depth < 5) return findSplitContainer(blocks[0], depth + 1);
return null;
}
const labelled = blocks.some(isLabelNode) || blocks.some(isShortLabelParagraph);
const grouped = blocks.some((b) => GROUP_CLASS_RE.test(clsStr(b)) || TOOLBAR_CLASS_RE.test(clsStr(b)));
if (labelled || grouped) return { container: root, blocks };
/* 没有标签时,只有「同构重复块」才算多个场景:4 张同样的卡片 = 4 个变体示例。
混合结构([缩略图容器, 预览遮罩]、[表格, 加载遮罩]、[横幅, 占位区, 回顶按钮])是**一个**演示的
内部零件,拆开会得到「图片预览 示例 1 / 示例 2」这种没有意义的条目 —— 退回单场景。 */
const sigOf = (b) => b.tag + '|' + (classList(b)[0] || '');
const repeated = blocks.length >= 2 && new Set(blocks.map(sigOf)).size === 1;
if (repeated) return { container: root, blocks };
if (depth < 5) {
const inner = blocks
.map((b) => findSplitContainer(b, depth + 1))
.filter(Boolean)
.sort((a, b) => b.blocks.length - a.blocks.length)[0];
if (inner) return inner;
}
return null;
}
/** 容器子节点 → 场景单元(label 开新场景;无 label 时每块自带标题) */
function splitUnits(blocks) {
const isLabel = (n) => isLabelNode(n) || isShortLabelParagraph(n);
const hasLabel = blocks.some(isLabel);
const units = [];
if (hasLabel) {
let cur = null;
blocks.forEach((b) => {
if (isLabel(b)) { cur = { titleNode: b, innerTitle: null, payload: [] }; units.push(cur); return; }
if (!cur) { cur = { titleNode: null, innerTitle: null, payload: [] }; units.push(cur); }
cur.payload.push(b);
});
} else {
blocks.forEach((b) => {
const inner = elementChildren(b).find(isLabelNode) || null;
units.push({ titleNode: null, innerTitle: inner, payload: [b] });
});
for (let i = 0; i < units.length - 1; i++) {
const u = units[i];
if (u.payload.length === 1 && TOOLBAR_CLASS_RE.test(clsStr(u.payload[0]))) {
units[i + 1].payload = u.payload.concat(units[i + 1].payload);
units[i + 1].innerTitle = u.innerTitle || units[i + 1].innerTitle;
u.drop = true;
}
}
}
return units.filter((u) => !u.drop && u.payload.length);
}
/** 演示页数据里的条目名(`{ icon, title: '提交成功' }` / `{ '403': { title: … } }`)——
卡片型演示把内容渲染在脚本里,DOM 上没有文字;这些 title/label 就是每个场景最好的名字。 */
function titlesFromScript(bodyHtml, count) {
if (count < 2) return null;
const scripts = String(bodyHtml).match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
const out = [];
scripts.forEach((block) => {
const text = block.replace(/^<script[^>]*>/i, '').replace(/<\/script>$/i, '');
const re = /(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*\s*=\s*(\[[\s\S]*?\n\s*\]|\{[\s\S]*?\n\s*\})\s*;/g;
let m;
while ((m = re.exec(text))) {
const names = [...m[1].matchAll(/\b(?:title|label|name)\s*:\s*['"]([^'"]{2,24})['"]/g)].map((x) => x[1]);
if (names.length === count) out.push(names);
}
});
return out.length ? out[0] : null;
}
/**
* 提取场景。
* @param {object} opts { html, slug, nameZh, contract }
* contract.variants(契约里的中文代表变体名)在演示页没有场景标题时兜底:
* 卡片型演示(4 张 .kole-card)没有 h2/hint,但契约里就写着「无数据 / 无权限 / …」,
* 逐条对应即可,比「示例 1/2/3/4」有用得多。
* @returns {{ mode:'scenarios'|'single', examples:Array }}
*/
export function extractExamples({ html, slug, nameZh, contract }) {
const bodyM = String(html || '').match(/<body[^>]*>([\s\S]*)<\/body>/i);
const bodyHtml = bodyM ? bodyM[1] : String(html || '');
const bodyNode = { type: 'element', tag: 'body', attrs: {}, children: parseHtmlTree(bodyHtml) };
linkParents(bodyNode);
const found = findSplitContainer(bodyNode);
let units = found ? splitUnits(found.blocks) : [];
const mode = (!found || units.length < 2) ? 'single' : 'scenarios';
if (mode === 'single') {
/* 单场景 = 整页演示里除演示控制条之外的全部内容 */
units = [{ titleNode: null, innerTitle: null, payload: elementChildren(bodyNode).filter(isContent).filter((b) => !isDemoControl(b)) }];
}
const variantLabels = (contract && Array.isArray(contract.variants)) ? contract.variants : [];
/* 卡片型演示的正文由脚本渲染,DOM 上没有文字:脚本数据里的 title/label 就是场景名 */
const scriptTitles = titlesFromScript(bodyHtml, units.length);
const seenTitles = new Set();
const examples = units.map((u, i) => {
const payload = u.payload.filter(isContent);
let title = u.titleNode ? textOf(u.titleNode) : (u.innerTitle ? textOf(u.innerTitle) : '');
if (!title) {
/* 文本兜底只接受「像标签的一行」:卡片型演示的第一段文本常是数据内容
(「李 李娜 离职 工号:E1024」「✕」「客户名称 负责人 …」),那不能当场景名。 */
const first = payload.map((p) => textOf(p)).find((t) => isLabelLike(t));
title = first || '';
}
title = title.replace(/^\d+[.、]\s*/, '').replace(/\s+/g, ' ').trim();
/* 没有场景标题时的兜底顺序(逐条对应,数量不符就不用,避免张冠李戴):
演示脚本数据里的 title/label → 契约的中文代表变体名 → 「组件名 示例 N」 */
let titleSrc = title ? 'demo' : '';
if (!title && scriptTitles) { title = scriptTitles[i]; titleSrc = 'script'; }
if (!title && variantLabels.length === units.length) { title = variantLabels[i]; titleSrc = 'contract'; }
let desc = '';
if (title && u.titleNode && found) {
const kids = elementChildren(found.container);
const after = kids[kids.indexOf(u.titleNode) + 1];
if (after && after.tag === 'p' && !isLabelNode(after) && !isContent(after)) desc = textOf(after);
}
let finalTitle = title || (mode === 'single' ? '基础用法' : ((nameZh || slug) + ' 示例 ' + (i + 1)));
let dup = 2;
while (seenTitles.has(finalTitle)) finalTitle = title + '(' + (dup++) + ')';
seenTitles.add(finalTitle);
/* —— 预览隐藏计划:保留 payload 及其祖先链,其余兄弟节点 display:none —— */
const keep = new Set();
const show = [];
payload.forEach((p) => {
const chain = [];
let cur = p;
while (cur && cur !== bodyNode) { chain.unshift(cur); cur = cur.__parent; }
chain.forEach((n) => keep.add(n));
const pp = pathOf(p, bodyNode);
if (pp) show.push(pp);
});
const hide = [];
const pushHide = (node) => {
const sp = pathOf(node, bodyNode);
if (sp && !hide.some((h) => h.join('/') === sp.join('/'))) hide.push(sp);
};
if (mode === 'scenarios') {
keep.forEach((n) => {
elementChildren(n.__parent || bodyNode).forEach((sib) => { if (!keep.has(sib)) pushHide(sib); });
});
elementChildren(bodyNode).forEach((sib) => { if (!keep.has(sib)) pushHide(sib); });
}
/* 代码里不重复场景标题(标题已作为卡片标题展示):把标签节点从代码块里摘掉 */
const skipInCode = new Set([u.titleNode, u.innerTitle].filter(Boolean));
/* 预览同理:标签若在载荷内部(`div.group > h2`、`div.demo-block > p.hint`),
隐藏计划要把它一起盖掉,否则卡片标题下会再出现一遍「1. 类型(默认尺寸)」。 */
if (mode === 'scenarios') {
[u.titleNode, u.innerTitle].filter(Boolean).forEach((labelNode) => {
let cur = labelNode;
while (cur && cur !== bodyNode) { if (payload.indexOf(cur) >= 0) { pushHide(labelNode); return; } cur = cur.__parent; }
});
}
const shownMarkup = payloadBody(payload, { cssClasses: new Set(), keepAll: true });
const codeHtml = payload
.map((p) => serializeSkipping(p, 0, { cssClasses: new Set(), keepAll: true }, skipInCode))
.filter(Boolean).join('\n');
const safety = checkPreviewSafe({ bodyHtml, shownMarkup, mode });
return {
id: slug + '-ex-' + (i + 1),
title: finalTitle,
titleSrc,
desc,
html: codeHtml,
preview: { show, hide, mode },
previewSafe: safety.safe,
previewUnsafeReasons: safety.reasons,
};
});
return { mode, examples };
}
/** 序列化时跳过指定节点(用于摘掉场景标题) */
function serializeSkipping(node, indent, ctx, skip) {
if (node.type === 'element' && skip.has(node)) {
const kids = (node.children || []).filter((c) => !(c.type === 'element' && (c.tag === 'script' || c.tag === 'style')));
return kids.map((c) => serializeSkipping(c, indent, ctx, skip)).filter(Boolean).join('\n');
}
if (node.type === 'text') return serializeHtml(node, indent, ctx);
if (node.tag === 'script' || node.tag === 'style') return '';
const kept = (node.children || []).filter((c) => !(c.type === 'element' && skip.has(c)));
if (kept.length === (node.children || []).length) return serializeHtml(node, indent, ctx);
const clone = { ...node, children: kept };
const inner = elementChildren(clone).map((c) => serializeSkipping(c, indent + 1, ctx, skip)).filter(Boolean);
const text = kept.filter((c) => c.type === 'text').map((c) => (c.text || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
const cls = classList(clone).filter((c) => keepClass(c, ctx));
const attrs = [];
if (cls.length) attrs.push('class="' + cls.join(' ') + '"');
if (!attrs.length && /* 脚手架壳 */ true && WRAPPER_TAGS.test(clone.tag) && !text.length) {
return inner.join('\n');
}
Object.keys(clone.attrs || {}).forEach((k) => {
if (k === 'class' || DROP_ATTR_RE.test(k) || /^data-/.test(k)) return;
const v = clone.attrs[k];
attrs.push(v === '' ? k : k + '="' + v + '"');
});
const pad = ' '.repeat(indent);
const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
const pieces = [...text, ...inner];
if (!pieces.length) return '';
if (pieces.every((p) => !/^</.test(p.trim()))) return pad + '<' + clone.tag + attrStr + '>' + pieces.join(' ') + '</' + clone.tag + '>';
return pad + '<' + clone.tag + attrStr + '>\n' + pieces.map((p) => (p.trim().startsWith('<') ? p : pad + ' ' + p)).join('\n') + '\n' + pad + '</' + clone.tag + '>';
}
/* ============================ 4. 预览安全性 ============================ */
/**
* 静态检查:把其它场景隐藏后,本场景的预览是否还能正常渲染。
* display:none 不删节点,所以「场景外的 id / 选择器依赖」不致命;会坏的是:
* 1) 脚本往 body 追加节点(追加内容不在隐藏计划里,会跑进每个场景的预览);
* 2) 脚本对节点做几何测量(隐藏与否会改变结果);
* 3) 本场景没有可渲染标记。
*/
export function checkPreviewSafe({ bodyHtml, shownMarkup, mode }) {
const reasons = [];
if (mode === 'single') return { safe: true, reasons };
let safe = true;
const scripts = (String(bodyHtml).match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || []).join('\n');
if (/document\.body\s*\.\s*(appendChild|append|insertBefore|prepend)\s*\(/.test(scripts)) {
safe = false;
reasons.push('演示脚本向 body 追加节点');
}
if (/getBoundingClientRect|offsetWidth|offsetHeight|clientWidth|clientHeight/.test(scripts)) {
safe = false;
reasons.push('演示脚本按可见尺寸测量布局');
}
if (!/[<>]/.test(shownMarkup)) {
safe = false;
reasons.push('本场景没有可渲染的标记');
}
return { safe, reasons: [...new Set(reasons)] };
}
/* ============================ 5. class ↔ prop 映射 ============================ */
function lastSeg(x) { return String(x || '').split('.').pop().replace(/[^A-Za-z0-9_$]/g, ''); }
function uniq(arr) { return [...new Set(arr)]; }
/** 组件根类:取 CSS 前几条规则里的类选择器(组件样式表首条规则即组件本体) */
export function cssRootClasses(css) {
const clean = String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
const out = [];
const ruleRe = /([^{}]+)\{/g;
let m;
let rules = 0;
while ((m = ruleRe.exec(clean)) && rules < 8) {
const sel = m[1].trim();
if (/^@/.test(sel) || !sel) continue;
rules++;
[...sel.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].forEach((x) => {
if (!out.includes(x[1])) out.push(x[1]);
});
if (out.length >= 3) break;
}
return out.slice(0, 3);
}
/** 组件的全部已知 prop 名:API 表 ∪ JSX 参数表 */
export function collectPropNames({ api, jsx = '', vue2 = '', vue3 = '' }) {
const names = new Set(((api && api.props) || []).map((p) => p.name));
const m = String(jsx).match(/export default function\s+[A-Za-z0-9_$]+\s*\(\s*\{([^}]+)\}/);
if (m) {
m[1].split(',').forEach((p) => {
const name = p.trim().split(/[:=]/)[0].trim();
if (/^[a-zA-Z_$][A-Za-z0-9_$]*$/.test(name)) names.add(name);
});
}
return names;
}
/**
* 「class ↔ prop」映射。只认源码里真实存在的对应关系:
* (a) 字面量映射表 const sizeMap = { large: 'btn-lg', … }(找到 `sizeMap[size]` 使用点)
* (b) 模板串前缀 `kole-input-${size}`
* (c) 字符串拼接前缀 'kole-tag-' + props.color
* (d) 布尔类开关 'is-loading': loading / loading && 'spinner' / error ? 'is-error' : ''
* (e) if (prop) cls.push('x') —— 布尔 prop 触发修饰类
* (f) 条件渲染的 class(closable && <i className="kole-tag-close">)→ 该 class 表示 prop=true
* (g) 前缀反演:仅在「取值已被契约维度或源码字面量证实」时才补「值 → 类」映射
*/
export function extractClassPropMap({ jsx = '', vue3 = '', vue2 = '', css = '', api, contract } = {}) {
const src = [jsx, vue3, vue2].join('\n');
const propNames = collectPropNames({ api, jsx, vue2, vue3 });
const has = (p) => propNames.has(p);
const byClass = {};
const byProp = {};
let m;
const setMap = (prop, value, cls, extra = {}) => {
if (!has(prop) || !cls) return;
byProp[prop] = byProp[prop] || { values: {}, prefix: '' };
byProp[prop].values[value] = cls;
cls.split(/\s+/).filter(Boolean).forEach((c) => {
if (byClass[c] && byClass[c].prop !== prop) {
byClass[c].ambiguous = true; // 同一个类对应多个 prop:宁可不映射
return;
}
byClass[c] = Object.assign({ prop, value }, extra);
});
};
/* (a) 字面量映射表 */
const mapRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*\{([^{}]*)\}\s*;?/g;
while ((m = mapRe.exec(src))) {
const pairs = [...m[2].matchAll(/([A-Za-z0-9_$'"]+)\s*:\s*['"]([^'"]+)['"]/g)]
.map((x) => [x[1].replace(/['"]/g, ''), x[2]]);
if (pairs.length < 2) continue;
const use = new RegExp('\\b' + m[1] + '\\s*\\[\\s*([A-Za-z0-9_$.]+)\\s*\\]').exec(src);
if (!use) continue;
const prop = lastSeg(use[1]);
if (!has(prop)) continue;
pairs.forEach(([val, cls]) => setMap(prop, val, cls));
}
/* (b)(c) 前缀拼接 */
const prefixOf = {};
const tplRe = /`([a-zA-Z0-9_-]*)\$\{\s*(?:props\.)?([A-Za-z0-9_$]+)(?:\s*\|\|\s*[^}]*)?\s*\}/g;
while ((m = tplRe.exec(src))) {
if (m[1].length >= 2 && has(m[2]) && !prefixOf[m[2]]) prefixOf[m[2]] = m[1];
}
const concatRe = /['"]([a-zA-Z0-9_-]+)['"]\s*\+\s*(?:props\.)?([A-Za-z0-9_$]+)/g;
while ((m = concatRe.exec(src))) {
if (m[1].length >= 2 && has(m[2]) && !prefixOf[m[2]]) prefixOf[m[2]] = m[1];
}
/* (d) 布尔类开关(负向断言:标识符前不能是 `.` 或标识符字符,避免 showPassword.value ? 'text' 这种误判) */
const boolRes = [
[/(?<![.\w$])['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]\s*:\s*(?:props\.)?([A-Za-z0-9_$]+)\s*(?:&&|\|\||$|\n|\})/g, 0],
[/(?<![.\w$])([A-Za-z0-9_$]+)\s*&&\s*['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]/g, 1],
[/(?<![.\w$])([A-Za-z0-9_$]+)\s*\?\s*['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]/g, 1],
];
boolRes.forEach(([re, order]) => {
let bm;
while ((bm = re.exec(src))) {
const prop = order === 0 ? bm[2] : bm[1];
const cls = order === 0 ? bm[1] : bm[2];
if (!has(prop) || !cls || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cls)) continue;
if (!byClass[cls]) byClass[cls] = { prop, value: true, boolean: true };
const bp = byProp[prop] = byProp[prop] || { values: {}, prefix: '' };
if (!bp.boolClass) bp.boolClass = cls;
}
});
/* (e) if (prop) cls.push('x') */
const pushRe = /(?<![.\w$])([A-Za-z0-9_$]+)\s*(?:===?\s*[^)]+)?\s*\)[^\n]{0,60}?push\s*\(\s*['"]([a-zA-Z][a-zA-Z0-9_-]*)['"]/g;
while ((m = pushRe.exec(src))) {
const prop = m[1];
const cls = m[2];
if (!has(prop) || byClass[cls]) continue;
byClass[cls] = { prop, value: true, boolean: true };
const bp = byProp[prop] = byProp[prop] || { values: {}, prefix: '' };
if (!bp.boolClass) bp.boolClass = cls;
}
/* (f) 条件渲染的 class:`prop && (<tag className="xxx">`。
两个约束缺一不可(实测踩过):
1) `&&` 之后必须紧跟 `(` / 空白 + `<元素`,否则 `onClose && onClose();` 这类
语句会被连到下一行的元素上,把 `.kole-mask` 误映射成「onClose=true」;
2) 元素内部不允许出现 `>`,避免跨元素贪吃。 */
const condRe = /(?<![.\w$])([A-Za-z0-9_$]+)\s*&&\s*\(?\s*<[a-zA-Z][^>]{0,180}?className\s*=\s*["']([a-zA-Z][a-zA-Z0-9_ -]*)["']/g;
while ((m = condRe.exec(src))) {
const prop = m[1];
if (!has(prop)) continue;
m[2].split(/\s+/).filter(Boolean).forEach((cls) => {
const seen = byClass[cls];
if (seen) {
/* 同一个共享类(.affix 既跟 prefix 又跟 suffix 出现)不能一刀切 */
if (seen.prop !== prop) seen.ambiguous = true;
return;
}
byClass[cls] = { prop, value: true, boolean: true, cond: true };
});
}
const cssClasses = uniq([...String(css || '').matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map((x) => x[1]));
const cssSet = new Set(cssClasses);
/* 清理:只保留「像类名」的条目,并让**推断出来的**映射必须在组件 CSS 里真实存在。
显式映射(map 字面量 / 布尔开关 / 条件渲染)本身就是源码证据,不因 CSS 缺类而删
—— 例如 .kole-input-md 是默认尺寸的实现细节、CSS 里可以没有这条规则。 */
Object.keys(byClass).forEach((c) => {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(c)) { delete byClass[c]; return; }
if (byClass[c].inferred && !cssSet.has(c)) delete byClass[c];
});
/* (g) 前缀反演:取值必须被证实(契约维度值 / 源码字面量 / 演示页类名) */
const proven = new Map(); // prop → Set(values)
const dims = ((contract && contract.dims) || []);
const addProven = (prop, vals) => {
if (!proven.has(prop)) proven.set(prop, new Set());
vals.forEach((v) => proven.get(prop).add(String(v)));
};
Object.keys(prefixOf).forEach((prop) => {
const prefix = prefixOf[prop];
const fromCss = cssClasses
.filter((c) => c.startsWith(prefix) && c.length > prefix.length)
.map((c) => c.slice(prefix.length))
.filter((v) => /^[a-z0-9][a-z0-9-]*$/.test(v));
const dim = dims.find((d) => String(d.name) === prop)
|| dims.find((d) => (d.values || []).some((v) => cssSet.has(prefix + String(v).toLowerCase())));
const vals = uniq([...(dim ? (dim.values || []).map((v) => String(v)) : []), ...fromCss]);
if (dim || fromCss.length) addProven(prop, vals);
/* 源码字面量里的取值(color !== 'blue' / PRESET = ['green','red'] …) */
const lits = [];
const litRe = new RegExp('(?<![.\\w$])' + prop + '\\s*(?:===?|!==?)\\s*[\'"]([a-zA-Z0-9-]+)[\'"]', 'g');
let lm;
while ((lm = litRe.exec(src))) lits.push(lm[1]);
const listRe = new RegExp('\\[([^\\]]*)\\]\\s*\\.\\s*(?:includes|indexOf)\\s*\\(\\s*(?:props\\.)?' + prop, 'g');
while ((lm = listRe.exec(src))) {
[...lm[1].matchAll(/['"]([a-zA-Z0-9-]+)['"]/g)].forEach((x) => lits.push(x[1]));
}
if (lits.length) addProven(prop, lits);
});
Object.keys(prefixOf).forEach((prop) => {
const prefix = prefixOf[prop];
const known = proven.get(prop);
if (!known) return;
byProp[prop] = byProp[prop] || { values: {}, prefix };
byProp[prop].prefix = byProp[prop].prefix || prefix;
known.forEach((value) => {
const cls = prefix + value;
if (!cssSet.has(cls)) return;
if (byClass[cls] && byClass[cls].prop !== prop) return;
if (!byClass[cls]) byClass[cls] = { prop, value, inferred: true };
byProp[prop].values[value] = cls;
});
});
/* 样式变量 ↔ prop('--kole-tag-color': props.color)—— 用于把内联变量还原成 prop 取值 */
const cssVarByProp = {};
const varRe = /['"](--kole-[a-z0-9-]+)['"]\s*:\s*(?:props\.)?([A-Za-z0-9_$]+)/g;
while ((m = varRe.exec(src))) {
if (has(m[2]) && !cssVarByProp[m[2]]) cssVarByProp[m[2]] = m[1];
}
/* 零件类 ↔ 文本 prop(Input 的 .error-text 里渲染的是 {errorText}):
演示页里这类节点的文案就是该 prop 的值,构建期据此把它还原成 prop(不猜、只认源码里的引用)。 */
const partTextProp = {};
const partRe = /(?:className|class)\s*=\s*["']([a-zA-Z][a-zA-Z0-9_ -]*)["'][\s\S]{0,200}?\{\{?\s*(?:props\.)?([A-Za-z0-9_$][A-Za-z0-9_$.]*)/g;
while ((m = partRe.exec(src))) {
const prop = lastSeg(m[2]);
if (!has(prop)) continue;
m[1].split(/\s+/).filter(Boolean).forEach((cls) => {
if (!partTextProp[cls]) partTextProp[cls] = prop;
});
}
return {
byClass, byProp, cssVarByProp, partTextProp,
rootClasses: cssRootClasses(css),
cssClasses,
propNames: [...propNames],
};
}
/* ============================ 6. 用法片段 ============================ */
const REACT_ATTR_MAP = { class: 'className', tabindex: 'tabIndex', for: 'htmlFor', maxlength: 'maxLength', readonly: 'readOnly', colspan: 'colSpan', rowspan: 'rowSpan' };
function styleToJsx(style) {
const body = String(style).split(';').map((s) => s.trim()).filter(Boolean).map((d) => {
const i = d.indexOf(':');
if (i < 0) return null;
const k = d.slice(0, i).trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const v = d.slice(i + 1).trim();
return k + ': ' + (/^-?\d+(\.\d+)?$/.test(v) ? v : "'" + v + "'");
}).filter(Boolean);
return body.length ? '{ ' + body.join(', ') + ' }' : null;
}
function styleToVue(style) {
const body = String(style).split(';').map((s) => s.trim()).filter(Boolean).map((d) => {
const i = d.indexOf(':');
if (i < 0) return null;
const k = d.slice(0, i).trim().replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
return k + ': ' + d.slice(i + 1).trim();
}).filter(Boolean);
return body.length ? '{ ' + body.join('; ') + ' }' : null;
}
function inlineStyleVars(style) {
const out = {};
String(style || '').split(';').forEach((d) => {
const i = d.indexOf(':');
if (i < 0) return;
const k = d.slice(0, i).trim();
const v = d.slice(i + 1).trim();
if (/^--/.test(k)) out[k] = v.replace(/^var\(\s*([^)]+)\s*\)$/, '$1');
});
return out;
}
function newCtx({ map, api, componentTag }) {
const apiProps = (api && api.props) || [];
return {
map, componentTag,
propNames: new Set(map.propNames && map.propNames.length ? map.propNames : apiProps.map((p) => p.name)),
/* 布尔 prop:`data-searchable="1"` 这种要还原成 `searchable`(而不是 searchable="1") */
boolProps: new Set(apiProps.filter((p) => /Boolean/.test(String(p.type || ''))).map((p) => p.name)),
anyMapped: { value: false },
reasons: [],
};
}
/** 从节点自身的类名 / 属性 / 内联变量里取 prop(全部有出处) */
function propsOfNode(node, ctx) {
const found = [];
const add = (name, value, why) => {
if (!ctx.propNames.has(name)) return false;
if (found.some((f) => f[0] === name)) return false;
if (found.length >= 8) return false;
found.push([name, value, why]);
ctx.anyMapped.value = true;
return true;
};
/* 1) 内联样式变量(优先:它是使用者要传的真实值,如 color="#722ED1") */
const vars = inlineStyleVars(node.attrs && node.attrs.style);
Object.keys(vars).forEach((v) => {
const prop = Object.keys(ctx.map.cssVarByProp).find((p) => ctx.map.cssVarByProp[p] === v);
if (prop) add(prop, vars[v], 'style-var');
});
/* 2) data-* / 原生属性直接同名 prop */
Object.keys(node.attrs || {}).forEach((k) => {
if (k === 'class' || k === 'style' || k === 'id' || k === 'role' || /^aria-/.test(k)) return;
const dm = /^data-([a-z0-9-]+)$/.exec(k);
const camel = dm ? dm[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) : k;
if (!ctx.propNames.has(camel)) return;
const raw = String(node.attrs[k]);
const isBool = ctx.boolProps && ctx.boolProps.has(camel);
if (isBool) {
if (/^(1|true|)$/i.test(raw)) { add(camel, true, dm ? 'data-attr' : 'attr'); return; }
if (/^(0|false)$/i.test(raw)) return;
}
/* 非布尔 prop 的「开关式写法」(data-tabs / 空值)表达不了数组或对象,跳过:
硬写成 `tabs` 会得到一个错误的布尔调用。 */
if (/^(1|true|)$/i.test(raw)) return;
add(camel, raw, dm ? 'data-attr' : 'attr');
});
/* 3) 类名映射(内部零件类只认布尔 / 明确对应;由 partTextProp 管文案的类跳过,交给 walk 取文本) */
classList(node).forEach((c) => {
const hit = ctx.map.byClass[c];
if (!hit || hit.ambiguous) return;
if (INTERNAL_PART_CLASSES.has(c)) {
if (!hit.boolean) return;
const owner = ctx.map.partTextProp && ctx.map.partTextProp[c];
if (owner && ctx.propNames.has(owner)) return; // 有文案取值时由 walk 的零件分支处理
}
add(hit.prop, hit.boolean ? true : hit.value, hit.inferred ? 'class-inferred' : 'class');
});
return found;
}
/** 本体(组件根)节点 → 一行调用代码;内部属于组件自身的节点折成 prop */
function collapseNode(node, end, ctx, indent) {
const props = propsOfNode(node, ctx);
const consumed = new Set();
const slotParts = []; // Vue 命名插槽内容
const childText = [];
const addProp = (list, name, value) => {
if (!ctx.propNames.has(name)) return false;
/* children / text 这类「内容 prop」要渲染成元素内容,不是属性 */
if (name === 'children' || name === 'text') {
if (typeof value === 'string' && value) childText.push(value);
ctx.anyMapped.value = true;
return true;
}
if (list.some((p) => p[0] === name)) return false;
if (list.length >= 8) return false;
list.push([name, value, 'inner']);
ctx.anyMapped.value = true;
return true;
};
const walk = (n) => {
if (n.type !== 'element' || n.tag === 'script' || n.tag === 'style') return;
const cls = classList(n);
const isSlotName = (name) => ((ctx.api && ctx.api.slots) || []).some((s) => s.name === name);
/* 歧义类(.affix 同时被 prefix/suffix 用)直接当做不到映射,不进 prop */
const own = propsOfNode(n, ctx).filter(([name]) => ctx.propNames.has(name));
let folded = false;
own.forEach(([name, value, why]) => {
if (isSlotName(name) && why !== 'style-var') {
/* 命名插槽:Vue 渲染成 <template #name>,React 直接给同名 prop;两者都表达不了的内容(图标)跳过 */
const text = textOf(n);
if (!text) return;
if (end === 'vue') slotParts.push([name, text]);
else addProp(props, name, text);
folded = true;
return;
}
if (addProp(props, name, value)) folded = true;
});
/* `xxxText` 配套 prop:类里有 error 提示节点时把文案带上(如 errorText) */
const paired = own.map((o) => o[0] + 'Text').find((p) => ctx.propNames.has(p));
const ownText = textOf(n);
if (paired && ownText && !folded) { addProp(props, paired, ownText); folded = true; }
/* 组件内部零件:不折 prop、也不当内容(使用者写法里不出现)。
但零件里的文案常常就是某个 prop 的值(Input 的 .error-text ← {errorText}),
这类对应关系由 partTextProp 从源码里提取(不猜)。多个零件类同时命中时取更具体的一个
(.affix.suffix 取 suffix 的映射,而不是通用的 affix)。 */
if (cls.some((c) => INTERNAL_PART_CLASSES.has(c)) && !cls.some((c) => ctx.map.rootClasses.includes(c))) {
const owner = [...cls].reverse().map((c) => ctx.map.partTextProp[c]).find(Boolean);
if (ownText && owner && ctx.propNames.has(owner)) addProp(props, owner, ownText);
consumed.add(n);
return;
}
if (folded) consumed.add(n);
/* 继续下钻:外层类给 size/state,内层原生控件还能给 placeholder / value */
elementChildren(n).forEach(walk);
};
elementChildren(node).forEach(walk);
/* 直接文本子节点 = 组件内容 */
(node.children || []).forEach((c) => {
if (c.type !== 'text') return;
const t = (c.text || '').replace(/\s+/g, ' ').trim();
if (t) childText.push(t);
});
/* 没被折成 prop 的后代文本(如 KoleTag 的标签文字) */
const collectText = (n) => {
if (consumed.has(n)) return;
if (n.type === 'text') { const t = (n.text || '').replace(/\s+/g, ' ').trim(); if (t) childText.push(t); return; }
if (n.tag === 'script' || n.tag === 'style') return;
(n.children || []).forEach(collectText);
};
(node.children || []).forEach(collectText);
const restClasses = classList(node).filter((c) => {
if (INTERNAL_PART_CLASSES.has(c)) return false;
if (ctx.map.rootClasses.includes(c)) return false; // 组件自己会加根类
const hit = ctx.map.byClass[c];
if (hit && !hit.ambiguous && ctx.propNames.has(hit.prop)) return false;
return ctx.map.cssClasses.includes(c);
});
const attrs = [];
props.forEach(([name, value, why]) => {
if (value === true) { attrs.push(name); return; }
if (end === 'vue' && /^-?\d+(\.\d+)?$/.test(String(value))) { attrs.push(':' + name + '="' + value + '"'); return; }
attrs.push(name + '="' + value + '"');
});
if (restClasses.length) attrs.push(end === 'jsx' ? 'className="' + restClasses.join(' ') + '"' : 'class="' + restClasses.join(' ') + '"');
/* 非 prop 的原生属性原样保留(id / role / aria-* …),内联样式只在有剩余变量时保留。
演示脚本引用到的 id 是演示钩子(`#demo-toggle`、`#app`),不进用法代码。 */
Object.keys(node.attrs || {}).forEach((k) => {
if (k === 'class' || k === 'style' || DROP_ATTR_RE.test(k) || /^data-/.test(k)) return;
if (ctx.demoIds && k === 'id' && ctx.demoIds.has(node.attrs.id)) return;
if (props.some((p) => p[0] === k)) return;
if (!NATIVE_ATTRS.has(k) && !/^aria-/.test(k)) return;
const v = node.attrs[k];
if (end === 'jsx') { const nm = REACT_ATTR_MAP[k] || k; attrs.push(v === '' ? nm : nm + '="' + v + '"'); }
else attrs.push(v === '' ? k : k + '="' + v + '"');
});
const leftoverVars = inlineStyleVars(node.attrs && node.attrs.style);
Object.keys(leftoverVars).forEach((v) => {
/* 组件内部的自定义属性(--kole-*):能被 prop 吸收的已吸收,剩下的属于实现细节,不进用法代码 */
delete leftoverVars[v];
});
if (Object.keys(leftoverVars).length) {
const styleText = Object.keys(leftoverVars).map((k) => k + ':' + leftoverVars[k]).join(';');
if (end === 'jsx') { const s = styleToJsx(styleText); if (s) attrs.push('style=' + s); }
else if (end === 'vue') { const s = styleToVue(styleText); if (s) attrs.push(':style="' + s + '"'); }
else attrs.push('style="' + styleText + '"');
}
const pad = ' '.repeat(indent);
const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
const text = uniq(childText).join(' ');
const selfClose = !text && !slotParts.length;
const head = pad + '<' + ctx.componentTag + attrStr + (selfClose ? ' />' : '>');
if (selfClose) return head;
if (end === 'vue' && slotParts.length) {
const inner = slotParts.map(([name, t]) => pad + ' <template #' + name + '>' + t + '</template>').join('\n');
return head + '\n' + inner + '\n' + pad + '</' + ctx.componentTag + '>';
}
return head + text + '</' + ctx.componentTag + '>';
}
/** 节点是否组件本体 */
function isComponentNode(node, map) {
return classList(node).some((c) => map.rootClasses.includes(c));
}
/**
* 子树是否像「用静态标记模拟数据列表」。
* Select / Table / Tree 这类组件的演示页把 options 逐条写成元素(甚至带 data-value 与 is-selected),
* 直接按类名映射会产出**错的调用**(把某一项的值当成根组件的 prop)。识别出来就换策略。
*/
export function looksLikeSimulatedList(node, map) {
let hit = false;
const walk = (n) => {
if (hit || n.type !== 'element') return;
const kids = elementChildren(n);
const groups = {};
kids.forEach((k) => {
const first = classList(k)[0];
if (!first) return;
groups[first] = (groups[first] || 0) + 1;
});
Object.keys(groups).forEach((cls) => {
if (groups[cls] < 3) return;
if (map.rootClasses.includes(cls)) return;
/* 只把「组件自己的类」当数据行;纯脚手架类(row/group…)不算 */
if (!map.cssClasses.includes(cls)) return;
hit = true;
});
kids.forEach(walk);
};
walk(node);
return hit;
}
/** 从场景载荷里挑「组件调用」节点:自身是组件本体 → 用它;否则收集其后代组件节点 */
export function pickComponentNodes(payload, map) {
const out = [];
const walk = (n, inside) => {
if (!n || n.type !== 'element' || n.tag === 'script' || n.tag === 'style') return;
const self = isComponentNode(n, map);
if (self && !inside) { out.push(n); return; }
elementChildren(n).forEach((c) => walk(c, inside || self));
};
payload.forEach((p) => walk(p, false));
return out;
}
/** 从演示页脚本里抠数据字面量(RadioCard 的 DATA / SwitchGroup 的 OPTIONS …) */
export function extractDemoData(bodyHtml) {
const scripts = String(bodyHtml).match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
const found = [];
scripts.forEach((block) => {
const text = block.replace(/^<script[^>]*>/i, '').replace(/<\/script>$/i, '');
const re = /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(\[[\s\S]*?\n\s*\]|\{[\s\S]*?\n\s*\})\s*;/g;
let m;
while ((m = re.exec(text))) {
const value = m[2].trim();
if (value.length < 12 || value.length > 1600) continue;
if (!/['"]/.test(value)) continue;
found.push({ name: m[1], value });
}
});
return found;
}
/** 演示页脚本里的「当前选中值」变量(selected = 'pro' / ['analytics']) */
export function extractDemoValue(bodyHtml) {
const scripts = String(bodyHtml).match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
const joined = scripts.join('\n');
const m = /(?:const|let|var)\s+(selected|value|current|active)\s*=\s*([^;\n]+);/.exec(joined);
if (!m) return null;
const raw = m[2].trim();
if (!/^(['"][^'"]*['"]|\[[^\]]*\]|-?\d+(\.\d+)?)$/.test(raw)) return null;
return { name: m[1], literal: raw };
}
/** 演示页脚本引用到的 id(这些 id 是演示钩子,不属于组件用法) */
export function extractDemoIds(bodyHtml) {
const scripts = String(bodyHtml).match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
const ids = new Set();
scripts.forEach((block) => {
const re = /getElementById\s*\(\s*['"]([^'"]+)['"]|querySelector(?:All)?\s*\(\s*['"]#([A-Za-z0-9_-]+)['"]/g;
let m;
while ((m = re.exec(block))) ids.add(m[1] || m[2]);
});
return ids;
}
function importLine(end, componentTag) {
const pkg = end === 'jsx' ? 'kole-ui/react' : end === 'vue3' ? 'kole-ui/vue3' : 'kole-ui/vue2';
return "import { " + componentTag + " } from '" + pkg + "';";
}
/**
* 生成四端用法片段。
* @returns {{ source:string, ends:{html:string,jsx:string,vue2:string,vue3:string}, notes:string[] }}
*/
export function buildSnippets({ example, map, api, prefix, demoData = [], demoValue = null, demoIds = new Set() }) {
const componentTag = 'Kole' + String(prefix || '').replace(/[^A-Za-z0-9]/g, '');
const roots = parseHtmlTree(example.html).filter((n) => n.type === 'element');
linkParents({ type: 'element', tag: 'root', attrs: {}, children: roots });
const ctx = newCtx({ map, api, componentTag });
ctx.api = api;
ctx.demoIds = demoIds;
const notes = [];
/* propNames / demoIds 要带进序列化语境:H5 端「API」常常就是 data-*(data-type → type prop),
只有能对上真实 prop 的 data-* 才留在用法片段里;演示脚本钩子 id 一律不留。 */
const codeCtx = { cssClasses: new Set(map.cssClasses), unwrapScaffold: true, propNames: ctx.propNames, demoIds };
const html = roots.map((r) => serializeHtml(r, 0, codeCtx)).filter(Boolean).join('\n');
/* —— 策略 1:演示页里能找到组件本体,逐字映射 prop ——
前提是这段标记确实是「调用写法」;若它其实是把数据列表摊平成静态标记
(Select 的 4 个 .kole-select-option、Table 的整张表),映射结果会是错的(把某项的值
当成根 prop),这时改用数据/API 表策略。 */
const picked = pickComponentNodes(roots, map);
const simulated = picked.some((n) => looksLikeSimulatedList(n, map));
if (picked.length && !simulated) {
const jsxBody = picked.map((n) => collapseNode(n, 'jsx', ctx, 0)).join('\n');
const vueBody = picked.map((n) => collapseNode(n, 'vue', ctx, 0)).join('\n');
if (ctx.anyMapped.value) {
return {
source: 'demo-mapped',
ends: {
html,
jsx: importLine('jsx', componentTag) + '\n\n' + '<>\n' + jsxBody + '\n</>',
vue3: importLine('vue3', componentTag) + '\n\n<template>\n' + vueBody + '\n</template>',
vue2: importLine('vue2', componentTag) + '\n\n<template>\n' + vueBody + '\n</template>',
},
notes,
};
}
notes.push('演示页标记只含页面结构,未含可映射的组件属性');
}
/* —— 策略 2:演示页数据 + API 表(挂载点型组件:RadioCard / SwitchGroup / CheckboxCard …) —— */
const props = ((api && api.props) || []);
const dataProp = props.find((p) => /^(options|items|data|list|users|steps|columns|presets)$/.test(p.name));
const hint = demoData.find((d) => /options|items|data|list/i.test(d.name));
const valueProp = props.find((p) => p.name === 'modelValue') || props.find((p) => p.name === 'value');
const supportsVModel = ((api && api.emits) || []).some((e) => /^update:modelValue$/.test(e.name));
if (dataProp && hint && valueProp) {
const dataLine = 'const ' + dataProp.name + ' = ' + hint.value.replace(/\n\s*/g, '\n ') + ';';
const init = demoValue ? demoValue.literal : 'null';
const jsx = [
"import { useState } from 'react';",
importLine('jsx', componentTag),
'',
dataLine,
'',
'export default function Demo() {',
' const [value, setValue] = useState(' + init + ');',
' return <' + componentTag + ' ' + dataProp.name + '={' + dataProp.name + '} ' + valueProp.name + '={value} onChange={setValue} />;',
'}',
].join('\n');
const script = [
'<' + 'script setup>',
"import { ref } from 'vue';",
importLine('vue3', componentTag),
'',
dataLine,
'const value = ref(' + init + ');',
'</' + 'script>',
].join('\n');
const vueTag = supportsVModel
? '<' + componentTag + ' :' + dataProp.name + '="' + dataProp.name + '" v-model="value" />'
: '<' + componentTag + ' :' + dataProp.name + '="' + dataProp.name + '" :' + valueProp.name + '="value" @change="value = $event" />';
return {
source: 'demo-data',
ends: {
html,
jsx,
vue3: script + '\n\n<template>\n ' + vueTag + '\n</template>',
vue2: script.replace("from 'kole-ui/vue3'", "from 'kole-ui/vue2'") + '\n\n<template>\n ' + vueTag + '\n</template>',
},
notes,
};
}
/* —— 策略 3:演示页没有可直接映射的调用写法时,给出最小调用骨架。
走的是「演示页里哪些属性真的对应 prop」这条链(能看到 placeholder / disabled 这类实参),
但**不把演示页的整段文案塞进 children** —— 那会把列表项文字糊成一个字符串。
出处标注为 api-derived,文档页会显示说明。 —— */
const seed = props.filter((p) => p.req).slice(0, 3);
const defaults = props
.filter((p) => p.def && !/^(—|false|null|undefined|\[\]|\{\}|'')$/.test(String(p.def).trim()))
.slice(0, 2);
const use = (seed.length ? seed : defaults);
const attrText = use.map((p) => {
const d = String(p.def || '').replace(/^['"]|['"]$/g, '');
return p.name + (d && d !== '—' ? '="' + d + '"' : '');
}).join(' ');
/* 从场景标记里捡「能对上 prop 的原生属性/类名」(placeholder、disabled、size…)。
跳过数据行(同一父节点下 ≥3 个同类兄弟):那些是列表项的 data-value,不是根组件的 prop。 */
const looseProps = [];
const rowsOf = (n) => {
const groups = {};
elementChildren(n).forEach((k) => {
const first = classList(k)[0];
if (first) groups[first] = (groups[first] || 0) + 1;
});
return new Set(Object.keys(groups).filter((g) => groups[g] >= 3));
};
const looseWalk = (n, rowCls) => {
if (n.type !== 'element' || n.tag === 'script' || n.tag === 'style') return;
const first = classList(n)[0];
if (!rowCls.has(first)) {
propsOfNode(n, ctx).forEach(([name, value]) => {
if (looseProps.some((x) => x[0] === name)) return;
looseProps.push([name, value]);
});
}
const rows = rowsOf(n);
elementChildren(n).forEach((c) => looseWalk(c, rows));
};
roots.forEach((r) => looseWalk(r, new Set()));
let skeleton;
let skeletonVue;
if (looseProps.length) {
const shown = looseProps.slice(0, 4)
.map(([name, value]) => (value === true || value === '' ? name : name + '="' + value + '"'))
.join(' ');
skeleton = '<' + componentTag + ' ' + shown + ' />';
skeletonVue = skeleton;
} else {
/* 什么属性都没有:给「数据 + 绑定」的标准调用形。
prop 名与绑定写法都取自真实 API 表(options/modelValue/update:modelValue),
但 JSX 与 Vue 的绑定语法不同({options} vs :options),分别产出。 */
const dataName = dataProp ? dataProp.name : '';
const bindJsx = dataProp && valueProp ? ' ' + dataName + '={' + dataName + '} ' + valueProp.name + '={value}' : '';
const bindVue = dataProp && valueProp
? ' :' + dataName + '="' + dataName + '"' + (supportsVModel ? ' v-model="value"' : ' :' + valueProp.name + '="value"')
: '';
skeleton = '<' + componentTag + bindJsx + ' />';
skeletonVue = '<' + componentTag + bindVue + ' />';
}
notes.push('演示页未含可映射的调用标记,本片段依据组件 API 表推导');
return {
source: 'api-derived',
ends: {
html,
jsx: importLine('jsx', componentTag) + '\n\n' + skeleton,
vue3: '<template>\n ' + skeletonVue + '\n</template>\n\n<' + 'script setup>\n' + importLine('vue3', componentTag) + '\n</' + 'script>',
vue2: '<template>\n ' + skeletonVue + '\n</template>\n\n<' + 'script>\n' + importLine('vue2', componentTag) + '\nexport default { components: { ' + componentTag + ' } };\n</' + 'script>',
},
notes,
};
}