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..526263eb84 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,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) -> 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 +84,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.