weaver_lib/renderers/
mod.rs

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