From 80d1788af07d20419d42ed28dce85596838b233f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 25 Apr 2026 23:22:37 +0200 Subject: [PATCH] bug-fixes --- .../article_highlight_controller.js | 9 +- .../controllers/nostr_preview_controller.js | 95 +++---- assets/styles/article.css | 15 +- src/Controller/ArticleController.php | 1 - src/Entity/ArticleHighlight.php | 14 +- src/Service/ArticleBodyHighlightInjector.php | 231 ++++++++++++++++-- src/Util/HighlightEventTags.php | 17 +- .../Organisms/HomeHighlightsAside.html.twig | 1 - 8 files changed, 277 insertions(+), 106 deletions(-) diff --git a/assets/controllers/article_highlight_controller.js b/assets/controllers/article_highlight_controller.js index 9062f51..d2eeee6 100644 --- a/assets/controllers/article_highlight_controller.js +++ b/assets/controllers/article_highlight_controller.js @@ -131,6 +131,9 @@ export default class extends Controller { if (!this.hasPopoverTarget) { return; } + if (!this._pinnedId && !this._openId) { + return; + } const t = event.target; if (this.popoverTarget.contains(t)) { return; @@ -205,11 +208,7 @@ export default class extends Controller { } else { this._clearHoverLeaveTimer(); } - this.popoverInnerTarget.innerHTML = - (meta.headHtml || '') + - '
' + - (meta.bodyHtml || '') + - '
'; + this.popoverInnerTarget.innerHTML = meta.headHtml || ''; this._placePopover(mark); this.popoverTarget.hidden = false; } diff --git a/assets/controllers/nostr_preview_controller.js b/assets/controllers/nostr_preview_controller.js index 8256808..b5b5f24 100644 --- a/assets/controllers/nostr_preview_controller.js +++ b/assets/controllers/nostr_preview_controller.js @@ -1,74 +1,59 @@ import { Controller } from '@hotwired/stimulus'; +const LOADING_HTML = `
Loading preview…
`; +const UNAVAILABLE_HTML = `
Preview unavailable.
`; + export default class extends Controller { static values = { identifier: String, type: String, decoded: String, - fullMatch: String - } + fullMatch: String, + }; - static targets = ['container'] + static targets = ['container']; - async connect() { - await this.fetchPreview(); + connect() { + this.fetchPreview(); } async fetchPreview() { + if (!this.hasContainerTarget) { + return; + } + this.containerTarget.innerHTML = LOADING_HTML; try { - this.containerTarget.innerHTML = '
Loading preview…
'; if (this.typeValue === 'url' && this.fullMatchValue) { - // Fetch OG preview for plain URLs - fetch("/og-preview/", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ url: this.fullMatchValue }) - }) - .then(res => { - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - return res.text(); - }) - .then(data => { - this.containerTarget.innerHTML = data; - }) - .catch(error => { - console.error("Error:", error); - this.containerTarget.innerHTML = `
Unable to load OG preview for ${this.fullMatchValue}
`; + const res = await fetch('/og-preview/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: this.fullMatchValue }), }); - } else { - // Fallback to Nostr preview - const data = { - identifier: this.identifierValue, - type: this.typeValue, - decoded: this.decodedValue - }; - fetch("/preview/", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(data) - }) - .then(res => { - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - return res.text(); - }) - .then(data => { - this.containerTarget.innerHTML = data; - }) - .catch(error => { - console.error("Error:", error); - }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + this.containerTarget.innerHTML = await res.text(); + return; + } + const res = await fetch('/preview/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier: this.identifierValue, + type: this.typeValue, + decoded: this.decodedValue, + }), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); } - } catch (error) { - console.error('Error fetching Nostr preview:', error); - this.containerTarget.innerHTML = `
Unable to load preview for ${this.fullMatchValue}
`; + this.containerTarget.innerHTML = await res.text(); + } catch (e) { + // NetworkError / offline: avoid console.error noise; one inline fallback per block + console.debug('nostr_preview: fetch failed', e); + this.containerTarget.innerHTML = this.typeValue === 'url' && this.fullMatchValue + ? `
Unable to load link preview for ${this.fullMatchValue}.
` + : UNAVAILABLE_HTML; } } } diff --git a/assets/styles/article.css b/assets/styles/article.css index 6396a0a..07debbe 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -449,11 +449,12 @@ max-width: min(24rem, calc(100vw - 1.5rem)); margin: 0; padding: 0.65rem 2rem 0.85rem 0.85rem; - border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-bg) 20%); + border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-bg) 25%); border-radius: 0.45rem; color: var(--color-text); - background: var(--color-bg); - box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.12); + /* Slightly above page bg so the card reads as a raised surface */ + background: color-mix(in srgb, var(--color-bg) 88%, #fff 12%); + box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.14); pointer-events: auto; } @@ -488,16 +489,10 @@ align-items: baseline; justify-content: space-between; gap: 0.4rem 0.75rem; - margin: 0 0 0.4rem; + margin: 0; font-size: 0.86rem; } -.user-highlight__body--popover { - margin-top: 0.3rem; - max-height: 40vh; - overflow: auto; -} - /* Full `context` quote + optional on the `content` substring (highlighter, not a box) */ .user-highlight__body { margin: 0.35rem 0 0; diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 07799b0..e442055 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -449,7 +449,6 @@ class ArticleController extends AbstractController 'authorPubkey' => $h->getAuthorPubkey(), 'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()), ]), - 'bodyHtml' => $h->getBodyHtml(), ]; } if ($out === []) { diff --git a/src/Entity/ArticleHighlight.php b/src/Entity/ArticleHighlight.php index d3a5cda..088b5e4 100644 --- a/src/Entity/ArticleHighlight.php +++ b/src/Entity/ArticleHighlight.php @@ -145,12 +145,16 @@ class ArticleHighlight return HighlightEventTags::contextFromTags($this->tags); } - /** Renders: full `content` in when `context` is empty; else `context` quote with `content` substring marked. */ + /** + * HTML for the home aside and article hover cards: when a `context` tag exists, the full quote is + * shown with `content` marked inside it; otherwise the event `content` only in a . The + * rendered article body still only wraps the `content` passage (see ArticleBodyHighlightInjector). + */ public function getBodyHtml(): string { - $ctx = $this->getContextText(); - $body = (string) $this->getContent(); - - return HighlightEventTags::buildHighlightedBodyHtml($ctx, $body); + return HighlightEventTags::buildHighlightedBodyHtml( + $this->getContextText(), + (string) $this->getContent() + ); } } diff --git a/src/Service/ArticleBodyHighlightInjector.php b/src/Service/ArticleBodyHighlightInjector.php index 03226a0..47857d2 100644 --- a/src/Service/ArticleBodyHighlightInjector.php +++ b/src/Service/ArticleBodyHighlightInjector.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\Entity\ArticleHighlight; +use App\Util\HighlightEventTags; use DOMDocument; use DOMElement; use DOMText; @@ -12,8 +13,10 @@ use DOMXPath; /** * Injects kind-9802 highlight ranges into the rendered article body by finding each event’s - * {@see ArticleHighlight::getContent} in the visible text. Matches across inline elements - * (e.g. em, strong) by concatenating text in document order. + * `content` in the visible text (the `context` tag is ignored; the article is the full context). + * Matches across inline elements (e.g. em, strong) by concatenating text in document order. + * If a literal match fails, compares a normalized form (NBSP→space, strip U+00AD / ZW, etc.), + * then maps the match back to the original HTML text (for e‑book style soft hyphens in 9802 content). */ final class ArticleBodyHighlightInjector { @@ -46,15 +49,11 @@ final class ArticleBodyHighlightInjector $injected = []; foreach ($sorted as $h) { - $needle = \trim($h->getContent()); - if ($needle === '') { - continue; - } $eid = \strtolower($h->getEventId()); if (64 !== \strlen($eid) || !ctype_xdigit($eid)) { continue; } - if ($this->tryWrapInDocument($this->root, $needle, $eid)) { + if ($this->tryInjectOneHighlight($this->root, $h, $eid)) { $injected[] = $eid; } } @@ -87,16 +86,43 @@ final class ArticleBodyHighlightInjector libxml_use_internal_errors($prev); libxml_clear_errors(); } - // getElementById is unreliable for HTML loaded without a DTD; use XPath. + // getElementById is unreliable for HTML loaded without a DTD; use XPath, then a div scan, then a tree walk. $xp = new DOMXPath($this->dom); $nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]'); - if (false === $nodes || 0 === $nodes->length) { + if (false !== $nodes && $nodes->length > 0) { + $first = $nodes->item(0); + $this->root = $first instanceof DOMElement ? $first : null; + } else { + $this->root = null; + } + if (null === $this->root) { + $de = $this->dom->documentElement; + if ($de instanceof DOMElement && $de->getAttribute('id') === self::ROOT_ID) { + $this->root = $de; + } + } + if (null === $this->root) { + $this->root = $this->findFirstDivById(self::ROOT_ID); + } + if (null === $this->root) { $this->root = $this->findElementByIdFallback(self::ROOT_ID); + } + } - return; + private function findFirstDivById(string $id): ?DOMElement + { + if ('' === $id) { + return null; + } + $n = $this->dom->getElementsByTagName('div'); + for ($i = 0, $L = $n->length; $i < $L; ++$i) { + $d = $n->item($i); + if ($d instanceof DOMElement && $d->getAttribute('id') === $id) { + return $d; + } } - $first = $nodes->item(0); - $this->root = $first instanceof DOMElement ? $first : null; + + return null; } private function findElementByIdFallback(string $id): ?DOMElement @@ -127,6 +153,161 @@ final class ArticleBodyHighlightInjector return null; } + private function tryInjectOneHighlight(DOMElement $root, ArticleHighlight $h, string $eid): bool + { + $resolved = $this->resolveInjectionNeedle($h); + foreach ($this->needleSearchVariants($resolved) as $needle) { + if ($needle === '') { + continue; + } + if ($this->tryWrapInDocument($root, $needle, $eid)) { + return true; + } + } + + return false; + } + + private function resolveInjectionNeedle(ArticleHighlight $h): string + { + $c = \trim($h->getContent()); + if ($c !== '') { + return $c; + } + + return \trim(HighlightEventTags::contextFromTags($h->getTags())); + } + + /** + * Nostr/Unicode vs rendered HTML: try a few equivalent strings for `mb_strpos` on the flattened text. + * + * @return list + */ + private function needleSearchVariants(string $base): array + { + if ($base === '') { + return []; + } + $candidates = [ + $base, + $this->replaceTypographicQuotes($base), + ]; + if (\class_exists(\Normalizer::class)) { + $c = \Normalizer::normalize($base, \Normalizer::FORM_C); + if (\is_string($c) && $c !== '' && $c !== $base) { + $candidates[] = $c; + } + } + $out = []; + $seen = []; + foreach ($candidates as $n) { + if ($n === '' || isset($seen[$n])) { + continue; + } + $seen[$n] = true; + $out[] = $n; + } + + return $out; + } + + private function replaceTypographicQuotes(string $s): string + { + return \strtr($s, [ + "\xC2\xA0" => ' ', // nbsp + "\xE2\x80\x99" => "'", + "\xE2\x80\x98" => "'", + "\xE2\x80\x9C" => '"', + "\xE2\x80\x9D" => '"', + "\xE2\x80\x93" => '-', + "\xE2\x80\x94" => '-', + ]); + } + + /** + * Strips/rewrites typographic/linebreak chars so Nostr `content` (e.g. e‑book soft hyphens) can + * match the article’s flat HTML text, then map positions back to the real string for {@see wrapTextSlice}. + */ + private function stringForSearch(string $s): string + { + $L = \mb_strlen($s, 'UTF-8'); + $out = ''; + for ($i = 0; $i < $L; ++$i) { + $ch = \mb_substr($s, $i, 1, 'UTF-8'); + $out .= $this->searchCharacterNormalized($ch); + } + + return $out; + } + + private function searchCharacterNormalized(string $ch): string + { + if ($ch === "\xC2\xAD") { // U+00AD soft hyphen + return ''; + } + if ($ch === "\xE2\x80\x8B" // U+200B + || $ch === "\xE2\x80\x8C" // U+200C + || $ch === "\xE2\x80\x8D" // U+200D + || $ch === "\xEF\xBB\xBF" // U+FEFF + ) { + return ''; + } + if ($ch === "\xC2\xA0" // U+00A0 + || $ch === "\xE2\x80\xAF" // U+202F narrow no-break + ) { + return ' '; + } + + return $ch; + } + + /** + * @return list length L+1; cuml[i] = "search" string length of prefix s[0..i) after per-char normalization + */ + private function buildCumulativeSearchLens(string $s): array + { + $L = \mb_strlen($s, 'UTF-8'); + $cuml = [0]; + for ($i = 0; $i < $L; ++$i) { + $ch = \mb_substr($s, $i, 1, 'UTF-8'); + $add = $this->searchCharacterNormalized($ch); + $cuml[] = $cuml[$i] + \mb_strlen($add, 'UTF-8'); + } + + return $cuml; + } + + /** + * @return array{0: int, 1: int} half-open [start, end) in mb char indices of $orig + */ + private function mapSearchStringRangeToOrigStringRange(string $orig, int $nStart, int $nEnd): array + { + $L = \mb_strlen($orig, 'UTF-8'); + $cuml = $this->buildCumulativeSearchLens($orig); + if (0 > $nStart || $nStart > $cuml[$L] || $nEnd < $nStart || $nEnd > $cuml[$L]) { + return [0, 0]; + } + $startO = -1; + for ($i = 0; $i < $L; ++$i) { + if ($cuml[$i + 1] > $nStart) { + $startO = $i; + break; + } + } + if ($startO < 0) { + return [0, 0]; + } + $endO = $L; + for ($e = 0; $e <= $L; ++$e) { + if ($cuml[$e] >= $nEnd) { + $endO = $e; + break; + } + } + + return [$startO, $endO]; + } + private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId): bool { $textNodes = $this->collectTextNodes($root); @@ -136,10 +317,6 @@ final class ArticleBodyHighlightInjector $cat = ''; /** @var list $segments */ $segments = []; - $nl = \mb_strlen($needle, 'UTF-8'); - if ($nl < 1) { - return false; - } foreach ($textNodes as $tn) { $t = (string) $tn->data; @@ -151,10 +328,26 @@ final class ArticleBodyHighlightInjector } $p = \mb_strpos($cat, $needle, 0, 'UTF-8'); - if (false === $p) { - return false; + $pEnd = false; + if (false !== $p) { + $pEnd = $p + \mb_strlen($needle, 'UTF-8'); + } else { + // e.g. soft hyphens (U+00AD) or NBSP in the event `content` vs plain text in the article + $catS = $this->stringForSearch($cat); + $needleS = $this->stringForSearch($needle); + if ($needleS === '') { + return false; + } + $pN = \mb_strpos($catS, $needleS, 0, 'UTF-8'); + if (false === $pN) { + return false; + } + $nEnd = $pN + \mb_strlen($needleS, 'UTF-8'); + [$p, $pEnd] = $this->mapSearchStringRangeToOrigStringRange($cat, $pN, $nEnd); + if ($pEnd <= $p) { + return false; + } } - $pEnd = $p + $nl; $cursor = 0; foreach ($textNodes as $tn) { $t = (string) $tn->data; diff --git a/src/Util/HighlightEventTags.php b/src/Util/HighlightEventTags.php index 6b7385b..bb3a513 100644 --- a/src/Util/HighlightEventTags.php +++ b/src/Util/HighlightEventTags.php @@ -5,12 +5,9 @@ declare(strict_types=1); namespace App\Util; /** - * NIP-84 (kind 9802) in this app: - * — Event **`content`**: the highlighted words (a substring to mark when `context` exists, or the whole note when it does not). - * — Optional **`context` tag**: the **full quote** in which to show that highlight; the event `content` is highlighted **inside** the context. - * — No / empty `context` → show `content` **entirely** wrapped in the highlighter (not plain text only). - * - * @param list>|list $tags + * NIP-84 (kind 9802): {@see buildHighlightedBodyHtml} drives list/hover-card HTML (full `context` + * with `content` marked when both exist). In-article marks are applied separately and only wrap + * the `content` substring in the article body ({@see \App\Service\ArticleBodyHighlightInjector}). */ final class HighlightEventTags { @@ -45,11 +42,11 @@ final class HighlightEventTags } /** - * Renders the full quote and wraps the `content` substring in when a context tag is present; - * otherwise the entire `content` is wrapped in (no surrounding quote). + * Card / aside body: with `context`, show the full quote and mark the `content` substring; with + * empty `context`, wrap all of `content` in one . * - * @param string $contextQuote Text from the `context` tag (the full quote). Empty means “no context”. - * @param string $contentField The event's `content` field: highlight to find within `contextQuote` when set. + * @param string $contextQuote Text from the `context` tag. Empty means no surrounding quote. + * @param string $contentField The event’s `content` (highlighted phrase). * * @return string safe HTML */ diff --git a/templates/components/Organisms/HomeHighlightsAside.html.twig b/templates/components/Organisms/HomeHighlightsAside.html.twig index 8258a4e..fe787c4 100644 --- a/templates/components/Organisms/HomeHighlightsAside.html.twig +++ b/templates/components/Organisms/HomeHighlightsAside.html.twig @@ -22,7 +22,6 @@
{{ _html|raw }}
{% else %} {% set _prew = h.content|default('')|trim %} - {% if _prew == '' %}{% set _prew = h.contextText|default('')|trim %}{% endif %} {% if _prew == '' %}{% set _prew = h.quoteExcerpt|default('')|trim %}{% endif %}
{{ _prew|u.truncate(200, '…') }}
{% endif %}