From 8c82ece88b2b53597a69aac9a00da13de5c36421 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 13 Apr 2026 02:25:08 +0100 Subject: [PATCH] Add GitHub-style markdown alert blocks to CommonMark renderer Support [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], and [!CAUTION] blockquote syntax with styled alert rendering, SVG icons, and gradient backgrounds matching the existing aside design. Convert security docs page asides to use the new alert syntax. Co-Authored-By: Claude Opus 4.6 --- .../CommonMark/AlertBlockQuoteRenderer.php | 101 +++++++++++++++ app/Support/CommonMark/CommonMark.php | 2 + package-lock.json | 2 +- resources/css/app.css | 102 +++++++++++++++ .../views/docs/mobile/3/concepts/security.md | 33 ++--- tests/Feature/AlertBlockQuoteRendererTest.php | 116 ++++++++++++++++++ 6 files changed, 334 insertions(+), 22 deletions(-) create mode 100644 app/Support/CommonMark/AlertBlockQuoteRenderer.php create mode 100644 tests/Feature/AlertBlockQuoteRendererTest.php diff --git a/app/Support/CommonMark/AlertBlockQuoteRenderer.php b/app/Support/CommonMark/AlertBlockQuoteRenderer.php new file mode 100644 index 00000000..942f416a --- /dev/null +++ b/app/Support/CommonMark/AlertBlockQuoteRenderer.php @@ -0,0 +1,101 @@ + + */ + protected const ALERT_TYPES = [ + 'NOTE' => [ + 'label' => 'Note', + 'icon' => '', + ], + 'TIP' => [ + 'label' => 'Tip', + 'icon' => '', + ], + 'IMPORTANT' => [ + 'label' => 'Important', + 'icon' => '', + ], + 'WARNING' => [ + 'label' => 'Warning', + 'icon' => '', + ], + 'CAUTION' => [ + 'label' => 'Caution', + 'icon' => '', + ], + ]; + + public function render(Node $node, ChildNodeRendererInterface $childRenderer): ?HtmlElement + { + BlockQuote::assertInstanceOf($node); + + $firstChild = $node->firstChild(); + + if (! $firstChild instanceof Paragraph) { + return null; + } + + $firstInline = $firstChild->firstChild(); + + if (! $firstInline instanceof Text) { + return null; + } + + $literal = $firstInline->getLiteral(); + + if (! preg_match('/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/', $literal, $matches)) { + return null; + } + + $type = $matches[1]; + $alertConfig = self::ALERT_TYPES[$type]; + + $remaining = ltrim(substr($literal, strlen($matches[0]))); + + if ($remaining !== '') { + $firstInline->setLiteral($remaining); + } else { + $nextSibling = $firstInline->next(); + + $firstInline->detach(); + + if ($nextSibling instanceof Newline) { + $nextSibling->detach(); + } + } + + if (! $firstChild->hasChildren()) { + $firstChild->detach(); + } + + $innerHtml = $childRenderer->renderNodes($node->children()); + + $titleHtml = new HtmlElement( + 'p', + ['class' => 'markdown-alert-title'], + $alertConfig['icon'].' '.$alertConfig['label'], + ); + + $typeSlug = strtolower($type); + + return new HtmlElement( + 'div', + ['class' => "markdown-alert markdown-alert-{$typeSlug}"], + $titleHtml.$innerHtml, + ); + } +} diff --git a/app/Support/CommonMark/CommonMark.php b/app/Support/CommonMark/CommonMark.php index dc943f4f..0b4763c9 100644 --- a/app/Support/CommonMark/CommonMark.php +++ b/app/Support/CommonMark/CommonMark.php @@ -5,6 +5,7 @@ use App\Extensions\TorchlightWithCopyExtension; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\CommonMark\Node\Block\BlockQuote; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\Embed\Bridge\OscaroteroEmbedAdapter; use League\CommonMark\Extension\Embed\Embed as EmbedNode; @@ -53,6 +54,7 @@ protected static function getConverter(): MarkdownConverter $environment->addExtension(new GithubFlavoredMarkdownExtension); static::$headingRenderer = new HeadingRenderer; $environment->addRenderer(Heading::class, static::$headingRenderer); + $environment->addRenderer(BlockQuote::class, new AlertBlockQuoteRenderer, 10); $environment->addExtension(new TableExtension); $environment->addExtension(new EmbedExtension); diff --git a/package-lock.json b/package-lock.json index 88744b28..e35278ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "tender-duck", + "name": "turbo-labrador", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/css/app.css b/resources/css/app.css index 7c59f8d4..c00fed36 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -393,6 +393,108 @@ nav.docs-navigation li:has(.third-tier .exact-active) > .subsection-header { @apply relative z-0 mt-5 overflow-hidden rounded-2xl bg-gradient-to-tl from-transparent to-violet-100/75 px-5 ring-1 ring-black/5 dark:from-slate-900/30 dark:to-indigo-900/35; } +/* GitHub-style markdown alerts */ +.prose .markdown-alert { + @apply relative z-0 my-4 overflow-hidden rounded-2xl border-l-4 px-5 py-3 ring-1 ring-black/5; +} + +.prose .markdown-alert > :first-child { + @apply mt-0; +} + +.prose .markdown-alert > :last-child { + @apply mb-0; +} + +.prose .markdown-alert-title { + @apply flex items-center gap-2 font-semibold; +} + +.prose .markdown-alert-title svg { + @apply shrink-0; +} + +.prose .markdown-alert-note { + @apply border-blue-400 bg-gradient-to-tl from-transparent to-blue-100/75 text-blue-900; +} + +.prose .markdown-alert-note .markdown-alert-title { + @apply text-blue-700; +} + +.prose .markdown-alert-tip { + @apply border-green-400 bg-gradient-to-tl from-transparent to-green-100/75 text-green-900; +} + +.prose .markdown-alert-tip .markdown-alert-title { + @apply text-green-700; +} + +.prose .markdown-alert-important { + @apply border-purple-400 bg-gradient-to-tl from-transparent to-purple-100/75 text-purple-900; +} + +.prose .markdown-alert-important .markdown-alert-title { + @apply text-purple-700; +} + +.prose .markdown-alert-warning { + @apply border-amber-400 bg-gradient-to-tl from-transparent to-amber-100/75 text-amber-900; +} + +.prose .markdown-alert-warning .markdown-alert-title { + @apply text-amber-700; +} + +.prose .markdown-alert-caution { + @apply border-red-400 bg-gradient-to-tl from-transparent to-red-100/75 text-red-900; +} + +.prose .markdown-alert-caution .markdown-alert-title { + @apply text-red-700; +} + +/* Dark mode markdown alerts */ +.dark .prose .markdown-alert-note { + @apply border-blue-500 from-slate-900/30 to-blue-900/35 text-blue-200; +} + +.dark .prose .markdown-alert-note .markdown-alert-title { + @apply text-blue-400; +} + +.dark .prose .markdown-alert-tip { + @apply border-green-500 from-slate-900/30 to-green-900/35 text-green-200; +} + +.dark .prose .markdown-alert-tip .markdown-alert-title { + @apply text-green-400; +} + +.dark .prose .markdown-alert-important { + @apply border-purple-500 from-slate-900/30 to-purple-900/35 text-purple-200; +} + +.dark .prose .markdown-alert-important .markdown-alert-title { + @apply text-purple-400; +} + +.dark .prose .markdown-alert-warning { + @apply border-amber-500 from-slate-900/30 to-amber-900/35 text-amber-200; +} + +.dark .prose .markdown-alert-warning .markdown-alert-title { + @apply text-amber-400; +} + +.dark .prose .markdown-alert-caution { + @apply border-red-500 from-slate-900/30 to-red-900/35 text-red-200; +} + +.dark .prose .markdown-alert-caution .markdown-alert-title { + @apply text-red-400; +} + .images-two-up { @apply grid gap-8 mt-0 items-center; diff --git a/resources/views/docs/mobile/3/concepts/security.md b/resources/views/docs/mobile/3/concepts/security.md index 0d3cf311..c26f73db 100644 --- a/resources/views/docs/mobile/3/concepts/security.md +++ b/resources/views/docs/mobile/3/concepts/security.md @@ -42,12 +42,9 @@ This data is only accessible by your app and is persisted beyond the lifetime of the next time your app is opened. - +> [!NOTE] +> Secure Storage is only meant for small amounts of text data, usually no more than a few KBs. If you need to store +> larger amounts of data or files, you should store this in a database or as a file. ### When to use the Laravel `Crypt` facade @@ -58,11 +55,8 @@ stored on the device. NativePHP securely reads the `APP_KEY` from secure storage and makes it available to Laravel. So you can safely use the `Crypt` facade to encrypt and decrypt data! - +> [!WARNING] +> Make sure you do not leak the `APP_KEY` or decrypted data inadvertently through error tracking or debug logging tools. This is great for encrypting larger amounts of data that wouldn't easily fit in secure storage. You can encrypt values and store them in the file system or in the SQLite database, knowing that they are safe at rest: @@ -85,14 +79,11 @@ $decryptedContents = Crypt::decryptString( ); ``` - +> [!CAUTION] +> Data encrypted with the `Crypt` facade should stay on the user's device with your app. Placing it encrypted anywhere +> else risks the chance that it will be unrecoverable. If the user loses their device or deletes your app, +> they will lose the encryption key and the data will be encrypted forever. +> +> If you wish to share data, decrypt it first, transmit securely (e.g. over HTTPS) and re-encrypt it with a different key +> that is safely managed elsewhere. diff --git a/tests/Feature/AlertBlockQuoteRendererTest.php b/tests/Feature/AlertBlockQuoteRendererTest.php new file mode 100644 index 00000000..b188bf7c --- /dev/null +++ b/tests/Feature/AlertBlockQuoteRendererTest.php @@ -0,0 +1,116 @@ + [!NOTE]\n> This is a note."); + + $this->assertStringContainsString('class="markdown-alert markdown-alert-note"', $html); + $this->assertStringContainsString('class="markdown-alert-title"', $html); + $this->assertStringContainsString('Note', $html); + $this->assertStringContainsString('This is a note.', $html); + $this->assertStringNotContainsString('[!NOTE]', $html); + } + + public function test_tip_alert_renders_with_correct_class_and_title(): void + { + $html = CommonMark::convertToHtml("> [!TIP]\n> This is a tip."); + + $this->assertStringContainsString('markdown-alert-tip', $html); + $this->assertStringContainsString('Tip', $html); + $this->assertStringContainsString('This is a tip.', $html); + } + + public function test_important_alert_renders_with_correct_class_and_title(): void + { + $html = CommonMark::convertToHtml("> [!IMPORTANT]\n> This is important."); + + $this->assertStringContainsString('markdown-alert-important', $html); + $this->assertStringContainsString('Important', $html); + $this->assertStringContainsString('This is important.', $html); + } + + public function test_warning_alert_renders_with_correct_class_and_title(): void + { + $html = CommonMark::convertToHtml("> [!WARNING]\n> This is a warning."); + + $this->assertStringContainsString('markdown-alert-warning', $html); + $this->assertStringContainsString('Warning', $html); + $this->assertStringContainsString('This is a warning.', $html); + } + + public function test_caution_alert_renders_with_correct_class_and_title(): void + { + $html = CommonMark::convertToHtml("> [!CAUTION]\n> This is a caution."); + + $this->assertStringContainsString('markdown-alert-caution', $html); + $this->assertStringContainsString('Caution', $html); + $this->assertStringContainsString('This is a caution.', $html); + } + + public function test_regular_blockquote_still_renders_as_blockquote(): void + { + $html = CommonMark::convertToHtml('> This is a regular quote.'); + + $this->assertStringContainsString('
', $html); + $this->assertStringNotContainsString('markdown-alert', $html); + } + + public function test_multi_paragraph_alert_content(): void + { + $markdown = "> [!NOTE]\n> First paragraph.\n>\n> Second paragraph."; + $html = CommonMark::convertToHtml($markdown); + + $this->assertStringContainsString('markdown-alert-note', $html); + $this->assertStringContainsString('First paragraph.', $html); + $this->assertStringContainsString('Second paragraph.', $html); + } + + public function test_inline_formatting_within_alert(): void + { + $markdown = "> [!TIP]\n> Use **bold** and `code` and [links](https://example.com)."; + $html = CommonMark::convertToHtml($markdown); + + $this->assertStringContainsString('markdown-alert-tip', $html); + $this->assertStringContainsString('bold', $html); + $this->assertStringContainsString('code', $html); + $this->assertStringContainsString('href="https://example.com"', $html); + } + + public function test_lowercase_type_does_not_trigger_alert(): void + { + $html = CommonMark::convertToHtml("> [!note]\n> This should not be an alert."); + + $this->assertStringContainsString('
', $html); + $this->assertStringNotContainsString('markdown-alert', $html); + } + + public function test_invalid_type_does_not_trigger_alert(): void + { + $html = CommonMark::convertToHtml("> [!DANGER]\n> This should not be an alert."); + + $this->assertStringContainsString('
', $html); + $this->assertStringNotContainsString('markdown-alert', $html); + } + + public function test_alert_contains_svg_icon(): void + { + $html = CommonMark::convertToHtml("> [!NOTE]\n> Content here."); + + $this->assertStringContainsString(' [!WARNING] Immediate text on same line.'); + + $this->assertStringContainsString('markdown-alert-warning', $html); + $this->assertStringNotContainsString('[!WARNING]', $html); + } +}