1use std::fmt::Write as _;
12
13pub 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("<"),
26 '>' => out.push_str(">"),
27 '&' => out.push_str("&"),
28 '"' => out.push_str("""),
29 '\'' => out.push_str("'"),
30 other => out.push(other),
31 }
32 }
33 out
34}
35
36const VOID: &[&str] = &[
38 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track",
39 "wbr",
40];
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Node {
45 Element(El),
47 Text(String),
49 Raw(String),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct El {
57 tag: &'static str,
58 attributes: Vec<(String, String)>,
59 children: Vec<Node>,
60}
61
62pub fn el(tag: &'static str) -> El {
70 El {
71 tag,
72 attributes: Vec::new(),
73 children: Vec::new(),
74 }
75}
76
77pub fn text(value: impl Into<String>) -> Node {
79 Node::Text(value.into())
80}
81
82pub fn raw(markup: impl Into<String>) -> Node {
85 Node::Raw(markup.into())
86}
87
88pub fn empty() -> Node {
90 Node::Raw(String::new())
91}
92
93impl El {
94 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 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 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 pub fn class(self, value: impl Into<String>) -> Self {
123 self.attr("class", value)
124 }
125
126 pub fn child(mut self, child: El) -> Self {
128 self.children.push(Node::Element(child));
129 self
130 }
131
132 pub fn node(mut self, node: Node) -> Self {
134 self.children.push(node);
135 self
136 }
137
138 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 pub fn nodes(mut self, nodes: impl IntoIterator<Item = Node>) -> Self {
147 self.children.extend(nodes);
148 self
149 }
150
151 pub fn text(mut self, value: impl Into<String>) -> Self {
153 self.children.push(Node::Text(value.into()));
154 self
155 }
156
157 pub fn raw(mut self, markup: impl Into<String>) -> Self {
159 self.children.push(Node::Raw(markup.into()));
160 self
161 }
162
163 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 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
203pub fn document(title: &str, head: Vec<El>, body: El) -> String {
210 document_on(title, &[], head, body)
211}
212
213pub 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=\"<y>\">"),
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("<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("""), "{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}