Skip to main content

majordomus_cli/cockpit/
html.rs

1//! Markup that cannot be unescaped by accident. There is no template engine here and no
2//! template string: a page is a tree of [`El`] values, and the only way text reaches the
3//! output is through a method that escapes it.
4//!
5//! The Cockpit renders what the repository holds — rule bodies, prompt text, ADR prose,
6//! file paths, a peer's announced intent — and every one of those is content this process
7//! did not write. A template string with a `{}` in it puts the escaping decision at every
8//! call site; this puts it in one place, and a page that wants raw markup has to say
9//! [`El::raw`] and be read for it.
10
11use std::fmt::Write as _;
12
13/// Escape text for an element body or an attribute value. The five characters that can
14/// leave either context are replaced; nothing else is touched, so UTF-8 stays UTF-8.
15///
16/// ```
17/// use majordomus_cli::cockpit::html::escape;
18/// assert_eq!(escape("<script>&\"'"), "&lt;script&gt;&amp;&quot;&#39;");
19/// assert_eq!(escape("příkaz"), "příkaz");
20/// ```
21pub fn escape(text: &str) -> String {
22    let mut out = String::with_capacity(text.len());
23    for c in text.chars() {
24        match c {
25            '<' => out.push_str("&lt;"),
26            '>' => out.push_str("&gt;"),
27            '&' => out.push_str("&amp;"),
28            '"' => out.push_str("&quot;"),
29            '\'' => out.push_str("&#39;"),
30            other => out.push(other),
31        }
32    }
33    out
34}
35
36/// Void elements: they take no children and are written without a closing tag.
37const VOID: &[&str] = &[
38    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track",
39    "wbr",
40];
41
42/// One node of a page: an element with attributes and children, or a piece of text.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Node {
45    /// An element.
46    Element(El),
47    /// Text, escaped when written.
48    Text(String),
49    /// Markup this process produced itself and vouches for.
50    Raw(String),
51}
52
53/// An element under construction. Every builder method takes and returns `self`, so a
54/// page reads as one expression.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct El {
57    tag: &'static str,
58    attributes: Vec<(String, String)>,
59    children: Vec<Node>,
60}
61
62/// An element with this tag.
63///
64/// ```
65/// use majordomus_cli::cockpit::html::el;
66/// let markup = el("p").class("lead").text("a < b").render();
67/// assert_eq!(markup, r#"<p class="lead">a &lt; b</p>"#);
68/// ```
69pub fn el(tag: &'static str) -> El {
70    El {
71        tag,
72        attributes: Vec::new(),
73        children: Vec::new(),
74    }
75}
76
77/// A text node.
78pub fn text(value: impl Into<String>) -> Node {
79    Node::Text(value.into())
80}
81
82/// Markup this process produced itself: an inlined SVG icon, a fragment already rendered
83/// by another function here. Never repository content, never a request parameter.
84pub fn raw(markup: impl Into<String>) -> Node {
85    Node::Raw(markup.into())
86}
87
88/// Nothing. For a conditional branch that renders no element.
89pub fn empty() -> Node {
90    Node::Raw(String::new())
91}
92
93impl El {
94    /// Set an attribute. A later set of the same name replaces the earlier one, so a
95    /// helper can set a default that a caller overrides.
96    pub fn attr(mut self, name: &str, value: impl Into<String>) -> Self {
97        let value = value.into();
98        match self.attributes.iter_mut().find(|(n, _)| n == name) {
99            Some(slot) => slot.1 = value,
100            None => self.attributes.push((name.to_string(), value)),
101        }
102        self
103    }
104
105    /// Set an attribute only when there is a value.
106    pub fn attr_if(self, name: &str, value: Option<impl Into<String>>) -> Self {
107        match value {
108            Some(v) => self.attr(name, v),
109            None => self,
110        }
111    }
112
113    /// A boolean attribute, present without a value (`hidden`, `open`, `disabled`).
114    pub fn flag(mut self, name: &str) -> Self {
115        if !self.attributes.iter().any(|(n, _)| n == name) {
116            self.attributes.push((name.to_string(), String::new()));
117        }
118        self
119    }
120
121    /// Set `class`.
122    pub fn class(self, value: impl Into<String>) -> Self {
123        self.attr("class", value)
124    }
125
126    /// Add a child element.
127    pub fn child(mut self, child: El) -> Self {
128        self.children.push(Node::Element(child));
129        self
130    }
131
132    /// Add a node.
133    pub fn node(mut self, node: Node) -> Self {
134        self.children.push(node);
135        self
136    }
137
138    /// Add several children.
139    pub fn children(mut self, children: impl IntoIterator<Item = El>) -> Self {
140        self.children
141            .extend(children.into_iter().map(Node::Element));
142        self
143    }
144
145    /// Add several nodes.
146    pub fn nodes(mut self, nodes: impl IntoIterator<Item = Node>) -> Self {
147        self.children.extend(nodes);
148        self
149    }
150
151    /// Add escaped text.
152    pub fn text(mut self, value: impl Into<String>) -> Self {
153        self.children.push(Node::Text(value.into()));
154        self
155    }
156
157    /// Add markup this process produced itself. Never repository content.
158    pub fn raw(mut self, markup: impl Into<String>) -> Self {
159        self.children.push(Node::Raw(markup.into()));
160        self
161    }
162
163    /// Apply a function when the condition holds: for a branch inside a builder chain.
164    pub fn when(self, condition: bool, f: impl FnOnce(Self) -> Self) -> Self {
165        if condition {
166            f(self)
167        } else {
168            self
169        }
170    }
171
172    /// The element as markup.
173    pub fn render(&self) -> String {
174        let mut out = String::new();
175        self.write(&mut out);
176        out
177    }
178
179    fn write(&self, out: &mut String) {
180        let _ = write!(out, "<{}", self.tag);
181        for (name, value) in &self.attributes {
182            if value.is_empty() {
183                let _ = write!(out, " {name}");
184            } else {
185                let _ = write!(out, " {name}=\"{}\"", escape(value));
186            }
187        }
188        out.push('>');
189        if VOID.contains(&self.tag) {
190            return;
191        }
192        for child in &self.children {
193            match child {
194                Node::Element(e) => e.write(out),
195                Node::Text(t) => out.push_str(&escape(t)),
196                Node::Raw(r) => out.push_str(r),
197            }
198        }
199        let _ = write!(out, "</{}>", self.tag);
200    }
201}
202
203/// A whole page: the doctype, the head this Cockpit always emits, and a body.
204///
205/// The head is not a template a page may replace. Its content — the character set, the
206/// viewport, the stylesheet, the theme script that runs before first paint — is the same
207/// on every page by construction, so a new page cannot forget the parts that make the
208/// Cockpit behave.
209pub fn document(title: &str, head: Vec<El>, body: El) -> String {
210    document_on(title, &[], head, body)
211}
212
213/// A whole page whose root element carries `root`'s attributes besides its language: the
214/// theme contract, where a script written for the site reads it from `<html>` as the site's
215/// pages carry it.
216///
217/// ```
218/// use majordomus_cli::cockpit::html::{document_on, el};
219/// let page = document_on("t", &[("data-theme-key", "a\"b".into())], vec![], el("body"));
220/// assert!(page.contains("<html lang=\"en\" data-theme-key=\"a&quot;b\">"), "{page}");
221/// ```
222pub fn document_on(title: &str, root: &[(&str, String)], head: Vec<El>, body: El) -> String {
223    let mut out = String::from("<!doctype html>\n<html lang=\"en\"");
224    for (name, value) in root {
225        out.push_str(&format!(" {name}=\"{}\"", escape(value)));
226    }
227    out.push_str(">\n<head>\n");
228    out.push_str(&el("meta").attr("charset", "utf-8").render());
229    out.push_str(
230        &el("meta")
231            .attr("name", "viewport")
232            .attr("content", "width=device-width, initial-scale=1")
233            .render(),
234    );
235    out.push_str(&el("title").text(title).render());
236    for e in head {
237        out.push_str(&e.render());
238    }
239    out.push_str("\n</head>\n");
240    out.push_str(&body.render());
241    out.push_str("\n</html>\n");
242    out
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn the_root_carries_its_attributes_escaped_and_a_plain_page_none() {
251        let page = document_on("t", &[("data-x", "<y>".into())], vec![], el("body"));
252        assert!(
253            page.contains("<html lang=\"en\" data-x=\"&lt;y&gt;\">"),
254            "{page}"
255        );
256        assert!(document("t", vec![], el("body")).contains("<html lang=\"en\">\n<head>"));
257    }
258
259    #[test]
260    fn repository_content_cannot_escape_an_element_body() {
261        let hostile = "</script><img src=x onerror=alert(1)>";
262        let rendered = el("pre").text(hostile).render();
263        assert!(!rendered.contains("<img"), "{rendered}");
264        assert!(rendered.contains("&lt;img"), "{rendered}");
265    }
266
267    #[test]
268    fn repository_content_cannot_escape_an_attribute() {
269        let hostile = "\" onmouseover=\"alert(1)";
270        let rendered = el("a").attr("href", hostile).text("x").render();
271        assert!(!rendered.contains("onmouseover=\"alert"), "{rendered}");
272        assert!(rendered.contains("&quot;"), "{rendered}");
273    }
274
275    #[test]
276    fn a_void_element_has_no_closing_tag() {
277        assert_eq!(el("br").render(), "<br>");
278        assert_eq!(
279            el("input").attr("type", "text").flag("required").render(),
280            "<input type=\"text\" required>"
281        );
282    }
283
284    #[test]
285    fn a_later_attribute_replaces_an_earlier_one() {
286        assert_eq!(
287            el("div").class("a").class("b").render(),
288            "<div class=\"b\"></div>"
289        );
290    }
291
292    #[test]
293    fn a_document_carries_the_head_every_page_gets() {
294        let page = document("T", vec![el("link").attr("rel", "stylesheet")], el("body"));
295        assert!(page.starts_with("<!doctype html>"));
296        assert!(page.contains("<title>T</title>"));
297        assert!(page.contains("charset=\"utf-8\""));
298        assert!(page.contains("<link rel=\"stylesheet\">"));
299        assert!(page.trim_end().ends_with("</html>"));
300    }
301}