From 40c928e91facf131414c8b34485f60fad494e281 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 17 May 2026 11:43:12 +0800 Subject: [PATCH 1/2] fix(markdown): accept blank line after admonition header Merge pulldown-cmark blockquote events split when an admonition header is followed by a blank `>` line, matching GitHub GFM and surviving Markdown prettifiers (pulldown-cmark #890). Fixes #3066. Co-authored-by: Cursor --- .../src/admonition_blockquote_merge.rs | 71 +++++++++++++++++++ crates/mdbook-markdown/src/lib.rs | 36 +++++++++- .../admonitions/expected/admonitions.html | 4 ++ .../expected_disabled/admonitions.html | 4 ++ .../markdown/admonitions/src/admonitions.md | 4 ++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 crates/mdbook-markdown/src/admonition_blockquote_merge.rs diff --git a/crates/mdbook-markdown/src/admonition_blockquote_merge.rs b/crates/mdbook-markdown/src/admonition_blockquote_merge.rs new file mode 100644 index 0000000000..fefda548d5 --- /dev/null +++ b/crates/mdbook-markdown/src/admonition_blockquote_merge.rs @@ -0,0 +1,71 @@ +//! Merges blockquote events split by pulldown-cmark when an admonition header is +//! followed by a blank `>` line. +//! +//! See . + +use pulldown_cmark::{Event, Tag, TagEnd}; + +/// An iterator adapter to collapse together the incorrectly split [`Event::Start`] +/// / [`Event::End`] blockquote pairs produced by pulldown-cmark when using GFM +/// admonitions with an extra blank line after the header. +pub(crate) struct AdmonitionBlockquoteMerge<'a, I> +where + I: Iterator>, +{ + inner: std::iter::Peekable, + buffer: Vec>>, +} + +impl<'a, I> AdmonitionBlockquoteMerge<'a, I> +where + I: Iterator>, +{ + pub(crate) fn new(iterator: I) -> Self { + Self { + inner: iterator.peekable(), + buffer: Vec::new(), + } + } +} + +impl<'a, I> Iterator for AdmonitionBlockquoteMerge<'a, I> +where + I: Iterator>, +{ + type Item = Event<'a>; + + fn next(&mut self) -> Option { + loop { + match (self.inner.peek(), self.buffer.as_slice()) { + // If we see 3 after accumulating 1&2, drop 2&3 and return 1. + ( + Some(Event::Start(Tag::BlockQuote(None))), + [ + Some(Event::Start(Tag::BlockQuote(Some(_)))), + Some(Event::End(TagEnd::BlockQuote(_))), + ], + ) => { + let _ = self.inner.next(); + let e = self.buffer.swap_remove(0); + self.buffer.clear(); + return e; + } + // If we see 2 and we've accumulated 1, buffer it and go around again. + ( + Some(Event::End(TagEnd::BlockQuote(_))), + [Some(Event::Start(Tag::BlockQuote(Some(_))))], + ) => { + self.buffer.push(self.inner.next()); + } + // If we see 1 and the buffer is empty, buffer it and go around again. + (Some(Event::Start(Tag::BlockQuote(Some(_)))), []) => { + self.buffer.push(self.inner.next()); + } + // Otherwise, if the buffer is empty, just pass it through. + (_, []) => return self.inner.next(), + // Otherwise, drain the buffer. + (_, [_, ..]) => return self.buffer.remove(0), + } + } + } +} diff --git a/crates/mdbook-markdown/src/lib.rs b/crates/mdbook-markdown/src/lib.rs index 7e2ce176ab..9594f5cc02 100644 --- a/crates/mdbook-markdown/src/lib.rs +++ b/crates/mdbook-markdown/src/lib.rs @@ -5,11 +5,35 @@ //! crate is used as the underlying parser. This crate re-exports //! [`pulldown_cmark`] so that you can access its types. -use pulldown_cmark::{Options, Parser}; +use pulldown_cmark::{Event, Options, Parser}; + +mod admonition_blockquote_merge; +use admonition_blockquote_merge::AdmonitionBlockquoteMerge; #[doc(inline)] pub use pulldown_cmark; +/// Markdown event iterator returned by [`new_cmark_parser`]. +pub struct MarkdownParser<'text> { + inner: MarkdownParserInner<'text>, +} + +enum MarkdownParserInner<'text> { + Plain(Parser<'text>), + WithAdmonitionMerge(AdmonitionBlockquoteMerge<'text, Parser<'text>>), +} + +impl<'text> Iterator for MarkdownParser<'text> { + type Item = Event<'text>; + + fn next(&mut self) -> Option { + match &mut self.inner { + MarkdownParserInner::Plain(parser) => parser.next(), + MarkdownParserInner::WithAdmonitionMerge(parser) => parser.next(), + } + } +} + /// Options for parsing markdown. #[non_exhaustive] pub struct MarkdownOptions { @@ -41,7 +65,7 @@ impl Default for MarkdownOptions { } /// Creates a new pulldown-cmark parser of the given text. -pub fn new_cmark_parser<'text>(text: &'text str, options: &MarkdownOptions) -> Parser<'text> { +pub fn new_cmark_parser<'text>(text: &'text str, options: &MarkdownOptions) -> MarkdownParser<'text> { let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_FOOTNOTES); @@ -57,5 +81,11 @@ pub fn new_cmark_parser<'text>(text: &'text str, options: &MarkdownOptions) -> P if options.admonitions { opts.insert(Options::ENABLE_GFM); } - Parser::new_ext(text, opts) + let parser = Parser::new_ext(text, opts); + let inner = if options.admonitions { + MarkdownParserInner::WithAdmonitionMerge(AdmonitionBlockquoteMerge::new(parser)) + } else { + MarkdownParserInner::Plain(parser) + }; + MarkdownParser { inner } } diff --git a/tests/testsuite/markdown/admonitions/expected/admonitions.html b/tests/testsuite/markdown/admonitions/expected/admonitions.html index 962e7d7992..9031da9a4a 100644 --- a/tests/testsuite/markdown/admonitions/expected/admonitions.html +++ b/tests/testsuite/markdown/admonitions/expected/admonitions.html @@ -4,6 +4,10 @@

Admonitions

This is a note.

There are multiple paragraphs.

+
+

Note

+

Blank line after the admonition header (prettifier-safe).

+

Tip

This is a tip.

diff --git a/tests/testsuite/markdown/admonitions/expected_disabled/admonitions.html b/tests/testsuite/markdown/admonitions/expected_disabled/admonitions.html index 65c10471f0..1490b1f3d8 100644 --- a/tests/testsuite/markdown/admonitions/expected_disabled/admonitions.html +++ b/tests/testsuite/markdown/admonitions/expected_disabled/admonitions.html @@ -5,6 +5,10 @@

Admonitions

There are multiple paragraphs.

+

[!NOTE]

+

Blank line after the admonition header (prettifier-safe).

+
+

[!TIP] This is a tip.

diff --git a/tests/testsuite/markdown/admonitions/src/admonitions.md b/tests/testsuite/markdown/admonitions/src/admonitions.md index bb5102ac3c..7b6b89d2e1 100644 --- a/tests/testsuite/markdown/admonitions/src/admonitions.md +++ b/tests/testsuite/markdown/admonitions/src/admonitions.md @@ -5,6 +5,10 @@ > > There are multiple paragraphs. +> [!NOTE] +> +> Blank line after the admonition header (prettifier-safe). + > [!TIP] > This is a tip. From d5d35aaec7682065ad7738e70609abb1adb15bcc Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 17 May 2026 11:46:53 +0800 Subject: [PATCH 2/2] style: rustfmt Co-authored-by: Cursor --- crates/mdbook-markdown/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/mdbook-markdown/src/lib.rs b/crates/mdbook-markdown/src/lib.rs index 9594f5cc02..526263eb84 100644 --- a/crates/mdbook-markdown/src/lib.rs +++ b/crates/mdbook-markdown/src/lib.rs @@ -65,7 +65,10 @@ impl Default for MarkdownOptions { } /// Creates a new pulldown-cmark parser of the given text. -pub fn new_cmark_parser<'text>(text: &'text str, options: &MarkdownOptions) -> MarkdownParser<'text> { +pub fn new_cmark_parser<'text>( + text: &'text str, + options: &MarkdownOptions, +) -> MarkdownParser<'text> { let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_FOOTNOTES);