Browse Source

render images and OG data inline

and profile pics to all userbadges
imwald
Silberengel 1 week ago
parent
commit
7fb17e5abf
  1. 66
      assets/styles/app.css
  2. 16
      assets/styles/article.css
  3. 16
      src/Service/NostrLinkParser.php
  4. 17
      src/Twig/Components/Molecules/UserFromNpub.php
  5. 31
      src/Util/CommonMark/Converter.php
  6. 3
      src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php
  7. 41
      src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php
  8. 72
      src/Util/PubkeyAvatarSvg.php
  9. 27
      templates/components/Molecules/UserFromNpub.html.twig
  10. 7
      templates/pages/article.html.twig

66
assets/styles/app.css

@ -513,6 +513,72 @@ footer a {
background-color: var(--color-secondary); 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 { .avatar {
width: 24px; /* Adjust the size as needed */ width: 24px; /* Adjust the size as needed */
height: 24px; /* Adjust the size as needed */ height: 24px; /* Adjust the size as needed */

16
assets/styles/article.css

@ -2,6 +2,13 @@
margin-top: 30px; 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 h2, .article-main h3,
.article-main h4, .article-main h5, .article-main h6 { .article-main h4, .article-main h5, .article-main h6 {
margin-top: 2em; margin-top: 2em;
@ -30,13 +37,20 @@
.byline { .byline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: center;
margin: 2rem 0; margin: 2rem 0;
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
font-size: 1rem; font-size: 1rem;
} }
.byline__author {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35em;
}
blockquote { blockquote {
border-left: 6px solid var(--color-bg-light); border-left: 6px solid var(--color-bg-light);
padding-left: 3px; padding-left: 3px;

16
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[] = [ $links[] = [
'type' => $nostrType ?? 'url', 'type' => $nostrType ?? 'url',
'identifier' => $nostrId, 'identifier' => $nostrId,
@ -69,6 +75,16 @@ readonly class NostrLinkParser
return $links; 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 private function parseBareNostrIdentifiers(string $content): array
{ {
$links = []; $links = [];

17
src/Twig/Components/Molecules/UserFromNpub.php

@ -3,30 +3,41 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Service\CacheService; use App\Service\CacheService;
use App\Util\PubkeyAvatarSvg;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
final class UserFromNpub final class UserFromNpub
{ {
public string $pubkey; public string $pubkey = '';
public string $npub; public string $npub;
public $user = null; public $user = null;
public string $fallbackSvg = '';
public function __construct(private readonly CacheService $cacheService) public function __construct(private readonly CacheService $cacheService)
{ {
} }
public function mount(string $ident): void 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')) { if (!str_starts_with($ident, 'npub')) {
$keys = new Key();
$this->pubkey = $ident; $this->pubkey = $ident;
$this->npub = $keys->convertPublicKeyToBech32($ident); $this->npub = $keys->convertPublicKeyToBech32($ident);
} else { } else {
$this->npub = $ident; $this->npub = $ident;
$this->pubkey = $keys->convertToHex($ident);
} }
$this->user = $this->cacheService->getMetadata($this->npub); $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);
} }
} }

31
src/Util/CommonMark/Converter.php

@ -50,8 +50,8 @@ readonly class Converter
'symbol' => '§', 'symbol' => '§',
], ],
'autolink' => [ 'autolink' => [
'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp'] 'allowed_protocols' => ['https', 'http'],
'default_protocol' => 'https', // defaults to 'http' 'default_protocol' => 'https',
], ],
'embed' => [ 'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below 'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below
@ -81,7 +81,32 @@ readonly class Converter
$converter = new MarkdownConverter($environment); $converter = new MarkdownConverter($environment);
$content = html_entity_decode($markdown); $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(
'#<a\b[^>]*\bhref="([^"]+)"[^>]*>\s*\1\s*</a>#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 '<img src="' . $safe . '" alt="" loading="lazy" decoding="async" />';
},
$html
);
} }
} }

3
src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php

@ -9,6 +9,7 @@ class RawImageLinkExtension implements ExtensionInterface
{ {
public function register(EnvironmentBuilderInterface $environment): void public function register(EnvironmentBuilderInterface $environment): void
{ {
$environment->addInlineParser(new RawImageLinkParser()); // UrlAutolinkParser uses default priority 0; run first so GIF/JPEG URLs become <img>, not <a>.
$environment->addInlineParser(new RawImageLinkParser(), 1000);
} }
} }

41
src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php

@ -1,34 +1,47 @@
<?php <?php
declare(strict_types=1);
namespace App\Util\CommonMark\ImagesExtension; namespace App\Util\CommonMark\ImagesExtension;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image; use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\SoftBreak; use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch; use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext; use League\CommonMark\Parser\InlineParserContext;
class RawImageLinkParser implements InlineParserInterface /**
* Bare image URLs (https://…/.png etc.) → inline &lt;img&gt;.
*
* Must register with priority &gt; UrlAutolinkParser (0) so we run first; only
* inline nodes may be appended (never wrap in Paragraph — that breaks the AST
* and lets Autolink turn the URL into a link instead).
*/
final class RawImageLinkParser implements InlineParserInterface
{ {
/** Same boundary rules as League\CommonMark\Extension\Autolink\UrlAutolinkParser */
private const ALLOWED_PREVIOUS = [null, ' ', "\t", "\n", "\x0b", "\x0c", "\r", '*', '_', '~', '('];
public function getMatchDefinition(): InlineParserMatch public function getMatchDefinition(): InlineParserMatch
{ {
// Match URLs ending with an image extension // Case-insensitive extension; optional query/hash (CDN, Tenor, etc.)
return InlineParserMatch::regex('https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?=\s|$)'); return InlineParserMatch::regex(
'(?i)https?:\/\/[^\s<>\[\]()"\']+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s<>\[\]()"\']*)?(?:#[^\s<>\[\]()"\']*)?(?=\s|$|[\])},;:!?\'"])'
);
} }
public function parse(InlineParserContext $inlineContext): bool public function parse(InlineParserContext $inlineContext): bool
{ {
$cursor = $inlineContext->getCursor(); $cursor = $inlineContext->getCursor();
if (! \in_array($cursor->peek(-1), self::ALLOWED_PREVIOUS, true)) {
return false;
}
$match = $inlineContext->getFullMatch(); $match = $inlineContext->getFullMatch();
// Create an <img> element instead of a text link $path = parse_url($match, PHP_URL_PATH);
$image = new Image($match, ''); $label = (\is_string($path) && $path !== '') ? basename($path) : '';
$paragraph = new Paragraph();
$paragraph->appendChild($image); $inlineContext->getContainer()->appendChild(new Image($match, $label));
$inlineContext->getContainer()->appendChild($paragraph); $cursor->advanceBy(\strlen($match));
// Advance the cursor to consume the matched part (important!)
$cursor->advanceBy(strlen($match));
return true; return true;
} }

72
src/Util/PubkeyAvatarSvg.php

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Util;
/**
* Deterministic soft avatar from a hex pubkey (or any hex-ish seed).
* Inline SVG uses theme CSS variables (brand, primary, surfaces).
*/
final class PubkeyAvatarSvg
{
public static function generate(string $hexSeed): string
{
$hex = strtolower(preg_replace('/[^0-9a-f]/', '', $hexSeed) ?? '');
if ($hex === '') {
$hex = hash('sha256', 'empty', false);
}
$bin = hex2bin(\strlen($hex) >= 64 ? substr($hex, 0, 64) : hash('sha256', $hex, false));
if ($bin === false || $bin === '') {
$bin = hash('sha256', $hex, true);
}
/** @var list<int> $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 = '<circle cx="32" cy="32" r="30" fill="none" stroke="var(--color-border)" stroke-opacity="' . $rop . '" stroke-width="1"/>';
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" class="pubkey-avatar-svg" aria-hidden="true" focusable="false">'
. '<defs><linearGradient id="pubkey-av-' . $id . '" x1="0%" y1="0%" x2="100%" y2="100%">'
. '<stop offset="0%" stop-color="var(--color-bg)" stop-opacity="0.94"/>'
. '<stop offset="100%" stop-color="var(--color-bg-light)" stop-opacity="1"/>'
. '</linearGradient></defs>'
. '<circle cx="32" cy="32" r="32" fill="url(#pubkey-av-' . $id . ')"/>'
. '<g transform="rotate(' . $rot . ' 32 32)">'
. '<circle cx="' . $cx1 . '" cy="' . $cy1 . '" r="' . $r1 . '" fill="var(--brand-color)" opacity="' . $o1 . '"/>'
. '<circle cx="' . $cx2 . '" cy="' . $cy2 . '" r="' . $r2 . '" fill="var(--color-secondary)" opacity="' . $o2 . '"/>'
. '<circle cx="' . $cx3 . '" cy="' . $cy3 . '" r="' . $r3 . '" fill="var(--color-primary)" opacity="' . $o3 . '"/>'
. '</g>'
. $ring
. '</svg>';
}
}

27
templates/components/Molecules/UserFromNpub.html.twig

@ -1,5 +1,26 @@
{% set avatar_url = null %}
{% if user %} {% if user %}
<a href="{{ path('author-profile', { npub: npub })}}"><twig:Atoms:NameOrNpub :author="user" :npub="npub"/></a> {% if user.picture is defined and user.picture %}
{% else %} {% set avatar_url = user.picture %}
<a href="{{ path('author-profile', { npub: npub })}}"><span>{{ npub|shortenNpub }}</span></a> {% elseif user.image is defined and user.image %}
{% set avatar_url = user.image %}
{% endif %}
{% endif %} {% endif %}
<a href="{{ path('author-profile', { npub: npub }) }}" class="user-badge">
{% if avatar_url %}
<span class="user-badge__avatar user-badge__avatar--wrap" aria-hidden="true">
<img class="user-badge__avatar-img" src="{{ avatar_url }}" alt="" loading="lazy" decoding="async" onerror="this.classList.add('is-broken')" />
<span class="user-badge__avatar--generated user-badge__avatar-fallback">{{ fallbackSvg|raw }}</span>
</span>
{% else %}
<span class="user-badge__avatar user-badge__avatar--generated" aria-hidden="true">{{ fallbackSvg|raw }}</span>
{% endif %}
<span class="user-badge__name">
{% if user %}
<twig:Atoms:NameOrNpub :author="user" :npub="npub" />
{% else %}
{{ npub|shortenNpub }}
{% endif %}
</span>
</a>

7
templates/pages/article.html.twig

@ -28,10 +28,9 @@
</div> </div>
{% if author %} {% if author %}
<div class="byline"> <div class="byline">
<span> <span class="byline__author">
{{ 'text.byline'|trans }} <a href="{{ path('author-redirect', {'pubkey': article.pubkey}) }}"> {{ 'text.byline'|trans }}
<twig:Atoms:NameOrNpub :author="author" :npub="npub" /> <twig:Molecules:UserFromNpub ident="{{ article.pubkey }}" />
</a>
</span> </span>
<span> <span>
{% if article.publishedAt is not null %} {% if article.publishedAt is not null %}

Loading…
Cancel
Save