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}