| //! Handlebars template processing. |
| |
| use std::collections::HashMap; |
| use std::path::Path; |
| |
| use anyhow::Error; |
| use handlebars::{ |
| handlebars_helper, Context, Decorator, DirectorySourceOptions, Handlebars, Helper, HelperDef, |
| HelperResult, Output, RenderContext, RenderError, RenderErrorReason, Renderable, |
| }; |
| |
| use crate::format::Formatter; |
| |
| type FormatterRef<'a> = &'a (dyn Formatter + Send + Sync); |
| |
| /// Processes the handlebars template at the given file. |
| pub fn expand(file: &Path, formatter: FormatterRef<'_>) -> Result<String, Error> { |
| let mut handlebars = Handlebars::new(); |
| handlebars.set_strict_mode(true); |
| handlebars.register_helper("lower", Box::new(lower)); |
| handlebars.register_helper("options", Box::new(OptionsHelper { formatter })); |
| handlebars.register_helper("option", Box::new(OptionHelper { formatter })); |
| handlebars.register_helper("man", Box::new(ManLinkHelper { formatter })); |
| handlebars.register_decorator("set", Box::new(set_decorator)); |
| handlebars.register_template_file("template", file)?; |
| let includes = file.parent().unwrap().join("includes"); |
| let mut options = DirectorySourceOptions::default(); |
| options.tpl_extension = ".md".to_string(); |
| handlebars.register_templates_directory(includes, options)?; |
| let man_name = file |
| .file_stem() |
| .expect("expected filename") |
| .to_str() |
| .expect("utf8 filename") |
| .to_string(); |
| let data = HashMap::from([("man_name", man_name)]); |
| let expanded = handlebars.render("template", &data)?; |
| Ok(expanded) |
| } |
| |
| /// Helper for `{{#options}}` block. |
| struct OptionsHelper<'a> { |
| formatter: FormatterRef<'a>, |
| } |
| |
| impl HelperDef for OptionsHelper<'_> { |
| fn call<'reg: 'rc, 'rc>( |
| &self, |
| h: &Helper<'rc>, |
| r: &'reg Handlebars<'reg>, |
| ctx: &'rc Context, |
| rc: &mut RenderContext<'reg, 'rc>, |
| out: &mut dyn Output, |
| ) -> HelperResult { |
| if in_options(rc) { |
| return Err( |
| RenderErrorReason::Other("options blocks cannot be nested".to_string()).into(), |
| ); |
| } |
| // Prevent nested {{#options}}. |
| set_in_context(rc, "__MDMAN_IN_OPTIONS", serde_json::Value::Bool(true)); |
| let s = self.formatter.render_options_start(); |
| out.write(&s)?; |
| let t = match h.template() { |
| Some(t) => t, |
| None => { |
| return Err(RenderErrorReason::Other( |
| "options block must not be empty".to_string(), |
| ) |
| .into()); |
| } |
| }; |
| let block = t.renders(r, ctx, rc)?; |
| out.write(&block)?; |
| |
| let s = self.formatter.render_options_end(); |
| out.write(&s)?; |
| remove_from_context(rc, "__MDMAN_IN_OPTIONS"); |
| Ok(()) |
| } |
| } |
| |
| /// Whether or not the context is currently inside a `{{#options}}` block. |
| fn in_options(rc: &RenderContext<'_, '_>) -> bool { |
| rc.context() |
| .map_or(false, |ctx| ctx.data().get("__MDMAN_IN_OPTIONS").is_some()) |
| } |
| |
| /// Helper for `{{#option}}` block. |
| struct OptionHelper<'a> { |
| formatter: FormatterRef<'a>, |
| } |
| |
| impl HelperDef for OptionHelper<'_> { |
| fn call<'reg: 'rc, 'rc>( |
| &self, |
| h: &Helper<'rc>, |
| r: &'reg Handlebars<'reg>, |
| gctx: &'rc Context, |
| rc: &mut RenderContext<'reg, 'rc>, |
| out: &mut dyn Output, |
| ) -> HelperResult { |
| if !in_options(rc) { |
| return Err( |
| RenderErrorReason::Other("option must be in options block".to_string()).into(), |
| ); |
| } |
| let params = h.params(); |
| if params.is_empty() { |
| return Err(RenderErrorReason::Other( |
| "option block must have at least one param".to_string(), |
| ) |
| .into()); |
| } |
| // Convert params to strings. |
| let params = params |
| .iter() |
| .map(|param| { |
| param |
| .value() |
| .as_str() |
| .ok_or_else(|| { |
| RenderErrorReason::Other("option params must be strings".to_string()) |
| }) |
| .into() |
| }) |
| .collect::<Result<Vec<&str>, RenderErrorReason>>()?; |
| let t = match h.template() { |
| Some(t) => t, |
| None => { |
| return Err( |
| RenderErrorReason::Other("option block must not be empty".to_string()).into(), |
| ); |
| } |
| }; |
| // Render the block. |
| let block = t.renders(r, gctx, rc)?; |
| |
| // Windows newlines can break some rendering, so normalize. |
| let block = block.replace("\r\n", "\n"); |
| |
| // Get the name of this page. |
| let man_name = gctx |
| .data() |
| .get("man_name") |
| .expect("expected man_name in context") |
| .as_str() |
| .expect("expect man_name str"); |
| |
| // Ask the formatter to convert this option to its format. |
| let option = self |
| .formatter |
| .render_option(¶ms, &block, man_name) |
| .map_err(|e| RenderErrorReason::Other(format!("option render failed: {}", e)))?; |
| out.write(&option)?; |
| Ok(()) |
| } |
| } |
| |
| /// Helper for `{{man name section}}` expression. |
| struct ManLinkHelper<'a> { |
| formatter: FormatterRef<'a>, |
| } |
| |
| impl HelperDef for ManLinkHelper<'_> { |
| fn call<'reg: 'rc, 'rc>( |
| &self, |
| h: &Helper<'rc>, |
| _r: &'reg Handlebars<'reg>, |
| _ctx: &'rc Context, |
| _rc: &mut RenderContext<'reg, 'rc>, |
| out: &mut dyn Output, |
| ) -> HelperResult { |
| let params = h.params(); |
| if params.len() != 2 { |
| return Err( |
| RenderErrorReason::Other("{{man}} must have two arguments".to_string()).into(), |
| ); |
| } |
| let name = params[0].value().as_str().ok_or_else(|| { |
| RenderErrorReason::Other("man link name must be a string".to_string()) |
| })?; |
| let section = params[1].value().as_u64().ok_or_else(|| { |
| RenderErrorReason::Other("man link section must be an integer".to_string()) |
| })?; |
| let section = u8::try_from(section) |
| .map_err(|_e| RenderErrorReason::Other("section number too large".to_string()))?; |
| let link = self |
| .formatter |
| .linkify_man_to_md(name, section) |
| .map_err(|e| RenderErrorReason::Other(format!("failed to linkify man: {}", e)))?; |
| out.write(&link)?; |
| Ok(()) |
| } |
| } |
| |
| /// `{{*set var=value}}` decorator. |
| /// |
| /// This sets a variable to a value within the template context. |
| fn set_decorator( |
| d: &Decorator<'_>, |
| _: &Handlebars<'_>, |
| _ctx: &Context, |
| rc: &mut RenderContext<'_, '_>, |
| ) -> Result<(), RenderError> { |
| let data_to_set = d.hash(); |
| for (k, v) in data_to_set { |
| set_in_context(rc, k, v.value().clone()); |
| } |
| Ok(()) |
| } |
| |
| /// Sets a variable to a value within the context. |
| fn set_in_context(rc: &mut RenderContext<'_, '_>, key: &str, value: serde_json::Value) { |
| let mut gctx = match rc.context() { |
| Some(c) => (*c).clone(), |
| None => Context::wraps(serde_json::Value::Object(serde_json::Map::new())).unwrap(), |
| }; |
| if let serde_json::Value::Object(m) = gctx.data_mut() { |
| m.insert(key.to_string(), value); |
| rc.set_context(gctx); |
| } else { |
| panic!("expected object in context"); |
| } |
| } |
| |
| /// Removes a variable from the context. |
| fn remove_from_context(rc: &mut RenderContext<'_, '_>, key: &str) { |
| let gctx = rc.context().expect("cannot remove from null context"); |
| let mut gctx = (*gctx).clone(); |
| if let serde_json::Value::Object(m) = gctx.data_mut() { |
| m.remove(key); |
| rc.set_context(gctx); |
| } else { |
| panic!("expected object in context"); |
| } |
| } |
| |
| handlebars_helper!(lower: |s: str| s.to_lowercase()); |