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 %}
+
+
+ {{ fallbackSvg|raw }}
+
+ {% else %}
+ {{ fallbackSvg|raw }}
+ {% 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 %}
-
- {{ 'text.byline'|trans }}
-
-
+
+ {{ 'text.byline'|trans }}
+
{% if article.publishedAt is not null %}