1#[cfg(feature = "templates")]
10use crate::{
11 frontmatter,
12 plugin::{Plugin, PluginContext},
13 template_engine::{TemplateConfig, TemplateEngine},
14 MAX_DIR_DEPTH,
15};
16#[cfg(feature = "templates")]
17use anyhow::Result;
18#[cfg(feature = "templates")]
19use std::{
20 collections::HashMap,
21 fs,
22 path::{Path, PathBuf},
23};
24
25#[cfg(feature = "templates")]
35#[derive(Debug)]
36pub struct TemplatePlugin {
37 config: TemplateConfig,
38}
39
40#[cfg(feature = "templates")]
41impl TemplatePlugin {
42 #[must_use]
44 pub const fn new(config: TemplateConfig) -> Self {
45 Self { config }
46 }
47
48 #[must_use]
51 pub fn from_template_dir(template_dir: &Path) -> Self {
52 Self {
53 config: TemplateConfig {
54 template_dir: template_dir.join("tera"),
55 ..Default::default()
56 },
57 }
58 }
59}
60
61#[cfg(feature = "templates")]
62impl Plugin for TemplatePlugin {
63 fn name(&self) -> &'static str {
64 "templates"
65 }
66
67 fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
68 let sidecar_dir = ctx.build_dir.join(".meta");
70 let count = frontmatter::emit_sidecars(&ctx.content_dir, &sidecar_dir)?;
71 if count > 0 {
72 log::info!("[templates] Emitted {count} frontmatter sidecar(s)");
73 }
74 Ok(())
75 }
76
77 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
78 let Some(engine) = TemplateEngine::init(self.config.clone())? else {
79 log::info!(
80 "[templates] No templates at {}, skipping",
81 self.config.template_dir.display()
82 );
83 return Ok(());
84 };
85
86 let mut site_globals = ctx
88 .config
89 .as_ref()
90 .map(TemplateEngine::site_globals_from_config)
91 .unwrap_or_default();
92
93 let data_files = TemplateEngine::load_data_files(&ctx.content_dir);
95 if !data_files.is_empty() {
96 let _ = site_globals.insert(
97 "data".to_string(),
98 serde_json::Value::Object(data_files.into_iter().collect()),
99 );
100 }
101
102 let sidecar_dir = ctx.build_dir.join(".meta");
103 let html_files = collect_html_files(&ctx.site_dir)?;
104
105 let mut rendered = 0usize;
106 for html_path in &html_files {
107 let content = fs::read_to_string(html_path)?;
108
109 let fm = read_frontmatter_for_html(
111 html_path,
112 &ctx.site_dir,
113 &sidecar_dir,
114 );
115
116 let layout =
118 fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
119 let template_name = format!("{layout}.html");
120
121 match engine.render_page(
122 &template_name,
123 &content,
124 &fm,
125 &site_globals,
126 ) {
127 Ok(output) => {
128 fs::write(html_path, output)?;
129 rendered += 1;
130 }
131 Err(e) => {
132 log::warn!(
133 "[templates] Failed to render {}: {e}",
134 html_path.display()
135 );
136 }
137 }
138 }
139
140 if rendered > 0 {
141 log::info!("[templates] Rendered {rendered} page(s)");
142 }
143 Ok(())
144 }
145}
146
147#[cfg(feature = "templates")]
149fn read_frontmatter_for_html(
150 html_path: &Path,
151 site_dir: &Path,
152 sidecar_dir: &Path,
153) -> HashMap<String, serde_json::Value> {
154 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
155 let sidecar = sidecar_dir.join(rel).with_extension("meta.json");
156 if sidecar.exists() {
157 if let Ok(content) = fs::read_to_string(&sidecar) {
158 if let Ok(meta) = serde_json::from_str(&content) {
159 return meta;
160 }
161 }
162 }
163 HashMap::new()
164}
165
166#[cfg(feature = "templates")]
168fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
169 crate::walk::walk_files_bounded_depth(dir, "html", MAX_DIR_DEPTH)
170}
171
172#[cfg(all(test, feature = "templates"))]
173#[allow(clippy::unwrap_used, clippy::expect_used)]
174mod tests {
175 use super::*;
176 use crate::cmd::SsgConfig;
177 use crate::test_support::init_logger;
178 use std::fs;
179 use tempfile::{tempdir, TempDir};
180
181 fn layout() -> (TempDir, PathBuf, PathBuf, PathBuf, PathBuf) {
186 init_logger();
187 let dir = tempdir().expect("tempdir");
188 let content = dir.path().join("content");
189 let build = dir.path().join("build");
190 let site = dir.path().join("site");
191 let templates = dir.path().join("templates/tera");
192 for d in [&content, &build, &site, &templates] {
193 fs::create_dir_all(d).expect("mkdir");
194 }
195 (dir, content, build, site, templates)
196 }
197
198 fn make_config(root: &Path) -> SsgConfig {
199 SsgConfig {
200 site_name: "Test".to_string(),
201 site_title: "Test Site".to_string(),
202 site_description: "Desc".to_string(),
203 base_url: "http://localhost".to_string(),
204 language: "en-GB".to_string(),
205 content_dir: root.join("content"),
206 output_dir: root.join("build"),
207 template_dir: root.join("templates"),
208 serve_dir: None,
209 i18n: None,
210 }
211 }
212
213 fn setup_project(dir: &Path) {
214 let content = dir.join("content");
215 let build = dir.join("build");
216 let site = dir.join("site");
217 let templates = dir.join("templates/tera");
218 fs::create_dir_all(&content).unwrap();
219 fs::create_dir_all(&build).unwrap();
220 fs::create_dir_all(&site).unwrap();
221 fs::create_dir_all(&templates).unwrap();
222
223 fs::write(
224 templates.join("base.html"),
225 r#"<!DOCTYPE html>
226<html><head><title>{{ page.title | default("") }}</title></head>
227<body>{% block content %}{% endblock %}</body></html>"#,
228 )
229 .unwrap();
230
231 fs::write(
232 templates.join("page.html"),
233 r#"{% extends "base.html" %}
234{% block content %}{{ page.content | safe }}{% endblock %}"#,
235 )
236 .unwrap();
237
238 fs::write(
239 content.join("index.md"),
240 "---\ntitle: Home\nlayout: page\n---\n# Welcome\n",
241 )
242 .unwrap();
243
244 fs::write(site.join("index.html"), "<h1>Welcome</h1>").unwrap();
245
246 let meta_dir = build.join(".meta");
247 fs::create_dir_all(&meta_dir).unwrap();
248 fs::write(
249 meta_dir.join("index.meta.json"),
250 r#"{"title": "Home", "layout": "page"}"#,
251 )
252 .unwrap();
253 }
254
255 #[test]
256 fn test_template_plugin_renders() {
257 let dir = tempdir().unwrap();
258 setup_project(dir.path());
259
260 let plugin = TemplatePlugin::new(TemplateConfig {
261 template_dir: dir.path().join("templates/tera"),
262 ..Default::default()
263 });
264
265 let config = SsgConfig {
266 site_name: "Test".to_string(),
267 site_title: "Test Site".to_string(),
268 site_description: "Desc".to_string(),
269 base_url: "http://localhost".to_string(),
270 language: "en-GB".to_string(),
271 content_dir: dir.path().join("content"),
272 output_dir: dir.path().join("build"),
273 template_dir: dir.path().join("templates"),
274 serve_dir: None,
275 i18n: None,
276 };
277
278 let content_dir = config.content_dir.clone();
279 let output_dir = config.output_dir.clone();
280 let template_dir = config.template_dir.clone();
281 let site = dir.path().join("site");
282 let ctx = PluginContext::with_config(
283 &content_dir,
284 &output_dir,
285 &site,
286 &template_dir,
287 config,
288 );
289
290 plugin.after_compile(&ctx).unwrap();
291
292 let output =
293 fs::read_to_string(dir.path().join("site/index.html")).unwrap();
294 assert!(output.contains("<!DOCTYPE html>"));
295 assert!(output.contains("Home"));
296 assert!(output.contains("<h1>Welcome</h1>"));
297 }
298
299 #[test]
300 fn test_template_plugin_skips_missing_templates() {
301 let dir = tempdir().unwrap();
302 let site = dir.path().join("site");
303 fs::create_dir_all(&site).unwrap();
304 fs::write(site.join("index.html"), "<p>hello</p>").unwrap();
305
306 let plugin = TemplatePlugin::new(TemplateConfig {
307 template_dir: dir.path().join("nonexistent"),
308 ..Default::default()
309 });
310
311 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
312
313 plugin.after_compile(&ctx).unwrap();
314
315 let output = fs::read_to_string(site.join("index.html")).unwrap();
316 assert_eq!(output, "<p>hello</p>");
317 }
318
319 #[test]
320 fn name_returns_templates_identifier() {
321 let plugin = TemplatePlugin::new(TemplateConfig::default());
322 assert_eq!(plugin.name(), "templates");
323 }
324
325 #[test]
326 fn new_stores_supplied_config() {
327 let cfg = TemplateConfig {
328 template_dir: std::env::temp_dir().join("ssg_template_fake"),
329 ..Default::default()
330 };
331 let plugin = TemplatePlugin::new(cfg.clone());
332 assert_eq!(plugin.config.template_dir, cfg.template_dir);
333 }
334
335 #[test]
336 fn from_template_dir_nests_under_tera_subdirectory() {
337 let plugin =
338 TemplatePlugin::from_template_dir(Path::new("/my/templates"));
339 assert!(plugin.config.template_dir.ends_with("templates/tera"));
340 }
341
342 #[test]
343 fn before_compile_emits_sidecars_from_content_markdown() {
344 let (_tmp, content, build, _site, templates) = layout();
345 fs::write(content.join("index.md"), "---\ntitle: Test\n---\nbody")
346 .unwrap();
347
348 let plugin = TemplatePlugin::new(TemplateConfig {
349 template_dir: templates,
350 ..Default::default()
351 });
352 let ctx = PluginContext::new(&content, &build, &content, &content);
353
354 plugin.before_compile(&ctx).unwrap();
355 assert!(build.join(".meta").join("index.meta.json").exists());
356 }
357
358 #[test]
359 fn before_compile_no_markdown_files_still_returns_ok() {
360 let (_tmp, content, build, _site, templates) = layout();
361 let plugin = TemplatePlugin::new(TemplateConfig {
362 template_dir: templates,
363 ..Default::default()
364 });
365 let ctx = PluginContext::new(&content, &build, &content, &content);
366 plugin.before_compile(&ctx).unwrap();
367 }
368
369 #[test]
370 fn after_compile_without_config_uses_empty_site_globals() {
371 let dir = tempdir().unwrap();
372 setup_project(dir.path());
373
374 let plugin = TemplatePlugin::new(TemplateConfig {
375 template_dir: dir.path().join("templates/tera"),
376 ..Default::default()
377 });
378 let ctx = PluginContext::new(
379 &dir.path().join("content"),
380 &dir.path().join("build"),
381 &dir.path().join("site"),
382 &dir.path().join("templates"),
383 );
384
385 plugin.after_compile(&ctx).unwrap();
386 let output =
387 fs::read_to_string(dir.path().join("site").join("index.html"))
388 .unwrap();
389 assert!(output.contains("<!DOCTYPE html>"));
390 }
391
392 #[test]
393 fn after_compile_loads_data_files_into_context() {
394 let dir = tempdir().unwrap();
395 setup_project(dir.path());
396
397 let data = dir.path().join("data");
398 fs::create_dir_all(&data).unwrap();
399 fs::write(data.join("nav.toml"), r#"site = "demo""#).unwrap();
400
401 let plugin = TemplatePlugin::new(TemplateConfig {
402 template_dir: dir.path().join("templates/tera"),
403 ..Default::default()
404 });
405 let config = make_config(dir.path());
406 let ctx = PluginContext::with_config(
407 &config.content_dir.clone(),
408 &config.output_dir.clone(),
409 &dir.path().join("site"),
410 &config.template_dir.clone(),
411 config,
412 );
413
414 plugin.after_compile(&ctx).unwrap();
415 let output =
416 fs::read_to_string(dir.path().join("site").join("index.html"))
417 .unwrap();
418 assert!(output.contains("<!DOCTYPE html>"));
419 }
420
421 #[test]
422 fn after_compile_unknown_layout_does_not_propagate_error() {
423 let dir = tempdir().unwrap();
424 setup_project(dir.path());
425
426 let meta_dir = dir.path().join("build").join(".meta");
427 fs::write(
428 meta_dir.join("index.meta.json"),
429 r#"{"title": "Home", "layout": "unknown_layout_999"}"#,
430 )
431 .unwrap();
432
433 let plugin = TemplatePlugin::new(TemplateConfig {
434 template_dir: dir.path().join("templates/tera"),
435 ..Default::default()
436 });
437 let ctx = PluginContext::new(
438 &dir.path().join("content"),
439 &dir.path().join("build"),
440 &dir.path().join("site"),
441 &dir.path().join("templates"),
442 );
443
444 plugin
445 .after_compile(&ctx)
446 .expect("render failure must not propagate");
447 }
448
449 #[test]
450 fn after_compile_default_layout_is_page_when_missing_field() {
451 let dir = tempdir().unwrap();
452 setup_project(dir.path());
453
454 let meta_dir = dir.path().join("build").join(".meta");
455 fs::write(meta_dir.join("index.meta.json"), r#"{"title": "Home"}"#)
456 .unwrap();
457
458 let plugin = TemplatePlugin::new(TemplateConfig {
459 template_dir: dir.path().join("templates/tera"),
460 ..Default::default()
461 });
462 let ctx = PluginContext::new(
463 &dir.path().join("content"),
464 &dir.path().join("build"),
465 &dir.path().join("site"),
466 &dir.path().join("templates"),
467 );
468
469 plugin.after_compile(&ctx).unwrap();
470 let out =
471 fs::read_to_string(dir.path().join("site").join("index.html"))
472 .unwrap();
473 assert!(out.contains("<!DOCTYPE html>"));
474 }
475
476 #[test]
481 fn read_frontmatter_for_html_direct_sidecar_match() {
482 let dir = tempdir().unwrap();
483 let site = dir.path().join("site");
484 let sidecars = dir.path().join(".meta");
485 fs::create_dir_all(&site).unwrap();
486 fs::create_dir_all(&sidecars).unwrap();
487
488 let html = site.join("post.html");
489 fs::write(&html, "").unwrap();
490 fs::write(sidecars.join("post.meta.json"), r#"{"title": "Direct"}"#)
491 .unwrap();
492
493 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
494 assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("Direct"));
495 }
496
497 #[test]
498 fn read_frontmatter_for_html_invalid_sidecar_returns_empty() {
499 let dir = tempdir().unwrap();
500 let site = dir.path().join("site");
501 let sidecars = dir.path().join(".meta");
502 fs::create_dir_all(&site).unwrap();
503 fs::create_dir_all(&sidecars).unwrap();
504
505 let html = site.join("post.html");
506 fs::write(&html, "").unwrap();
507 fs::write(sidecars.join("post.meta.json"), "{not valid").unwrap();
508
509 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
510 assert!(meta.is_empty());
511 }
512
513 #[test]
514 fn read_frontmatter_for_html_no_match_returns_empty_map() {
515 let dir = tempdir().unwrap();
516 let site = dir.path().join("site");
517 let sidecars = dir.path().join(".meta");
518 fs::create_dir_all(&site).unwrap();
519 fs::create_dir_all(&sidecars).unwrap();
520
521 let html = site.join("ghost.html");
522 fs::write(&html, "").unwrap();
523
524 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
525 assert!(meta.is_empty());
526 }
527
528 #[test]
533 fn collect_html_files_filters_non_html_extensions() {
534 let dir = tempdir().unwrap();
535 fs::write(dir.path().join("a.html"), "").unwrap();
536 fs::write(dir.path().join("b.css"), "").unwrap();
537 fs::write(dir.path().join("c.js"), "").unwrap();
538
539 let files = collect_html_files(dir.path()).unwrap();
540 assert_eq!(files.len(), 1);
541 }
542
543 #[test]
544 fn collect_html_files_recurses_into_subdirectories() {
545 let dir = tempdir().unwrap();
546 let nested = dir.path().join("blog").join("2026");
547 fs::create_dir_all(&nested).unwrap();
548 fs::write(dir.path().join("index.html"), "").unwrap();
549 fs::write(nested.join("post.html"), "").unwrap();
550
551 let files = collect_html_files(dir.path()).unwrap();
552 assert_eq!(files.len(), 2);
553 }
554
555 #[test]
556 fn collect_html_files_returns_empty_for_missing_directory() {
557 let dir = tempdir().unwrap();
558 let result = collect_html_files(&dir.path().join("missing")).unwrap();
559 assert!(result.is_empty());
560 }
561}