heartwood every commit a ring

the almanac sorts its tools onto separate shelves

5696a113 by Isaac Bythewood · 2 days ago

modified src/almanac.rs
@@ -1,11 +1,13 @@use chrono::{DateTime, Datelike, NaiveDate, Timelike};use chrono_tz::Tz;use serde::Serialize;use crate::astro::{moon_phase, sky_data_lines};use crate::content::{parse_frontmatter, parse_list_items, ListItems, MoonTip, Season, SiteData};use crate::markdown::{render_block, render_inline};use crate::rng::{day_hash, pick_items, Mulberry32};#[derive(Serialize)]pub struct Assembled {    pub date_line: String,    pub season_name: String,
added src/app.rs
@@ -0,0 +1,55 @@use axum::{http::header, middleware as axum_middleware, Router};use minijinja::Environment;use std::path::PathBuf;use std::sync::Arc;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::content::{self, SiteData};use crate::middleware::log_requests;use crate::routes;use crate::templates;#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub data: Arc<SiteData>,    pub dist_dir: PathBuf,}impl AppState {    pub fn from_env() -> Self {        let project_root: PathBuf = std::env::var("DARKFURROW_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("."));        let templates_dir = project_root.join("templates");        let dist_dir = project_root.join("dist");        let data_dir = project_root.join("data");        let manifest_path = dist_dir.join(".vite/manifest.json");        let env = templates::build_env(&templates_dir, &manifest_path);        let data = content::load_data(&data_dir).expect("failed to load data");        Self {            env: Arc::new(env),            data: Arc::new(data),            dist_dir,        }    }}pub fn router(state: AppState) -> Router {    let static_files = tower::ServiceBuilder::new()        .layer(SetResponseHeaderLayer::if_not_present(            header::CACHE_CONTROL,            header::HeaderValue::from_static("public, max-age=31536000"),        ))        .service(ServeDir::new(&state.dist_dir));    Router::new()        .merge(routes::index::router())        .nest_service("/static", static_files)        .layer(axum_middleware::from_fn(log_requests))        .with_state(state)}
added src/error.rs
@@ -0,0 +1,16 @@use axum::http::StatusCode;use axum::response::{IntoResponse, Response};pub struct AppError(pub StatusCode, pub String);impl<E: std::fmt::Display> From<E> for AppError {    fn from(e: E) -> Self {        AppError(StatusCode::INTERNAL_SERVER_ERROR, format!("internal error: {e}"))    }}impl IntoResponse for AppError {    fn into_response(self) -> Response {        (self.0, self.1).into_response()    }}
modified src/main.rs
@@ -1,160 +1,26 @@mod almanac;mod app;mod astro;mod content;mod error;mod markdown;mod middleware;mod rng;mod routes;mod templates;use axum::{    extract::{Query, Request, State},    http::{header, StatusCode},    middleware::{self, Next},    response::{Html, IntoResponse, Json, Response},    routing::get,    Router,};use chrono::Local;use chrono_tz::America::New_York;use minijinja::{context, Environment};use serde::Deserialize;use std::net::SocketAddr;use std::path::PathBuf;use std::sync::Arc;use std::time::Instant;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use content::SiteData;#[derive(Clone)]struct AppState {    env: Arc<Environment<'static>>,    data: Arc<SiteData>,}#[derive(Deserialize)]struct ContentQuery {    #[serde(default)]    season: Option<String>,}#[tokio::main]async fn main() {    let project_root: PathBuf = std::env::var("DARKFURROW_ROOT")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("."));    let templates_dir = project_root.join("templates");    let dist_dir = project_root.join("dist");    let data_dir = project_root.join("data");    let manifest_path = dist_dir.join(".vite/manifest.json");    let env = templates::build_env(&templates_dir, &manifest_path);    let data = content::load_data(&data_dir).expect("failed to load data");    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let state = AppState {        env: Arc::new(env),        data: Arc::new(data),    };    let app = Router::new()        .route("/", get(index))        .route("/api/content", get(api_content))        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(SetResponseHeaderLayer::if_not_present(                    header::CACHE_CONTROL,                    header::HeaderValue::from_static("public, max-age=31536000"),                ))                .service(ServeDir::new(&dist_dir)),        )        .layer(middleware::from_fn(log_requests))        .with_state(state);    let app = app::router(app::AppState::from_env());    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();    eprintln!("darkfurrow listening on http://{addr}");    axum::serve(listener, app).await.unwrap();}async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}struct AppError(StatusCode, String);impl<E: std::fmt::Display> From<E> for AppError {    fn from(e: E) -> Self {        AppError(StatusCode::INTERNAL_SERVER_ERROR, format!("internal error: {e}"))    }}impl IntoResponse for AppError {    fn into_response(self) -> Response {        (self.0, self.1).into_response()    }}async fn index(    State(state): State<AppState>,    Query(q): Query<ContentQuery>,) -> Result<Html<String>, AppError> {    let now = chrono::Utc::now().with_timezone(&New_York);    let content = almanac::assemble_content(now, &state.data, q.season.as_deref());    let tmpl = state.env.get_template("index.html")?;    let body = tmpl.render(context! {        date_line => content.date_line,        season_name => content.season_name,        season_note => content.season_note,        season_key => content.season_key,        time_key => content.time_key,        haiku_html => content.haiku_html,        sections_html => content.sections_html,        footer_text => content.footer_text,        season_nav_html => content.season_nav_html,    })?;    Ok(Html(body))}async fn api_content(    State(state): State<AppState>,    Query(q): Query<ContentQuery>,) -> Result<Json<serde_json::Value>, AppError> {    let now = chrono::Utc::now().with_timezone(&New_York);    let content = almanac::assemble_content(now, &state.data, q.season.as_deref());    Ok(Json(serde_json::json!({        "date_line": content.date_line,        "season_name": content.season_name,        "season_note": content.season_note,        "season_key": content.season_key,        "time_key": content.time_key,        "haiku_html": content.haiku_html,        "sections_html": content.sections_html,        "footer_text": content.footer_text,        "season_nav_html": content.season_nav_html,    })))}
added src/middleware.rs
@@ -0,0 +1,27 @@use axum::extract::Request;use axum::middleware::Next;use axum::response::Response;use chrono::Local;use std::time::Instant;pub async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}
added src/routes/index.rs
@@ -0,0 +1,45 @@use axum::{    extract::{Query, State},    response::{Html, Json},    routing::get,    Router,};use chrono_tz::America::New_York;use serde::Deserialize;use crate::almanac::{self, Assembled};use crate::app::AppState;use crate::error::AppError;pub fn router() -> Router<AppState> {    Router::new()        .route("/", get(index))        .route("/api/content", get(api_content))}#[derive(Deserialize)]struct ContentQuery {    #[serde(default)]    season: Option<String>,}fn build(state: &AppState, season: Option<&str>) -> Assembled {    let now = chrono::Utc::now().with_timezone(&New_York);    almanac::assemble_content(now, &state.data, season)}async fn index(    State(state): State<AppState>,    Query(q): Query<ContentQuery>,) -> Result<Html<String>, AppError> {    let content = build(&state, q.season.as_deref());    let tmpl = state.env.get_template("index.html")?;    Ok(Html(tmpl.render(content)?))}async fn api_content(    State(state): State<AppState>,    Query(q): Query<ContentQuery>,) -> Result<Json<Assembled>, AppError> {    Ok(Json(build(&state, q.season.as_deref())))}
added src/routes/mod.rs
@@ -0,0 +1 @@pub mod index;