heartwood every commit a ring
2.9 KB raw
use axum::{
    extract::{Query, State},
    http::{header, HeaderMap, StatusCode, Uri},
    response::{Html, IntoResponse, Response},
    routing::get,
    Router,
};
use minijinja::context;
use rand::seq::SliceRandom;
use serde::Deserialize;

use crate::app::AppState;
use crate::error::AppError;
use crate::posts::{self, Post};
use crate::render::{build_request, render_html, Crumb};

#[derive(Deserialize)]
struct SearchQuery {
    #[serde(default)]
    q: String,
}

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/search/", get(page))
        .route("/search/live/", get(live))
}

fn matches(post: &Post, needle_lower: &str) -> bool {
    post.title.to_lowercase().contains(needle_lower)
        || post.description.to_lowercase().contains(needle_lower)
        || post
            .tags
            .iter()
            .any(|t| t.to_lowercase().contains(needle_lower))
}

async fn page(
    State(state): State<AppState>,
    Query(q): Query<SearchQuery>,
    uri: Uri,
    headers: HeaderMap,
) -> Result<Html<String>, AppError> {
    let request = build_request(&uri, &headers);
    let published = posts::published(&state.posts);
    let mut results: Vec<Post> = Vec::new();
    let mut random_posts: Option<Vec<Post>> = None;
    if !q.q.is_empty() {
        let needle = q.q.to_lowercase();
        for p in &published {
            if matches(p, &needle) {
                results.push(p.clone());
            }
        }
    } else {
        let mut rng = rand::thread_rng();
        let mut shuffled = published.clone();
        shuffled.shuffle(&mut rng);
        random_posts = Some(shuffled.into_iter().take(6).collect());
    }
    let breadcrumbs = vec![Crumb {
        title: "Home".into(),
        url: "/".into(),
    }];
    let page = context! {
        title => "Search",
        slug => "search",
        description => "Search posts on webdev, coding, security, and sysadmin.",
    };
    render_html(
        &state,
        "search.html",
        context! { page, results, random_posts, q => &q.q, breadcrumbs },
        &request,
    )
}

async fn live(State(state): State<AppState>, Query(q): Query<SearchQuery>) -> Response {
    let published = posts::published(&state.posts);
    let mut out = Vec::new();
    if !q.q.is_empty() {
        let needle = q.q.to_lowercase();
        for p in &published {
            if matches(p, &needle) {
                out.push(serde_json::json!({
                    "title": p.title,
                    "description": p.description,
                    "url": format!("/posts/{}/", p.slug),
                }));
                if out.len() >= 5 {
                    break;
                }
            }
        }
    }
    // Match Flask's jsonify: trailing newline.
    let body = serde_json::to_string(&out).unwrap_or_default() + "\n";
    let mut h = HeaderMap::new();
    h.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
    (StatusCode::OK, h, body).into_response()
}