site generation
This commit is contained in:
+122
-6
@@ -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<PathBuf, FleenError> {
|
||||
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<Vec<RenderOutput>, 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<RenderOutput>) -> 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<RenderOutput>, path: &str) -> Option<usize> {
|
||||
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<RenderOutput>, path: &str) -> Option<usize> {
|
||||
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<RenderOutput>, path: &str) -> Option<usize> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+54
-35
@@ -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<String>,
|
||||
@@ -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<RenderOutput, RenderError> {
|
||||
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<RenderOutput, RenderError> {
|
||||
pub fn file_render(source: PathBuf, root: &Path) -> Result<RenderOutput, RenderError> {
|
||||
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<RenderOutput, RenderError>
|
||||
} 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<PathBuf>) -> 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("<p>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("<!DOCTYPE html>")); // 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"))
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -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<PathBuf>| 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<PathBuf>, 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(_)) => {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
i am in a subdirectory
|
||||
Vendored
Reference in New Issue
Block a user