feat: bootstrap htmx templating system

main
Jef Roosens 2025-03-29 21:26:06 +01:00
parent ad015b47e4
commit b3e49be299
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
10 changed files with 210 additions and 2 deletions

View File

@ -1,4 +1,4 @@
use std::time::Duration; use std::{sync::Arc, time::Duration};
use crate::server; use crate::server;
@ -9,12 +9,17 @@ pub fn serve(config: &crate::config::Config) -> u8 {
tracing::info!("Initializing database and running migrations"); tracing::info!("Initializing database and running migrations");
// TODO remove unwraps
let store = let store =
gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME)) gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME))
.unwrap(); .unwrap();
let tera = crate::web::initialize_tera().unwrap();
let store = gpodder::GpodderRepository::new(store); let store = gpodder::GpodderRepository::new(store);
let ctx = server::Context { store }; let ctx = server::Context {
store,
tera: Arc::new(tera),
};
let app = server::app(ctx.clone()); let app = server::app(ctx.clone());
let rt = tokio::runtime::Builder::new_multi_thread() let rt = tokio::runtime::Builder::new_multi_thread()

View File

@ -1,6 +1,7 @@
mod cli; mod cli;
mod config; mod config;
mod server; mod server;
mod web;
use clap::Parser; use clap::Parser;

View File

@ -10,6 +10,7 @@ pub type AppResult<T> = Result<T, AppError>;
pub enum AppError { pub enum AppError {
// Db(db::DbError), // Db(db::DbError),
IO(std::io::Error), IO(std::io::Error),
Tera(tera::Error),
Other(Box<dyn std::error::Error + 'static + Send + Sync>), Other(Box<dyn std::error::Error + 'static + Send + Sync>),
BadRequest, BadRequest,
Unauthorized, Unauthorized,
@ -21,6 +22,7 @@ impl fmt::Display for AppError {
match self { match self {
// Self::Db(_) => write!(f, "database error"), // Self::Db(_) => write!(f, "database error"),
Self::IO(_) => write!(f, "io error"), Self::IO(_) => write!(f, "io error"),
Self::Tera(_) => write!(f, "tera error"),
Self::Other(_) => write!(f, "other error"), Self::Other(_) => write!(f, "other error"),
Self::BadRequest => write!(f, "bad request"), Self::BadRequest => write!(f, "bad request"),
Self::Unauthorized => write!(f, "unauthorized"), Self::Unauthorized => write!(f, "unauthorized"),
@ -33,6 +35,7 @@ impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self { match self {
// Self::Db(err) => Some(err), // Self::Db(err) => Some(err),
Self::Tera(err) => Some(err),
Self::IO(err) => Some(err), Self::IO(err) => Some(err),
Self::Other(err) => Some(err.as_ref()), Self::Other(err) => Some(err.as_ref()),
Self::NotFound | Self::Unauthorized | Self::BadRequest => None, Self::NotFound | Self::Unauthorized | Self::BadRequest => None,
@ -46,6 +49,12 @@ impl From<std::io::Error> for AppError {
} }
} }
impl From<tera::Error> for AppError {
fn from(value: tera::Error) -> Self {
Self::Tera(value)
}
}
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {

View File

@ -1,6 +1,9 @@
mod error; mod error;
mod gpodder; mod gpodder;
mod r#static; mod r#static;
mod web;
use std::sync::Arc;
use axum::{ use axum::{
body::Body, body::Body,
@ -16,12 +19,14 @@ use tower_http::trace::TraceLayer;
#[derive(Clone)] #[derive(Clone)]
pub struct Context { pub struct Context {
pub store: ::gpodder::GpodderRepository, pub store: ::gpodder::GpodderRepository,
pub tera: Arc<tera::Tera>,
} }
pub fn app(ctx: Context) -> Router { pub fn app(ctx: Context) -> Router {
Router::new() Router::new()
.merge(gpodder::router(ctx.clone())) .merge(gpodder::router(ctx.clone()))
.nest("/static", r#static::router()) .nest("/static", r#static::router())
.nest("/_", web::router(ctx.clone()))
.layer(axum::middleware::from_fn(header_logger)) .layer(axum::middleware::from_fn(header_logger))
.layer(axum::middleware::from_fn(body_logger)) .layer(axum::middleware::from_fn(body_logger))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())

View File

@ -0,0 +1,13 @@
use axum::{extract::State, http::HeaderMap, routing::get, Router};
use crate::web::{Page, TemplateExt, TemplateResponse, View};
use super::Context;
pub fn router(_ctx: Context) -> Router<Context> {
Router::new().route("/", get(get_index))
}
async fn get_index(State(ctx): State<Context>, headers: HeaderMap) -> TemplateResponse<Page<View>> {
View::Index.page(&headers).response(&ctx.tera)
}

78
src/web/mod.rs 100644
View File

@ -0,0 +1,78 @@
mod page;
mod view;
use std::sync::Arc;
use axum::{
body::Body,
http::{HeaderMap, Response, StatusCode},
response::{Html, IntoResponse},
};
pub use page::Page;
pub use view::View;
const BASE_TEMPLATE: &str = "base.html";
/// Trait defining shared methods for working with typed Tera templates
pub trait Template {
/// Returns the name or path used to identify the template in the Tera struct
fn template(&self) -> &'static str;
/// Render the template using the given Tera instance.
///
/// Templates are expected to manage their own context requirements if needed.
fn render(&self, tera: &tera::Tera) -> tera::Result<String>;
}
/// Useful additional functions on sized Template implementors
pub trait TemplateExt: Sized + Template {
fn response(self, tera: &Arc<tera::Tera>) -> TemplateResponse<Self> {
TemplateResponse::new(tera, self)
}
fn page(self, headers: &HeaderMap) -> Page<Self> {
Page::new(self).headers(headers)
}
}
impl<T: Sized + Template> TemplateExt for T {}
/// A specific instance of a template. This type can be used as a return type from Axum handlers.
pub struct TemplateResponse<T> {
tera: Arc<tera::Tera>,
template: T,
}
impl<T> TemplateResponse<T> {
pub fn new(tera: &Arc<tera::Tera>, template: T) -> Self {
Self {
tera: Arc::clone(&tera),
template,
}
}
}
impl<T: Template> IntoResponse for TemplateResponse<T> {
fn into_response(self) -> Response<Body> {
match self.template.render(&self.tera) {
Ok(s) => Html(s).into_response(),
Err(err) => {
tracing::error!("tera template failed: {err}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
pub fn initialize_tera() -> tera::Result<tera::Tera> {
let mut tera = tera::Tera::default();
tera.add_raw_templates([
(BASE_TEMPLATE, include_str!("templates/base.html")),
(View::Index.template(), include_str!("templates/index.html")),
])?;
Ok(tera)
}

53
src/web/page.rs 100644
View File

@ -0,0 +1,53 @@
use axum::http::{HeaderMap, HeaderValue};
use super::Template;
const HX_REQUEST_HEADER: &str = "HX-Request";
const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
/// Overarching template type that conditionally wraps its inner template with the base template if
/// required, as derived from the request headers
pub struct Page<T> {
template: T,
wrap_with_base: bool,
}
impl<T: Template> Template for Page<T> {
fn template(&self) -> &'static str {
self.template.template()
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
let inner = self.template.render(tera)?;
if self.wrap_with_base {
let mut ctx = tera::Context::new();
ctx.insert("inner", &inner);
tera.render(super::BASE_TEMPLATE, &ctx)
} else {
Ok(inner)
}
}
}
impl<T> Page<T> {
pub fn new(template: T) -> Self {
Self {
template,
wrap_with_base: false,
}
}
pub fn headers(mut self, headers: &HeaderMap) -> Self {
let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some();
let is_hist_restore_req = headers
.get(HX_HISTORY_RESTORE_HEADER)
.map(|val| val == HeaderValue::from_static("true"))
.unwrap_or(false);
self.wrap_with_base = !is_htmx_req || is_hist_restore_req;
self
}
}

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/static/htmx_2.0.4.min.js" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
<link rel="stylesheet" href="/static/pico_2.1.1.classless.jade.min.css" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<style type="text/css">
a:hover {
cursor:pointer;
}
</style>
</head>
<body>
<main>
<nav>
</nav>
<article id="content">
{{ inner | safe }}
</article>
</main>
</body>
</html>

View File

@ -0,0 +1,3 @@
<h1>Otter</h1>
Otter is a self-hostable Gpodder implementation.

17
src/web/view.rs 100644
View File

@ -0,0 +1,17 @@
use super::Template;
pub enum View {
Index,
}
impl Template for View {
fn template(&self) -> &'static str {
match self {
Self::Index => "index.html",
}
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
tera.render(self.template(), &tera::Context::new())
}
}