feat: overhaul templating system

image-uploads
Jef Roosens 2025-01-10 15:16:01 +01:00
parent 03b3f692e1
commit 4a4b8bba3d
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
22 changed files with 157 additions and 127 deletions

20
README.md 100644
View File

@ -0,0 +1,20 @@
# Calathea
## Templates and rendering
Calathea uses the [Tera](https://keats.github.io/tera/) templating engine. To
combine Tera and HTMX, the template directory has the following structure:
* `components` contains components used to build views. Components are defined
as Tera [macros](https://keats.github.io/tera/docs/#macros).
* `views` contains view templates that make use of the components.
* `updates` are templates returned from e.g. POST requests. Tbey usually
directly wrap a component, e.g. a plant `li` item.
* `base.html` defines the skeleton for each page. Each view is rendered with
this as its surrounding HTML.
When a page is requested from the server, it inspects the `HX-Request` and
`HX-History-Restore-Request` headers to determine whether a full page should be
returned or only the inner view. Using this, the server cn support direct
routing to pages and caching properly while still supporting partial rendering
using HTMX.

View File

@ -32,7 +32,7 @@ async fn main() {
let pool = r2d2::Pool::new(manager).unwrap();
db::run_migrations(&pool, &MIGRATIONS).unwrap();
let tera = load_templates();
let tera = Tera::new("templates/**/*").unwrap();
let ctx = Context {
pool,
tera: Arc::new(tera),
@ -48,40 +48,3 @@ async fn main() {
.await
.unwrap();
}
fn load_templates() -> Tera {
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("base.html", include_str!("templates/base.html")),
("index.html", include_str!("templates/index.html")),
(
"partials/plants_ul.html",
include_str!("templates/partials/plants_ul.html"),
),
(
"partials/plant_li.html",
include_str!("templates/partials/plant_li.html"),
),
("plant_page.html", include_str!("templates/plant_page.html")),
(
"partials/plant_info.html",
include_str!("templates/partials/plant_info.html"),
),
(
"partials/comment_li.html",
include_str!("templates/partials/comment_li.html"),
),
(
"partials/event_li.html",
include_str!("templates/partials/event_li.html"),
),
(
"macros/event.html",
include_str!("templates/macros/event.html"),
),
])
.unwrap();
tera
}

View File

@ -17,5 +17,5 @@ async fn post_comment(
let mut context = Context::new();
context.insert("comment", &comment);
Ok(Html(ctx.tera.render("partials/comment_li.html", &context)?))
Ok(Html(ctx.tera.render("updates/comment_li.html", &context)?))
}

View File

@ -17,5 +17,5 @@ async fn post_event(
let mut context = Context::new();
context.insert("event", &event);
Ok(Html(ctx.tera.render("partials/event_li.html", &context)?))
Ok(Html(ctx.tera.render("updates/event_li.html", &context)?))
}

View File

@ -20,14 +20,32 @@ pub type Result<T> = std::result::Result<T, error::AppError>;
const HX_REQUEST_HEADER: &str = "HX-Request";
const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
pub fn render_partial(headers: &HeaderMap) -> bool {
pub fn should_render_full(headers: &HeaderMap) -> bool {
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);
is_htmx_req && !is_hist_restore_req
!is_htmx_req || is_hist_restore_req
}
pub fn render_view(
tera: &tera::Tera,
view: &str,
ctx: &tera::Context,
headers: &HeaderMap,
) -> tera::Result<String> {
let view = tera.render(view, ctx)?;
if should_render_full(headers) {
let mut ctx = tera::Context::new();
ctx.insert("view", &view);
tera.render("base.html", &ctx)
} else {
Ok(view)
}
}
pub fn app(ctx: crate::Context) -> axum::Router {
@ -52,12 +70,17 @@ pub fn app(ctx: crate::Context) -> axum::Router {
))
}
async fn get_index(State(ctx): State<crate::Context>) -> Result<Html<String>> {
async fn get_index(State(ctx): State<crate::Context>, headers: HeaderMap) -> Result<Html<String>> {
let plants = tokio::task::spawn_blocking(move || Plant::all(&ctx.pool))
.await
.unwrap()?;
let mut context = Context::new();
context.insert("plants", &plants);
Ok(Html(ctx.tera.render("index.html", &context)?))
Ok(Html(render_view(
&ctx.tera,
"views/index.html",
&context,
&headers,
)?))
}

View File

@ -9,7 +9,7 @@ use tera::Context;
use crate::db::{self, DbError, Plant};
use super::{error::AppError, render_partial};
use super::error::AppError;
pub fn app() -> axum::Router<crate::Context> {
Router::new()
@ -45,13 +45,12 @@ async fn get_plant_page(
context.insert("events", &events);
context.insert("event_types", &db::EVENT_TYPES);
let tmpl = if render_partial(&headers) {
"partials/plant_info.html"
} else {
"plant_page.html"
};
Ok(Html(ctx.tera.render(tmpl, &context)?))
Ok(Html(super::render_view(
&ctx.tera,
"views/plant.html",
&context,
&headers,
)?))
}
None => Err(AppError::NotFound),
}
@ -67,5 +66,5 @@ async fn post_plant(
let mut context = Context::new();
context.insert("plant", &plant);
Ok(Html(ctx.tera.render("partials/plant_li.html", &context)?))
Ok(Html(ctx.tera.render("updates/plant_li.html", &context)?))
}

View File

@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Calathea</h1>
<h2>Plants</h2>
{% include "partials/plants_ul.html" %}
<h3>Add new plant</h3>
<form hx-post="/plants" hx-target="#plants" hx-swap="beforeend">
<label for="name">Name:</label>
<input type="text" id="name" name="name"></br>
<label for="species">Species:</label>
<input type="text" id="species" name="species"></br>
<label for="description">Description:</label>
<textarea id="description" name="description" rows=4></textarea></br>
<input type="submit">
</form>
{% endblock content %}

View File

@ -1 +0,0 @@
<li>{{ comment.comment }}</li>

View File

@ -1,2 +0,0 @@
{% import "macros/event.html" as macros_event %}
{{ macros_event::li(event=event) }}

View File

@ -1,31 +0,0 @@
{% import "macros/event.html" as macros_event %}
<h1>Calathea</h1>
<h2>{{ plant.name }}</h2>
<h3>Species</h3>
<p>{{ plant.species }}</p>
<h3>Description</h3>
<p>{{ plant.description }}</p>
<h3>Events</h3>
<div id="events">
<ul id="events_ul">
{% for event in events %}
{{ macros_event::li(event=event) }}
{% endfor %}
</ul>
{{ macros_event::form(plant_id=plant.id) }}
</div>
<h3>Comments</h3>
<ul id="comments">
{% for comment in comments %}
<li>
{{ comment.comment }}
</li>
{% endfor %}
</ul>
<form hx-post="/comments" hx-target="#comments" hx-swap="beforeend">
<input type="hidden" id="plant_id" name="plant_id" value="{{ plant.id }}">
<label for="comment">Comment:</label>
<textarea id="comment" name="comment" rows=4></textarea></br>
<input type="submit">
</form>

View File

@ -1 +0,0 @@
<li>{{ plant.name }} ({{ plant.species }})</li>

View File

@ -1,7 +0,0 @@
<ul id="plants">
{% for plant in plants %}
<li>
<a hx-get="/plants/{{ plant.id }}" hx-target="#content" hx-push-url="true">{{ plant.name }}</a> ({{ plant.species }})
</li>
{% endfor %}
</ul>

View File

@ -1,8 +0,0 @@
{% import "macros/event.html" as macros_event %}
{% extends "base.html" %}
{% block content %}
{% include "partials/plant_info.html" %}
{% endblock content %}

View File

@ -4,9 +4,11 @@
<script src="/static/htmx_2.0.4.min.js" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
</head>
<body>
<div id="content">
{% block content %}
{% endblock content %}
</div>
<main>
<h1>Calathea</h1>
<div id="content">
{{ view | safe }}
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,22 @@
{% macro li(comment) %}
<li>{{ comment.comment }}</li>
{% endmacro li %}
{% macro list(comments) %}
<div id="comments">
<ul id="comments_ul">
{% for comment in comments %}
{{ self::li(comment=comment) }}
{% endfor %}
</ul>
</div>
{% endmacro list %}
{% macro form(plant_id, target="#comments > ul") %}
<form hx-post="/comments" hx-target="{{ target }}" hx-swap="beforeend">
<input type="hidden" id="plant_id" name="plant_id" value="{{ plant_id }}">
<label for="comment">Comment:</label>
<textarea id="comment" name="comment" rows=4></textarea></br>
<input type="submit">
</form>
{% endmacro form %}

View File

@ -8,7 +8,17 @@
</li>
{% endmacro li %}
{% macro form(plant_id, target="#events_ul") %}
{% macro list(events) %}
<div id="events">
<ul>
{% for event in events %}
{{ self::li(event=event) }}
{% endfor %}
</ul>
</div>
{% endmacro ul %}
{% macro form(plant_id, target="#events > ul") %}
<form hx-post="/events" hx-target="{{ target }}" hx-swap="beforeend">
<input type="hidden" id="plant_id" name="plant_id" value="{{ plant_id }}">
<label for="event_type">Type:</label>

View File

@ -0,0 +1,37 @@
{% macro info(plant) %}
<div class="plant_info">
<h2>{{ plant.name }}</h2>
<h3>Species</h3>
<p>{{ plant.species }}</p>
<h3>Description</h3>
<p>{{ plant.description }}</p>
</div>
{% endmacro info %}
{% macro li(plant) %}
<li>
<a hx-get="/plants/{{ plant.id }}" hx-target="#content" hx-push-url="true">{{ plant.name }}</a> ({{ plant.species }})
</li>
{% endmacro li %}
{% macro list(plants) %}
<div id="plants">
<ul>
{% for plant in plants %}
{{ self::li(plant=plant) }}
{% endfor %}
</ul>
</div>
{% endmacro %}
{% macro form(target="#plants > ul") %}
<form hx-post="/plants" hx-target="{{ target }}" hx-swap="beforeend">
<label for="name">Name:</label>
<input type="text" id="name" name="name"></br>
<label for="species">Species:</label>
<input type="text" id="species" name="species"></br>
<label for="description">Description:</label>
<textarea id="description" name="description" rows=4></textarea></br>
<input type="submit">
</form>
{% endmacro %}

View File

@ -0,0 +1,2 @@
{% import "components/comment.html" as comp_comment %}
{{ comp_comment::li(comment=comment) }}

View File

@ -0,0 +1,2 @@
{% import "components/event.html" as comp_event %}
{{ comp_event::li(event=event) }}

View File

@ -0,0 +1,2 @@
{% import "components/plant.html" as comp_plant %}
{{ comp_plant::li(plant=plant) }}

View File

@ -0,0 +1,6 @@
{% import "components/plant.html" as comp_plant %}
<h2>Plants</h2>
{{ comp_plant::list(plants=plants) }}
<h3>Add new plant</h3>
{{ comp_plant::form() }}

View File

@ -0,0 +1,11 @@
{% import "components/event.html" as comp_event %}
{% import "components/plant.html" as comp_plant %}
{% import "components/comment.html" as comp_comment %}
{{ comp_plant::info(plant=plant) }}
<h3>Events</h3>
{{ comp_event::list(events=events) }}
{{ comp_event::form(plant_id=plant.id) }}
<h3>Comments</h3>
{{ comp_comment::list(comments=comments) }}
{{ comp_comment::form(plant_id=plant.id) }}