Skills › Visual Explainer › visual-explainer › html.mjs
html.mjs
67 lines · plugins/visual-explainer/kit/html.mjs at 9d5f880
1// A small HTML tree, shared by the page and plan renderers: enough to find tags and attributes. Untouched parts serialize byte for byte.
2const VOID = new Set("area base br col embed hr img input link meta source track wbr".split(" "));
3const RAW_TEXT = new Set(["script", "style", "textarea", "title"]);
4const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
5export const decode = (s) => s.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (m, e) => (e[0] === "#" ? String.fromCodePoint(e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : +e.slice(1)) : ENTITIES[e.toLowerCase()] ?? m));
6export const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
7const TAG = /<!--[\s\S]*?-->|<!doctype[^>]*>|<\/([a-zA-Z][\w:-]*)\s*>|<([a-zA-Z][\w:-]*)((?:\s+[^\s=\/>"']+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gi;
9function parseAttrs(src) {
10 const attrs = {};
11 for (const m of src.matchAll(/([^\s=\/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) attrs[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? "");
12 return attrs;
13}
15export function parseHtml(html) {
16 const root = { name: "#root", attrs: {}, children: [] };
17 const stack = [root];
18 const top = () => stack[stack.length - 1];
19 const text = (raw) => raw && top().children.push({ text: raw });
20 let last = 0;
21 let m;
22 TAG.lastIndex = 0;
23 while ((m = TAG.exec(html))) {
24 text(html.slice(last, m.index));
25 last = TAG.lastIndex;
26 if (m[0].startsWith("<!")) { top().children.push({ text: m[0], comment: true }); continue; }
27 if (m[1]) {
28 const i = stack.findLastIndex((n) => n.name === m[1].toLowerCase());
29 if (i > 0) { stack[i].closeRaw = m[0]; stack.length = i; } else text(m[0]);
30 continue;
31 }
32 const name = m[2].toLowerCase();
33 const node = { name, attrs: parseAttrs(m[3]), openRaw: m[0], children: [], pos: m.index, selfClose: !!m[4] || VOID.has(name) };
34 top().children.push(node);
35 if (node.selfClose) continue;
36 if (RAW_TEXT.has(name)) {
37 // only "</script" followed by space, / or > ends the text; "</scripture>" inside a string does not
38 const closer = new RegExp(`</${name}(?=[\\s/>])`, "gi");
39 closer.lastIndex = last;
40 const end = closer.exec(html)?.index ?? -1;
41 const stop = end < 0 ? html.length : end;
42 node.children.push({ text: html.slice(last, stop) });
43 node.closeRaw = end < 0 ? "" : html.slice(end).match(/^<\/[^>]*>/)[0];
44 last = stop + node.closeRaw.length;
45 TAG.lastIndex = last;
46 continue;
47 }
48 stack.push(node);
49 }
50 text(html.slice(last));
51 return root;
52}
54export const serialize = (n) => n.html ?? n.text ?? (n.openRaw ?? "") + n.children.map(serialize).join("") + (n.closeRaw ?? "");
55export const inner = (n) => n.children.map(serialize).join("");
56export const kids = (n) => n.children.filter((c) => c.name);
57function* walk(n) { for (const c of n.children ?? []) if (c.name) { yield c; yield* walk(c); } }
58export const find = (n, pred) => [...walk(n)].filter(pred);
59export const textOf = (n) => (n.text !== undefined ? (n.comment ? "" : decode(n.text)) : n.name === "script" || n.name === "style" ? "" : n.children.map(textOf).join(""));
60export const rawText = (n) => n.children.map((c) => c.text ?? "").join("");
61export const words = (s) => s.trim().split(/\s+/).filter(Boolean).length;
62export const hasClass = (n, c) => (n.attrs?.class ?? "").split(/\s+/).includes(c);
63export function addClass(n, cls) {
64 const m = n.openRaw.match(/\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
65 n.openRaw = m ? n.openRaw.replace(m[0], ` class="${esc(`${m[1] ?? m[2]} ${cls}`.trim())}"`) : n.openRaw.replace(/\s*(\/?)>$/, ` class="${cls}"$1>`);
66 n.attrs.class = `${n.attrs.class ?? ""} ${cls}`.trim();
67}