feat: render the web UI's Issues tab from an external Askama template
commit
552ca56feat: render the web UI's Issues tab from an external Askama template
Introduce Askama as the server-rendered template engine and migrate the first
tab body out of inline Maud into templates/issues.html, keeping the Maud page
shell via a render_body seam that wraps a rendered template into Markup.
Icons and the reflected issue cards cross the seam as raw HTML strings through
Askama’s safe filter (Maud’s PreEscaped is not Display). This is the
first step of moving tab markup out of Rust; more tabs follow.
feat: add askama 0.16 dependency feat: add web::pages::render_body seam bridging Askama templates to the Maud shell feat: add an Icons accessor so templates can emit the shared inline-SVG icons Assisted-by: Claude:claude-opus-4-8
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
.gitignore
@@ -2,3 +2,5 @@
.claude/
PROMPT.md
*.html
+# Askama view templates are source, not generated HTML output.
+!crates/git-ents-server/templates/*.html
Cargo.lock
@@ -386,6 +386,59 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
+[[package]]
+name = "askama"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
+dependencies = [
+ "askama_macros",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "glob",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn",
+]
+
+[[package]]
+name = "askama_macros"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
+dependencies = [
+ "askama_derive",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
+dependencies = [
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "unicode-ident",
+ "winnow",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -450,6 +503,15 @@
"tracing",
]
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -1038,6 +1100,7 @@
"acdc-converters-html",
"acdc-parser",
"arborium",
+ "askama",
"axum",
"clap",
"clap_mangen",
Cargo.toml
@@ -32,6 +32,7 @@
acdc-parser = { git = "https://github.com/nlopes/acdc", rev = "3452a4a8d745e6279e50e1d465551b2e33160464" }
acdc-converters-core = { git = "https://github.com/nlopes/acdc", rev = "3452a4a8d745e6279e50e1d465551b2e33160464" }
acdc-converters-html = { git = "https://github.com/nlopes/acdc", rev = "3452a4a8d745e6279e50e1d465551b2e33160464" }
+askama = "0.16"
axum = "0.8"
clap = { version = "4.5.60", features = ["derive"] }
clap_mangen = "0.2.31"
crates/git-ents-server/Cargo.toml
@@ -8,6 +8,7 @@
[dependencies]
git-ents = { path = "../git-ents" }
acdc-parser = { workspace = true }
+askama = { workspace = true }
acdc-converters-core = { workspace = true }
acdc-converters-html = { workspace = true }
arborium = { workspace = true }
crates/git-ents-server/src/web/icons.rs
@@ -44,3 +44,25 @@
icon_logo => "north-star",
icon_search => "search",
}
+
+/// Zero-size icon bundle so Askama templates can emit an icon as
+/// `{{ icons.icon_search()|safe }}` — the same inline SVG the free functions
+/// hand to Maud, as a raw HTML string (Askama's `safe` filter needs `Display`,
+/// which Maud's `PreEscaped` does not implement). Methods are added here as
+/// tabs migrate off Maud.
+pub(super) struct Icons;
+
+impl Icons {
+ pub(super) fn icon_plus(&self) -> String {
+ icon_plus().into_string()
+ }
+ pub(super) fn icon_issue(&self) -> String {
+ icon_issue().into_string()
+ }
+ pub(super) fn icon_check(&self) -> String {
+ icon_check().into_string()
+ }
+ pub(super) fn icon_search(&self) -> String {
+ icon_search().into_string()
+ }
+}
crates/git-ents-server/src/web/pages.rs
@@ -8,6 +8,7 @@
use std::pin::Pin;
use arborium::{Config, Highlighter, HtmlFormat};
+use askama::Template;
use axum::response::{IntoResponse, Response};
use gix_date::Time;
use gix_hash::{ObjectId, Prefix};
@@ -30,6 +31,16 @@
/// exhaust the server.
const MAX_RENDER_BYTES: usize = 2 * 1024 * 1024;
+/// Render an Askama tab-body template into [`Markup`] the Maud page shell can
+/// wrap. A template render failure is a programming error (a bad template),
+/// surfaced as an inline notice rather than a panic.
+fn render_body<T: Template>(tpl: &T) -> Markup {
+ match tpl.render() {
+ Ok(html) => PreEscaped(html),
+ Err(err) => html! { div.card { div.card-row.muted { "Template error: " (err) } } },
+ }
+}
+
/// A single repository's overview: the rendered README beside an aside of
/// clone, about, releases, and language cards.
pub(super) async fn repo_page(repo: &Path, meta: &RepoMeta, host: Option<&str>) -> Markup {
@@ -747,61 +758,51 @@
/// derived from the labels that exist. Issue creation is a write path that does
/// not exist yet, so the "New issue" button stays disabled.
pub(super) async fn issues_page(repo: &Path, meta: &RepoMeta) -> Markup {
- let issues = load_issues(repo).await;
- let body = match &issues {
- Err(err) => html! { div.card { div.card-row.muted { "Could not read issues: " (err) } } },
+ let tpl = match load_issues(repo).await {
+ Err(err) => IssuesTemplate {
+ icons: Icons,
+ error: Some(err),
+ labels: Vec::new(),
+ open: Vec::new(),
+ open_count: 0,
+ closed_count: 0,
+ },
Ok(issues) => {
let open: Vec<&(String, git_ents::issues::Issue)> =
issues.iter().filter(|(_id, i)| i.is_open()).collect();
let closed = issues.len().saturating_sub(open.len());
- let mut labels: Vec<&str> = issues
+ let mut labels: Vec<String> = issues
.iter()
- .flat_map(|(_id, i)| i.labels.iter().map(String::as_str))
+ .flat_map(|(_id, i)| i.labels.iter().cloned())
.collect();
labels.sort_unstable();
labels.dedup();
- html! {
- div.filter-row {
- div.filter-search {
- (icon_search())
- input type="search" placeholder="Filter bug reports" aria-label="Filter" disabled;
- }
- span.chip.active { "All" }
- @for label in &labels {
- span.chip { (label) }
- }
- }
- div.card {
- div.card-header.subtabs {
- span.subtab.active { (icon_issue()) "Open" span.tab-count { (open.len()) } }
- span.subtab { (icon_check()) "Closed" span.tab-count { (closed) } }
- }
- @if open.is_empty() {
- div.blankslate {
- h2 { "No open bug reports" }
- p { "Open one to start tracking a bug." }
- }
- } @else {
- @for (_id, issue) in &open {
- (issue.render())
- }
- }
- }
+ IssuesTemplate {
+ icons: Icons,
+ error: None,
+ labels,
+ open_count: open.len(),
+ closed_count: closed,
+ open: open
+ .iter()
+ .map(|(_id, issue)| issue.render().into_string())
+ .collect(),
}
}
};
- repo_shell(
- meta,
- Tab::Issues,
- "Bug reports",
- html! {
- div.issues-head {
- h1.page-title { "Bug reports" }
- button.btn-primary type="button" disabled title="Not available yet" { (icon_plus()) "New issue" }
- }
- (body)
- },
- )
+ repo_shell(meta, Tab::Issues, "Bug reports", render_body(&tpl))
+}
+
+/// The Issues tab body: the open/closed filter and per-issue cards.
+#[derive(Template)]
+#[template(path = "issues.html")]
+struct IssuesTemplate {
+ icons: Icons,
+ error: Option<String>,
+ labels: Vec<String>,
+ open: Vec<String>,
+ open_count: usize,
+ closed_count: usize,
}
/// Load the repository's issues off the async runtime, since `issues::list`
crates/git-ents-server/templates/issues.html
@@ -1,0 +1,30 @@
+<div class="issues-head">
+ <h1 class="page-title">Bug reports</h1>
+ <button class="btn-primary" type="button" disabled title="Not available yet">{{ icons.icon_plus()|safe }}New issue</button>
+</div>
+{% if let Some(err) = error %}
+<div class="card"><div class="card-row muted">Could not read issues: {{ err }}</div></div>
+{% else %}
+<div class="filter-row">
+ <div class="filter-search">
+ {{ icons.icon_search()|safe }}
+ <input type="search" placeholder="Filter bug reports" aria-label="Filter" disabled>
+ </div>
+ <span class="chip active">All</span>
+ {% for label in labels %}<span class="chip">{{ label }}</span>{% endfor %}
+</div>
+<div class="card">
+ <div class="card-header subtabs">
+ <span class="subtab active">{{ icons.icon_issue()|safe }}Open<span class="tab-count">{{ open_count }}</span></span>
+ <span class="subtab">{{ icons.icon_check()|safe }}Closed<span class="tab-count">{{ closed_count }}</span></span>
+ </div>
+ {% if open.is_empty() %}
+ <div class="blankslate">
+ <h2>No open bug reports</h2>
+ <p>Open one to start tracking a bug.</p>
+ </div>
+ {% else %}
+ {% for card in open %}{{ card|safe }}{% endfor %}
+ {% endif %}
+</div>
+{% endif %}