Browse Source

Inline links

imwald
Nuša Pukšič 3 months ago
parent
commit
39831eb882
  1. 402
      src/Util/CommonMark/Converter.php
  2. 6
      templates/pages/article.html.twig

402
src/Util/CommonMark/Converter.php

@ -6,7 +6,6 @@ use App\Enum\KindsEnum;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension; use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
use App\Util\NostrKeyUtil; use App\Util\NostrKeyUtil;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Exception\CommonMarkException;
@ -30,229 +29,340 @@ use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent; use nostriphant\NIP19\Data\NEvent;
use nostriphant\NIP19\Data\Note; use nostriphant\NIP19\Data\Note;
use nostriphant\NIP19\Data\NProfile; use nostriphant\NIP19\Data\NProfile;
use nostriphant\NIP19\Data\NPub;
readonly class Converter final readonly class Converter
{ {
/** Match any nostr:* bech link (used for batching) */
private const RE_ALL_NOSTR = '~nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\s<>()\[\]{}"\'`.,;:!?]*~i';
/** Replace anchors with href="nostr:..." while preserving inner text */
private const RE_NOSTR_ANCHOR = '~<a\b(?<attrs>[^>]*?)\bhref=(["\'])(?<nostr>nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^"\']*)\2(?<attrs2>[^>]*)>(?<inner>.*?)</a>~is';
/** Bare-text nostr links, defensive against href immediate prefix */
private const RE_BARE_NOSTR = '~(?<!href=")(?<!href=\')nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\s<>()\[\]{}"\'`.,;:!?]*~i';
public function __construct( public function __construct(
private RedisCacheService $redisCacheService, private RedisCacheService $redisCacheService,
private NostrClient $nostrClient, private NostrClient $nostrClient,
private TwigEnvironment $twig, private TwigEnvironment $twig,
private NostrKeyUtil $nostrKeyUtil private NostrKeyUtil $nostrKeyUtil
){} ) {}
/** /**
* @throws CommonMarkException * @throws CommonMarkException
*/ */
public function convertToHTML(string $markdown): string public function convertToHTML(string $markdown): string
{ {
// Check if the article has more than three headings $headingsCount = preg_match_all('/^#+\s.*$/m', $markdown);
// Match all headings (from level 1 to 6)
preg_match_all('/^#+\s.*$/m', $markdown, $matches);
$headingsCount = count($matches[0]);
// Configure the Environment with all the CommonMark parsers/renderers
$config = [ $config = [
'table_of_contents' => [ 'table_of_contents' => ['min_heading_level' => 1, 'max_heading_level' => 2],
'min_heading_level' => 1, 'heading_permalink' => ['symbol' => '§'],
'max_heading_level' => 2, 'autolink' => ['allowed_protocols' => ['https'], 'default_protocol' => 'https'],
], 'embed' => [
'heading_permalink' => [ 'adapter' => new OscaroteroEmbedAdapter(),
'symbol' => '§', 'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm', 'blossom.primal.net', 'i.nostr.build', 'video.nostr.build'],
], 'fallback' => 'link',
'autolink' => [
'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp']
'default_protocol' => 'https', // defaults to 'http'
],
'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below
'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm', 'blossom.primal.net', 'i.nostr.build', 'video.nostr.build'], // If null, all domains are allowed
'fallback' => 'link'
], ],
]; ];
$environment = new Environment($config);
// Add the extensions $env = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension()); $env->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new FootnoteExtension()); $env->addExtension(new FootnoteExtension());
$environment->addExtension(new TableExtension()); $env->addExtension(new TableExtension());
$environment->addExtension(new StrikethroughExtension()); $env->addExtension(new StrikethroughExtension());
$environment->addExtension(new SmartPunctExtension()); $env->addExtension(new SmartPunctExtension());
$environment->addExtension(new EmbedExtension()); $env->addExtension(new EmbedExtension());
$environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content'])); $env->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content']));
$environment->addExtension(new RawImageLinkExtension()); $env->addExtension(new RawImageLinkExtension());
$environment->addExtension(new AutolinkExtension()); $env->addExtension(new AutolinkExtension());
if ($headingsCount > 3) { if ($headingsCount > 3) {
$environment->addExtension(new HeadingPermalinkExtension()); $env->addExtension(new HeadingPermalinkExtension());
$environment->addExtension(new TableOfContentsExtension()); $env->addExtension(new TableOfContentsExtension());
} }
// Instantiate the converter engine and start converting some Markdown! $converter = new MarkdownConverter($env);
$converter = new MarkdownConverter($environment); $html = (string) $converter->convert(html_entity_decode($markdown));
$content = html_entity_decode($markdown);
$html = $converter->convert($content);
// Process nostr links after conversion to avoid re-processing HTML
return $this->processNostrLinks($html); return $this->processNostrLinks($html);
} }
private function processNostrLinks(string $content): string private function processNostrLinks(string $content): string
{ {
// Find all nostr: links // 1) Collect all nostr refs for batching (anchors + bare text)
preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $content, $matches); preg_match_all(self::RE_ALL_NOSTR, $content, $mAll);
if (empty($mAll[0])) {
if (empty($matches[0])) {
return $content; return $content;
} }
$links = array_unique($matches[0]); $uniqueLinks = array_values(array_unique($mAll[0]));
$replacements = []; [$eventIds, $pubkeyHexes] = $this->collectBatchKeys($uniqueLinks);
// Collect data for batching // 2) Batch fetch events (map: id => event)
$pubkeys = []; $eventsById = $this->fetchEventsById($eventIds, $pubkeyHexes);
$eventIds = [];
// 3) Batch fetch metadata (map: hex => profile)
$metadataByHex = $this->fetchMetadataByHex(array_keys($pubkeyHexes));
// 4) Replace anchors (inline by default, card if data-embed or class)
$content = $this->replaceNostrAnchors($content, $eventsById, $metadataByHex);
// 5) Replace bare text only in text nodes
$content = $this->replaceBareTextNostr($content, $eventsById, $metadataByHex);
return $content;
}
/** @return array{0: array<string,int>, 1: array<string,int>} [$eventIds, $pubkeyHexes] */
private function collectBatchKeys(array $links): array
{
$eventIds = []; // id => 1
$pubkeyHexes = []; // hex => 1
foreach ($links as $link) { foreach ($links as $link) {
$bechEncoded = substr($link, 6); // Remove "nostr:" $bech = substr($link, 6);
try { try {
$decoded = new Bech32($bechEncoded); $decoded = new Bech32($bech);
switch ($decoded->type) { switch ($decoded->type) {
case 'npub': case 'npub':
/** @var NPub $object */ $hex = $this->nostrKeyUtil->npubToHex($bech);
$object = $decoded->data; $pubkeyHexes[$hex] = 1;
$hex = $this->nostrKeyUtil->npubToHex($bechEncoded);
$pubkeys[$hex] = $bechEncoded;
break; break;
case 'nprofile': case 'nprofile':
/** @var NProfile $object */ /** @var NProfile $obj */
$object = $decoded->data; $obj = $decoded->data;
$pubkeys[$object->pubkey] = $this->nostrKeyUtil->hexToNpub($object->pubkey); $pubkeyHexes[$obj->pubkey] = 1;
break; break;
case 'note': case 'note':
/** @var Note $object */ /** @var Note $obj */
$object = $decoded->data; $obj = $decoded->data;
$eventIds[$object->data] = $bechEncoded; $eventIds[$obj->data] = 1;
break; break;
case 'nevent': case 'nevent':
/** @var NEvent $object */ /** @var NEvent $obj */
$object = $decoded->data; $obj = $decoded->data;
$eventIds[$object->id] = $bechEncoded; $eventIds[$obj->id] = 1;
break; break;
case 'naddr': case 'naddr':
// For naddr, we might need to fetch the event, but for now, handle as simple link // no prefetch for now
break; break;
} }
} catch (\Exception $e) { } catch (\Throwable) {
// Invalid link, skip // skip invalid
continue;
} }
} }
// Fetch events in batch return [$eventIds, $pubkeyHexes];
$events = []; }
if (!empty($eventIds)) {
try { /** @param array<string,int> $eventIds @param array<string,int> $pubkeyHexes @return array<string,object> */
$events = $this->nostrClient->getEventsByIds(array_keys($eventIds)); private function fetchEventsById(array $eventIds, array &$pubkeyHexes): array
} catch (\Exception $e) { {
// If batch fails, events remain empty $eventsById = [];
if (empty($eventIds)) {
return $eventsById;
}
try {
$list = $this->nostrClient->getEventsByIds(array_keys($eventIds));
foreach ($list as $event) {
// expect $event->id and $event->pubkey
if (!empty($event->id)) {
$eventsById[$event->id] = $event;
}
if (!empty($event->pubkey)) {
$pubkeyHexes[$event->pubkey] = 1;
}
} }
} catch (\Throwable) {
// swallow; fall back to simple links
} }
// Collect pubkeys from events for metadata fetching return $eventsById;
$eventPubkeys = []; }
foreach ($events as $event) {
$eventPubkeys[$event->pubkey] = true; /** @param string[] $hexes @return array<string, mixed|null> */
private function fetchMetadataByHex(array $hexes): array
{
if (empty($hexes)) {
return [];
} }
// Fetch metadata in batch $byHex = [];
$allHexes = array_unique(array_merge(array_keys($pubkeys), array_keys($eventPubkeys)));
$metadata = [];
try { try {
$fetchedMetadata = $this->redisCacheService->getMultipleMetadata($allHexes); $fetched = $this->redisCacheService->getMultipleMetadata($hexes);
foreach ($allHexes as $hex) { foreach ($hexes as $hex) {
$metadata[$hex] = $fetchedMetadata[$hex] ?? null; $byHex[$hex] = $fetched[$hex] ?? null;
} }
} catch (\Exception $e) { } catch (\Throwable) {
foreach ($allHexes as $hex) { foreach ($hexes as $hex) {
$metadata[$hex] = null; $byHex[$hex] = null;
} }
} }
// Now, render each link return $byHex;
foreach ($links as $link) { }
$bechEncoded = substr($link, 6);
/** Replace <a href="nostr:..."></a> with inline links by default (card if opted in) */
private function replaceNostrAnchors(string $content, array $eventsById, array $metadataByHex): string
{
return preg_replace_callback(self::RE_NOSTR_ANCHOR, function ($m) use ($eventsById, $metadataByHex) {
$nostrUrl = $m['nostr'];
$bech = substr($nostrUrl, 6);
$attrsAll = trim(($m['attrs'] ?? '') . ' ' . ($m['attrs2'] ?? ''));
$inner = $m['inner'];
// Inline by default for anchors
$preferInline = true;
// Opt-in to card if data-embed="1" or class contains "nostr-card" or "embed"
if (preg_match('~\bdata-embed\s*=\s*("1"|\'1\'|1)\b~i', $attrsAll) ||
preg_match('~\bclass\s*=\s*("|\')[^"\']*\b(nostr-card|embed)\b[^"\']*\1~i', $attrsAll)) {
$preferInline = false;
}
try { try {
$decoded = new Bech32($bechEncoded); $decoded = new Bech32($bech);
$html = $this->renderNostrLink($decoded, $bechEncoded, $metadata, $events); return $this->renderNostrLink($decoded, $bech, $metadataByHex, $eventsById, $inner, $preferInline);
$replacements[$link] = $html; } catch (\Throwable) {
} catch (\Exception $e) { return $m[0]; // keep original anchor on error
// Keep original link if error }
$replacements[$link] = $link; }, $content);
}
/** Replace bare-text nostr links in text nodes only */
private function replaceBareTextNostr(string $content, array $eventsById, array $metadataByHex): string
{
$parts = preg_split('~(<[^>]+>)~', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === false) {
return $content;
}
foreach ($parts as $i => $part) {
// Skip tags and empties
if ($part === '' || $part[0] === '<') {
continue;
} }
$parts[$i] = preg_replace_callback(self::RE_BARE_NOSTR, function ($mm) use ($eventsById, $metadataByHex) {
$nostrUrl = $mm[0];
$bech = substr($nostrUrl, 6);
try {
$decoded = new Bech32($bech);
// Bare text can render cards (preferInline = false)
return $this->renderNostrLink($decoded, $bech, $metadataByHex, $eventsById, null, false);
} catch (\Throwable) {
return $nostrUrl;
}
}, $part);
} }
// Replace in content return implode('', $parts);
return str_replace(array_keys($replacements), array_values($replacements), $content);
} }
private function renderNostrLink(Bech32 $decoded, string $bechEncoded, array $metadata, array $events): string /**
{ * Renders a single nostr reference to HTML.
* - $eventsById: event.id => event
* - $metadataByHex: authorHex => profile
* - $displayText: preserve original anchor text if provided
* - $preferInline: true for inline <a>, false to allow cards
*/
private function renderNostrLink(
Bech32 $decoded,
string $bechEncoded,
array $metadataByHex,
array $eventsById,
?string $displayText = null,
bool $preferInline = false
): string {
switch ($decoded->type) { switch ($decoded->type) {
case 'npub': case 'npub': {
$hex = $this->nostrKeyUtil->npubToHex($bechEncoded); $hex = $this->nostrKeyUtil->npubToHex($bechEncoded);
$profile = $metadata[$hex] ?? null; $profile = $metadataByHex[$hex] ?? null;
$label = $profile && isset($profile->name) ? $profile->name : $this->labelFromKey($bechEncoded); $label = $displayText !== null && $displayText !== ''
return '<a href="/p/' . $bechEncoded . '" class="nostr-mention">@' . htmlspecialchars($label) . '</a>'; ? $displayText
case 'nprofile': : (($profile->name ?? null) ?: $this->labelFromKey($bechEncoded));
/** @var NProfile $object */
$object = $decoded->data; return '<a href="/p/' . $this->e($bechEncoded) . '" class="nostr-mention">@' . $this->e($label) . '</a>';
$npub = $this->nostrKeyUtil->npubToHex($object->pubkey); }
$label = $this->labelFromKey($npub);
return '<a href="/p/' . $npub . '" class="nostr-mention">@' . htmlspecialchars($label) . '</a>'; case 'nprofile': {
case 'note': /** @var NProfile $obj */
$object = $decoded->data; $obj = $decoded->data;
$event = $events[$object->data] ?? null; $hex = $obj->pubkey;
if ($event && $event->kind === 20) { $npub = $this->nostrKeyUtil->hexToNpub($hex);
$pictureCardHtml = $this->twig->render('/event/_kind20_picture.html.twig', [ $profile = $metadataByHex[$hex] ?? null;
$label = $displayText !== null && $displayText !== ''
? $displayText
: (($profile->name ?? null) ?: $this->labelFromKey($npub));
return '<a href="/p/' . $this->e($npub) . '" class="nostr-mention">@' . $this->e($label) . '</a>';
}
case 'note': {
/** @var Note $obj */
$obj = $decoded->data;
$event = $eventsById[$obj->data] ?? null;
// Card only if allowed and kind 20 (picture)
if (!$preferInline && $event && (int) $event->kind === 20) {
return $this->twig->render('/event/_kind20_picture.html.twig', [
'event' => $event, 'event' => $event,
'embed' => true 'embed' => true,
]); ]);
return $pictureCardHtml;
} else {
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>';
} }
case 'nevent':
$object = $decoded->data; $text = $displayText !== null && $displayText !== '' ? $displayText : $bechEncoded;
$event = $events[$object->id] ?? null; return '<a href="/e/' . $this->e($bechEncoded) . '" class="nostr-link">' . $this->e($text) . '</a>';
if ($event) { }
$authorMetadata = $metadata[$event->pubkey] ?? null;
$eventCardHtml = $this->twig->render('components/event_card.html.twig', [ case 'nevent': {
'event' => $event, /** @var NEvent $obj */
'author' => $authorMetadata, $obj = $decoded->data;
'nevent' => $bechEncoded $event = $eventsById[$obj->id] ?? null;
]);
return $eventCardHtml; // Inline if requested (anchors) or if we don’t have event data
} else { if ($preferInline || !$event) {
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>'; $text = $displayText !== null && $displayText !== '' ? $displayText : $bechEncoded;
return '<a href="/e/' . $this->e($bechEncoded) . '" class="nostr-link">' . $this->e($text) . '</a>';
} }
case 'naddr':
/** @var NAddr $object */ // Otherwise render a rich card
$object = $decoded->data; $authorMeta = $metadataByHex[$event->pubkey] ?? null;
if ($object->kind === KindsEnum::LONGFORM->value) { return $this->twig->render('components/event_card.html.twig', [
return '<a href="/article/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>'; 'event' => $event,
} else { 'author' => $authorMeta,
return '<a href="/e/' . $bechEncoded . '" class="nostr-link">' . $bechEncoded . '</a>'; 'nevent' => $bechEncoded,
]);
}
case 'naddr': {
/** @var NAddr $obj */
$obj = $decoded->data;
$text = $displayText !== null && $displayText !== '' ? $displayText : $bechEncoded;
if ((int) $obj->kind === (int) KindsEnum::LONGFORM->value) {
return '<a href="/article/' . $this->e($bechEncoded) . '" class="nostr-link">' . $this->e($text) . '</a>';
} }
return '<a href="/e/' . $this->e($bechEncoded) . '" class="nostr-link">' . $this->e($text) . '</a>';
}
default: default:
return $bechEncoded; return $this->e($bechEncoded);
} }
} }
private function labelFromKey(string $npub): string private function labelFromKey(string $npub): string
{ {
$start = substr($npub, 0, 5); $start = substr($npub, 0, 5);
$end = substr($npub, -5); $end = substr($npub, -5);
return $start . '...' . $end; return $start . '...' . $end;
} }
private function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
} }

6
templates/pages/article.html.twig

@ -111,6 +111,12 @@
</div> </div>
{% if is_granted('ROLE_ADMIN') %}
<pre>
{{ article.raw|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
{% endif %}
<twig:Organisms:CommentForm <twig:Organisms:CommentForm
:publish_url="path('comment_publish')" :publish_url="path('comment_publish')"
:csrf_token="csrf_token('comment_publish')" :csrf_token="csrf_token('comment_publish')"

Loading…
Cancel
Save