From 93f734bdf02a519eff0b0da9456d8bcf975baee8 Mon Sep 17 00:00:00 2001 From: Adrian Hedqvist Date: Sun, 21 Apr 2024 14:13:18 +0200 Subject: [PATCH] progress on sections --- config.toml | 1 + pages/index.md | 26 ++++++++ pages/posts/index.md | 1 + src/handlers/mod.rs | 41 ++++++------- src/handlers/pages.rs | 50 +++++++++------- src/handlers/tags.rs | 8 +-- src/main.rs | 26 ++++---- src/markdown.rs | 21 ++++--- src/page.rs | 59 ++++++++++++++++--- src/settings.rs | 5 +- templates/index.html | 26 -------- templates/{post.html => page.html} | 2 +- templates/partials/head.html | 26 ++++---- templates/partials/header.html | 2 +- .../{posts_index.html => section_index.html} | 0 15 files changed, 178 insertions(+), 116 deletions(-) create mode 100644 pages/index.md create mode 100644 pages/posts/index.md delete mode 100644 templates/index.html rename templates/{post.html => page.html} (96%) rename templates/{posts_index.html => section_index.html} (100%) diff --git a/config.toml b/config.toml index 40cd57a..db52b75 100644 --- a/config.toml +++ b/config.toml @@ -1,3 +1,4 @@ +title = "tollyx.se" base_url = "http://localhost:8080/" bind_address = "0.0.0.0:8080" logging = "info,website=debug" diff --git a/pages/index.md b/pages/index.md new file mode 100644 index 0000000..7bcf119 --- /dev/null +++ b/pages/index.md @@ -0,0 +1,26 @@ +why hello there this is a new index page if everything is setup correctly + +whoah this time it's actually generated from markdown too unlike the other one + +anyway here's a new todo list: + + +## todo + +- [x] static content +- [x] template rendering (tera) +- [x] markdown rendering (pulldown_cmark) +- [x] post metadata (wow now pulldown_cmark can get it for me instead of hacking it in with regex, nice) +- [x] page aliases (gotta keep all those old links working) +- [x] rss/atom (tera is useful here too) +- [x] code hilighting (syntact) +- [x] cache headers (pages uses etags, some others timestamps. it works) +- [x] docker from-scratch image (it's small!) +- [x] opentelemetry (metrics, traces) + - [ ] opentelemetry logs? (don't know if I'm gonna need it? can probably just make the collector grab them from the docker logs?) +- [ ] file-watching (rebuild pages when they're changed, not only on startup) +- [ ] ~~sass/less compilation~~ (don't think I need it, will skip for now) +- [ ] fancy css (but nothing too fancy, I like it [Simple And Clean](https://youtu.be/0nKizH5TV_g?t=42)) +- [ ] other pages (now I've got it set up so I can write any page in markdown!!!) +- [ ] graphviz (or something else) to svg rendering (want it to be serverside) +- [ ] image processing (resizing, conversion to jpgxl, avif, others?) diff --git a/pages/posts/index.md b/pages/posts/index.md new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/pages/posts/index.md @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index adf804f..7e0cb12 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,4 +1,5 @@ use axum::{ + body::Body, extract::{Request, State}, http::{header, HeaderMap, StatusCode}, middleware::Next, @@ -30,11 +31,12 @@ async fn record_hit(method: String, path: String) { pub fn routes() -> Router> { Router::new() - .route("/", get(index)) + // .route("/", get(index)) .merge(pages::router()) .merge(tags::router()) - .route("/healthcheck", get(healthcheck)) - .route_service("/static/*path", ServeDir::new("./")) + .route("/healthcheck", get(|| async { "OK" })) + .nest_service("/static", ServeDir::new("static")) + .fallback(|| async { WebsiteError::NotFound }) .layer(axum::middleware::from_fn(metrics_middleware)) } @@ -43,8 +45,8 @@ pub async fn index( State(state): State>, headers: HeaderMap, ) -> std::result::Result { - if should_return_304(&headers, Some(state.startup_time.into())) { - return Ok(StatusCode::NOT_MODIFIED.into_response()); + if let Some(res) = should_return_304(&headers, Some(state.startup_time.into())) { + return Ok(res); } let mut ctx = tera::Context::new(); ctx.insert("base_url", &state.base_url.to_string()); @@ -66,14 +68,6 @@ pub async fn index( .into_response()) } -async fn healthcheck() -> &'static str { - "OK" -} - -pub async fn not_found() -> impl IntoResponse { - (StatusCode::NOT_FOUND, ()) -} - #[instrument(skip(request, next))] pub async fn metrics_middleware(request: Request, next: Next) -> Response { let path = request.uri().path().to_string(); @@ -88,32 +82,39 @@ pub async fn metrics_middleware(request: Request, next: Next) -> Response { response } -fn should_return_304(headers: &HeaderMap, last_changed: Option>) -> bool { +fn should_return_304( + headers: &HeaderMap, + last_changed: Option>, +) -> Option { let Some(date) = last_changed else { - return false; + return None; }; let Some(since) = headers.get(header::IF_MODIFIED_SINCE) else { - return false; + return None; }; let Ok(parsed) = DateTime::::parse_from_rfc2822(since.to_str().unwrap()) else { - return false; + return None; }; - date >= parsed + if date >= parsed { + Some(Response::builder().status(304).body(Body::empty()).unwrap()) + } else { + None + } } impl IntoResponse for WebsiteError { fn into_response(self) -> Response { match self { - WebsiteError::NotFound => (StatusCode::NOT_FOUND, ()).into_response(), + WebsiteError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(), WebsiteError::InternalError(e) => { if let Some(s) = e.source() { error!("internal error: {}: {}", e, s); } else { error!("internal error: {}", e); } - (StatusCode::INTERNAL_SERVER_ERROR, ()).into_response() + (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() } } } diff --git a/src/handlers/pages.rs b/src/handlers/pages.rs index 4256eb7..623604a 100644 --- a/src/handlers/pages.rs +++ b/src/handlers/pages.rs @@ -1,16 +1,18 @@ use std::sync::Arc; +use anyhow::anyhow; use axum::{ - extract::{OriginalUri, Path, Request, State}, - http::{self, header, request, HeaderMap, StatusCode}, + body::Body, + extract::{OriginalUri, Request, State}, + http::{self, header, HeaderMap, StatusCode, Uri}, response::{Html, IntoResponse, Redirect, Response}, routing::get, Router, }; use serde_derive::Serialize; -use tower::{Service, ServiceExt}; -use tower_http::{follow_redirect::policy::PolicyExt, services::ServeDir}; +use tower::ServiceExt; +use tower_http::services::ServeDir; use tracing::{debug, instrument}; use crate::{ @@ -24,7 +26,8 @@ pub fn router() -> Router> { Router::new() .route("/atom.xml", get(feed)) .route("/posts/", get(index)) - .route("/posts", get(|| async { Redirect::permanent("/") })) + .route("/posts", get(|| async { Redirect::permanent("/posts/") })) + .route("/", get(view)) .route("/*path", get(view)) } @@ -46,8 +49,8 @@ async fn index( let last_changed = posts.iter().filter_map(|p| p.last_modified()).max(); - if should_return_304(&headers, last_changed) { - return Ok(StatusCode::NOT_MODIFIED.into_response()); + if let Some(res) = should_return_304(&headers, last_changed) { + return Ok(res); } posts.sort_by_key(|p| &p.date); @@ -60,7 +63,7 @@ async fn index( c.insert("posts", &posts); c.insert("base_url", &state.base_url.to_string()); - let res = state.tera.render("posts_index.html", &c)?; + let res = state.tera.render("section_index.html", &c)?; Ok(( StatusCode::OK, @@ -78,13 +81,12 @@ async fn index( .into_response()) } -#[instrument(skip(state, uri, method, headers, request))] +#[instrument(skip(state, uri, method, headers))] async fn view( - uri: OriginalUri, + OriginalUri(uri): OriginalUri, State(state): State>, method: http::method::Method, headers: HeaderMap, - request: Request, ) -> Result { // Fetch post let Some(post) = state.pages.get(uri.path()) else { @@ -101,12 +103,10 @@ async fn view( } } + debug!("page not found for '{uri}', fallback to static files"); + // TODO: I don't like how we create a new oneshot for every 404 request, but I don't know if there's a better way - return Ok(ServeDir::new("pages") - .oneshot(request) - .await - .unwrap() - .into_response()); + return get_static_file("pages", uri).await; }; if !post.is_published() { @@ -159,8 +159,8 @@ pub async fn feed( let last_changed = posts.iter().filter_map(|p| p.last_modified()).max(); - if should_return_304(&headers, last_changed) { - return Ok(StatusCode::NOT_MODIFIED.into_response()); + if let Some(res) = should_return_304(&headers, last_changed) { + return Ok(res); } posts.sort_by_key(|p| &p.date); @@ -195,6 +195,16 @@ pub async fn redirect( } } +async fn get_static_file(base_dir: &str, uri: Uri) -> Result { + let req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + + ServeDir::new(base_dir) + .oneshot(req) + .await + .map(IntoResponse::into_response) + .map_err(|e| anyhow!(e).into()) +} + #[cfg(test)] mod tests { use chrono::DateTime; @@ -207,14 +217,14 @@ mod tests { fn render_index() { let posts = vec![ Page { - title: "test".into(), + title: Some("test".into()), slug: "test".into(), tags: vec!["abc".into(), "def".into()], date: Some(DateTime::parse_from_rfc3339("2023-03-26T13:04:01+02:00").unwrap()), ..Default::default() }, Page { - title: "test2".into(), + title: Some("test2".into()), slug: "test2".into(), date: None, ..Default::default() diff --git a/src/handlers/tags.rs b/src/handlers/tags.rs index 4ec2f68..2ee42d3 100644 --- a/src/handlers/tags.rs +++ b/src/handlers/tags.rs @@ -62,8 +62,8 @@ pub async fn view( let last_changed = posts.iter().filter_map(|p| p.last_modified()).max(); - if should_return_304(&headers, last_changed) { - return Ok(StatusCode::NOT_MODIFIED.into_response()); + if let Some(res) = should_return_304(&headers, last_changed) { + return Ok(res); } posts.sort_by_key(|p| &p.date); @@ -108,8 +108,8 @@ pub async fn feed( let last_changed = posts.iter().filter_map(|p| p.last_modified()).max(); - if should_return_304(&headers, last_changed) { - return Ok(StatusCode::NOT_MODIFIED.into_response()); + if let Some(res) = should_return_304(&headers, last_changed) { + return Ok(res); } posts.sort_by_key(|p| &p.date); diff --git a/src/main.rs b/src/main.rs index 1cfd7c0..6f3d748 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ pub struct AppState { base_url: Uri, pages: HashMap, aliases: HashMap, + settings: Settings, tags: HashMap, tera: Tera, } @@ -39,13 +40,15 @@ pub struct AppState { #[tokio::main] async fn main() -> Result<()> { let cfg = settings::get()?; - println!("{cfg:?}"); - observability::init(&cfg)?; - info!("Starting server..."); - let app = init_app(&cfg).await?; - let listener = TcpListener::bind(&cfg.bind_address).await.unwrap(); + info!("Starting server..."); + let listener = TcpListener::bind(&cfg.bind_address).await.unwrap(); + info!( + "Bind address: {:?} Base Url: {:?}", + cfg.bind_address, cfg.base_url + ); + let app = init_app(cfg).await?; axum::serve(listener, app.into_make_service()) .with_graceful_shutdown(shutdown_signal()) .await?; @@ -56,7 +59,7 @@ async fn main() -> Result<()> { } #[instrument(skip(cfg))] -async fn init_app(cfg: &Settings) -> Result { +async fn init_app(cfg: Settings) -> Result { let base_url: Uri = cfg.base_url.parse().unwrap(); let tera = Tera::new("templates/**/*")?; @@ -64,13 +67,17 @@ async fn init_app(cfg: &Settings) -> Result { startup_time: chrono::offset::Utc::now(), base_url, tera, + settings: cfg, ..Default::default() }; let pages = page::load_all(&state, "pages/".into()).await?; info!("{} pages loaded", pages.len()); for page in pages.values() { - debug!("slug: {}, path: {}", page.slug, page.absolute_path); + debug!( + "slug: {:?}, path: {:?}, children: {:?}", + page.slug, page.absolute_path, page.child_pages + ); } let tags = tag::get_tags(pages.values()); @@ -82,11 +89,10 @@ async fn init_app(cfg: &Settings) -> Result { .map(|a| (a.clone(), p.absolute_path.clone())) }) .collect(); + state.pages = pages; state.tags = tags; - let state = Arc::new(state); - info!("Listening at {}", state.base_url); Ok(handlers::routes() .layer(CorsLayer::permissive()) .layer(CompressionLayer::new()) @@ -95,7 +101,7 @@ async fn init_app(cfg: &Settings) -> Result { .make_span_with(observability::make_span) .on_response(observability::on_response), ) - .with_state(state.clone())) + .with_state(Arc::new(state))) } async fn shutdown_signal() { diff --git a/src/markdown.rs b/src/markdown.rs index ca76058..f600acf 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -30,21 +30,19 @@ pub fn render_markdown_to_html(base_uri: Option<&Uri>, markdown: &str) -> Render opt.insert(Options::ENABLE_SMART_PUNCTUATION); opt.insert(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS); - let mut content_html = String::new(); + let mut content_html = String::with_capacity(markdown.len()); let parser = Parser::new_ext(markdown, opt); + let mut accumulated_block = String::new(); let mut code_lang = None; - let mut code_accumulator = String::new(); let mut meta_kind = None; - let mut meta_accumulator = String::new(); let mut events = Vec::new(); + let mut metadata = String::new(); for event in parser { match event { Event::Text(text) => { - if code_lang.is_some() { - code_accumulator.push_str(&text); - } else if meta_kind.is_some() { - meta_accumulator.push_str(&text); + if code_lang.is_some() || meta_kind.is_some() { + accumulated_block.push_str(&text); } else { events.push(Event::Text(text)); } @@ -54,6 +52,8 @@ pub fn render_markdown_to_html(base_uri: Option<&Uri>, markdown: &str) -> Render } Event::End(TagEnd::MetadataBlock(_)) => { meta_kind = None; + metadata.push_str(&accumulated_block); + accumulated_block.clear(); } Event::Start(Tag::Link { mut dest_url, @@ -103,12 +103,11 @@ pub fn render_markdown_to_html(base_uri: Option<&Uri>, markdown: &str) -> Render } Event::End(TagEnd::CodeBlock) => { let lang = code_lang.take().unwrap_or("".into()); - let res = hilighting::hilight(&code_accumulator, &lang, Some("base16-ocean.dark")) + let res = hilighting::hilight(&accumulated_block, &lang, Some("base16-ocean.dark")) .unwrap(); events.push(Event::Html(res.into())); - - code_accumulator.clear(); + accumulated_block.clear(); } _ => events.push(event), } @@ -123,6 +122,6 @@ pub fn render_markdown_to_html(base_uri: Option<&Uri>, markdown: &str) -> Render RenderResult { content_html, - metadata: meta_accumulator, + metadata, } } diff --git a/src/page.rs b/src/page.rs index 8e40c9d..e5501b5 100644 --- a/src/page.rs +++ b/src/page.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + ffi::OsStr, fmt::Debug, hash::{Hash, Hasher}, path::{Path, PathBuf}, @@ -18,39 +19,53 @@ use crate::{helpers, markdown, AppState, WebsiteError}; #[derive(Deserialize, Debug, Default)] pub struct TomlFrontMatter { - pub title: String, + pub title: Option, pub date: Option, pub updated: Option, pub draft: Option, + pub template: Option, pub aliases: Option>, pub tags: Option>, } #[derive(Serialize, Clone, Debug, Default)] pub struct Page { - pub title: String, + pub title: Option, pub draft: bool, pub date: Option>, pub updated: Option>, pub aliases: Vec, pub tags: Vec, + pub child_pages: Vec, pub content: String, + pub template: String, pub slug: String, pub absolute_path: String, + pub section: Option, pub etag: String, } impl Page { pub fn new(slug: String, content: String, fm: TomlFrontMatter) -> Page { let mut hasher = std::hash::DefaultHasher::default(); + fm.title.hash(&mut hasher); + fm.draft.hash(&mut hasher); + fm.tags.hash(&mut hasher); content.hash(&mut hasher); let etag = format!("W/\"{:x}\"", hasher.finish()); Page { - absolute_path: format!("/{slug}/"), + absolute_path: if slug.is_empty() { + String::from("/") + } else { + format!("/{slug}/") + }, slug, etag, + section: None, content, + child_pages: vec![], + template: fm.template.unwrap_or_else(|| "page.html".to_string()), title: fm.title, draft: fm.draft.unwrap_or(false), date: fm @@ -81,10 +96,27 @@ impl Page { pub async fn load_all(state: &AppState, folder: PathBuf) -> Result> { let mut pages = HashMap::::new(); let mut dirs: Vec = vec![folder.clone()]; + let mut section_stack: Vec = vec![]; while let Some(dir) = dirs.pop() { let mut read_dir = fs::read_dir(&dir).await?; + let mut section_index = dir.clone(); + section_index.push("index.md"); + let is_section = section_index.exists(); + let current_section = if is_section { + let mut page = load_page(state, §ion_index, &folder).await?; + page.section = section_stack.last().cloned(); + section_stack.push(page.absolute_path.clone()); + pages.insert(page.absolute_path.clone(), page); + section_stack.last() + } else { + section_stack.last() + }; + debug!("current section: {current_section:?}"); + + let mut child_pages = vec![]; + while let Some(entry) = read_dir.next_entry().await? { let path = entry.path(); if path.is_dir() { @@ -92,11 +124,23 @@ pub async fn load_all(state: &AppState, folder: PathBuf) -> Result Res &path_str[root.len()..=i] } else { &path_str[root.len()..] - } - .trim_start_matches("pages/"); + }; let base_uri = helpers::uri_with_path(&state.base_url, base_path); @@ -140,11 +183,13 @@ pub async fn load_page(state: &AppState, path: &Path, root_folder: &Path) -> Res pub async fn render_page(state: &AppState, page: &Page) -> Result { let mut ctx = tera::Context::new(); ctx.insert("page", &page); + ctx.insert("all_pages", &state.pages); + ctx.insert("site_title", &state.settings.title); ctx.insert("base_url", &state.base_url.to_string()); state .tera - .render("post.html", &ctx) + .render(&page.template, &ctx) .map_err(std::convert::Into::into) } diff --git a/src/settings.rs b/src/settings.rs index 78e03a5..1e30b33 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -2,15 +2,16 @@ use anyhow::{Error, Result}; use config::Config; use serde::Deserialize; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Default)] pub struct Settings { + pub title: String, pub base_url: String, pub bind_address: String, pub logging: String, pub otlp: Otlp, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Default)] pub struct Otlp { pub enabled: bool, pub endpoint: String, diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 72f1e30..0000000 --- a/templates/index.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -

tollyx.net

-

hi hello welcome to my website it's pretty wip right now yeah ok bye

-

todo

-
    -
  • ✅ static content
  • -
  • ✅ template rendering (tera)
  • -
  • ✅ markdown rendering (pulldown_cmark)
  • -
  • ✅ post metadata (frontmatter, toml)
  • -
  • ✅ app metrics (page hits, etc)
  • -
  • ✅ tests
  • -
  • ✅ page aliases (redirects, for back-compat with old routes)
  • -
  • ✅ rss/atom/jsonfeed (atom is good enough for now)
  • -
  • ✅ proper error handling (i guess??)
  • -
  • ✅ code hilighting? (good enough for now, gotta figure out themes n' stuff later)
  • -
  • ⬜ cache headers (etag, last-modified, returning 304, other related headers) -
  • ⬜ sass compilation (using rsass? grass?)
  • -
  • ⬜ fancy styling
  • -
  • ⬜ other pages???
  • -
  • ⬜ graphviz to svg rendering??
  • -
  • ⬜ image processing?? (resizing, conversion)
  • -
  • ⬜ opentelemetry?
  • -
-{% endblock main %} diff --git a/templates/post.html b/templates/page.html similarity index 96% rename from templates/post.html rename to templates/page.html index 8d475f6..8b8ad06 100644 --- a/templates/post.html +++ b/templates/page.html @@ -14,7 +14,7 @@ {%- endif -%} {%- endif %} - {{ page.content | safe -}} + {{ page.content | safe }} {% if page.tags -%}
    diff --git a/templates/partials/head.html b/templates/partials/head.html index 9685aa9..9d5b6e9 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -2,29 +2,27 @@ - + {% if tag_slug -%} - + {%- endif %} - - - {% if page -%} - - {%- elif tag -%} - - {%- else -%} - - {%- endif %} - + + + {% if page.title -%} - {{ page.title }} | tollyx.net + + {{ page.title }} | {{ site_title }} + {%- elif tag -%} + + #{{ tag.slug }} | {{ site_title }} {%- else -%} - tollyx.net + + {{ site_title }} {%- endif %} \ No newline at end of file diff --git a/templates/partials/header.html b/templates/partials/header.html index 6227b2c..702c996 100644 --- a/templates/partials/header.html +++ b/templates/partials/header.html @@ -1,5 +1,5 @@
    diff --git a/templates/posts_index.html b/templates/section_index.html similarity index 100% rename from templates/posts_index.html rename to templates/section_index.html