Skip to main content

molt/
ssg.rs

1//! Static site generation.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/static/StaticSiteGenerator.swift`.
4//!
5//! Compiled out on `wasm32` regardless of the `ssg` feature — there is no filesystem
6//! there.
7
8use 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/// Writes rendered pages and assets into an output directory.
16///
17/// # Examples
18/// ```no_run
19/// use molt::prelude::*;
20/// use molt::{Document, ssg::StaticSiteGenerator};
21///
22/// let site = StaticSiteGenerator::new("dist");
23/// site.clean(true)?;
24/// site.generate(&Document::new(Some("pt-BR")), "index.html", &RenderOptions::pretty())?;
25/// # Ok::<(), std::io::Error>(())
26/// ```
27#[derive(Debug, Clone)]
28pub struct StaticSiteGenerator {
29    output_directory: PathBuf,
30}
31
32impl StaticSiteGenerator {
33    /// Creates a generator writing into `output_directory`.
34    pub fn new(output_directory: impl Into<PathBuf>) -> Self {
35        Self {
36            output_directory: output_directory.into(),
37        }
38    }
39
40    /// The directory pages are written into.
41    #[must_use]
42    pub fn output_directory(&self) -> &Path {
43        &self.output_directory
44    }
45
46    /// Renders a document to `path`, relative to the output directory.
47    ///
48    /// The page is written with [`Document::render_to`], section by section, into a
49    /// temporary file that is then renamed. The bytes match [`Document::render_with`].
50    ///
51    /// # Errors
52    /// Returns an error if the path escapes the output directory, or if the write fails.
53    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    /// Renders several documents.
67    ///
68    /// With the `parallel` feature the pages are rendered concurrently — sound because the
69    /// node tree is `Send + Sync`, which Winged-Swift's reference-typed tree is not.
70    ///
71    /// **Every** failure is reported, not just the first: a bulk build that names one of
72    /// twelve broken pages is worse than useless.
73    ///
74    /// # Errors
75    /// Returns an error naming every page that failed.
76    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    /// Renders a bare node to `path`, optionally prefixed by the doctype.
100    ///
101    /// # Errors
102    /// Returns an error if the path is unsafe or the write fails.
103    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    /// Copies a file into the output directory, replacing any existing destination.
119    ///
120    /// # Errors
121    /// Returns an error if the destination is unsafe or the copy fails.
122    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    /// Removes the output directory, optionally recreating it empty.
135    ///
136    /// # Errors
137    /// Returns an error if the output directory is unsafe to delete — empty, the
138    /// filesystem root, or containing a `..` component. Winged-Swift's version has no such
139    /// guard and will happily recurse through whatever it is pointed at.
140    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    /// Writes UTF-8 `content` to `path`, creating parent directories.
153    ///
154    /// The write is atomic: content goes to a temporary file in the same directory and is
155    /// then renamed, so a reader never sees a half-written page.
156    ///
157    /// # Errors
158    /// Returns an error if the path escapes the output directory or the write fails.
159    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    /// Writes through a temporary file in the destination directory, then renames.
164    ///
165    /// The closure receives the open temporary file. On any error the temporary file is
166    /// removed, so a reader never observes a half-written page under the final name.
167    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    /// Joins `path` onto the output directory, rejecting anything that escapes it.
191    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
203/// Rejects output directories that are dangerous to delete recursively.
204fn 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
224/// Turns per-page results into one error naming every failure.
225fn 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    /// A throwaway directory that removes itself. Avoids a dev-dependency for six tests.
248    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    /// Ports `StaticSiteGeneratorTests.generateWritesADocument`.
272    #[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    /// Ports `ThreeZeroTests.generatorStreamsDocuments` and
285    /// `ThreeZeroTests.replacesAnExistingFileAndReportsFailures`.
286    #[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    /// Ports `StaticSiteGeneratorTests.generateCreatesNestedDirectories`.
306    #[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    /// Ports `StaticSiteGeneratorTests.generateCanSkipDoctype`.
320    #[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    /// Ports `StaticSiteGeneratorTests.writeFileAndCopyAsset`.
344    #[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    /// Ports `StaticSiteGeneratorTests.cleanRemovesPreviousOutput`.
360    #[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    /// The guard Winged-Swift does not have.
372    #[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    /// Ports `StaticSiteGeneratorTests.generateMultipleWritesEveryPage`.
393    #[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    /// Ports `StaticSiteGeneratorTests.generateWritesDoctypeAndMarkup`.
427    #[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    /// Ports `StaticSiteGeneratorTests.generateMultipleDocuments`.
447    #[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}