sqlpage/
template_helpers.rs

1use std::borrow::Cow;
2
3use crate::{app_config::AppConfig, utils::static_filename};
4use anyhow::Context as _;
5use handlebars::{
6    handlebars_helper, Context, Handlebars, HelperDef, JsonTruthy, PathAndJson, RenderError,
7    RenderErrorReason, Renderable, ScopedJson,
8};
9use serde_json::Value as JsonValue;
10
11/// Simple static json helper
12type H0 = fn() -> JsonValue;
13/// Simple json to json helper
14type H = fn(&JsonValue) -> JsonValue;
15/// Simple json to json helper with error handling
16type EH = fn(&JsonValue) -> anyhow::Result<JsonValue>;
17/// Helper that takes two arguments
18type HH = fn(&JsonValue, &JsonValue) -> JsonValue;
19/// Helper that takes three arguments
20#[allow(clippy::upper_case_acronyms)]
21type HHH = fn(&JsonValue, &JsonValue, &JsonValue) -> JsonValue;
22
23pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) {
24    let site_prefix = config.site_prefix.clone();
25
26    register_helper(h, "all", HelperCheckTruthy(false));
27    register_helper(h, "any", HelperCheckTruthy(true));
28
29    register_helper(h, "stringify", stringify_helper as H);
30    register_helper(h, "parse_json", parse_json_helper as EH);
31    register_helper(h, "default", default_helper as HH);
32    register_helper(h, "entries", entries_helper as H);
33    register_helper(h, "replace", replace_helper as HHH);
34    // delay helper: store a piece of information in memory that can be output later with flush_delayed
35    h.register_helper("delay", Box::new(delay_helper));
36    h.register_helper("flush_delayed", Box::new(flush_delayed_helper));
37    register_helper(h, "plus", plus_helper as HH);
38    register_helper(h, "minus", minus_helper as HH);
39    h.register_helper("sum", Box::new(sum_helper));
40    register_helper(h, "loose_eq", loose_eq_helper as HH);
41    register_helper(h, "starts_with", starts_with_helper as HH);
42
43    // to_array: convert a value to a single-element array. If the value is already an array, return it as-is.
44    register_helper(h, "to_array", to_array_helper as H);
45
46    // array_contains: check if an array contains an element. If the first argument is not an array, it is compared to the second argument.
47    handlebars_helper!(array_contains: |array: Json, element: Json| match array {
48        JsonValue::Array(arr) => arr.contains(element),
49        other => other == element
50    });
51    h.register_helper("array_contains", Box::new(array_contains));
52
53    // array_contains_case_insensitive: check if an array contains an element case-insensitively. If the first argument is not an array, it is compared to the second argument case-insensitively.
54    handlebars_helper!(array_contains_case_insensitive: |array: Json, element: Json| {
55        match array {
56            JsonValue::Array(arr) => arr.iter().any(|v| json_eq_case_insensitive(v, element)),
57            other => json_eq_case_insensitive(other, element),
58        }
59    });
60    h.register_helper(
61        "array_contains_case_insensitive",
62        Box::new(array_contains_case_insensitive),
63    );
64
65    // static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage.<hash>.js
66    register_helper(h, "static_path", StaticPathHelper(site_prefix.clone()));
67    register_helper(h, "app_config", AppConfigHelper(config.clone()));
68
69    // icon helper: generate an image with the specified icon
70    h.register_helper("icon_img", Box::new(IconImgHelper(site_prefix)));
71    register_helper(h, "markdown", MarkdownHelper::new(config));
72    register_helper(h, "buildinfo", buildinfo_helper as EH);
73    register_helper(h, "typeof", typeof_helper as H);
74    register_helper(h, "rfc2822_date", rfc2822_date_helper as EH);
75    register_helper(h, "url_encode", url_encode_helper as H);
76    register_helper(h, "csv_escape", csv_escape_helper as HH);
77}
78
79fn json_eq_case_insensitive(a: &JsonValue, b: &JsonValue) -> bool {
80    match (a, b) {
81        (JsonValue::String(a), JsonValue::String(b)) => a.eq_ignore_ascii_case(b),
82        _ => a == b,
83    }
84}
85
86fn stringify_helper(v: &JsonValue) -> JsonValue {
87    v.to_string().into()
88}
89
90fn parse_json_helper(v: &JsonValue) -> Result<JsonValue, anyhow::Error> {
91    Ok(match v {
92        serde_json::value::Value::String(s) => serde_json::from_str(s)?,
93        other => other.clone(),
94    })
95}
96
97fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue {
98    if v.is_null() {
99        default.clone()
100    } else {
101        v.clone()
102    }
103}
104
105fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
106    if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
107        (a + b).into()
108    } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
109        (a + b).into()
110    } else {
111        JsonValue::Null
112    }
113}
114
115fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
116    if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
117        (a - b).into()
118    } else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
119        (a - b).into()
120    } else {
121        JsonValue::Null
122    }
123}
124
125fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
126    if let (Some(a), Some(b)) = (a.as_str(), b.as_str()) {
127        a.starts_with(b)
128    } else if let (Some(arr1), Some(arr2)) = (a.as_array(), b.as_array()) {
129        arr1.starts_with(arr2)
130    } else {
131        false
132    }
133    .into()
134}
135fn entries_helper(v: &JsonValue) -> JsonValue {
136    match v {
137        serde_json::value::Value::Object(map) => map
138            .into_iter()
139            .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
140            .collect(),
141        serde_json::value::Value::Array(values) => values
142            .iter()
143            .enumerate()
144            .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
145            .collect(),
146        _ => vec![],
147    }
148    .into()
149}
150
151fn to_array_helper(v: &JsonValue) -> JsonValue {
152    match v {
153        JsonValue::Array(arr) => arr.clone(),
154        JsonValue::Null => vec![],
155        JsonValue::String(s) if s.starts_with('[') => {
156            if let Ok(JsonValue::Array(r)) = serde_json::from_str(s) {
157                r
158            } else {
159                vec![JsonValue::String(s.clone())]
160            }
161        }
162        other => vec![other.clone()],
163    }
164    .into()
165}
166
167/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix
168struct StaticPathHelper(String);
169
170impl CanHelp for StaticPathHelper {
171    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
172        let static_file = match args {
173            [v] => v.value(),
174            _ => return Err("expected one argument".to_string()),
175        };
176        let name = static_file
177            .as_str()
178            .ok_or_else(|| format!("static_path: not a string: {static_file}"))?;
179        let path = match name {
180            "sqlpage.js" => static_filename!("sqlpage.js"),
181            "sqlpage.css" => static_filename!("sqlpage.css"),
182            "apexcharts.js" => static_filename!("apexcharts.js"),
183            "tomselect.js" => static_filename!("tomselect.js"),
184            "favicon.svg" => static_filename!("favicon.svg"),
185            other => return Err(format!("unknown static file: {other:?}")),
186        };
187        Ok(format!("{}{}", self.0, path).into())
188    }
189}
190
191/// Generate the full path to a builtin sqlpage asset. Struct Param is the site prefix
192struct AppConfigHelper(AppConfig);
193
194impl CanHelp for AppConfigHelper {
195    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
196        let static_file = match args {
197            [v] => v.value(),
198            _ => return Err("expected one argument".to_string()),
199        };
200        let name = static_file
201            .as_str()
202            .ok_or_else(|| format!("app_config: not a string: {static_file}"))?;
203        match name {
204            "max_uploaded_file_size" => Ok(JsonValue::Number(self.0.max_uploaded_file_size.into())),
205            "environment" => serde_json::to_value(self.0.environment).map_err(|e| e.to_string()),
206            "site_prefix" => Ok(self.0.site_prefix.clone().into()),
207            other => Err(format!("unknown app config property: {other:?}")),
208        }
209    }
210}
211
212/// Generate an image with the specified icon. Struct Param is the site prefix
213struct IconImgHelper(String);
214impl HelperDef for IconImgHelper {
215    fn call<'reg: 'rc, 'rc>(
216        &self,
217        helper: &handlebars::Helper<'rc>,
218        _r: &'reg Handlebars<'reg>,
219        _ctx: &'rc Context,
220        _rc: &mut handlebars::RenderContext<'reg, 'rc>,
221        writer: &mut dyn handlebars::Output,
222    ) -> handlebars::HelperResult {
223        let null = handlebars::JsonValue::Null;
224        let params = [0, 1].map(|i| helper.params().get(i).map_or(&null, PathAndJson::value));
225        let name = match params[0] {
226            JsonValue::String(s) => s,
227            other => {
228                log::debug!("icon_img: {other:?} is not an icon name, not rendering anything");
229                return Ok(());
230            }
231        };
232        let size = params[1].as_u64().unwrap_or(24);
233        write!(
234            writer,
235            "<svg width={size} height={size}><use href=\"{}{}#tabler-{name}\" /></svg>",
236            self.0,
237            static_filename!("tabler-icons.svg")
238        )?;
239        Ok(())
240    }
241}
242
243fn typeof_helper(v: &JsonValue) -> JsonValue {
244    match v {
245        JsonValue::Null => "null",
246        JsonValue::Bool(_) => "boolean",
247        JsonValue::Number(_) => "number",
248        JsonValue::String(_) => "string",
249        JsonValue::Array(_) => "array",
250        JsonValue::Object(_) => "object",
251    }
252    .into()
253}
254
255pub trait MarkdownConfig {
256    fn allow_dangerous_html(&self) -> bool;
257    fn allow_dangerous_protocol(&self) -> bool;
258}
259
260impl MarkdownConfig for AppConfig {
261    fn allow_dangerous_html(&self) -> bool {
262        self.markdown_allow_dangerous_html
263    }
264
265    fn allow_dangerous_protocol(&self) -> bool {
266        self.markdown_allow_dangerous_protocol
267    }
268}
269
270/// Helper to render markdown with configurable options
271#[derive(Default)]
272struct MarkdownHelper {
273    allow_dangerous_html: bool,
274    allow_dangerous_protocol: bool,
275}
276
277impl MarkdownHelper {
278    fn new(config: &impl MarkdownConfig) -> Self {
279        Self {
280            allow_dangerous_html: config.allow_dangerous_html(),
281            allow_dangerous_protocol: config.allow_dangerous_protocol(),
282        }
283    }
284
285    fn get_preset_options(&self, preset_name: &str) -> Result<markdown::Options, String> {
286        let mut options = markdown::Options::gfm();
287        options.compile.allow_dangerous_html = self.allow_dangerous_html;
288        options.compile.allow_dangerous_protocol = self.allow_dangerous_protocol;
289        options.compile.allow_any_img_src = true;
290
291        match preset_name {
292            "default" => {}
293            "allow_unsafe" => {
294                options.compile.allow_dangerous_html = true;
295                options.compile.allow_dangerous_protocol = true;
296            }
297            _ => return Err(format!("unknown markdown preset: {preset_name}")),
298        }
299
300        Ok(options)
301    }
302}
303
304impl CanHelp for MarkdownHelper {
305    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
306        let (markdown_src_value, preset_name) = match args {
307            [v] => (v.value(), "default"),
308            [v, preset] => {
309                let value = v.value();
310                let preset_name_value = preset.value();
311                let preset = preset_name_value.as_str()
312                    .ok_or_else(|| format!("markdown template helper expects a string as preset name. Got: {preset_name_value}"))?;
313                (value, preset)
314            }
315            _ => return Err("markdown template helper expects one or two arguments".to_string()),
316        };
317        let markdown_src = match markdown_src_value {
318            JsonValue::String(s) => Cow::Borrowed(s),
319            JsonValue::Array(arr) => Cow::Owned(
320                arr.iter()
321                    .map(|v| v.as_str().unwrap_or_default())
322                    .collect::<Vec<_>>()
323                    .join("\n"),
324            ),
325            JsonValue::Null => Cow::Owned(String::new()),
326            other => Cow::Owned(other.to_string()),
327        };
328
329        let options = self.get_preset_options(preset_name)?;
330        markdown::to_html_with_options(&markdown_src, &options)
331            .map(JsonValue::String)
332            .map_err(|e| e.to_string())
333    }
334}
335
336fn buildinfo_helper(x: &JsonValue) -> anyhow::Result<JsonValue> {
337    match x {
338        JsonValue::String(s) if s == "CARGO_PKG_NAME" => Ok(env!("CARGO_PKG_NAME").into()),
339        JsonValue::String(s) if s == "CARGO_PKG_VERSION" => Ok(env!("CARGO_PKG_VERSION").into()),
340        other => Err(anyhow::anyhow!("unknown buildinfo key: {other:?}")),
341    }
342}
343
344// rfc2822_date: take an ISO date and convert it to an RFC 2822 date
345fn rfc2822_date_helper(v: &JsonValue) -> anyhow::Result<JsonValue> {
346    let date: chrono::DateTime<chrono::FixedOffset> = match v {
347        JsonValue::String(s) => {
348            // we accept both dates with and without time
349            chrono::DateTime::parse_from_rfc3339(s)
350                .or_else(|_| {
351                    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
352                        .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().fixed_offset())
353                })
354                .with_context(|| format!("invalid date: {s}"))?
355        }
356        JsonValue::Number(n) => {
357            chrono::DateTime::from_timestamp(n.as_i64().with_context(|| "not a timestamp")?, 0)
358                .with_context(|| "invalid timestamp")?
359                .into()
360        }
361        other => anyhow::bail!("expected a date, got {other:?}"),
362    };
363    // format: Thu, 01 Jan 1970 00:00:00 +0000
364    Ok(date.format("%a, %d %b %Y %T %z").to_string().into())
365}
366
367// Percent-encode a string
368fn url_encode_helper(v: &JsonValue) -> JsonValue {
369    let as_str = match v {
370        JsonValue::String(s) => s,
371        other => &other.to_string(),
372    };
373    percent_encoding::percent_encode(as_str.as_bytes(), percent_encoding::NON_ALPHANUMERIC)
374        .to_string()
375        .into()
376}
377
378// Percent-encode a string
379fn csv_escape_helper(v: &JsonValue, separator: &JsonValue) -> JsonValue {
380    let as_str = match v {
381        JsonValue::String(s) => s,
382        other => &other.to_string(),
383    };
384    let separator = separator.as_str().unwrap_or(",");
385    if as_str.contains(separator) || as_str.contains('"') || as_str.contains('\n') {
386        format!(r#""{}""#, as_str.replace('"', r#""""#)).into()
387    } else {
388        as_str.to_owned().into()
389    }
390}
391
392fn with_each_block<'a, 'reg, 'rc>(
393    rc: &'a mut handlebars::RenderContext<'reg, 'rc>,
394    mut action: impl FnMut(&mut handlebars::BlockContext<'rc>, bool) -> Result<(), RenderError>,
395) -> Result<(), RenderError> {
396    let mut blks = Vec::new();
397    while let Some(mut top) = rc.block_mut().map(std::mem::take) {
398        rc.pop_block();
399        action(&mut top, rc.block().is_none())?;
400        blks.push(top);
401    }
402    while let Some(blk) = blks.pop() {
403        rc.push_block(blk);
404    }
405    Ok(())
406}
407
408pub(crate) const DELAYED_CONTENTS: &str = "_delayed_contents";
409
410fn delay_helper<'reg, 'rc>(
411    h: &handlebars::Helper<'rc>,
412    r: &'reg Handlebars<'reg>,
413    ctx: &'rc Context,
414    rc: &mut handlebars::RenderContext<'reg, 'rc>,
415    _out: &mut dyn handlebars::Output,
416) -> handlebars::HelperResult {
417    let inner = h
418        .template()
419        .ok_or(RenderErrorReason::BlockContentRequired)?;
420    let mut str_out = handlebars::StringOutput::new();
421    inner.render(r, ctx, rc, &mut str_out)?;
422    let mut delayed_render = str_out.into_string()?;
423    with_each_block(rc, |block, is_last| {
424        if is_last {
425            let old_delayed_render = block
426                .get_local_var(DELAYED_CONTENTS)
427                .and_then(JsonValue::as_str)
428                .unwrap_or_default();
429            delayed_render += old_delayed_render;
430            let contents = JsonValue::String(std::mem::take(&mut delayed_render));
431            block.set_local_var(DELAYED_CONTENTS, contents);
432        }
433        Ok(())
434    })?;
435    Ok(())
436}
437
438fn flush_delayed_helper<'reg, 'rc>(
439    _h: &handlebars::Helper<'rc>,
440    _r: &'reg Handlebars<'reg>,
441    _ctx: &'rc Context,
442    rc: &mut handlebars::RenderContext<'reg, 'rc>,
443    writer: &mut dyn handlebars::Output,
444) -> handlebars::HelperResult {
445    with_each_block(rc, |block_context, _last| {
446        let delayed = block_context
447            .get_local_var(DELAYED_CONTENTS)
448            .and_then(JsonValue::as_str)
449            .filter(|s| !s.is_empty());
450        if let Some(contents) = delayed {
451            writer.write(contents)?;
452            block_context.set_local_var(DELAYED_CONTENTS, JsonValue::Null);
453        }
454        Ok(())
455    })
456}
457
458fn sum_helper<'reg, 'rc>(
459    helper: &handlebars::Helper<'rc>,
460    _r: &'reg Handlebars<'reg>,
461    _ctx: &'rc Context,
462    _rc: &mut handlebars::RenderContext<'reg, 'rc>,
463    writer: &mut dyn handlebars::Output,
464) -> handlebars::HelperResult {
465    let mut sum = 0f64;
466    for v in helper.params() {
467        sum += v
468            .value()
469            .as_f64()
470            .ok_or(RenderErrorReason::InvalidParamType("number"))?;
471    }
472    write!(writer, "{sum}")?;
473    Ok(())
474}
475
476/// Compare two values loosely, i.e. treat all values as strings. (42 == "42")
477fn loose_eq_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
478    match (a, b) {
479        (JsonValue::String(a), JsonValue::String(b)) => a == b,
480        (JsonValue::String(a), non_str) => a == &non_str.to_string(),
481        (non_str, JsonValue::String(b)) => &non_str.to_string() == b,
482        (a, b) => a == b,
483    }
484    .into()
485}
486/// Helper that returns the first argument with the given truthiness, or the last argument if none have it.
487/// Equivalent to a && b && c && ... if the truthiness is false,
488/// or a || b || c || ... if the truthiness is true.
489pub struct HelperCheckTruthy(bool);
490
491impl CanHelp for HelperCheckTruthy {
492    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
493        for arg in args {
494            if arg.value().is_truthy(false) == self.0 {
495                return Ok(arg.value().clone());
496            }
497        }
498        if let Some(last) = args.last() {
499            Ok(last.value().clone())
500        } else {
501            Err("expected at least one argument".to_string())
502        }
503    }
504}
505
506trait CanHelp: Send + Sync + 'static {
507    fn call(&self, v: &[PathAndJson]) -> Result<JsonValue, String>;
508}
509
510impl CanHelp for H0 {
511    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
512        match args {
513            [] => Ok(self()),
514            _ => Err("expected no arguments".to_string()),
515        }
516    }
517}
518
519impl CanHelp for H {
520    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
521        match args {
522            [v] => Ok(self(v.value())),
523            _ => Err("expected one argument".to_string()),
524        }
525    }
526}
527
528impl CanHelp for EH {
529    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
530        match args {
531            [v] => self(v.value()).map_err(|e| e.to_string()),
532            _ => Err("expected one argument".to_string()),
533        }
534    }
535}
536
537impl CanHelp for HH {
538    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
539        match args {
540            [a, b] => Ok(self(a.value(), b.value())),
541            _ => Err("expected two arguments".to_string()),
542        }
543    }
544}
545
546impl CanHelp for HHH {
547    fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
548        match args {
549            [a, b, c] => Ok(self(a.value(), b.value(), c.value())),
550            _ => Err("expected three arguments".to_string()),
551        }
552    }
553}
554
555struct JFun<F: CanHelp> {
556    name: &'static str,
557    fun: F,
558}
559impl<F: CanHelp> handlebars::HelperDef for JFun<F> {
560    fn call_inner<'reg: 'rc, 'rc>(
561        &self,
562        helper: &handlebars::Helper<'rc>,
563        _r: &'reg Handlebars<'reg>,
564        _: &'rc Context,
565        _rc: &mut handlebars::RenderContext<'reg, 'rc>,
566    ) -> Result<handlebars::ScopedJson<'rc>, RenderError> {
567        let result = self
568            .fun
569            .call(helper.params().as_slice())
570            .map_err(|s| RenderErrorReason::Other(format!("{}: {}", self.name, s)))?;
571        Ok(ScopedJson::Derived(result))
572    }
573}
574
575fn register_helper(h: &mut Handlebars, name: &'static str, fun: impl CanHelp) {
576    h.register_helper(name, Box::new(JFun { name, fun }));
577}
578
579fn replace_helper(text: &JsonValue, original: &JsonValue, replacement: &JsonValue) -> JsonValue {
580    let text_str = match text {
581        JsonValue::String(s) => s,
582        other => &other.to_string(),
583    };
584    let original_str = match original {
585        JsonValue::String(s) => s,
586        other => &other.to_string(),
587    };
588    let replacement_str = match replacement {
589        JsonValue::String(s) => s,
590        other => &other.to_string(),
591    };
592
593    text_str.replace(original_str, replacement_str).into()
594}
595
596#[cfg(test)]
597mod tests {
598    use crate::template_helpers::{rfc2822_date_helper, CanHelp, MarkdownHelper};
599    use handlebars::{JsonValue, PathAndJson, ScopedJson};
600    use serde_json::Value;
601
602    const CONTENT_KEY: &str = "contents_md";
603
604    #[test]
605    fn test_rfc2822_date() {
606        assert_eq!(
607            rfc2822_date_helper(&JsonValue::String("1970-01-02T03:04:05+02:00".into()))
608                .unwrap()
609                .as_str()
610                .unwrap(),
611            "Fri, 02 Jan 1970 03:04:05 +0200"
612        );
613        assert_eq!(
614            rfc2822_date_helper(&JsonValue::String("1970-01-02".into()))
615                .unwrap()
616                .as_str()
617                .unwrap(),
618            "Fri, 02 Jan 1970 00:00:00 +0000"
619        );
620    }
621
622    #[test]
623    fn test_basic_gfm_markdown() {
624        let helper = MarkdownHelper::default();
625
626        let contents = Value::String("# Heading".to_string());
627        let actual = helper.call(&as_args(&contents)).unwrap();
628
629        assert_eq!(Some("<h1>Heading</h1>"), actual.as_str());
630    }
631
632    // Optionally allow potentially unsafe html blocks
633    // See https://spec.commonmark.org/0.31.2/#html-blocks
634    mod markdown_html_blocks {
635
636        use super::*;
637
638        const UNSAFE_MARKUP: &str = "<table><tr><td>";
639        const ESCAPED_UNSAFE_MARKUP: &str = "&lt;table&gt;&lt;tr&gt;&lt;td&gt;";
640        #[test]
641        fn test_html_blocks_with_various_settings() {
642            struct TestCase {
643                name: &'static str,
644                preset: Option<Value>,
645                expected_output: Result<&'static str, String>,
646            }
647
648            let helper = MarkdownHelper::default();
649            let content = contents();
650
651            let test_cases = [
652                TestCase {
653                    name: "default settings",
654                    preset: Some(Value::String("default".to_string())),
655                    expected_output: Ok(ESCAPED_UNSAFE_MARKUP),
656                },
657                TestCase {
658                    name: "allow_unsafe preset",
659                    preset: Some(Value::String("allow_unsafe".to_string())),
660                    expected_output: Ok(UNSAFE_MARKUP),
661                },
662                TestCase {
663                    name: "undefined allow_unsafe",
664                    preset: Some(Value::Null),
665                    expected_output: Err(
666                        "markdown template helper expects a string as preset name. Got: null"
667                            .to_string(),
668                    ),
669                },
670                TestCase {
671                    name: "allow_unsafe is false",
672                    preset: Some(Value::Bool(false)),
673                    expected_output: Err(
674                        "markdown template helper expects a string as preset name. Got: false"
675                            .to_string(),
676                    ),
677                },
678            ];
679
680            for case in test_cases {
681                let args = match case.preset {
682                    None => &as_args(&content)[..],
683                    Some(ref preset) => &as_args_with_unsafe(&content, preset)[..],
684                };
685
686                match helper.call(args) {
687                    Ok(actual) => assert_eq!(
688                        case.expected_output.unwrap(),
689                        actual.as_str().unwrap(),
690                        "Failed on case: {}",
691                        case.name
692                    ),
693                    Err(e) => assert_eq!(
694                        case.expected_output.unwrap_err(),
695                        e,
696                        "Failed on case: {}",
697                        case.name
698                    ),
699                }
700            }
701        }
702
703        fn as_args_with_unsafe<'a>(
704            contents: &'a Value,
705            allow_unsafe: &'a Value,
706        ) -> [PathAndJson<'a>; 2] {
707            [
708                as_helper_arg(CONTENT_KEY, contents),
709                as_helper_arg("allow_unsafe", allow_unsafe),
710            ]
711        }
712
713        fn contents() -> Value {
714            Value::String(UNSAFE_MARKUP.to_string())
715        }
716    }
717
718    fn as_args(contents: &Value) -> [PathAndJson<'_>; 1] {
719        [as_helper_arg(CONTENT_KEY, contents)]
720    }
721
722    fn as_helper_arg<'a>(path: &'a str, value: &'a Value) -> PathAndJson<'a> {
723        let json_context = as_json_context(path, value);
724        to_path_and_json(path, json_context)
725    }
726
727    fn to_path_and_json<'a>(path: &'a str, value: ScopedJson<'a>) -> PathAndJson<'a> {
728        PathAndJson::new(Some(path.to_string()), value)
729    }
730
731    fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> {
732        ScopedJson::Context(value, vec![path.to_string()])
733    }
734}