Skip to main content

solid_pod_rs_forge/html/
mod.rs

1//! Server-rendered HTML views — zero framework, inline CSS, every
2//! interpolation passed through [`crate::request::esc`].
3//!
4//! There is no templating engine and no client-side JS in the rendered
5//! markup: the forge is server-rendered by construction, and the XSS
6//! spine is content-type + `esc()` (see [`crate::request`]).
7
8use crate::request::esc;
9
10mod views;
11pub use views::*;
12
13/// Minimal inline stylesheet shared by every page. Kept tiny and inline
14/// so the forge serves without any external asset dependency.
15const STYLE: &str = "\
16:root{color-scheme:light dark}\
17body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;\
18max-width:60rem;margin:1.5rem auto;padding:0 1rem;line-height:1.5}\
19a{color:#3573b3;text-decoration:none}a:hover{text-decoration:underline}\
20header.forge{border-bottom:1px solid #8884;margin-bottom:1rem;padding-bottom:.5rem}\
21code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}\
22pre{background:#8881;padding:.75rem;border-radius:6px;overflow-x:auto}\
23table{border-collapse:collapse;width:100%}\
24td,th{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #8883}\
25.muted{color:#8888}.badge{font-size:.8rem;padding:.1rem .45rem;border-radius:10px;background:#8882}\
26.state-open{background:#2c9e4b33;color:#2c9e4b}.state-closed{background:#b5433933;color:#b54339}\
27.state-merged{background:#8250df33;color:#8250df}\
28nav.crumbs{font-size:.95rem;margin-bottom:.75rem}\
29";
30
31/// Wrap `body_html` in a full HTML document with a shared header and the
32/// inline stylesheet. `title` is escaped; `body_html` must already be
33/// composed of `esc()`'d values.
34#[must_use]
35pub fn page(title: &str, body_html: &str) -> String {
36    format!(
37        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
38<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
39<title>{}</title><style>{}</style></head><body>\
40<header class=\"forge\"><strong>\u{2692} forge</strong></header>{}</body></html>",
41        esc(title),
42        STYLE,
43        body_html
44    )
45}
46
47/// Render a breadcrumb nav bar from `(label, href)` pairs. The last
48/// entry is rendered as plain (non-link) text.
49#[must_use]
50pub fn crumbs(items: &[(&str, Option<String>)]) -> String {
51    let mut out = String::from("<nav class=\"crumbs\">");
52    for (i, (label, href)) in items.iter().enumerate() {
53        if i > 0 {
54            out.push_str(" / ");
55        }
56        match href {
57            Some(h) => out.push_str(&format!("<a href=\"{}\">{}</a>", esc(h), esc(label))),
58            None => out.push_str(&esc(label)),
59        }
60    }
61    out.push_str("</nav>");
62    out
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn page_escapes_title() {
71        let p = page("<x>", "<p>ok</p>");
72        assert!(p.contains("<title>&lt;x&gt;</title>"));
73        assert!(p.contains("<p>ok</p>"));
74        assert!(p.starts_with("<!doctype html>"));
75    }
76
77    #[test]
78    fn crumbs_last_is_plain_text() {
79        let c = crumbs(&[
80            ("home", Some("/forge".into())),
81            ("alice", Some("/forge/alice".into())),
82            ("repo", None),
83        ]);
84        assert!(c.contains("<a href=\"/forge\">home</a>"));
85        assert!(c.contains(" / repo</nav>"));
86        // The final crumb is not a link.
87        assert!(!c.contains(">repo</a>"));
88    }
89}