23 changed files with 1736 additions and 106 deletions
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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'); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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 : ''; |
||||||
|
} |
||||||
|
} |
||||||
@ -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> |
||||||
@ -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 %} |
||||||
Loading…
Reference in new issue