mdman/
hbs.rs

1//! Handlebars template processing.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::Error;
7use handlebars::{
8    handlebars_helper, Context, Decorator, DirectorySourceOptions, Handlebars, Helper, HelperDef,
9    HelperResult, Output, RenderContext, RenderError, RenderErrorReason, Renderable,
10};
11
12use crate::format::Formatter;
13
14type FormatterRef<'a> = &'a (dyn Formatter + Send + Sync);
15
16/// Processes the handlebars template at the given file.
17pub fn expand(file: &Path, formatter: FormatterRef<'_>) -> Result<String, Error> {
18    let mut handlebars = Handlebars::new();
19    handlebars.set_strict_mode(true);
20    handlebars.register_helper("lower", Box::new(lower));
21    handlebars.register_helper("options", Box::new(OptionsHelper { formatter }));
22    handlebars.register_helper("option", Box::new(OptionHelper { formatter }));
23    handlebars.register_helper("man", Box::new(ManLinkHelper { formatter }));
24    handlebars.register_decorator("set", Box::new(set_decorator));
25    handlebars.register_template_file("template", file)?;
26    let includes = file.parent().unwrap().join("includes");
27    let mut options = DirectorySourceOptions::default();
28    options.tpl_extension = ".md".to_string();
29    handlebars.register_templates_directory(includes, options)?;
30    let man_name = file
31        .file_stem()
32        .expect("expected filename")
33        .to_str()
34        .expect("utf8 filename")
35        .to_string();
36    let data = HashMap::from([("man_name", man_name)]);
37    let expanded = handlebars.render("template", &data)?;
38    Ok(expanded)
39}
40
41/// Helper for `{{#options}}` block.
42struct OptionsHelper<'a> {
43    formatter: FormatterRef<'a>,
44}
45
46impl HelperDef for OptionsHelper<'_> {
47    fn call<'reg: 'rc, 'rc>(
48        &self,
49        h: &Helper<'rc>,
50        r: &'reg Handlebars<'reg>,
51        ctx: &'rc Context,
52        rc: &mut RenderContext<'reg, 'rc>,
53        out: &mut dyn Output,
54    ) -> HelperResult {
55        if in_options(rc) {
56            return Err(
57                RenderErrorReason::Other("options blocks cannot be nested".to_string()).into(),
58            );
59        }
60        // Prevent nested {{#options}}.
61        set_in_context(rc, "__MDMAN_IN_OPTIONS", serde_json::Value::Bool(true));
62        let s = self.formatter.render_options_start();
63        out.write(&s)?;
64        let t = match h.template() {
65            Some(t) => t,
66            None => {
67                return Err(RenderErrorReason::Other(
68                    "options block must not be empty".to_string(),
69                )
70                .into());
71            }
72        };
73        let block = t.renders(r, ctx, rc)?;
74        out.write(&block)?;
75
76        let s = self.formatter.render_options_end();
77        out.write(&s)?;
78        remove_from_context(rc, "__MDMAN_IN_OPTIONS");
79        Ok(())
80    }
81}
82
83/// Whether or not the context is currently inside a `{{#options}}` block.
84fn in_options(rc: &RenderContext<'_, '_>) -> bool {
85    rc.context()
86        .map_or(false, |ctx| ctx.data().get("__MDMAN_IN_OPTIONS").is_some())
87}
88
89/// Helper for `{{#option}}` block.
90struct OptionHelper<'a> {
91    formatter: FormatterRef<'a>,
92}
93
94impl HelperDef for OptionHelper<'_> {
95    fn call<'reg: 'rc, 'rc>(
96        &self,
97        h: &Helper<'rc>,
98        r: &'reg Handlebars<'reg>,
99        gctx: &'rc Context,
100        rc: &mut RenderContext<'reg, 'rc>,
101        out: &mut dyn Output,
102    ) -> HelperResult {
103        if !in_options(rc) {
104            return Err(
105                RenderErrorReason::Other("option must be in options block".to_string()).into(),
106            );
107        }
108        let params = h.params();
109        if params.is_empty() {
110            return Err(RenderErrorReason::Other(
111                "option block must have at least one param".to_string(),
112            )
113            .into());
114        }
115        // Convert params to strings.
116        let params = params
117            .iter()
118            .map(|param| {
119                param
120                    .value()
121                    .as_str()
122                    .ok_or_else(|| {
123                        RenderErrorReason::Other("option params must be strings".to_string())
124                    })
125                    .into()
126            })
127            .collect::<Result<Vec<&str>, RenderErrorReason>>()?;
128        let t = match h.template() {
129            Some(t) => t,
130            None => {
131                return Err(
132                    RenderErrorReason::Other("option block must not be empty".to_string()).into(),
133                );
134            }
135        };
136        // Render the block.
137        let block = t.renders(r, gctx, rc)?;
138
139        // Windows newlines can break some rendering, so normalize.
140        let block = block.replace("\r\n", "\n");
141
142        // Get the name of this page.
143        let man_name = gctx
144            .data()
145            .get("man_name")
146            .expect("expected man_name in context")
147            .as_str()
148            .expect("expect man_name str");
149
150        // Ask the formatter to convert this option to its format.
151        let option = self
152            .formatter
153            .render_option(&params, &block, man_name)
154            .map_err(|e| RenderErrorReason::Other(format!("option render failed: {}", e)))?;
155        out.write(&option)?;
156        Ok(())
157    }
158}
159
160/// Helper for `{{man name section}}` expression.
161struct ManLinkHelper<'a> {
162    formatter: FormatterRef<'a>,
163}
164
165impl HelperDef for ManLinkHelper<'_> {
166    fn call<'reg: 'rc, 'rc>(
167        &self,
168        h: &Helper<'rc>,
169        _r: &'reg Handlebars<'reg>,
170        _ctx: &'rc Context,
171        _rc: &mut RenderContext<'reg, 'rc>,
172        out: &mut dyn Output,
173    ) -> HelperResult {
174        let params = h.params();
175        if params.len() != 2 {
176            return Err(
177                RenderErrorReason::Other("{{man}} must have two arguments".to_string()).into(),
178            );
179        }
180        let name = params[0].value().as_str().ok_or_else(|| {
181            RenderErrorReason::Other("man link name must be a string".to_string())
182        })?;
183        let section = params[1].value().as_u64().ok_or_else(|| {
184            RenderErrorReason::Other("man link section must be an integer".to_string())
185        })?;
186        let section = u8::try_from(section)
187            .map_err(|_e| RenderErrorReason::Other("section number too large".to_string()))?;
188        let link = self
189            .formatter
190            .linkify_man_to_md(name, section)
191            .map_err(|e| RenderErrorReason::Other(format!("failed to linkify man: {}", e)))?;
192        out.write(&link)?;
193        Ok(())
194    }
195}
196
197/// `{{*set var=value}}` decorator.
198///
199/// This sets a variable to a value within the template context.
200fn set_decorator(
201    d: &Decorator<'_>,
202    _: &Handlebars<'_>,
203    _ctx: &Context,
204    rc: &mut RenderContext<'_, '_>,
205) -> Result<(), RenderError> {
206    let data_to_set = d.hash();
207    for (k, v) in data_to_set {
208        set_in_context(rc, k, v.value().clone());
209    }
210    Ok(())
211}
212
213/// Sets a variable to a value within the context.
214fn set_in_context(rc: &mut RenderContext<'_, '_>, key: &str, value: serde_json::Value) {
215    let mut gctx = match rc.context() {
216        Some(c) => (*c).clone(),
217        None => Context::wraps(serde_json::Value::Object(serde_json::Map::new())).unwrap(),
218    };
219    if let serde_json::Value::Object(m) = gctx.data_mut() {
220        m.insert(key.to_string(), value);
221        rc.set_context(gctx);
222    } else {
223        panic!("expected object in context");
224    }
225}
226
227/// Removes a variable from the context.
228fn remove_from_context(rc: &mut RenderContext<'_, '_>, key: &str) {
229    let gctx = rc.context().expect("cannot remove from null context");
230    let mut gctx = (*gctx).clone();
231    if let serde_json::Value::Object(m) = gctx.data_mut() {
232        m.remove(key);
233        rc.set_context(gctx);
234    } else {
235        panic!("expected object in context");
236    }
237}
238
239handlebars_helper!(lower: |s: str| s.to_lowercase());