Skip to main content

surf_parse/
template.rs

1//! Template variable interpolation for SurfDoc rendering.
2//!
3//! Provides `{= key =}` variable substitution during HTML output.
4//! Variables are resolved at render time (not parse time), keeping the AST clean.
5//!
6//! # Usage
7//!
8//! ```
9//! use surf_parse::template::TemplateContext;
10//!
11//! let mut ctx = TemplateContext::new();
12//! ctx.insert("name", "Brady");
13//! ctx.insert("user.email", "brady@cloudsurf.com");
14//!
15//! let html = "<h1>Hello, {= name =}!</h1><p>{= user.email =}</p>";
16//! let result = ctx.resolve(html);
17//! assert_eq!(result, "<h1>Hello, Brady!</h1><p>brady@cloudsurf.com</p>");
18//! ```
19
20use std::collections::HashMap;
21
22/// Context for template variable interpolation.
23///
24/// Variables are flat key-value pairs. Dot-path notation (e.g., `user.name`)
25/// is stored and looked up as a flat string key — no nested map traversal.
26#[derive(Debug, Clone, Default)]
27pub struct TemplateContext {
28    vars: HashMap<String, String>,
29}
30
31impl TemplateContext {
32    /// Create an empty template context.
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Insert a variable. Returns `&mut Self` for chaining.
38    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
39        self.vars.insert(key.into(), value.into());
40        self
41    }
42
43    /// Resolve `{= key =}` patterns in an HTML string.
44    ///
45    /// - Matched keys are replaced with their HTML-escaped value.
46    /// - Missing keys are replaced with an empty string.
47    /// - Whitespace inside delimiters is trimmed: `{= name =}` and `{=name=}` both work.
48    pub fn resolve(&self, html: &str) -> String {
49        let mut result = String::with_capacity(html.len());
50        let mut rest = html;
51
52        while let Some(start_pos) = rest.find("{=") {
53            // Push everything before the delimiter
54            result.push_str(&rest[..start_pos]);
55
56            let after_open = &rest[start_pos + 2..];
57            if let Some(end_pos) = after_open.find("=}") {
58                let key = after_open[..end_pos].trim();
59                if let Some(value) = self.vars.get(key) {
60                    result.push_str(&escape_html(value));
61                }
62                // Missing keys → empty string (nothing pushed)
63                rest = &after_open[end_pos + 2..];
64            } else {
65                // No closing =} found — emit the {= literally and move past it
66                result.push_str("{=");
67                rest = after_open;
68            }
69        }
70
71        // Push any remaining text
72        result.push_str(rest);
73        result
74    }
75}
76
77/// Escape HTML special characters to prevent XSS in interpolated values.
78fn escape_html(s: &str) -> String {
79    let mut escaped = String::with_capacity(s.len());
80    for c in s.chars() {
81        match c {
82            '&' => escaped.push_str("&amp;"),
83            '<' => escaped.push_str("&lt;"),
84            '>' => escaped.push_str("&gt;"),
85            '"' => escaped.push_str("&quot;"),
86            _ => escaped.push(c),
87        }
88    }
89    escaped
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn basic_interpolation() {
98        let mut ctx = TemplateContext::new();
99        ctx.insert("name", "Brady");
100        assert_eq!(ctx.resolve("Hello, {= name =}!"), "Hello, Brady!");
101    }
102
103    #[test]
104    fn dot_path_key() {
105        let mut ctx = TemplateContext::new();
106        ctx.insert("user.email", "brady@cloudsurf.com");
107        assert_eq!(
108            ctx.resolve("Email: {= user.email =}"),
109            "Email: brady@cloudsurf.com"
110        );
111    }
112
113    #[test]
114    fn missing_key_is_empty() {
115        let ctx = TemplateContext::new();
116        assert_eq!(ctx.resolve("Hello, {= unknown =}!"), "Hello, !");
117    }
118
119    #[test]
120    fn html_escaping() {
121        let mut ctx = TemplateContext::new();
122        ctx.insert("input", "<script>alert(1)</script>");
123        assert_eq!(
124            ctx.resolve("{= input =}"),
125            "&lt;script&gt;alert(1)&lt;/script&gt;"
126        );
127    }
128
129    #[test]
130    fn ampersand_escaping() {
131        let mut ctx = TemplateContext::new();
132        ctx.insert("name", "Tom & Jerry");
133        assert_eq!(ctx.resolve("{= name =}"), "Tom &amp; Jerry");
134    }
135
136    #[test]
137    fn quote_escaping() {
138        let mut ctx = TemplateContext::new();
139        ctx.insert("attr", "say \"hello\"");
140        assert_eq!(ctx.resolve("{= attr =}"), "say &quot;hello&quot;");
141    }
142
143    #[test]
144    fn no_variables_unchanged() {
145        let ctx = TemplateContext::new();
146        assert_eq!(
147            ctx.resolve("<h1>No variables here</h1>"),
148            "<h1>No variables here</h1>"
149        );
150    }
151
152    #[test]
153    fn multiple_variables_one_line() {
154        let mut ctx = TemplateContext::new();
155        ctx.insert("first", "Brady");
156        ctx.insert("last", "Davis");
157        assert_eq!(
158            ctx.resolve("{= first =} {= last =}"),
159            "Brady Davis"
160        );
161    }
162
163    #[test]
164    fn variable_in_html_context() {
165        let mut ctx = TemplateContext::new();
166        ctx.insert("name", "Brady");
167        assert_eq!(
168            ctx.resolve("<h1>Hello {= name =}</h1>"),
169            "<h1>Hello Brady</h1>"
170        );
171    }
172
173    #[test]
174    fn no_whitespace_in_delimiters() {
175        let mut ctx = TemplateContext::new();
176        ctx.insert("x", "42");
177        assert_eq!(ctx.resolve("{=x=}"), "42");
178    }
179
180    #[test]
181    fn extra_whitespace_in_delimiters() {
182        let mut ctx = TemplateContext::new();
183        ctx.insert("x", "42");
184        assert_eq!(ctx.resolve("{=   x   =}"), "42");
185    }
186
187    #[test]
188    fn unclosed_delimiter_is_literal() {
189        let ctx = TemplateContext::new();
190        assert_eq!(ctx.resolve("price {= no closing"), "price {= no closing");
191    }
192
193    #[test]
194    fn empty_key_is_missing() {
195        let ctx = TemplateContext::new();
196        assert_eq!(ctx.resolve("{= =}"), "");
197    }
198
199    #[test]
200    fn chained_insert() {
201        let mut ctx = TemplateContext::new();
202        ctx.insert("a", "1").insert("b", "2");
203        assert_eq!(ctx.resolve("{= a =},{= b =}"), "1,2");
204    }
205
206    #[test]
207    fn adjacent_variables() {
208        let mut ctx = TemplateContext::new();
209        ctx.insert("a", "hello");
210        ctx.insert("b", "world");
211        assert_eq!(ctx.resolve("{= a =}{= b =}"), "helloworld");
212    }
213
214    #[test]
215    fn variable_at_start_and_end() {
216        let mut ctx = TemplateContext::new();
217        ctx.insert("greeting", "Hi");
218        assert_eq!(ctx.resolve("{= greeting =}"), "Hi");
219    }
220
221    #[test]
222    fn preserves_surrounding_text() {
223        let mut ctx = TemplateContext::new();
224        ctx.insert("plan", "Pro");
225        assert_eq!(
226            ctx.resolve("Your plan: {= plan =}. Enjoy!"),
227            "Your plan: Pro. Enjoy!"
228        );
229    }
230}