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#[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 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 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)); }
216 }
217 }
218 _ => {
219 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 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 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}