// html-to-wx-nodes.ts declare global { interface DOMParser { new (): DOMParser; parseFromString(source: string, mimeType: string): Document; } interface Document { body: HTMLElement; } interface HTMLElement extends Element { innerHTML: string; } interface Element { tagName: string; attributes: NamedNodeMap; childNodes: ChildNode[]; } interface ChildNode { nodeType: number; textContent: string | null; } interface NamedNodeMap { length: number; item(index: number): Attr | null; } interface Attr { name: string; value: string; } const Node: { TEXT_NODE: number; ELEMENT_NODE: number; }; var DOMParser: { prototype: DOMParser; new (): DOMParser; }; } type WxNode = { name?: string; // 元素节点:标签名,如 'div','img' attrs?: Record; // 属性集合 children?: WxNode[]; // 子节点(元素或文本) type?: "text"; // 文本节点标记 text?: string; // 文本节点内容 }; function decodeHtmlEntities(str: string) { return str.replace(/<|>|&|"|'/g, (s) => { switch (s) { case "<": return "<"; case ">": return ">"; case "&": return "&"; case """: return '"'; case "'": return "'"; default: return s; } }); } function elementToNode(el: Element): WxNode { const node: WxNode = { name: el.tagName.toLowerCase(), attrs: {}, children: [], }; // copy attributes for (let i = 0; i < el.attributes.length; i++) { const a = el.attributes.item(i)!; node.attrs![a.name] = a.value; } // map special: if data-align present, try to map to style (optional) if (node.attrs!["data-align"] && !node.attrs!["style"]) { const align = node.attrs!["data-align"]; if (node.name === "img") { if (align === "center") { node.attrs!["style"] = "display:block;margin:0 auto;"; } else if (align === "left") { node.attrs!["style"] = "float:left;margin-right:8px;"; } else if (align === "right") { node.attrs!["style"] = "float:right;margin-left:8px;"; } } else { // for block elements like div, p node.attrs!["style"] = `text-align:${align};`; } } // children: process childNodes el.childNodes.forEach((child: ChildNode) => { if (child.nodeType === Node.TEXT_NODE) { const txt = (child.textContent || "").trim(); if (txt.length > 0) { node.children!.push({ type: "text", text: decodeHtmlEntities(txt) }); } } else if (child.nodeType === Node.ELEMENT_NODE) { node.children!.push(elementToNode(child as Element & ChildNode)); } }); // if no children for img, remove children prop (not required) if (node.name === "img") { delete node.children; } return node; } export function htmlToWxNodes(html: string): WxNode[] { html = (html || "").trim(); if (!html) return []; // try DOMParser (browser / devtools) if (typeof DOMParser !== "undefined") { try { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const body = doc.body; const nodes: WxNode[] = []; body.childNodes.forEach((child) => { if (child.nodeType === Node.TEXT_NODE) { const txt = (child.textContent || "").trim(); if (txt) nodes.push({ type: "text", text: decodeHtmlEntities(txt) }); } else if (child.nodeType === Node.ELEMENT_NODE) { nodes.push(elementToNode(child as Element & ChildNode)); } }); return nodes; } catch (e) { // fallthrough to fallback parser console.warn("DOMParser failed, fallback to simple parse", e); } } // Fallback: very small parser for simple HTML (img, div, p, plain text) const fallbackNodes: WxNode[] = []; const imgRegex = /]+?)\/?>/gi; let lastIndex = 0; let m; while ((m = imgRegex.exec(html)) !== null) { // push text before this img as text node if (m.index > lastIndex) { const txt = html .slice(lastIndex, m.index) .replace(/<[^>]+>/g, "") .trim(); if (txt) fallbackNodes.push({ type: "text", text: decodeHtmlEntities(txt) }); } const attrsStr = m[1]; const attrs: Record = {}; attrsStr.replace( /([^\s=]+)(?:="([^"]*)")?/g, (_: string, k: string, v: string) => { attrs[k] = v || ""; return ""; } ); // normalize src, alt const node: WxNode = { name: "img", attrs: { src: attrs.src || attrs["data-src"] || "", alt: attrs.alt || "", }, }; if (attrs["data-align"]) { const al = attrs["data-align"]; if (al === "center") node.attrs!["style"] = "display:block;margin:0 auto;max-width:100%;height:auto;"; if (al === "left") node.attrs!["style"] = "float:left;margin-right:8px;max-width:100%;height:auto;"; if (al === "right") node.attrs!["style"] = "float:right;margin-left:8px;max-width:100%;height:auto;"; } fallbackNodes.push(node); lastIndex = imgRegex.lastIndex; } // trailing text if (lastIndex < html.length) { const trailing = html .slice(lastIndex) .replace(/<[^>]+>/g, "") .trim(); if (trailing) fallbackNodes.push({ type: "text", text: decodeHtmlEntities(trailing) }); } return fallbackNodes; }