feat: bootstrap htmx templating system
parent
ad015b47e4
commit
b3e49be299
|
@ -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()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<h1>Otter</h1>
|
||||||
|
|
||||||
|
Otter is a self-hostable Gpodder implementation.
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue