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#[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 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 #[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 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 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 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#[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#[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}