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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
This commit is contained in:
Claude
2026-07-28 20:47:53 +00:00
parent af04267f9a
commit 4d24e2b09c
2 changed files with 42 additions and 2 deletions
@@ -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,
@@ -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 its not like theres anyw",
"Your food is prechewed for you. The caged tiger prefers a pot of meat slop to an antelope they have to chase. And its not like theres 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 dont 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 dont want it. A single totalizing control sy",
excessWhitespace.contextOrReconstructed(),
)
}
@Test
fun prefersExplicitContextTagOverSelectorReconstruction() {
val withContext =