diff --git a/Cargo.lock b/Cargo.lock index da63f7c..cb1f757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4" + [[package]] name = "async-compression" version = "0.3.15" @@ -283,8 +289,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ "iana-time-zone", + "js-sys", "num-integer", "num-traits", + "serde", + "time", + "wasm-bindgen", "winapi", ] @@ -600,7 +610,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -959,7 +969,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -1544,6 +1554,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tokio" version = "1.26.0" @@ -1894,6 +1915,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1958,8 +1985,10 @@ checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" name = "website" version = "0.1.0" dependencies = [ + "anyhow", "axum", "cached", + "chrono", "color-eyre", "glob", "hyper", diff --git a/Cargo.toml b/Cargo.toml index b86776f..fa02fd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,10 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.70" axum = { version = "0.6.12", features = ["http2"] } cached = "0.42.0" +chrono = { version = "0.4.24", features = ["serde"] } color-eyre = "0.6.1" glob = "0.3.0" #grass = { version = "0.12.3", features = ["random"] } # not really needed yet diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..085e7fd --- /dev/null +++ b/Containerfile @@ -0,0 +1,11 @@ +FROM rust:slim as build-env +WORKDIR /app +COPY . /app +RUN cargo build --release + +FROM gcr.io/distroless/cc +COPY --from=build-env /app/target/release/website / +COPY --from=build-env /templates / +COPY --from=build-env /posts / +COPY --from=build-env /static / +CMD ["./website"] \ No newline at end of file diff --git a/posts/foldertest/index.md b/posts/foldertest/index.md index 44ea513..4409aa0 100644 --- a/posts/foldertest/index.md +++ b/posts/foldertest/index.md @@ -1,5 +1,6 @@ +++ title="TOML metadata test" +date=2023-03-25T14:50:25+01:00 +++ # Testing post as index within folder diff --git a/posts/foldertest/nestedpost.md b/posts/foldertest/nestedpost.md index abdb7f7..77a8a06 100644 --- a/posts/foldertest/nestedpost.md +++ b/posts/foldertest/nestedpost.md @@ -1,6 +1,9 @@ +++ -title='nested post test does not work' +title='nested post test does work!' +date=2022-03-25T14:50:25+01:00 +++ # yet again a nested post test will it work this time, at least with the slug?? + +how about we go [even deeper!!](evendeeper/) diff --git a/posts/foldertest/nestedpost/evendeeper.md b/posts/foldertest/nestedpost/evendeeper.md new file mode 100644 index 0000000..3449497 --- /dev/null +++ b/posts/foldertest/nestedpost/evendeeper.md @@ -0,0 +1,7 @@ ++++ +title='how about even deeper??' +date=2024-03-25T14:50:25+01:00 ++++ +# WOWOAOWOFAODWAOWOAWAOWA + +SOOO DEEP BRO diff --git a/posts/test.md b/posts/test.md index 6c27e6b..1fbf5a9 100644 --- a/posts/test.md +++ b/posts/test.md @@ -1,3 +1,7 @@ ++++ +title="test page please ignore" +date=2023-03-25T15:16:10+01:00 ++++ # Test page please ignore Hello world! diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0362aec..17cbcd8 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,6 @@ +use hyper::StatusCode; use std::sync::Arc; -use tracing::log::*; +use tracing::{instrument, log::*}; use axum::{ response::{Html, IntoResponse, Response}, @@ -8,14 +9,24 @@ use axum::{ use crate::State; -pub mod posts; - +#[derive(Debug)] pub enum Error { NotFound, + InternalError(anyhow::Error), +} + +impl From for Error +where + E: Into, +{ + fn from(value: E) -> Self { + Error::InternalError(value.into()) + } } pub type Result>> = std::result::Result; +#[instrument(skip(state))] pub async fn index(Extension(state): Extension>) -> Result { let ctx = tera::Context::new(); let res = state.tera.render("index.html", &ctx).map_err(|e| { @@ -27,10 +38,15 @@ pub async fn index(Extension(state): Extension>) -> Result { impl IntoResponse for Error { fn into_response(self) -> Response { - let result: Vec = "not found".into(); - let body = axum::body::boxed(axum::body::Full::from(result)); match self { - Error::NotFound => Response::builder().status(404).body(body).unwrap(), + Error::NotFound => { + info!("not found"); + (StatusCode::NOT_FOUND, ()).into_response() + } + Error::InternalError(e) => { + error!("internal error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, ()).into_response() + } } } } diff --git a/src/handlers/posts.rs b/src/handlers/posts.rs deleted file mode 100644 index 371ceab..0000000 --- a/src/handlers/posts.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::sync::Arc; - -use axum::{extract::Path, response::Html, Extension}; -use tracing::log::*; - -use super::{Error, Result}; -use crate::{post::render_post, State}; - -pub async fn view(Path(path): Path, Extension(state): Extension>) -> Result { - info!("Requested post: {}", path); - let post = state - .posts - .iter() - .find(|p| p.slug.eq_ignore_ascii_case(&path)) - .ok_or(Error::NotFound)?; - //let post = load_post(&path).await.ok_or(Error::NotFound)?; - let res = render_post(&state, post).await.ok_or(Error::NotFound)?; - Ok(Html(res.into())) -} - -pub async fn index(Extension(state): Extension>) -> Result { - let mut ctx = tera::Context::new(); - ctx.insert("posts", &state.posts); - let res = state.tera.render("postsindex.html", &ctx).map_err(|e| { - error!("Failed rendering posts index: {:?}", e); - Error::NotFound - })?; - Ok(Html(res.into())) -} diff --git a/src/main.rs b/src/main.rs index b34892f..d6e4a9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,17 @@ -use std::{sync::Arc, time}; +use std::{collections::HashMap, sync::Arc}; use axum::{routing::get, Extension, Router}; use color_eyre::eyre::Result; use post::Post; use tera::Tera; -use tower_http::trace::TraceLayer; -use tracing::log::*; +use tower_http::{compression::CompressionLayer, trace::TraceLayer}; +use tracing::{instrument, log::*}; mod handlers; mod post; pub struct State { - posts: Vec, + posts: HashMap, tera: Tera, } @@ -21,32 +21,8 @@ async fn main() -> Result<()> { tracing_subscriber::fmt::init(); info!("Starting server..."); - let ts = time::Instant::now(); + let app = init_app().await?; - let tera = Tera::new("templates/**/*")?; - let posts = post::load_all()?; - - let state = Arc::new(State { tera, posts }); - - let middleware = tower::ServiceBuilder::new() - .layer(TraceLayer::new_for_http()) - .layer(Extension(state.clone())); - - let app = Router::new() - .route("/", get(handlers::index)) - .nest( - "/posts", - Router::new() - .route("/", get(handlers::posts::index)) - .route("/:route/", get(handlers::posts::view)) - .fallback_service(tower_http::services::ServeDir::new("./posts")), - ) - .nest_service("/static", tower_http::services::ServeDir::new("./static")) - .layer(middleware); - - let duration = time::Instant::now() - ts; - - info!("loaded server in {}ms", duration.as_secs_f64() * 1000.0); info!("Now listening at http://localhost:8180"); axum::Server::bind(&"0.0.0.0:8180".parse().unwrap()) @@ -55,3 +31,29 @@ async fn main() -> Result<()> { Ok(()) } + +#[instrument] +pub async fn init_app() -> Result { + let tera = Tera::new("templates/**/*")?; + let posts = post::load_all().await?; + + let posts_router = post::build_router(posts.values()); + + let state = Arc::new(State { tera, posts }); + + let middleware = tower::ServiceBuilder::new() + .layer(TraceLayer::new_for_http()) + .layer(Extension(state)) + .layer(CompressionLayer::new()); + + let app = Router::new() + .route("/", get(handlers::index)) + .nest( + "/posts", + posts_router.fallback_service(tower_http::services::ServeDir::new("./posts")), + ) + .nest_service("/static", tower_http::services::ServeDir::new("./static")) + .layer(middleware); + + Ok(app) +} diff --git a/src/post.rs b/src/post.rs index 776866d..1222015 100644 --- a/src/post.rs +++ b/src/post.rs @@ -1,20 +1,30 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path, sync::Arc}; -use cached::proc_macro::cached; -use color_eyre::eyre::Result; +use axum::{response::Html, routing::get, Extension, Router}; +use chrono::{DateTime, FixedOffset}; use glob::glob; + +use hyper::Uri; use lazy_static::lazy_static; use pulldown_cmark::{html, Options, Parser}; use regex::Regex; use serde_derive::{Deserialize, Serialize}; use tokio::fs; -use tracing::log::*; -use crate::State; +use tracing::{instrument, log::*}; + +use crate::{handlers, State}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct FrontMatter { - pub title: Option, + pub title: String, + pub date: DateTime, +} + +#[derive(Deserialize, Debug)] +pub struct TomlFrontMatter { + pub title: String, + pub date: toml::value::Datetime, } #[derive(Serialize, Clone, Debug)] @@ -22,74 +32,48 @@ pub struct Post { pub content: String, pub slug: String, pub absolute_path: String, - pub frontmatter: Option, + pub frontmatter: FrontMatter, } -pub fn load_all() -> Result> { - Ok(glob("posts/**/*.md")? - .map(|p| { - let path = p.unwrap(); - info!("found page: {}", path.display()); +#[instrument] +pub async fn load_all() -> color_eyre::eyre::Result> { + let mut res = HashMap::::new(); + for path in glob("posts/**/*.md")? { + let path = path.unwrap(); + debug!("found page: {}", path.display()); - let filename = path.file_name().unwrap(); - let slug = if filename.eq_ignore_ascii_case("index.md") { - path.parent().unwrap().to_string_lossy() - } else { - path.to_string_lossy() - } - .trim_start_matches("posts") - .trim_start_matches(std::path::MAIN_SEPARATOR) - .trim_end_matches(".md") - .trim_end_matches(std::path::MAIN_SEPARATOR) - .replace('\\', "/"); + let post = load_post(&path.to_string_lossy()).await?; - info!("slug: {slug}"); - - let raw = std::fs::read_to_string(&path).unwrap(); - let (frontmatter, content) = parse_frontmatter(raw); - - let content = content.map(|c| { - let options = Options::all(); - let mut content_html = String::new(); - let parser = Parser::new_ext(&c, options); - html::push_html(&mut content_html, parser); - content_html - }); - - Post { - absolute_path: format!("/posts/{slug}/"), - slug, - content: content.unwrap_or_default(), - frontmatter, - } - }) - .collect()) + res.insert(post.slug.clone(), post); + } + Ok(res) } -#[cached(time = 60, key = "String", convert = r#"{ String::from(path) }"#)] -pub async fn load_post(path: &str) -> Option { - let path = path - .trim_end_matches('/') +#[instrument] +pub async fn load_post(path: &str) -> color_eyre::eyre::Result { + let path = path.replace('\\', "/"); + let slug = path + .trim_start_matches("posts") + .trim_start_matches('/') + .trim_start_matches('\\') + .trim_end_matches(".html") .trim_end_matches(".md") - .trim_end_matches(".html"); + .trim_end_matches("index") + .trim_end_matches('\\') + .trim_end_matches('/'); - info!("loading post: {path}"); + debug!("loading post: {slug}"); - let path = if path.starts_with("posts/") { - Path::new(path).to_owned() - } else { - Path::new("posts").join(path) - }; + let file_path = Path::new("posts").join(slug); - let content = if let Ok(content) = fs::read_to_string(path.with_extension("md")).await { - content - } else if let Ok(content) = fs::read_to_string(path.join("index.md")).await { + let content = if let Ok(content) = fs::read_to_string(file_path.with_extension("md")).await { content } else { - return None; + fs::read_to_string(file_path.join("index.md")).await? }; - let (frontmatter, content) = parse_frontmatter(content); + let (tomlfm, content) = parse_frontmatter(content)?; + let tomlfm = tomlfm.expect("Missing frontmatter"); let content = content.map(|c| { let options = Options::all(); @@ -99,15 +83,31 @@ pub async fn load_post(path: &str) -> Option { content_html }); - Some(Post { - absolute_path: format!("/{}/", path.to_string_lossy()), - slug: path.to_string_lossy().into(), + let date = toml_date_to_chrono(tomlfm.date)?; + + let frontmatter = FrontMatter { + title: tomlfm.title, + date, + }; + + Ok(Post { + absolute_path: format!("/posts/{}/", slug), + slug: slug.to_string(), content: content.unwrap_or_default(), frontmatter, }) } -fn parse_frontmatter(src: String) -> (Option, Option) { +fn toml_date_to_chrono( + toml: toml::value::Datetime, +) -> color_eyre::eyre::Result> { + Ok(DateTime::parse_from_rfc3339(&toml.to_string())?) +} + +#[instrument] +fn parse_frontmatter( + src: String, +) -> color_eyre::eyre::Result<(Option, Option)> { lazy_static! { static ref FRONTMATTER_REGEX: Regex = regex::Regex::new( r"^[\s]*\+{3}(\r?\n(?s).*?(?-s))\+{3}[\s]*(?:$|(?:\r?\n((?s).*(?-s))$))" @@ -115,36 +115,72 @@ fn parse_frontmatter(src: String) -> (Option, Option) { .unwrap(); }; - if let Some(fm) = FRONTMATTER_REGEX.captures(&src) { + Ok(if let Some(captures) = FRONTMATTER_REGEX.captures(&src) { ( - fm.get(1) - .and_then(|m| toml::from_str(m.as_str()).expect("invalid toml")), - fm.get(2).map(|m| m.as_str().to_owned()), + Some(toml::from_str(captures.get(1).unwrap().as_str())?), + captures.get(2).map(|m| m.as_str().to_owned()), ) } else { (None, Some(src)) - } -} - -#[derive(Serialize)] -struct Postcontext { - content: String, -} - -#[cached(time = 60, key = "String", convert = r"{ post.absolute_path.clone() }")] -pub async fn render_post(state: &State, post: &Post) -> Option { - info!("rendering post: {}", post.absolute_path); - - let ctx = tera::Context::from_serialize(Postcontext { - content: post.content.clone(), }) - .ok()?; - let res = match state.tera.render("post.html", &ctx) { - Ok(res) => res, - Err(e) => { - error!("Failed rendering post: {}", e); - return None; - } - }; - Some(res) +} + +#[instrument(skip(state, post))] +async fn render_post(state: &State, post: &Post) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("title", &post.frontmatter.title); + ctx.insert("content", &post.content); + + state.tera.render("post.html", &ctx).map_err(|e| e.into()) +} + +#[instrument(skip(posts))] +pub fn build_router<'a, I>(posts: I) -> Router +where + I: Iterator, +{ + let mut router = Router::new().route("/", get(index)); + + for post in posts { + let slug = &post.slug; + let path = format!("/{slug}/"); + info!("adding post route: {path}"); + router = router.route(&path, get(view)); + } + + router +} + +#[instrument(skip(state))] +pub async fn view( + uri: Uri, + Extension(state): Extension>, +) -> Result, handlers::Error> { + debug!("viewing post: {uri}"); + let post = state + .posts + .get(uri.path().trim_matches('/')) + .ok_or(handlers::Error::NotFound)?; + + let res = render_post(&state, post).await?; + + Ok(Html(res)) +} + +#[instrument(skip(state))] +pub async fn index( + Extension(state): Extension>, +) -> Result, handlers::Error> { + let mut ctx = tera::Context::new(); + + let mut posts = state.posts.values().collect::>(); + + posts.sort_by_key(|p| &p.frontmatter.date); + + ctx.insert("title", "Posts"); + ctx.insert("posts", &posts); + + let res = state.tera.render("posts_index.html", &ctx)?; + + Ok(Html(res)) } diff --git a/templates/partials/head.html b/templates/partials/head.html index 80b7145..a6df569 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -4,5 +4,9 @@ - Document + {% if title -%} + {{ title }} | tollyx.net + {% else -%} + tollyx.net + {% endif -%} \ No newline at end of file diff --git a/templates/posts_index.html b/templates/posts_index.html new file mode 100644 index 0000000..6f099f0 --- /dev/null +++ b/templates/posts_index.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block main -%} +

posts

+

i occasionally write some stuff i guess

+ +{% endblock main -%} \ No newline at end of file diff --git a/templates/postsindex.html b/templates/postsindex.html deleted file mode 100644 index a86009a..0000000 --- a/templates/postsindex.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -

posts

-

i occasionally write some stuff i guess

- -{% endblock main %} \ No newline at end of file