From 5b2892b215669996802593f0e77d836d17f18b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Mon, 9 Dec 2024 19:53:21 +0100 Subject: [PATCH] Converting MD into HTML, setting up article view --- src/Controller/ArticleController.php | 62 +++++++ src/Util/Bech32/Bech32Decoder.php | 154 ++++++++++++++++++ src/Util/CommonMark/Converter.php | 74 +++++++++ .../NostrEventRenderer.php | 36 ++++ .../NostrSchemeExtension/NostrMentionLink.php | 29 ++++ .../NostrMentionParser.php | 50 ++++++ .../NostrMentionRenderer.php | 34 ++++ .../NostrRawNpubParser.php | 42 +++++ .../NostrSchemeExtension/NostrSchemeData.php | 56 +++++++ .../NostrSchemeExtension.php | 27 +++ .../NostrSchemeParser.php | 102 ++++++++++++ templates/pages/article.html.twig | 45 +++++ 12 files changed, 711 insertions(+) create mode 100644 src/Controller/ArticleController.php create mode 100644 src/Util/Bech32/Bech32Decoder.php create mode 100644 src/Util/CommonMark/Converter.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrMentionLink.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrMentionParser.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrMentionRenderer.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrSchemeData.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php create mode 100644 src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php create mode 100644 templates/pages/article.html.twig diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php new file mode 100644 index 0000000..b753339 --- /dev/null +++ b/src/Controller/ArticleController.php @@ -0,0 +1,62 @@ +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() + ]); + } + +} diff --git a/src/Util/Bech32/Bech32Decoder.php b/src/Util/Bech32/Bech32Decoder.php new file mode 100644 index 0000000..7205633 --- /dev/null +++ b/src/Util/Bech32/Bech32Decoder.php @@ -0,0 +1,154 @@ +> $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]; + } +} diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php new file mode 100644 index 0000000..e6e5e93 --- /dev/null +++ b/src/Util/CommonMark/Converter.php @@ -0,0 +1,74 @@ + [ + '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); + } + +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php new file mode 100644 index 0000000..9e6f804 --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php @@ -0,0 +1,36 @@ +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; + + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrMentionLink.php b/src/Util/CommonMark/NostrSchemeExtension/NostrMentionLink.php new file mode 100644 index 0000000..5d205b3 --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrMentionLink.php @@ -0,0 +1,29 @@ +label = $label; + $this->npub = $npubPart; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function getNpub(): string + { + return $this->npub; + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrMentionParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrMentionParser.php new file mode 100644 index 0000000..694f1da --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrMentionParser.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrMentionRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrMentionRenderer.php new file mode 100644 index 0000000..74f03fb --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrMentionRenderer.php @@ -0,0 +1,34 @@ +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 + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php new file mode 100644 index 0000000..03a71cd --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrRawNpubParser.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeData.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeData.php new file mode 100644 index 0000000..3408d0e --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeData.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php new file mode 100644 index 0000000..9fe4548 --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeExtension.php @@ -0,0 +1,27 @@ +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) + ; + } +} diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php new file mode 100644 index 0000000..8d9b3b2 --- /dev/null +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php @@ -0,0 +1,102 @@ +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; + } +} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig new file mode 100644 index 0000000..83ed74a --- /dev/null +++ b/templates/pages/article.html.twig @@ -0,0 +1,45 @@ +{% extends 'base.html.twig' %} + +{% block body %} +
+
+

{{ article.title }}

+
+ {% if author %} + + {% endif %} +
+
+
+ {{ article.summary }} +
+ + {% if article.image %} +
+ {{ article.title }} +
+ {% endif %} + +
+ {{ content|raw }} +
+ +
+
+ {% for tag in article.topics %} + {{ tag }} + {% endfor %} +
+ +
+{% endblock %}