stapix

Yet another static page generator for photo galleries

Commit
b6e895d8edaffbdd62d8c79783fc34b0477b8ecb
Parent
c5ee607c7934eb80bac38146ed296578392b7e85
Author
Pablo <pablo-pie@riseup.net>
Date

Implemented incremental build of the HTML pages and made it the default option

Also implemented an option to disable incremental builds

Diffstat

5 files changed, 160 insertions, 98 deletions

Status File Name N° Changes Insertions Deletions
Modified Cargo.lock 2 1 1
Modified Cargo.toml 2 1 1
Modified README.md 8 7 1
Modified src/log.rs 2 1 1
Modified src/main.rs 244 150 94
diff --git a/Cargo.lock b/Cargo.lock
@@ -584,7 +584,7 @@ dependencies = [
 
 [[package]]
 name = "stapix"
-version = "0.2.3"
+version = "0.2.4"
 dependencies = [
  "crossterm",
  "image",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "stapix"
-version = "0.2.3"
+version = "0.2.4"
 edition = "2021"
 license = "GPLv3"
 
diff --git a/README.md b/README.md
@@ -10,6 +10,7 @@ simple feature set:
 * Accessible and optimized HTML output implementing current best practices
 * Simple static webpages with no need for JavaScript
 * Automatically generate WebP thumbnails in parallel
+* Incremental builds
 
 For a live example please see <https://photos.pablopie.xyz>!
 
@@ -27,7 +28,7 @@ for one such example of fork.
 To use stapix run:
 
 ```console
-$ stapix config.yml
+$ stapix config.yml [--full-build]
 ```
 
 The configuration file `config.yml` should consist of a list of struct entries
@@ -74,6 +75,11 @@ was taken) to be displayed for all users. **The `alt` and `caption` attributes
 should not be the same!** See
 <https://www.htmhell.dev/adventcalendar/2022/22/> for further details.
 
+### Options
+
+* **`--full-build`:** Disables incremental builds. Re-renders all pages and
+  thumbnails.
+
 ## Installation
 
 stapix can be installed via Cargo by cloning this directory, as in:
diff --git a/src/log.rs b/src/log.rs
@@ -130,7 +130,7 @@ macro_rules! usage {
         let mut stderr = io::stderr();
         let _ = writeln!(
             stderr,
-            "{usage_header_msg} {} config.yml",
+            "{usage_header_msg} {} config.yml [--full-build]",
             $program,
             usage_header_msg = "[USAGE]".yellow().bold()
         );
diff --git a/src/main.rs b/src/main.rs
@@ -6,7 +6,7 @@ use std::{
     fmt::{self, Display},
     fs::{self, File},
     io::{self, Write},
-    path::PathBuf,
+    path::{PathBuf, Path},
     process::ExitCode,
     sync::mpsc,
 };
@@ -18,7 +18,19 @@ mod log;
 mod gallery_entry;
 
 /// A wrapper for HTML-escaped strings
-pub struct Escaped<'a>(pub &'a str);
+struct Escaped<'a>(pub &'a str);
+
+/// A wrapper to display lists of command line arguments
+struct ArgList<'a>(pub &'a [String]);
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum RenderResult {
+    Skipped,
+    Success,
+    Failure,
+}
+
+const FULL_BUILD_OPT: &str = "--full-build";
 
 const TARGET_PATH:  &str = "./site";
 const PAGES_PATH:   &str = "pix";
@@ -47,15 +59,18 @@ fn main() -> ExitCode {
 
     let args: Vec<String> = env::args().collect();
 
-    let (program, config) = match &args[..] {
-        [program, config] => (program, config),
-        [program] => {
-            error!("Expected 1 command line argument, found none");
+    let (program, config, full_build) = match &args[..] {
+        [program, config] => (program, config, false),
+        [program, config, opt] if opt == FULL_BUILD_OPT => {
+            (program, config, true)
+        }
+        [program, _config, ..] => {
+            errorln!("Unknown arguments: {}", ArgList(&args[2..]));
             usage!(program);
             return ExitCode::FAILURE;
         }
-        [program, ..] => {
-            error!("Expected 1 command line argument, found many");
+        [program] => {
+            errorln!("Expected 1 command line argument, found none");
             usage!(program);
             return ExitCode::FAILURE;
         }
@@ -66,22 +81,22 @@ fn main() -> ExitCode {
     match f.map(serde_yaml::from_reader::<_, Vec<GalleryEntry>>) {
         // Error opening the config file
         Err(err) => {
-            error!("Couldn't open {config:?}: {err}");
+            errorln!("Couldn't open {config:?}: {err}");
             usage!(program);
             ExitCode::FAILURE
         }
         // Error parsing the config file
         Ok(Err(err)) => {
-            error!("Couldn't parse {config:?}: {err}");
+            errorln!("Couldn't parse {config:?}: {err}");
             usage_config!();
             ExitCode::FAILURE
         }
-        Ok(Ok(pics)) => render_gallery(pics),
+        Ok(Ok(pics)) => render_gallery(pics, full_build),
     }
 }
 
 /// Coordinates the rendering of all the pages and file conversions
-fn render_gallery(pics: Vec<GalleryEntry>) -> ExitCode {
+fn render_gallery(pics: Vec<GalleryEntry>, full_build: bool) -> ExitCode {
     info!("Copying image files to the target directory...");
 
     for pic in &pics {
@@ -126,15 +141,15 @@ fn render_gallery(pics: Vec<GalleryEntry>) -> ExitCode {
         let sender = sender.clone();
         let pic = pic.clone();
         rendering_pool.execute(move || {
-            sender.send(render_thumbnail(pic))
+            sender.send(render_thumbnail(pic, full_build))
                 .expect("channel should still be alive awaiting for the completion of this task");
         });
     }
 
     for _ in 0..pics.len() {
         match reciever.recv() {
-            Ok(false) => return ExitCode::FAILURE,
-            Ok(true)  => {}
+            Ok(RenderResult::Failure) => return ExitCode::FAILURE,
+            Ok(RenderResult::Success | RenderResult::Skipped)  => {}
             Err(_)    => {
                 // Propagate the panic to the main thread: reciever.recv should
                 // only fail if some of the rendering threads panicked
@@ -154,10 +169,13 @@ fn render_gallery(pics: Vec<GalleryEntry>) -> ExitCode {
 
     for pic in pics {
         info!("Rendering HTML page for {name:?}...", name = pic.file_name);
-        if render_pic_page(&pic).is_err() {
-            return ExitCode::FAILURE;
+        match render_pic_page(&pic, full_build) {
+            RenderResult::Success => info_done!(),
+            RenderResult::Skipped => {
+                info_done!("Skipped! (use {FULL_BUILD_OPT} to overwrite)");
+            }
+            RenderResult::Failure => return ExitCode::FAILURE,
         }
-        info_done!();
     }
 
     ExitCode::SUCCESS
@@ -223,81 +241,97 @@ fn render_index(pics: &Vec<GalleryEntry>) -> io::Result<()> {
     writeln!(f, "</html>")
 }
 
-fn render_pic_page(pic: &GalleryEntry) -> io::Result<()> {
+fn render_pic_page(pic: &GalleryEntry, full_build: bool) -> RenderResult {
     let mut path = PathBuf::from(TARGET_PATH);
     path.push(PAGES_PATH);
     path.push(pic.file_name.clone() + ".html");
 
+    // Only try to re-render HTML page in case the page is older than the
+    // image file
+    if !full_build && !needs_update(&path, &pic.path) {
+        return RenderResult::Skipped;
+    }
+
     let mut f = match File::create(&path) {
         Ok(file) => file,
         Err(err) => {
             errorln!("Could not open file {path:?}: {err}");
-            return Err(err);
+            return RenderResult::Failure;
         }
     };
 
-    writeln!(f, "<!DOCTYPE html>")?;
-    write_license(&mut f)?;
-    writeln!(f, "<html lang=\"en\">")?;
-    writeln!(f, "<head>")?;
-    writeln!(
-        f,
-        "<title>{PAGE_TITLE} &dash; {name}</title>",
-        name = Escaped(&pic.file_name)
-    )?;
-    write_head(&mut f)?;
-    writeln!(
-        f,
-        "<link rel=\"preload\" as=\"image\" href=\"/{PHOTOS_PATH}/{n}\">",
-        n = Escaped(&pic.file_name)
-    )?;
-    writeln!(f, "</head>")?;
+    /// Does the deeds
+    fn write_file(f: &mut File, pic: &GalleryEntry) -> io::Result<()> {
+        writeln!(f, "<!DOCTYPE html>")?;
+        write_license(f)?;
+        writeln!(f, "<html lang=\"en\">")?;
+        writeln!(f, "<head>")?;
+        writeln!(
+            f,
+            "<title>{PAGE_TITLE} &dash; {name}</title>",
+            name = Escaped(&pic.file_name)
+        )?;
+        write_head(f)?;
+        writeln!(
+            f,
+            "<link rel=\"preload\" as=\"image\" href=\"/{PHOTOS_PATH}/{n}\">",
+            n = Escaped(&pic.file_name)
+        )?;
+        writeln!(f, "</head>")?;
 
-    writeln!(f, "<body>")?;
-    write_nav(&mut f)?;
+        writeln!(f, "<body>")?;
+        write_nav(f)?;
 
-    writeln!(f, "<main>")?;
-    if pic.caption.is_some() {
-        writeln!(f, "<figure>")?;
-    } else {
-        writeln!(f, "<figure aria-label=\"File {name}\">",
-                 name = Escaped(&pic.file_name))?;
-    }
-    writeln!(f, "<div id=\"picture-container\">")?;
-    writeln!(
-        f,
-        "<img alt=\"{alt}\" src=\"/{PHOTOS_PATH}/{file_name}\">",
-        alt = Escaped(&pic.alt),
-        file_name = Escaped(&pic.file_name)
-    )?;
-    writeln!(f, "</div>")?;
-    if let Some(caption) = &pic.caption {
-        writeln!(f, "<figcaption>")?;
-        writeln!(f, "{}", Escaped(caption))?;
-        writeln!(f, "</figcaption>")?;
-    }
-    writeln!(f, "</figure>")?;
-    writeln!(f, "</main>")?;
+        writeln!(f, "<main>")?;
+        if pic.caption.is_some() {
+            writeln!(f, "<figure>")?;
+        } else {
+            writeln!(f, "<figure aria-label=\"File {name}\">",
+                     name = Escaped(&pic.file_name))?;
+        }
+        writeln!(f, "<div id=\"picture-container\">")?;
+        writeln!(
+            f,
+            "<img alt=\"{alt}\" src=\"/{PHOTOS_PATH}/{file_name}\">",
+            alt = Escaped(&pic.alt),
+            file_name = Escaped(&pic.file_name)
+        )?;
+        writeln!(f, "</div>")?;
+        if let Some(caption) = &pic.caption {
+            writeln!(f, "<figcaption>")?;
+            writeln!(f, "{}", Escaped(caption))?;
+            writeln!(f, "</figcaption>")?;
+        }
+        writeln!(f, "</figure>")?;
+        writeln!(f, "</main>")?;
+
+        writeln!(f, "<footer>")?;
+        write!(f, "original work by ")?;
+        if let Some(url) = &pic.author_url {
+            writeln!(f, "<a role=\"author\" href=\"{url}\">{author}</a>",
+                     author = Escaped(&pic.author))?;
+        } else {
+            writeln!(f, "{}", Escaped(&pic.author))?;
+        }
+        writeln!(f, "<br>")?;
+        if let LicenseType::Cc(license) = &pic.license {
+            writeln!(f, "licensed under <a role=\"license\" href=\"{url}\">{license}</a>",
+                     url = license.url())?;
+        } else {
+            writeln!(f, "this is public domain")?;
+        }
+        writeln!(f, "</footer>")?;
 
-    writeln!(f, "<footer>")?;
-    write!(f, "original work by ")?;
-    if let Some(url) = &pic.author_url {
-        writeln!(f, "<a role=\"author\" href=\"{url}\">{author}</a>",
-                 author = Escaped(&pic.author))?;
-    } else {
-        writeln!(f, "{}", Escaped(&pic.author))?;
+        writeln!(f, "</body>")?;
+        writeln!(f, "</html>")
     }
-    writeln!(f, "<br>")?;
-    if let LicenseType::Cc(license) = &pic.license {
-        writeln!(f, "licensed under <a role=\"license\" href=\"{url}\">{license}</a>",
-                 url = license.url())?;
+
+    if let Err(err) = write_file(&mut f, pic) {
+        errorln!("Could not write to {path:?}: {err}");
+        RenderResult::Failure
     } else {
-        writeln!(f, "this is public domain")?;
+        RenderResult::Success
     }
-    writeln!(f, "</footer>")?;
-
-    writeln!(f, "</body>")?;
-    writeln!(f, "</html>")
 }
 
 fn write_nav(f: &mut File) -> io::Result<()> {
@@ -363,27 +397,20 @@ fn write_license(f: &mut File) -> io::Result<()> {
     )
 }
 
-/// Returns `true` if rendering succeded (or was skipped) and `false`
-/// otherwise. All warinings and error messages should be logged in here.
 // TODO: Render GIF files as mp4 instead
-fn render_thumbnail(pic: GalleryEntry) -> bool {
+fn render_thumbnail(pic: GalleryEntry, full_build: bool) -> RenderResult {
     let mut thumb_path = PathBuf::from(TARGET_PATH);
     thumb_path.push(THUMBS_PATH);
     thumb_path.push(pic.file_name.clone() + ".webp");
 
     // Only try to render thumbnail in case the thumbnail file in the machine
     // is older than the source file
-    if let (Ok(thumb_meta), Ok(img_meta)) = (fs::metadata(&thumb_path), fs::metadata(&pic.path)) {
-        let thumb_mod_date = thumb_meta.modified()
-            .expect("os should support file modification date");
-        let img_mod_date = img_meta.modified()
-            .expect("os should support file modification date");
-
-        if thumb_mod_date > img_mod_date {
-            warnln!("Skipped rendering the thumbnail for {name:?} (update {path:?} to overwrite)",
-                     name = pic.file_name, path = pic.path);
-            return true;
-        }
+    if !full_build && !needs_update(&thumb_path, &pic.path) {
+        warnln!(
+            "Skipped rendering the thumbnail for {name:?} (use {FULL_BUILD_OPT} to overwrite)",
+            name = pic.file_name
+        );
+        return RenderResult::Skipped;
     }
 
     let mut thumb_file = match File::create(&thumb_path) {
@@ -392,7 +419,7 @@ fn render_thumbnail(pic: GalleryEntry) -> bool {
             errorln!(
                 "Couldn't open WebP thumbnail file {thumb_path:?}: {err}"
             );
-            return false;
+            return RenderResult::Failure;
         }
     };
 
@@ -404,7 +431,7 @@ fn render_thumbnail(pic: GalleryEntry) -> bool {
                 path = pic.file_name,
                 err = err
             );
-            return false;
+            return RenderResult::Failure;
         }
     };
 
@@ -415,7 +442,7 @@ fn render_thumbnail(pic: GalleryEntry) -> bool {
                 "Faileded to decode image file {name:?}: {err}",
                 name = pic.file_name,
             );
-            return false;
+            return RenderResult::Failure;
         }
     };
 
@@ -438,10 +465,22 @@ fn render_thumbnail(pic: GalleryEntry) -> bool {
             "Couldn't write WebP thumnail to file {path:?}: {err}",
             path = thumb_path
         );
-        return false;
+        return RenderResult::Failure;
     }
 
     infoln!("Rendered WebP thumbnail for {name:?}", name = pic.file_name);
+    RenderResult::Success
+}
+
+/// Returns `false` if both `p1` and `p2` exist and and `p1` is newer than
+/// `f2`. Returns `true` otherwise
+fn needs_update<P1: AsRef<Path>, P2: AsRef<Path>>(p1: P1, p2: P2) -> bool {
+    if let (Ok(m1), Ok(m2)) = (fs::metadata(&p1), fs::metadata(&p2)) {
+        if m1.modified().unwrap() > m2.modified().unwrap() {
+            return false;
+        }
+    }
+
     true
 }
 
@@ -461,3 +500,20 @@ impl<'a> Display for Escaped<'a> {
         Ok(())
     }
 }
+
+impl<'a> Display for ArgList<'a> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
+        let mut first = true;
+
+        for arg in self.0 {
+            if first {
+                first = false;
+                write!(f, "{:?}", arg)?;
+            } else {
+                write!(f, " {:?}", arg)?;
+            }
+        }
+
+        Ok(())
+    }
+}