1use std::fs;
9use std::io;
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(
51 &self,
52 document: &Document,
53 path: &str,
54 options: &RenderOptions,
55 ) -> io::Result<()> {
56 self.write_file(&document.render_with(options), path)
57 }
58
59 pub fn generate_multiple(
70 &self,
71 documents: &[(Document, String)],
72 options: &RenderOptions,
73 ) -> io::Result<()> {
74 #[cfg(feature = "parallel")]
75 let results: Vec<(String, io::Result<()>)> = {
76 use rayon::prelude::*;
77 documents
78 .par_iter()
79 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
80 .collect()
81 };
82
83 #[cfg(not(feature = "parallel"))]
84 let results: Vec<(String, io::Result<()>)> = documents
85 .iter()
86 .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
87 .collect();
88
89 collect_failures(results)
90 }
91
92 pub fn generate_page(
97 &self,
98 page: &Node,
99 path: &str,
100 options: &RenderOptions,
101 doctype: bool,
102 ) -> io::Result<()> {
103 let mut content = String::with_capacity(1024);
104 if doctype {
105 content.push_str("<!DOCTYPE html>\n");
106 }
107 page.write_into(&mut content, options, 0);
108 self.write_file(&content, path)
109 }
110
111 pub fn copy_asset(&self, from: impl AsRef<Path>, to: &str) -> io::Result<()> {
116 let destination = self.resolve(to)?;
117 if let Some(parent) = destination.parent() {
118 fs::create_dir_all(parent)?;
119 }
120 if destination.exists() {
121 fs::remove_file(&destination)?;
122 }
123 fs::copy(from, destination)?;
124 Ok(())
125 }
126
127 pub fn clean(&self, create_directory: bool) -> io::Result<()> {
134 guard_destructive_path(&self.output_directory)?;
135
136 if self.output_directory.exists() {
137 fs::remove_dir_all(&self.output_directory)?;
138 }
139 if create_directory {
140 fs::create_dir_all(&self.output_directory)?;
141 }
142 Ok(())
143 }
144
145 pub fn write_file(&self, content: &str, path: &str) -> io::Result<()> {
153 let destination = self.resolve(path)?;
154 if let Some(parent) = destination.parent() {
155 fs::create_dir_all(parent)?;
156 }
157
158 let temporary = destination.with_extension("winged-tmp");
159 fs::write(&temporary, content)?;
160 fs::rename(&temporary, &destination)?;
161 Ok(())
162 }
163
164 fn resolve(&self, path: &str) -> io::Result<PathBuf> {
166 let candidate = Path::new(path);
167 if candidate.is_absolute() || candidate.components().any(|c| c == Component::ParentDir) {
168 return Err(io::Error::new(
169 io::ErrorKind::InvalidInput,
170 format!("path {path:?} escapes the output directory"),
171 ));
172 }
173 Ok(self.output_directory.join(candidate))
174 }
175}
176
177fn guard_destructive_path(path: &Path) -> io::Result<()> {
179 let reject = |reason: &str| {
180 Err(io::Error::new(
181 io::ErrorKind::InvalidInput,
182 format!("refusing to clean {}: {reason}", path.display()),
183 ))
184 };
185
186 if path.as_os_str().is_empty() {
187 return reject("the output directory is empty");
188 }
189 if path.parent().is_none() {
190 return reject("the output directory is a filesystem root");
191 }
192 if path.components().any(|c| c == Component::ParentDir) {
193 return reject("the output directory contains a `..` component");
194 }
195 Ok(())
196}
197
198fn collect_failures(results: Vec<(String, io::Result<()>)>) -> io::Result<()> {
200 let failures: Vec<String> = results
201 .into_iter()
202 .filter_map(|(path, result)| result.err().map(|e| format!("{path}: {e}")))
203 .collect();
204
205 if failures.is_empty() {
206 Ok(())
207 } else {
208 Err(io::Error::other(format!(
209 "{} page(s) failed to generate:\n {}",
210 failures.len(),
211 failures.join("\n ")
212 )))
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::elements::{body, h1, head, html_tag, title};
220
221 struct TempDir(PathBuf);
223
224 impl TempDir {
225 fn new(name: &str) -> Self {
226 let path = std::env::temp_dir().join(format!("winged-rust-{name}"));
227 let _ = fs::remove_dir_all(&path);
228 fs::create_dir_all(&path).expect("temp dir is creatable");
229 Self(path)
230 }
231 }
232
233 impl Drop for TempDir {
234 fn drop(&mut self) {
235 let _ = fs::remove_dir_all(&self.0);
236 }
237 }
238
239 fn page(text: &str) -> Document {
240 Document::new(Some("en"))
241 .head_children([title().text(text)])
242 .body_children([h1().text(text)])
243 }
244
245 #[test]
247 fn generate_writes_a_rendered_document() {
248 let dir = TempDir::new("generate");
249 let site = StaticSiteGenerator::new(&dir.0);
250 site.generate(&page("Home"), "index.html", &RenderOptions::pretty())
251 .expect("written");
252
253 let written = fs::read_to_string(dir.0.join("index.html")).expect("readable");
254 assert!(written.starts_with("<!DOCTYPE html>"));
255 assert!(written.contains("<h1>Home</h1>"));
256 }
257
258 #[test]
260 fn nested_paths_create_their_parent_directories() {
261 let dir = TempDir::new("nested");
262 let site = StaticSiteGenerator::new(&dir.0);
263 site.generate(
264 &page("Post"),
265 "blog/2026/post.html",
266 &RenderOptions::compact(),
267 )
268 .expect("written");
269 assert!(dir.0.join("blog/2026/post.html").exists());
270 }
271
272 #[test]
274 fn a_bare_page_can_be_written_without_a_doctype() {
275 let dir = TempDir::new("doctype");
276 let site = StaticSiteGenerator::new(&dir.0);
277 let node = Node::from(h1().text("Fragment"));
278
279 site.generate_page(&node, "with.html", &RenderOptions::compact(), true)
280 .expect("written");
281 site.generate_page(&node, "without.html", &RenderOptions::compact(), false)
282 .expect("written");
283
284 assert!(
285 fs::read_to_string(dir.0.join("with.html"))
286 .unwrap()
287 .starts_with("<!DOCTYPE")
288 );
289 assert!(
290 !fs::read_to_string(dir.0.join("without.html"))
291 .unwrap()
292 .contains("DOCTYPE")
293 );
294 }
295
296 #[test]
298 fn copy_asset_replaces_an_existing_destination() {
299 let dir = TempDir::new("assets");
300 let source = dir.0.join("source.css");
301 fs::write(&source, "body{}").expect("written");
302
303 let site = StaticSiteGenerator::new(dir.0.join("out"));
304 site.copy_asset(&source, "css/style.css").expect("copied");
305 fs::write(&source, "body{color:red}").expect("written");
306 site.copy_asset(&source, "css/style.css").expect("recopied");
307
308 let copied = fs::read_to_string(dir.0.join("out/css/style.css")).expect("readable");
309 assert_eq!(copied, "body{color:red}");
310 }
311
312 #[test]
314 fn clean_empties_the_output_directory() {
315 let dir = TempDir::new("clean");
316 let site = StaticSiteGenerator::new(dir.0.join("out"));
317 site.write_file("x", "a.html").expect("written");
318
319 site.clean(true).expect("cleaned");
320 assert!(dir.0.join("out").exists());
321 assert!(!dir.0.join("out/a.html").exists());
322 }
323
324 #[test]
326 fn clean_refuses_an_unsafe_output_directory() {
327 for unsafe_path in ["", "/"] {
328 let site = StaticSiteGenerator::new(unsafe_path);
329 assert!(
330 site.clean(false).is_err(),
331 "{unsafe_path:?} should be rejected"
332 );
333 }
334 assert!(StaticSiteGenerator::new("dist/../..").clean(false).is_err());
335 }
336
337 #[test]
338 fn a_page_path_cannot_escape_the_output_directory() {
339 let dir = TempDir::new("escape");
340 let site = StaticSiteGenerator::new(&dir.0);
341 assert!(site.write_file("x", "../escaped.html").is_err());
342 assert!(site.write_file("x", "/etc/escaped.html").is_err());
343 }
344
345 #[test]
347 fn generate_multiple_writes_every_page() {
348 let dir = TempDir::new("multiple");
349 let site = StaticSiteGenerator::new(&dir.0);
350 let documents = vec![
351 (page("A"), "a.html".to_string()),
352 (page("B"), "nested/b.html".to_string()),
353 ];
354
355 site.generate_multiple(&documents, &RenderOptions::pretty())
356 .expect("written");
357 assert!(dir.0.join("a.html").exists());
358 assert!(dir.0.join("nested/b.html").exists());
359 }
360
361 #[test]
362 fn generate_multiple_reports_every_failure_not_just_the_first() {
363 let dir = TempDir::new("failures");
364 let site = StaticSiteGenerator::new(&dir.0);
365 let documents = vec![
366 (page("ok"), "ok.html".to_string()),
367 (page("bad"), "../one.html".to_string()),
368 (page("bad"), "../two.html".to_string()),
369 ];
370
371 let error = site
372 .generate_multiple(&documents, &RenderOptions::compact())
373 .expect_err("two pages are unwritable");
374 let message = error.to_string();
375 assert!(message.contains("../one.html"), "{message}");
376 assert!(message.contains("../two.html"), "{message}");
377 }
378
379 #[test]
381 fn a_page_is_written_with_its_doctype() {
382 let directory = TempDir::new("doctype-and-markup");
383 let site = StaticSiteGenerator::new(&directory.0);
384
385 let page = Node::from(
386 html_tag()
387 .child(head().child(title().text("Home")))
388 .child(body().child(h1().text("Hello"))),
389 );
390 site.generate_page(&page, "index.html", &RenderOptions::pretty(), true)
391 .expect("write");
392
393 let written = fs::read_to_string(directory.0.join("index.html")).expect("read");
394 assert!(written.starts_with("<!DOCTYPE html>\n"));
395 assert!(written.contains("<title>Home</title>"));
396 assert!(written.contains("<h1>Hello</h1>"));
397 }
398
399 #[test]
401 fn generate_multiple_writes_each_document_to_its_own_path() {
402 let directory = TempDir::new("multiple-documents");
403 let site = StaticSiteGenerator::new(&directory.0);
404
405 let pages = vec![
406 (page("Home"), "index.html".to_string()),
407 (page("About"), "about/index.html".to_string()),
408 ];
409 site.generate_multiple(&pages, &RenderOptions::compact())
410 .expect("write");
411
412 assert!(
413 fs::read_to_string(directory.0.join("index.html"))
414 .expect("read")
415 .contains("<h1>Home</h1>")
416 );
417 assert!(
418 fs::read_to_string(directory.0.join("about/index.html"))
419 .expect("read")
420 .contains("<h1>About</h1>")
421 );
422 }
423}