From 4d24e2b09cb77d66098749341c17bdd34a73df94 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 20:47:53 +0000 Subject: [PATCH] fix: collapse scraped whitespace when reconstructing highlight context A kind:9802 highlight with no `context` tag falls back to reconstructing the surrounding passage from the W3C `textquoteselector` prefix/suffix. Those fragments are scraped from the source web page, so they carry the page's block-boundary whitespace (runs of newlines/spaces between DOM nodes). Glued in verbatim as `prefix + content + suffix`, they render as a stack of blank lines above the marked quote. Collapse each whitespace run in the prefix/suffix to a single space (and trim the outer edges) in `HighlightEvent.contextOrReconstructed()`. The highlight's own `content` is left verbatim so its offsets inside the reconstructed context stay exact for the in-context marker. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91 --- .../quartz/nip84Highlights/HighlightEvent.kt | 15 +++++++++- .../nip84Highlights/HighlightEventTest.kt | 29 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index 60ac3be542..3b8c81ff3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -131,6 +131,13 @@ class HighlightEvent( * NIP-84 `context` tag and falling back to reconstructing it from a W3C * `textquoteselector`'s prefix/suffix (as web highlighter clients emit) so the * in-context rendering still works when no `context` tag is present. + * + * The prefix/suffix are scraped from a web page, so they carry the page's + * block-boundary whitespace — runs of newlines and spaces between DOM nodes — which + * would otherwise render as a stack of blank lines around the highlight. Each run is + * collapsed to a single space so the surrounding context reads as one continuous + * passage; the highlight's own [content] is left verbatim so its offsets inside the + * reconstructed context stay exact for the in-context marker. */ fun contextOrReconstructed(): String? { context()?.let { return it } @@ -138,7 +145,10 @@ class HighlightEvent( val selector = textQuoteSelector() ?: return null if (selector.prefix == null && selector.suffix == null) return null - return (selector.prefix ?: "") + content + (selector.suffix ?: "") + val prefix = selector.prefix?.replace(WHITESPACE_RUN, " ")?.trimStart() ?: "" + val suffix = selector.suffix?.replace(WHITESPACE_RUN, " ")?.trimEnd() ?: "" + + return prefix + content + suffix } fun inPost() = firstTaggedATag() @@ -150,6 +160,9 @@ class HighlightEvent( companion object { const val KIND = 9802 + /** Any run of whitespace (spaces, tabs, newlines) — collapsed to a single space. */ + private val WHITESPACE_RUN = Regex("\\s+") + suspend fun create( msg: String, signer: NostrSigner, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt index 146ce5bb89..3d53f36d3a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip84Highlights/HighlightEventTest.kt @@ -64,12 +64,39 @@ class HighlightEventTest { @Test fun reconstructsContextFromSelectorWhenNoContextTag() { + // The suffix's leading "\n\n" (a page block boundary) is collapsed to a single space so + // it doesn't render as blank lines; the quote's own content is left verbatim. assertEquals( - "Your food is prechewed for you. The caged tiger prefers a pot of meat slop to an antelope they have to chase.\n\nAnd it’s not like there’s anyw", + "Your food is prechewed for you. The caged tiger prefers a pot of meat slop to an antelope they have to chase. And it’s not like there’s anyw", webHighlight.contextOrReconstructed(), ) } + @Test + fun collapsesRunsOfWhitespaceScrapedFromThePage() { + // A real highlight whose prefix carries five newlines between two paragraphs of the + // source page. Without collapsing, the reconstructed context renders a stack of blank + // lines above the marked quote. + val excessWhitespace = + HighlightEvent( + id = "fc2366a5ac54de837842492e525f8f5d141d4a9bba5b1238e135adaf4225763f", + pubKey = "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", + createdAt = 1785270682, + tags = + arrayOf( + arrayOf("r", "https://geohot.github.io//blog/jekyll/update/2026/06/06/our-great-war.html"), + arrayOf("textquoteselector", "-", "n way, the better.\n\n\n\n\nHowever, ", ". A single totalizing control sy"), + ), + content = "it will end badly for everyone if the systems of comfort prevent structural exit for the people who don’t want it", + sig = "0428ad8aef2a12f36a4dc86105f8b429a4b4161f1fa72b08414fb2dd6c1a2276838b167682cb49ab94aa5c4e1d6351bbec0b7906a4b5e6cec1ab64a9d4c63d9d", + ) + + assertEquals( + "n way, the better. However, it will end badly for everyone if the systems of comfort prevent structural exit for the people who don’t want it. A single totalizing control sy", + excessWhitespace.contextOrReconstructed(), + ) + } + @Test fun prefersExplicitContextTagOverSelectorReconstruction() { val withContext =