From 8570116e6b9368c670536cf9d9540f5cb40518b7 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 6 Sep 2025 00:28:51 -0500 Subject: [PATCH] site generation --- src/fleen_app.rs | 128 +++++++++++++++++++++++++++++++-- src/main.rs | 12 +++- src/renderer.rs | 89 ++++++++++++++--------- src/server.rs | 9 ++- testdata/dir/subdir.md | 1 + testdata/with_index/index.html | 0 6 files changed, 192 insertions(+), 47 deletions(-) create mode 100644 testdata/dir/subdir.md delete mode 100644 testdata/with_index/index.html diff --git a/src/fleen_app.rs b/src/fleen_app.rs index dc41337..c210f2e 100644 --- a/src/fleen_app.rs +++ b/src/fleen_app.rs @@ -1,15 +1,17 @@ use std::cell::RefCell; -use std::{fs, io}; +use std::{fs, io, time}; use std::path::{Path, PathBuf}; use std::process::Command; use clipboard_rs::Clipboard; use clipboard_rs::common::RustImage; use thiserror::Error; -use tinyrand::Rand; -use crate::fleen_app::FleenError::{RootDirNonexistence, RootDirPopulated}; +use tinyrand::{Rand, Seeded}; +use crate::fleen_app::FleenError::{RootDirNonexistence, RootDirPopulated, TargetDir}; use crate::fleen_app::TreeEntry::{CloseDir, Dir}; +use crate::renderer; +use crate::renderer::{RenderError, RenderOutput}; -#[derive(Error, Debug, Clone)] +#[derive(Error, Debug)] pub enum FleenError { #[error("Can't reach root dir {0}")] RootDirNonexistence(PathBuf), @@ -24,7 +26,13 @@ pub enum FleenError { #[error("Image dir doesn't exist")] NoImageDir, #[error("No image on clipboard")] - NoClipboardImage + NoClipboardImage, + #[error("Render error: {0}")] + RenderError(#[from] RenderError), + #[error("Target dir is invalid (can't contain the app dir or vice versa)")] + TargetDir, + #[error("IO error: {0}")] + Io(#[from] io::Error) } #[derive(Clone, Debug)] @@ -191,7 +199,7 @@ impl FleenApp { pub fn unique_image_name(&self) -> Result { if !self.image_dir_exists() { return Err(FleenError::NoImageDir) } - let mut rng = tinyrand::StdRand::default(); + let mut rng = tinyrand::StdRand::seed(time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_secs()); loop { let fname = format!("image_{}.png", Self::random_name(&mut rng)); let path = self.root.join("images").join(fname); @@ -221,4 +229,112 @@ impl FleenApp { let _ = c.set_text(format!("![]({})", uri)); Ok("Image saved!".to_string()) } + + pub fn compile(&self) -> Result, FleenError> { + let mut sources = vec![]; // The list of renderoutputs we need to perform + + // Traverse a directory + fn visit_dir(dir: &Path, root: &Path, sources: &mut Vec) -> Result<(), RenderError> { + // Root is the app root. Dir is the directory path within the app root, like "assets". + // File is the filename (or child dir name) within the dir, so, root+dir+file is an + // absolute path + for entry in root.join(dir).read_dir().unwrap() { + let file = PathBuf::from(entry.unwrap().file_name()); + sources.push(renderer::file_render(dir.join(&file), root)?); + if root.join(&dir).join(&file).is_dir() { + // root + dir + file is a child directory, so we want to recurse... + // into dir + file. + visit_dir(&dir.join(&file), root, sources)? + } + } + Ok(()) + } + visit_dir(Path::new(""), &self.root, &mut sources)?; + Ok(sources) + } + + pub fn build_site(&self, target: &Path) -> Result<(), FleenError> { + if self.root.ancestors().any(|a| a == target) || + target.ancestors().any(|a| a == self.root) { + return Err(TargetDir) + } + + let actions = self.compile()?; + for action in actions.into_iter() { + action.file_operation(&self.root, target)?; + } + Ok(()) + } } + +#[cfg(test)] +mod tests { + use super::*; + + fn find_rendered_index(actions: &Vec, path: &str) -> Option { + let path = PathBuf::from(path); + actions.iter().position(|a| { + if let RenderOutput::Rendered(p, _) = a && p == &path { + true + } else { + false + } + }) + } + + fn find_dir_index(actions: &Vec, path: &str) -> Option { + let path = PathBuf::from(path); + actions.iter().position(|a| { + if let RenderOutput::Dir(p) = a && p == &path { + true + } else { + false + } + }) + } + + fn find_raw_index(actions: &Vec, path: &str) -> Option { + let path = PathBuf::from(path); + actions.iter().position(|a| { + if let RenderOutput::RawFile(p) = a && p == &path { + true + } else { + false + } + }) + } + + #[test] + fn test_compile() { + let app = FleenApp::open(PathBuf::from("./testdata")).unwrap(); + let actions = app.compile().unwrap(); + + // Rendered files in the root that exist + assert!(find_rendered_index(&actions,"index.html").is_some()); + assert!(find_rendered_index(&actions,"nolayout.html").is_some()); + assert!(find_rendered_index(&actions,"not_hidden.html").is_some()); + + // Rendered files that are hidden for whatever reason + assert!(find_rendered_index(&actions,"hidden.html").is_none()); + assert!(find_rendered_index(&actions,"_skipped.html").is_none()); + + // The original md files are not reproduced: + assert!(find_rendered_index(&actions,"index.md").is_none()); + assert!(find_rendered_index(&actions,"nolayout.md").is_none()); + assert!(find_rendered_index(&actions,"not_hidden.md").is_none()); + + // A subdir + assert!(find_dir_index(&actions, "dir").is_some()); + + // The thing in it should be made after the dir itself: + let file_idx = find_rendered_index(&actions, "dir/subdir.html").unwrap(); + let dir_idx = find_dir_index(&actions, "dir").unwrap(); + assert!(file_idx > dir_idx); + + // Raw files should be produced: + assert!(find_raw_index(&actions, "raw.txt").is_some()); + + // But not hidden ones: + assert!(find_raw_index(&actions, "_layouts/post.html").is_none()); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b2b9b9b..44210f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,12 +88,22 @@ impl FleenUi { egui::CentralPanel::default().show(ctx, |ui| { ui.horizontal(|ui| { - let width = ui.available_width() / 3.0; + let width = ui.available_width() / 3.0 - 5.0; ui.column(width, |ui| { self.tree_view(ui); just_clicked = self.tree_buttons(ui); }); ui.column(width, |ui| self.server_controls(ui)); + ui.column(width, |ui| { + if ui.add_fill_width(Button::green("Build site...")).clicked() && + let Some(path) = rfd::FileDialog::new().pick_folder() { + match self.app.as_ref().unwrap().build_site(&path) { + // TODO some kind of temporary notification thing + Ok(()) => { println!("Yay!") } + Err(err) => { self.error = Some(err) } + } + } + }) }); }); diff --git a/src/renderer.rs b/src/renderer.rs index 7bf4824..23984d4 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,4 +1,4 @@ -use std::fs; +use std::{fs, io}; use std::io::Error; use std::path::{Path, PathBuf}; use markdown::message::Message; @@ -6,6 +6,7 @@ use markdown::{Constructs, Options, ParseOptions}; use markdown::mdast::Node; use serde::Deserialize; use thiserror::Error; +use crate::fleen_app::FleenError; /// The things we might return from trying to render a file #[derive(Clone, PartialEq, Debug)] @@ -14,7 +15,7 @@ pub enum RenderOutput { Rendered(PathBuf, String), /// Hidden output should be returned by the test server but not rendered to a file Hidden(PathBuf, String), - /// The raw contents of the path + /// The raw contents of the given (relative) path. RawFile(PathBuf), /// No contents; this file should not be output / test server should return 404 NoOutput, @@ -22,6 +23,24 @@ pub enum RenderOutput { Dir(PathBuf) } +impl RenderOutput { + pub fn file_operation(&self, root: &Path, target: &Path) -> Result<(), io::Error> { + match self { + RenderOutput::Rendered(path, contents) => { + fs::write(target.join(path), contents) + } + RenderOutput::Hidden(_, _) | RenderOutput::NoOutput => Ok(()), // Don't do anything! + RenderOutput::RawFile(path) => { + fs::copy(root.join(path), target.join(path))?; + Ok(()) + } + RenderOutput::Dir(path) => { + fs::create_dir(target.join(path)) + } + } + } +} + #[derive(Deserialize)] pub struct Frontmatter { layout: Option, @@ -57,20 +76,34 @@ pub enum RenderError { FrontmatterParse(toml::de::Error, PathBuf) } -/// Return the path for the default thing to render for the test server, which is either index.html -/// (if it exists) or index.md. Note that this does not guarantee that the file actually exists, it -/// just chooses that because those are the files which can become index.html in the output and -/// index.html is what a reasonable webserver will pick as the default file. -pub fn default_path(root: &Path) -> PathBuf { - if let Ok(true) = fs::exists(root.join("index.html")) { - "index.html".into() +/// Take a source file path (relative to the root) and the root path, and return a RenderOutput for it. +/// This function is called for server output, which has different rules from file output. +pub fn server_render(source: PathBuf, root: &Path) -> Result { + let extension = source.extension().map(|o| o.to_str().unwrap()); + if skipped_path(source.clone()) { + // Skipped path, nothing + Ok(RenderOutput::NoOutput) + } else if root.join(source.clone()).is_dir() { + // Dir, which matters for producing files + Ok(RenderOutput::Dir(source)) + } else if let Ok(true) = fs::exists(root.join(source.clone())) { + match extension { + // Asked for a markdown file, but those become html, and we should request it as html: + Some("md") => Ok(RenderOutput::NoOutput), + // Not a markdown file, but it exists, return it raw + _ => Ok(RenderOutput::RawFile(source)) + } + } else if matches!(extension, Some("html")) && + let Ok(true) = fs::exists(root.join(source.with_extension("md"))) { + // We asked for an html file which doesn't exist but a corresponding md file does, render it + render_as_markdown(source.with_extension("md"), root) } else { - "index.md".into() + // Asked for something which doesn't exist and it's not the md -> html case, 404: + Ok(RenderOutput::NoOutput) } } -/// Take a source file path (relative to the root) and the root path, and return a RenderOutput for it -pub fn render(source: PathBuf, root: &Path) -> Result { +pub fn file_render(source: PathBuf, root: &Path) -> Result { let extension = source.extension().map(|o| o.to_str().unwrap()); if skipped_path(source.clone()) { // Skipped path, nothing @@ -81,17 +114,12 @@ pub fn render(source: PathBuf, root: &Path) -> Result } else if let Ok(true) = fs::exists(root.join(source.clone())) { match extension { // Asked for a markdown file, render it - // TODO this is a little silly for the server, this should properly be NoOutput but that's a big change Some("md") => render_as_markdown(source.clone(), root), // Not a markdown file, but it exists, return it raw - _ => Ok(RenderOutput::RawFile(root.join(source))) + _ => Ok(RenderOutput::RawFile(source)) } - } else if matches!(extension, Some("html")) && - let Ok(true) = fs::exists(root.join(source.with_extension("md"))) { - // We asked for an html file which doesn't exist but a corresponding md file does, render it - render_as_markdown(source.with_extension("md"), root) } else { - // Asked for something which doesn't exist and it's not the md -> html case, 404: + // Asked for something which doesn't exist: Ok(RenderOutput::NoOutput) } } @@ -156,7 +184,7 @@ mod tests { use super::*; fn render_file(path: impl Into) -> RenderOutput { - match render(path.into(), Path::new("./testdata")) { + match server_render(path.into(), Path::new("./testdata")) { Ok(ro) => ro, Err(e) => { println!("{}", e); @@ -167,7 +195,7 @@ mod tests { #[test] fn test_rendering_markdown() { - let contents = render_file("index.md"); + let contents = render_file("index.html"); assert!(matches!(contents, RenderOutput::Rendered(_, _))); let RenderOutput::Rendered(filename, contents) = contents else { unreachable!() }; assert_eq!(filename.to_str(), Some("index.html")); // Goes to the right filename @@ -179,7 +207,7 @@ mod tests { #[test] fn test_no_layout() { - let contents = render_file("nolayout.md"); + let contents = render_file("nolayout.html"); assert!(matches!(contents, RenderOutput::Rendered(_, _))); // Normal output let RenderOutput::Rendered(_, contents) = contents else { unreachable!() }; assert!(contents.starts_with("

This file has no layout")) // Doesn't use any layout, just the contents @@ -187,7 +215,7 @@ mod tests { #[test] fn test_no_output() { - let contents = render_file("_skipped.md"); + let contents = render_file("_skipped.html"); assert!(matches!(contents, RenderOutput::NoOutput)); // This begins with an underscore so it's skipped let contents = render_file("_layouts/post.html"); @@ -196,20 +224,20 @@ mod tests { #[test] fn test_hidden() { - let contents = render_file("hidden.md"); + let contents = render_file("hidden.html"); assert!(matches!(contents, RenderOutput::Hidden(_, _))); // The dev server should return it but it shouldn't produce a file let RenderOutput::Hidden(_, contents) = contents else { unreachable!() }; assert!(contents.starts_with("")); // Uses the layout assert!(contents.matches("This file should render to hidden").next().is_some()); - let contents = render_file("not_hidden.md"); + let contents = render_file("not_hidden.html"); assert!(matches!(contents, RenderOutput::Rendered(_, _))); // If we don't specify, it's published by default } #[test] fn test_raw() { let contents = render_file("raw.txt"); - assert_eq!(contents, RenderOutput::RawFile(PathBuf::from("./testdata/raw.txt"))); + assert_eq!(contents, RenderOutput::RawFile(PathBuf::from("raw.txt"))); } #[test] @@ -227,13 +255,4 @@ mod tests { let contents = render_file("index.html"); assert!(matches!(contents, RenderOutput::Rendered(_, _))) // We asked for the html file which doesn't exist but the md does } - - #[test] - fn test_default_path() { - // index.html doesn't exist so we default to index.md, which will become index.html - assert_eq!(default_path(Path::new("./testdata")), PathBuf::from("index.md")); - - // index.html exists so we default to it, rather than render something - assert_eq!(default_path(Path::new("./testdata/with_index")), PathBuf::from("index.html")) - } } \ No newline at end of file diff --git a/src/server.rs b/src/server.rs index 0c2cf19..9f91e87 100644 --- a/src/server.rs +++ b/src/server.rs @@ -6,14 +6,13 @@ use axum::http::Uri; use axum::response::{IntoResponse, Response}; use axum::Router; use axum::routing::get; -use crate::renderer::{default_path, render, RenderOutput}; +use crate::renderer::{server_render, RenderOutput}; pub async fn start_server(root: PathBuf, port: u32) { let app = Router::new() .route("/", get(|State(root): State| async move { // We need a separate route for the default path because {*p} must match at least one thing - let path = default_path(&root); - serve_path(String::from(path.to_str().unwrap()), root.clone()) + serve_path("index.html".to_string(), root.clone()) })) .route("/{*path}", get(|State(root): State, uri: Uri| async move { serve_path(String::from(uri.path()), root.clone()) @@ -26,7 +25,7 @@ pub async fn start_server(root: PathBuf, port: u32) { fn serve_path(path: String, root: PathBuf) -> impl IntoResponse { let path = path.strip_prefix("/").unwrap_or(path.as_str()); - let render = render(path.into(), root.as_ref()); + let render = server_render(path.into(), root.as_ref()); match render { Ok(RenderOutput::Rendered(_, content)) | @@ -38,7 +37,7 @@ fn serve_path(path: String, root: PathBuf) -> impl IntoResponse { Ok(RenderOutput::RawFile(file)) => { Response::builder() .status(200) - .body(Body::from(fs::read(file).unwrap_or(vec![]))).unwrap() + .body(Body::from(fs::read(root.join(file)).unwrap_or(vec![]))).unwrap() } Ok(RenderOutput::NoOutput) | Ok(RenderOutput::Dir(_)) => { diff --git a/testdata/dir/subdir.md b/testdata/dir/subdir.md new file mode 100644 index 0000000..9fca7fd --- /dev/null +++ b/testdata/dir/subdir.md @@ -0,0 +1 @@ +i am in a subdirectory \ No newline at end of file diff --git a/testdata/with_index/index.html b/testdata/with_index/index.html deleted file mode 100644 index e69de29..0000000