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 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 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)); }
160 }
161 }
162 _ => {
163 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 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 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}