Browse Source

highlight rendering

imwald
Silberengel 3 days ago
parent
commit
0bc43f7488
  1. 6
      assets/bootstrap.js
  2. 288
      assets/controllers/article_highlight_controller.js
  3. 19
      assets/styles/app.css
  4. 119
      assets/styles/article.css
  5. 156
      assets/styles/layout.css
  6. 3
      config/unfold.yaml
  7. 31
      migrations/Version20260425200000.php
  8. 153
      src/Command/PrewarmCommand.php
  9. 82
      src/Controller/ArticleController.php
  10. 3
      src/Controller/DefaultController.php
  11. 156
      src/Entity/ArticleHighlight.php
  12. 68
      src/Repository/ArticleHighlightRepository.php
  13. 317
      src/Service/ArticleBodyHighlightInjector.php
  14. 3
      src/Service/ArticleCommentThreadLoader.php
  15. 104
      src/Service/HighlightSyncService.php
  16. 116
      src/Service/NostrClient.php
  17. 137
      src/Util/HighlightEventTags.php
  18. 8
      templates/components/Molecules/ArticleHighlightMetaHead.html.twig
  19. 1
      templates/components/Organisms/Comments.html.twig
  20. 38
      templates/components/Organisms/HomeHighlightsAside.html.twig
  21. 3
      templates/home.html.twig
  22. 24
      templates/pages/article.html.twig
  23. 7
      translations/messages.en.yaml

6
assets/bootstrap.js vendored

@ -2,6 +2,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; @@ -2,6 +2,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js';
import CommentReplyController from './controllers/comment_reply_controller.js';
import CopyTextController from './controllers/copy_text_controller.js';
import ArticleHighlightController from './controllers/article_highlight_controller.js';
const app = startStimulusApp();
@ -21,3 +22,8 @@ try { @@ -21,3 +22,8 @@ try {
} catch {
/* already registered by the bundle */
}
try {
app.register('article-highlight', ArticleHighlightController);
} catch {
/* already registered by the bundle */
}

288
assets/controllers/article_highlight_controller.js

@ -0,0 +1,288 @@ @@ -0,0 +1,288 @@
import { Controller } from '@hotwired/stimulus';
const MARK_SEL = 'mark.article-body-highlight';
/**
* In-article NIP-84 marks: show popover on hover / click; #h-<64-hex> scrolls and focuses the mark.
*/
export default class extends Controller {
static targets = ['article', 'meta', 'popover', 'popoverInner'];
connect() {
this._metaById = {};
if (this.hasMetaTarget) {
try {
this._metaById = JSON.parse(this.metaTarget.textContent || '{}');
} catch {
this._metaById = {};
}
}
this._pinnedId = null;
this._openId = null;
this._hoverLeaveTimer = null;
this._onHash = () => {
this._scrollToHash();
};
this._onScrollReposition = () => {
if (this._openId && this.hasPopoverTarget) {
const a = this._getMarkByEventId(this._openId);
if (a) {
this._placePopover(a);
}
}
};
this._onPopoverPointerEnter = () => {
this._clearHoverLeaveTimer();
};
this._onPopoverPointerLeave = () => {
if (!this._pinnedId) {
this._hoverLeaveTimer = setTimeout(() => {
this._hoverLeaveTimer = null;
this._hideUnpinnedPopover();
}, 200);
}
};
this._onMarkPointerEnter = (e) => {
const a = e.target && e.target.closest && e.target.closest(MARK_SEL);
if (!a) {
return;
}
this._clearHoverLeaveTimer();
this._openForMark(a, false);
};
this._onMarkPointerLeave = (e) => {
const a = e.target && e.target.closest && e.target.closest(MARK_SEL);
if (!a) {
return;
}
this._onMarkLeave(a);
};
this._onMarkClick = (e) => {
const a = e.target && e.target.closest && e.target.closest(MARK_SEL);
if (!a) {
return;
}
e.preventDefault();
e.stopPropagation();
const id = (a.getAttribute('data-event-id') || '').toLowerCase();
if (this._pinnedId === id) {
this._closePinned();
this._hideUnpinnedPopover();
return;
}
this._openForMark(a, true);
};
this._onMarkFocus = (e) => {
const t = e.target;
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) {
return;
}
this._openForMark(t, false);
};
this._onMarkKeydown = (e) => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
const t = e.target;
if (!t || !t.classList || !t.classList.contains('article-body-highlight')) {
return;
}
e.preventDefault();
this._openForMark(t, true);
};
if (this.hasArticleTarget) {
this.articleTarget.addEventListener('pointerenter', this._onMarkPointerEnter, true);
this.articleTarget.addEventListener('pointerleave', this._onMarkPointerLeave, true);
this.articleTarget.addEventListener('click', this._onMarkClick, true);
this.articleTarget.addEventListener('focusin', this._onMarkFocus, true);
this.articleTarget.addEventListener('keydown', this._onMarkKeydown, true);
}
if (this.hasPopoverTarget) {
this.popoverTarget.addEventListener('pointerenter', this._onPopoverPointerEnter);
this.popoverTarget.addEventListener('pointerleave', this._onPopoverPointerLeave);
}
window.addEventListener('scroll', this._onScrollReposition, true);
window.addEventListener('resize', this._onScrollReposition);
this._scrollToHash();
window.addEventListener('hashchange', this._onHash);
}
disconnect() {
window.removeEventListener('hashchange', this._onHash);
window.removeEventListener('scroll', this._onScrollReposition, true);
window.removeEventListener('resize', this._onScrollReposition);
if (this.hasArticleTarget) {
this.articleTarget.removeEventListener('pointerenter', this._onMarkPointerEnter, true);
this.articleTarget.removeEventListener('pointerleave', this._onMarkPointerLeave, true);
this.articleTarget.removeEventListener('click', this._onMarkClick, true);
this.articleTarget.removeEventListener('focusin', this._onMarkFocus, true);
this.articleTarget.removeEventListener('keydown', this._onMarkKeydown, true);
}
if (this.hasPopoverTarget) {
this.popoverTarget.removeEventListener('pointerenter', this._onPopoverPointerEnter);
this.popoverTarget.removeEventListener('pointerleave', this._onPopoverPointerLeave);
}
this._clearHoverLeaveTimer();
}
closeOnOutside(event) {
if (!this.hasPopoverTarget) {
return;
}
const t = event.target;
if (this.popoverTarget.contains(t)) {
return;
}
if (t && t.closest && t.closest('mark.article-body-highlight')) {
return;
}
if (this._pinnedId) {
this._closePinned();
} else {
this._hideUnpinnedPopover();
}
}
onKeydown(event) {
if (event.key === 'Escape') {
this._closePinned();
this._hideUnpinnedPopover();
}
}
closePopover() {
this._closePinned();
this._hideUnpinnedPopover();
}
_onMarkLeave(mark) {
this._clearHoverLeaveTimer();
if (this._pinnedId) {
return;
}
this._hoverLeaveTimer = setTimeout(() => {
this._hoverLeaveTimer = null;
if (this._openId === (mark.getAttribute('data-event-id') || '').toLowerCase()) {
this._hideUnpinnedPopover();
}
}, 200);
}
_clearHoverLeaveTimer() {
if (this._hoverLeaveTimer) {
clearTimeout(this._hoverLeaveTimer);
this._hoverLeaveTimer = null;
}
}
_getMarkByEventId(id) {
if (!id) {
return null;
}
return (
this.element.querySelector(`#highlight-${id}`) ||
this.element.querySelector(`mark.article-body-highlight[data-event-id="${CSS.escape(id)}"]`)
);
}
_openForMark(mark, pin) {
if (!this.hasPopoverTarget || !this.hasPopoverInnerTarget) {
return;
}
const id = (mark.getAttribute('data-event-id') || '').toLowerCase();
if (!id || 64 !== id.length) {
return;
}
const meta = this._metaById[id];
if (!meta) {
return;
}
this._openId = id;
if (pin) {
this._pinnedId = id;
} else {
this._clearHoverLeaveTimer();
}
this.popoverInnerTarget.innerHTML =
(meta.headHtml || '') +
'<div class="user-highlight__body user-highlight__body--popover">' +
(meta.bodyHtml || '') +
'</div>';
this._placePopover(mark);
this.popoverTarget.hidden = false;
}
_placePopover(anchor) {
if (!this.hasPopoverTarget) {
return;
}
const p = this.popoverTarget;
p.style.position = 'fixed';
p.style.zIndex = '200';
p.style.left = '0';
p.style.top = '0';
const r = anchor.getBoundingClientRect();
p.hidden = false;
const pr = p.getBoundingClientRect();
let left = r.left + r.width / 2 - pr.width / 2;
let top = r.bottom + 8;
if (left < 8) {
left = 8;
}
if (left + pr.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - pr.width);
}
if (top + pr.height > window.innerHeight - 8) {
top = Math.max(8, r.top - 8 - pr.height);
}
p.style.left = `${Math.round(left)}px`;
p.style.top = `${Math.round(top)}px`;
}
_hideUnpinnedPopover() {
this._openId = null;
this._clearHoverLeaveTimer();
if (this._pinnedId) {
return;
}
if (this.hasPopoverTarget) {
this.popoverTarget.hidden = true;
}
if (this.hasPopoverInnerTarget) {
this.popoverInnerTarget.innerHTML = '';
}
}
_closePinned() {
this._pinnedId = null;
if (this.hasPopoverTarget) {
this.popoverTarget.hidden = true;
}
if (this.hasPopoverInnerTarget) {
this.popoverInnerTarget.innerHTML = '';
}
this._openId = null;
}
_scrollToHash() {
const raw = window.location.hash || '';
if (!raw.startsWith('#h-')) {
return;
}
const id = raw.slice(3).toLowerCase().replace(/[^0-9a-f]/g, '');
if (id.length !== 64) {
return;
}
const el = this.element.querySelector(`#highlight-${id}`) || this._getMarkByEventId(id);
if (!el) {
return;
}
el.classList.remove('article-body-highlight--target');
void el.offsetWidth;
el.classList.add('article-body-highlight--target');
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
this._openForMark(el, false);
}
}

19
assets/styles/app.css

@ -197,6 +197,17 @@ svg.icon { @@ -197,6 +197,17 @@ svg.icon {
box-shadow: 0 1px 0 color-mix(in srgb, var(--color-text) 6%, transparent);
border-top: 3px solid hsl(var(--tile-hue) 38% 40%);
overflow: hidden;
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
/* Home masonry tiles: global `a:hover { text-decoration: underline }` would apply; cancel + lift card on hover / focus. */
.featured-tile:has(.featured-tile__link:hover),
.featured-tile:has(.featured-tile__link:focus-visible) {
box-shadow:
0 1px 0 color-mix(in srgb, var(--color-text) 6%, transparent),
0 10px 28px color-mix(in srgb, var(--color-text) 8%, transparent);
border-color: color-mix(in srgb, var(--color-text-mid) 14%, var(--color-border) 86%);
transform: translateY(-2px);
}
.featured-tile__link {
@ -205,6 +216,14 @@ svg.icon { @@ -205,6 +216,14 @@ svg.icon {
text-decoration: none;
}
.featured-tile__link:hover,
.featured-tile__link:hover .card-title,
.featured-tile__link:hover .lede,
.featured-tile__link:hover h2,
.featured-tile__link:hover p {
text-decoration: none;
}
.featured-tile__link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;

119
assets/styles/article.css

@ -164,16 +164,7 @@ @@ -164,16 +164,7 @@
.comments-quotes__title {
font-size: 1.25rem;
margin: 0 0 0.35rem;
}
.comments-quotes__lede {
font-size: 0.95rem;
margin: 0 0 1.25rem;
}
.comments-quotes__lede code {
font-size: 0.9em;
margin: 0 0 0.9rem;
}
.comments-quotes__sep {
@ -430,3 +421,111 @@ @@ -430,3 +421,111 @@
border-color: #a12b2b;
background: #fdecec;
}
/* NIP-84: kind-9802 marks in .article-main + client popover (no separate thread list) */
.article-body-highlight {
cursor: pointer;
scroll-margin-top: 6rem;
}
.article-body-highlight--target {
box-shadow: inset 0 -2px 0 0 var(--color-secondary);
background: color-mix(in srgb, var(--color-secondary) 10%, transparent);
transition: background 0.35s ease, box-shadow 0.35s ease;
}
.article-body-highlight:focus-visible {
outline: 2px solid var(--color-focus-ring, var(--color-primary));
outline-offset: 2px;
}
.article-body-highlight__popover[hidden] {
display: none;
}
.article-body-highlight__popover {
position: fixed;
z-index: 200;
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-radius: 0.45rem;
color: var(--color-text);
background: var(--color-bg);
box-shadow: 0 0.4rem 1.25rem rgba(0, 0, 0, 0.12);
pointer-events: auto;
}
.article-body-highlight__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
margin: 0;
border: 0;
padding: 0.15rem 0.4rem;
line-height: 1;
font-size: 1.35rem;
color: var(--color-text-mid);
background: transparent;
border-radius: 0.2rem;
cursor: pointer;
}
.article-body-highlight__close:hover,
.article-body-highlight__close:focus-visible {
color: var(--color-text);
outline: 2px solid var(--color-focus-ring, var(--color-primary));
}
.article-body-highlight__inner {
position: relative;
}
.article-body-highlight__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 0.75rem;
margin: 0 0 0.4rem;
font-size: 0.86rem;
}
.user-highlight__body--popover {
margin-top: 0.3rem;
max-height: 40vh;
overflow: auto;
}
/* Full `context` quote + optional <mark> on the `content` substring (highlighter, not a box) */
.user-highlight__body {
margin: 0.35rem 0 0;
font-size: 0.95rem;
line-height: 1.65;
color: var(--color-text);
font-family: var(--main-body-font), serif;
}
/* In-flow highlighter marker (NIP-84: `content` inside `context` quote) */
.article-main mark.user-highlight__marker,
.user-highlight__body mark.user-highlight__marker,
mark.user-highlight__marker {
margin: 0;
padding: 0.08em 0.1em 0.12em;
border-radius: 0.12em;
font: inherit;
line-height: inherit;
color: inherit;
background: color-mix(in srgb, #7ad67a 38%, #f0e8a0 62%);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/* When `content` is not a substring of `context` (rare) */
.user-highlight__marker-orphan {
margin: 0.5rem 0 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text-mid);
}

156
assets/styles/layout.css

@ -179,33 +179,29 @@ @@ -179,33 +179,29 @@
margin: 0;
}
/* Pill badges: align with article `a.tag` (app.css) but scoped to the nav */
/* Pill badges: borderless, low-contrast chips (softer than article `a.tag`) */
.layout > nav a.topic-badge.sidebar-top-topics__link,
.layout > nav a.topic-badge {
display: inline-block;
max-width: 100%;
background-color: var(--color-bg-light);
color: var(--color-text-mid);
padding: 0.22rem 0.55rem;
background-color: color-mix(in srgb, var(--color-text-mid) 7%, var(--color-bg));
color: color-mix(in srgb, var(--color-text-mid) 78%, var(--color-bg) 22%);
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-size: 0.7rem;
line-height: 1.35;
font-weight: 500;
font-weight: 400;
text-decoration: none;
border: 1px solid var(--color-border);
border: none;
box-sizing: border-box;
word-break: break-word;
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
transition: background-color 0.2s ease, color 0.2s ease;
}
.layout > nav a.topic-badge:hover,
.layout > nav a.topic-badge:focus-visible {
background-color: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-light));
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 22%, var(--color-border));
background-color: color-mix(in srgb, var(--color-text-mid) 12%, var(--color-bg));
color: color-mix(in srgb, var(--color-primary) 42%, var(--color-text-mid));
text-decoration: none;
}
}
@ -494,6 +490,18 @@ main { @@ -494,6 +490,18 @@ main {
margin-top: 152px;
}
/* Right column: same clearance as <main> so the highlights pane is not under #site-header. */
.layout > aside {
margin-top: 152px;
/* Default: do not stretch — avoids a full-height empty column on pages with blank <aside> (e.g. article). */
align-self: flex-start;
}
/* Home: stretch the aside to the same row height as <main> so the highlights column isn’t a short box; list flows with the page. */
.layout:has(.home-body--wall) > aside {
align-self: stretch;
}
/*
* Left column account block: keep it in document flow (not position:fixed) so order is
* badge logout / search featured authors. Fixed positioning removed the menu from the flow
@ -551,13 +559,129 @@ main { @@ -551,13 +559,129 @@ main {
/* Right sidebar */
aside {
width: 190px;
min-width: 150px;
width: min(22vw, 260px);
min-width: 170px;
flex-shrink: 0;
flex-grow: 0;
padding: 1em;
}
/* Home: full list height — no max-height; window scroll. */
.home-aside-highlights {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
width: 100%;
}
/* Wrapper around the list (keeps end padding; no max-height so highlights aren’t trapped in a short box). */
.home-aside-highlights__scroller {
overflow: visible;
padding-right: max(0.6rem, calc(0.35rem + env(safe-area-inset-right, 0px)));
box-sizing: border-box;
}
.home-aside-highlights__title {
margin: 0 0 0.55rem;
font-family: var(--font-family), sans-serif;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text-mid) 72%, var(--color-bg) 28%);
line-height: 1.3;
}
.home-aside-highlights__list {
list-style: none;
margin: 0;
padding: 0 0.2rem 0 0; /* keep inset from scrollbar */
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* Block body HTML (full quote + <mark> like the article) must not sit inside <a> — use overlay “hit” link. */
.home-aside-highlights__item-inner {
position: relative;
color: color-mix(in srgb, var(--color-text-mid) 90%, var(--color-primary) 10%);
padding: 0.1rem 0 0.15rem 0.55rem;
border: none;
border-left: 1px solid color-mix(in srgb, var(--color-text-mid) 7%, var(--color-border) 93%);
border-radius: 0;
background: transparent;
line-height: 1.45;
font-size: 0.78rem;
transition: color 0.18s ease, border-left-color 0.18s ease, background 0.18s ease;
}
.home-aside-highlights__item-inner:hover {
color: var(--color-primary);
border-left-color: color-mix(in srgb, var(--color-primary) 32%, var(--color-border) 68%);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg) 96%);
}
.home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) {
color: var(--color-primary);
border-left-color: color-mix(in srgb, var(--color-primary) 38%, var(--color-border) 62%);
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg) 96%);
outline: 2px solid var(--color-focus-ring, var(--color-primary));
outline-offset: 3px;
}
.home-aside-highlights__hit {
position: absolute;
inset: 0;
z-index: 2;
text-decoration: none;
}
/* Let clicks go to the overlay; quote/meta stay visible above background only visually (no pointer on text). */
.home-aside-highlights__item-inner .home-aside-highlights__quote,
.home-aside-highlights__item-inner .home-aside-highlights__meta {
position: relative;
z-index: 0;
pointer-events: none;
}
.home-aside-highlights__quote {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
overflow: hidden;
font-style: italic;
font-weight: 400;
font-family: var(--main-body-font, Georgia), ui-serif, serif;
margin-bottom: 0.3rem;
word-break: break-word;
color: inherit;
}
/* Match article `bodyHtml` semantics; keep aside scale (`.user-highlight__body` is larger in-article). */
.home-aside-highlights__quote--html.user-highlight__body {
margin: 0 0 0.3rem;
font-size: 0.78rem;
line-height: 1.45;
color: inherit;
}
.home-aside-highlights__meta {
display: block;
font-size: 0.7rem;
font-style: normal;
font-family: var(--font-family), system-ui, sans-serif;
color: color-mix(in srgb, var(--color-text-mid) 80%, var(--color-bg) 20%);
letter-spacing: 0.02em;
transition: color 0.18s ease;
}
.home-aside-highlights__item-inner:hover .home-aside-highlights__meta,
.home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) .home-aside-highlights__meta {
color: color-mix(in srgb, var(--color-primary) 45%, var(--color-text-mid) 55%);
}
table {
width: 100%;
margin: 20px 0;

3
config/unfold.yaml

@ -17,7 +17,8 @@ parameters: @@ -17,7 +17,8 @@ parameters:
'wss://nostr.wine',
'wss://nostr21.com',
'wss://nostr.sovbit.host',
'wss://orly-relay.imwald.eu'
'wss://orly-relay.imwald.eu',
'wss://nostr.einundzwei.space'
]
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped).
profile_relays: [

31
migrations/Version20260425200000.php

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Kind 9802 (highlights) for long-form articles — stored locally; not part of the relay-only comment thread cache.
*/
final class Version20260425200000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Table article_highlight for Nostr kind-9802 highlights (linked to article rows)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE article_highlight (id INT AUTO_INCREMENT NOT NULL, event_id VARCHAR(64) NOT NULL, article_id INT NOT NULL, author_pubkey VARCHAR(64) NOT NULL, content LONGTEXT NOT NULL, tags JSON NOT NULL, event_created_at BIGINT NOT NULL, quote_excerpt VARCHAR(512) DEFAULT NULL, INDEX IDX_8F7E8A72946689E (article_id), INDEX IDX_highlight_event_created (event_created_at), UNIQUE INDEX UNIQ_8F7E8A7271F7E88B (event_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE article_highlight ADD CONSTRAINT FK_8F7E8A72946689E FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE article_highlight DROP FOREIGN KEY FK_8F7E8A72946689E');
$this->addSql('DROP TABLE article_highlight');
}
}

153
src/Command/PrewarmCommand.php

@ -12,6 +12,7 @@ use App\Service\CacheService; @@ -12,6 +12,7 @@ use App\Service\CacheService;
use App\Service\FeaturedAuthorSync;
use App\Service\MagazineContentService;
use App\Service\Nip05VerificationService;
use App\Service\HighlightSyncService;
use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
use App\Service\NostrClient;
@ -30,12 +31,12 @@ use Symfony\Component\Console\Terminal; @@ -30,12 +31,12 @@ use Symfony\Component\Console\Terminal;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Prewarms magazine index cache, author metadata cache, and optional comment thread cache.
* Does not persist comments to MySQL; comments are cache-only in this app.
* Prewarms magazine index cache, author metadata cache, optional comment thread cache, and
* kind-9802 highlights into MySQL. Comments remain cache-only; highlights use `article_highlight`.
*/
#[AsCommand(
name: 'app:prewarm',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05 verification cache, and comment caches',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05, comment caches, and highlight DB',
)]
final class PrewarmCommand extends Command
{
@ -53,6 +54,7 @@ final class PrewarmCommand extends Command @@ -53,6 +54,7 @@ final class PrewarmCommand extends Command
private readonly Nip05VerificationService $nip05Verification,
private readonly ProfileIdentityLinksBuilder $profileIdentityLinks,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly HighlightSyncService $highlightSyncService,
) {
parent::__construct();
}
@ -69,7 +71,10 @@ final class PrewarmCommand extends Command @@ -69,7 +71,10 @@ final class PrewarmCommand extends Command
->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0')
->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50')
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10')
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600');
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600')
->addOption('no-highlights', null, InputOption::VALUE_NONE, 'Skip kind-9802 highlight fetch → MySQL')
->addOption('highlights-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine articles to sync highlights for (0 = all)', '0')
->addOption('highlights-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the highlight sync phase', '600');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@ -404,71 +409,129 @@ final class PrewarmCommand extends Command @@ -404,71 +409,129 @@ final class PrewarmCommand extends Command
if ($input->getOption('no-comments')) {
$io->note('Skipping comments (--no-comments).');
} else {
$maxArticles = (int) $input->getOption('comments-max');
$io->section('Comment / interaction cache');
$commentBudgetSeconds = max(1, (int) $input->getOption('comments-budget'));
$commentPhaseStart = microtime(true);
$deadline = $commentPhaseStart + $commentBudgetSeconds;
$magazineList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication();
if ($maxArticles > 0) {
$magazineList = \array_slice($magazineList, 0, $maxArticles);
}
$articles = $magazineList;
$articleCount = \count($articles);
$w = 0;
if ($articleCount === 0) {
$io->note('No articles in DB to scan for comment cache.');
} else {
$cBar = $this->createPrewarmProgressBar($io, $articleCount, 'Comment threads');
$cBar->start();
try {
/** @var Article $article */
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning(sprintf(
'Comment phase stopped: comments-budget reached (%s).',
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
break;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
$cBar->advance(1);
$cBar->setMessage('skip · invalid row');
return Command::SUCCESS;
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$msg = $slug;
if (strlen($msg) > 56) {
$msg = substr($msg, 0, 53).'…';
}
$cBar->setMessage($msg);
$eventHex = (string) ($article->getEventId() ?? '');
try {
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
}
$cBar->advance(1);
}
} finally {
$this->finishPrewarmProgressBarWithoutFillingToMax($cBar, $io);
}
}
$io->success(sprintf(
'Warmed comment cache for %d of %d article(s). Comment phase wall time %s.',
$w,
$articleCount,
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
}
$maxArticles = (int) $input->getOption('comments-max');
if ($input->getOption('no-highlights')) {
$io->note('Skipping highlight DB sync (--no-highlights).');
$io->section('Comment / interaction cache');
$commentBudgetSeconds = max(1, (int) $input->getOption('comments-budget'));
$commentPhaseStart = microtime(true);
$deadline = $commentPhaseStart + $commentBudgetSeconds;
$magazineList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication();
if ($maxArticles > 0) {
$magazineList = \array_slice($magazineList, 0, $maxArticles);
return Command::SUCCESS;
}
$maxH = (int) $input->getOption('highlights-max');
$io->section('Highlights (kind 9802 → MySQL)');
$hBudget = max(1, (int) $input->getOption('highlights-budget'));
$hStart = microtime(true);
$hDeadline = $hStart + $hBudget;
$hList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication();
if ($maxH > 0) {
$hList = \array_slice($hList, 0, $maxH);
}
$articles = $magazineList;
$articleCount = \count($articles);
$w = 0;
if ($articleCount === 0) {
$io->note('No articles in DB to scan for comment cache.');
$hCount = \count($hList);
$hW = 0;
if ($hCount === 0) {
$io->note('No articles in DB to scan for highlights.');
} else {
$cBar = $this->createPrewarmProgressBar($io, $articleCount, 'Comment threads');
$cBar->start();
$hBar = $this->createPrewarmProgressBar($io, $hCount, 'Kind 9802 highlights');
$hBar->start();
try {
/** @var Article $article */
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning(sprintf(
'Comment phase stopped: comments-budget reached (%s).',
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
));
foreach ($hList as $article) {
if (microtime(true) >= $hDeadline) {
$io->warning(sprintf('Highlight phase stopped: highlights-budget reached (%d s).', $hBudget));
break;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
$cBar->advance(1);
$cBar->setMessage('skip · invalid row');
$hBar->advance(1);
$hBar->setMessage('skip · invalid row');
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$msg = $slug;
if (strlen($msg) > 56) {
$msg = substr($msg, 0, 53).'…';
$tmsg = $slug;
if (strlen($tmsg) > 56) {
$tmsg = substr($tmsg, 0, 53).'…';
}
$cBar->setMessage($msg);
$eventHex = (string) ($article->getEventId() ?? '');
$hBar->setMessage($tmsg);
try {
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
$hW += $this->highlightSyncService->syncForArticle($article);
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
$this->logger->warning('app:prewarm highlight', ['slug' => $slug, 'error' => $e->getMessage()]);
}
$cBar->advance(1);
$hBar->advance(1);
}
} finally {
$this->finishPrewarmProgressBarWithoutFillingToMax($cBar, $io);
$this->finishPrewarmProgressBarWithoutFillingToMax($hBar, $io);
}
}
$io->success(sprintf(
'Warmed comment cache for %d of %d article(s). Comment phase wall time %s.',
$w,
$articleCount,
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds),
'Highlight rows written/updated: <info>%d</info> (articles scanned: <info>%d</info>, wall time <info>%.0f</info>s / %d s).',
$hW,
$hCount,
microtime(true) - $hStart,
$hBudget
));
return Command::SUCCESS;

82
src/Controller/ArticleController.php

@ -3,6 +3,9 @@ @@ -3,6 +3,9 @@
namespace App\Controller;
use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Repository\ArticleHighlightRepository;
use App\Service\ArticleBodyHighlightInjector;
use App\Enum\KindsEnum;
use App\Nostr\Nip22CommentTags;
use App\Form\EditorType;
@ -296,9 +299,10 @@ class ArticleController extends AbstractController @@ -296,9 +299,10 @@ class ArticleController extends AbstractController
EntityManagerInterface $entityManager,
CacheService $cacheService,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
): Response
{
ArticleCommentThreadLoader $commentThreadLoader,
ArticleHighlightRepository $articleHighlightRepository,
ArticleBodyHighlightInjector $articleBodyHighlightInjector,
): Response {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
@ -312,7 +316,9 @@ class ArticleController extends AbstractController @@ -312,7 +316,9 @@ class ArticleController extends AbstractController
$article,
$cacheService,
$converter,
$commentThreadLoader
$commentThreadLoader,
$articleHighlightRepository,
$articleBodyHighlightInjector
);
}
@ -362,7 +368,9 @@ class ArticleController extends AbstractController @@ -362,7 +368,9 @@ class ArticleController extends AbstractController
Article $article,
CacheService $cacheService,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
ArticleCommentThreadLoader $commentThreadLoader,
ArticleHighlightRepository $articleHighlightRepository,
ArticleBodyHighlightInjector $articleBodyHighlightInjector,
): Response {
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
@ -396,6 +404,11 @@ class ArticleController extends AbstractController @@ -396,6 +404,11 @@ class ArticleController extends AbstractController
$commentsPreloaded = true;
}
$highlights = $articleHighlightRepository->findByArticle($article);
$injection = $articleBodyHighlightInjector->inject($html, $highlights);
$html = $injection['html'];
$highlightsClientJson = $this->buildHighlightsClientJson($highlights, $injection['injectedEventIds']);
return $this->render('pages/article.html.twig', [
'article' => $article,
'author' => $author,
@ -404,9 +417,67 @@ class ArticleController extends AbstractController @@ -404,9 +417,67 @@ class ArticleController extends AbstractController
'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded,
'comment_reply_context' => $commentReplyContext,
'article_highlights_client_json' => $highlightsClientJson,
]);
}
/**
* @param list<ArticleHighlight> $highlights
* @param list<string> $injectedEventIds
*/
private function buildHighlightsClientJson(array $highlights, array $injectedEventIds): ?string
{
if ($injectedEventIds === []) {
return null;
}
$byId = [];
foreach ($highlights as $h) {
$id = \strtolower($h->getEventId());
if (64 === \strlen($id) && ctype_xdigit($id)) {
$byId[$id] = $h;
}
}
$out = [];
foreach ($injectedEventIds as $eid) {
$eid = \strtolower($eid);
$h = $byId[$eid] ?? null;
if (! $h instanceof ArticleHighlight) {
continue;
}
$out[$eid] = [
'headHtml' => $this->renderView('components/Molecules/ArticleHighlightMetaHead.html.twig', [
'authorPubkey' => $h->getAuthorPubkey(),
'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()),
]),
'bodyHtml' => $h->getBodyHtml(),
];
}
if ($out === []) {
return null;
}
return \json_encode(
$out,
\JSON_THROW_ON_ERROR
| \JSON_UNESCAPED_UNICODE
| \JSON_HEX_TAG
| \JSON_HEX_AMP
| \JSON_HEX_APOS
| \JSON_HEX_QUOT
);
}
private function formatHighlightListDate(int $unix): string
{
if ($unix <= 0) {
return '';
}
$tz = new \DateTimeZone(@\date_default_timezone_get() ?: 'UTC');
$dt = (new \DateTimeImmutable('@'.(string) $unix))->setTimezone($tz);
return $dt->format('F j, Y');
}
/**
* Base article-level reply context so the top "Reply" button can render before async comments load.
*
@ -594,6 +665,7 @@ class ArticleController extends AbstractController @@ -594,6 +665,7 @@ class ArticleController extends AbstractController
'author' => $user->getMetadata(),
'npub' => $previewNpub,
'comments_preloaded' => false,
'article_highlights_client_json' => null,
]);
}

3
src/Controller/DefaultController.php

@ -4,6 +4,7 @@ declare(strict_types=1); @@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Repository\ArticleHighlightRepository;
use App\Service\MagazineContentService;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -16,6 +17,7 @@ class DefaultController extends AbstractController @@ -16,6 +17,7 @@ class DefaultController extends AbstractController
{
public function __construct(
private readonly MagazineContentService $magazineContent,
private readonly ArticleHighlightRepository $articleHighlightRepository,
) {
}
@ -26,6 +28,7 @@ class DefaultController extends AbstractController @@ -26,6 +28,7 @@ class DefaultController extends AbstractController
return $this->render('home.html.twig', [
'home_featured_tiles' => $this->magazineContent->buildHomeMixedFeaturedWallTiles($categoryATags),
'home_highlights' => $this->articleHighlightRepository->findRecentForHome(40),
]);
}

156
src/Entity/ArticleHighlight.php

@ -0,0 +1,156 @@ @@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ArticleHighlightRepository;
use App\Util\HighlightEventTags;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Nostr kind 9802 (highlight) events that reference a long-form article by `a` / `A` address.
* Ingested from relays and served from MySQL (not from the comment-thread cache).
*/
#[ORM\Entity(repositoryClass: ArticleHighlightRepository::class)]
#[ORM\Table(name: 'article_highlight')]
#[ORM\Index(name: 'IDX_highlight_event_created', columns: ['event_created_at'])]
class ArticleHighlight
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column]
private ?int $id = null;
/** Event id (hex, lowercase) — globally unique. */
#[ORM\Column(length: 64, unique: true)]
private string $eventId = '';
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: null)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Article $article = null;
/** Pubkey (hex) of the account that created the highlight. */
#[ORM\Column(length: 64)]
private string $authorPubkey = '';
#[ORM\Column(type: Types::TEXT)]
private string $content = '';
/** Full tag array as returned on the wire (includes textquoteselector, a/A, etc.). */
#[ORM\Column(type: Types::JSON)]
private array $tags = [];
/** Nostr `created_at` (unix seconds). */
#[ORM\Column(type: Types::BIGINT)]
private int $eventCreatedAt = 0;
/** Short quote line for list UI / deep-link hint (from textquoteselector or content). */
#[ORM\Column(type: Types::STRING, length: 512, nullable: true)]
private ?string $quoteExcerpt = null;
public function getId(): ?int
{
return $this->id;
}
public function getEventId(): string
{
return $this->eventId;
}
public function setEventId(string $eventId): static
{
$this->eventId = strtolower($eventId);
return $this;
}
public function getArticle(): ?Article
{
return $this->article;
}
public function setArticle(?Article $article): static
{
$this->article = $article;
return $this;
}
public function getAuthorPubkey(): string
{
return $this->authorPubkey;
}
public function setAuthorPubkey(string $authorPubkey): static
{
$this->authorPubkey = $authorPubkey;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getTags(): array
{
return $this->tags;
}
public function setTags(array $tags): static
{
$this->tags = $tags;
return $this;
}
public function getEventCreatedAt(): int
{
return $this->eventCreatedAt;
}
public function setEventCreatedAt(int $eventCreatedAt): static
{
$this->eventCreatedAt = $eventCreatedAt;
return $this;
}
public function getQuoteExcerpt(): ?string
{
return $this->quoteExcerpt;
}
public function setQuoteExcerpt(?string $quoteExcerpt): static
{
$this->quoteExcerpt = $quoteExcerpt;
return $this;
}
/** The full quote from the optional `context` tag. Event `content` is highlighted *inside* this when present. */
public function getContextText(): string
{
return HighlightEventTags::contextFromTags($this->tags);
}
/** Renders: full `content` in <mark> when `context` is empty; else `context` quote with `content` substring marked. */
public function getBodyHtml(): string
{
$ctx = $this->getContextText();
$body = (string) $this->getContent();
return HighlightEventTags::buildHighlightedBodyHtml($ctx, $body);
}
}

68
src/Repository/ArticleHighlightRepository.php

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Enum\EventStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ArticleHighlight>
*/
class ArticleHighlightRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ArticleHighlight::class);
}
/**
* Newest highlights across published/archived long-form, for the home aside.
*
* @return list<ArticleHighlight>
*/
public function findRecentForHome(int $limit = 36): array
{
if ($limit <= 0) {
return [];
}
$qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a')
->where('a.eventStatus IN (:st)')
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('h.eventCreatedAt', 'DESC')
->addOrderBy('h.id', 'DESC')
->setMaxResults($limit);
/** @var list<ArticleHighlight> $rows */
$rows = $qb->getQuery()->getResult();
return $rows;
}
/**
* @return list<ArticleHighlight>
*/
public function findByArticle(Article $article): array
{
$id = $article->getId();
if (null === $id || (int) $id < 1) {
return [];
}
/** @var list<ArticleHighlight> $out */
$out = $this->createQueryBuilder('h')
->where('h.article = :art')
->setParameter('art', $article)
->orderBy('h.eventCreatedAt', 'DESC')
->getQuery()
->getResult();
return $out;
}
}

317
src/Service/ArticleBodyHighlightInjector.php

@ -0,0 +1,317 @@ @@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\ArticleHighlight;
use DOMDocument;
use DOMElement;
use DOMText;
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.
*/
final class ArticleBodyHighlightInjector
{
private const ROOT_ID = '_article_hl';
private DOMDocument $dom;
private ?DOMElement $root = null;
/**
* @param list<ArticleHighlight> $highlights
*
* @return array{html: string, injectedEventIds: list<string>}
*/
public function inject(string $html, array $highlights): array
{
if ($highlights === [] || $html === '') {
return ['html' => $html, 'injectedEventIds' => []];
}
$sorted = $highlights;
usort(
$sorted,
static fn (ArticleHighlight $a, ArticleHighlight $b) => $a->getEventCreatedAt() <=> $b->getEventCreatedAt()
);
$this->loadDom($html);
if (null === $this->root) {
return ['html' => $html, 'injectedEventIds' => []];
}
$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)) {
$injected[] = $eid;
}
}
$out = '';
foreach ($this->root->childNodes as $child) {
$out .= (string) $this->dom->saveHTML($child);
}
return ['html' => $out, 'injectedEventIds' => $injected];
}
private function loadDom(string $html): void
{
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->root = null;
if ($html === '') {
return;
}
$enc = '<?xml encoding="UTF-8"?>'.'<div id="'.self::ROOT_ID.'">'.$html.'</div>';
$prev = libxml_use_internal_errors(true);
try {
if (false === $this->dom->loadHTML(
$enc,
\LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD
)) {
libxml_clear_errors();
}
} finally {
libxml_use_internal_errors($prev);
libxml_clear_errors();
}
// getElementById is unreliable for HTML loaded without a DTD; use XPath.
$xp = new DOMXPath($this->dom);
$nodes = $xp->query('//div[@id="'.self::ROOT_ID.'"]');
if (false === $nodes || 0 === $nodes->length) {
$this->root = $this->findElementByIdFallback(self::ROOT_ID);
return;
}
$first = $nodes->item(0);
$this->root = $first instanceof DOMElement ? $first : null;
}
private function findElementByIdFallback(string $id): ?DOMElement
{
if ('' === $id) {
return null;
}
$stack = [];
if (null === $this->dom->documentElement) {
return null;
}
$stack[] = $this->dom->documentElement;
while ($stack !== []) {
$el = \array_pop($stack);
if (! $el instanceof DOMElement) {
continue;
}
if ($el->getAttribute('id') === $id) {
return $el;
}
for ($c = $el->lastChild; $c; $c = $c->previousSibling) {
if ($c instanceof DOMElement) {
$stack[] = $c;
}
}
}
return null;
}
private function tryWrapInDocument(DOMElement $root, string $needle, string $eventId): bool
{
$textNodes = $this->collectTextNodes($root);
if ($textNodes === []) {
return false;
}
$cat = '';
/** @var list<array{0: DOMText, 1: int, 2: int}> $segments */
$segments = [];
$nl = \mb_strlen($needle, 'UTF-8');
if ($nl < 1) {
return false;
}
foreach ($textNodes as $tn) {
$t = (string) $tn->data;
$len = \mb_strlen($t, 'UTF-8');
if ($len === 0) {
continue;
}
$cat .= $t;
}
$p = \mb_strpos($cat, $needle, 0, 'UTF-8');
if (false === $p) {
return false;
}
$pEnd = $p + $nl;
$cursor = 0;
foreach ($textNodes as $tn) {
$t = (string) $tn->data;
$nodeLen = \mb_strlen($t, 'UTF-8');
if ($nodeLen === 0) {
continue;
}
$nStart = $cursor;
$nEnd = $cursor + $nodeLen;
if ($pEnd <= $nStart) {
break;
}
if ($p >= $nEnd) {
$cursor = $nEnd;
continue;
}
$oStart = \max($p, $nStart);
$oEnd = \min($pEnd, $nEnd);
if ($oStart < $oEnd) {
$lStart = $oStart - $nStart;
$lLen = $oEnd - $oStart;
$segments[] = [$tn, $lStart, $lLen];
}
$cursor = $nEnd;
if ($oEnd >= $pEnd) {
break;
}
}
if ($segments === []) {
return false;
}
for ($i = \count($segments) - 1; $i >= 0; --$i) {
[$n, $off, $nLen] = $segments[$i];
if (! $this->wrapTextSlice(
$n,
$off,
$nLen,
$eventId,
0 === $i
)) {
return false;
}
}
return true;
}
/**
* @return list<DOMText>
*/
private function collectTextNodes(DOMElement $el): array
{
$out = [];
for ($c = $el->firstChild; $c; $c = $c->nextSibling) {
if ($c instanceof DOMText) {
if ($this->isSafeTextContext($c)) {
$out[] = $c;
}
} elseif ($c instanceof DOMElement) {
if ($this->shouldNotDescendInto($c)) {
continue;
}
foreach ($this->collectTextNodes($c) as $tn) {
$out[] = $tn;
}
}
}
return $out;
}
private function shouldNotDescendInto(DOMElement $c): bool
{
$n = $c->nodeName;
return 'script' === $n
|| 'style' === $n
|| 'pre' === $n
|| 'textarea' === $n
|| 'code' === $n
|| 'mark' === $n;
}
private function isSafeTextContext(DOMText $textNode): bool
{
$p = $textNode->parentNode;
while (null !== $p && $p->nodeType === XML_ELEMENT_NODE) {
if (! $p instanceof DOMElement) {
$p = $p->parentNode;
continue;
}
$n = $p->nodeName;
if ('script' === $n || 'style' === $n || 'pre' === $n || 'textarea' === $n) {
return false;
}
if ('code' === $n) {
return false;
}
if ('mark' === $n) {
$cl = (string) $p->getAttribute('class');
if (\str_contains($cl, 'article-body-highlight')) {
return false;
}
}
$p = $p->parentNode;
}
return true;
}
private function wrapTextSlice(DOMText $textNode, int $uOffset, int $uLength, string $eventId, bool $firstInReadingOrder): bool
{
if ($uLength < 1) {
return false;
}
$t = (string) $textNode->data;
$nLen = \mb_strlen($t, 'UTF-8');
if ($uOffset < 0 || $uOffset + $uLength > $nLen) {
return false;
}
$before = $uOffset > 0 ? \mb_substr($t, 0, $uOffset, 'UTF-8') : '';
$match = \mb_substr($t, $uOffset, $uLength, 'UTF-8');
$restStart = $uOffset + $uLength;
$after = $restStart < $nLen ? \mb_substr($t, $restStart, null, 'UTF-8') : '';
$parent = $textNode->parentNode;
if (null === $parent) {
return false;
}
$ref = $textNode;
if ($before !== '') {
$parent->insertBefore($this->dom->createTextNode($before), $ref);
}
$mark = $this->dom->createElement('mark');
if (! $mark) {
return false;
}
$mark->setAttribute('class', 'user-highlight__marker article-body-highlight');
if ($firstInReadingOrder) {
$mark->setAttribute('id', 'highlight-'.$eventId);
$mark->setAttribute('tabindex', '0');
}
$mark->setAttribute('data-event-id', $eventId);
$mark->setAttribute('data-article-body-highlight', '1');
if (! $firstInReadingOrder) {
$mark->setAttribute('data-article-body-highlight-continuation', '1');
}
$mark->appendChild($this->dom->createTextNode($match));
$parent->insertBefore($mark, $ref);
if ($after === '') {
$parent->removeChild($ref);
} else {
$ref->data = $after;
}
return true;
}
}

3
src/Service/ArticleCommentThreadLoader.php

@ -13,6 +13,7 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -13,6 +13,7 @@ use Symfony\Contracts\Cache\ItemInterface;
/**
* Loads Nostr article discussion: NIP-22 (1111) + legacy kind 1 replies, plus quotes/reposts (q / a tags).
* Kind-9802 highlights are not in this response; they live in `article_highlight`.
*
* Reply blurbs mirror the jumble client: resolve the parent from `e` / `E` tags (NIP-10, `reply` marker,
* last-of-sequence), then show a short preview of the parent’s body (see jumble `ParentNotePreview`). Inline
@ -140,7 +141,7 @@ final readonly class ArticleCommentThreadLoader @@ -140,7 +141,7 @@ final readonly class ArticleCommentThreadLoader
*/
private function cacheKeyForThread(string $coordinate, ?string $articleEventHexId): string
{
return 'comments_v5_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? ''));
return 'comments_v6_'.hash('sha256', $coordinate."\0".($articleEventHexId ?? ''));
}
/**

104
src/Service/HighlightSyncService.php

@ -0,0 +1,104 @@ @@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Enum\KindsEnum;
use App\Repository\ArticleHighlightRepository;
use App\Util\HighlightEventTags;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Pulls kind-9802 highlights from relays and upserts into {@see ArticleHighlight}.
*/
final class HighlightSyncService
{
public function __construct(
private readonly NostrClient $nostrClient,
private readonly EntityManagerInterface $entityManager,
private readonly ArticleHighlightRepository $highlightRepository,
private readonly LoggerInterface $logger,
) {
}
/**
* @return int number of highlight rows written/updated
*/
public function syncForArticle(Article $article): int
{
$id = $article->getId();
if (null === $id || (int) $id < 1) {
return 0;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || 64 !== \strlen($pubkey) || !ctype_xdigit($pubkey)) {
return 0;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$events = $this->nostrClient->fetchHighlightEventsForArticle($coordinate);
$n = 0;
foreach ($events as $ev) {
if (!\is_object($ev)) {
continue;
}
if ((int) ($ev->kind ?? 0) !== KindsEnum::HIGHLIGHTS->value) {
continue;
}
$eid = strtolower((string) ($ev->id ?? ''));
if (64 !== \strlen($eid) || !ctype_xdigit($eid)) {
continue;
}
$author = strtolower((string) ($ev->pubkey ?? ''));
if (64 !== \strlen($author) || !ctype_xdigit($author)) {
continue;
}
$tags = $ev->tags ?? [];
if (!\is_array($tags)) {
$tags = [];
}
$content = (string) ($ev->content ?? '');
$ca = (int) ($ev->created_at ?? 0);
if ($ca < 0) {
$ca = 0;
}
$excerpt = HighlightEventTags::excerptForFeed($content, $tags);
if ($excerpt === '') {
$excerpt = \mb_substr(\trim($content), 0, 240);
}
$row = $this->highlightRepository->findOneBy(['eventId' => $eid]);
if ($row === null) {
$row = new ArticleHighlight();
$row->setEventId($eid);
}
$row->setArticle($article);
$row->setAuthorPubkey($author);
$row->setContent($content);
$row->setTags($tags);
$row->setEventCreatedAt($ca);
$row->setQuoteExcerpt($excerpt !== '' ? $excerpt : null);
$this->entityManager->persist($row);
++$n;
}
if ($n > 0) {
$this->entityManager->flush();
}
$this->logger->info('highlight_sync.article', [
'article_id' => $id,
'slug' => $slug,
'ingested' => $n,
]);
return $n;
}
}

116
src/Service/NostrClient.php

@ -1240,6 +1240,7 @@ class NostrClient @@ -1240,6 +1240,7 @@ class NostrClient
/**
* NIP-22 kind 1111 thread, legacy kind 1 replies (pre-NIP-22 clients), and quote/repost-style references.
* Kind 9802 highlights are excluded; they are stored in `article_highlight` via {@see fetchHighlightEventsForArticle()}.
*
* @param string $coordinate kind:pubkey:d-identifier (e.g. longform address)
* @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching
@ -1399,6 +1400,94 @@ class NostrClient @@ -1399,6 +1400,94 @@ class NostrClient
return ['thread' => $thread, 'quotes' => $quotes, 'partial' => $partial];
}
/**
* Fetches kind 9802 (highlights) that reference the long-form address. Used for DB ingest only.
*
* @return list<object> unique wire events by id
*/
public function fetchHighlightEventsForArticle(string $coordinate): array
{
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier');
}
$pubkey = $parts[1];
$tRelays = microtime(true);
$authorRelays = $this->getAuthorNip65RelaysList($pubkey);
$this->logger->info('nostr.highlight_relay_list', [
'elapsed_ms' => (int) round((microtime(true) - $tRelays) * 1000),
'author_relay_count' => \count($authorRelays),
]);
$baseForDiscussion = $this->configuredArticleRelayUrlList();
$mergedForDiscussion = $this->withAggrNostrLandIfUserSubscribesNostrLand(
array_merge($baseForDiscussion, $authorRelays)
);
$plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR));
if (\count($plannedRelayUrls) > self::MAX_DISCUSSION_RELAY_URLS) {
$plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_DISCUSSION_RELAY_URLS);
}
$limH = 200;
$filters = [];
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limH);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limH);
$filters[] = $f;
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters);
$this->logger->info('nostr.highlight_req', [
'subscription_id' => $subscriptionId,
'coordinate' => $coordinate,
'relay_count' => \count($plannedRelayUrls),
]);
try {
if (!\is_file($this->projectDir.'/bin/nostr_relay_request_worker.php') || \count($plannedRelayUrls) <= 1) {
$forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls);
$response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage);
} else {
try {
$response = $this->sendArticleDiscussionToRelaysParallel($plannedRelayUrls, $requestMessage);
} catch (\Throwable $e) {
$this->logger->warning('nostr.highlight.parallel_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls);
$response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage);
}
}
} catch (\Throwable $e) {
$this->logger->error('nostr.highlight_req_failed: '.$e->getMessage(), [
'coordinate' => $coordinate,
]);
throw new \RuntimeException('Nostr request failed for highlights', 0, $e);
}
$byId = [];
$this->processResponse($response, function ($event) use (&$byId) {
if (\is_object($event) && isset($event->id) && (int) ($event->kind ?? 0) === KindsEnum::HIGHLIGHTS->value) {
$byId[(string) $event->id] = $event;
}
return null;
});
$this->logger->info('nostr.highlight_done', ['count' => \count($byId)]);
return array_values($byId);
}
/**
* @param list<string> $relayUrls
*
@ -1660,6 +1749,10 @@ class NostrClient @@ -1660,6 +1749,10 @@ class NostrClient
private function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool
{
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::HIGHLIGHTS->value) {
// Highlights are stored in `article_highlight`, not the discussion/quote list.
return false;
}
if ($kind === KindsEnum::COMMENTS->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
@ -1697,17 +1790,6 @@ class NostrClient @@ -1697,17 +1790,6 @@ class NostrClient
}
}
}
if ($kind === KindsEnum::HIGHLIGHTS->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$n = (string) ($tag[0] ?? '');
if (($n === 'a' || $n === 'A') && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
}
return false;
}
@ -1759,7 +1841,6 @@ class NostrClient @@ -1759,7 +1841,6 @@ class NostrClient
KindsEnum::REPOST->value,
KindsEnum::GENERIC_REPOST->value,
KindsEnum::COMMENTS->value,
KindsEnum::HIGHLIGHTS->value,
];
$qVals = [$coordinate];
if ($rootEventHexId !== null && $rootEventHexId !== '') {
@ -1777,17 +1858,6 @@ class NostrClient @@ -1777,17 +1858,6 @@ class NostrClient
$f->setLimit(50);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit(40);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#A', [$coordinate]);
$f->setLimit(40);
$filters[] = $f;
return $filters;
}

137
src/Util/HighlightEventTags.php

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
<?php
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 <mark> (not plain text only).
*
* @param list<array<int, string>>|list<array> $tags
*/
final class HighlightEventTags
{
public const HIGHLIGHT_MARK_CLASS = 'user-highlight__marker';
/**
* The full passage from the `context` tag (one tag may split across many values in some clients).
*/
public static function contextFromTags(array $tags): string
{
$parts = [];
foreach ($tags as $t) {
if (!\is_array($t) || \count($t) < 2) {
continue;
}
if (strtolower((string) ($t[0] ?? '')) !== 'context') {
continue;
}
for ($i = 1, $c = \count($t); $i < $c; ++$i) {
$p = (string) ($t[$i] ?? '');
if ($p !== '') {
$parts[] = $p;
}
}
}
if ($parts === []) {
return '';
}
$joined = \implode(' ', $parts);
return \mb_substr($joined, 0, 8000);
}
/**
* Renders the full quote and wraps the `content` substring in <mark> when a context tag is present;
* otherwise the entire `content` is wrapped in <mark> (no surrounding quote).
*
* @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.
*
* @return string safe HTML
*/
public static function buildHighlightedBodyHtml(string $contextQuote, string $contentField): string
{
$q = (string) $contextQuote;
$hi = (string) $contentField;
if ($q === '' && $hi === '') {
return '';
}
if ($q === '') {
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.self::escapeWithNl2br($hi).'</mark>';
}
if ($hi === '') {
return self::escapeWithNl2br($q);
}
$pos = \mb_strpos($q, $hi, 0, 'UTF-8');
if ($pos !== false) {
$len = \mb_strlen($hi, 'UTF-8');
$before = \mb_substr($q, 0, $pos, 'UTF-8');
$match = \mb_substr($q, $pos, $len, 'UTF-8');
$after = \mb_substr($q, $pos + $len, null, 'UTF-8');
return self::escapeWithNl2br($before).self::markHtml($match).self::escapeWithNl2br($after);
}
// Substring not found: show the full context quote, then the highlight line so the note is not empty.
return self::escapeWithNl2br($q).'<p class="user-highlight__marker-orphan">'.self::markHtml($hi).'</p>';
}
public static function escapeWithNl2br(string $s): string
{
return \nl2br(\htmlspecialchars($s, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), false);
}
private static function markHtml(string $innerText): string
{
$e = \htmlspecialchars($innerText, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
return '<mark class="'.self::HIGHLIGHT_MARK_CLASS.'">'.$e.'</mark>';
}
/**
* Text from `textquoteselector` (legacy / client-specific), first non-empty segment.
*
* @param list<array<int, string>>|list<array> $tags
*/
public static function excerptFromTextquoteselectorTags(array $tags): string
{
foreach ($tags as $t) {
if (!\is_array($t) || \count($t) < 2) {
continue;
}
if (strtolower((string) ($t[0] ?? '')) !== 'textquoteselector') {
continue;
}
for ($i = 1, $c = \count($t); $i < $c; ++$i) {
$p = \trim((string) ($t[$i] ?? ''));
if ($p !== '') {
return \mb_substr($p, 0, 400);
}
}
}
return '';
}
/**
* List preview: prefer the event `content` (the highlight / note body), else `context` quote, else tq.
*/
public static function excerptForFeed(string $content, array $tags): string
{
$c = \trim((string) $content);
if ($c !== '') {
return \mb_substr($c, 0, 400);
}
$ctx = \trim(self::contextFromTags($tags));
if ($ctx !== '') {
return \mb_substr($ctx, 0, 400);
}
$tq = \trim(self::excerptFromTextquoteselectorTags($tags));
return $tq !== '' ? $tq : '';
}
}

8
templates/components/Molecules/ArticleHighlightMetaHead.html.twig

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
<div class="article-body-highlight__head">
<div class="article-body-highlight__who">
<twig:Molecules:UserFromNpub ident="{{ authorPubkey }}" />
</div>
{% if dateLabel|default('')|trim != '' %}
<span class="text-subtle article-body-highlight__when">{{ dateLabel }}</span>
{% endif %}
</div>

1
templates/components/Organisms/Comments.html.twig

@ -109,7 +109,6 @@ @@ -109,7 +109,6 @@
{% if quotes is defined and quotes|length > 0 %}
<div class="comments-quotes">
<h3 class="comments-quotes__title">Quotes and references</h3>
<p class="text-subtle comments-quotes__lede">Other notes that cite this article in a <code>q</code> tag (NIP-18) or reference its address in <code>a</code> / <code>A</code> (e.g. generic reposts, highlights).</p>
<div class="comments-quotes__list">
{% for item in quotes %}
{% set cid = item.id|default('') %}

38
templates/components/Organisms/HomeHighlightsAside.html.twig

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
{% if highlights is defined and highlights is not empty %}
<section class="home-aside-highlights" aria-label="{{ 'sidebar.highlights'|trans }}">
<h2 class="home-aside-highlights__title">{{ 'sidebar.highlights'|trans }}</h2>
<div class="home-aside-highlights__scroller">
<ul class="home-aside-highlights__list" role="list">
{% for h in highlights %}
{% set art = h.article %}
{% if art %}
{% set _np = npub_from_hex(art.pubkey|default('')) %}
{% if _np != '' and art.slug|default('') != '' %}
<li class="home-aside-highlights__item">
<div class="home-aside-highlights__item-inner">
<a
class="home-aside-highlights__hit"
href="{{ path('article', { npub: _np, slug: art.slug }) ~ '#h-' ~ h.eventId }}"
aria-label="{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') })|e('html_attr') }}"
>
<span class="visually-hidden">{{ 'sidebar.highlight_view'|trans({ '%title%': art.title|default('') }) }}</span>
</a>
{% set _html = h.bodyHtml|default('')|trim %}
{% if _html != '' %}
<div class="home-aside-highlights__quote home-aside-highlights__quote--html user-highlight__body">{{ _html|raw }}</div>
{% 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 %}
<div class="home-aside-highlights__quote home-aside-highlights__quote--plain">{{ _prew|u.truncate(200, '…') }}</div>
{% endif %}
<span class="home-aside-highlights__meta text-subtle">{{ art.title|default('')|u.truncate(52, '…') }}</span>
</div>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ul>
</div>
</section>
{% endif %}

3
templates/home.html.twig

@ -32,6 +32,5 @@ @@ -32,6 +32,5 @@
{% endblock %}
{% block aside %}
{# <h6>Magazines</h6>#}
{# <twig:Organisms:ZineList />#}
{% include 'components/Organisms/HomeHighlightsAside.html.twig' with { highlights: home_highlights|default([]) } only %}
{% endblock %}

24
templates/pages/article.html.twig

@ -59,7 +59,11 @@ @@ -59,7 +59,11 @@
{% endblock %}
{% block body %}
<div
data-controller="article-highlight"
class="article-page-root"
data-action="click@window->article-highlight#closeOnOutside keydown@window->article-highlight#onKeydown"
>
{% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
@ -98,7 +102,7 @@ @@ -98,7 +102,7 @@
</div>
{% endif %}
<div class="article-main">
<div class="article-main" data-article-highlight-target="article">
{{ content|raw }}
</div>
@ -113,6 +117,21 @@ @@ -113,6 +117,21 @@
</div>
{% if article_highlights_client_json|default('')|trim != '' %}
<script type="application/json" id="article-highlights-client-json" data-article-highlight-target="meta">{{ article_highlights_client_json|raw }}</script>
{% endif %}
<div
class="article-body-highlight__popover"
data-article-highlight-target="popover"
data-article-highlight-turbo-permanent
hidden
role="dialog"
aria-label="{{ 'highlight.popover'|trans }}"
>
<button type="button" class="article-body-highlight__close" data-action="article-highlight#closePopover" aria-label="{{ 'highlight.close'|trans }}">×</button>
<div class="article-body-highlight__inner" data-article-highlight-target="popoverInner"></div>
</div>
<hr class="divider" />
{# <pre>#}
@ -141,6 +160,7 @@ @@ -141,6 +160,7 @@
</div>
</div>
</section>
</div>
{% endblock %}
{% block aside %}

7
translations/messages.en.yaml

@ -1,9 +1,16 @@ @@ -1,9 +1,16 @@
sidebar:
featured_authors: 'Featured authors'
topics: 'Topics'
highlights: 'Highlights'
highlight_view: 'View highlight: %title%'
topic:
browse: 'Articles with this tag'
empty: 'No published articles with this tag yet.'
highlight:
section_title: 'Highlights'
section_lede: 'NIP-84 (kind 9802). The note’s `content` is the highlighted phrase. If a `context` tag is set, that tag is the full quote and the `content` text is marked inside it; otherwise only `content` is shown.'
popover: 'Highlight'
close: 'Close'
text:
byline: 'By'
search: 'Search...'

Loading…
Cancel
Save