From 7fb17e5abfedd36e32649517100f12d31ea38a8e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 21 Apr 2026 10:19:29 +0200 Subject: [PATCH] render images and OG data inline and profile pics to all userbadges --- assets/styles/app.css | 66 +++++++++++++++++ assets/styles/article.css | 16 ++++- src/Service/NostrLinkParser.php | 16 +++++ .../Components/Molecules/UserFromNpub.php | 17 ++++- src/Util/CommonMark/Converter.php | 31 +++++++- .../ImagesExtension/RawImageLinkExtension.php | 3 +- .../ImagesExtension/RawImageLinkParser.php | 41 +++++++---- src/Util/PubkeyAvatarSvg.php | 72 +++++++++++++++++++ .../Molecules/UserFromNpub.html.twig | 27 ++++++- templates/pages/article.html.twig | 7 +- 10 files changed, 267 insertions(+), 29 deletions(-) create mode 100644 src/Util/PubkeyAvatarSvg.php diff --git a/assets/styles/app.css b/assets/styles/app.css index cf9cc2f..979e5e0 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -513,6 +513,72 @@ footer a { background-color: var(--color-secondary); } +/* Author link + small Nostr profile picture (NIP-01 `picture`) */ +.user-badge { + display: inline-flex; + align-items: center; + gap: 0.4em; + vertical-align: middle; + text-decoration: none; +} + +.user-badge:hover { + text-decoration: underline; +} + +.user-badge__avatar { + display: inline-block; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + box-shadow: 0 0 0 1px var(--color-border); +} + +.user-badge__avatar--wrap { + position: relative; + vertical-align: middle; +} + +.user-badge__avatar-img { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + max-width: none; + object-fit: cover; + object-position: center; + display: block; + border-radius: 50%; +} + +.user-badge__avatar-img.is-broken { + display: none; +} + +.user-badge__avatar-fallback { + position: absolute; + inset: 0; + z-index: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + overflow: hidden; +} + +.user-badge__avatar--generated .pubkey-avatar-svg, +.user-badge__avatar-fallback .pubkey-avatar-svg { + width: 100%; + height: 100%; + display: block; +} + +.user-badge__name { + line-height: 1.2; +} + .avatar { width: 24px; /* Adjust the size as needed */ height: 24px; /* Adjust the size as needed */ diff --git a/assets/styles/article.css b/assets/styles/article.css index 856ff1d..ce7a8d9 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -2,6 +2,13 @@ margin-top: 30px; } +.article-main img { + max-width: 100%; + height: auto; + display: block; + margin: 1.25rem auto; +} + .article-main h2, .article-main h3, .article-main h4, .article-main h5, .article-main h6 { margin-top: 2em; @@ -30,13 +37,20 @@ .byline { display: flex; justify-content: space-between; - align-items: baseline; + align-items: center; margin: 2rem 0; padding-top: 0.5rem; border-top: 1px solid var(--color-border); font-size: 1rem; } +.byline__author { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35em; +} + blockquote { border-left: 6px solid var(--color-bg-light); padding-left: 3px; diff --git a/src/Service/NostrLinkParser.php b/src/Service/NostrLinkParser.php index dab92ab..4ab8c1d 100644 --- a/src/Service/NostrLinkParser.php +++ b/src/Service/NostrLinkParser.php @@ -56,6 +56,12 @@ readonly class NostrLinkParser ]); } } + + // Inline image URLs are already rendered in the body; skip OG/footer preview for them. + if ($nostrId === null && $this->isDirectImageUrl($url)) { + continue; + } + $links[] = [ 'type' => $nostrType ?? 'url', 'identifier' => $nostrId, @@ -69,6 +75,16 @@ readonly class NostrLinkParser return $links; } + private function isDirectImageUrl(string $url): bool + { + // Ends in image extension, or CDN style `…/name.jpg/…` (thumb, width, etc.) + if (1 === preg_match('~\.(?:jpe?g|png|gif|webp|avif)(?:\?[^#]*)?(?:#.*)?$~i', $url)) { + return true; + } + + return 1 === preg_match('~\.(?:jpe?g|png|gif|webp|avif)/~i', $url); + } + private function parseBareNostrIdentifiers(string $content): array { $links = []; diff --git a/src/Twig/Components/Molecules/UserFromNpub.php b/src/Twig/Components/Molecules/UserFromNpub.php index b0c629a..0497b31 100644 --- a/src/Twig/Components/Molecules/UserFromNpub.php +++ b/src/Twig/Components/Molecules/UserFromNpub.php @@ -3,30 +3,41 @@ namespace App\Twig\Components\Molecules; use App\Service\CacheService; +use App\Util\PubkeyAvatarSvg; use swentel\nostr\Key\Key; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class UserFromNpub { - public string $pubkey; + public string $pubkey = ''; + public string $npub; + public $user = null; + public string $fallbackSvg = ''; + public function __construct(private readonly CacheService $cacheService) { } public function mount(string $ident): void { - // if npub doesn't start with 'npub' then assume it's a hex pubkey + $keys = new Key(); if (!str_starts_with($ident, 'npub')) { - $keys = new Key(); $this->pubkey = $ident; $this->npub = $keys->convertPublicKeyToBech32($ident); } else { $this->npub = $ident; + $this->pubkey = $keys->convertToHex($ident); } + $this->user = $this->cacheService->getMetadata($this->npub); + + $seed = (\strlen($this->pubkey) === 64 && ctype_xdigit($this->pubkey)) + ? $this->pubkey + : hash('sha256', $this->npub, false); + $this->fallbackSvg = PubkeyAvatarSvg::generate($seed); } } diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php index 3c1284e..924e915 100644 --- a/src/Util/CommonMark/Converter.php +++ b/src/Util/CommonMark/Converter.php @@ -50,8 +50,8 @@ readonly class Converter 'symbol' => '§', ], 'autolink' => [ - 'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp'] - 'default_protocol' => 'https', // defaults to 'http' + 'allowed_protocols' => ['https', 'http'], + 'default_protocol' => 'https', ], 'embed' => [ 'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below @@ -81,7 +81,32 @@ readonly class Converter $converter = new MarkdownConverter($environment); $content = html_entity_decode($markdown); - return $converter->convert($content); + $html = (string) $converter->convert($content); + + return $this->rewriteAutolinkedImageLinks($html); + } + + /** + * UrlAutolinkParser turns bare URLs into <a href="U">U</a>. If U is an image URL, show an img. + * Covers cached HTML from before RawImageLinkParser priority fix and edge cases where autolink still wins. + */ + private function rewriteAutolinkedImageLinks(string $html): string + { + return (string) preg_replace_callback( + '#]*\bhref="([^"]+)"[^>]*>\s*\1\s*#i', + static function (array $m): string { + $url = $m[1]; + if (preg_match('~\.(?:jpe?g|png|gif|webp|avif)(?:\?[^#]*)?(?:#.*)?$~i', $url) !== 1) { + return $m[0]; + } + + $safe = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return ''; + }, + $html + ); } } + diff --git a/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php b/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php index 049b247..218f819 100644 --- a/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php +++ b/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php @@ -9,6 +9,7 @@ class RawImageLinkExtension implements ExtensionInterface { public function register(EnvironmentBuilderInterface $environment): void { - $environment->addInlineParser(new RawImageLinkParser()); + // UrlAutolinkParser uses default priority 0; run first so GIF/JPEG URLs become , not . + $environment->addInlineParser(new RawImageLinkParser(), 1000); } } diff --git a/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php b/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php index ad89224..4439082 100644 --- a/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php +++ b/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php @@ -1,34 +1,47 @@ \[\]()"\']+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s<>\[\]()"\']*)?(?:#[^\s<>\[\]()"\']*)?(?=\s|$|[\])},;:!?\'"])' + ); } public function parse(InlineParserContext $inlineContext): bool { $cursor = $inlineContext->getCursor(); + if (! \in_array($cursor->peek(-1), self::ALLOWED_PREVIOUS, true)) { + return false; + } + $match = $inlineContext->getFullMatch(); - // Create an element instead of a text link - $image = new Image($match, ''); - $paragraph = new Paragraph(); - $paragraph->appendChild($image); - $inlineContext->getContainer()->appendChild($paragraph); - - // Advance the cursor to consume the matched part (important!) - $cursor->advanceBy(strlen($match)); + $path = parse_url($match, PHP_URL_PATH); + $label = (\is_string($path) && $path !== '') ? basename($path) : ''; + + $inlineContext->getContainer()->appendChild(new Image($match, $label)); + $cursor->advanceBy(\strlen($match)); return true; } diff --git a/src/Util/PubkeyAvatarSvg.php b/src/Util/PubkeyAvatarSvg.php new file mode 100644 index 0000000..2cee0d7 --- /dev/null +++ b/src/Util/PubkeyAvatarSvg.php @@ -0,0 +1,72 @@ += 64 ? substr($hex, 0, 64) : hash('sha256', $hex, false)); + if ($bin === false || $bin === '') { + $bin = hash('sha256', $hex, true); + } + + /** @var list $b */ + $b = array_values(unpack('C*', substr($bin, 0, 32))); + while (\count($b) < 32) { + $b[] = 0; + } + + $e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $id = $e(substr($hex, 0, 16)); + + $cx1 = 8 + ($b[0] % 40); + $cy1 = 6 + ($b[1] % 36); + $r1 = 22 + ($b[2] % 14); + $o1 = $e((string) round(0.22 + ($b[3] % 18) / 100, 3)); + + $cx2 = 18 + ($b[4] % 38); + $cy2 = 20 + ($b[5] % 32); + $r2 = 18 + ($b[6] % 12); + $o2 = $e((string) round(0.18 + ($b[7] % 20) / 100, 3)); + + $cx3 = 28 + ($b[8] % 30); + $cy3 = 28 + ($b[9] % 28); + $r3 = 14 + ($b[10] % 10); + $o3 = $e((string) round(0.28 + ($b[11] % 22) / 100, 3)); + + $rot = ($b[12] << 8 | $b[13]) % 140 - 70; + + $ring = ''; + if (($b[14] & 1) === 1) { + $rop = $e((string) round(0.12 + ($b[15] % 10) / 100, 3)); + $ring = ''; + } + + return ''; + } +} diff --git a/templates/components/Molecules/UserFromNpub.html.twig b/templates/components/Molecules/UserFromNpub.html.twig index 2f48145..5e35d64 100644 --- a/templates/components/Molecules/UserFromNpub.html.twig +++ b/templates/components/Molecules/UserFromNpub.html.twig @@ -1,5 +1,26 @@ +{% set avatar_url = null %} {% if user %} - -{% else %} - {{ npub|shortenNpub }} + {% if user.picture is defined and user.picture %} + {% set avatar_url = user.picture %} + {% elseif user.image is defined and user.image %} + {% set avatar_url = user.image %} + {% endif %} {% endif %} + + + {% if avatar_url %} + + {% else %} + + {% endif %} + + {% if user %} + + {% else %} + {{ npub|shortenNpub }} + {% endif %} + + diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 7306b68..92c2c91 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -28,10 +28,9 @@ {% if author %}