git » sdk » commit f2accce

Use paragraphs with possible breaks in them, not div

author Stephen Paul Weber
2026-05-03 03:08:18 UTC
committer Stephen Paul Weber
2026-05-03 19:34:35 UTC
parent ad1e8065219392929567a144ff924a9084bb42c4

Use paragraphs with possible breaks in them, not div

Much more screen-reader friendly

borogove/XEP0393.hx +28 -5
test/TestChatMessage.hx +20 -3
test/TestChatMessageBuilder.hx +2 -2
test/TestHtml.hx +1 -1
test/TestXEP0393.hx +100 -31

diff --git a/borogove/XEP0393.hx b/borogove/XEP0393.hx
index 61ec127..3a7f742 100644
--- a/borogove/XEP0393.hx
+++ b/borogove/XEP0393.hx
@@ -113,7 +113,7 @@ class XEP0393 {
 			endsWithNewline = true;
 		}
 
-		if (xhtml.name == "p") {
+		if (xhtml.name == "p" && xhtml.attr.get("class") != "tight") {
 			s.add("\n");
 		}
 
@@ -231,11 +231,26 @@ class XEP0393 {
 			return parsePreformatted(styled);
 		} else {
 			var end = 0;
+			final nodes = [];
+			var tight = false;
 			final styledLength = styled.length;
-			while (end < styledLength && styled.charAt(end) != "\n") end++;
-			final lineEnd = end;
-			if (end < styledLength && styled.charAt(end) == "\n") end++;
-			return { block: new Stanza("div").addChildNodes(parseSpans(styled.substr(0, lineEnd))), rest: styled.substr(end) };
+			while (end < styledLength) {
+				var lineEnd = end;
+				while (lineEnd < styledLength && styled.charAt(lineEnd) != "\n") lineEnd++;
+				if (nodes.length > 0) nodes.push(Element(new Stanza("br")));
+				for (span in parseSpans(styled.substr(end, lineEnd - end))) nodes.push(span);
+				end = lineEnd + 1;
+				if (styled.charAt(end) == "\n") {
+					end++;
+					break;
+				}
+				if (styled.charAt(end) == ">" || styled.substr(end, 3) == "```") {
+					tight = true;
+					break;
+				}
+			}
+
+			return { block: new Stanza("p", tight ? { "class": "tight" } : {}).addChildNodes(nodes), rest: styled.substr(end) };
 		}
 	}
 
@@ -262,6 +277,10 @@ class XEP0393 {
 			}
 		}
 
+		if (styled.charAt(end) == "\n") {
+			end++;
+		}
+
 		return { block: new Stanza("blockquote").addChildren(parse(lines.join(""))), rest: styled.substr(end) };
 	}
 
@@ -293,6 +312,10 @@ class XEP0393 {
 			}
 		}
 
+		if (styled.charAt(end) == "\n") {
+			end++;
+		}
+
 		final block = new Stanza("pre");
 		if (lang != "") {
 			block.tag("code", {"class": 'language-$lang'}).text(lines.join(""));
diff --git a/test/TestChatMessage.hx b/test/TestChatMessage.hx
index cbb922a..e08794d 100644
--- a/test/TestChatMessage.hx
+++ b/test/TestChatMessage.hx
@@ -57,7 +57,7 @@ class TestChatMessage extends utest.Test {
 		final msg = Message.fromStanza(stanza, JID.parse("bob@example.com"));
 		switch (msg.parsed) {
 			case ChatMessageStanza(m):
-				Assert.equals("<div>line 1</div><div><strong>line 2</strong></div>", m.body().toString());
+				Assert.equals("<p>line 1<br /><strong>line 2</strong></p>", m.body().toString());
 				Assert.equals("line 1\n*line 2*", m.body().toPlainText());
 			default:
 				Assert.fail("Expected ChatMessageStanza");
@@ -100,6 +100,23 @@ class TestChatMessage extends utest.Test {
 		}
 	}
 
+	public function testStyledBodyWithPreTightAfterPlain() {
+		final stanza = new Stanza("message");
+		stanza.attr.set("id", "test-id-1");
+		stanza.attr.set("from", "alice@example.com");
+		stanza.attr.set("to", "bob@example.com");
+		stanza.attr.set("type", "chat");
+		stanza.addChild(new Stanza("body").text("hello\n```\nlet hello;"));
+
+		final msg = Message.fromStanza(stanza, JID.parse("bob@example.com"));
+		switch (msg.parsed) {
+			case ChatMessageStanza(m):
+				Assert.equals("<p class=\"tight\">hello</p><pre>let hello;\n</pre>", m.body().toString());
+				Assert.equals("hello\n```\nlet hello;\n```", m.body().toPlainText());
+			default:
+				Assert.fail("Expected ChatMessageStanza");
+		}
+	}
 
 	public function testStyledBodyWithLinkBeaks() {
 		final stanza = new Stanza("message");
@@ -112,7 +129,7 @@ class TestChatMessage extends utest.Test {
 		final msg = Message.fromStanza(stanza, JID.parse("bob@example.com"));
 		switch (msg.parsed) {
 			case ChatMessageStanza(m):
-				Assert.equals("<div>Hey &lt;<a href=\"https://example.com\">https://example.com</a>&gt;</div>", m.body().toString());
+				Assert.equals("<p>Hey &lt;<a href=\"https://example.com\">https://example.com</a>&gt;</p>", m.body().toString());
 				Assert.equals("Hey <https://example.com>", m.body().toPlainText());
 			default:
 				Assert.fail("Expected ChatMessageStanza");
@@ -130,7 +147,7 @@ class TestChatMessage extends utest.Test {
 		final msg = Message.fromStanza(stanza, JID.parse("bob@example.com"));
 		switch (msg.parsed) {
 			case ChatMessageStanza(m):
-				Assert.equals("<div>Hey <a href=\"https://example.com\">example.com</a></div>", m.body().toString());
+				Assert.equals("<p>Hey <a href=\"https://example.com\">example.com</a></p>", m.body().toString());
 				Assert.equals("Hey example.com", m.body().toPlainText());
 			default:
 				Assert.fail("Expected ChatMessageStanza");
diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx
index 4dd814f..17a3777 100644
--- a/test/TestChatMessageBuilder.hx
+++ b/test/TestChatMessageBuilder.hx
@@ -22,7 +22,7 @@ class TestChatMessageBuilder extends utest.Test {
 		final msg = new ChatMessageBuilder();
 		msg.setBody(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```",
+			"> Hello\n> you\n\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```",
 			msg.text
 		);
 	}
@@ -58,7 +58,7 @@ class TestChatMessageBuilder extends utest.Test {
 		final msg = new ChatMessageBuilder();
 		msg.setBody(Html.fromString("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><p>a</p><p>b</p><pre>hello<br>you"));
 		Assert.equals(
-			"> Hello\n> you\n:boop:\n*hi* _hi_ ~hey~ `up`\na\nb\n```\nhello\nyou\n```",
+			"> Hello\n> you\n\n:boop:\n*hi* _hi_ ~hey~ `up`\na\n\nb\n\n```\nhello\nyou\n```",
 			msg.text
 		);
 	}
diff --git a/test/TestHtml.hx b/test/TestHtml.hx
index 9047cfa..837ee4c 100644
--- a/test/TestHtml.hx
+++ b/test/TestHtml.hx
@@ -47,7 +47,7 @@ class TestHtml extends utest.Test {
 		final participant = new Participant("hatter", null, "", false, [], msg.from, null);
 
 		Assert.equals(
-			"<div class=\"action\"><div>hatter says hello</div></div>",
+			"<div class=\"action\"><p>hatter says hello</p></div>",
 			msg.build().body(participant).toString()
 		);
 	}
diff --git a/test/TestXEP0393.hx b/test/TestXEP0393.hx
index fd0e853..0920216 100644
--- a/test/TestXEP0393.hx
+++ b/test/TestXEP0393.hx
@@ -13,7 +13,7 @@ class TestXEP0393 extends utest.Test {
 
 	public function testSpansDoNotEscapeBlocks() {
 		Assert.equals(
-			"<div>There are three blocks in this body, one per line,</div><div>but there is no *formatting</div><div>as spans* may not escape blocks.</div>",
+			"<p>There are three blocks in this body, one per line,<br/>but there is no *formatting<br/>as spans* may not escape blocks.</p>",
 			toHtml("There are three blocks in this body, one per line,
 but there is no *formatting
 as spans* may not escape blocks.")
@@ -23,7 +23,7 @@ as spans* may not escape blocks.")
 	public function testPreformattedBlockSimple() {
 		Assert.equals(
 			"<pre>(println \"Hello, world!\")
-</pre><div/><div>This should show up as monospace, preformatted text ⤴</div>",
+</pre><p>This should show up as monospace, preformatted text ⤴</p>",
 			toHtml("```
 (println \"Hello, world!\")
 ```
@@ -35,7 +35,7 @@ This should show up as monospace, preformatted text ⤴")
 	public function testPreformattedBlock() {
 		Assert.equals(
 			"<pre><code class=\"language-ignored\">(println \"Hello, world!\")
-</code></pre><div/><div>This should show up as monospace, preformatted text ⤴</div>",
+</code></pre><p>This should show up as monospace, preformatted text ⤴</p>",
 			toHtml("```ignored
 (println \"Hello, world!\")
 ```
@@ -47,7 +47,7 @@ This should show up as monospace, preformatted text ⤴")
 	public function testPreformattedBlockUnterminated() {
 		Assert.equals(
 			"<blockquote><pre><code class=\"language-ignored\">(println \"Hello, world!\")
-</code></pre></blockquote><div/><div>The entire blockquote is a preformatted text block, but this line</div><div>is plaintext!</div>",
+</code></pre></blockquote><p>The entire blockquote is a preformatted text block, but this line<br/>is plaintext!</p>",
 			toHtml("> ```ignored
 > (println \"Hello, world!\")
 
@@ -58,7 +58,7 @@ is plaintext!")
 
 	public function testQuotation() {
 		Assert.equals(
-			"<blockquote><div>That that is, is.</div></blockquote><div/><div>Said the old hermit of Prague.</div>",
+			"<blockquote><p>That that is, is.</p></blockquote><p>Said the old hermit of Prague.</p>",
 			toHtml("> That that is, is.
 
 Said the old hermit of Prague.")
@@ -67,7 +67,7 @@ Said the old hermit of Prague.")
 
 	public function testNestedQuotation() {
 		Assert.equals(
-			"<blockquote><blockquote><div>That that is, is.</div></blockquote><div>Said the old hermit of Prague.</div></blockquote><div/><div>Who?</div>",
+			"<blockquote><blockquote><p>That that is, is.</p></blockquote><p>Said the old hermit of Prague.</p></blockquote><p>Who?</p>",
 			toHtml(">> That that is, is.
 > Said the old hermit of Prague.
 
@@ -75,9 +75,36 @@ Who?")
 		);
 	}
 
+	public function testQuotationAfterPlain() {
+		Assert.equals(
+			"<p class=\"tight\">He said:</p><blockquote><p>What is up</p></blockquote>",
+			toHtml("He said:
+> What is up")
+		);
+	}
+
+	public function testCodeAfterPlain() {
+		Assert.equals(
+			"<p class=\"tight\">He said:</p><pre>some code
+</pre>",
+			toHtml("He said:
+```
+some code")
+		);
+	}
+
+	public function testQuotationAfterPlainPara() {
+		Assert.equals(
+			"<p>He said:</p><blockquote><p>What is up</p></blockquote>",
+			toHtml("He said:
+
+> What is up")
+		);
+	}
+
 	public function testPlainSpan() {
 		Assert.equals(
-			"<div>plain span</div>",
+			"<p>plain span</p>",
 			toHtml("plain span")
 		);
 	}
@@ -111,98 +138,98 @@ Who?")
 
 	public function testStrongSpan() {
 		Assert.equals(
-			"<div><strong>strong span</strong></div>",
+			"<p><strong>strong span</strong></p>",
 			toHtml("*strong span*")
 		);
 	}
 
 	public function testPlainEmphasisPlain() {
 		Assert.equals(
-			"<div>plain <em>emphasis</em> plain</div>",
+			"<p>plain <em>emphasis</em> plain</p>",
 			toHtml("plain _emphasis_ plain")
 		);
 	}
 
 	public function testPrePlainStrong() {
 		Assert.equals(
-			"<div><tt>pre</tt> plain <strong>strong</strong></div>",
+			"<p><tt>pre</tt> plain <strong>strong</strong></p>",
 			toHtml("`pre` plain *strong*")
 		);
 	}
 
 	public function testStrongPlain() {
 		Assert.equals(
-			"<div><strong>strong</strong>plain*</div>",
+			"<p><strong>strong</strong>plain*</p>",
 			toHtml("*strong*plain*")
 		);
 	}
 
 	public function testPlainStrong() {
 		Assert.equals(
-			"<div>* plain <strong>strong</strong></div>",
+			"<p>* plain <strong>strong</strong></p>",
 			toHtml("* plain *strong*")
 		);
 	}
 
 	public function testNotStrong1() {
 		Assert.equals(
-			"<div>not strong*</div>",
+			"<p>not strong*</p>",
 			toHtml("not strong*")
 		);
 	}
 
 	public function testNotStrong2() {
 		Assert.equals(
-			"<div>*not strong</div>",
+			"<p>*not strong</p>",
 			toHtml("*not strong")
 		);
 	}
 
 	public function testNotStrong3() {
 		Assert.equals(
-			"<div>*not </div><div> strong</div>",
+			"<p>*not <br/> strong</p>",
 			toHtml("*not \n strong")
 		);
 	}
 
 	public function testNotStrong4() {
 		Assert.equals(
-			"<div>**</div>",
+			"<p>**</p>",
 			toHtml("**")
 		);
 	}
 
 	public function testNotStrong5() {
 		Assert.equals(
-			"<div>***</div>",
+			"<p>***</p>",
 			toHtml("***")
 		);
 	}
 
 	public function testNotStrong6() {
 		Assert.equals(
-			"<div>****</div>",
+			"<p>****</p>",
 			toHtml("****")
 		);
 	}
 
 	public function testStrike() {
 		Assert.equals(
-			"<div>Everyone <s>dis</s>likes cake.</div>",
+			"<p>Everyone <s>dis</s>likes cake.</p>",
 			toHtml("Everyone ~dis~likes cake.")
 		);
 	}
 
 	public function testThisIsMonospace() {
 		Assert.equals(
-			"<div>This is <tt>*monospace*</tt></div>",
+			"<p>This is <tt>*monospace*</tt></p>",
 			toHtml("This is `*monospace*`")
 		);
 	}
 
 	public function testThisIsMonospaceAndBold() {
 		Assert.equals(
-			"<div>This is <strong><tt>monospace and bold</tt></strong></div>",
+			"<p>This is <strong><tt>monospace and bold</tt></strong></p>",
 			toHtml("This is *`monospace and bold`*")
 		);
 	}
@@ -212,70 +239,112 @@ Who?")
 
 	public function testAutolink() {
 		Assert.equals(
-			"<blockquote><div><a href=\"https://example.com\">example.com</a></div></blockquote>",
+			"<blockquote><p><a href=\"https://example.com\">example.com</a></p></blockquote>",
 			toHtml("> example.com")
 		);
 	}
 
 	public function testNoAutolink() {
 		Assert.equals(
-			"<div><tt>example.com</tt></div>",
+			"<p><tt>example.com</tt></p>",
 			toHtml("`example.com`")
 		);
 	}
 
 	public function testAutolinkXMPP() {
 		Assert.equals(
-			"<div>hello <a href=\"xmpp:alice@example.com\">xmpp:alice@example.com</a></div>",
+			"<p>hello <a href=\"xmpp:alice@example.com\">xmpp:alice@example.com</a></p>",
 			toHtml("hello xmpp:alice@example.com")
 		);
 	}
 
+	public function testAutolinkXMPPQueryString() {
+		Assert.equals(
+			"<p>hello <a href=\"xmpp:alice@example.com?;a=b\">xmpp:alice@example.com?;a=b</a></p>",
+			toHtml("hello xmpp:alice@example.com?;a=b")
+		);
+	}
+
+	public function testAutolinkTel() {
+		Assert.equals(
+			"<p>hello <a href=\"tel:+15551234567\">tel:+15551234567</a></p>",
+			toHtml("hello tel:+15551234567")
+		);
+	}
+
+	public function testAutolinkSms() {
+		Assert.equals(
+			"<p>hello <a href=\"sms:+15551234567\">sms:+15551234567</a></p>",
+			toHtml("hello sms:+15551234567")
+		);
+	}
+
+	public function testAutolinkMailto() {
+		Assert.equals(
+			"<p>hello <a href=\"mailto:alice@example.com\">mailto:alice@example.com</a></p>",
+			toHtml("hello mailto:alice@example.com")
+		);
+	}
+
+	public function testAutolinkMailtoQueryString() {
+		Assert.equals(
+			"<p>hello <a href=\"mailto:alice@example.com?subject=Hi\">mailto:alice@example.com?subject=Hi</a></p>",
+			toHtml("hello mailto:alice@example.com?subject=Hi")
+		);
+	}
+
+	public function testAutolinkEmail() {
+		Assert.equals(
+			"<p>hello <a href=\"mailto:alice@example.com\">alice@example.com</a></p>",
+			toHtml("hello alice@example.com")
+		);
+	}
+
 	public function testAutolinkAfterEmoji() {
 		Assert.equals(
-			"<div>📞 icon <a href=\"https://example.com\">example.com</a></div>",
+			"<p>📞 icon <a href=\"https://example.com\">example.com</a></p>",
 			toHtml("📞 icon example.com")
 		);
 	}
 
 	public function testAutolinkNoTrailingSlash() {
 		Assert.equals(
-			"<div><a href=\"https://example.com/test/\">https://example.com/test/</a> a</div>",
+			"<p><a href=\"https://example.com/test/\">https://example.com/test/</a> a</p>",
 			toHtml("https://example.com/test/ a")
 		);
 	}
 
 	public function testAutolinkBareDomain() {
 		Assert.equals(
-			"<div><a href=\"https://example.com\">example.com</a></div>",
+			"<p><a href=\"https://example.com\">example.com</a></p>",
 			toHtml("example.com")
 		);
 	}
 
 	public function testAutolinkNoTrailingHash() {
 		Assert.equals(
-			"<div><a href=\"https://example.com/test#\">https://example.com/test#</a> a</div>",
+			"<p><a href=\"https://example.com/test#\">https://example.com/test#</a> a</p>",
 			toHtml("https://example.com/test# a")
 		);
 	}
 
 	public function testAutolinkTrailingDot() {
 		Assert.equals(
-			"<div><a href=\"https://example.com/test\">https://example.com/test</a>.</div>",
+			"<p><a href=\"https://example.com/test\">https://example.com/test</a>.</p>",
 			toHtml("https://example.com/test.")
 		);
 	}
 
 	public function testAutolinkTrailingParen() {
 		Assert.equals(
-			"<div><a href=\"https://example.com/test\">https://example.com/test</a>)</div>",
+			"<p><a href=\"https://example.com/test\">https://example.com/test</a>)</p>",
 			toHtml("https://example.com/test)")
 		);
 	}
 
 	public function testAutolinkBeaks() {
 		Assert.equals(
-			"<div>&lt;<a href=\"https://example.com/test\">https://example.com/test</a>&gt;</div>",
+			"<p>&lt;<a href=\"https://example.com/test\">https://example.com/test</a>&gt;</p>",
 			toHtml("<https://example.com/test>")
 		);
 	}