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

2.9 KB · 102 lines · Rust Raw History
  1use axum::{
  2    extract::{Query, State},
  3    http::{header, HeaderMap, StatusCode, Uri},
  4    response::{Html, IntoResponse, Response},
  5    routing::get,
  6    Router,
  7};
  8use minijinja::context;
  9use rand::seq::SliceRandom;
 10use serde::Deserialize;
 11
 12use crate::app::AppState;
 13use crate::error::AppError;
 14use crate::posts::{self, Post};
 15use crate::render::{build_request, render_html, Crumb};
 16
 17#[derive(Deserialize)]
 18struct SearchQuery {
 19    #[serde(default)]
 20    q: String,
 21}
 22
 23pub fn router() -> Router<AppState> {
 24    Router::new()
 25        .route("/search/", get(page))
 26        .route("/search/live/", get(live))
 27}
 28
 29fn matches(post: &Post, needle_lower: &str) -> bool {
 30    post.title.to_lowercase().contains(needle_lower)
 31        || post.description.to_lowercase().contains(needle_lower)
 32        || post
 33            .tags
 34            .iter()
 35            .any(|t| t.to_lowercase().contains(needle_lower))
 36}
 37
 38async fn page(
 39    State(state): State<AppState>,
 40    Query(q): Query<SearchQuery>,
 41    uri: Uri,
 42    headers: HeaderMap,
 43) -> Result<Html<String>, AppError> {
 44    let request = build_request(&uri, &headers);
 45    let published = posts::published(&state.posts);
 46    let mut results: Vec<Post> = Vec::new();
 47    let mut random_posts: Option<Vec<Post>> = None;
 48    if !q.q.is_empty() {
 49        let needle = q.q.to_lowercase();
 50        for p in &published {
 51            if matches(p, &needle) {
 52                results.push(p.clone());
 53            }
 54        }
 55    } else {
 56        let mut rng = rand::thread_rng();
 57        let mut shuffled = published.clone();
 58        shuffled.shuffle(&mut rng);
 59        random_posts = Some(shuffled.into_iter().take(6).collect());
 60    }
 61    let breadcrumbs = vec![Crumb {
 62        title: "Home".into(),
 63        url: "/".into(),
 64    }];
 65    let page = context! {
 66        title => "Search",
 67        slug => "search",
 68        description => "Search posts on webdev, coding, security, and sysadmin.",
 69    };
 70    render_html(
 71        &state,
 72        "search.html",
 73        context! { page, results, random_posts, q => &q.q, breadcrumbs },
 74        &request,
 75    )
 76}
 77
 78async fn live(State(state): State<AppState>, Query(q): Query<SearchQuery>) -> Response {
 79    let published = posts::published(&state.posts);
 80    let mut out = Vec::new();
 81    if !q.q.is_empty() {
 82        let needle = q.q.to_lowercase();
 83        for p in &published {
 84            if matches(p, &needle) {
 85                out.push(serde_json::json!({
 86                    "title": p.title,
 87                    "description": p.description,
 88                    "url": format!("/posts/{}/", p.slug),
 89                }));
 90                if out.len() >= 5 {
 91                    break;
 92                }
 93            }
 94        }
 95    }
 96    // Match Flask's jsonify: trailing newline.
 97    let body = serde_json::to_string(&out).unwrap_or_default() + "\n";
 98    let mut h = HeaderMap::new();
 99    h.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
100    (StatusCode::OK, h, body).into_response()
101}