repos

blog.bythewood.me-rust

mirror archived upstream

Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.

axumblogdockermarkdownminijinjarustself-hostedtypstvite

4.7 KB · 169 lines · Rust Raw History
  1use axum::{
  2    extract::{Path as AxumPath, State},
  3    http::{HeaderMap, Uri},
  4    response::{Html, Redirect},
  5    routing::get,
  6    Router,
  7};
  8use minijinja::context;
  9
 10use crate::app::AppState;
 11use crate::error::AppError;
 12use crate::posts::{self, Post};
 13use crate::render::{build_request, render_html, Crumb};
 14
 15pub fn router() -> Router<AppState> {
 16    Router::new()
 17        .route("/blog/", get(index))
 18        .route("/blog/tag/{tag}/", get(by_tag))
 19        .route("/blog/year/{year}/", get(by_year))
 20        .route("/blog/{slug}/", get(post_redirect))
 21        .route("/blog/{slug}/pdf/", get(post_pdf_redirect))
 22        .route("/blog/{slug}/md/", get(post_md_redirect))
 23}
 24
 25async fn index(
 26    State(state): State<AppState>,
 27    uri: Uri,
 28    headers: HeaderMap,
 29) -> Result<Html<String>, AppError> {
 30    let request = build_request(&uri, &headers);
 31    let published = posts::published(&state.posts);
 32    let tags = posts::collect_tags(&published);
 33    let years = posts::collect_years(&published);
 34    let breadcrumbs = vec![Crumb {
 35        title: "Home".into(),
 36        url: "/".into(),
 37    }];
 38    let page = context! {
 39        title => "Blog",
 40        slug => "blog",
 41        description => "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood.",
 42    };
 43    render_html(
 44        &state,
 45        "blog_index.html",
 46        context! { page, blog_posts => published, tags, years, breadcrumbs },
 47        &request,
 48    )
 49}
 50
 51async fn by_tag(
 52    State(state): State<AppState>,
 53    AxumPath(tag): AxumPath<String>,
 54    uri: Uri,
 55    headers: HeaderMap,
 56) -> Result<Html<String>, AppError> {
 57    let request = build_request(&uri, &headers);
 58    let published = posts::published(&state.posts);
 59    let filtered: Vec<Post> = published
 60        .iter()
 61        .filter(|p| p.tags.contains(&tag))
 62        .cloned()
 63        .collect();
 64    if filtered.is_empty() {
 65        return Err(AppError::not_found());
 66    }
 67    let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {
 68        Some(
 69            published
 70                .iter()
 71                .filter(|p| !p.tags.contains(&tag))
 72                .take(4)
 73                .cloned()
 74                .collect(),
 75        )
 76    } else {
 77        None
 78    };
 79    let tags = posts::collect_tags(&published);
 80    let years = posts::collect_years(&published);
 81    let active_tag = context! { name => &tag, slug => &tag };
 82    let page = context! {
 83        title => format!("Tag: {tag}"),
 84        slug => format!("tag-{tag}"),
 85        description => format!("Posts tagged {tag}"),
 86    };
 87    let breadcrumbs = vec![
 88        Crumb {
 89            title: "Home".into(),
 90            url: "/".into(),
 91        },
 92        Crumb {
 93            title: "Blog".into(),
 94            url: "/blog/".into(),
 95        },
 96    ];
 97    render_html(
 98        &state,
 99        "blog_index.html",
100        context! { page, blog_posts => filtered, extra_posts, active_tag, tags, years, breadcrumbs },
101        &request,
102    )
103}
104
105async fn by_year(
106    State(state): State<AppState>,
107    AxumPath(year): AxumPath<String>,
108    uri: Uri,
109    headers: HeaderMap,
110) -> Result<Html<String>, AppError> {
111    let request = build_request(&uri, &headers);
112    let published = posts::published(&state.posts);
113    let filtered: Vec<Post> = published
114        .iter()
115        .filter(|p| p.date.starts_with(&year))
116        .cloned()
117        .collect();
118    if filtered.is_empty() {
119        return Err(AppError::not_found());
120    }
121    let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {
122        Some(
123            published
124                .iter()
125                .filter(|p| !p.date.starts_with(&year))
126                .take(4)
127                .cloned()
128                .collect(),
129        )
130    } else {
131        None
132    };
133    let tags = posts::collect_tags(&published);
134    let years = posts::collect_years(&published);
135    let page = context! {
136        title => format!("Year: {year}"),
137        slug => format!("year-{year}"),
138        description => format!("Posts from {year}"),
139    };
140    let breadcrumbs = vec![
141        Crumb {
142            title: "Home".into(),
143            url: "/".into(),
144        },
145        Crumb {
146            title: "Blog".into(),
147            url: "/blog/".into(),
148        },
149    ];
150    render_html(
151        &state,
152        "blog_index.html",
153        context! { page, blog_posts => filtered, extra_posts, active_year => &year, tags, years, breadcrumbs },
154        &request,
155    )
156}
157
158async fn post_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {
159    Redirect::permanent(&format!("/posts/{slug}/"))
160}
161
162async fn post_pdf_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {
163    Redirect::permanent(&format!("/posts/{slug}/pdf/"))
164}
165
166async fn post_md_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {
167    Redirect::permanent(&format!("/posts/{slug}/md/"))
168}