/** * 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('', '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 + '>'; } 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 + ''; } 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 + ''; } /** 场景标记(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(/]*>([\s\S]*?)<\/script>/gi) || []; const out = []; scripts.forEach((block) => { const text = block.replace(/^]*>/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(/]*>([\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) => !/^' + pieces.join(' ') + ''; return pad + '<' + clone.tag + attrStr + '>\n' + pieces.map((p) => (p.trim().startsWith('<') ? p : pad + ' ' + p)).join('\n') + '\n' + pad + ''; } /* ============================ 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(/]*>([\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 && )→ 该 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 = [ [/(? { 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 = /(?`。 两个约束缺一不可(实测踩过): 1) `&&` 之后必须紧跟 `(` / 空白 + `<元素`,否则 `onClose && onClose();` 这类 语句会被连到下一行的元素上,把 `.kole-mask` 误映射成「onClose=true」; 2) 元素内部不允许出现 `>`,避免跨元素贪吃。 */ const condRe = /(?]{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('(? 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 渲染成