1use std::fs;
9use std::io::{self, Write};
10use std::path::{Component, Path, PathBuf};
11
12use crate::core::{Node, Render, RenderOptions};
13use crate::document::Document;
14
15#[derive(Debug, Clone)]
28pub struct StaticSiteGenerator {
29 output_directory: PathBuf,
30}
31
32impl StaticSiteGenerator {
33 pub fn new(output_directory: impl Into<PathBuf>) -> Self {
35 Self {
36 output_directory: output_directory.into(),
37 }
38 }
39
40 #[must_use]
42 pub fn output_directory(&self) -> &Path {
43 &self.output_directory
44 }
45
46 pub fn generate(
54 &self,
55 document: &Document,
56 path: &str,
57 options: &RenderOptions,
58 ) -> io::Result<()> {
59 self.write_atomic(path, |file| {
60 let mut buffered = io::BufWriter::new(file);
61 document.render_to(&mut buffered, options)?;
62 buffered.flush()
63 })
64 }
65
66 pub fn generate_multiple(
77 &self,
78 documents: &[(Document, String)],
79 options: &RenderOptions,
80 ) -> io::Result<()> {
81 #[cfg(feature = "parallel")]
82 let results: Vec<(String, io::Result<()>)> = {
83 use rayon::prelude::*;
84 documents
85 .par_iter()
86 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
87 .collect()
88 };
89
90 #[cfg(not(feature = "parallel"))]
91 let results: Vec<(String, io::Result<()>)> = documents
92 .iter()
93 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
94 .collect();
95
96 collect_failures(results)
97 }
98
99 pub fn generate_page(
104 &self,
105 page: &Node,
106 path: &str,
107 options: &RenderOptions,
108 doctype: bool,
109 ) -> io::Result<()> {
110 let mut content = String::with_capacity(1024);
111 if doctype {
112 content.push_str("<!DOCTYPE html>\n");
113 }
114 page.write_into(&mut content, options, 0);
115 self.write_file(&content, path)
116 }
117
118 pub fn copy_asset(&self, from: impl AsRef<Path>, to: &str) -> io::Result<()> {
123 let destination = self.resolve(to)?;
124 if let Some(parent) = destination.parent() {
125 fs::create_dir_all(parent)?;
126 }
127 if destination.exists() {
128 fs::remove_file(&destination)?;
129 }
130 fs::copy(from, destination)?;
131 Ok(())
132 }
133
134 pub fn clean(&self, create_directory: bool) -> io::Result<()> {
141 guard_destructive_path(&self.output_directory)?;
142
143 if self.output_directory.exists() {
144 fs::remove_dir_all(&self.output_directory)?;
145 }
146 if create_directory {
147 fs::create_dir_all(&self.output_directory)?;
148 }
149 Ok(())
150 }
151
152 pub fn write_file(&self, content: &str, path: &str) -> io::Result<()> {
160 self.write_atomic(path, |file| file.write_all(content.as_bytes()))
161 }
162
163 fn write_atomic(
168 &self,
169 path: &str,
170 write_contents: impl FnOnce(&mut fs::File) -> io::Result<()>,
171 ) -> io::Result<()> {
172 let destination = self.resolve(path)?;
173 if let Some(parent) = destination.parent() {
174 fs::create_dir_all(parent)?;
175 }
176
177 let temporary = destination.with_extension("winged-tmp");
178 let mut file = fs::File::create(&temporary)?;
179 if let Err(error) = write_contents(&mut file) {
180 let _ = fs::remove_file(&temporary);
181 return Err(error);
182 }
183 if let Err(error) = fs::rename(&temporary, &destination) {
184 let _ = fs::remove_file(&temporary);
185 return Err(error);
186 }
187 Ok(())
188 }
189
190 fn resolve(&self, path: &str) -> io::Result<PathBuf> {
192 let candidate = Path::new(path);
193 if candidate.is_absolute() || candidate.components().any(|c| c == Component::ParentDir) {
194 return Err(io::Error::new(
195 io::ErrorKind::InvalidInput,
196 format!("path {path:?} escapes the output directory"),
197 ));
198 }
199 Ok(self.output_directory.join(candidate))
200 }
201}
202
203fn guard_destructive_path(path: &Path) -> io::Result<()> {
205 let reject = |reason: &str| {
206 Err(io::Error::new(
207 io::ErrorKind::InvalidInput,
208 format!("refusing to clean {}: {reason}", path.display()),
209 ))
210 };
211
212 if path.as_os_str().is_empty() {
213 return reject("the output directory is empty");
214 }
215 if path.parent().is_none() {
216 return reject("the output directory is a filesystem root");
217 }
218 if path.components().any(|c| c == Component::ParentDir) {
219 return reject("the output directory contains a `..` component");
220 }
221 Ok(())
222}
223
224fn collect_failures(results: Vec<(String, io::Result<()>)>) -> io::Result<()> {
226 let failures: Vec<String> = results
227 .into_iter()
228 .filter_map(|(path, result)| result.err().map(|e| format!("{path}: {e}")))
229 .collect();
230
231 if failures.is_empty() {
232 Ok(())
233 } else {
234 Err(io::Error::other(format!(
235 "{} page(s) failed to generate:\n {}",
236 failures.len(),
237 failures.join("\n ")
238 )))
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::elements::{body, h1, head, html_tag, title};
246
247 struct TempDir(PathBuf);
249
250 impl TempDir {
251 fn new(name: &str) -> Self {
252 let path = std::env::temp_dir().join(format!("molt-{name}"));
253 let _ = fs::remove_dir_all(&path);
254 fs::create_dir_all(&path).expect("temp dir is creatable");
255 Self(path)
256 }
257 }
258
259 impl Drop for TempDir {
260 fn drop(&mut self) {
261 let _ = fs::remove_dir_all(&self.0);
262 }
263 }
264
265 fn page(text: &str) -> Document {
266 Document::new(Some("en"))
267 .head_children([title().text(text)])
268 .body_children([h1().text(text)])
269 }
270
271 #[test]
273 fn generate_writes_a_rendered_document() {
274 let dir = TempDir::new("generate");
275 let site = StaticSiteGenerator::new(&dir.0);
276 site.generate(&page("Home"), "index.html", &RenderOptions::pretty())
277 .expect("written");
278
279 let written = fs::read_to_string(dir.0.join("index.html")).expect("readable");
280 assert!(written.starts_with("<!DOCTYPE html>"));
281 assert!(written.contains("<h1>Home</h1>"));
282 }
283
284 #[test]
287 fn generate_replaces_a_file_with_the_rendered_bytes() {
288 let dir = TempDir::new("stream");
289 let site = StaticSiteGenerator::new(&dir.0);
290 let destination = dir.0.join("index.html");
291 fs::write(&destination, "old").expect("seed");
292
293 let page = page("Home");
294 site.generate(&page, "index.html", &RenderOptions::pretty())
295 .expect("written");
296
297 let written = fs::read(destination).expect("readable");
298 assert_eq!(
299 written,
300 page.render_with(&RenderOptions::pretty()).into_bytes()
301 );
302 assert!(!dir.0.join("index.winged-tmp").exists());
303 }
304
305 #[test]
307 fn nested_paths_create_their_parent_directories() {
308 let dir = TempDir::new("nested");
309 let site = StaticSiteGenerator::new(&dir.0);
310 site.generate(
311 &page("Post"),
312 "blog/2026/post.html",
313 &RenderOptions::compact(),
314 )
315 .expect("written");
316 assert!(dir.0.join("blog/2026/post.html").exists());
317 }
318
319 #[test]
321 fn a_bare_page_can_be_written_without_a_doctype() {
322 let dir = TempDir::new("doctype");
323 let site = StaticSiteGenerator::new(&dir.0);
324 let node = Node::from(h1().text("Fragment"));
325
326 site.generate_page(&node, "with.html", &RenderOptions::compact(), true)
327 .expect("written");
328 site.generate_page(&node, "without.html", &RenderOptions::compact(), false)
329 .expect("written");
330
331 assert!(
332 fs::read_to_string(dir.0.join("with.html"))
333 .unwrap()
334 .starts_with("<!DOCTYPE")
335 );
336 assert!(
337 !fs::read_to_string(dir.0.join("without.html"))
338 .unwrap()
339 .contains("DOCTYPE")
340 );
341 }
342
343 #[test]
345 fn copy_asset_replaces_an_existing_destination() {
346 let dir = TempDir::new("assets");
347 let source = dir.0.join("source.css");
348 fs::write(&source, "body{}").expect("written");
349
350 let site = StaticSiteGenerator::new(dir.0.join("out"));
351 site.copy_asset(&source, "css/style.css").expect("copied");
352 fs::write(&source, "body{color:red}").expect("written");
353 site.copy_asset(&source, "css/style.css").expect("recopied");
354
355 let copied = fs::read_to_string(dir.0.join("out/css/style.css")).expect("readable");
356 assert_eq!(copied, "body{color:red}");
357 }
358
359 #[test]
361 fn clean_empties_the_output_directory() {
362 let dir = TempDir::new("clean");
363 let site = StaticSiteGenerator::new(dir.0.join("out"));
364 site.write_file("x", "a.html").expect("written");
365
366 site.clean(true).expect("cleaned");
367 assert!(dir.0.join("out").exists());
368 assert!(!dir.0.join("out/a.html").exists());
369 }
370
371 #[test]
373 fn clean_refuses_an_unsafe_output_directory() {
374 for unsafe_path in ["", "/"] {
375 let site = StaticSiteGenerator::new(unsafe_path);
376 assert!(
377 site.clean(false).is_err(),
378 "{unsafe_path:?} should be rejected"
379 );
380 }
381 assert!(StaticSiteGenerator::new("dist/../..").clean(false).is_err());
382 }
383
384 #[test]
385 fn a_page_path_cannot_escape_the_output_directory() {
386 let dir = TempDir::new("escape");
387 let site = StaticSiteGenerator::new(&dir.0);
388 assert!(site.write_file("x", "../escaped.html").is_err());
389 assert!(site.write_file("x", "/etc/escaped.html").is_err());
390 }
391
392 #[test]
394 fn generate_multiple_writes_every_page() {
395 let dir = TempDir::new("multiple");
396 let site = StaticSiteGenerator::new(&dir.0);
397 let documents = vec![
398 (page("A"), "a.html".to_string()),
399 (page("B"), "nested/b.html".to_string()),
400 ];
401
402 site.generate_multiple(&documents, &RenderOptions::pretty())
403 .expect("written");
404 assert!(dir.0.join("a.html").exists());
405 assert!(dir.0.join("nested/b.html").exists());
406 }
407
408 #[test]
409 fn generate_multiple_reports_every_failure_not_just_the_first() {
410 let dir = TempDir::new("failures");
411 let site = StaticSiteGenerator::new(&dir.0);
412 let documents = vec![
413 (page("ok"), "ok.html".to_string()),
414 (page("bad"), "../one.html".to_string()),
415 (page("bad"), "../two.html".to_string()),
416 ];
417
418 let error = site
419 .generate_multiple(&documents, &RenderOptions::compact())
420 .expect_err("two pages are unwritable");
421 let message = error.to_string();
422 assert!(message.contains("../one.html"), "{message}");
423 assert!(message.contains("../two.html"), "{message}");
424 }
425
426 #[test]
428 fn a_page_is_written_with_its_doctype() {
429 let directory = TempDir::new("doctype-and-markup");
430 let site = StaticSiteGenerator::new(&directory.0);
431
432 let page = Node::from(
433 html_tag()
434 .child(head().child(title().text("Home")))
435 .child(body().child(h1().text("Hello"))),
436 );
437 site.generate_page(&page, "index.html", &RenderOptions::pretty(), true)
438 .expect("write");
439
440 let written = fs::read_to_string(directory.0.join("index.html")).expect("read");
441 assert!(written.starts_with("<!DOCTYPE html>\n"));
442 assert!(written.contains("<title>Home</title>"));
443 assert!(written.contains("<h1>Hello</h1>"));
444 }
445
446 #[test]
448 fn generate_multiple_writes_each_document_to_its_own_path() {
449 let directory = TempDir::new("multiple-documents");
450 let site = StaticSiteGenerator::new(&directory.0);
451
452 let pages = vec![
453 (page("Home"), "index.html".to_string()),
454 (page("About"), "about/index.html".to_string()),
455 ];
456 site.generate_multiple(&pages, &RenderOptions::compact())
457 .expect("write");
458
459 assert!(
460 fs::read_to_string(directory.0.join("index.html"))
461 .expect("read")
462 .contains("<h1>Home</h1>")
463 );
464 assert!(
465 fs::read_to_string(directory.0.join("about/index.html"))
466 .expect("read")
467 .contains("<h1>About</h1>")
468 );
469 }
470}