Skip to main content

ephemeris_documents/markdown/
render.rs

1//! Rendering Markdown blocks to `CommonMark`.
2
3use comrak::nodes::{
4    Ast, LineColumn, ListType, NodeCode, NodeCodeBlock, NodeHeading, NodeLink, NodeList, NodeValue,
5};
6use comrak::{Arena, Options, format_commonmark};
7
8use super::ast::{MarkdownBlock, MarkdownInline};
9use crate::error::{DocumentError, InternalError, MarkdownError};
10
11impl MarkdownBlock {
12    /// Render `blocks` into a complete document.
13    ///
14    /// # Errors
15    /// Returns an error if the AST contains invalid values or cannot be
16    /// rendered.
17    pub(super) fn render_all(blocks: &[Self]) -> Result<String, DocumentError> {
18        let arena = Arena::new();
19        let tree = Tree { arena: &arena };
20        let root = tree.node(NodeValue::Document);
21        tree.append_blocks(root, blocks)?;
22        root.validate()
23            .map_err(|validation| InternalError::markdown_tree(&validation))?;
24        let mut options = Options::default();
25        options.render.prefer_fenced = true;
26        let mut output = String::new();
27        format_commonmark(root, &options, &mut output)
28            .map_err(|source| MarkdownError::RenderFailed { source })?;
29        Ok(output)
30    }
31}
32
33/// A `comrak` tree under construction.
34struct Tree<'a> {
35    arena: &'a Arena<'a>,
36}
37
38impl<'a> Tree<'a> {
39    fn node(&self, value: NodeValue) -> comrak::Node<'a> {
40        self.arena
41            .alloc(Ast::new(value, LineColumn::default()).into())
42    }
43
44    fn append_blocks(
45        &self,
46        parent: comrak::Node<'a>,
47        blocks: &[MarkdownBlock],
48    ) -> Result<(), DocumentError> {
49        for block in blocks {
50            match block {
51                MarkdownBlock::Heading { level, content } => {
52                    let heading = self.node(NodeValue::Heading(NodeHeading {
53                        level: level.get(),
54                        ..Default::default()
55                    }));
56                    self.append_inlines(heading, content);
57                    parent.append(heading);
58                }
59                MarkdownBlock::Paragraph { content } => {
60                    let paragraph = self.node(NodeValue::Paragraph);
61                    self.append_inlines(paragraph, content);
62                    parent.append(paragraph);
63                }
64                MarkdownBlock::UnorderedList { items } => {
65                    let metadata = NodeList {
66                        list_type: ListType::Bullet,
67                        bullet_char: b'-',
68                        tight: true,
69                        ..Default::default()
70                    };
71                    let list = self.node(NodeValue::List(metadata));
72                    for item in items {
73                        let item_node = self.node(NodeValue::Item(metadata));
74                        self.append_blocks(item_node, item)?;
75                        list.append(item_node);
76                    }
77                    parent.append(list);
78                }
79                MarkdownBlock::CodeBlock { language, value } => {
80                    if language
81                        .as_deref()
82                        .is_some_and(|info| info.contains(['\r', '\n']))
83                    {
84                        return Err(MarkdownError::CodeBlockLanguageMultiline.into());
85                    }
86                    let mut literal = value.clone();
87                    if !literal.ends_with('\n') {
88                        literal.push('\n');
89                    }
90                    parent.append(self.node(NodeValue::CodeBlock(Box::new(NodeCodeBlock {
91                        fenced: true,
92                        fence_char: b'`',
93                        fence_length: 3,
94                        info: language.clone().unwrap_or_default(),
95                        literal,
96                        ..Default::default()
97                    }))));
98                }
99            }
100        }
101        Ok(())
102    }
103
104    fn append_inlines(&self, parent: comrak::Node<'a>, inlines: &[MarkdownInline]) {
105        for inline in inlines {
106            match inline {
107                MarkdownInline::Text { value } => {
108                    parent.append(self.node(NodeValue::Text(value.clone().into())));
109                }
110                MarkdownInline::Emphasis { content } => {
111                    let emphasis = self.node(NodeValue::Emph);
112                    self.append_inlines(emphasis, content);
113                    parent.append(emphasis);
114                }
115                MarkdownInline::Strong { content } => {
116                    let strong = self.node(NodeValue::Strong);
117                    self.append_inlines(strong, content);
118                    parent.append(strong);
119                }
120                MarkdownInline::Code { value } => {
121                    parent.append(self.node(NodeValue::Code(NodeCode {
122                        literal: value.clone(),
123                        ..Default::default()
124                    })));
125                }
126                MarkdownInline::Link {
127                    destination,
128                    content,
129                } => {
130                    let link = self.node(NodeValue::Link(Box::new(NodeLink {
131                        url: destination.clone(),
132                        title: String::new(),
133                    })));
134                    self.append_inlines(link, content);
135                    parent.append(link);
136                }
137            }
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests;