Skip to main content

molt/
ssg.rs

1//! Static site generation.
2//!
3//!
4//! Compiled out on `wasm32` regardless of the `ssg` feature — there is no filesystem
5//! there.
6
7use std::fs;
8use std::io::{self, Write};
9use std::path::{Component, Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use crate::core::{Node, Render, RenderOptions};
13use crate::document::Document;
14
15/// Why a static site operation failed.
16///
17/// A real error type rather than `io::Error::other(String)`, so a caller can tell an
18/// unsafe path from a full disk and can read which page of a bulk build failed. It
19/// converts into [`io::Error`] (`?` works in a function returning `io::Result`), keeping
20/// `InvalidInput` for the two refusals.
21///
22/// # Examples
23/// ```
24/// use molt::ssg::{SsgError, StaticSiteGenerator};
25///
26/// let site = StaticSiteGenerator::new("dist");
27/// let error = site.write_file("x", "../escape.html").expect_err("refused");
28/// assert!(matches!(error, SsgError::InvalidPath { .. }));
29/// ```
30#[derive(Debug)]
31#[non_exhaustive]
32pub enum SsgError {
33    /// A page or asset path that cannot be written under the output directory: a `..`, a
34    /// root or drive prefix, no file name, or a symbolic link on the way.
35    InvalidPath {
36        /// The path as given.
37        path: String,
38        /// What is wrong with it.
39        reason: &'static str,
40    },
41    /// `clean` refused to delete the output directory.
42    UnsafeCleanTarget {
43        /// The directory as given.
44        path: PathBuf,
45        /// Why it is too dangerous to remove.
46        reason: &'static str,
47    },
48    /// The filesystem failed.
49    Io {
50        /// The file or directory the operation was working on.
51        path: PathBuf,
52        /// The underlying error.
53        source: io::Error,
54    },
55    /// Several pages of a bulk build failed. Every failure is here, not just the first.
56    Pages(Vec<(String, SsgError)>),
57}
58
59impl std::fmt::Display for SsgError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::InvalidPath { path, reason } => write!(f, "path {path:?} {reason}"),
63            Self::UnsafeCleanTarget { path, reason } => {
64                write!(f, "refusing to clean {}: {reason}", path.display())
65            }
66            Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
67            Self::Pages(failures) => {
68                write!(f, "{} page(s) failed to generate:", failures.len())?;
69                for (path, error) in failures {
70                    write!(f, "\n  {path}: {error}")?;
71                }
72                Ok(())
73            }
74        }
75    }
76}
77
78impl std::error::Error for SsgError {
79    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80        match self {
81            Self::Io { source, .. } => Some(source),
82            _ => None,
83        }
84    }
85}
86
87impl From<SsgError> for io::Error {
88    fn from(error: SsgError) -> Self {
89        match error {
90            SsgError::InvalidPath { .. } | SsgError::UnsafeCleanTarget { .. } => {
91                Self::new(io::ErrorKind::InvalidInput, error)
92            }
93            SsgError::Io { source, .. } => source,
94            SsgError::Pages(_) => Self::other(error),
95        }
96    }
97}
98
99/// Result alias for this module's operations.
100type Result<T> = std::result::Result<T, SsgError>;
101
102/// Attaches the path an I/O operation was working on.
103fn io_error(path: &Path) -> impl FnOnce(io::Error) -> SsgError + '_ {
104    move |source| SsgError::Io {
105        path: path.to_path_buf(),
106        source,
107    }
108}
109
110/// Writes rendered pages and assets into an output directory.
111///
112/// # Examples
113/// ```no_run
114/// use molt::prelude::*;
115/// use molt::{Document, ssg::StaticSiteGenerator};
116///
117/// let site = StaticSiteGenerator::new("dist");
118/// site.clean(true)?;
119/// site.generate(&Document::new(Some("pt-BR")), "index.html", &RenderOptions::pretty())?;
120/// # Ok::<(), molt::ssg::SsgError>(())
121/// ```
122#[derive(Debug, Clone)]
123pub struct StaticSiteGenerator {
124    output_directory: PathBuf,
125}
126
127impl StaticSiteGenerator {
128    /// Creates a generator writing into `output_directory`.
129    pub fn new(output_directory: impl Into<PathBuf>) -> Self {
130        Self {
131            output_directory: output_directory.into(),
132        }
133    }
134
135    /// The directory pages are written into.
136    #[must_use]
137    pub fn output_directory(&self) -> &Path {
138        &self.output_directory
139    }
140
141    /// Renders a document to `path`, relative to the output directory.
142    ///
143    /// The page is written with [`Document::render_to`], section by section, into a
144    /// temporary file that is then renamed. The bytes match [`Document::render_with`].
145    ///
146    /// # Errors
147    /// Returns an error if the path escapes the output directory, or if the write fails.
148    pub fn generate(&self, document: &Document, path: &str, options: &RenderOptions) -> Result<()> {
149        self.write_atomic(path, |file| {
150            let mut buffered = io::BufWriter::new(file);
151            document.render_to(&mut buffered, options)?;
152            buffered.flush()
153        })
154    }
155
156    /// Renders several documents.
157    ///
158    /// With the `parallel` feature the pages are rendered concurrently — sound because the
159    /// node tree is `Send + Sync`.
160    ///
161    /// **Every** failure is reported, not just the first: a bulk build that names one of
162    /// twelve broken pages is worse than useless.
163    ///
164    /// # Errors
165    /// Returns an error naming every page that failed.
166    pub fn generate_multiple(
167        &self,
168        documents: &[(Document, String)],
169        options: &RenderOptions,
170    ) -> Result<()> {
171        #[cfg(feature = "parallel")]
172        let results: Vec<(String, Result<()>)> = {
173            use rayon::prelude::*;
174            documents
175                .par_iter()
176                .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
177                .collect()
178        };
179
180        #[cfg(not(feature = "parallel"))]
181        let results: Vec<(String, Result<()>)> = documents
182            .iter()
183            .map(|(doc, path)| (path.clone(), self.generate(doc, path, options)))
184            .collect();
185
186        collect_failures(results)
187    }
188
189    /// Renders a bare node to `path`, optionally prefixed by the doctype.
190    ///
191    /// # Errors
192    /// Returns an error if the path is unsafe or the write fails.
193    pub fn generate_page(
194        &self,
195        page: &Node,
196        path: &str,
197        options: &RenderOptions,
198        doctype: bool,
199    ) -> Result<()> {
200        let mut content = String::with_capacity(1024);
201        if doctype {
202            content.push_str("<!DOCTYPE html>\n");
203        }
204        page.write_into(&mut content, options, 0);
205        self.write_file(&content, path)
206    }
207
208    /// Copies a file into the output directory, replacing any existing destination.
209    ///
210    /// # Errors
211    /// Returns an error if the destination is unsafe or the copy fails.
212    pub fn copy_asset(&self, from: impl AsRef<Path>, to: &str) -> Result<()> {
213        let destination = self.resolve(to)?;
214        if let Some(parent) = destination.parent() {
215            fs::create_dir_all(parent).map_err(io_error(parent))?;
216        }
217        // Copy beside the destination and rename over it, so a failed copy leaves the old
218        // file in place instead of the delete-then-copy gap this used to have.
219        let temporary = temporary_path(&destination);
220        if let Err(error) = fs::copy(from.as_ref(), &temporary) {
221            let _ = fs::remove_file(&temporary);
222            return Err(io_error(from.as_ref())(error));
223        }
224        if let Err(error) = fs::rename(&temporary, &destination) {
225            let _ = fs::remove_file(&temporary);
226            return Err(io_error(&destination)(error));
227        }
228        Ok(())
229    }
230
231    /// Removes the output directory, optionally recreating it empty.
232    ///
233    /// # Errors
234    /// Returns an error if the output directory is unsafe to delete — empty, a filesystem
235    /// root, a top-level directory, the home directory, the working directory or one of its
236    /// ancestors, or containing a `..` component, because removing it recursively would delete
237    /// whatever it points at.
238    pub fn clean(&self, create_directory: bool) -> Result<()> {
239        guard_destructive_path(&self.output_directory)?;
240
241        let directory = &self.output_directory;
242        if directory.exists() {
243            fs::remove_dir_all(directory).map_err(io_error(directory))?;
244        }
245        if create_directory {
246            fs::create_dir_all(directory).map_err(io_error(directory))?;
247        }
248        Ok(())
249    }
250
251    /// Writes UTF-8 `content` to `path`, creating parent directories.
252    ///
253    /// The write is atomic: content goes to a temporary file in the same directory and is
254    /// then renamed, so a reader never sees a half-written page.
255    ///
256    /// # Errors
257    /// Returns an error if the path escapes the output directory or the write fails.
258    pub fn write_file(&self, content: &str, path: &str) -> Result<()> {
259        self.write_atomic(path, |file| file.write_all(content.as_bytes()))
260    }
261
262    /// Writes through a temporary file in the destination directory, then renames.
263    ///
264    /// The closure receives the open temporary file. On any error the temporary file is
265    /// removed, so a reader never observes a half-written page under the final name.
266    fn write_atomic(
267        &self,
268        path: &str,
269        write_contents: impl FnOnce(&mut fs::File) -> io::Result<()>,
270    ) -> Result<()> {
271        let destination = self.resolve(path)?;
272        if let Some(parent) = destination.parent() {
273            fs::create_dir_all(parent).map_err(io_error(parent))?;
274        }
275
276        let temporary = temporary_path(&destination);
277        // `create_new` refuses an existing path, symlinks included, so the temporary file
278        // can never be a pre-planted link that redirects the write.
279        let mut file = fs::OpenOptions::new()
280            .write(true)
281            .create_new(true)
282            .open(&temporary)
283            .map_err(io_error(&temporary))?;
284        if let Err(error) = write_contents(&mut file) {
285            let _ = fs::remove_file(&temporary);
286            return Err(io_error(&destination)(error));
287        }
288        if let Err(error) = fs::rename(&temporary, &destination) {
289            let _ = fs::remove_file(&temporary);
290            return Err(io_error(&destination)(error));
291        }
292        Ok(())
293    }
294
295    /// Joins `path` onto the output directory, rejecting anything that escapes it.
296    ///
297    /// Only plain names are accepted: `..`, a root and a Windows drive prefix (`C:foo`
298    /// replaces the base on `join`) are refused, and so is a path with no file name
299    /// (`""`, `"."`, `"a/"`), which would name the output directory itself. A component
300    /// that already exists as a symlink is refused too, because writing through it lands
301    /// wherever the link points.
302    ///
303    /// The symlink check is not atomic with the write: someone who can create links inside
304    /// the output directory *while it is being generated* can still race it.
305    fn resolve(&self, path: &str) -> Result<PathBuf> {
306        let reject = |reason: &'static str| {
307            Err(SsgError::InvalidPath {
308                path: path.to_owned(),
309                reason,
310            })
311        };
312
313        let candidate = Path::new(path);
314        let mut names = 0;
315        for component in candidate.components() {
316            match component {
317                Component::Normal(_) => names += 1,
318                Component::CurDir => {}
319                _ => return reject("escapes the output directory"),
320            }
321        }
322        if names == 0 || path.ends_with(['/', '\\']) {
323            return reject("does not name a file");
324        }
325
326        let mut resolved = self.output_directory.clone();
327        for component in candidate.components() {
328            if let Component::Normal(name) = component {
329                resolved.push(name);
330                // Absent components cannot be links; only existing ones are inspected.
331                if fs::symlink_metadata(&resolved).is_ok_and(|m| m.file_type().is_symlink()) {
332                    return reject("passes through a symbolic link");
333                }
334            }
335        }
336        Ok(resolved)
337    }
338}
339
340/// A sibling of `destination` that no other writer will pick.
341///
342/// `with_extension` was the wrong tool: it *replaces* the extension, so `index.html` and
343/// `index.xml` both became `index.tmp` and two pages written in parallel shared one
344/// file. The full file name, the process id and a counter make the name unique.
345fn temporary_path(destination: &Path) -> PathBuf {
346    static COUNTER: AtomicU64 = AtomicU64::new(0);
347    let name = destination
348        .file_name()
349        .map_or_else(|| "page".into(), |n| n.to_string_lossy().into_owned());
350    let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
351    destination.with_file_name(format!(".{name}.molt-{}-{unique}.tmp", std::process::id()))
352}
353
354/// Rejects output directories that are dangerous to delete recursively.
355///
356/// Beyond the empty path, a filesystem root and `..`, this refuses the directory the
357/// process is running in or any ancestor of it (`"."` has a non-empty parent, which is how
358/// the old check let `clean(".")` through), the home directory, and anything fewer than two
359/// levels below the root (`/usr`, `/home`). The guard only reads the filesystem; it never
360/// removes anything, so it can be tested directly.
361fn guard_destructive_path(path: &Path) -> Result<()> {
362    let reject = |reason: &'static str| {
363        Err(SsgError::UnsafeCleanTarget {
364            path: path.to_path_buf(),
365            reason,
366        })
367    };
368
369    if path.as_os_str().is_empty() {
370        return reject("the output directory is empty");
371    }
372    if path.parent().is_none() {
373        return reject("the output directory is a filesystem root");
374    }
375    if path.components().any(|c| c == Component::ParentDir) {
376        return reject("the output directory contains a `..` component");
377    }
378
379    let absolute = absolute_path(path).map_err(io_error(path))?;
380    let depth = absolute
381        .components()
382        .filter(|c| matches!(c, Component::Normal(_)))
383        .count();
384    if depth < 2 {
385        return reject("the output directory is a top-level directory");
386    }
387    if let Ok(cwd) = std::env::current_dir().and_then(fs::canonicalize) {
388        if cwd.starts_with(&absolute) {
389            return reject("the output directory is, or contains, the working directory");
390        }
391    }
392    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
393    if let Some(home) = home.and_then(|h| fs::canonicalize(h).ok()) {
394        if home == absolute {
395            return reject("the output directory is the home directory");
396        }
397    }
398    Ok(())
399}
400
401/// `path` made absolute and, when it exists, with symlinks resolved.
402fn absolute_path(path: &Path) -> io::Result<PathBuf> {
403    if let Ok(canonical) = fs::canonicalize(path) {
404        return Ok(canonical);
405    }
406    let mut absolute = fs::canonicalize(std::env::current_dir()?)?;
407    for component in path.components() {
408        match component {
409            Component::Normal(name) => absolute.push(name),
410            Component::RootDir | Component::Prefix(_) => {
411                absolute = PathBuf::from(component.as_os_str());
412            }
413            _ => {}
414        }
415    }
416    Ok(absolute)
417}
418
419/// Turns per-page results into one error carrying every failure.
420fn collect_failures(results: Vec<(String, Result<()>)>) -> Result<()> {
421    let failures: Vec<(String, SsgError)> = results
422        .into_iter()
423        .filter_map(|(path, result)| result.err().map(|error| (path, error)))
424        .collect();
425
426    if failures.is_empty() {
427        Ok(())
428    } else {
429        Err(SsgError::Pages(failures))
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::elements::{body, h1, head, html_tag, title};
437
438    /// A throwaway directory that removes itself. Avoids a dev-dependency for six tests.
439    struct TempDir(PathBuf);
440
441    impl TempDir {
442        fn new(name: &str) -> Self {
443            // The process id keeps two test runs (or two checkouts) off the same directory.
444            let path = std::env::temp_dir().join(format!("molt-{name}-{}", std::process::id()));
445            let _ = fs::remove_dir_all(&path);
446            fs::create_dir_all(&path).expect("temp dir is creatable");
447            Self(path)
448        }
449    }
450
451    impl Drop for TempDir {
452        fn drop(&mut self) {
453            let _ = fs::remove_dir_all(&self.0);
454        }
455    }
456
457    /// Whether `dir` holds exactly these entries, i.e. no temporary file was left behind.
458    fn only_files_named(dir: &Path, names: &[&str]) -> bool {
459        let mut found: Vec<String> = fs::read_dir(dir)
460            .expect("readable")
461            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
462            .collect();
463        found.sort();
464        found == names
465    }
466
467    fn page(text: &str) -> Document {
468        Document::new(Some("en"))
469            .head_children([title().text(text)])
470            .body_children([h1().text(text)])
471    }
472
473    #[test]
474    fn generate_writes_a_rendered_document() {
475        let dir = TempDir::new("generate");
476        let site = StaticSiteGenerator::new(&dir.0);
477        site.generate(&page("Home"), "index.html", &RenderOptions::pretty())
478            .expect("written");
479
480        let written = fs::read_to_string(dir.0.join("index.html")).expect("readable");
481        assert!(written.starts_with("<!DOCTYPE html>"));
482        assert!(written.contains("<h1>Home</h1>"));
483    }
484
485    /// A generated file is the streamed render, replaces an existing one, and reports failures.
486    #[test]
487    fn generate_replaces_a_file_with_the_rendered_bytes() {
488        let dir = TempDir::new("stream");
489        let site = StaticSiteGenerator::new(&dir.0);
490        let destination = dir.0.join("index.html");
491        fs::write(&destination, "old").expect("seed");
492
493        let page = page("Home");
494        site.generate(&page, "index.html", &RenderOptions::pretty())
495            .expect("written");
496
497        let written = fs::read(destination).expect("readable");
498        assert_eq!(
499            written,
500            page.render_with(&RenderOptions::pretty()).into_bytes()
501        );
502        assert!(only_files_named(&dir.0, &["index.html"]));
503    }
504
505    #[test]
506    fn nested_paths_create_their_parent_directories() {
507        let dir = TempDir::new("nested");
508        let site = StaticSiteGenerator::new(&dir.0);
509        site.generate(
510            &page("Post"),
511            "blog/2026/post.html",
512            &RenderOptions::compact(),
513        )
514        .expect("written");
515        assert!(dir.0.join("blog/2026/post.html").exists());
516    }
517
518    #[test]
519    fn a_bare_page_can_be_written_without_a_doctype() {
520        let dir = TempDir::new("doctype");
521        let site = StaticSiteGenerator::new(&dir.0);
522        let node = Node::from(h1().text("Fragment"));
523
524        site.generate_page(&node, "with.html", &RenderOptions::compact(), true)
525            .expect("written");
526        site.generate_page(&node, "without.html", &RenderOptions::compact(), false)
527            .expect("written");
528
529        assert!(
530            fs::read_to_string(dir.0.join("with.html"))
531                .unwrap()
532                .starts_with("<!DOCTYPE")
533        );
534        assert!(
535            !fs::read_to_string(dir.0.join("without.html"))
536                .unwrap()
537                .contains("DOCTYPE")
538        );
539    }
540
541    #[test]
542    fn copy_asset_replaces_an_existing_destination() {
543        let dir = TempDir::new("assets");
544        let source = dir.0.join("source.css");
545        fs::write(&source, "body{}").expect("written");
546
547        let site = StaticSiteGenerator::new(dir.0.join("out"));
548        site.copy_asset(&source, "css/style.css").expect("copied");
549        fs::write(&source, "body{color:red}").expect("written");
550        site.copy_asset(&source, "css/style.css").expect("recopied");
551
552        let copied = fs::read_to_string(dir.0.join("out/css/style.css")).expect("readable");
553        assert_eq!(copied, "body{color:red}");
554    }
555
556    #[test]
557    fn clean_empties_the_output_directory() {
558        let dir = TempDir::new("clean");
559        let site = StaticSiteGenerator::new(dir.0.join("out"));
560        site.write_file("x", "a.html").expect("written");
561
562        site.clean(true).expect("cleaned");
563        assert!(dir.0.join("out").exists());
564        assert!(!dir.0.join("out/a.html").exists());
565    }
566
567    /// `clean` refuses a directory that would be dangerous to remove.
568    #[test]
569    fn clean_refuses_an_unsafe_output_directory() {
570        for unsafe_path in ["", "/"] {
571            let site = StaticSiteGenerator::new(unsafe_path);
572            assert!(
573                site.clean(false).is_err(),
574                "{unsafe_path:?} should be rejected"
575            );
576        }
577        assert!(StaticSiteGenerator::new("dist/../..").clean(false).is_err());
578    }
579
580    #[test]
581    fn a_page_path_cannot_escape_the_output_directory() {
582        let dir = TempDir::new("escape");
583        let site = StaticSiteGenerator::new(&dir.0);
584        assert!(site.write_file("x", "../escaped.html").is_err());
585        assert!(site.write_file("x", "/etc/escaped.html").is_err());
586    }
587
588    #[test]
589    fn a_path_that_does_not_name_a_file_is_rejected() {
590        let dir = TempDir::new("nameless");
591        let site = StaticSiteGenerator::new(&dir.0);
592        for path in ["", ".", "./", "a/", "a\\", "a/../../x.html", "C:\\x.html"] {
593            // `C:\x.html` is an ordinary file name on Unix, so it is only rejected on Windows.
594            if path.starts_with("C:") && !cfg!(windows) {
595                continue;
596            }
597            let outcome = site.write_file("x", path);
598            assert!(outcome.is_err(), "{path:?} should be rejected");
599        }
600        // Nothing was created beside the output directory by `with_extension`.
601        let parent = dir.0.parent().expect("temp dir has a parent");
602        let name = dir
603            .0
604            .file_name()
605            .expect("named")
606            .to_string_lossy()
607            .into_owned();
608        assert!(!parent.join(format!("{name}.tmp")).exists());
609        assert!(only_files_named(&dir.0, &[]));
610    }
611
612    #[cfg(unix)]
613    #[test]
614    fn a_symlink_inside_the_output_directory_is_not_followed() {
615        let dir = TempDir::new("symlink");
616        let outside = dir.0.join("outside");
617        fs::create_dir_all(&outside).expect("created");
618        let out = dir.0.join("out");
619        fs::create_dir_all(&out).expect("created");
620        std::os::unix::fs::symlink(&outside, out.join("link")).expect("linked");
621
622        let site = StaticSiteGenerator::new(&out);
623        assert!(site.write_file("x", "link/x.html").is_err());
624        let asset = dir.0.join("a.css");
625        fs::write(&asset, "a{}").expect("written");
626        assert!(site.copy_asset(&asset, "link/a.css").is_err());
627        assert!(only_files_named(&outside, &[]));
628    }
629
630    /// `with_extension` made `index.html` and `index.xml` share `index.tmp`.
631    #[test]
632    fn pages_with_the_same_stem_do_not_share_a_temporary_file() {
633        let dir = TempDir::new("stem");
634        let site = StaticSiteGenerator::new(&dir.0);
635        std::thread::scope(|scope| {
636            for round in 0..8 {
637                for name in ["index.html", "index.xml", "index.htm"] {
638                    let site = &site;
639                    scope.spawn(move || {
640                        site.write_file(&format!("{name}:{round}"), name)
641                            .expect("written");
642                    });
643                }
644            }
645        });
646        assert!(only_files_named(
647            &dir.0,
648            &["index.htm", "index.html", "index.xml"]
649        ));
650        for name in ["index.html", "index.xml", "index.htm"] {
651            let content = fs::read_to_string(dir.0.join(name)).expect("readable");
652            assert!(
653                content.starts_with(&format!("{name}:")),
654                "{name}: {content}"
655            );
656        }
657    }
658
659    #[test]
660    fn a_failed_asset_copy_keeps_the_previous_file() {
661        let dir = TempDir::new("asset-atomic");
662        let site = StaticSiteGenerator::new(dir.0.join("out"));
663        let source = dir.0.join("source.css");
664        fs::write(&source, "old").expect("written");
665        site.copy_asset(&source, "style.css").expect("copied");
666
667        assert!(
668            site.copy_asset(dir.0.join("missing.css"), "style.css")
669                .is_err()
670        );
671        assert_eq!(
672            fs::read_to_string(dir.0.join("out/style.css")).expect("readable"),
673            "old"
674        );
675        assert!(only_files_named(&dir.0.join("out"), &["style.css"]));
676    }
677
678    /// `Path::new(".").parent()` is `Some("")`, so the old guard let `clean(".")` through.
679    #[test]
680    fn the_clean_guard_refuses_the_working_directory_and_what_contains_it() {
681        let cwd = std::env::current_dir().expect("cwd");
682        let mut unsafe_paths = vec![PathBuf::from("."), PathBuf::from("./"), cwd.clone()];
683        unsafe_paths.extend(cwd.ancestors().skip(1).map(Path::to_path_buf));
684        unsafe_paths.push(PathBuf::from("/usr"));
685        if let Some(home) = std::env::var_os("HOME") {
686            unsafe_paths.push(PathBuf::from(home));
687        }
688        for path in unsafe_paths {
689            assert!(
690                guard_destructive_path(&path).is_err(),
691                "{path:?} should be refused"
692            );
693        }
694        // A directory of its own, even one that does not exist yet, is fine.
695        assert!(guard_destructive_path(&cwd.join("dist")).is_ok());
696        assert!(guard_destructive_path(Path::new("dist")).is_ok());
697    }
698
699    #[test]
700    fn generate_multiple_writes_every_page() {
701        let dir = TempDir::new("multiple");
702        let site = StaticSiteGenerator::new(&dir.0);
703        let documents = vec![
704            (page("A"), "a.html".to_string()),
705            (page("B"), "nested/b.html".to_string()),
706        ];
707
708        site.generate_multiple(&documents, &RenderOptions::pretty())
709            .expect("written");
710        assert!(dir.0.join("a.html").exists());
711        assert!(dir.0.join("nested/b.html").exists());
712    }
713
714    #[test]
715    fn generate_multiple_reports_every_failure_not_just_the_first() {
716        let dir = TempDir::new("failures");
717        let site = StaticSiteGenerator::new(&dir.0);
718        let documents = vec![
719            (page("ok"), "ok.html".to_string()),
720            (page("bad"), "../one.html".to_string()),
721            (page("bad"), "../two.html".to_string()),
722        ];
723
724        let error = site
725            .generate_multiple(&documents, &RenderOptions::compact())
726            .expect_err("two pages are unwritable");
727        let message = error.to_string();
728        assert!(message.contains("../one.html"), "{message}");
729        assert!(message.contains("../two.html"), "{message}");
730
731        // The failures are data, not only text.
732        let SsgError::Pages(failures) = error else {
733            panic!("a bulk build reports Pages");
734        };
735        let paths: Vec<&str> = failures.iter().map(|(path, _)| path.as_str()).collect();
736        assert_eq!(paths, ["../one.html", "../two.html"]);
737        assert!(
738            failures
739                .iter()
740                .all(|(_, e)| matches!(e, SsgError::InvalidPath { .. }))
741        );
742    }
743
744    #[test]
745    fn each_failure_has_its_own_variant_and_converts_to_an_io_error() {
746        use std::error::Error;
747
748        let dir = TempDir::new("variants");
749        let site = StaticSiteGenerator::new(&dir.0);
750
751        let refused = site.write_file("x", "../out.html").expect_err("path");
752        assert!(matches!(refused, SsgError::InvalidPath { ref path, .. } if path == "../out.html"));
753        assert!(refused.source().is_none());
754        assert_eq!(io::Error::from(refused).kind(), io::ErrorKind::InvalidInput);
755
756        let unsafe_clean = StaticSiteGenerator::new(".")
757            .clean(false)
758            .expect_err("guard");
759        assert!(matches!(unsafe_clean, SsgError::UnsafeCleanTarget { .. }));
760        assert_eq!(
761            io::Error::from(unsafe_clean).kind(),
762            io::ErrorKind::InvalidInput
763        );
764
765        // A directory sits where the page should go, so the rename fails.
766        fs::create_dir_all(dir.0.join("page.html")).expect("created");
767        let failed = site
768            .write_file("x", "page.html")
769            .expect_err("rename onto a directory");
770        assert!(matches!(failed, SsgError::Io { .. }));
771        assert!(failed.source().is_some());
772        assert!(failed.to_string().contains("page.html"));
773        assert!(
774            only_files_named(&dir.0, &["page.html"]),
775            "no temp left behind"
776        );
777    }
778
779    #[test]
780    fn a_page_is_written_with_its_doctype() {
781        let directory = TempDir::new("doctype-and-markup");
782        let site = StaticSiteGenerator::new(&directory.0);
783
784        let page = Node::from(
785            html_tag()
786                .child(head().child(title().text("Home")))
787                .child(body().child(h1().text("Hello"))),
788        );
789        site.generate_page(&page, "index.html", &RenderOptions::pretty(), true)
790            .expect("write");
791
792        let written = fs::read_to_string(directory.0.join("index.html")).expect("read");
793        assert!(written.starts_with("<!DOCTYPE html>\n"));
794        assert!(written.contains("<title>Home</title>"));
795        assert!(written.contains("<h1>Hello</h1>"));
796    }
797
798    #[test]
799    fn generate_multiple_writes_each_document_to_its_own_path() {
800        let directory = TempDir::new("multiple-documents");
801        let site = StaticSiteGenerator::new(&directory.0);
802
803        let pages = vec![
804            (page("Home"), "index.html".to_string()),
805            (page("About"), "about/index.html".to_string()),
806        ];
807        site.generate_multiple(&pages, &RenderOptions::compact())
808            .expect("write");
809
810        assert!(
811            fs::read_to_string(directory.0.join("index.html"))
812                .expect("read")
813                .contains("<h1>Home</h1>")
814        );
815        assert!(
816            fs::read_to_string(directory.0.join("about/index.html"))
817                .expect("read")
818                .contains("<h1>About</h1>")
819        );
820    }
821}