From 3ed587f5929e94984b10cb5c35b946b39c9cdc26 Mon Sep 17 00:00:00 2001 From: Jaapio Date: Tue, 24 Feb 2026 22:10:25 +0100 Subject: [PATCH] Add support for multiline admonitions in markdown fixes #1299 --- .../src/Markdown/Parsers/BlockQuoteParser.php | 162 ++++++++++++------ .../bootstrap-admonition/expected/index.html | 109 ++++++------ .../class/class-directive/expected/index.html | 8 +- .../expected/index.html | 4 +- .../admonitions-md/expected/index.html | 32 +++- .../markdown/admonitions-md/input/index.md | 6 + .../expected/index.html | 6 +- 7 files changed, 210 insertions(+), 117 deletions(-) diff --git a/packages/guides-markdown/src/Markdown/Parsers/BlockQuoteParser.php b/packages/guides-markdown/src/Markdown/Parsers/BlockQuoteParser.php index aec3e201d..fd3d52020 100644 --- a/packages/guides-markdown/src/Markdown/Parsers/BlockQuoteParser.php +++ b/packages/guides-markdown/src/Markdown/Parsers/BlockQuoteParser.php @@ -28,7 +28,9 @@ use RuntimeException; use function array_shift; +use function array_values; use function count; +use function is_string; use function sprintf; use function trim; @@ -42,6 +44,40 @@ public function __construct( ) { } + /** + * @param array $content + * + * @phpstan-assert-if-false non-empty-list $content + */ + private static function contentIsTextOnlyParagraph(array $content): bool + { + if (count($content) === 0) { + return true; + } + + if ($content[0] instanceof ParagraphNode === false) { + return true; + } + + $paragraphContent = $content[0]->getValue()[0]->getValue(); + + if (is_string($paragraphContent)) { + return true; + } + + return $paragraphContent[0] instanceof PlainTextInlineNode === false; + } + + /** @param array $content */ + private static function contentIsNotParagraph(array $content): bool + { + if (count($content) === 0) { + return true; + } + + return $content[0] instanceof ParagraphNode === false; + } + public function parse(MarkupLanguageParser $parser, NodeWalker $walker, CommonMarkNode $current): Node { $content = []; @@ -61,68 +97,90 @@ public function parse(MarkupLanguageParser $parser, NodeWalker $walker, CommonMa } // leaving the heading node - if ($commonMarkNode instanceof BlockQuote) { - if (count($content) > 0 && $content[0] instanceof ParagraphNode && ($content[0]->getValue()[0]) instanceof InlineCompoundNode) { - $paragraphContent = $content[0]->getValue()[0]->getValue(); - if (count($paragraphContent) > 0 && $paragraphContent[0] instanceof PlainTextInlineNode) { - $text = trim($paragraphContent[0]->getValue()); - $newParagraphContent = $paragraphContent; - array_shift($newParagraphContent); - switch ($text) { - case '[!NOTE]': - return new AdmonitionNode( - 'note', - new InlineCompoundNode([new PlainTextInlineNode('Note')]), - 'Note', - $newParagraphContent, - ); - - case '[!TIP]': - return new AdmonitionNode( - 'tip', - new InlineCompoundNode([new PlainTextInlineNode('Tip')]), - 'Tip', - $newParagraphContent, - ); - - case '[!IMPORTANT]': - return new AdmonitionNode( - 'important', - new InlineCompoundNode([new PlainTextInlineNode('Important')]), - 'Important', - $newParagraphContent, - ); - - case '[!WARNING]': - return new AdmonitionNode( - 'warning', - new InlineCompoundNode([new PlainTextInlineNode('Warning')]), - 'Warning', - $newParagraphContent, - ); - - case '[!CAUTION]': - return new AdmonitionNode( - 'caution', - new InlineCompoundNode([new PlainTextInlineNode('Caution')]), - 'Caution', - $newParagraphContent, - ); - } - } + if ($commonMarkNode instanceof BlockQuote === false) { + $this->logger->warning(sprintf('"%s" node is not yet supported in context %s. ', $commonMarkNode::class, 'BlockQuote')); - $content[0] = new ParagraphNode([new InlineCompoundNode($paragraphContent)]); - } + throw new RuntimeException('Unexpected end of NodeWalker'); + } + + if (self::contentIsNotParagraph($content)) { + return new QuoteNode($content); + } + if (self::contentIsTextOnlyParagraph($content)) { return new QuoteNode($content); } - $this->logger->warning(sprintf('"%s" node is not yet supported in context %s. ', $commonMarkNode::class, 'BlockQuote')); + $admonitionNode = $this->toAdmonition(array_values($content)); + + return $admonitionNode ?? new QuoteNode($content); } throw new RuntimeException('Unexpected end of NodeWalker'); } + /** @param non-empty-list $content */ + private function toAdmonition(array $content): AdmonitionNode|null + { + if ($content[0] instanceof ParagraphNode === false) { + return null; + } + + $paragraphContent = $content[0]->getValue()[0]->getValue(); + if ($paragraphContent[0] instanceof PlainTextInlineNode === false) { + return null; + } + + $text = trim($paragraphContent[0]->getValue()); + $newParagraphContent = $paragraphContent; + array_shift($newParagraphContent); + $content[0] = new ParagraphNode([new InlineCompoundNode($newParagraphContent)]); + + switch ($text) { + case '[!NOTE]': + return new AdmonitionNode( + 'note', + new InlineCompoundNode([new PlainTextInlineNode('Note')]), + 'Note', + $content, + ); + + case '[!TIP]': + return new AdmonitionNode( + 'tip', + new InlineCompoundNode([new PlainTextInlineNode('Tip')]), + 'Tip', + $content, + ); + + case '[!IMPORTANT]': + return new AdmonitionNode( + 'important', + new InlineCompoundNode([new PlainTextInlineNode('Important')]), + 'Important', + $content, + ); + + case '[!WARNING]': + return new AdmonitionNode( + 'warning', + new InlineCompoundNode([new PlainTextInlineNode('Warning')]), + 'Warning', + $content, + ); + + case '[!CAUTION]': + return new AdmonitionNode( + 'caution', + new InlineCompoundNode([new PlainTextInlineNode('Caution')]), + 'Caution', + $content, + ); + } + + return null; + } + public function supports(NodeWalkerEvent $event): bool { return $event->isEntering() && $event->getNode() instanceof BlockQuote; diff --git a/tests/Integration/tests/bootstrap/bootstrap-admonition/expected/index.html b/tests/Integration/tests/bootstrap/bootstrap-admonition/expected/index.html index b3ad55abc..c8dcc1f04 100644 --- a/tests/Integration/tests/bootstrap/bootstrap-admonition/expected/index.html +++ b/tests/Integration/tests/bootstrap/bootstrap-admonition/expected/index.html @@ -1,78 +1,79 @@ -
-

Document Title

- + diff --git a/tests/Integration/tests/class/class-directive/expected/index.html b/tests/Integration/tests/class/class-directive/expected/index.html index 51a5d98ea..9d61331be 100644 --- a/tests/Integration/tests/class/class-directive/expected/index.html +++ b/tests/Integration/tests/class/class-directive/expected/index.html @@ -21,11 +21,15 @@

Document title

-

A Note with a class

+ +

A Note with a class

+
-

A note without a class

+ +

A note without a class

+
diff --git a/tests/Integration/tests/comments/comment-ends-in-markup/expected/index.html b/tests/Integration/tests/comments/comment-ends-in-markup/expected/index.html index 61543dd90..7043647aa 100644 --- a/tests/Integration/tests/comments/comment-ends-in-markup/expected/index.html +++ b/tests/Integration/tests/comments/comment-ends-in-markup/expected/index.html @@ -4,7 +4,9 @@

Some Title

-

No comment!

+ +

No comment!

+
diff --git a/tests/Integration/tests/markdown/admonitions-md/expected/index.html b/tests/Integration/tests/markdown/admonitions-md/expected/index.html index ce89d07c4..52c4b259b 100644 --- a/tests/Integration/tests/markdown/admonitions-md/expected/index.html +++ b/tests/Integration/tests/markdown/admonitions-md/expected/index.html @@ -4,23 +4,45 @@

Markdown Admonitions

+
diff --git a/tests/Integration/tests/markdown/admonitions-md/input/index.md b/tests/Integration/tests/markdown/admonitions-md/input/index.md index f1fe5b863..d066191d8 100644 --- a/tests/Integration/tests/markdown/admonitions-md/input/index.md +++ b/tests/Integration/tests/markdown/admonitions-md/input/index.md @@ -14,3 +14,9 @@ > [!CAUTION] > Advises about risks or negative outcomes of certain actions. + +> [!NOTE] +> You can add two types of expenses to your reimbursement: +> +> * **Receipt:** Any invoice or receipt that is applicable for reimbursement. +> * **Driven kilometers:** Travel by private car, reimbursed at a per-kilometer rate. diff --git a/tests/Integration/tests/markdown/sections-no-level-1-md/expected/index.html b/tests/Integration/tests/markdown/sections-no-level-1-md/expected/index.html index c4de8e63d..6252cf9f0 100644 --- a/tests/Integration/tests/markdown/sections-no-level-1-md/expected/index.html +++ b/tests/Integration/tests/markdown/sections-no-level-1-md/expected/index.html @@ -6,7 +6,7 @@

Introduction

This is a sample Markdown document demonstrating sections and subsections.

-
+

Section 1

This is the first section of the document.

@@ -24,7 +24,7 @@

Subsection 1.2

-
+

Section 2

Moving on to the second section of the document.

@@ -36,7 +36,7 @@

Subsection 2.1

-
+

Conclusion

In conclusion, this is a simple example of a Markdown document with various sections and subsections.