1use handlebars::{
2 Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason,
3 Renderable,
4};
5use regex::Regex;
6use serde_json::{Map, Value};
7
8use crate::error::{FlowError, FlowErrorLocation, Result};
9
10const STATE_TOKEN_PREFIX: &str = "__STATE_TOKEN__";
11const STATE_TOKEN_SUFFIX: &str = "__";
12
13pub struct TemplateRenderer {
14 handlebars: Handlebars<'static>,
15 manifest_id: Option<String>,
16}
17
18impl TemplateRenderer {
19 pub fn new(manifest_id: Option<String>) -> Self {
20 let mut handlebars = Handlebars::new();
21 handlebars.register_escape_fn(|s| s.to_string());
22 handlebars.register_helper("json", Box::new(JsonHelper));
23 handlebars.register_helper("default", Box::new(DefaultHelper));
24 handlebars.register_helper("ifEq", Box::new(IfEqHelper));
25 Self {
26 handlebars,
27 manifest_id,
28 }
29 }
30
31 pub fn render_json(
32 &self,
33 template: &str,
34 state: &Map<String, Value>,
35 node_id: &str,
36 ) -> Result<Value> {
37 let preprocessed = preprocess_template(template);
38 let mut ctx = Map::new();
39 ctx.insert("state".to_string(), Value::Object(state.clone()));
40 let rendered = self
41 .handlebars
42 .render_template(&preprocessed, &ctx)
43 .map_err(|e| FlowError::Internal {
44 message: format!(
45 "template render error in node '{node_id}'{}: {e}",
46 manifest_label(self.manifest_id.as_deref())
47 ),
48 location: FlowErrorLocation::at_path(format!("nodes.{node_id}.template")),
49 })?;
50 let mut value: Value =
51 serde_json::from_str(&rendered).map_err(|e| FlowError::Internal {
52 message: format!(
53 "template JSON parse error in node '{node_id}'{}: {e}",
54 manifest_label(self.manifest_id.as_deref())
55 ),
56 location: FlowErrorLocation::at_path(format!("nodes.{node_id}.template")),
57 })?;
58 substitute_state_tokens(&mut value, state).map_err(|e| FlowError::Internal {
59 message: format!(
60 "{e} (node '{node_id}'{})",
61 manifest_label(self.manifest_id.as_deref())
62 ),
63 location: FlowErrorLocation::at_path(format!("nodes.{node_id}.template")),
64 })?;
65 Ok(value)
66 }
67}
68
69fn manifest_label(manifest_id: Option<&str>) -> String {
70 manifest_id
71 .map(|id| format!(" in manifest '{id}'"))
72 .unwrap_or_default()
73}
74
75fn preprocess_template(template: &str) -> String {
76 let re = Regex::new(r"\{\{\s*state\.([A-Za-z_]\w*)\s*\}\}").unwrap();
77 re.replace_all(template, |caps: ®ex::Captures<'_>| {
78 state_token_value(caps.get(1).unwrap().as_str())
79 })
80 .to_string()
81}
82
83fn state_token_value(key: &str) -> String {
84 format!("{STATE_TOKEN_PREFIX}{key}{STATE_TOKEN_SUFFIX}")
85}
86
87fn substitute_state_tokens(
88 target: &mut Value,
89 state: &Map<String, Value>,
90) -> std::result::Result<(), String> {
91 match target {
92 Value::String(s) => {
93 if let Some(key) = s
94 .strip_prefix(STATE_TOKEN_PREFIX)
95 .and_then(|rest| rest.strip_suffix(STATE_TOKEN_SUFFIX))
96 {
97 let value = state
98 .get(key)
99 .ok_or_else(|| format!("state value for '{key}' not found"))?;
100 *target = value.clone();
101 }
102 Ok(())
103 }
104 Value::Array(items) => {
105 for item in items {
106 substitute_state_tokens(item, state)?;
107 }
108 Ok(())
109 }
110 Value::Object(map) => {
111 for value in map.values_mut() {
112 substitute_state_tokens(value, state)?;
113 }
114 Ok(())
115 }
116 _ => Ok(()),
117 }
118}
119
120struct JsonHelper;
121
122impl HelperDef for JsonHelper {
123 fn call<'reg: 'rc, 'rc>(
124 &self,
125 helper: &Helper<'rc>,
126 _: &'reg Handlebars<'reg>,
127 _: &'rc Context,
128 _: &mut RenderContext<'reg, 'rc>,
129 out: &mut dyn Output,
130 ) -> std::result::Result<(), RenderError> {
131 let value = helper
132 .param(0)
133 .map(|p| p.value().clone())
134 .ok_or_else(|| helper_error("json helper expects 1 parameter"))?;
135 let rendered = serde_json::to_string(&value)
136 .map_err(|e| helper_error(&format!("json helper: {e}")))?;
137 out.write(&rendered)?;
138 Ok(())
139 }
140}
141
142struct DefaultHelper;
143
144impl HelperDef for DefaultHelper {
145 fn call<'reg: 'rc, 'rc>(
146 &self,
147 helper: &Helper<'rc>,
148 _: &'reg Handlebars<'reg>,
149 _: &'rc Context,
150 _: &mut RenderContext<'reg, 'rc>,
151 out: &mut dyn Output,
152 ) -> std::result::Result<(), RenderError> {
153 let value = helper.param(0).map(|p| p.value().clone());
154 let fallback = helper
155 .param(1)
156 .map(|p| p.value().clone())
157 .ok_or_else(|| helper_error("default helper expects 2 parameters"))?;
158 let use_fallback = matches!(value.as_ref(), None | Some(Value::Null))
159 || matches!(value.as_ref(), Some(Value::String(s)) if s.is_empty());
160 let rendered_value = if use_fallback {
161 fallback
162 } else {
163 value.unwrap()
164 };
165 let rendered = serde_json::to_string(&rendered_value)
166 .map_err(|e| helper_error(&format!("default helper: {e}")))?;
167 out.write(&rendered)?;
168 Ok(())
169 }
170}
171
172struct IfEqHelper;
173
174impl HelperDef for IfEqHelper {
175 fn call<'reg: 'rc, 'rc>(
176 &self,
177 helper: &Helper<'rc>,
178 r: &'reg Handlebars<'reg>,
179 ctx: &'rc Context,
180 rc: &mut RenderContext<'reg, 'rc>,
181 out: &mut dyn Output,
182 ) -> std::result::Result<(), RenderError> {
183 let left = helper
184 .param(0)
185 .map(|p| p.value().clone())
186 .ok_or_else(|| helper_error("ifEq helper expects 2 parameters"))?;
187 let right = helper
188 .param(1)
189 .map(|p| p.value().clone())
190 .ok_or_else(|| helper_error("ifEq helper expects 2 parameters"))?;
191 let matches = left == right;
192 if matches {
193 if let Some(t) = helper.template() {
194 t.render(r, ctx, rc, out)?;
195 }
196 } else if let Some(t) = helper.inverse() {
197 t.render(r, ctx, rc, out)?;
198 }
199 Ok(())
200 }
201}
202
203fn helper_error(message: &str) -> RenderError {
204 RenderErrorReason::Other(message.to_string()).into()
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use serde_json::json;
211
212 fn render(template: &str, state: Map<String, Value>) -> Value {
213 let renderer = TemplateRenderer::new(Some("manifest.test".to_string()));
214 renderer
215 .render_json(template, &state, "emit_config")
216 .unwrap()
217 }
218
219 #[test]
220 fn if_truthy_renders_block() {
221 let mut state = Map::new();
222 state.insert("needs_interaction".to_string(), Value::Bool(true));
223 let template = r#"{ "enabled": {{#if state.needs_interaction}}true{{/if}} }"#;
224 let value = render(template, state);
225 assert_eq!(value.get("enabled"), Some(&Value::Bool(true)));
226 }
227
228 #[test]
229 fn ifeq_matches_string_and_bool() {
230 let mut state = Map::new();
231 state.insert("mode".to_string(), Value::String("asset".to_string()));
232 state.insert("flag".to_string(), Value::Bool(false));
233 let template = r#"
234 {
235 "mode": {{#ifEq state.mode "asset"}} "asset" {{else}} "inline" {{/ifEq}},
236 "flagged": {{#ifEq state.flag false}} true {{else}} false {{/ifEq}}
237 }"#;
238 let value = render(template, state);
239 assert_eq!(value.get("mode"), Some(&Value::String("asset".to_string())));
240 assert_eq!(value.get("flagged"), Some(&Value::Bool(true)));
241 }
242
243 #[test]
244 fn json_helper_emits_raw_json() {
245 let mut state = Map::new();
246 state.insert("inline_json".to_string(), json!({"a": 1, "b": [true]}));
247 let template = r#"{ "inline": {{json state.inline_json}} }"#;
248 let value = render(template, state);
249 assert_eq!(value.get("inline"), Some(&json!({"a": 1, "b": [true]})));
250 }
251
252 #[test]
253 fn preserves_simple_state_interpolation() {
254 let mut state = Map::new();
255 state.insert("temperature".to_string(), json!(0.4));
256 let template = r#"{ "temperature": "{{state.temperature}}" }"#;
257 let value = render(template, state);
258 assert_eq!(value.get("temperature"), Some(&json!(0.4)));
259 }
260}