Skip to main content

shelly/
html.rs

1use std::fmt;
2
3/// A server-rendered HTML fragment.
4///
5/// Shelly v0 treats HTML as an opaque fragment. Later milestones can replace
6/// this with a typed template representation that separates static and dynamic
7/// segments for diffing.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Html {
10    value: String,
11    template: Option<TemplateSnapshot>,
12}
13
14impl Html {
15    /// Construct an HTML fragment from a string-like value.
16    pub fn new(value: impl Into<String>) -> Self {
17        Self {
18            value: value.into(),
19            template: None,
20        }
21    }
22
23    /// Borrow the fragment as a string slice.
24    pub fn as_str(&self) -> &str {
25        &self.value
26    }
27
28    /// Consume the fragment and return the underlying string.
29    pub fn into_string(self) -> String {
30        self.value
31    }
32
33    /// Borrow template metadata when this HTML came from `Template`.
34    pub fn template(&self) -> Option<&TemplateSnapshot> {
35        self.template.as_ref()
36    }
37}
38
39impl From<String> for Html {
40    fn from(value: String) -> Self {
41        Self::new(value)
42    }
43}
44
45impl From<&str> for Html {
46    fn from(value: &str) -> Self {
47        Self::new(value)
48    }
49}
50
51impl fmt::Display for Html {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(&self.value)
54    }
55}
56
57/// Render metadata retained from a segmented template.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct TemplateSnapshot {
60    static_segments: Vec<&'static str>,
61    dynamic_segments: Vec<String>,
62}
63
64impl TemplateSnapshot {
65    /// Borrow trusted static template segments.
66    pub fn static_segments(&self) -> &[&'static str] {
67        &self.static_segments
68    }
69
70    /// Borrow escaped dynamic template segments.
71    pub fn dynamic_segments(&self) -> &[String] {
72        &self.dynamic_segments
73    }
74
75    pub(crate) fn compatible_with(&self, other: &Self) -> bool {
76        self.static_segments == other.static_segments
77            && self.dynamic_segments.len() == other.dynamic_segments.len()
78    }
79}
80
81/// A rendered template split into static and dynamic segments.
82///
83/// Static segments are trusted template literals. Dynamic segments are escaped
84/// before rendering, which gives the v1 template macro a safe default while
85/// keeping v0 `Html` as the final transport shape.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Template {
88    static_segments: Vec<&'static str>,
89    dynamic_segments: Vec<String>,
90}
91
92impl Template {
93    /// Construct a segmented template.
94    pub fn new(static_segments: Vec<&'static str>, dynamic_segments: Vec<String>) -> Self {
95        Self {
96            static_segments,
97            dynamic_segments,
98        }
99    }
100
101    /// Borrow trusted static template segments.
102    pub fn static_segments(&self) -> &[&'static str] {
103        &self.static_segments
104    }
105
106    /// Borrow escaped dynamic template segments.
107    pub fn dynamic_segments(&self) -> &[String] {
108        &self.dynamic_segments
109    }
110
111    /// Render this template into opaque v0 HTML.
112    pub fn render(&self) -> Html {
113        let mut out = String::new();
114        for (index, segment) in self.static_segments.iter().enumerate() {
115            out.push_str(segment);
116            if let Some(dynamic) = self.dynamic_segments.get(index) {
117                out.push_str(r#"<span data-shelly-slot=""#);
118                out.push_str(&index.to_string());
119                out.push_str(r#"">"#);
120                out.push_str(dynamic);
121                out.push_str("</span>");
122            }
123        }
124
125        Html {
126            value: out,
127            template: Some(TemplateSnapshot {
128                static_segments: self.static_segments.clone(),
129                dynamic_segments: self.dynamic_segments.clone(),
130            }),
131        }
132    }
133
134    /// Render this template into a raw HTML string.
135    pub fn render_string(&self) -> String {
136        self.render().into_string()
137    }
138}
139
140impl From<Template> for Html {
141    fn from(value: Template) -> Self {
142        value.render()
143    }
144}
145
146/// Escape text for inclusion in HTML text or attribute values.
147///
148/// Shelly v0 keeps rendered HTML opaque, so application code that interpolates
149/// untrusted values into `Html` must escape those values before formatting.
150pub fn escape_html(value: &str) -> String {
151    value
152        .replace('&', "&amp;")
153        .replace('<', "&lt;")
154        .replace('>', "&gt;")
155        .replace('"', "&quot;")
156        .replace('\'', "&#39;")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{escape_html, Html, Template};
162
163    #[test]
164    fn html_wraps_fragment() {
165        let html = Html::new("<p>Hello</p>");
166        assert_eq!(html.as_str(), "<p>Hello</p>");
167        assert_eq!(html.into_string(), "<p>Hello</p>");
168    }
169
170    #[test]
171    fn escape_html_escapes_text_and_attribute_delimiters() {
172        assert_eq!(
173            escape_html(r#"<script title="x">Tom & 'Ada'</script>"#),
174            "&lt;script title=&quot;x&quot;&gt;Tom &amp; &#39;Ada&#39;&lt;/script&gt;"
175        );
176    }
177
178    #[test]
179    fn template_renders_static_and_dynamic_segments() {
180        let template = Template::new(vec!["<p>Hello ", "</p>"], vec![escape_html("<Ada & Bob>")]);
181
182        assert_eq!(template.static_segments(), &["<p>Hello ", "</p>"]);
183        assert_eq!(template.dynamic_segments(), &["&lt;Ada &amp; Bob&gt;"]);
184        assert_eq!(
185            template.render_string(),
186            r#"<p>Hello <span data-shelly-slot="0">&lt;Ada &amp; Bob&gt;</span></p>"#
187        );
188    }
189
190    #[test]
191    fn html_from_and_template_snapshot_helpers_cover_metadata_paths() {
192        let from_string = Html::from(String::from("<p>a</p>"));
193        let from_str = Html::from("<p>b</p>");
194        assert_eq!(from_string.as_str(), "<p>a</p>");
195        assert_eq!(from_str.as_str(), "<p>b</p>");
196
197        let template_a = Template::new(vec!["<p>", "</p>"], vec!["1".to_string()]);
198        let template_b = Template::new(vec!["<p>", "</p>"], vec!["2".to_string()]);
199        let html_a = template_a.render();
200        let html_b = template_b.render();
201        let snap_a = html_a.template().expect("template metadata");
202        let snap_b = html_b.template().expect("template metadata");
203
204        assert_eq!(snap_a.static_segments(), &["<p>", "</p>"]);
205        assert_eq!(snap_a.dynamic_segments(), &["1".to_string()]);
206        assert!(snap_a.compatible_with(snap_b));
207    }
208}