quokka/state/
templating.rs

1use std::{collections::BTreeMap, marker::PhantomData};
2
3#[cfg(feature = "markdown")]
4use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Tag, TagEnd};
5use rust_embed::Embed;
6
7use crate::{config::TryFromModule, Error, Result};
8
9use super::FromState;
10
11///
12/// Register template sources and use them inside your handlers to render HTML or whatever your templates will output
13///
14/// See the functions docs or the [handlebars] crate for more info
15///
16#[derive(Clone, Debug)]
17pub struct Templating {
18    registry: Box<handlebars::Handlebars<'static>>,
19}
20
21impl Templating {
22    pub fn try_new() -> Result<Self> {
23        use serde_json::Value;
24
25        let mut registry = handlebars::Handlebars::default();
26        registry.register_helper("version", Box::new(VersionHelper));
27        registry.register_helper("concat", Box::new(ConcatHelper));
28        registry.register_helper("debug", Box::new(debug));
29        registry.register_helper("len", Box::new(len));
30        registry.register_helper("is_empty", Box::new(is_empty));
31        registry.register_helper("add", Box::new(MathHelper::<DoAddMath>::default()));
32        registry.register_helper("sub", Box::new(MathHelper::<DoSubMath>::default()));
33        registry.register_helper("mul", Box::new(MathHelper::<DoMulMath>::default()));
34        registry.register_helper("div", Box::new(MathHelper::<DoDivMath>::default()));
35        registry.register_helper("array", Box::new(ArrayHelper));
36        registry.register_helper("object", Box::new(ObjectHelper));
37        #[cfg(feature = "markdown")]
38        registry.register_helper("markdown", Box::new(render_markdown));
39        #[cfg(feature = "markdown")]
40        registry.register_helper("safe_markdown", Box::new(render_escaped_markdown));
41
42        handlebars::handlebars_helper!(debug: |value: Value| { tracing::debug!("{value:#?}") });
43        handlebars::handlebars_helper!(len: |value: Value| { match value {
44            Value::String(str) => str.len(),
45            Value::Array(vec) => vec.len(),
46            Value::Object(map) => map.len(),
47            value => value.to_string().len()
48        } });
49        handlebars::handlebars_helper!(is_empty: |value: Value| { match value {
50            Value::String(str) => str.is_empty(),
51            Value::Array(vec) => vec.is_empty(),
52            Value::Object(map) => map.is_empty(),
53            value => value.to_string().is_empty()
54        } });
55
56        #[cfg(debug_assertions)]
57        registry.set_dev_mode(true);
58
59        Ok(Self {
60            registry: registry.into(),
61        })
62    }
63
64    ///
65    /// Registers a template source from a struct that implement [rust_embed::Embed].
66    ///
67    /// # Example
68    ///
69    /// ```
70    /// use rust_embed::Embed as RustEmbed;
71    /// use quokka::state::Templating;
72    ///
73    /// #[derive(RustEmbed)]
74    /// #[folder = "test/templates"]
75    /// #[include = "*.hbs"]
76    /// struct Templates;
77    ///
78    /// let mut tpl = Templating::try_new().unwrap();
79    /// tpl.register_embedded_templates::<Templates>().unwrap();
80    /// ```
81    pub fn register_embedded_templates<E: Embed + 'static>(&mut self) -> Result<()> {
82        self.registry
83            .register_embed_templates::<E>()
84            .map_err(Error::wrap_error("Unable to load templates", 500))?;
85
86        Ok(())
87    }
88
89    ///
90    /// Registers a template source from a [rust_embed::Embed] struct.
91    ///
92    /// This function creates "aliases" for partials by stripping away the top-level path until
93    /// "partials", strips of the file extension and (if applicable) omits the last level of the
94    /// path if it matches the directory name.
95    ///
96    /// # Example
97    ///
98    /// ```
99    /// use rust_embed::Embed as RustEmbed;
100    /// use quokka::state::Templating;
101    ///
102    /// #[derive(RustEmbed)]
103    /// #[folder = "test/templates"]
104    /// #[include = "*.hbs"]
105    /// struct Templates;
106    ///
107    /// let mut tpl = Templating::try_new().unwrap();
108    /// tpl.register_embedded_templates_aliased_partials::<Templates>().unwrap();
109    ///
110    /// assert_eq!(tpl.render("tests/partials/test/test.html.hbs", &()).unwrap(), "Hello World!\n");
111    /// assert_eq!(tpl.render("partials/test", &()).unwrap(), "Hello World!\n");
112    /// assert_eq!(tpl.render("tests/partials/test", &()).unwrap(), "Hello World!\n");
113    /// assert!(tpl.render("partials/test/test", &()).is_err());
114    /// ```
115    ///
116    #[tracing::instrument(skip(self))]
117    pub fn register_embedded_templates_aliased_partials<E: Embed + 'static>(
118        &mut self,
119    ) -> Result<()> {
120        #[cfg(debug_assertions)]
121        self.registry
122            .register_embed_templates::<AliasedEmbed<E>>()
123            .map_err(Error::wrap_error("Unable to load templates", 500))?;
124
125        #[cfg(not(debug_assertions))]
126        self.registry
127            .register_embed_templates::<E>()
128            .map_err(Error::wrap_error("Unable to load templates", 500))?;
129
130        #[cfg(not(debug_assertions))]
131        for path in E::iter() {
132            let template = self
133                .registry
134                .get_template(&path)
135                .ok_or(Error::new("Unable to find template"))?
136                .clone();
137
138            for variant in get_partials_path_mutations(path.to_string()) {
139                self.registry.register_template(&variant, template.clone());
140            }
141        }
142
143        Ok(())
144    }
145
146    ///
147    /// Registers an inline template
148    ///
149    /// # Example
150    ///
151    /// ```
152    /// use quokka::state::Templating;
153    ///
154    /// let mut tpl = Templating::try_new().unwrap();
155    ///
156    /// assert!(tpl.render("test", &()).is_err());
157    ///
158    /// tpl.register_inline_template("test", "Hello World\n").unwrap();
159    ///
160    /// assert_eq!(tpl.render("test", &()).unwrap(), "Hello World\n");
161    /// ```
162    ///
163    pub fn register_inline_template(&mut self, name: &str, template: &str) -> Result<()> {
164        self.registry
165            .register_template_string(name, template)
166            .map_err(Error::wrap_error("Unable to register template string", 500))?;
167
168        Ok(())
169    }
170
171    ///
172    /// Register a handlebars helper
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use quokka::state::Templating;
178    /// use handlebars::handlebars_helper;
179    ///
180    /// handlebars_helper!{ greet: |name: String| format!("Hello {name}") }
181    ///
182    /// let mut tpl = Templating::try_new().unwrap();
183    /// tpl.register_inline_template("test", "{{greet \"World\"}}").unwrap();
184    ///
185    /// assert!(tpl.render("test", &()).is_err());
186    ///
187    /// tpl.register_helper("greet", greet);
188    ///
189    /// assert_eq!(tpl.render("test", &()).unwrap(), "Hello World");
190    /// ```
191    ///
192    pub fn register_helper<H: handlebars::HelperDef + Send + Sync + 'static, B: Into<Box<H>>>(
193        &mut self,
194        name: &str,
195        helper: B,
196    ) {
197        self.registry.register_helper(name, helper.into());
198    }
199
200    ///
201    /// Renders a template using the given context
202    ///
203    /// # Example
204    ///
205    /// ```
206    /// use quokka::state::Templating;
207    ///
208    /// let mut tpl = Templating::try_new().unwrap();
209    ///
210    /// assert!(tpl.render("test", &()).is_err());
211    ///
212    /// tpl.register_inline_template("test", "Hello World\n").unwrap();
213    ///
214    /// assert_eq!(tpl.render("test", &()).unwrap(), "Hello World\n");
215    /// ```
216    ///
217    pub fn render<S: serde::Serialize>(&self, template: &str, context: &S) -> Result<String> {
218        self.registry
219            .render(template, context)
220            .map_err(Error::wrap_error("Unable to render template", 500))
221    }
222}
223
224struct VersionHelper;
225
226impl handlebars::HelperDef for VersionHelper {
227    fn call<'reg: 'rc, 'rc>(
228        &self,
229        _: &handlebars::Helper,
230        _: &handlebars::Handlebars,
231        _: &handlebars::Context,
232        _: &mut handlebars::RenderContext,
233        out: &mut dyn handlebars::Output,
234    ) -> handlebars::HelperResult {
235        let version = clap::crate_version!();
236        out.write(version)?;
237
238        Ok(())
239    }
240}
241
242struct ConcatHelper;
243
244impl handlebars::HelperDef for ConcatHelper {
245    fn call<'reg: 'rc, 'rc>(
246        &self,
247        helper: &handlebars::Helper,
248        _: &handlebars::Handlebars,
249        _: &handlebars::Context,
250        _: &mut handlebars::RenderContext,
251        out: &mut dyn handlebars::Output,
252    ) -> handlebars::HelperResult {
253        let value = helper
254            .params()
255            .iter()
256            .map(|value| match value.value() {
257                serde_json::Value::String(value) => value.clone(),
258                value => value.to_string(),
259            })
260            .collect::<String>();
261
262        out.write(&value)?;
263
264        Ok(())
265    }
266}
267
268struct ArrayHelper;
269
270impl handlebars::HelperDef for ArrayHelper {
271    fn call_inner<'reg: 'rc, 'rc>(
272        &self,
273        helper: &handlebars::Helper,
274        _: &handlebars::Handlebars,
275        _: &handlebars::Context,
276        _: &mut handlebars::RenderContext,
277    ) -> std::result::Result<handlebars::ScopedJson<'rc>, handlebars::RenderError> {
278        let value = helper
279            .params()
280            .iter()
281            .map(|value| value.value().clone())
282            .collect::<Vec<_>>();
283
284        Ok(handlebars::ScopedJson::Derived(serde_json::Value::Array(
285            value,
286        )))
287    }
288}
289
290struct ObjectHelper;
291
292impl handlebars::HelperDef for ObjectHelper {
293    fn call_inner<'reg: 'rc, 'rc>(
294        &self,
295        helper: &handlebars::Helper,
296        _: &handlebars::Handlebars,
297        _: &handlebars::Context,
298        _: &mut handlebars::RenderContext,
299    ) -> std::result::Result<handlebars::ScopedJson<'rc>, handlebars::RenderError> {
300        let hash = helper
301            .hash()
302            .iter()
303            .map(|(key, value)| (*key, value.value()))
304            .collect::<BTreeMap<&str, &serde_json::Value>>();
305
306        Ok(handlebars::ScopedJson::Derived(
307            serde_json::to_value(hash).unwrap_or_default(),
308        ))
309    }
310}
311
312trait DoMath<N> {
313    fn do_math(a: N, b: N) -> N;
314}
315
316#[derive(Default)]
317struct DoAddMath;
318
319impl<T: std::ops::Add<T, Output = T>> DoMath<T> for DoAddMath {
320    fn do_math(a: T, b: T) -> T {
321        a + b
322    }
323}
324
325#[derive(Default)]
326struct DoSubMath;
327
328impl<T: std::ops::Sub<T, Output = T>> DoMath<T> for DoSubMath {
329    fn do_math(a: T, b: T) -> T {
330        a - b
331    }
332}
333
334#[derive(Default)]
335struct DoMulMath;
336
337impl<T: std::ops::Mul<T, Output = T>> DoMath<T> for DoMulMath {
338    fn do_math(a: T, b: T) -> T {
339        a * b
340    }
341}
342
343#[derive(Default)]
344struct DoDivMath;
345
346impl<T: std::ops::Div<T, Output = T>> DoMath<T> for DoDivMath {
347    fn do_math(a: T, b: T) -> T {
348        a / b
349    }
350}
351
352#[derive(Default)]
353struct MathHelper<A>(PhantomData<A>);
354
355impl<A> handlebars::HelperDef for MathHelper<A>
356where
357    A: DoMath<f64> + DoMath<i64> + DoMath<u64> + 'static,
358{
359    fn call_inner<'reg: 'rc, 'rc>(
360        &self,
361        helper: &handlebars::Helper,
362        reg: &handlebars::Handlebars,
363        _: &handlebars::Context,
364        _: &mut handlebars::RenderContext,
365    ) -> std::result::Result<handlebars::ScopedJson<'rc>, handlebars::RenderError> {
366        let mut args = helper.params().iter();
367        let a = args
368            .next()
369            .ok_or(handlebars::RenderErrorReason::ParamNotFoundForIndex(
370                "add", 0,
371            ))?;
372        let b = args
373            .next()
374            .ok_or(handlebars::RenderErrorReason::ParamNotFoundForIndex(
375                "add", 1,
376            ))?;
377
378        match (a.value(), b.value()) {
379            (serde_json::Value::Number(a), serde_json::Value::Number(b)) => {
380                if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
381                    return Ok(handlebars::ScopedJson::Derived(serde_json::Value::from(
382                        <A as DoMath<i64>>::do_math(a, b),
383                    )));
384                }
385                if let (Some(a), Some(b)) = (a.as_u64(), b.as_u64()) {
386                    return Ok(handlebars::ScopedJson::Derived(serde_json::Value::from(
387                        <A as DoMath<u64>>::do_math(a, b),
388                    )));
389                }
390                if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
391                    return Ok(handlebars::ScopedJson::Derived(serde_json::Value::from(
392                        <A as DoMath<f64>>::do_math(a, b),
393                    )));
394                }
395            }
396            (serde_json::Value::Null, _) | (_, serde_json::Value::Null) if reg.strict_mode() => {
397                Err(handlebars::RenderErrorReason::InvalidParamType("Number"))?
398            }
399            (serde_json::Value::Null, _) | (_, serde_json::Value::Null) => {
400                return Ok(handlebars::ScopedJson::Derived(serde_json::Value::Null));
401            }
402            _ => Err(handlebars::RenderErrorReason::InvalidParamType("Number"))?,
403        }
404
405        let value = serde_json::Value::Null;
406
407        Ok(handlebars::ScopedJson::Derived(value))
408    }
409}
410
411#[cfg(feature = "markdown")]
412handlebars::handlebars_helper!(render_markdown: |markdown: String| {
413    let mut html = String::new();
414    let mut markdown_filter = String::new();
415    pulldown_cmark_escape::escape_html(&mut markdown_filter, &markdown)
416        .inspect_err(|error| {
417            tracing::error!(?error, "Unable to escape markdown.")
418        })
419        .ok();
420    let parser = pulldown_cmark::Parser::new(&markdown_filter);
421    pulldown_cmark::html::push_html(&mut html, parser);
422
423    format!(r#"<div class="markdown">{html}</div>"#)
424});
425
426#[cfg(feature = "markdown")]
427fn render_custom_markdown_tags(markdown: String) -> String {
428    let mut in_metadata = false;
429
430    #[cfg(feature = "markdown-code-highlighting")]
431    let mut in_code_block = false;
432    #[cfg(feature = "markdown-code-highlighting")]
433    let mut code_block_language = String::new();
434
435    let parser =
436        pulldown_cmark::Parser::new_ext(&markdown, pulldown_cmark::Options::all()).map(|event| {
437            match &event {
438                Event::Start(Tag::MetadataBlock(MetadataBlockKind::PlusesStyle)) => {
439                    in_metadata = true;
440                }
441                Event::End(TagEnd::MetadataBlock(MetadataBlockKind::PlusesStyle)) => {
442                    in_metadata = false;
443                }
444                Event::Text(text) => {
445                    if in_metadata {
446                        return handle_metadata_block(text.to_string())
447                            .map(|content| Event::Html(content.into()))
448                            .unwrap_or_else(|| event);
449                    }
450
451                    #[cfg(feature = "markdown-code-highlighting")]
452                    if in_code_block {
453                        return handle_fenced_code_block(text.to_string(), &code_block_language)
454                            .map(|content| Event::Html(content.into()))
455                            .unwrap_or_else(|| {
456                                Event::Html(
457                                    format!("<div class=\"ui background text\">{text}</div>")
458                                        .into(),
459                                )
460                            });
461                    }
462                }
463                #[cfg(feature = "markdown-code-highlighting")]
464                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
465                    if lang.is_empty() {
466                        return event;
467                    }
468
469                    in_code_block = true;
470                    code_block_language = lang.to_string();
471                }
472                #[cfg(feature = "markdown-code-highlighting")]
473                Event::End(TagEnd::CodeBlock) => {
474                    in_code_block = false;
475                }
476                _ => {}
477            }
478
479            event
480        });
481
482    let mut html = String::new();
483    pulldown_cmark::html::push_html(&mut html, parser);
484
485    html
486}
487
488///
489/// Highlights  the given text in the given language
490///
491/// # Example
492///
493/// ```rust
494///
495/// let code = r#"pub fn main() {
496///     println!("Test");
497/// }
498/// "#.to_string();
499///
500/// let highlighted = quokka::state::templating::handle_fenced_code_block(code, "rust").unwrap();
501///
502/// assert_eq!(highlighted, "<div class=\"ui background text\"><span class=\"keyword\">pub</span> <span class=\"keyword\">fn</span> <span class=\"function\">main</span><span class=\"punctuation bracket\">(</span><span class=\"punctuation bracket\">)</span> <span class=\"punctuation bracket\">{</span>\n    <span class=\"function macro\">println</span><span class=\"function macro\">!</span><span class=\"punctuation bracket\">(</span><span class=\"string\">&quot;Test&quot;</span><span class=\"punctuation bracket\">)</span><span class=\"punctuation delimiter\">;</span>\n<span class=\"punctuation bracket\">}</span>\n</div>")
503/// ```
504#[cfg(feature = "markdown-code-highlighting")]
505#[doc(hidden)]
506pub fn handle_fenced_code_block(text: String, language: &str) -> Option<String> {
507    let html_output = inkjet::Highlighter::new()
508        .highlight_to_string(
509            inkjet::Language::from_token(language)?,
510            &inkjet::formatter::Html,
511            &text,
512        )
513        .inspect_err(|error| {
514            tracing::debug!(?error, ?language, "Unable to highlight markdown code")
515        })
516        .ok()?;
517
518    Some(format!(
519        "<div class=\"ui background text\">{html_output}</div>"
520    ))
521}
522
523#[cfg(feature = "markdown")]
524fn handle_metadata_block(text: String) -> Option<String> {
525    let mut lines = text.lines();
526    let first = lines.next().unwrap_or_default().trim();
527
528    if !first.starts_with("[!") || !first.ends_with(']') {
529        return None;
530    }
531
532    let tag_name = &first[2..first.len() - 1];
533    let content = lines.fold(String::new(), |acc, str| format!("{acc}{str}\n"));
534
535    Some(format!(
536        r#"<div class="tagblock-{tag_name}">{content}</div>"#
537    ))
538}
539
540#[cfg(feature = "markdown")]
541handlebars::handlebars_helper!(render_escaped_markdown: |markdown: String| {
542    let html = render_custom_markdown_tags(markdown);
543
544    format!(r#"<div class="markdown">{html}</div>"#)
545});
546
547///
548/// Condenses the file path of handlebars "partial" templates in the iterator
549///
550/// - Removes a loading `module_identifier/` path
551/// - Removes `.html.hbs` and `.hbs` extensions
552/// - Flattens a duplicated directory/template name (`partials/container/container.html.hbs` => `partials/container`)
553///
554/// Gets the files by fuzzy "contains" in the get function
555///
556#[derive(Clone, Debug, Default)]
557pub struct AliasedEmbed<E>(PhantomData<E>);
558
559impl<E: rust_embed::Embed> rust_embed::Embed for AliasedEmbed<E> {
560    fn get(file_path: &str) -> Option<rust_embed::EmbeddedFile> {
561        let file_path = file_path.to_string();
562
563        E::iter()
564            .find(|path| {
565                file_path.eq(path)
566                    || get_partials_path_mutations(path.to_string()).contains(&file_path)
567            })
568            .and_then(|path| E::get(&path))
569    }
570
571    fn iter() -> rust_embed::Filenames {
572        #[cfg(debug_assertions)]
573        use rust_embed::Filenames;
574
575        match E::iter() {
576            #[cfg(debug_assertions)]
577            Filenames::Dynamic(boxed) => {
578                let mut out = boxed.collect::<Vec<_>>();
579
580                for path in E::iter() {
581                    out.extend(
582                        get_partials_path_mutations(path.to_string())
583                            .into_iter()
584                            .map(|value| value.into()),
585                    );
586                }
587
588                let path = out.first().unwrap();
589
590                out.push(path.replace(".html.hbs", "").into());
591
592                Filenames::Dynamic(Box::new(out.into_iter()))
593            }
594
595            #[cfg(not(debug_assertions))]
596            names => names,
597        }
598    }
599}
600
601fn get_partials_path_mutations(path: String) -> Vec<String> {
602    let mut out = Vec::new();
603
604    if !path.contains("partials") {
605        return vec![path
606            .trim_end_matches(".html.hbs")
607            .trim_end_matches(".hbs")
608            .to_string()];
609    }
610
611    let Some((module_name, _)) = path.split_once("/") else {
612        return out;
613    };
614    let path = path
615        .split("/")
616        .skip_while(|part| part.ne(&"partials"))
617        .collect::<Vec<_>>()
618        .join("/");
619
620    if !path.is_empty() {
621        out.push(path.clone());
622    }
623
624    let path = path.trim_end_matches(".html.hbs").trim_end_matches(".hbs");
625    if let Some((starting, end)) = path.rsplit_once('/') {
626        if starting.ends_with(end) {
627            out.push(starting.to_string());
628            out.push(format!("{module_name}/{starting}"));
629
630            return out;
631        }
632
633        out.push(format!("{starting}/{end}"));
634        out.push(format!("{module_name}/{starting}/{end}"));
635    }
636
637    out
638}
639
640impl TryFromModule for Templating {
641    async fn try_from_module(_: &crate::config::Module) -> Result<Option<Self>>
642    where
643        Self: Sized,
644    {
645        Ok(Some(Self::try_new()?))
646    }
647}
648
649impl FromState<Templating> for Templating {
650    fn from_state(state: &Templating) -> Self {
651        state.clone()
652    }
653}