git » sdk » commit 2a9ca8d

Html builder

author Stephen Paul Weber
2026-04-07 19:55:56 UTC
committer Stephen Paul Weber
2026-04-07 19:59:29 UTC
parent 391d85d120a6d655c03f1c7bb3e608bfdc0640b1

Html builder

HaxeCBridge.hx +10 -5
borogove/Chat.hx +5 -3
borogove/ChatMessage.hx +3 -44
borogove/ChatMessageBuilder.hx +4 -33
borogove/Html.hx +194 -33
test/TestChatMessageBuilder.hx +5 -3
test/TestHtml.hx +12 -11

diff --git a/HaxeCBridge.hx b/HaxeCBridge.hx
index 5d2bd0e..abcf8ef 100644
--- a/HaxeCBridge.hx
+++ b/HaxeCBridge.hx
@@ -286,16 +286,21 @@ class HaxeCBridge {
 							case TPType(TPath(_.name => "String")): true;
 							default: false;
 							}
-							if (isString) {
-								passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).map(cpp.NativeString.fromPointer).copy());
-							} else {
-								passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).copy());
-							}
 							args.push({ name: arg.name, type: TPath({name: "ConstPointer", pack: ["cpp"], params: path.params.map(tp -> convertSecondaryTP(tp))}) });
 							switch (arg.type) {
 							case TPath(path) if (path.name == "Null" || path.sub == "Null"): true;
+								if (isString) {
+									passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).map(cpp.NativeString.fromPointer).copy());
+								} else {
+									passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).copy());
+								}
 								args.push({ name: arg.name + "__len", type: TPath({name: "PtrDiffT", pack: []}) });
 							default:
+								if (isString) {
+									passArgs.push(macro $i{arg.name} == null ? [] : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).map(cpp.NativeString.fromPointer).copy());
+								} else {
+									passArgs.push(macro $i{arg.name} == null ? [] : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).copy());
+								}
 								args.push({ name: arg.name + "__len", type: TPath({name: "SizeT", pack: ["cpp"]}) });
 							}
 						default:
diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 6221fed..50e08a5 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -256,7 +256,9 @@ abstract class Chat {
 			},
 			(text, uri) -> {
 				final hash = Hash.fromUri(uri);
-				toSend.setHtml('<img alt="' + Util.xmlEscape(text) + '" src="' + Util.xmlEscape(hash == null ? uri : hash.bobUri()) + '" />');
+				toSend.setHtml(
+					new Html([Element(new Stanza("img", { alt: text, src: hash == null ? uri : hash.bobUri() }))], null)
+				);
 				return "";
 			}
 		);
@@ -1113,7 +1115,7 @@ class DirectChat extends Chat {
 			if (reaction.envelopeId == null) throw "Cannot remove custom emoji reaction without envelopeId";
 			final correct = m.reply();
 			correct.localId = ID.unique();
-			correct.setHtml("");
+			correct.setHtml(new Html([], null));
 			correct.text = null;
 
 			final fakeEnvelope = new ChatMessageBuilder();
@@ -1843,7 +1845,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 			if (reaction.envelopeId == null) throw "Cannot remove custom emoji reaction without envelopeId";
 			final correct = m.reply();
 			correct.localId = ID.unique();
-			correct.setHtml("");
+			correct.setHtml(new Html([], null));
 			correct.text = null;
 
 
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index 6f634cd..e9e52f5 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -397,55 +397,14 @@ class ChatMessage {
 	}
 
 	/**
-		Walk the HTML version of the message body
+		The HTML version of the message body
 
 		WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately!
 
-		@param f callback taking tag or text, attribute names, attribute values, and transformed children, and returning the transformation of this element or text
 		@param sender optionally specify the full details of the sender
 	**/
-	public function html<T>(f: (String, Null<Array<String>>, Null<Array<String>>, Null<Array<T>>)->T, sender: Null<Participant> = null):Array<T> {
-		var isAction = false;
-
-		function mkTxt(txt: String) {
-			final senderP = sender;
-			return if (!isAction && txt.startsWith("/me ") && senderP != null) {
-				isAction = true;
-				f(senderP.displayName + txt.substr(3), null, null, null);
-			} else {
-				f(txt, null, null, null);
-			};
-		}
-
-		final fragment = htmlBody().map(item -> switch (item) {
-			case Element(el):
-				el.reduce(
-					(st, kids) -> {
-						// We don't deeply sanitize but we can remove some obvious dumb stuff
-						if (st.name == "style" || st.name == "script") return mkTxt("");
-
-						final keys = st.attr.keys().filter(k -> !k.startsWith("on"));
-						return f(
-							st.name,
-							keys,
-							keys.map(k -> {
-								final v = st.attr.get(k) ?? "";
-								if (st.name == "img" && k == "src" && v != "") {
-									final hash = Hash.fromUri(v);
-									hash == null ? v : hash.toUri();
-								} else {
-									v;
-								}
-							}),
-							kids
-						);
-					},
-					txt -> mkTxt(txt)
-				);
-			case CData(txt):
-				mkTxt(txt.content);
-		});
-		return isAction ? [f("div", ["class"], ["action"], fragment)] : fragment;
+	public function html(sender: Null<Participant> = null):Html {
+		return new Html(htmlBody(), sender);
 	}
 
 	/**
diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx
index db7a9e6..eb0963c 100644
--- a/borogove/ChatMessageBuilder.hx
+++ b/borogove/ChatMessageBuilder.hx
@@ -177,7 +177,7 @@ class ChatMessageBuilder {
 		?versions: Array<ChatMessage>,
 		?payloads: Array<Stanza>,
 		?encryption: Null<EncryptionInfo>,
-		?html: Null<String>,
+		?html: Null<Html>,
 	}) {
 		this.localId = params?.localId;
 		this.serverId = params?.serverId;
@@ -261,49 +261,20 @@ class ChatMessageBuilder {
 	}
 
 	/**
-		Set rich text using an HTML string
+		Set rich text using HTML
 		Also sets the plain text body appropriately
 	**/
-	public function setHtml(html: String) {
+	public function setHtml(html: Html) {
 		final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" });
 		final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" });
 		htmlEl.addChild(body);
-		final nodes = htmlparser.HtmlParser.run(html, true);
-		for (node in nodes) {
-			final el = Util.downcast(node, htmlparser.HtmlNodeElement);
-			if (el != null && (el.name == "html" || el.name == "body")) {
-				for (inner in el.nodes) {
-					body.addDirectChild(htmlToNode(inner));
-				}
-			} else {
-				body.addDirectChild(htmlToNode(node));
-			}
-		}
+		body.addChildNodes(html.xml);
 		final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html");
 		if (htmlIdx >= 0) payloads.splice(htmlIdx, 1);
 		payloads.push(htmlEl);
 		text = ~/\n$/.replace(XEP0393.render(body), "");
 	}
 
-	private function htmlToNode(node: htmlparser.HtmlNode) {
-		final txt = Util.downcast(node, htmlparser.HtmlNodeText);
-		if (txt != null) {
-			return CData(new TextNode(txt.toText()));
-		}
-		final el = Util.downcast(node, htmlparser.HtmlNodeElement);
-		if (el != null) {
-			final s = new Stanza(el.name, {});
-			for (attr in el.attributes) {
-				s.attr.set(attr.name, attr.value);
-			}
-			for (child in el.nodes) {
-				s.addDirectChild(htmlToNode(child));
-			}
-			return Element(s);
-		}
-		throw "node was neither text nor element?";
-	}
-
 	/**
 		The ID of the Chat this message is associated with
 	**/
diff --git a/borogove/Html.hx b/borogove/Html.hx
index eb78af2..8fca3e6 100644
--- a/borogove/Html.hx
+++ b/borogove/Html.hx
@@ -1,5 +1,11 @@
 package borogove;
 
+import haxe.DynamicAccess;
+import haxe.ds.ReadOnlyArray;
+using StringTools;
+
+import borogove.Stanza;
+
 #if cpp
 import HaxeCBridge;
 #end
@@ -10,6 +16,11 @@ import HaxeCBridge;
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
 #end
+/**
+	Rich text
+
+	WARNING: this is possibly untrusted HTML. You must render or sanitize appropriately!
+**/
 class Html {
 	private static final HTML_EMPTY = [
 		"area",
@@ -28,54 +39,204 @@ class Html {
 		"wbr"
 	];
 
-	public static function asString(tag: String, attr: Null<Array<String>>, attrValue: Null<Array<String>>, kids: Null<Array<String>>): String {
-		if (attr == null && kids == null) {
-			return StringTools.htmlEscape(tag);
-		} else if (attr != null && attrValue != null) {
-			final el = Xml.createElement(tag);
-			for (i => attr_k in attr) {
-				el.set(attr_k, attrValue[i]);
+	@:allow(borogove)
+	private final xml: ReadOnlyArray<Node>;
+	private final sender: Null<Participant>;
+
+	@:allow(borogove)
+	private function new(xml: Array<Node>, sender: Null<Participant>) {
+		this.xml = xml;
+		this.sender = sender;
+	}
+
+	#if js
+	/**
+		HTML builder, make an element
+	**/
+	public static function element(tag: String, attrs: DynamicAccess<String>, children: Array<Html>) {
+		final s = new Stanza(tag, attrs);
+		for (c in children) {
+			for (n in c.xml) {
+				s.addDirectChild(n);
 			}
+		}
 
-			final start = el.toString();
-			final buffer = new StringBuf();
-			buffer.addSub(start, 0, start.length-2);
+		return new Html([Element(s)], null);
+	}
+	#else
+	/**
+		HTML builder, make an element
+	**/
+	public static function element(tag: String, attr: Array<String>, attrValues: Array<String>, children: Array<Html>) {
+		final attrs: DynamicAccess<String> = {};
+		for (i => a in attr) {
+			attrs[a] = attrValues[i];
+		}
 
-			if (HTML_EMPTY.contains(tag)) {
-				buffer.add(" />");
-				return buffer.toString();
+		final s = new Stanza(tag, attrs);
+		for (c in children) {
+			for (n in c.xml) {
+				s.addDirectChild(n);
 			}
+		}
+
+		return new Html([Element(s)], null);
+	}
+	#end
+
+	/**
+		HTML builder, make some text
+	**/
+	public static function text(text: String) {
+		return new Html([CData(new TextNode(text))], null);
+	}
 
-			buffer.add(">");
-			if (kids != null) {
-				for (kid in kids) {
-					buffer.add(kid);
+	/**
+		Build HTML payload from source
+	**/
+	public static function fromString(html: String): Html {
+		final nodes = [];
+		for (node in htmlparser.HtmlParser.run(html, true)) {
+			final el = Util.downcast(node, htmlparser.HtmlNodeElement);
+			if (el != null && (el.name == "html" || el.name == "body")) {
+				for (inner in el.nodes) {
+					nodes.push(htmlToNode(inner));
 				}
+			} else {
+				nodes.push(htmlToNode(node));
 			}
+		}
+		return new Html(nodes, null);
+	}
 
-			buffer.add("</");
-			buffer.add(tag);
-			buffer.add(">");
-			return buffer.toString();
+	private static function htmlToNode(node: htmlparser.HtmlNode) {
+		final txt = Util.downcast(node, htmlparser.HtmlNodeText);
+		if (txt != null) {
+			return CData(new TextNode(txt.toText()));
+		}
+		final el = Util.downcast(node, htmlparser.HtmlNodeElement);
+		if (el != null) {
+			final s = new Stanza(el.name, {});
+			for (attr in el.attributes) {
+				s.attr.set(attr.name, attr.value);
+			}
+			for (child in el.nodes) {
+				s.addDirectChild(htmlToNode(child));
+			}
+			return Element(s);
 		}
+		throw "node was neither text nor element?";
+	}
+
+	/**
+		Walk the HTML tree to produce a new value
+	**/
+	public function reduce<T>(f: (String, Null<Array<String>>, Null<Array<String>>, Null<Array<T>>)->T):Array<T> {
+		var isAction = false;
+
+		function mkTxt(txt: String) {
+			final senderP = sender;
+			return if (!isAction && txt.startsWith("/me ") && senderP != null) {
+				isAction = true;
+				f(senderP.displayName + txt.substr(3), null, null, null);
+			} else {
+				f(txt, null, null, null);
+			};
+		}
+
+		final fragment = xml.map(item -> switch (item) {
+			case Element(el):
+				el.reduce(
+					(st, kids) -> {
+						// We don't deeply sanitize but we can remove some obvious dumb stuff
+						if (st.name == "style" || st.name == "script") return mkTxt("");
+
+						final keys = st.attr.keys().filter(k -> !k.startsWith("on"));
+						return f(
+							st.name,
+							keys,
+							keys.map(k -> {
+								final v = st.attr.get(k) ?? "";
+								if (st.name == "img" && k == "src" && v != "") {
+									final hash = Hash.fromUri(v);
+									hash == null ? v : hash.toUri();
+								} else {
+									v;
+								}
+							}),
+							kids
+						);
+					},
+					txt -> mkTxt(txt)
+				);
+			case CData(txt):
+				mkTxt(txt.content);
+		});
+		return isAction ? [f("div", ["class"], ["action"], fragment)] : fragment;
+	}
+
+	/**
+		Get HTML source as a string
+	**/
+	public function toString(): String {
+		return reduce((tag, attr, attrValue, kids) -> {
+			if (attr == null && kids == null) {
+				return StringTools.htmlEscape(tag);
+			} else if (attr != null && attrValue != null) {
+				final el = Xml.createElement(tag);
+				for (i => attr_k in attr) {
+					el.set(attr_k, attrValue[i]);
+				}
+
+				final start = el.toString();
+				final buffer = new StringBuf();
+				buffer.addSub(start, 0, start.length-2);
 
-		throw "Invalid arguments";
+				if (HTML_EMPTY.contains(tag)) {
+					buffer.add(" />");
+					return buffer.toString();
+				}
+
+				buffer.add(">");
+				if (kids != null) {
+					for (kid in kids) {
+						buffer.add(kid);
+					}
+				}
+
+				buffer.add("</");
+				buffer.add(tag);
+				buffer.add(">");
+				return buffer.toString();
+			}
+
+			throw "Invalid arguments";
+		}).join("");
 	}
 
 	#if js
-	public static function asDOM(tag: String, attr: Null<Array<String>>, attrValue: Null<Array<String>>, kids: Null<Array<js.html.Node>>): js.html.Node {
-		if (attr == null && kids == null) {
-			return js.Browser.document.createTextNode(tag);
-		} else if (attr != null && attrValue != null) {
-			final el = js.Browser.document.createElement(tag);
-			for (i => attr_k in attr) {
-				el.setAttribute(attr_k, attrValue[i]);
+	/**
+		Get HTML as a DocumentFragment
+	**/
+	public function asDOM(): js.html.DocumentFragment {
+		final nodes = reduce((tag, attr, attrValue, kids) -> {
+			if (attr == null && kids == null) {
+				return (js.Browser.document.createTextNode(tag) : js.html.Node);
+			} else if (attr != null && attrValue != null) {
+				final el = js.Browser.document.createElement(tag);
+				for (i => attr_k in attr) {
+					el.setAttribute(attr_k, attrValue[i]);
+				}
+				if (kids != null) el.append(...kids);
+				return el;
 			}
-			if (kids != null) el.append(...kids);
-			return el;
-		}
 
-		throw "Invalid arguments";
+			throw "Invalid arguments";
+		});
+
+		final frag = new js.html.DocumentFragment();
+		frag.append(...nodes);
+		return frag;
 	}
 #end
 }
diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx
index 0daf802..8a9980f 100644
--- a/test/TestChatMessageBuilder.hx
+++ b/test/TestChatMessageBuilder.hx
@@ -2,13 +2,15 @@ package test;
 
 import utest.Assert;
 import utest.Async;
+
+import borogove.Html;
 import borogove.ChatMessageBuilder;
 
 @:access(borogove)
 class TestChatMessageBuilder extends utest.Test {
 	public function testConvertHtmlToXHTML() {
 		final msg = new ChatMessageBuilder();
-		msg.setHtml("Hello <div><img src='hai'><br>");
+		msg.setHtml(Html.fromString("Hello <div><img src='hai'><br>"));
 		Assert.equals(
 			"<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\">Hello <div><img src=\"hai\"/><br/></div></body></html>",
 			msg.payloads[0].toString()
@@ -17,7 +19,7 @@ class TestChatMessageBuilder extends utest.Test {
 
 	public function testConvertHtmlToText() {
 		final msg = new ChatMessageBuilder();
-		msg.setHtml("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you");
+		msg.setHtml(Html.fromString("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you"));
 		Assert.equals(
 			"> Hello\n> you\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```",
 			msg.text
@@ -26,7 +28,7 @@ class TestChatMessageBuilder extends utest.Test {
 
 	public function testConvertHtmlToXHTMLIgnoresBody() {
 		final msg = new ChatMessageBuilder();
-		msg.setHtml("<body>Hello <div><img src='hai'><br></body>");
+		msg.setHtml(Html.fromString("<body>Hello <div><img src='hai'><br></body>"));
 		Assert.equals(
 			"<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\">Hello <div><img src=\"hai\"/><br/></div></body></html>",
 			msg.payloads[0].toString()
diff --git a/test/TestHtml.hx b/test/TestHtml.hx
index ff4e06a..c72c3a1 100644
--- a/test/TestHtml.hx
+++ b/test/TestHtml.hx
@@ -3,6 +3,7 @@ package test;
 import utest.Assert;
 import utest.Async;
 
+import borogove.Html;
 import borogove.ChatMessageBuilder;
 import borogove.JID;
 import borogove.Participant;
@@ -15,10 +16,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml("Hello <div class='sup&amp;2'><img src='hai'><br><p></p>");
+		msg.setHtml(Html.fromString("Hello <div class='sup&amp;2'><img src='hai'><br><p></p>"));
 		Assert.equals(
 			"Hello <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div>",
-			msg.build().html(borogove.Html.asString).join("")
+			msg.build().html().toString()
 		);
 	}
 
@@ -28,10 +29,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>");
+		msg.setHtml(Html.fromString("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>"));
 		Assert.equals(
 			"<img src=\"ni:///sha-1;Ry4iB1Gfglwq_8Y2VQojy88e9aw\" />",
-			msg.build().html(borogove.Html.asString).join("")
+			msg.build().html().toString()
 		);
 	}
 
@@ -47,7 +48,7 @@ class TestHtml extends utest.Test {
 
 		Assert.equals(
 			"<div class=\"action\"><div>hatter says hello</div></div>",
-			msg.build().html(borogove.Html.asString, participant).join("")
+			msg.build().html(participant).toString()
 		);
 	}
 
@@ -57,13 +58,13 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml("/me says <div class='sup&amp;2'><img src='hai'><br><p></p>");
+		msg.setHtml(Html.fromString("/me says <div class='sup&amp;2'><img src='hai'><br><p></p>"));
 
 		final participant = new Participant("hatter", null, "", false, msg.from, null);
 
 		Assert.equals(
 			"<div class=\"action\">hatter says <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div></div>",
-			msg.build().html(borogove.Html.asString, participant).join("")
+			msg.build().html(participant).toString()
 		);
 	}
 
@@ -73,10 +74,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml("<a onclick='alert();'>hello</a>");
+		msg.setHtml(Html.fromString("<a onclick='alert();'>hello</a>"));
 		Assert.equals(
 			"<a>hello</a>",
-			msg.build().html(borogove.Html.asString).join("")
+			msg.build().html().toString()
 		);
 	}
 
@@ -86,10 +87,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml("<style>hai</style><script>hai</script>hai");
+		msg.setHtml(Html.fromString("<style>hai</style><script>hai</script>hai"));
 		Assert.equals(
 			"hai",
-			msg.build().html(borogove.Html.asString).join("")
+			msg.build().html().toString()
 		);
 	}
 }