majordomus_cli/web/
html.rs1pub(crate) const TOKENS: &str = include_str!("tokens.css");
22
23pub 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("&"),
35 '<' => out.push_str("<"),
36 '>' => out.push_str(">"),
37 '"' => out.push_str("""),
38 '\'' => out.push_str("'"),
39 _ => out.push(ch),
40 }
41 }
42 out
43}
44
45pub 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
98pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
100 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
118pub 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
132pub 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}