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