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