From dc45477deb43bd01a63b426394b528ee0eec47dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:08:01 +0000 Subject: [PATCH] fix: don't glue quotes onto detected urls A bare host wrapped in quotes ("relay.momostr.pink") was detected with the opening quote attached, so the rendered link read `"relay.momostr.pink` and pointed at a host that does not exist. The mirror case was also wrong: a quoted url with a path/query/fragment kept the closing quote, because those readers only stop on a space. Quotes are not host characters, so they now end the current token exactly like a space does in readDefault (covering the leading quote and a quote glued to a previous word, e.g. `href="www.google.com"`), and they were added to CANNOT_BEGIN_URLS_WITH / CANNOT_END_URLS_WITH so a trailing quote read as part of a path, query or fragment is stripped on readEnd. The set covers the ascii quotes plus the typographic family, including the guillemets below the international-character threshold that the ascii boundary rule never cut. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7 --- .../richtext/RichTextParserQuotedUrlTest.kt | 76 +++++++++++++++++++ .../commons/richtext/UrlParserTest.kt | 28 +++++++ .../urldetector/detection/UrlDetector.kt | 40 +++++++++- .../urldetector/detection/UriDetectionTest.kt | 32 ++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt new file mode 100644 index 0000000000..595e395bbe --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A quoted host name (`this bridge-relay "relay.momostr.pink" doesn't appear`) used to be + * detected with the opening quote glued onto it, so the rendered link read + * `"relay.momostr.pink` and pointed at a host that doesn't exist. Quotes are not host + * characters, so they must be left in the surrounding text on both sides. + */ +class RichTextParserQuotedUrlTest { + private fun segmentsOf(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun quotedSchemelessUrlKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed", + ).filterIsInstance() + + assertEquals(listOf("relay.momostr.pink"), segments.map { it.segmentText }) + } + + @Test + fun quotedUrlWithSchemeKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf( + "the docs are at \"https://example.com/some/page?a=b\" if you need them", + ).filterIsInstance() + + assertEquals(listOf("https://example.com/some/page?a=b"), segments.map { it.segmentText }) + } + + @Test + fun quotedRelayUrlKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf("add \"wss://relay.momostr.pink\" to your list") + .filterIsInstance() + + assertEquals(listOf("wss://relay.momostr.pink"), segments.map { it.segmentText }) + } + + @Test + fun apostrophesInProseDontCreateLinks() { + val segments = segmentsOf("it doesn't appear until you go into the authors' accounts") + + assertEquals(emptyList(), segments.filterIsInstance()) + assertEquals(emptyList(), segments.filterIsInstance()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index f1444b88cc..c0c858b47d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -349,6 +349,34 @@ class UrlParserTest { Urls(withScheme = setOf("https://test.com")), ) + @Test + fun testQuotedRelayName() = + test( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed at all", + Urls(withoutScheme = setOf("relay.momostr.pink")), + ) + + @Test + fun testSingleQuotedRelayName() = + test( + "It seems like this bridge-relay 'relay.momostr.pink' doesn't appear in the feed at all", + Urls(withoutScheme = setOf("relay.momostr.pink")), + ) + + @Test + fun testQuotedUrlWithScheme() = + test( + "the docs are at \"https://example.com/some/page?a=b\" if you need them", + Urls(withScheme = setOf("https://example.com/some/page?a=b")), + ) + + @Test + fun testQuotedRelayUrl() = + test( + "add \"wss://relay.momostr.pink\" to your list", + Urls(relayUrls = setOf("wss://relay.momostr.pink")), + ) + @Test fun testBlossom() { val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index cf2e8a5f4c..ae05dee3da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -91,7 +91,17 @@ class UrlDetector( while (!reader.eof()) { // read the next char to process. when (val curr = reader.read()) { - ' ' -> { + // A quote can never be part of a host name, so a note that wraps a bare domain in + // quotes (`the relay "relay.example.com" is down`) must not glue the opening quote + // onto the url. Quotes therefore end the current token exactly like a space does. + // Kept as literals (instead of `in QUOTES`) so this hot branch stays a tableswitch; + // the list must mirror [QUOTES], which the punctuation round-trip test enforces. + ' ', '"', '\'', '`', + '\u00AB', '\u00BB', + '\u2018', '\u2019', '\u201A', '\u201B', + '\u201C', '\u201D', '\u201E', '\u201F', + '\u2039', '\u203A', + -> { // space found; if we have a scheme, attempt to read the domain before resetting if (buffer.isNotEmpty() && hasScheme) { reader.goBack() @@ -719,6 +729,30 @@ class UrlDetector( "$it//" } + /** + * Quotes never belong to a url. The opening side is already dropped when [readDefault] + * breaks the token on them, but the closing side can still be swallowed by the path, + * query or fragment readers (which only stop on a space), so it is stripped on [readEnd]. + */ + val QUOTES = + setOf( + '"', + '\'', + '`', + '\u00AB', + '\u00BB', + '\u2018', + '\u2019', + '\u201A', + '\u201B', + '\u201C', + '\u201D', + '\u201E', + '\u201F', + '\u2039', + '\u203A', + ) + val CANNOT_BEGIN_URLS_WITH = setOf( ',', @@ -734,7 +768,7 @@ class UrlDetector( '\u3002', '\uFF0E', '\uFF61', - ) + ) + QUOTES val CANNOT_END_URLS_WITH = setOf( @@ -752,6 +786,6 @@ class UrlDetector( '\u3002', '\uFF0E', '\uFF61', - ) + ) + QUOTES } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 76ffc005e8..4a4c45fdc8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -882,6 +882,38 @@ class UriDetectionTest { runTest("visit example.com,", "example.com") } + @Test + fun testQuotedUrlsDropTheQuotes() { + // a bare domain wrapped in quotes must not glue the opening quote onto the host. + runTest( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed", + "relay.momostr.pink", + ) + + UrlDetector.QUOTES.forEach { quote -> + runTest("$quote relay.momostr.pink $quote", "relay.momostr.pink") + runTest("${quote}relay.momostr.pink$quote", "relay.momostr.pink") + runTest("${quote}wss://relay.momostr.pink$quote", "wss://relay.momostr.pink") + + // the closing quote is swallowed by the path/query/fragment readers, which only stop + // on a space, so it has to be stripped at the end of the url instead. + runTest("say ${quote}https://example.com/foo$quote out", "https://example.com/foo") + runTest("say ${quote}https://example.com/foo?a=b$quote out", "https://example.com/foo?a=b") + runTest("say ${quote}https://example.com/foo#b$quote out", "https://example.com/foo#b") + + // a quote glued to the previous word is a boundary too: `href="www.google.com"`. + runTest("href=${quote}www.google.com$quote", "www.google.com") + } + } + + @Test + fun testApostropheStillEndsTheHostForGroupInviteLinks() { + // NIP-29 invite links are `'`; UrlParser recovers the suffix from the + // content, so the detector must keep reporting the relay url alone. + runTest("wss://relay.example.com'groupid", "wss://relay.example.com") + runTest("wss://relay.example.com'groupid?code=xyz", "wss://relay.example.com") + } + private fun runTest( text: String, vararg expected: String?,