/**
* demo-examples.mjs — 把演示页拆成「单个使用场景 ⇄ 单个用法代码块」(零运行时依赖)
*
* 需求(用户 2026-09-20 反馈,对齐 Element *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 = /|<(\/?)([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 被剔干净、没别的属性、容器标签)→ 拆掉壳保留内容,
否则使用者的用法代码里会夹一层 `
` 这种无关嵌套。 */
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…)没有闭合标签:写成 `` 是非法 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;
}
/**
* 独立成段的短标签:`
默认态(选中:全部订单)
` 后面跟内容块——
* 这是本仓库演示页写「场景标签」的第二种形态(第一种是 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']);
/* 演示控制条:演示页上的开关(`
`),不是组件用法 */
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(/