1#![warn(clippy::disallowed_types)]
25
26use ahash::AHashMap;
27use chrono::{DateTime, Utc};
28use comrak::plugins::syntect::SyntectAdapter;
29use comrak::{markdown_to_html_with_plugins, ComrakPlugins, ListStyleType};
30use derive_more::{Constructor, Div, Error, From, Into, Mul, Rem, Shl, Shr};
31use html_minifier::HTMLMinifier;
32use liquid::*;
33use miette::{miette, IntoDiagnostic, WrapErr};
34use relative_path::RelativePath;
35use serde::{Deserialize, Serialize};
36use std::convert::TryFrom;
37use std::ffi::OsString;
38use std::fmt;
39use std::fmt::Write;
40use std::fs;
41use std::path::Path;
42use sys_locale::get_locale;
43
44#[derive(
45 Eq,
46 PartialEq,
47 PartialOrd,
48 Clone,
49 Default,
50 Debug,
51 Serialize,
52 Deserialize,
53 From,
54 Into,
55 Error,
56 Mul,
57 Div,
58 Rem,
59 Shr,
60 Shl,
61 Constructor,
62)]
63pub struct Date {
65 pub year: String,
67 pub short_year: String,
69 pub month: String,
71 pub i_month: String,
73 pub short_month: String,
75 pub long_month: String,
77 pub day: String,
79 pub i_day: String,
81 pub y_day: String,
83 pub w_year: String,
85 pub week: String,
87 pub w_day: String,
89 pub short_day: String,
91 pub long_day: String,
93 pub hour: String,
95 pub minute: String,
97 pub second: String,
99 pub rfc_3339: String,
101 pub rfc_2822: String,
103}
104
105impl fmt::Display for Date {
107 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
108 write!(f, "{}", self.rfc_3339)
109 }
110}
111
112impl Date {
113 pub fn value_to_date(value: Option<&serde_yaml::Value>, locale: chrono::Locale) -> Date {
121 match value {
122 Some(d) => {
123 let datetime = DateTime::parse_from_rfc3339(
124 d.as_str()
125 .ok_or(miette!(
126 "Unable to read `date` value ({:?}) as a string.",
127 d
128 ))
129 .unwrap(),
130 )
131 .into_diagnostic()
132 .wrap_err(format!(
133 "Unable to parse `date` value ({:?}) as an RFC 3339 date-time.",
134 d
135 ))
136 .unwrap(); Date::chrono_to_date(datetime.into(), locale)
139 }
140 None => Date {
141 year: String::new(),
142 short_year: String::new(),
143 month: String::new(),
144 i_month: String::new(),
145 short_month: String::new(),
146 long_month: String::new(),
147 day: String::new(),
148 i_day: String::new(),
149 y_day: String::new(),
150 w_year: String::new(),
151 week: String::new(),
152 w_day: String::new(),
153 short_day: String::new(),
154 long_day: String::new(),
155 hour: String::new(),
156 minute: String::new(),
157 second: String::new(),
158 rfc_3339: String::new(),
159 rfc_2822: String::new(),
160 },
161 }
162 }
163
164 pub fn chrono_to_date(datetime: chrono::DateTime<Utc>, locale: chrono::Locale) -> Date {
172 Date {
173 year: format!("{}", datetime.format_localized("%Y", locale)),
174 short_year: format!("{}", datetime.format_localized("%y", locale)),
175 month: format!("{}", datetime.format_localized("%m", locale)),
176 i_month: format!("{}", datetime.format_localized("%-m", locale)),
177 short_month: format!("{}", datetime.format_localized("%b", locale)),
178 long_month: format!("{}", datetime.format_localized("%B", locale)),
179 day: format!("{}", datetime.format_localized("%d", locale)),
180 i_day: format!("{}", datetime.format_localized("%-d", locale)),
181 y_day: format!("{}", datetime.format_localized("%j", locale)),
182 w_year: format!("{}", datetime.format_localized("%G", locale)),
183 week: format!("{}", datetime.format_localized("%U", locale)),
184 w_day: format!("{}", datetime.format_localized("%u", locale)),
185 short_day: format!("{}", datetime.format_localized("%a", locale)),
186 long_day: format!("{}", datetime.format_localized("%A", locale)),
187 hour: format!("{}", datetime.format_localized("%H", locale)),
188 minute: format!("{}", datetime.format_localized("%M", locale)),
189 second: format!("{}", datetime.format_localized("%S", locale)),
190 rfc_3339: datetime.to_rfc3339(),
191 rfc_2822: datetime.to_rfc2822(),
192 }
193 }
194}
195
196#[derive(
197 Eq,
198 PartialEq,
199 Clone,
200 Default,
201 Debug,
202 Serialize,
203 Deserialize,
204 From,
205 Into,
206 Error,
207 Mul,
208 Div,
209 Rem,
210 Shr,
211 Shl,
212 Constructor,
213)]
214pub struct Page {
216 pub data: AHashMap<String, serde_yaml::Value>,
218 pub content: String,
220 pub permalink: String,
223 pub date: Date,
226 pub directory: String,
228 pub name: String,
230 pub url: String,
232 pub markdown: bool,
234 pub math: bool,
236 pub minify: bool,
238}
239
240impl fmt::Display for Page {
242 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
243 write!(f, "{self:#?}")
244 }
245}
246
247#[derive(
248 PartialEq,
249 Clone,
250 Debug,
251 Serialize,
252 Deserialize,
253 From,
254 Into,
255 Mul,
256 Div,
257 Rem,
258 Shr,
259 Shl,
260 Constructor,
261)]
262pub struct Global {
264 pub locale: String,
266 pub date: Date,
268 pub minify: bool,
270}
271
272impl Default for Global {
274 fn default() -> Self {
275 Self {
276 locale: default_locale_string(),
277 date: Date::default(),
278 minify: false,
279 }
280 }
281}
282
283pub fn default_locale_string() -> String {
285 get_locale().unwrap_or("en_US".to_owned())
286}
287
288pub fn default_locale() -> chrono::Locale {
290 chrono::Locale::try_from(default_locale_string().as_str()).unwrap_or(chrono::Locale::en_US)
291}
292
293pub fn locale_string_to_locale(locale: String) -> chrono::Locale {
295 chrono::Locale::try_from(locale.as_str()).unwrap_or(default_locale())
296}
297
298pub struct Build {
300 pub collections: AHashMap<String, Vec<Page>>,
302 pub global_context: (AHashMap<String, serde_yaml::Value>, Global),
304 pub liquid_parser: liquid::Parser,
306}
307
308impl Default for Build {
310 fn default() -> Self {
311 Self {
312 collections: AHashMap::new(),
313 global_context: get_global_context(),
314 liquid_parser: create_liquid_parser(),
315 }
316 }
317}
318
319impl Build {
320 pub fn get_page_object(&self, page_path: String) -> Page {
326 let split_page = split_frontmatter(
328 fs::read_to_string(&page_path)
329 .into_diagnostic()
330 .wrap_err(format!("Failed to read the file at '{}'.", &page_path))
331 .unwrap(),
332 ); let frontmatter: AHashMap<String, serde_yaml::Value> = serde_yaml::from_str(&split_page.0)
334 .into_diagnostic()
335 .wrap_err(format!(
336 "Failed to parse frontmatter from '{}'.",
337 &page_path
338 ))
339 .unwrap(); let permalink_string: String = match frontmatter.get("permalink") {
342 Some(p) => p
343 .as_str()
344 .ok_or(miette!(
345 "Unable to read `permalink` value ({:?}) as string in frontmatter of file '{}'.",
346 p,
347 &page_path
348 ))
349 .unwrap()
350 .to_string(),
351 None => String::new(),
352 };
353
354 let markdown_bool: bool = match frontmatter.get("markdown") {
355 Some(m) => m
356 .as_bool()
357 .ok_or(miette!(
358 "Unable to read `markdown` value ({:?}) as string in frontmatter of file '{}'.",
359 m,
360 &page_path
361 ))
362 .unwrap(),
363 None => true,
364 };
365
366 let math_bool: bool = match frontmatter.get("math") {
367 Some(m) => m
368 .as_bool()
369 .ok_or(miette!(
370 "Unable to read `math` value ({:?}) as string in frontmatter of file '{}'.",
371 m,
372 &page_path
373 ))
374 .unwrap(),
375 None => true,
376 };
377
378 let locale_value = match frontmatter.get("locale") {
379 Some(pl) => pl
380 .as_str()
381 .ok_or(miette!(
382 "Unable to read `locale` value ({:?}) from page frontmatter.",
383 pl
384 ))
385 .unwrap()
386 .to_owned(),
387 None => self.global_context.1.locale.clone(),
388 };
389
390 let minify_value = match frontmatter.get("minify") {
391 Some(m) => m
392 .as_bool()
393 .ok_or(miette!(
394 "Unable to read `minify` value ({:?}) from page frontmatter.",
395 m
396 ))
397 .unwrap()
398 .to_owned(),
399 None => self.global_context.1.minify,
400 };
401
402 let locale: chrono::Locale = locale_string_to_locale(locale_value); let date_object = Date::value_to_date(frontmatter.get("date"), locale);
405
406 let page_path_io = Path::new(&page_path[..]); let mut page = Page {
410 minify: minify_value,
411 data: serde_yaml::from_str(&split_page.0)
412 .into_diagnostic()
413 .wrap_err(format!(
414 "Unable to parse page frontmatter ({}) while rendering '{}'.",
415 &split_page.0, &page_path
416 ))
417 .unwrap(),
418 content: split_page.1,
419 permalink: permalink_string.clone(),
420 date: date_object,
421 directory: page_path_io
422 .parent()
423 .unwrap_or(Path::new(""))
424 .to_str()
425 .ok_or(miette!(
426 "Unable to represent parent directory of page as a string while rendering '{}'.",
427 &page_path
428 ))
429 .unwrap()
430 .to_owned(),
431 name: page_path_io
432 .file_stem()
433 .unwrap_or(&OsString::new())
434 .to_str()
435 .ok_or(miette!(
436 "Unable to represent file stem of page as a string while rendering '{}'.",
437 &page_path
438 ))
439 .unwrap()
440 .to_owned(),
441 url: String::new(),
442 markdown: markdown_bool,
443 math: math_bool,
444 };
445
446 match &page.permalink[..] {
447 "" => {}
449 _ => {
450 page.url = self.render(&page, &get_permalink(&permalink_string), false, false);
452 }
453 }
454
455 page
456 }
457
458 pub fn get_contexts(&self, page: &Page) -> Object {
464 let layout_name = page.data.get("layout");
468
469 let layout: AHashMap<String, serde_yaml::Value> = match layout_name {
471 None => AHashMap::new(),
472 Some(l) => serde_yaml::from_str(
473 &split_frontmatter(
474 fs::read_to_string(format!(
475 "./layouts/{}.mokkf",
476 l
477 .as_str()
478 .ok_or(miette!("Unable to represent layout name ({:?}) as a string while rendering '{:#?}'.", l, page))
479 .unwrap()
480 ))
481 .into_diagnostic()
482 .wrap_err(format!("Unable to read layout file ({:?}) mentioned in frontmatter of file '{}'.", l, page.name))
483 .unwrap(),
484 )
485 .0,
486 )
487 .into_diagnostic()
488 .wrap_err(format!("Unable to parse frontmatter of layout file ({:?}) mentioned in frontmatter of file '{}'.", l, page.name))
489 .unwrap(),
490 };
491
492 let contexts = object!({
493 "global": self.global_context.0,
494 "page": page,
495 "layout": layout,
496 "collections": self.collections,
497 });
498
499 contexts
500 }
501
502 pub fn render(&self, page: &Page, text_to_render: &str, markdown: bool, math: bool) -> String {
514 let template = self
515 .liquid_parser
516 .parse(text_to_render)
517 .into_diagnostic()
518 .wrap_err(format!(
519 "Unable to parse text to render ('{text_to_render}') for {page:#?}."
520 ))
521 .unwrap();
522
523 let mut rendered = template
524 .render(&self.get_contexts(page))
525 .into_diagnostic()
526 .wrap_err(format!(
527 "Unable to render text ('{text_to_render}') for {page:#?}."
528 ))
529 .unwrap();
530
531 rendered = match markdown {
532 true => render_markdown(rendered, math),
533 false => rendered,
534 };
535
536 rendered = match math {
537 true => latex2mathml::replace(&rendered)
538 .into_diagnostic()
539 .wrap_err(format!(
540 "Unable to render math in document ('{rendered}') for {page:#?}."
541 ))
542 .unwrap(),
543 false => rendered,
544 };
545
546 match &page.minify {
547 true => {
548 let mut html_minifier = HTMLMinifier::new();
549 html_minifier
550 .digest(&rendered)
551 .into_diagnostic()
552 .wrap_err(format!("Unable to minify HTML for {page:#?}."))
553 .unwrap();
554 String::from_utf8_lossy(html_minifier.get_html()).to_string()
555 }
556 false => rendered,
557 }
558 }
559
560 pub fn compile(&mut self, mut page: Page) -> String {
566 let layout_name = &page.data.get("layout");
567 let collection_name = &page.data.get("collection");
568
569 page.content = self.render(&page, &page.content, page.markdown, page.math);
572 let compiled_page = match layout_name {
573 None => page.content.to_owned(),
574 Some(l) => {
575 let layout_object = self.get_page_object(
576 format!("./layouts/{}.mokkf", l.as_str().ok_or(miette!("Unable to represent layout name ({:?}) as a string while rendering '{:#?}'.", l, page)).unwrap()),
577 );
578 let layouts = self.render_layouts(&page, layout_object); self.render(&page, &layouts, false, false)
580 }
582 };
583
584 match collection_name {
586 None => {}
587 Some(c) => {
588 let collection_name_str = c
589 .as_str()
590 .ok_or(miette!(
591 "Unable to represent collection name ({:?}) as a string while rendering '{:#?}'.",
592 c,
593 page
594 ))
595 .unwrap();
596 match self
597 .collections
598 .contains_key(&collection_name_str.to_string())
599 {
600 true => {
601 (*self
602 .collections
603 .get_mut(collection_name_str)
604 .ok_or(miette!(
605 "Unable to get collection ({}) while rendering '{:#?}'.",
606 collection_name_str,
607 page
608 ))
609 .unwrap())
610 .push(page);
611 }
612 false => {
613 self.collections
614 .insert(collection_name_str.to_owned(), vec![page]);
615 }
616 }
617 }
618 }
619
620 compiled_page
621 }
622
623 pub fn render_layouts(&self, sub: &Page, layout: Page) -> String {
631 let merged_sub_page = Page {
634 data: sub
635 .clone()
636 .data
637 .into_iter()
638 .chain(layout.clone().data)
639 .collect(),
640 content: layout.clone().content,
641 date: sub.clone().date,
642 name: sub.clone().name,
643 directory: sub.clone().directory,
644 permalink: sub.clone().permalink,
645 url: sub.clone().url,
646 minify: sub.clone().minify,
647 markdown: layout.markdown,
648 math: layout.math,
649 };
650
651 let super_layout = layout.data.get("layout");
652 let rendered: String = match super_layout {
653 Some(l) => {
654 let super_layout_object = self.get_page_object(
655 format!(
656 "./layouts/{}.mokkf",
657 l
658 .as_str()
659 .ok_or(miette!("Unable to represent layout name ({:?}) as a string while rendering '{:#?}'.", l, merged_sub_page))
660 .unwrap()
661 ),
662 );
663 self.render_layouts(&merged_sub_page, super_layout_object)
664 }
665 None => self.render(sub, &layout.content, layout.markdown, layout.math),
666 };
667
668 rendered
669 }
670}
671
672pub fn get_permalink(permalink: &str) -> String {
690 match permalink {
691 "date" => {
692 "/{{ page.data.collection }}/{{ page.date.year }}/{{ page.date.month }}/{{ page.date.day }}/{{ page.data.title }}.html".to_owned()
693 }
694 "pretty" => {
695 "/{{ page.data.collection }}/{{ page.date.year }}/{{ page.date.month }}/{{ page.date.day }}/{{ page.data.title }}/index.html".to_owned()
696 }
697 "ordinal" => {
698 "/{{ page.data.collection }}/{{ page.date.year }}/{{ page.date.y_day }}/{{ page.data.title }}.html"
699 .to_owned()
700 }
701 "weekdate" => {
702 "/{{ page.data.collection }}/{{ page.date.year }}/W{{ page.date.week }}/{{ page.date.short_day }}/{{ page.data.title }}.html".to_owned()
703 }
704 "none" => {
705 "/{{ page.data.collection }}/{{ page.data.title }}.html".to_owned()
706 }
707 _ => {
708 permalink.to_string()
709 }
710 }
711}
712
713pub fn split_frontmatter(page_text: String) -> (String, String) {
719 let mut begin = false;
720 let mut end = false;
721 let mut frontmatter = String::new();
722 let mut contents = String::new();
723
724 for line in page_text.lines() {
725 if !begin && line == "---" {
726 begin = true;
727 } else if begin && line == "---" && !end {
728 end = true;
729 } else if begin && !end {
730 writeln!(frontmatter, "{}", &line)
732 .into_diagnostic()
733 .wrap_err(format!("Failed to write a line of frontmatter to memory ({}). Managed to write the following:\n{}", &line, &frontmatter))
734 .unwrap();
735 } else {
736 writeln!(contents, "{}", &line)
738 .into_diagnostic()
739 .wrap_err(format!("Failed to write a line of content to memory ({}). Managed to write the following:\n{}", &line, &contents))
740 .unwrap();
741 }
742 }
743
744 if frontmatter.trim().is_empty() {
745 frontmatter = "empty: true".to_owned();
746 }
747
748 (frontmatter, contents)
749}
750
751pub fn create_liquid_parser() -> liquid::Parser {
753 let mut partial = liquid::partials::InMemorySource::new();
754 let snippets = glob::glob("./snippets/**/*");
755 if let Ok(s) = snippets {
756 for snippet in s {
757 let unwrapped_snippet = snippet
758 .into_diagnostic()
759 .wrap_err("Unable to interpret path to snippet file.")
760 .unwrap();
761 if unwrapped_snippet.is_file() {
762 let relative_path = RelativePath::from_path(&unwrapped_snippet)
763 .into_diagnostic()
764 .wrap_err(format!(
765 "Unable to interpret path to snippet file ('{}') as a relative path.",
766 unwrapped_snippet.display()
767 ))
768 .unwrap();
769 let snippet_name = relative_path.strip_prefix("snippets").unwrap().to_string();
770 let path = &unwrapped_snippet.as_path();
771 partial.add(
772 snippet_name,
773 &fs::read_to_string(path)
774 .into_diagnostic()
775 .wrap_err(format!("Unable to read snippet file '{}'.", path.display()))
776 .unwrap(),
777 );
778 }
779 }
780 }
781 let partial_compiler = liquid::partials::EagerCompiler::new(partial);
782 liquid::ParserBuilder::with_stdlib()
783 .tag(liquid_lib::jekyll::IncludeTag)
784 .filter(liquid_lib::jekyll::ArrayToSentenceString)
785 .filter(liquid_lib::jekyll::Pop)
786 .filter(liquid_lib::jekyll::Push)
787 .filter(liquid_lib::jekyll::Shift)
788 .filter(liquid_lib::jekyll::Slugify)
789 .filter(liquid_lib::jekyll::Unshift)
790 .filter(liquid_lib::shopify::Pluralize)
791 .filter(liquid_lib::extra::DateInTz)
792 .partials(partial_compiler)
793 .build()
794 .into_diagnostic()
795 .wrap_err("Unable to build a Liquid parser.")
796 .unwrap()
797}
798
799pub fn render_markdown(text_to_render: String, math: bool) -> String {
807 let mut options = comrak::Options::default();
808
809 options.extension.strikethrough = true;
810 options.extension.tagfilter = false;
811 options.extension.table = true;
812 options.extension.autolink = false;
813 options.extension.tasklist = true;
814 options.extension.superscript = !math;
815 options.extension.header_ids = Some(String::from("h-"));
816 options.extension.footnotes = true;
817 options.extension.description_lists = true;
818 options.extension.front_matter_delimiter = None;
819 options.extension.shortcodes = true;
820
821 options.parse.smart = true;
822 options.parse.default_info_string = None;
823 options.parse.relaxed_tasklist_matching = true;
824 options.parse.relaxed_autolinks = true;
825
826 options.render.hardbreaks = true;
827 options.render.github_pre_lang = true;
828 options.render.full_info_string = true;
829 options.render.width = 80;
830 options.render.unsafe_ = true;
831 options.render.escape = false;
832 options.render.list_style = ListStyleType::Dash;
833 options.render.sourcepos = false;
834
835 let mut plugins = ComrakPlugins::default();
836 let syntax_highlighting_adapter = SyntectAdapter::new("InspiredGitHub");
837 plugins.render.codefence_syntax_highlighter = Some(&syntax_highlighting_adapter);
838
839 markdown_to_html_with_plugins(&text_to_render, &options, &plugins)
840}
841
842pub fn get_global_context() -> (AHashMap<String, serde_yaml::Value>, Global) {
844 let global_context: AHashMap<String, serde_yaml::Value> = match fs::read_to_string(
845 "./_global.yml",
846 ) {
847 Ok(g) => {
848 serde_yaml::from_str(&g)
849 .into_diagnostic()
850 .wrap_err(format!("Unable to parse global file ({g})."))
851 .unwrap() }
853 Err(e) => {
854 serde_yaml::from_str("empty: true")
855 .into_diagnostic()
856 .wrap_err(format!("Unable to initialise a blank global file. If you're seeing this message, something is very wrong. The global file cannot be read and a blank, default global file failed to initialise. An error occurred when attempting to read the global file: {e}"))
857 .unwrap() }
859 };
860
861 let locale_value = match global_context.get("locale") {
862 Some(l) => l
863 .as_str()
864 .ok_or(miette!(
865 "Unable to read `locale` value ({:?}) from global file.",
866 l
867 ))
868 .unwrap()
869 .to_owned(),
870 None => get_locale().unwrap_or("en_US".to_owned()),
871 };
872
873 let minify_value = match global_context.get("minify") {
876 Some(m) => m
877 .as_bool()
878 .ok_or(miette!(
879 "Unable to read `minify` value ({:?}) from global file.",
880 m
881 ))
882 .unwrap(),
883 None => false,
884 };
885
886 let global = Global {
887 locale: locale_value.clone(),
888 date: Date::chrono_to_date(Utc::now(), locale_string_to_locale(locale_value)),
889 minify: minify_value,
890 };
891
892 let mut global_map: AHashMap<String, serde_yaml::Value> = serde_yaml::from_value(
893 serde_yaml::to_value(global.clone())
894 .into_diagnostic()
895 .wrap_err(miette!(
896 "Unable to represent global file data ({:#?}) as a collection of values.",
897 global
898 ))
899 .unwrap(),
900 )
901 .into_diagnostic()
902 .wrap_err(miette!(
903 "Unable to represent global file data as a collection of values."
904 ))
905 .unwrap();
906 global_map.extend(global_context);
907
908 (global_map, global)
909}