1#[cfg(feature = "templates")]
11use anyhow::{Context, Result};
12#[cfg(feature = "templates")]
13use std::{collections::HashMap, path::PathBuf};
14
15#[cfg(feature = "templates")]
17#[derive(Debug, Clone)]
18pub struct TemplateConfig {
19 pub template_dir: PathBuf,
21 pub globals: HashMap<String, serde_json::Value>,
23 pub autoescape: bool,
25}
26
27#[cfg(feature = "templates")]
28impl Default for TemplateConfig {
29 fn default() -> Self {
30 Self {
31 template_dir: PathBuf::from("templates/tera"),
32 globals: HashMap::new(),
33 autoescape: true,
34 }
35 }
36}
37
38#[cfg(feature = "templates")]
40#[derive(Debug)]
41pub struct TemplateEngine {
42 env: minijinja::Environment<'static>,
43 config: TemplateConfig,
44}
45
46#[cfg(feature = "templates")]
47impl TemplateEngine {
48 pub fn init(config: TemplateConfig) -> Result<Option<Self>> {
54 if !config.template_dir.exists() {
55 return Ok(None);
56 }
57
58 let mut env = minijinja::Environment::new();
59 env.set_loader(minijinja::path_loader(&config.template_dir));
60
61 if !config.autoescape {
62 env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
63 }
64
65 env.add_filter("reading_time", reading_time_filter);
67 env.add_filter("slugify", slugify_filter);
68
69 Ok(Some(Self { env, config }))
70 }
71
72 pub fn render_page(
80 &self,
81 template_name: &str,
82 page_content: &str,
83 frontmatter: &HashMap<String, serde_json::Value>,
84 site_globals: &HashMap<String, serde_json::Value>,
85 ) -> Result<String> {
86 let mut page: serde_json::Map<String, serde_json::Value> = frontmatter
88 .iter()
89 .map(|(k, v)| (k.clone(), v.clone()))
90 .collect();
91 let _ = page.insert(
92 "content".to_string(),
93 serde_json::Value::String(page_content.to_string()),
94 );
95
96 let mut ctx = serde_json::Map::new();
98 let _ = ctx.insert("page".to_string(), serde_json::Value::Object(page));
99 let _ = ctx.insert(
100 "site".to_string(),
101 serde_json::Value::Object(
102 site_globals
103 .iter()
104 .map(|(k, v)| (k.clone(), v.clone()))
105 .collect(),
106 ),
107 );
108
109 for (k, v) in &self.config.globals {
111 let _ = ctx.insert(k.clone(), v.clone());
112 }
113
114 let tmpl_name = if self.env.get_template(template_name).is_ok() {
116 template_name
117 } else if self.env.get_template("page.html").is_ok() {
118 "page.html"
119 } else {
120 return Ok(page_content.to_string());
122 };
123
124 let tmpl = self.env.get_template(tmpl_name).with_context(|| {
125 format!("Failed to load template '{tmpl_name}'")
126 })?;
127
128 tmpl.render(serde_json::Value::Object(ctx))
129 .with_context(|| format!("Failed to render template '{tmpl_name}'"))
130 }
131
132 #[must_use]
134 pub fn site_globals_from_config(
135 config: &crate::cmd::SsgConfig,
136 ) -> HashMap<String, serde_json::Value> {
137 let mut globals = HashMap::new();
138 let _ = globals.insert(
139 "name".to_string(),
140 serde_json::Value::String(config.site_name.clone()),
141 );
142 let _ = globals.insert(
143 "title".to_string(),
144 serde_json::Value::String(config.site_title.clone()),
145 );
146 let _ = globals.insert(
147 "description".to_string(),
148 serde_json::Value::String(config.site_description.clone()),
149 );
150 let _ = globals.insert(
151 "base_url".to_string(),
152 serde_json::Value::String(config.base_url.clone()),
153 );
154 let _ = globals.insert(
155 "language".to_string(),
156 serde_json::Value::String(config.language.clone()),
157 );
158 globals
159 }
160
161 #[must_use]
168 pub fn load_data_files(
169 content_dir: &std::path::Path,
170 ) -> HashMap<String, serde_json::Value> {
171 let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
172 let mut data = HashMap::new();
173
174 if !data_dir.exists() {
175 return data;
176 }
177
178 let Ok(entries) = std::fs::read_dir(&data_dir) else {
179 return data;
180 };
181
182 for entry in entries.flatten() {
183 let path = entry.path();
184 if !path.is_file() {
185 continue;
186 }
187
188 let stem = path
189 .file_stem()
190 .unwrap_or_default()
191 .to_string_lossy()
192 .to_string();
193 let ext = path
194 .extension()
195 .unwrap_or_default()
196 .to_string_lossy()
197 .to_lowercase();
198
199 let Ok(content) = std::fs::read_to_string(&path) else {
200 continue;
201 };
202
203 let value: Option<serde_json::Value> = match ext.as_str() {
204 "toml" => toml::from_str::<serde_json::Value>(&content).ok(),
205 "json" => serde_json::from_str(&content).ok(),
206 "yml" | "yaml" => serde_json::from_str(&content).ok(),
207 _ => None,
208 };
209
210 if let Some(val) = value {
211 let _ = data.insert(stem, val);
212 }
213 }
214
215 data
216 }
217}
218
219#[cfg(feature = "templates")]
224fn reading_time_filter(value: String) -> String {
225 let word_count = value.split_whitespace().count();
226 let minutes = (word_count / 200).max(1);
227 format!("{minutes} min read")
228}
229
230#[cfg(feature = "templates")]
234fn slugify_filter(value: String) -> String {
235 value
236 .to_lowercase()
237 .chars()
238 .map(|c| if c.is_alphanumeric() { c } else { '-' })
239 .collect::<String>()
240 .split('-')
241 .filter(|s| !s.is_empty())
242 .collect::<Vec<_>>()
243 .join("-")
244}
245
246#[cfg(all(test, feature = "templates"))]
247#[allow(clippy::unwrap_used, clippy::expect_used)]
248mod tests {
249 use super::*;
250 use std::fs;
251 use std::path::Path;
252 use tempfile::tempdir;
253
254 fn setup_templates(dir: &Path) {
255 crate::test_support::init_logger();
256 let tera_dir = dir.join("tera");
257 fs::create_dir_all(&tera_dir).unwrap();
258
259 fs::write(
260 tera_dir.join("base.html"),
261 r#"<!DOCTYPE html>
262<html lang="{{ site.language | default("en") }}">
263<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
264{% block head_extra %}{% endblock %}
265</head>
266<body>
267<main>{% block content %}{% endblock %}</main>
268<footer>{% block footer %}<p>© {{ site.name | default("") }}</p>{% endblock %}</footer>
269</body>
270</html>"#,
271 )
272 .unwrap();
273
274 fs::write(
275 tera_dir.join("page.html"),
276 r#"{% extends "base.html" %}
277{% block content %}{{ page.content | safe }}{% endblock %}"#,
278 )
279 .unwrap();
280
281 fs::write(
282 tera_dir.join("post.html"),
283 r#"{% extends "base.html" %}
284{% block content %}
285<article>
286<h1>{{ page.title | default("") }}</h1>
287<time>{{ page.date | default("") }}</time>
288<p>{{ page.content | reading_time }}</p>
289{{ page.content | safe }}
290</article>
291{% endblock %}"#,
292 )
293 .unwrap();
294 }
295
296 #[test]
297 fn test_init_missing_dir() {
298 let config = TemplateConfig {
299 template_dir: PathBuf::from("/nonexistent/path"),
300 ..Default::default()
301 };
302 let result = TemplateEngine::init(config).unwrap();
303 assert!(result.is_none());
304 }
305
306 #[test]
307 fn test_init_and_render_page() {
308 let dir = tempdir().unwrap();
309 setup_templates(dir.path());
310
311 let config = TemplateConfig {
312 template_dir: dir.path().join("tera"),
313 ..Default::default()
314 };
315 let engine = TemplateEngine::init(config).unwrap().unwrap();
316
317 let mut fm = HashMap::new();
318 let _ = fm.insert(
319 "title".to_string(),
320 serde_json::Value::String("Hello".to_string()),
321 );
322
323 let mut site = HashMap::new();
324 let _ = site.insert(
325 "name".to_string(),
326 serde_json::Value::String("My Site".to_string()),
327 );
328 let _ = site.insert(
329 "language".to_string(),
330 serde_json::Value::String("en-GB".to_string()),
331 );
332
333 let result = engine
334 .render_page("page.html", "<p>Body</p>", &fm, &site)
335 .unwrap();
336
337 assert!(result.contains("Hello"));
338 assert!(result.contains("<p>Body</p>"));
339 assert!(result.contains("My Site"));
340 assert!(result.contains("en-GB"));
341 }
342
343 #[test]
344 fn test_render_post_with_reading_time() {
345 let dir = tempdir().unwrap();
346 setup_templates(dir.path());
347
348 let config = TemplateConfig {
349 template_dir: dir.path().join("tera"),
350 ..Default::default()
351 };
352 let engine = TemplateEngine::init(config).unwrap().unwrap();
353
354 let content = "word ".repeat(600); let mut fm = HashMap::new();
356 let _ = fm.insert(
357 "title".to_string(),
358 serde_json::Value::String("Post".to_string()),
359 );
360 let _ = fm.insert(
361 "date".to_string(),
362 serde_json::Value::String("2026-01-01".to_string()),
363 );
364
365 let site = HashMap::new();
366 let result = engine
367 .render_page("post.html", &content, &fm, &site)
368 .unwrap();
369
370 assert!(result.contains("3 min read"));
371 assert!(result.contains("<article>"));
372 }
373
374 #[test]
375 fn test_fallback_to_page_html() {
376 let dir = tempdir().unwrap();
377 setup_templates(dir.path());
378
379 let config = TemplateConfig {
380 template_dir: dir.path().join("tera"),
381 ..Default::default()
382 };
383 let engine = TemplateEngine::init(config).unwrap().unwrap();
384
385 let fm = HashMap::new();
386 let site = HashMap::new();
387 let result = engine
388 .render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
389 .unwrap();
390
391 assert!(result.contains("<p>fallback</p>"));
392 }
393
394 #[test]
395 fn test_reading_time_filter_direct() {
396 let text = "word ".repeat(400);
397 let result = reading_time_filter(text);
398 assert_eq!(result, "2 min read");
399 }
400
401 #[test]
402 fn test_slugify_filter() {
403 assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
404 assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
405 }
406
407 #[test]
412 fn load_data_files_missing_data_dir_returns_empty_map() {
413 let dir = tempdir().unwrap();
414 let content = dir.path().join("content");
415 fs::create_dir_all(&content).unwrap();
416 let result = TemplateEngine::load_data_files(&content);
417 assert!(result.is_empty());
418 }
419
420 #[test]
421 fn load_data_files_parses_toml_and_json_and_yaml() {
422 let dir = tempdir().unwrap();
423 let content = dir.path().join("content");
424 fs::create_dir_all(&content).unwrap();
425 let data = dir.path().join("data");
426 fs::create_dir_all(&data).unwrap();
427
428 fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
429 fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
430 .unwrap();
431 fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
432 fs::write(data.join("ignored.txt"), "not parsed").unwrap();
433
434 let sub = data.join("sub");
435 fs::create_dir_all(&sub).unwrap();
436 fs::write(sub.join("inside.json"), "{}").unwrap();
437
438 let result = TemplateEngine::load_data_files(&content);
439 assert!(result.contains_key("site"));
440 assert!(result.contains_key("nav"));
441 assert!(result.contains_key("conf"));
442 assert!(!result.contains_key("ignored"));
443 assert!(!result.contains_key("sub"));
444 }
445
446 #[test]
447 fn load_data_files_skips_files_with_invalid_content() {
448 let dir = tempdir().unwrap();
449 let content = dir.path().join("content");
450 fs::create_dir_all(&content).unwrap();
451 let data = dir.path().join("data");
452 fs::create_dir_all(&data).unwrap();
453
454 fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
455 fs::write(data.join("broken.json"), "{not valid").unwrap();
456 fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
457
458 let result = TemplateEngine::load_data_files(&content);
459 assert!(result.contains_key("good"));
460 assert!(!result.contains_key("broken"));
461 }
462
463 #[test]
464 fn load_data_files_ignores_unsupported_extensions() {
465 let dir = tempdir().unwrap();
466 let content = dir.path().join("content");
467 fs::create_dir_all(&content).unwrap();
468 let data = dir.path().join("data");
469 fs::create_dir_all(&data).unwrap();
470
471 fs::write(data.join("a.xml"), "<x/>").unwrap();
472 fs::write(data.join("b.csv"), "a,b").unwrap();
473 fs::write(data.join("c"), "no extension").unwrap();
474
475 let result = TemplateEngine::load_data_files(&content);
476 assert!(result.is_empty());
477 }
478
479 #[test]
484 fn render_page_injects_custom_globals_from_config() {
485 let dir = tempdir().unwrap();
486 setup_templates(dir.path());
487
488 fs::write(
490 dir.path().join("tera").join("branded.html"),
491 r"<p>{{ brand }}</p>",
492 )
493 .unwrap();
494
495 let config = TemplateConfig {
496 template_dir: dir.path().join("tera"),
497 globals: {
498 let mut g = HashMap::new();
499 let _ = g.insert(
500 "brand".to_string(),
501 serde_json::Value::String("Acme".to_string()),
502 );
503 g
504 },
505 ..Default::default()
506 };
507 let engine = TemplateEngine::init(config).unwrap().unwrap();
508
509 let result = engine
510 .render_page("branded.html", "", &HashMap::new(), &HashMap::new())
511 .unwrap();
512 assert!(result.contains("Acme"));
513 }
514
515 #[test]
516 fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
517 {
518 let dir = tempdir().unwrap();
519 let tera_dir = dir.path().join("tera");
520 fs::create_dir_all(&tera_dir).unwrap();
521 fs::write(
523 tera_dir.join("base.html"),
524 r"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>",
525 )
526 .unwrap();
527
528 let config = TemplateConfig {
529 template_dir: tera_dir,
530 ..Default::default()
531 };
532 let engine = TemplateEngine::init(config).unwrap().unwrap();
533
534 let content = "<p>raw content</p>";
535 let result = engine
536 .render_page(
537 "nonexistent.html",
538 content,
539 &HashMap::new(),
540 &HashMap::new(),
541 )
542 .unwrap();
543 assert_eq!(result, content);
544 }
545
546 #[test]
547 fn init_with_autoescape_false() {
548 let dir = tempdir().unwrap();
549 setup_templates(dir.path());
550
551 let config = TemplateConfig {
552 template_dir: dir.path().join("tera"),
553 autoescape: false,
554 ..Default::default()
555 };
556 let engine = TemplateEngine::init(config).unwrap().unwrap();
557 let result = engine
558 .render_page(
559 "page.html",
560 "<p>x</p>",
561 &HashMap::new(),
562 &HashMap::new(),
563 )
564 .unwrap();
565 assert!(result.contains("<p>x</p>"));
566 }
567
568 #[test]
569 fn init_with_broken_template_errors_on_render() {
570 let dir = tempdir().unwrap();
571 let tera_dir = dir.path().join("tera");
572 fs::create_dir_all(&tera_dir).unwrap();
573 fs::write(tera_dir.join("broken.html"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
575
576 let config = TemplateConfig {
577 template_dir: tera_dir,
578 ..Default::default()
579 };
580 let engine = TemplateEngine::init(config).unwrap().unwrap();
582 let result = engine.render_page(
584 "broken.html",
585 "",
586 &HashMap::new(),
587 &HashMap::new(),
588 );
589 assert!(result.is_err());
590 }
591
592 #[test]
593 #[cfg(unix)]
594 fn load_data_files_unreadable_file_continues_silently() {
595 let dir = tempdir().unwrap();
596 let content = dir.path().join("content");
597 fs::create_dir_all(&content).unwrap();
598 let data = dir.path().join("data");
599 fs::create_dir_all(&data).unwrap();
600
601 fs::create_dir_all(data.join("not-really.toml")).unwrap();
602 fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
603
604 let result = TemplateEngine::load_data_files(&content);
605 assert!(result.contains_key("real"));
606 assert!(!result.contains_key("not-really"));
607 }
608
609 #[test]
610 fn load_data_files_data_dir_is_a_file_returns_empty() {
611 let dir = tempdir().unwrap();
612 let content = dir.path().join("content");
613 fs::create_dir_all(&content).unwrap();
614 let data = dir.path().join("data");
615 fs::write(&data, "I am a file, not a directory").unwrap();
616
617 let result = TemplateEngine::load_data_files(&content);
618 assert!(result.is_empty());
619 }
620
621 #[test]
622 fn render_page_propagates_render_errors() {
623 let dir = tempdir().unwrap();
624 let tera_dir = dir.path().join("tera");
625 fs::create_dir_all(&tera_dir).unwrap();
626 fs::write(
628 tera_dir.join("broken.html"),
629 r"{{ page.title | nonexistent_filter }}",
630 )
631 .unwrap();
632
633 let config = TemplateConfig {
634 template_dir: tera_dir,
635 ..Default::default()
636 };
637 let engine = TemplateEngine::init(config).unwrap().unwrap();
638
639 let mut fm = HashMap::new();
640 let _ = fm.insert(
641 "title".to_string(),
642 serde_json::Value::String("T".to_string()),
643 );
644
645 let result =
646 engine.render_page("broken.html", "", &fm, &HashMap::new());
647 assert!(result.is_err());
648 }
649}