weaver_lib/renderers/
mod.rs

1pub mod globals;
2use async_trait::async_trait;
3use futures::StreamExt;
4use globals::LiquidGlobals;
5use markdown::CompileOptions;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use markdown::{ParseOptions, mdast::Node};
10use slug::slugify;
11use tokio::sync::Mutex;
12
13use crate::document::Heading;
14use crate::filters::raw_html::RawHtml;
15use crate::routes::route_from_path;
16use crate::{BuildError, document::Document};
17
18#[derive(Debug, PartialEq)]
19pub struct WritableFile {
20    pub contents: String,
21    pub path: PathBuf,
22}
23
24#[async_trait]
25pub trait ContentRenderer {
26    async fn render(&self, data: &mut LiquidGlobals) -> Result<WritableFile, BuildError>;
27}
28
29fn out_path_for_document(document: &Document, weaver_config: &Arc<crate::WeaverConfig>) -> PathBuf {
30    let out_base = weaver_config.build_dir.clone();
31    let document_content_path = route_from_path(
32        weaver_config.content_dir.clone().into(),
33        document.at_path.clone().into(),
34    );
35
36    format!("{}{}index.html", out_base, document_content_path).into()
37}
38
39pub enum TemplateRenderer<'a> {
40    LiquidBuilder {
41        liquid_parser: liquid::Parser,
42        for_document: &'a Document,
43        weaver_template: Arc<Mutex<crate::Template>>,
44        weaver_config: Arc<crate::WeaverConfig>,
45    },
46}
47
48#[async_trait]
49impl<'a> ContentRenderer for TemplateRenderer<'a> {
50    async fn render(&self, data: &mut LiquidGlobals) -> Result<WritableFile, BuildError> {
51        match self {
52            Self::LiquidBuilder {
53                liquid_parser,
54                weaver_template,
55                for_document,
56                weaver_config,
57            } => {
58                let wtemplate = weaver_template.lock().await;
59
60                match liquid_parser
61                    .parse(&wtemplate.contents)
62                    .unwrap()
63                    .render(&data.to_liquid_data())
64                {
65                    Ok(result) => Ok(WritableFile {
66                        contents: result,
67                        path: out_path_for_document(for_document, weaver_config),
68                    }),
69                    Err(err) => {
70                        dbg!("Template rendering error {:#?}", &err);
71                        Err(BuildError::Err(err.to_string()))
72                    }
73                }
74            }
75        }
76    }
77}
78
79impl<'a> TemplateRenderer<'a> {
80    pub fn new(
81        template: Arc<Mutex<crate::Template>>,
82        for_document: &'a Document,
83        weaver_config: Arc<crate::WeaverConfig>,
84    ) -> Self {
85        Self::LiquidBuilder {
86            liquid_parser: liquid::ParserBuilder::with_stdlib()
87                .filter(RawHtml)
88                .build()
89                .unwrap(),
90            weaver_template: template.clone(),
91            for_document,
92            weaver_config,
93        }
94    }
95}
96
97pub struct MarkdownRenderer {
98    document: Arc<Mutex<Document>>,
99    templates: Arc<Vec<Arc<Mutex<crate::Template>>>>,
100    weaver_config: Arc<crate::WeaverConfig>,
101}
102
103#[async_trait]
104impl ContentRenderer for MarkdownRenderer {
105    async fn render(&self, data: &mut LiquidGlobals) -> Result<WritableFile, BuildError> {
106        let mut doc_guard = self.document.lock().await;
107        let template = self
108            .find_template_by_string(doc_guard.metadata.template.clone())
109            .await
110            .unwrap();
111
112        doc_guard.toc = self.toc_from_document(&doc_guard);
113
114        let markdown_html = markdown::to_html_with_options(
115            doc_guard.markdown.as_str(),
116            &markdown::Options {
117                compile: CompileOptions {
118                    allow_dangerous_html: true,
119                    ..CompileOptions::gfm()
120                },
121                ..markdown::Options::gfm()
122            },
123        )
124        .expect("failed to render markdown to html");
125        let template_renderer =
126            TemplateRenderer::new(template.clone(), &doc_guard, self.weaver_config.clone());
127        data.page.body = markdown_html;
128
129        template_renderer.render(&mut data.to_owned()).await
130    }
131}
132
133impl MarkdownRenderer {
134    pub fn new(
135        document: Arc<Mutex<Document>>,
136        templates: Arc<Vec<Arc<Mutex<crate::Template>>>>,
137        weaver_config: Arc<crate::WeaverConfig>,
138    ) -> Self {
139        Self {
140            document,
141            templates,
142            weaver_config,
143        }
144    }
145
146    // Helper function to recursively extract text from inline nodes
147    // This is needed to get the raw text content of a heading or other inline structures
148    fn extract_text_from_mdast_inline(node: &Node) -> String {
149        let mut text = String::new();
150        match &node {
151            Node::Text(text_node) => text.push_str(&text_node.value),
152            Node::Code(code_node) => text.push_str(&code_node.value),
153            // Add other inline node types you want to include text from (e.g., Strong, Emphasis, Link)
154            // These nodes typically have children, so we need to recurse
155            Node::Emphasis(_) | Node::Strong(_) | Node::Link(_) => {
156                if let Some(children) = node.children() {
157                    for child in children.iter() {
158                        text.push_str(&Self::extract_text_from_mdast_inline(child)); // Recurse
159                    }
160                }
161            }
162            _ => {
163                // For other node types, if they have children, recurse into them
164                if let Some(children) = node.children() {
165                    for child in children.iter() {
166                        text.push_str(&Self::extract_text_from_mdast_inline(child));
167                    }
168                }
169            }
170        }
171        text
172    }
173
174    fn collect_mdast_headings_to_map(node: &Node, headings_map: &mut Vec<Heading>) {
175        // Check if the current node is a Heading
176        if let Node::Heading(heading) = &node {
177            let heading_text = if let Some(children) = node.children() {
178                let mut text = String::new();
179                for child in children.iter() {
180                    text.push_str(&Self::extract_text_from_mdast_inline(child));
181                }
182                text
183            } else {
184                String::new()
185            };
186            let slug = slugify(&heading_text);
187            if !slug.is_empty() {
188                headings_map.push(Heading {
189                    slug,
190                    text: heading_text,
191                    depth: heading.depth,
192                });
193            }
194        }
195
196        // Recursively visit children of the current node.
197        // Headings can appear as children of Root, BlockQuote, List, ListItem, etc.
198        if let Some(children) = node.children() {
199            for child in children.iter() {
200                Self::collect_mdast_headings_to_map(child, headings_map);
201            }
202        }
203    }
204
205    fn toc_from_document(&self, document: &Document) -> Vec<Heading> {
206        let mut toc_map = vec![];
207        let ast = markdown::to_mdast(document.markdown.as_str(), &ParseOptions::gfm()).unwrap();
208        Self::collect_mdast_headings_to_map(&ast, &mut toc_map);
209        toc_map
210    }
211
212    async fn find_template_by_string(
213        &self,
214        template_name: String,
215    ) -> Option<&Arc<Mutex<crate::Template>>> {
216        futures::stream::iter(self.templates.iter())
217            .filter(|&t| {
218                let name = template_name.clone();
219                Box::pin(
220                    async move { t.lock().await.at_path.ends_with(format!("{}.liquid", name)) },
221                )
222            })
223            .next()
224            .await
225    }
226}
227
228#[cfg(test)]
229mod test {
230    use std::collections::HashMap;
231
232    use crate::{config::WeaverConfig, template::Template};
233
234    use super::*;
235
236    use pretty_assertions::assert_eq;
237
238    #[tokio::test]
239    async fn test_liquid() {
240        let base_path_wd = std::env::current_dir().unwrap().display().to_string();
241        let base_path = format!("{}/test_fixtures/example", base_path_wd);
242        let template = Template::new_from_path(
243            format!("{}/test_fixtures/liquid/template.liquid", base_path_wd).into(),
244        );
245        let doc_arc =
246            Document::new_from_path(format!("{}/content/with_headings.md", base_path).into());
247        let config = Arc::new(WeaverConfig::new_from_path(base_path.clone().into()));
248        let renderer =
249            TemplateRenderer::new(Arc::new(Mutex::new(template)), &doc_arc, config.clone());
250
251        let mut data = LiquidGlobals::new(
252            Arc::new(Mutex::new(Document::new_from_path(
253                format!("{}/content/with_headings.md", base_path).into(),
254            ))),
255            &Arc::new(HashMap::new()),
256        )
257        .await;
258
259        assert_eq!(
260            WritableFile {
261                contents: "<!doctype html>
262<html>
263	<head>
264		<title>test</title>
265	</head>
266	<body></body>
267</html>
268"
269                .into(),
270                path: format!("{}/site/with_headings/index.html", base_path).into(),
271            },
272            renderer.render(&mut data).await.unwrap()
273        );
274    }
275
276    #[test]
277    fn test_markdown_toc_generation() {
278        let base_path_wd = std::env::current_dir().unwrap().display().to_string();
279        let base_path = format!("{}/test_fixtures/markdown", base_path_wd);
280        let doc_arc = Arc::new(Mutex::new(Document::new_from_path(
281            format!("{}/with_headings.md", base_path).into(),
282        )));
283        let config_path = format!("{}/test_fixtures/config/custom_config", base_path_wd);
284        let config = Arc::new(WeaverConfig::new_from_path(config_path.clone().into()));
285        let renderer = MarkdownRenderer::new(doc_arc.clone(), vec![].into(), config.clone());
286
287        assert_eq!(
288            vec![
289                Heading {
290                    depth: 1,
291                    text: "heading 1".into(),
292                    slug: "heading-1".into(),
293                },
294                Heading {
295                    depth: 2,
296                    text: "heading 2".into(),
297                    slug: "heading-2".into(),
298                },
299                Heading {
300                    depth: 3,
301                    text: "heading 3".into(),
302                    slug: "heading-3".into(),
303                },
304                Heading {
305                    depth: 4,
306                    text: "heading 4".into(),
307                    slug: "heading-4".into(),
308                },
309                Heading {
310                    depth: 5,
311                    text: "heading 5".into(),
312                    slug: "heading-5".into(),
313                },
314                Heading {
315                    depth: 6,
316                    text: "heading 6".into(),
317                    slug: "heading-6".into(),
318                },
319            ],
320            renderer.toc_from_document(&Document::new_from_path(
321                format!("{}/with_headings.md", base_path).into(),
322            ))
323        );
324    }
325
326    #[tokio::test]
327    async fn test_render() {
328        let base_path_wd = std::env::current_dir().unwrap().display().to_string();
329        let base_path = format!("{}/test_fixtures/example", base_path_wd);
330        let template =
331            Template::new_from_path(format!("{}/templates/default.liquid", base_path).into());
332        let doc_arc = Arc::new(Mutex::new(Document::new_from_path(
333            format!("{}/content/with_headings.md", base_path).into(),
334        )));
335        let config = Arc::new(WeaverConfig::new_from_path(base_path.clone().into()));
336        let renderer = MarkdownRenderer::new(
337            doc_arc.clone(),
338            vec![Arc::new(Mutex::new(template))].into(),
339            config.clone(),
340        );
341
342        let mut data = LiquidGlobals::new(doc_arc, &Arc::new(HashMap::new())).await;
343        let result = renderer.render(&mut data).await;
344
345        assert_eq!(
346            WritableFile {
347                contents: r#"<!doctype html>
348<html lang="en">
349	<head>
350		<meta charset="utf-8" />
351
352		<title>test</title>
353		<link rel="icon" href="/static/favicon.ico" />
354		<meta name="viewport" content="width=device-width, initial-scale=1" />
355
356		<meta name="description" content="test"/>
357		<meta name="keywords" content="test"/>
358	</head>
359	<body>
360		<main>
361			<h1>test</h1>
362			<article>
363				<h1>heading 1</h1>
364<p>I am a paragraph.</p>
365<h2>heading <span>2</span></h2>
366<p>I'm the second paragraph.</p>
367<h3>heading 3</h3>
368<h4>heading 4</h4>
369<h5>heading 5</h5>
370<h6>heading 6</h6>
371			</article>
372		</main>
373	</body>
374</html>
375
376"#
377                .into(),
378                path: format!("{}/site/with_headings/index.html", base_path).into()
379            },
380            result.unwrap()
381        );
382    }
383}