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 %}