Browse Source

Converting MD into HTML, setting up article view

imwald
Nuša Pukšič 1 year ago
parent
commit
5b2892b215
  1. 62
      src/Controller/ArticleController.php
  2. 154
      src/Util/Bech32/Bech32Decoder.php
  3. 74
      src/Util/CommonMark/Converter.php
  4. 36
      src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php
  5. 29
      src/Util/CommonMark/NostrSchemeExtension/NostrMentionLink.php
  6. 50
      src/Util/CommonMark/NostrSchemeExtension/NostrMentionParser.php
  7. 34
      src/Util/CommonMark/NostrSchemeExtension/NostrMentionRenderer.php
  8. 42
      src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php
  9. 56
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeData.php
  10. 27
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php
  11. 102
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php
  12. 45
      templates/pages/article.html.twig

62
src/Controller/ArticleController.php

@ -0,0 +1,62 @@
<?php
namespace App\Controller;
use App\Entity\Article;
use App\Entity\User;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ArticleController extends AbstractController
{
/**
* @throws InvalidArgumentException
*/
#[Route('/article/d/{slug}', name: 'article-slug')]
public function article(EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache,
Converter $converter, $slug): Response
{
$article = null;
// check if an item with same eventId already exists in the db
$repository = $entityManager->getRepository(Article::class);
$articles = $repository->findBy(['slug' => $slug]);
$revisions = count($repository->findBy(['slug' => $slug]));
if ($revisions > 1) {
// sort articles by created at date
usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
// get the last article
$article = end($articles);
} else {
$article = $articles[0];
}
if (!$article) {
throw $this->createNotFoundException('The article does not exist');
}
$cacheKey = 'article_' . $article->getId();
$cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent()));
$articlesCache->save($cacheItem);
}
// find user by npub
$author = $entityManager->getRepository(User::class)->findOneBy(['npub' => $article->getPubkey()]);
return $this->render('Pages/article.html.twig', [
'article' => $article,
'author' => $author,
'content' => $cacheItem->get()
]);
}
}

154
src/Util/Bech32/Bech32Decoder.php

@ -0,0 +1,154 @@
<?php
namespace App\Util\Bech32;
use Exception;
class Bech32Decoder
{
// Function to decode Bech32 without character length restriction
/**
* @throws Exception
*/
public function decodeBech32(string $bech32): array
{
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
// Find the separator (1)
$pos = strrpos($bech32, '1');
if ($pos === false) {
throw new Exception('Invalid Bech32 string');
}
// Extract human-readable part (HRP)
$hrp = substr($bech32, 0, $pos);
// Extract data part
$data_part = substr($bech32, $pos + 1);
$data = [];
for ($i = 0; $i < strlen($data_part); $i++) {
$data[] = strpos($charset, $data_part[$i]);
if ($data[$i] === false) {
throw new Exception('Invalid character in Bech32 string');
}
}
return [$hrp, $data];
}
// Function to convert 5-bit data to 8-bit data
public function convertBits(array $data, int $fromBits, int $toBits, bool $pad = true): array
{
$acc = 0;
$bits = 0;
$ret = [];
$maxv = (1 << $toBits) - 1;
$max_acc = (1 << ($fromBits + $toBits - 1)) - 1;
foreach ($data as $value) {
if ($value < 0 || $value >> $fromBits) {
throw new Exception('Invalid value in data');
}
$acc = (($acc << $fromBits) | $value) & $max_acc;
$bits += $fromBits;
while ($bits >= $toBits) {
$bits -= $toBits;
$ret[] = ($acc >> $bits) & $maxv;
}
}
if ($pad) {
if ($bits > 0) {
$ret[] = ($acc << ($toBits - $bits)) & $maxv;
}
} else if ($bits >= $fromBits || (($acc << ($toBits - $bits)) & $maxv)) {
throw new Exception('Invalid padding');
}
return $ret;
}
// Public method to decode a Nostr Bech32 string and return hex
public function decodeNostrBech32ToHex(string $bech32): string
{
list($hrp, $data) = $this->decodeBech32($bech32);
// Convert 5-bit data back to 8-bit data
$decodedData = $this->convertBits($data, 5, 8, false);
// Return the decoded data as a hex string
return bin2hex(pack('C*', ...$decodedData));
}
// Public method to decode a Nostr Bech32 string and return the binary data
/**
* @throws Exception
*/
public function decodeNostrBech32ToBinary(string $bech32): array
{
list($hrp, $data) = $this->decodeBech32($bech32);
// Convert 5-bit data to 8-bit data
$decodedData = $this->convertBits($data, 5, 8);
return [$hrp, $decodedData];
}
// Public method to parse the binary data into TLV format
public function parseTLV(array $binaryData): array
{
$parsedTLVs = [];
$offset = 0;
while ($offset < count($binaryData)) {
if ($offset + 1 >= count($binaryData)) {
throw new Exception("Incomplete TLV data");
}
// Read the Type (T) and Length (L)
$type = $binaryData[$offset];
$length = $binaryData[$offset + 1];
$offset += 2;
// Ensure we have enough data for the value
if ($offset + $length > count($binaryData)) {
break;
} else {
// Extract the Value (V)
$value = array_slice($binaryData, $offset, $length);
}
$offset += $length;
// Add the TLV to the parsed array
$parsedTLVs[] = [
'type' => $type,
'length' => $length,
'value' => $value,
];
}
return $parsedTLVs;
}
// Decode and parse a Bech32 string
/**
* @throws Exception
*/
public function decodeAndParseNostrBech32(string $bech32): array
{
// Step 1: Decode Bech32 to binary data
list($hrp, $binaryData) = $this->decodeNostrBech32ToBinary($bech32);
if ($hrp == 'npub') {
return [$hrp, $binaryData];
}
// Step 2: Parse the binary data into TLV format
$tlvData = $this->parseTLV($binaryData);
return [$hrp, $tlvData];
}
}

74
src/Util/CommonMark/Converter.php

@ -0,0 +1,74 @@
<?php
namespace App\Util\CommonMark;
use App\Util\Bech32\Bech32Decoder;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Exception\CommonMarkException;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Footnote\FootnoteExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension;
use League\CommonMark\MarkdownConverter;
class Converter
{
public function __construct(private readonly Bech32Decoder $bech32Decoder)
{
}
/**
* @throws CommonMarkException
*/
public function convertToHTML(string $markdown): string
{
// Check if the article has more than three headings
// 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 = [
'table_of_contents' => [
'min_heading_level' => 1,
'max_heading_level' => 2,
],
'heading_permalink' => [
'symbol' => '§',
],
'autolink' => [
'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp']
'default_protocol' => 'https', // defaults to 'http'
],
];
$environment = new Environment($config);
// Add the extensions
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new FootnoteExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new StrikethroughExtension());
// create a custom extension, that handles nostr mentions
$environment->addExtension(new NostrSchemeExtension($this->bech32Decoder));
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new AutolinkExtension());
if ($headingsCount > 3) {
$environment->addExtension(new HeadingPermalinkExtension());
$environment->addExtension(new TableOfContentsExtension());
}
// Instantiate the converter engine and start converting some Markdown!
$converter = new MarkdownConverter($environment);
$content = html_entity_decode($markdown);
dump($content);
return $converter->convert($content);
}
}

36
src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php

@ -0,0 +1,36 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
class NostrEventRenderer implements NodeRendererInterface
{
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (!($node instanceof NostrSchemeData)) {
throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node));
}
if ($node->getType() === 'nevent') {
// Construct the local link URL from the special part
$url = '/e/' . $node->getSpecial();
} else if ($node->getType() === 'naddr') {
dump($node);
// Construct the local link URL from the special part
$url = '/' . $node->getSpecial();
}
if (isset($url)) {
// Create the anchor element
return new HtmlElement('a', ['href' => $url], '@' . $node->getSpecial());
}
return false;
}
}

29
src/Util/CommonMark/NostrSchemeExtension/NostrMentionLink.php

@ -0,0 +1,29 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Node\Inline\AbstractInline;
class NostrMentionLink extends AbstractInline
{
private ?string $label;
private string $npub;
public function __construct(?string $label, string $npubPart)
{
parent::__construct();
$this->label = $label;
$this->npub = $npubPart;
}
public function getLabel(): ?string
{
return $this->label;
}
public function getNpub(): string
{
return $this->npub;
}
}

50
src/Util/CommonMark/NostrSchemeExtension/NostrMentionParser.php

@ -0,0 +1,50 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use swentel\nostr\Key\Key;
/**
* Class NostrMentionParser
* Looks for links that look like Markdown links in the format `[label](url)`,
* but have npub1XXXX instead of a URL
* @package App\Util\CommonMark
*/
class NostrMentionParser implements InlineParserInterface
{
public function getMatchDefinition(): InlineParserMatch
{
// Define a match for a markdown link-like structure with "npub" links
return InlineParserMatch::regex('\[([^\]]+)\]\(npub1[0-9a-zA-Z]+\)');
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
// Get the match and extract relevant parts
$matches = $inlineContext->getMatches();
// The entire match is like "[label](npubXXXX)", now we need to extract "label" and "npubXXXX"
$fullMatch = $matches[0]; // Full matched string like "[label](npubXXXX)"
$label = $matches[1]; // This is the text between the square brackets: "label"
// Extract "npub" part from fullMatch
$npubLink = substr($fullMatch, strpos($fullMatch, 'npub1'), -1); // e.g., "npubXXXX"
$npubPart = substr($npubLink, 5); // Extract the part after "npub1", i.e., "XXXX"
$key = new Key();
$hex = $key->convertToHex($npubLink);
// Create a new inline node for the custom link
$inlineContext->getContainer()->appendChild(new NostrMentionLink($label, $hex));
// Advance the cursor to consume the matched part (important!)
$cursor->advanceBy(strlen($fullMatch));
return true;
}
}

34
src/Util/CommonMark/NostrSchemeExtension/NostrMentionRenderer.php

@ -0,0 +1,34 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\HtmlElement;
class NostrMentionRenderer implements NodeRendererInterface
{
public function render(Node $node, ChildNodeRendererInterface $childRenderer): HtmlElement
{
if (!($node instanceof NostrMentionLink)) {
throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node));
}
$label = $node->getLabel() ?? $this->labelFromKey($node->getNpub());
// Construct the local link URL from the npub part
$url = '/p/' . $node->getNpub();
// Create the anchor element
return new HtmlElement('a', ['href' => $url], '@' . $label);
}
private function labelFromKey($npub): string
{
$start = substr($npub, 0, 5); // First 5 characters
$end = substr($npub, -5); // Last 5 characters
return $start . '...' . $end; // Concatenate with ellipsis
}
}

42
src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php

@ -0,0 +1,42 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use swentel\nostr\Key\Key;
/**
* Class NostrRawNpubParser
* Looks for raw nostr mentions formatted as npub1XXXX
*/
class NostrRawNpubParser implements InlineParserInterface
{
public function __construct()
{
}
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('npub1[0-9a-zA-Z]+');
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
// Get the match and extract relevant parts
$fullMatch = $inlineContext->getFullMatch();
$key = new Key();
$hex = $key->convertToHex($fullMatch);
// Create a new inline node for the custom link
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $hex));
// Advance the cursor to consume the matched part (important!)
$cursor->advanceBy(strlen($fullMatch));
return true;
}
}

56
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeData.php

@ -0,0 +1,56 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use League\CommonMark\Node\Inline\AbstractInline;
/**
* Class NostrSchemeData
* NIP-19 bech32-encoded entities
*
* @package App\Util\CommonMark
*/
class NostrSchemeData extends AbstractInline
{
private $type;
private $special;
private $relays;
private $author;
private $kind;
public function __construct($type, $special, $relays, $author, $kind)
{
parent::__construct();
$this->type = $type;
$this->special = $special;
$this->relays = $relays;
$this->author = $author;
$this->kind = $kind;
}
public function getType()
{
return $this->type;
}
public function getSpecial()
{
return $this->special;
}
public function getRelays()
{
return $this->relays;
}
public function getAuthor()
{
return $this->author;
}
public function getKind()
{
return $this->kind;
}
}

27
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php

@ -0,0 +1,27 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Util\Bech32\Bech32Decoder;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
class NostrSchemeExtension implements ExtensionInterface
{
public function __construct(private readonly Bech32Decoder $bech32Decoder)
{
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment
->addInlineParser(new NostrMentionParser(), 200)
->addInlineParser(new NostrSchemeParser($this->bech32Decoder), 199)
->addInlineParser(new NostrRawNpubParser(), 198)
->addRenderer(NostrSchemeData::class, new NostrEventRenderer(), 2)
->addRenderer(NostrMentionLink::class, new NostrMentionRenderer(), 1)
;
}
}

102
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php

@ -0,0 +1,102 @@
<?php
namespace App\Util\CommonMark\NostrSchemeExtension;
use App\Util\Bech32\Bech32Decoder;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use function BitWasp\Bech32\convertBits;
use function BitWasp\Bech32\decode;
class NostrSchemeParser implements InlineParserInterface
{
public function __construct(private Bech32Decoder $bech32Decoder)
{
}
public function getMatchDefinition(): InlineParserMatch
{
return InlineParserMatch::regex('nostr:[0-9a-zA-Z]+');
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
// Get the match and extract relevant parts
$fullMatch = $inlineContext->getFullMatch();
// The match is a Bech32 encoded string
// decode it to get the parts
$bechEncoded = substr($fullMatch, 6); // Extract the part after "nostr:", i.e., "XXXX"
dump($bechEncoded);
try {
list($hrp, $tlv) = $this->bech32Decoder->decodeAndParseNostrBech32($bechEncoded);
dump($hrp);
dump($tlv);
switch ($hrp) {
case 'npub':
$str = '';
list($hrp, $data) = decode($bechEncoded);
$bytes = convertBits($data, count($data), 5, 8, false);
foreach ($bytes as $item) {
$str .= str_pad(dechex($item), 2, '0', STR_PAD_LEFT);
}
$npubPart = $str;
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $npubPart));
break;
case 'nprofile':
$type = 0; // npub
foreach ($tlv as $item) {
if ($item['type'] === $type) {
// from array of integers to string
$str = '';
foreach ($item['value'] as $byte) {
$str .= str_pad(dechex($byte), 2, '0', STR_PAD_LEFT);
}
$npubPart = $str;
break;
}
}
if (isset($npubPart)) {
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $npubPart));
}
break;
case 'nevent':
foreach ($tlv as $item) {
// event id
if ($item['type'] === 0) {
$eventId = implode('', array_map(fn($byte) => sprintf('%02x', $byte), $item['value']));
break;
}
// relays
if ($item['type'] === 1) {
$relays[] = implode('', array_map('chr', $item['value']));
}
}
dump($relays ?? null);
// TODO also potentially contains relays, author, and kind
$inlineContext->getContainer()->appendChild(new NostrSchemeData('nevent', $eventId, $relays ?? null, null, null));
break;
case 'naddr':
$inlineContext->getContainer()->appendChild(new NostrSchemeData('naddr', $bechEncoded, null, null, null));
break;
case 'nrelay':
// deprecated
default:
return false;
}
} catch (\Exception $e) {
dump($e->getMessage());
return false;
}
// Advance the cursor to consume the matched part (important!)
$cursor->advanceBy(strlen($fullMatch));
return true;
}
}

45
templates/pages/article.html.twig

@ -0,0 +1,45 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="card">
<div class="card-header">
<h1 class="card-title">{{ article.title }}</h1>
</div>
{% if author %}
<div class="byline">
<span>
{{ 'text.byline'|trans }} <a href="{{ path('author', {'npub': author.npub}) }}">
<twig:NameOrNpub displayName="{{ author.displayName }}" name="{{ author.name }}" npub="{{ author.npub }}" />
</a>
</span>
<span>
<small><twig:ux:icon name="heroicons:pencil" class="icon" /> {{ article.createdAt|date('F j, Y') }}</small><br>
{% if article.publishedAt is not null %}<small>{{ article.publishedAt|date('F j, Y') }}</small>{% endif %}
</span>
</div>
{% endif %}
</div>
<div class="card-body">
<div class="lede">
{{ article.summary }}
</div>
{% if article.image %}
<div class="article__image">
<img src="{{ article.image }}" alt="{{ article.title }}">
</div>
{% endif %}
<div class="article-main">
{{ content|raw }}
</div>
<hr class="divider" />
<div class="tags">
{% for tag in article.topics %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endblock %}
Loading…
Cancel
Save