Skip to main content

robinpath_modules/modules/
template_mod.rs

1use robinpath::{RobinPath, Value};
2
3pub fn register(rp: &mut RobinPath) {
4    rp.register_builtin("template.render", |args, _| {
5        let tmpl = args.first().map(|v| v.to_display_string()).unwrap_or_default();
6        let data = args.get(1).cloned().unwrap_or(Value::Null);
7        Ok(Value::String(render_template(&tmpl, &data)))
8    });
9
10    rp.register_builtin("template.escape", |args, _| {
11        let s = args.first().map(|v| v.to_display_string()).unwrap_or_default();
12        Ok(Value::String(html_escape(&s)))
13    });
14
15    rp.register_builtin("template.compile", |args, _| {
16        let tmpl = args.first().map(|v| v.to_display_string()).unwrap_or_default();
17        // In a scripting context, "compile" just validates and returns the template
18        // Since we can't return closures, we return the template string for use with render
19        let vars = extract_variables(&tmpl);
20        let mut obj = indexmap::IndexMap::new();
21        obj.insert("template".to_string(), Value::String(tmpl));
22        obj.insert("variables".to_string(), Value::Array(vars));
23        Ok(Value::Object(obj))
24    });
25
26    rp.register_builtin("template.extractVariables", |args, _| {
27        let tmpl = args.first().map(|v| v.to_display_string()).unwrap_or_default();
28        Ok(Value::Array(extract_variables(&tmpl)))
29    });
30
31    rp.register_builtin("template.renderString", |args, _| {
32        let s = args.first().map(|v| v.to_display_string()).unwrap_or_default();
33        let data = args.get(1).cloned().unwrap_or(Value::Null);
34        Ok(Value::String(render_template(&s, &data)))
35    });
36}
37
38fn render_template(tmpl: &str, data: &Value) -> String {
39    let mut result = String::new();
40    let chars: Vec<char> = tmpl.chars().collect();
41    let len = chars.len();
42    let mut i = 0;
43
44    while i < len {
45        if i + 1 < len && chars[i] == '{' && chars[i + 1] == '{' {
46            // Find closing }}
47            let start = i + 2;
48            if let Some(end) = find_closing_braces(&chars, start) {
49                let tag = chars[start..end].iter().collect::<String>().trim().to_string();
50
51                if tag.starts_with('!') {
52                    // Comment — skip
53                } else if tag.starts_with('#') {
54                    // Section: {{#name}}...{{/name}}
55                    let section_name = tag[1..].trim();
56                    let close_tag = format!("{{{{/{}}}}}", section_name);
57                    if let Some(close_pos) = tmpl[end + 2..].find(&close_tag) {
58                        let inner = &tmpl[end + 2..end + 2 + close_pos];
59                        let val = resolve_value(data, section_name);
60                        match &val {
61                            Value::Bool(true) => {
62                                result.push_str(&render_template(inner, data));
63                            }
64                            Value::Array(arr) => {
65                                for item in arr {
66                                    result.push_str(&render_template(inner, item));
67                                }
68                            }
69                            Value::Object(_) => {
70                                result.push_str(&render_template(inner, &val));
71                            }
72                            v if v.is_truthy() => {
73                                result.push_str(&render_template(inner, data));
74                            }
75                            _ => {} // Falsy: skip section
76                        }
77                        i = end + 2 + close_pos + close_tag.len();
78                        continue;
79                    }
80                } else if tag.starts_with('^') {
81                    // Inverted section: {{^name}}...{{/name}}
82                    let section_name = tag[1..].trim();
83                    let close_tag = format!("{{{{/{}}}}}", section_name);
84                    if let Some(close_pos) = tmpl[end + 2..].find(&close_tag) {
85                        let inner = &tmpl[end + 2..end + 2 + close_pos];
86                        let val = resolve_value(data, section_name);
87                        if !val.is_truthy() {
88                            result.push_str(&render_template(inner, data));
89                        }
90                        i = end + 2 + close_pos + close_tag.len();
91                        continue;
92                    }
93                } else if tag.starts_with('{') && tag.ends_with('}') {
94                    // Unescaped: {{{name}}}
95                    let var_name = tag[1..tag.len() - 1].trim();
96                    let val = resolve_value(data, var_name);
97                    result.push_str(&val.to_display_string());
98                    // Skip extra closing brace
99                    i = end + 2;
100                    if i < len && chars.get(i) == Some(&'}') {
101                        i += 1;
102                    }
103                    continue;
104                } else {
105                    // Variable: {{name}} — HTML-escaped
106                    let val = resolve_value(data, &tag);
107                    let display = val.to_display_string();
108                    if display != "null" && display != "undefined" {
109                        result.push_str(&html_escape(&display));
110                    }
111                }
112
113                i = end + 2;
114                continue;
115            }
116        }
117
118        result.push(chars[i]);
119        i += 1;
120    }
121
122    result
123}
124
125fn find_closing_braces(chars: &[char], start: usize) -> Option<usize> {
126    let len = chars.len();
127    let mut i = start;
128    while i + 1 < len {
129        if chars[i] == '}' && chars[i + 1] == '}' {
130            return Some(i);
131        }
132        i += 1;
133    }
134    None
135}
136
137fn resolve_value(data: &Value, path: &str) -> Value {
138    let parts: Vec<&str> = path.split('.').collect();
139    let mut current = data.clone();
140    for part in parts {
141        match &current {
142            Value::Object(obj) => {
143                current = obj.get(part).cloned().unwrap_or(Value::Null);
144            }
145            _ => return Value::Null,
146        }
147    }
148    current
149}
150
151fn extract_variables(tmpl: &str) -> Vec<Value> {
152    let mut vars = Vec::new();
153    let mut seen = std::collections::HashSet::new();
154    let chars: Vec<char> = tmpl.chars().collect();
155    let len = chars.len();
156    let mut i = 0;
157
158    while i + 1 < len {
159        if chars[i] == '{' && chars[i + 1] == '{' {
160            let start = i + 2;
161            if let Some(end) = find_closing_braces(&chars, start) {
162                let tag = chars[start..end].iter().collect::<String>().trim().to_string();
163                // Extract variable names (skip comments, section markers)
164                let name = if tag.starts_with('!') || tag.starts_with('/') {
165                    None
166                } else if tag.starts_with('#') || tag.starts_with('^') {
167                    Some(tag[1..].trim().to_string())
168                } else if tag.starts_with('{') && tag.ends_with('}') {
169                    Some(tag[1..tag.len() - 1].trim().to_string())
170                } else {
171                    Some(tag)
172                };
173                if let Some(name) = name {
174                    if !name.is_empty() && seen.insert(name.clone()) {
175                        vars.push(Value::String(name));
176                    }
177                }
178                i = end + 2;
179                continue;
180            }
181        }
182        i += 1;
183    }
184
185    vars
186}
187
188fn html_escape(s: &str) -> String {
189    s.replace('&', "&amp;")
190        .replace('<', "&lt;")
191        .replace('>', "&gt;")
192        .replace('"', "&quot;")
193        .replace('\'', "&#39;")
194}