Skip to main content

majordomus_cli/web/
html.rs

1//! The one page style every generated report uses.
2//!
3//! Self-contained by rule: the style is inline, no asset is fetched, and every link is
4//! relative, so a report works at whatever mount it is given and from the filesystem. That
5//! is why the style is *inline*; it is not why the values would be *chosen* here. The
6//! palette and the type stack are `tokens.css`, generated from `share/design/tokens.yaml`
7//! and compiled in, so a report renders in the same design as the site and the Cockpit
8//! without fetching anything. Layout below is this page's own; colour, type, radius and
9//! measure are tokens.
10//! It is
11//! mobile-first in the same sense the site is — one readable column at any width, tables
12//! that scroll inside their own box rather than pushing the page sideways — because these
13//! pages are this repository's own UI, not a third party's output.
14
15/// The design tokens, compiled in: `:root` custom properties for the palette and the type
16/// stack, with dark answered both by the media query — a report is often opened from the
17/// filesystem, where no script has run — and by the `dark` class the site and the Cockpit
18/// switch on. Generated by `scripts/design-tokens` from `share/design/tokens.yaml`, which
19/// is the one place any of these values is chosen; the `design` gate fails when the file
20/// beside this one no longer matches it.
21pub(crate) const TOKENS: &str = include_str!("tokens.css");
22
23/// Escape text for HTML: the only escaping a generated page needs, applied at every
24/// interpolation rather than hoped for.
25///
26/// ```
27/// use majordomus_cli::web::report::html::escape;
28/// assert_eq!(escape("a<b & c\">"), "a&lt;b &amp; c&quot;&gt;");
29/// ```
30pub fn escape(text: &str) -> String {
31    let mut out = String::with_capacity(text.len());
32    for ch in text.chars() {
33        match ch {
34            '&' => out.push_str("&amp;"),
35            '<' => out.push_str("&lt;"),
36            '>' => out.push_str("&gt;"),
37            '"' => out.push_str("&quot;"),
38            '\'' => out.push_str("&#39;"),
39            _ => out.push(ch),
40        }
41    }
42    out
43}
44
45/// The document around a report's body.
46///
47/// `subtitle` is the one line under the title; `body` is already-escaped HTML.
48pub fn page(title: &str, subtitle: &str, body: &str) -> String {
49    format!(
50        r#"<!DOCTYPE html>
51<html lang="en">
52<head>
53<meta charset="utf-8">
54<meta name="viewport" content="width=device-width, initial-scale=1">
55<title>{title}</title>
56<style>
57{TOKENS}* {{ box-sizing: border-box; }}
58body {{ margin: 0; padding: 1.25rem 1rem 3rem; background: var(--mj-bg); color: var(--mj-fg);
59        font: 1rem/1.5 var(--font-sans); }}
60main {{ max-width: var(--mj-measure); margin: 0 auto; }}
61h1 {{ font-size: 1.5rem; margin: 0 0 .25rem; }}
62h2 {{ font-size: 1.125rem; margin: 2rem 0 .5rem; }}
63p.lede {{ color: var(--mj-muted); margin: 0 0 1.5rem; }}
64dl.summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
65              gap: .75rem; margin: 0 0 1.5rem; padding: 0; }}
66/* min-width: 0 because a grid item does not shrink below its content by default, and a
67   summary value is whatever the run measured — one long unbreakable figure widens its track,
68   the track widens the grid, and the page scrolls sideways at every width the track does not
69   fit. The wrap on the value is the other half: the figure gives way before the layout does. */
70dl.summary > div {{ min-width: 0; border: 1px solid var(--mj-line); border-radius: var(--radius-lg); padding: .75rem; background: var(--mj-sunken); }}
71dl.summary dt {{ color: var(--mj-muted); font-size: .875rem; margin: 0; }}
72dl.summary dd {{ margin: .25rem 0 0; font-size: 1.35rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }}
73.scroll {{ overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--mj-line); border-radius: var(--radius-lg); }}
74table {{ border-collapse: collapse; width: 100%; font-size: .875rem; }}
75th, td {{ text-align: left; padding: .5rem .65rem; border-bottom: 1px solid var(--mj-line); white-space: nowrap; }}
76th {{ background: var(--mj-sunken); font-weight: 600; }}
77td.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
78tr:last-child td {{ border-bottom: 0; }}
79.pass {{ color: var(--mj-ok); font-weight: 600; }}
80.fail {{ color: var(--mj-bad); font-weight: 600; }}
81code, .mono {{ font-family: var(--font-mono); font-size: .875em; }}
82footer {{ margin-top: 2.5rem; color: var(--mj-muted); font-size: .875rem; border-top: 1px solid var(--mj-line); padding-top: .75rem; }}
83a {{ color: inherit; text-decoration: underline; }}
84</style>
85</head>
86<body>
87<main>
88<h1>{title}</h1>
89<p class="lede">{subtitle}</p>
90{body}
91</main>
92</body>
93</html>
94"#
95    )
96}
97
98/// A table inside its own scrolling box: wide evidence never pushes the page sideways.
99pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
100    // the box scrolls, so it is reachable from the keyboard: a scrollable region that only
101    // a pointer can reach is the accessibility defect the site audit refuses (WCAG 2.1.1)
102    let mut out = String::from("<div class=\"scroll\" tabindex=\"0\"><table><thead><tr>");
103    for header in headers {
104        out.push_str(&format!("<th>{}</th>", escape(header)));
105    }
106    out.push_str("</tr></thead><tbody>");
107    for row in rows {
108        out.push_str("<tr>");
109        for cell in row {
110            out.push_str(&format!("<td>{cell}</td>"));
111        }
112        out.push_str("</tr>");
113    }
114    out.push_str("</tbody></table></div>");
115    out
116}
117
118/// The summary tiles at the top of a report.
119pub fn summary(items: &[(&str, String)]) -> String {
120    let mut out = String::from("<dl class=\"summary\">");
121    for (label, value) in items {
122        out.push_str(&format!(
123            "<div><dt>{}</dt><dd>{}</dd></div>",
124            escape(label),
125            escape(value)
126        ));
127    }
128    out.push_str("</dl>");
129    out
130}
131
132/// The footer every report carries: where the evidence came from.
133pub fn origin(origin: &super::report::Origin, evidence: &[(&str, &str)]) -> String {
134    let mut out = String::from("<footer>");
135    out.push_str(&format!(
136        "rendered {} by majordomus {}",
137        escape(&origin.rendered_at),
138        escape(&origin.version)
139    ));
140    if let Some(revision) = &origin.revision {
141        out.push_str(&format!(
142            " at revision <span class=\"mono\">{}</span>",
143            escape(revision)
144        ));
145    }
146    if !evidence.is_empty() {
147        out.push_str("<br>evidence: ");
148        for (i, (label, href)) in evidence.iter().enumerate() {
149            if i > 0 {
150                out.push_str(", ");
151            }
152            out.push_str(&format!(
153                "<a href=\"{}\">{}</a>",
154                escape(href),
155                escape(label)
156            ));
157        }
158    }
159    out.push_str("</footer>");
160    out
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn a_page_is_self_contained_and_declares_a_viewport() {
169        let html = page("Title", "Subtitle", "<p>body</p>");
170        assert!(html.contains("name=\"viewport\""));
171        assert!(!html.contains("<link"), "a report fetches no stylesheet");
172        assert!(!html.contains("<script"), "a report runs no script");
173        assert!(
174            !html.contains("href=\"/"),
175            "every link is relative to the mount"
176        );
177    }
178
179    #[test]
180    fn a_table_scrolls_inside_its_own_box() {
181        let html = table(&["a"], &[vec!["1".into()]]);
182        assert!(
183            html.starts_with("<div class=\"scroll\" tabindex=\"0\">"),
184            "{html}"
185        );
186    }
187
188    #[test]
189    fn text_from_evidence_cannot_close_a_tag() {
190        let html = table(&["name"], &[vec![escape("</td><script>alert(1)</script>")]]);
191        assert!(!html.contains("<script>"), "{html}");
192    }
193}