basalt_core/
markdown.rs

1//! A Markdown parser that transforms Markdown input into a custom abstract syntax tree (AST)
2//! intented to be rendered with [basalt](https://github.com/erikjuhani/basalt)—a TUI application
3//! for Obsidian.
4//!
5//! This module provides a [`Parser`] type, which processes raw Markdown input into a [`Vec`] of
6//! [`Node`]s. These [`Node`]s represent semantic elements such as headings, paragraphs, block
7//! quotes, and code blocks.
8//!
9//! The parser is built on top of [`pulldown_cmark`].
10//!
11//! ## Simple usage
12//!
13//! At the simplest level, you can parse a Markdown string by calling the [`from_str`] function:
14//!
15//! ```
16//! use basalt_core::markdown::{from_str, Range, Node, MarkdownNode, HeadingLevel, Text};
17//!
18//! let markdown = "# My Heading\n\nSome text.";
19//! let nodes = from_str(markdown);
20//!
21//! assert_eq!(nodes, vec![
22//!   Node {
23//!     markdown_node: MarkdownNode::Heading {
24//!       level: HeadingLevel::H1,
25//!       text: Text::from("My Heading"),
26//!     },
27//!     source_range: Range { start: 0, end: 13 },
28//!   },
29//!   Node {
30//!     markdown_node: MarkdownNode::Paragraph {
31//!       text: Text::from("Some text."),
32//!     },
33//!     source_range: Range { start: 14, end: 24 }
34//!   },
35//! ])
36//! ```
37//!
38//! ## Implementation details
39//!
40//! The [`Parser`] processes [`pulldown_cmark::Event`]s one by one, building up the current
41//! [`Node`] in `current_node`. When an event indicates the start of a new structure (e.g.,
42//! `Event::Start(Tag::Heading {..})`), the [`Parser`] pushes or replaces the current node
43//! with a new one. When an event indicates the end of that structure, the node is finalized
44//! and pushed into [`Parser::output`].
45//!
46//! Unrecognized events (such as [`InlineHtml`](pulldown_cmark::Event::InlineHtml)) are simply
47//! ignored for the time being.
48//!
49//! ## Not yet implemented
50//!
51//! - Handling of inline HTML, math blocks, etc.
52//! - Tracking code block language (`lang`) properly (currently set to [`None`]).
53use std::vec::IntoIter;
54
55use pulldown_cmark::{Event, Options, Tag, TagEnd};
56
57/// A style that can be applied to [`TextNode`] (code, emphasis, strikethrough, strong).
58#[derive(Clone, Debug, PartialEq)]
59pub enum Style {
60    /// Inline code style (e.g. `code`).
61    Code,
62    /// Italic/emphasis style (e.g. `*emphasis*`).
63    Emphasis,
64    /// Strikethrough style (e.g. `~~strikethrough~~`).
65    Strikethrough,
66    /// Bold/strong style (e.g. `**strong**`).
67    Strong,
68}
69
70/// Represents the variant of a list or task item (checked, unchecked, etc.).
71#[derive(Clone, Debug, PartialEq)]
72pub enum ItemKind {
73    /// A checkbox item that is marked as done using `- [x]`.
74    HardChecked,
75    /// A checkbox item that is checked, but not explicitly recognized as
76    /// `HardChecked` (e.g., `- [?]`).
77    Checked,
78    /// A checkbox item that is unchecked using `- [ ]`.
79    Unchecked,
80    // TODO: Remove in favor of using List node that has children of nodes
81    /// An ordered list item (e.g., `1. item`), storing the numeric index.
82    Ordered(u64),
83    /// An unordered list item (e.g., `- item`).
84    Unordered,
85}
86
87#[derive(Clone, Debug, PartialEq)]
88#[allow(missing_docs)]
89pub enum HeadingLevel {
90    H1 = 1,
91    H2,
92    H3,
93    H4,
94    H5,
95    H6,
96}
97
98impl From<pulldown_cmark::HeadingLevel> for HeadingLevel {
99    fn from(value: pulldown_cmark::HeadingLevel) -> Self {
100        match value {
101            pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
102            pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
103            pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
104            pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
105            pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
106            pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
107        }
108    }
109}
110
111/// Represents specialized block quote kind variants (tip, note, warning, etc.).
112///
113/// Currently, the underlying [`pulldown_cmark`] parser distinguishes these via syntax like `">
114/// [!NOTE] Some note"`.
115#[derive(Clone, Debug, PartialEq)]
116#[allow(missing_docs)]
117pub enum BlockQuoteKind {
118    Note,
119    Tip,
120    Important,
121    Warning,
122    Caution,
123}
124
125impl From<pulldown_cmark::BlockQuoteKind> for BlockQuoteKind {
126    fn from(value: pulldown_cmark::BlockQuoteKind) -> Self {
127        match value {
128            pulldown_cmark::BlockQuoteKind::Tip => BlockQuoteKind::Tip,
129            pulldown_cmark::BlockQuoteKind::Note => BlockQuoteKind::Note,
130            pulldown_cmark::BlockQuoteKind::Warning => BlockQuoteKind::Warning,
131            pulldown_cmark::BlockQuoteKind::Caution => BlockQuoteKind::Caution,
132            pulldown_cmark::BlockQuoteKind::Important => BlockQuoteKind::Important,
133        }
134    }
135}
136
137/// Denotes whether a list is ordered or unordered.
138#[derive(Clone, Debug, PartialEq)]
139pub enum ListKind {
140    /// An ordered list item (e.g., `1. item`), storing the numeric index.
141    Ordered(u64),
142    /// An unordered list item (e.g., `- item`).
143    Unordered,
144}
145
146/// A single unit of text that is optionally styled (e.g., code).
147///
148/// [`TextNode`] can be any combination of sentence, words or characters.
149///
150/// Usually styled text will be contained in a single [`TextNode`] with the given [`Style`]
151/// property.
152#[derive(Clone, Debug, PartialEq, Default)]
153pub struct TextNode {
154    /// The literal text content.
155    pub content: String,
156    /// Optional inline style of the text.
157    pub style: Option<Style>,
158}
159
160impl From<&str> for TextNode {
161    fn from(value: &str) -> Self {
162        value.to_string().into()
163    }
164}
165
166impl From<String> for TextNode {
167    fn from(value: String) -> Self {
168        Self {
169            content: value,
170            ..Default::default()
171        }
172    }
173}
174
175impl TextNode {
176    /// Creates a new [`TextNode`] from `content` and optional [`Style`].
177    pub fn new(content: String, style: Option<Style>) -> Self {
178        Self { content, style }
179    }
180}
181
182/// A wrapper type holding a list of [`TextNode`]s.
183#[derive(Clone, Debug, PartialEq, Default)]
184pub struct Text(Vec<TextNode>);
185
186impl From<&str> for Text {
187    fn from(value: &str) -> Self {
188        TextNode::from(value).into()
189    }
190}
191
192impl From<String> for Text {
193    fn from(value: String) -> Self {
194        TextNode::from(value).into()
195    }
196}
197
198impl From<TextNode> for Text {
199    fn from(value: TextNode) -> Self {
200        Self([value].to_vec())
201    }
202}
203
204impl From<Vec<TextNode>> for Text {
205    fn from(value: Vec<TextNode>) -> Self {
206        Self(value)
207    }
208}
209
210impl From<&[TextNode]> for Text {
211    fn from(value: &[TextNode]) -> Self {
212        Self(value.to_vec())
213    }
214}
215
216impl IntoIterator for Text {
217    type Item = TextNode;
218    type IntoIter = IntoIter<Self::Item>;
219    fn into_iter(self) -> Self::IntoIter {
220        self.0.into_iter()
221    }
222}
223
224impl Text {
225    /// Appends a [`TextNode`] to the inner text list.
226    fn push(&mut self, node: TextNode) {
227        self.0.push(node);
228    }
229}
230
231/// A [`std::ops::Range`] type for depicting range in [`crate::markdown`].
232///
233/// # Examples
234///
235/// ```
236/// use basalt_core::markdown::{Node, MarkdownNode, Range, Text};
237///
238/// let node = Node {
239///   markdown_node: MarkdownNode::Paragraph {
240///     text: Text::default(),
241///   },
242///   source_range: Range::default(),
243/// };
244/// ```
245pub type Range<Idx> = std::ops::Range<Idx>;
246
247/// A node in the Markdown AST.
248///
249/// Each `Node` contains a [`MarkdownNode`] variant representing a specific kind of Markdown
250/// element (paragraph, heading, code block, etc.), along with a `source_range` indicating where in
251/// the source text this node occurs.
252///
253/// # Examples
254///
255/// ```
256/// use basalt_core::markdown::{Node, MarkdownNode, Range, Text};
257///
258/// let node = Node::new(
259///   MarkdownNode::Paragraph {
260///     text: Text::default(),
261///   },
262///   0..10,
263/// );
264///
265/// assert_eq!(node.markdown_node, MarkdownNode::Paragraph { text: Text::default() });
266/// assert_eq!(node.source_range, Range { start: 0, end: 10 });
267/// ```
268#[derive(Clone, Debug, PartialEq)]
269pub struct Node {
270    /// The specific Markdown node represented by this node.
271    pub markdown_node: MarkdownNode,
272
273    /// The range in the original source text that this node covers.
274    pub source_range: Range<usize>,
275}
276
277impl Node {
278    /// Creates a new `Node` from the provided [`MarkdownNode`] and source range.
279    pub fn new(markdown_node: MarkdownNode, source_range: Range<usize>) -> Self {
280        Self {
281            markdown_node,
282            source_range,
283        }
284    }
285
286    /// Pushes a [`TextNode`] into the markdown node, if it contains a text buffer.
287    ///
288    /// If the markdown node is a [`MarkdownNode::BlockQuote`], the [`TextNode`] will be pushed
289    /// into the last child [`Node`], if any.
290    /// ```
291    pub(crate) fn push_text_node(&mut self, node: TextNode) {
292        match &mut self.markdown_node {
293            MarkdownNode::Paragraph { text, .. }
294            | MarkdownNode::Heading { text, .. }
295            | MarkdownNode::CodeBlock { text, .. }
296            | MarkdownNode::Item { text, .. } => text.push(node),
297            MarkdownNode::BlockQuote { nodes, .. } => {
298                if let Some(last_node) = nodes.last_mut() {
299                    last_node.push_text_node(node);
300                }
301            }
302        }
303    }
304}
305
306/// The Markdown AST node enumeration.
307#[derive(Clone, Debug, PartialEq)]
308#[allow(missing_docs)]
309pub enum MarkdownNode {
310    /// A heading node that represents different heading levels.
311    ///
312    /// The level is controlled with the [`HeadingLevel`] definition.
313    Heading {
314        level: HeadingLevel,
315        text: Text,
316    },
317    Paragraph {
318        text: Text,
319    },
320    /// A block quote node that represents different quote block variants including callout blocks.
321    ///
322    /// The variant is controlled with the [`BlockQuoteKind`] definition. When [`BlockQuoteKind`]
323    /// is [`None`] the block quote should be interpreted as a regular block quote:
324    /// `"> Block quote"`.
325    BlockQuote {
326        kind: Option<BlockQuoteKind>,
327        nodes: Vec<Node>,
328    },
329    /// A fenced code block, optionally with a language identifier.
330    CodeBlock {
331        lang: Option<String>,
332        text: Text,
333    },
334    /// A list item node that represents different list item variants including task items.
335    ///
336    /// The variant is controlled with the [`ItemKind`] definition. When [`ItemKind`] is [`None`]
337    /// the item should be interpreted as unordered list item: `"- Item"`.
338    Item {
339        kind: Option<ItemKind>,
340        text: Text,
341    },
342}
343
344/// Returns `true` if the [`MarkdownNode`] should be closed upon encountering the given [`TagEnd`].
345fn matches_tag_end(node: &Node, tag_end: &TagEnd) -> bool {
346    matches!(
347        (&node.markdown_node, tag_end),
348        (MarkdownNode::Paragraph { .. }, TagEnd::Paragraph)
349            | (MarkdownNode::Heading { .. }, TagEnd::Heading(..))
350            | (MarkdownNode::BlockQuote { .. }, TagEnd::BlockQuote(..))
351            | (MarkdownNode::CodeBlock { .. }, TagEnd::CodeBlock)
352            | (MarkdownNode::Item { .. }, TagEnd::Item)
353    )
354}
355
356/// Parses the given Markdown input into a list of [`Node`]s.
357///
358/// This is a convenience function for constructing a [`Parser`] and calling [`Parser::parse`].  
359///
360/// # Examples
361///
362/// ```
363/// use basalt_core::markdown::{from_str, Range, Node, MarkdownNode, HeadingLevel, Text};
364///
365/// let markdown = "# My Heading\n\nSome text.";
366/// let nodes = from_str(markdown);
367///
368/// assert_eq!(nodes, vec![
369///   Node {
370///     markdown_node: MarkdownNode::Heading {
371///       level: HeadingLevel::H1,
372///       text: Text::from("My Heading"),
373///     },
374///     source_range: Range { start: 0, end: 13 },
375///   },
376///   Node {
377///     markdown_node: MarkdownNode::Paragraph {
378///       text: Text::from("Some text."),
379///     },
380///     source_range: Range { start: 14, end: 24 },
381///   },
382/// ])
383/// ```
384pub fn from_str(text: &str) -> Vec<Node> {
385    Parser::new(text).parse()
386}
387
388/// A parser that consumes [`pulldown_cmark::Event`]s and produces a [`Vec`] of [`Node`].
389///
390/// # Examples
391///
392/// ```
393/// use basalt_core::markdown::{Parser, Range, Node, MarkdownNode, HeadingLevel, Text};
394///
395/// let markdown = "# My Heading\n\nSome text.";
396/// let parser = Parser::new(markdown);
397/// let nodes = parser.parse();
398///
399/// assert_eq!(nodes, vec![
400///   Node {
401///     markdown_node: MarkdownNode::Heading {
402///       level: HeadingLevel::H1,
403///       text: Text::from("My Heading"),
404///     },
405///     source_range: Range { start: 0, end: 13 },
406///   },
407///   Node {
408///     markdown_node: MarkdownNode::Paragraph {
409///       text: Text::from("Some text."),
410///     },
411///     source_range: Range { start: 14, end: 24 },
412///   },
413/// ])
414/// ```
415pub struct Parser<'a> {
416    /// Contains the completed AST [`Node`]s.
417    pub output: Vec<Node>,
418    inner: pulldown_cmark::TextMergeWithOffset<'a, pulldown_cmark::OffsetIter<'a>>,
419    current_node: Option<Node>,
420}
421
422impl<'a> Iterator for Parser<'a> {
423    type Item = (Event<'a>, Range<usize>);
424    fn next(&mut self) -> Option<Self::Item> {
425        self.inner.next()
426    }
427}
428
429impl<'a> Parser<'a> {
430    /// Creates a new [`Parser`] from a Markdown input string.
431    ///
432    /// The parser uses [`pulldown_cmark::Parser::new_ext`] with [`Options::all()`] and
433    /// [`pulldown_cmark::TextMergeWithOffset`] internally.
434    ///
435    /// The offset is required to know where the node appears in the provided source text.
436    pub fn new(text: &'a str) -> Self {
437        let parser = pulldown_cmark::TextMergeWithOffset::new(
438            pulldown_cmark::Parser::new_ext(text, Options::all()).into_offset_iter(),
439        );
440
441        Self {
442            inner: parser,
443            output: vec![],
444            current_node: None,
445        }
446    }
447
448    /// Pushes a [`Node`] as a child if the current node is a [`BlockQuote`], otherwise sets it as
449    /// the `current_node`.
450    fn push_node(&mut self, node: Node) {
451        if let Some(Node {
452            markdown_node: MarkdownNode::BlockQuote { nodes, .. },
453            ..
454        }) = &mut self.current_node
455        {
456            nodes.push(node);
457        } else {
458            self.set_node(&node);
459        }
460    }
461
462    /// Pushes a [`TextNode`] into the `current_node` if it exists.
463    fn push_text_node(&mut self, node: TextNode) {
464        if let Some(ref mut current) = self.current_node {
465            current.push_text_node(node);
466        }
467    }
468
469    /// Sets (or replaces) the `current_node` with a new one, discarding any old node.
470    fn set_node(&mut self, block: &Node) {
471        self.current_node.replace(block.clone());
472    }
473
474    /// Handles the start of a [`Tag`]. Pushes the matching semantic node to be processed.
475    fn tag(&mut self, tag: Tag<'a>, range: Range<usize>) {
476        match tag {
477            Tag::Paragraph => self.push_node(Node::new(
478                MarkdownNode::Paragraph {
479                    text: Text::default(),
480                },
481                range,
482            )),
483            Tag::Heading { level, .. } => self.push_node(Node::new(
484                MarkdownNode::Heading {
485                    level: level.into(),
486                    text: Text::default(),
487                },
488                range,
489            )),
490            Tag::BlockQuote(kind) => self.push_node(Node::new(
491                MarkdownNode::BlockQuote {
492                    kind: kind.map(|kind| kind.into()),
493                    nodes: vec![],
494                },
495                range,
496            )),
497            Tag::CodeBlock(_) => self.push_node(Node::new(
498                MarkdownNode::CodeBlock {
499                    lang: None,
500                    text: Text::default(),
501                },
502                range,
503            )),
504            Tag::Item => self.push_node(Node::new(
505                MarkdownNode::Item {
506                    kind: None,
507                    text: Text::default(),
508                },
509                range,
510            )),
511            // For now everything below this comment are defined as paragraph nodes
512            Tag::HtmlBlock
513            | Tag::List(_)
514            | Tag::FootnoteDefinition(_)
515            | Tag::Table(_)
516            | Tag::TableHead
517            | Tag::TableRow
518            | Tag::TableCell
519            | Tag::Emphasis
520            | Tag::Strong
521            | Tag::Strikethrough
522            | Tag::Link { .. }
523            | Tag::Image { .. }
524            | Tag::MetadataBlock(_)
525            | Tag::DefinitionList
526            | Tag::DefinitionListTitle
527            | Tag::DefinitionListDefinition => {}
528        }
529    }
530
531    /// Handles the end of a [`Tag`], finalizing a node if matching.
532    fn tag_end(&mut self, tag_end: TagEnd) {
533        let Some(node) = self.current_node.take() else {
534            return;
535        };
536
537        if matches_tag_end(&node, &tag_end) {
538            self.output.push(node);
539        } else {
540            self.set_node(&node);
541        }
542    }
543
544    /// Processes a single [`Event`] from the underlying [`pulldown_cmark::Parser`] iterator.
545    fn handle_event(&mut self, event: Event<'a>, range: Range<usize>) {
546        match event {
547            Event::Start(tag) => self.tag(tag, range),
548            Event::End(tag_end) => self.tag_end(tag_end),
549            Event::Text(text) => self.push_text_node(TextNode::new(text.to_string(), None)),
550            Event::Code(text) => {
551                self.push_text_node(TextNode::new(text.to_string(), Some(Style::Code)))
552            }
553            Event::TaskListMarker(checked) => {
554                // The range for these markdown items only applies to the `[ ]` portion.
555                // TODO: Add implementation for ListBlock, which will retain the complete source
556                // range.
557                if checked {
558                    self.set_node(&Node::new(
559                        MarkdownNode::Item {
560                            kind: Some(ItemKind::HardChecked),
561                            text: Text::default(),
562                        },
563                        range,
564                    ));
565                } else {
566                    self.set_node(&Node::new(
567                        MarkdownNode::Item {
568                            kind: Some(ItemKind::Unchecked),
569                            text: Text::default(),
570                        },
571                        range,
572                    ));
573                }
574            }
575            Event::InlineMath(_)
576            | Event::DisplayMath(_)
577            | Event::Html(_)
578            | Event::InlineHtml(_)
579            | Event::SoftBreak
580            | Event::HardBreak
581            | Event::Rule
582            | Event::FootnoteReference(_) => {
583                // TODO: Not yet implemented
584            }
585        }
586    }
587
588    /// Consumes the parser, processing all remaining events from the stream into a list of
589    /// [`Node`]s.
590    ///
591    /// # Examples
592    ///
593    /// ```
594    /// # use basalt_core::markdown::{Parser, Node, MarkdownNode, Range, Text};
595    /// let parser = Parser::new("Hello world");
596    ///
597    /// let nodes = parser.parse();
598    ///
599    /// assert_eq!(nodes, vec![
600    ///   Node {
601    ///     markdown_node: MarkdownNode::Paragraph {
602    ///       text: Text::from("Hello world"),
603    ///     },
604    ///     source_range: Range { start: 0, end: 11 },
605    ///   },
606    /// ]);
607    /// ```
608    pub fn parse(mut self) -> Vec<Node> {
609        while let Some((event, range)) = self.next() {
610            self.handle_event(event, range);
611        }
612
613        if let Some(node) = self.current_node.take() {
614            self.output.push(node);
615        }
616
617        self.output
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use indoc::indoc;
624
625    fn p(str: &str, range: Range<usize>) -> Node {
626        Node::new(MarkdownNode::Paragraph { text: str.into() }, range)
627    }
628
629    fn blockquote(nodes: Vec<Node>, range: Range<usize>) -> Node {
630        Node::new(MarkdownNode::BlockQuote { kind: None, nodes }, range)
631    }
632
633    fn item(str: &str, range: Range<usize>) -> Node {
634        Node::new(
635            MarkdownNode::Item {
636                kind: None,
637                text: str.into(),
638            },
639            range,
640        )
641    }
642
643    fn task(str: &str, range: Range<usize>) -> Node {
644        Node::new(
645            MarkdownNode::Item {
646                kind: Some(ItemKind::Unchecked),
647                text: str.into(),
648            },
649            range,
650        )
651    }
652
653    fn completed_task(str: &str, range: Range<usize>) -> Node {
654        Node::new(
655            MarkdownNode::Item {
656                kind: Some(ItemKind::HardChecked),
657                text: str.into(),
658            },
659            range,
660        )
661    }
662
663    fn heading(level: HeadingLevel, str: &str, range: Range<usize>) -> Node {
664        Node::new(
665            MarkdownNode::Heading {
666                level,
667                text: str.into(),
668            },
669            range,
670        )
671    }
672
673    fn h1(str: &str, range: Range<usize>) -> Node {
674        heading(HeadingLevel::H1, str, range)
675    }
676
677    fn h2(str: &str, range: Range<usize>) -> Node {
678        heading(HeadingLevel::H2, str, range)
679    }
680
681    fn h3(str: &str, range: Range<usize>) -> Node {
682        heading(HeadingLevel::H3, str, range)
683    }
684
685    fn h4(str: &str, range: Range<usize>) -> Node {
686        heading(HeadingLevel::H4, str, range)
687    }
688
689    fn h5(str: &str, range: Range<usize>) -> Node {
690        heading(HeadingLevel::H5, str, range)
691    }
692
693    fn h6(str: &str, range: Range<usize>) -> Node {
694        heading(HeadingLevel::H6, str, range)
695    }
696
697    use super::*;
698
699    #[test]
700    fn test_parse() {
701        let tests = [
702            (
703                indoc! {r#"# Heading 1
704
705                ## Heading 2
706
707                ### Heading 3
708
709                #### Heading 4
710
711                ##### Heading 5
712
713                ###### Heading 6
714                "#},
715                vec![
716                    h1("Heading 1", 0..12),
717                    h2("Heading 2", 13..26),
718                    h3("Heading 3", 27..41),
719                    h4("Heading 4", 42..57),
720                    h5("Heading 5", 58..74),
721                    h6("Heading 6", 75..92),
722                ],
723            ),
724            // TODO: Implement correct test case when `- [?] ` task item syntax is supported
725            // Now we interpret it as a regular paragraph
726            (
727                indoc! { r#"## Tasks
728
729                - [ ] Task
730
731                - [x] Completed task
732
733                - [?] Completed task
734                "#},
735                vec![
736                    h2("Tasks", 0..9),
737                    task("Task", 12..15),
738                    completed_task("Completed task", 24..27),
739                    p("[?] Completed task", 46..65),
740                ],
741            ),
742            (
743                indoc! {r#"## Quotes
744
745                You _can_ quote text by adding a `>` symbols before the text.
746
747                > Human beings face ever more complex and urgent problems, and their effectiveness in dealing with these problems is a matter that is critical to the stability and continued progress of society.
748                >
749                >- Doug Engelbart, 1961
750                "#},
751                vec![
752                    h2("Quotes", 0..10),
753                    Node::new(MarkdownNode::Paragraph {
754                        text: vec![
755                            TextNode::new("You ".into(), None),
756                            TextNode::new("can".into(),None),
757                            TextNode::new(" quote text by adding a ".into(), None),
758                            TextNode::new(">".into(), Some(Style::Code)),
759                            TextNode::new(" symbols before the text.".into(), None),
760                        ]
761                        .into(),
762                    }, 11..73),
763                    blockquote(vec![
764                        p("Human beings face ever more complex and urgent problems, and their effectiveness in dealing with these problems is a matter that is critical to the stability and continued progress of society.", 76..269),
765                        item("Doug Engelbart, 1961", 272..295)
766                    ], 74..295),
767                ],
768            ),
769        ];
770
771        tests
772            .iter()
773            .for_each(|test| assert_eq!(from_str(test.0), test.1));
774    }
775}