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::Subscript
528            | Tag::Superscript
529            | Tag::DefinitionListDefinition => {}
530        }
531    }
532
533    /// Handles the end of a [`Tag`], finalizing a node if matching.
534    fn tag_end(&mut self, tag_end: TagEnd) {
535        let Some(node) = self.current_node.take() else {
536            return;
537        };
538
539        if matches_tag_end(&node, &tag_end) {
540            self.output.push(node);
541        } else {
542            self.set_node(&node);
543        }
544    }
545
546    /// Processes a single [`Event`] from the underlying [`pulldown_cmark::Parser`] iterator.
547    fn handle_event(&mut self, event: Event<'a>, range: Range<usize>) {
548        match event {
549            Event::Start(tag) => self.tag(tag, range),
550            Event::End(tag_end) => self.tag_end(tag_end),
551            Event::Text(text) => self.push_text_node(TextNode::new(text.to_string(), None)),
552            Event::Code(text) => {
553                self.push_text_node(TextNode::new(text.to_string(), Some(Style::Code)))
554            }
555            Event::TaskListMarker(checked) => {
556                // The range for these markdown items only applies to the `[ ]` portion.
557                // TODO: Add implementation for ListBlock, which will retain the complete source
558                // range.
559                if checked {
560                    self.set_node(&Node::new(
561                        MarkdownNode::Item {
562                            kind: Some(ItemKind::HardChecked),
563                            text: Text::default(),
564                        },
565                        range,
566                    ));
567                } else {
568                    self.set_node(&Node::new(
569                        MarkdownNode::Item {
570                            kind: Some(ItemKind::Unchecked),
571                            text: Text::default(),
572                        },
573                        range,
574                    ));
575                }
576            }
577            Event::InlineMath(_)
578            | Event::DisplayMath(_)
579            | Event::Html(_)
580            | Event::InlineHtml(_)
581            | Event::SoftBreak
582            | Event::HardBreak
583            | Event::Rule
584            | Event::FootnoteReference(_) => {
585                // TODO: Not yet implemented
586            }
587        }
588    }
589
590    /// Consumes the parser, processing all remaining events from the stream into a list of
591    /// [`Node`]s.
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// # use basalt_core::markdown::{Parser, Node, MarkdownNode, Range, Text};
597    /// let parser = Parser::new("Hello world");
598    ///
599    /// let nodes = parser.parse();
600    ///
601    /// assert_eq!(nodes, vec![
602    ///   Node {
603    ///     markdown_node: MarkdownNode::Paragraph {
604    ///       text: Text::from("Hello world"),
605    ///     },
606    ///     source_range: Range { start: 0, end: 11 },
607    ///   },
608    /// ]);
609    /// ```
610    pub fn parse(mut self) -> Vec<Node> {
611        while let Some((event, range)) = self.next() {
612            self.handle_event(event, range);
613        }
614
615        if let Some(node) = self.current_node.take() {
616            self.output.push(node);
617        }
618
619        self.output
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use indoc::indoc;
626    use similar_asserts::assert_eq;
627
628    fn p(str: &str, range: Range<usize>) -> Node {
629        Node::new(MarkdownNode::Paragraph { text: str.into() }, range)
630    }
631
632    fn blockquote(nodes: Vec<Node>, range: Range<usize>) -> Node {
633        Node::new(MarkdownNode::BlockQuote { kind: None, nodes }, range)
634    }
635
636    fn item(str: &str, range: Range<usize>) -> Node {
637        Node::new(
638            MarkdownNode::Item {
639                kind: None,
640                text: str.into(),
641            },
642            range,
643        )
644    }
645
646    fn task(str: &str, range: Range<usize>) -> Node {
647        Node::new(
648            MarkdownNode::Item {
649                kind: Some(ItemKind::Unchecked),
650                text: str.into(),
651            },
652            range,
653        )
654    }
655
656    fn completed_task(str: &str, range: Range<usize>) -> Node {
657        Node::new(
658            MarkdownNode::Item {
659                kind: Some(ItemKind::HardChecked),
660                text: str.into(),
661            },
662            range,
663        )
664    }
665
666    fn heading(level: HeadingLevel, str: &str, range: Range<usize>) -> Node {
667        Node::new(
668            MarkdownNode::Heading {
669                level,
670                text: str.into(),
671            },
672            range,
673        )
674    }
675
676    fn h1(str: &str, range: Range<usize>) -> Node {
677        heading(HeadingLevel::H1, str, range)
678    }
679
680    fn h2(str: &str, range: Range<usize>) -> Node {
681        heading(HeadingLevel::H2, str, range)
682    }
683
684    fn h3(str: &str, range: Range<usize>) -> Node {
685        heading(HeadingLevel::H3, str, range)
686    }
687
688    fn h4(str: &str, range: Range<usize>) -> Node {
689        heading(HeadingLevel::H4, str, range)
690    }
691
692    fn h5(str: &str, range: Range<usize>) -> Node {
693        heading(HeadingLevel::H5, str, range)
694    }
695
696    fn h6(str: &str, range: Range<usize>) -> Node {
697        heading(HeadingLevel::H6, str, range)
698    }
699
700    use super::*;
701
702    #[test]
703    fn test_parse() {
704        let tests = [
705            (
706                indoc! {r#"# Heading 1
707
708                ## Heading 2
709
710                ### Heading 3
711
712                #### Heading 4
713
714                ##### Heading 5
715
716                ###### Heading 6
717                "#},
718                vec![
719                    h1("Heading 1", 0..12),
720                    h2("Heading 2", 13..26),
721                    h3("Heading 3", 27..41),
722                    h4("Heading 4", 42..57),
723                    h5("Heading 5", 58..74),
724                    h6("Heading 6", 75..92),
725                ],
726            ),
727            // TODO: Implement correct test case when `- [?] ` task item syntax is supported
728            // Now we interpret it as a regular paragraph
729            (
730                indoc! { r#"## Tasks
731
732                - [ ] Task
733
734                - [x] Completed task
735
736                - [?] Completed task
737                "#},
738                vec![
739                    h2("Tasks", 0..9),
740                    task("Task", 12..15),
741                    completed_task("Completed task", 24..27),
742                    p("[?] Completed task", 46..65),
743                ],
744            ),
745            (
746                indoc! {r#"## Quotes
747
748                You _can_ quote text by adding a `>` symbols before the text.
749
750                > 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.
751                >
752                >- Doug Engelbart, 1961
753                "#},
754                vec![
755                    h2("Quotes", 0..10),
756                    Node::new(MarkdownNode::Paragraph {
757                        text: vec![
758                            TextNode::new("You ".into(), None),
759                            TextNode::new("can".into(),None),
760                            TextNode::new(" quote text by adding a ".into(), None),
761                            TextNode::new(">".into(), Some(Style::Code)),
762                            TextNode::new(" symbols before the text.".into(), None),
763                        ]
764                        .into(),
765                    }, 11..73),
766                    blockquote(vec![
767                        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),
768                        item("Doug Engelbart, 1961", 272..295)
769                    ], 74..295),
770                ],
771            ),
772        ];
773
774        tests
775            .iter()
776            .for_each(|test| assert_eq!(from_str(test.0), test.1));
777    }
778}