| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-12 20:25:35 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-12 20:46:15 UTC |
| parent | bfa6f9c9e9af16d7541990e3fca03a5d1a875a1d |
| 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 <<a href=\"https://example.com\">https://example.com</a>></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><<a href=\"https://example.com/test\">https://example.com/test</a>></div>", + toHtml("<https://example.com/test>") + ); + } }