Skip to main content

winged_rust/
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;
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 winged_rust::prelude::*;
20/// use winged_rust::{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    /// # Errors
49    /// Returns an error if the path escapes the output directory, or if the write fails.
50    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    /// Renders several documents.
60    ///
61    /// With the `parallel` feature the pages are rendered concurrently — sound because the
62    /// node tree is `Send + Sync`, which Winged-Swift's reference-typed tree is not.
63    ///
64    /// **Every** failure is reported, not just the first: a bulk build that names one of
65    /// twelve broken pages is worse than useless.
66    ///
67    /// # Errors
68    /// Returns an error naming every page that failed.
69    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    /// Renders a bare node to `path`, optionally prefixed by the doctype.
93    ///
94    /// # Errors
95    /// Returns an error if the path is unsafe or the write fails.
96    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    /// Copies a file into the output directory, replacing any existing destination.
112    ///
113    /// # Errors
114    /// Returns an error if the destination is unsafe or the copy fails.
115    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    /// Removes the output directory, optionally recreating it empty.
128    ///
129    /// # Errors
130    /// Returns an error if the output directory is unsafe to delete — empty, the
131    /// filesystem root, or containing a `..` component. Winged-Swift's version has no such
132    /// guard and will happily recurse through whatever it is pointed at.
133    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    /// Writes UTF-8 `content` to `path`, creating parent directories.
146    ///
147    /// The write is atomic: content goes to a temporary file in the same directory and is
148    /// then renamed, so a reader never sees a half-written page.
149    ///
150    /// # Errors
151    /// Returns an error if the path escapes the output directory or the write fails.
152    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    /// Joins `path` onto the output directory, rejecting anything that escapes it.
165    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
177/// Rejects output directories that are dangerous to delete recursively.
178fn 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
198/// Turns per-page results into one error naming every failure.
199fn 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    /// A throwaway directory that removes itself. Avoids a dev-dependency for six tests.
222    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    /// Ports `StaticSiteGeneratorTests.generateWritesADocument`.
246    #[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    /// Ports `StaticSiteGeneratorTests.generateCreatesNestedDirectories`.
259    #[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    /// Ports `StaticSiteGeneratorTests.generateCanSkipDoctype`.
273    #[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    /// Ports `StaticSiteGeneratorTests.writeFileAndCopyAsset`.
297    #[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    /// Ports `StaticSiteGeneratorTests.cleanRemovesPreviousOutput`.
313    #[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    /// The guard Winged-Swift does not have.
325    #[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    /// Ports `StaticSiteGeneratorTests.generateMultipleWritesEveryPage`.
346    #[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    /// Ports `StaticSiteGeneratorTests.generateWritesDoctypeAndMarkup`.
380    #[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    /// Ports `StaticSiteGeneratorTests.generateMultipleDocuments`.
400    #[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}