git » sdk » commit d956639

Fix some autolink edge cases

author Stephen Paul Weber
2026-04-12 20:25:35 UTC
committer Stephen Paul Weber
2026-04-12 20:46:15 UTC
parent bfa6f9c9e9af16d7541990e3fca03a5d1a875a1d

Fix some autolink edge cases

borogove/Autolink.hx +11 -2
borogove/XEP0393.hx +21 -20
test/TestChatMessage.hx +19 -0
test/TestXEP0393.hx +35 -0

diff --git a/borogove/Autolink.hx b/borogove/Autolink.hx
index fd776c5..14354c2 100644
--- a/borogove/Autolink.hx
+++ b/borogove/Autolink.hx
@@ -355,13 +355,22 @@ class Autolink {
 		//final pattern = new EReg(pattern, "u");
 		if (pattern.matchSub(s, start)) {
 			final pos = pattern.matchedPos();
-			final link = pattern.matched(0);
+			var len = pos.len;
+			if (addHttps) {
+				while (pos.pos + len < s.length && ["/", "#"].contains(s.charAt(pos.pos + len))) {
+					len++;
+				}
+				while (len > 0 && [".", ",", ";", ":", "!", "?", ")", "]", ">"].contains(s.charAt(pos.pos + len - 1))) {
+					len--;
+				}
+			}
+			final link = s.substr(pos.pos, len);
 			final uri = !addHttps || StringTools.contains(link, "://") ? link : "https://" + link;
 			var text = link.startsWith("xmpp:") ? ~/omemo-sid[^;]+;?/.replace(link, "") : link;
 			text = link.startsWith("xmpp:") && text.endsWith(";") ? text.substr(0, text.length - 1) : text;
 			text = text.endsWith("?") ? text.substr(0, text.length - 1) : text;
 
-			return { span: Element(new Stanza("a", { href: uri }).text(text)), start: pos.pos, end: pos.pos + pos.len };
+			return { span: Element(new Stanza("a", { href: uri }).text(text)), start: pos.pos, end: pos.pos + len };
 		} else {
 			return { span: null, start: s.length, end: s.length };
 		}
diff --git a/borogove/XEP0393.hx b/borogove/XEP0393.hx
index 6e32cb3..a8beac7 100644
--- a/borogove/XEP0393.hx
+++ b/borogove/XEP0393.hx
@@ -16,7 +16,7 @@ class XEP0393 {
 		return blocks;
 	}
 
-	public static function render(xhtml: Stanza, inPre = false, followNewline = true) {
+	public static function render(xhtml: Stanza, inPre = false, followNewline = true, hasOpenBracket = false) {
 		if (xhtml.name == "br") {
 			return "\n";
 		}
@@ -76,14 +76,16 @@ class XEP0393 {
 			}
 			final text = textBuf.toString();
 			if (text == href || href.endsWith(text)) {
-				return '<$href>';
+				return hasOpenBracket ? href : '<$href>';
 			}
 			return '$text <$href>';
 		}
 
+		var lastRendered = "";
 		for (child in xhtml.children) {
-			final rendered = renderNode(child, xhtml.name == "pre", endsWithNewline);
+			final rendered = renderNode(child, xhtml.name == "pre", endsWithNewline, lastRendered.endsWith("<"));
 			s.add(rendered);
+			lastRendered = rendered;
 			endsWithNewline = rendered.endsWith("\n");
 		}
 
@@ -124,9 +126,9 @@ class XEP0393 {
 		return s.toString();
 	}
 
-	public static function renderNode(xhtml: Node, inPre = false, followNewline = true) {
+	public static function renderNode(xhtml: Node, inPre = false, followNewline = true, hasOpenBracket = false) {
 		return switch (xhtml) {
-			case Element(c): render(c, inPre, followNewline);
+			case Element(c): render(c, inPre, followNewline, hasOpenBracket);
 			case CData(c): c.content;
 		};
 	}
@@ -137,8 +139,19 @@ class XEP0393 {
 		var nextLink: Null<{ span: Null<Node>, start: Int, end: Int }> = null;
 		final styledLength = styled.length;
 		while (start < styledLength) {
+			if (nextLink == null || start > nextLink.start) {
+				nextLink = Autolink.one(styled, start);
+				if (nextLink != null) {
+					nextLink.start = styled.convertIndex(nextLink.start);
+					nextLink.end = styled.convertIndex(nextLink.end);
+				}
+			}
+
 			final char = styled.charAt(start);
-			if (isSpace(styled, start + 1)) {
+			if (nextLink != null && nextLink.start == start && nextLink.span != null) {
+				spans.push(nextLink.span);
+				start = nextLink.end;
+			} else if (isSpace(styled, start + 1)) {
 				// The opening styling directive MUST NOT be followed by a whitespace character
 				spans.push(CData(new TextNode(styled.substr(start, 2))));
 				start += 2;
@@ -164,20 +177,8 @@ class XEP0393 {
 				spans.push(parsed.span);
 				start = parsed.end;
 			} else {
-				if (nextLink == null || start > nextLink.start) {
-					nextLink = Autolink.one(styled, start);
-					if (nextLink != null) {
-						nextLink.start = styled.convertIndex(nextLink.start);
-						nextLink.end = styled.convertIndex(nextLink.end);
-					}
-				}
-				if (nextLink != null && nextLink.start == start && nextLink.span != null) {
-					spans.push(nextLink.span);
-					start = nextLink.end;
-				} else {
-					spans.push(CData(new TextNode(char)));
-					start++;
-				}
+				spans.push(CData(new TextNode(char)));
+				start++;
 			}
 		}
 		return mergeSpans(spans);
diff --git a/test/TestChatMessage.hx b/test/TestChatMessage.hx
index cf38366..dfea59b 100644
--- a/test/TestChatMessage.hx
+++ b/test/TestChatMessage.hx
@@ -80,4 +80,23 @@ class TestChatMessage extends utest.Test {
 				Assert.fail("Expected ChatMessageStanza");
 		}
 	}
+
+
+	public function testStyledBodyWithLink() {
+		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("Hey <https://example.com>"));
+
+		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("Hey <https://example.com>", m.body().toPlainText());
+			default:
+				Assert.fail("Expected ChatMessageStanza");
+		}
+	}
 }
diff --git a/test/TestXEP0393.hx b/test/TestXEP0393.hx
index 4e6df20..f089556 100644
--- a/test/TestXEP0393.hx
+++ b/test/TestXEP0393.hx
@@ -237,4 +237,39 @@ Who?")
 			toHtml("📞 icon example.com")
 		);
 	}
+
+	public function testAutolinkNoTrailingSlash() {
+		Assert.equals(
+			"<div><a href=\"https://example.com/test/\">https://example.com/test/</a> a</div>",
+			toHtml("https://example.com/test/ a")
+		);
+	}
+
+	public function testAutolinkNoTrailingHash() {
+		Assert.equals(
+			"<div><a href=\"https://example.com/test#\">https://example.com/test#</a> a</div>",
+			toHtml("https://example.com/test# a")
+		);
+	}
+
+	public function testAutolinkTrailingDot() {
+		Assert.equals(
+			"<div><a href=\"https://example.com/test\">https://example.com/test</a>.</div>",
+			toHtml("https://example.com/test.")
+		);
+	}
+
+	public function testAutolinkTrailingParen() {
+		Assert.equals(
+			"<div><a href=\"https://example.com/test\">https://example.com/test</a>)</div>",
+			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>",
+			toHtml("<https://example.com/test>")
+		);
+	}
 }