feat: render meta-ref documents through a Facet-driven Render trait
commit 4a3d047
feat: render meta-ref documents through a Facet-driven Render trait
The Checks and Settings cards each hand-wrote the same row markup for
their meta-ref documents. A new Render trait, bounded on Facet, gives a
default that walks any value’s reflected shape — struct, map, list, or
scalar — into card rows, so a new refs/meta/* type renders with no extra
code. Check uses the default; Signer and Run override render to keep the
shortened key label and the one-line run summary. The pages iterate the
typed sets and call render per item, keeping card chrome in place.
feat: add web::render with a Facet-default Render trait and Signer/Run overrides
feat: derive Facet on the public Check, Signer, Run, and RunOutcome types
refactor: render the Checks and Authorized-signers cards via Render
build: add facet to git-ents-server deps
Assisted-by: Claude:claude-opus-4-8
crates/git-ents/src/checks.rs
@@ -33,7 +33,7 @@
}
/// One configured check recorded in [`CHECKS_REF`].
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Check {
/// The name it is stored under.
pub name: String,
@@ -104,7 +104,7 @@
}
/// One check's outcome within a [`Run`].
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct RunOutcome {
/// The check's name (its `checks/<name>` in [`CHECKS_REF`]).
pub name: String,
@@ -114,7 +114,7 @@
}
/// One recorded execution of the check set against a commit.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Run {
/// When the run was recorded, as seconds since the Unix epoch — the run
/// commit's committer date.
crates/git-ents/src/signers.rs
@@ -22,7 +22,7 @@
}
/// One authorized signer recorded in [`AUTH_REF`].
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
pub struct Signer {
/// The key it is stored under — its fingerprint.
pub fingerprint: String,
crates/git-ents-server/src/web/mod.rs
@@ -4,13 +4,15 @@
//! smart-HTTP gateway in [`crate::http`] delegates plain browser GETs here.
//!
//! The module is split by concern: [`assets`] bundles the CSS/JS, [`icons`]
-//! holds the inline SVGs, [`git`] is the data layer over `git`, and [`pages`]
-//! renders each tab. This file owns routing and the shared page shell.
+//! holds the inline SVGs, [`git`] is the data layer over `git`, [`render`]
+//! turns reflected meta-ref values into HTML, and [`pages`] renders each tab.
+//! This file owns routing and the shared page shell.
mod assets;
mod git;
mod icons;
mod pages;
+mod render;
use std::path::{Path, PathBuf};
crates/git-ents-server/src/web/pages.rs
@@ -20,6 +20,7 @@
releases, root_tree,
};
use super::icons::*;
+use super::render::Render;
use super::{RepoMeta, Tab, not_found, repo_shell};
/// A single repository's overview: the rendered README beside an aside of
@@ -658,7 +659,7 @@
@for run in &commit.runs {
div.card-row.signer-row {
code.key { (commit.commit.get(..8).unwrap_or(&commit.commit)) }
- span.muted { (run_summary(run)) }
+ (run.render())
}
}
}
@@ -679,10 +680,7 @@
}
Ok(checks) => {
@for check in checks {
- div.card-row.signer-row {
- code.key { (check.name) }
- span.muted { (check.command) }
- }
+ (check.render())
}
}
}
@@ -711,15 +709,6 @@
.map_err(|err| err.to_string())
}
-/// A one-line summary of a run's outcomes, e.g. `fmt pass · test fail`.
-fn run_summary(run: &git_ents::checks::Run) -> String {
- run.results
- .iter()
- .map(|result| format!("{} {}", result.name, result.outcome))
- .collect::<Vec<_>>()
- .join(" · ")
-}
-
/// The Issues ("Bug reports") tab. There is no issue store yet, so the filters
/// are present for the design and the list is an empty state.
pub(super) fn issues_page(meta: &RepoMeta) -> Markup {
@@ -813,10 +802,7 @@
}
Ok(signers) => {
@for signer in signers {
- div.card-row.signer-row {
- code.key { (signer.fingerprint) }
- span.muted { (signer_label(&signer.key)) }
- }
+ (signer.render())
}
}
}
@@ -860,19 +846,6 @@
.map_err(|err| err.to_string())
}
-/// A short label for a signer's key: its type and trailing comment, dropping the
-/// long base64 body that would not fit on the row.
-fn signer_label(key: &str) -> String {
- let mut parts = key.split_whitespace();
- let kind = parts.next().unwrap_or_default();
- let comment = parts.nth(1).unwrap_or_default();
- if comment.is_empty() {
- kind.to_owned()
- } else {
- format!("{kind} · {comment}")
- }
-}
-
/// A Features row with a static toggle reflecting `on`.
fn feature_row(title: &str, desc: &str, on: bool) -> Markup {
html! {
crates/git-ents-server/src/web/render.rs
@@ -1,0 +1,126 @@
+//! Turning typed `refs/meta/*` documents into HTML.
+//!
+//! Every value the meta refs carry is a [`facet::Facet`] type, so one structural
+//! walk over its reflected shape can render any of them: a struct becomes a row
+//! (its first field the key, the rest a muted value), a map becomes one keyed
+//! row per entry, a list stacks its items, and a scalar is its text. That walk
+//! is the [`Render`] trait's default, so a new meta-ref type renders for free.
+//! Types whose presentation needs domain knowledge — a signer's shortened key, a
+//! run's one-line summary — override [`Render::render`] instead.
+
+use facet::{Def, Facet, Peek, Type, UserType};
+use maud::{Markup, html};
+
+use git_ents::checks::{Check, Run};
+use git_ents::signers::Signer;
+
+/// HTML rendering for a meta-ref value. The default walks the value's [`Facet`]
+/// shape structurally; a type overrides [`render`](Render::render) when its
+/// presentation needs more than the structure carries.
+pub(super) trait Render: for<'a> Facet<'a> {
+ /// Render `self` as a fragment of card rows.
+ fn render(&self) -> Markup {
+ render_peek(Peek::new(self))
+ }
+}
+
+/// A check renders structurally: its name is the key, its command the value.
+impl Render for Check {}
+
+/// A signer's stored key is too long for a row, so show a short label beside the
+/// fingerprint instead of the raw key the structural walk would print.
+impl Render for Signer {
+ fn render(&self) -> Markup {
+ row(&self.fingerprint, &signer_label(&self.key))
+ }
+}
+
+/// A run is a set of per-check outcomes; collapse them to one summary line
+/// rather than a row per outcome.
+impl Render for Run {
+ fn render(&self) -> Markup {
+ html! { span.muted { (run_summary(self)) } }
+ }
+}
+
+/// Render any reflected value as card rows by walking its shape.
+fn render_peek(peek: Peek<'_, '_>) -> Markup {
+ match peek.shape().def {
+ Def::Scalar => return html! { (scalar_text(&peek)) },
+ Def::Map(_) => {
+ let Ok(map) = peek.into_map() else {
+ return html! {};
+ };
+ return html! {
+ @for (key, value) in map.iter() {
+ (row(&scalar_text(&key), &scalar_text(&value)))
+ }
+ };
+ }
+ Def::List(_) | Def::Array(_) | Def::Slice(_) => {
+ let Ok(list) = peek.into_list_like() else {
+ return html! {};
+ };
+ return html! { @for item in list.iter() { (render_peek(item)) } };
+ }
+ _ => {}
+ }
+ if let Type::User(UserType::Struct(st)) = peek.shape().ty {
+ let Ok(strukt) = peek.into_struct() else {
+ return html! {};
+ };
+ let mut key = String::new();
+ let mut rest: Vec<String> = Vec::new();
+ for index in 0..st.fields.len() {
+ let Ok(field) = strukt.field(index) else {
+ continue;
+ };
+ let text = scalar_text(&field);
+ if index == 0 {
+ key = text;
+ } else {
+ rest.push(text);
+ }
+ }
+ return row(&key, &rest.join(" · "));
+ }
+ html! {}
+}
+
+/// One card row: a key in `code.key`, its value muted beside it.
+fn row(key: &str, value: &str) -> Markup {
+ html! {
+ div.card-row.signer-row {
+ code.key { (key) }
+ span.muted { (value) }
+ }
+ }
+}
+
+/// The textual form of a scalar peek: its string value, or its `Display`.
+fn scalar_text(peek: &Peek<'_, '_>) -> String {
+ peek.as_str()
+ .map_or_else(|| format!("{peek}"), str::to_owned)
+}
+
+/// A short label for a signer's key: its type and trailing comment, dropping the
+/// long base64 body that would not fit on the row.
+fn signer_label(key: &str) -> String {
+ let mut parts = key.split_whitespace();
+ let kind = parts.next().unwrap_or_default();
+ let comment = parts.nth(1).unwrap_or_default();
+ if comment.is_empty() {
+ kind.to_owned()
+ } else {
+ format!("{kind} · {comment}")
+ }
+}
+
+/// A one-line summary of a run's outcomes, e.g. `fmt pass · test fail`.
+fn run_summary(run: &Run) -> String {
+ run.results
+ .iter()
+ .map(|result| format!("{} {}", result.name, result.outcome))
+ .collect::<Vec<_>>()
+ .join(" · ")
+}