dokkoo/
lib.rs

1/*
2	This file is part of Dokkoo.
3
4	Dokkoo is free software: you can redistribute it and/or modify
5	it under the terms of the GNU Affero General Public License as published by
6	the Free Software Foundation, either version 3 of the License, or
7	(at your option) any later version.
8
9	Dokkoo is distributed in the hope that it will be useful,
10	but WITHOUT ANY WARRANTY; without even the implied warranty of
11	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12	GNU Affero General Public License for more details.
13
14	You should have received a copy of the GNU Affero General Public License
15	along with Dokkoo.  If not, see <https://www.gnu.org/licenses/>.
16*/
17/*
18lib.rs - Handling Mokk files (`.mokkf`)
19
20Mokk is a custom file format that is used by Dokkoo to generate static websites.
21
22A Mokk file represents a document or page written in accordance to [the Mokk specification](https://dirout.github.io/mokk).
23*/
24#![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)]
63/// A Mokk file's date-time metadata
64pub struct Date {
65	/// Year with four digits
66	pub year: String,
67	/// Year without the century (00..99)
68	pub short_year: String,
69	/// Month (01..12)
70	pub month: String,
71	/// Month without leading zeros
72	pub i_month: String,
73	/// Three-letter month abbreviation, e.g. “Jan”
74	pub short_month: String,
75	/// Full month name, e.g. “January”
76	pub long_month: String,
77	/// Day of the month (01..31)
78	pub day: String,
79	/// Day of the month without leading zeros
80	pub i_day: String,
81	/// Ordinal day of the year, with leading zeros. (001..366)
82	pub y_day: String,
83	/// Week year which may differ from the month year for up to three days at the start of January and end of December
84	pub w_year: String,
85	/// Week number of the current year, starting with the first week having a majority of its days in January (01..53)
86	pub week: String,
87	/// Day of the week, starting with Monday (1..7)
88	pub w_day: String,
89	/// Three-letter weekday abbreviation, e.g. “Sun”
90	pub short_day: String,
91	/// Weekday name, e.g. “Sunday”
92	pub long_day: String,
93	/// Hour of the day, 24-hour clock, zero-padded (00..23)
94	pub hour: String,
95	/// Minute of the hour (00..59)
96	pub minute: String,
97	/// Second of the minute (00..59)
98	pub second: String,
99	/// A Mokk file's date-time metadata, formatted per the RFC 3339 standard
100	pub rfc_3339: String,
101	/// A Mokk file's date-time metadata, formatted per the RFC 2822 standard
102	pub rfc_2822: String,
103}
104
105/// Handle conversion of a Date object into a string of characters
106impl 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	/// Convert a `serde_yaml::Value` object into a `Date` object
114	///
115	/// # Arguments
116	///
117	/// * `value` - The `serde_yaml::Value` object to convert
118	///
119	/// * `locale` - A `chrono::Locale` object
120	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(); // Turn the date-time into a DateTime object for easy manipulation (to generate temporal metadata)
137
138				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	/// Convert a `chrono::DateTime` object into a `Date` object
165	///
166	/// # Arguments
167	///
168	/// * `datetime` - A `chrono::DateTime<chrono::Utc>` object
169	///
170	/// * `locale` - A `chrono::Locale` object
171	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)]
214/// Generated data regarding a Mokk file
215pub struct Page {
216	/// A Mokk file's contextual data, represented as YAML at the head/front of the file
217	pub data: AHashMap<String, serde_yaml::Value>,
218	/// A Mokk file's contents following the frontmatter
219	pub content: String,
220	/// Data representing the output path of a Mokk file.
221	/// This is defined in a Mokk file's frontmatter
222	pub permalink: String,
223	/// A Mokk file's date-time metadata, formatted per the RFC 3339 standard.
224	/// This is defined in a Mokk file's frontmatter
225	pub date: Date,
226	/// Path to the Mokk file, not including the Mokk file itself
227	pub directory: String,
228	/// The Mokk file's base filename
229	pub name: String,
230	/// The output path of a file; a processed `permalink` value
231	pub url: String,
232	/// Whether a Mokk file's contents are intended to be processed as Markdown or not
233	pub markdown: bool,
234	/// Whether a Mokk file's contents are intended to be processed as LaTeX Math or not
235	pub math: bool,
236	/// Whether a Mokk file is intended to be minified
237	pub minify: bool,
238}
239
240/// Handle conversion of a Page object into a string of characters
241impl 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)]
262/// Build configuration data held in memory during the build process, from the global file
263pub struct Global {
264	/// The global locale, used to format dates
265	pub locale: String,
266	/// The `Date` object representing the date & time of the build
267	pub date: Date,
268	/// Whether the build's outputs are intended to be minified
269	pub minify: bool,
270}
271
272/// The initial state of a `Global` object
273impl 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
283/// Gets a string representing the system locale, if available. Otherwise, defaults to 'en_US'
284pub fn default_locale_string() -> String {
285	get_locale().unwrap_or("en_US".to_owned())
286}
287
288/// Gets the system locale, if available. Otherwise, defaults to `en_US`
289pub fn default_locale() -> chrono::Locale {
290	chrono::Locale::try_from(default_locale_string().as_str()).unwrap_or(chrono::Locale::en_US)
291}
292
293/// Gets a `chrono::Locale` object from a string
294pub fn locale_string_to_locale(locale: String) -> chrono::Locale {
295	chrono::Locale::try_from(locale.as_str()).unwrap_or(default_locale())
296}
297
298/// Data held in memory during the build process
299pub struct Build {
300	/// A collection of pages, grouped by their collection name
301	pub collections: AHashMap<String, Vec<Page>>,
302	/// The global context, defined in the Mokk's global file
303	pub global_context: (AHashMap<String, serde_yaml::Value>, Global),
304	/// The Liquid parser
305	pub liquid_parser: liquid::Parser,
306}
307
308/// The initial state of a `Build` object
309impl 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	/// Returns an object with a `Page`'s context
321	///
322	/// # Arguments
323	///
324	/// * `page_path` - The `.mokkf` file's path as a `String`
325	pub fn get_page_object(&self, page_path: String) -> Page {
326		// Define variables which we'll use to create our Document, which we'll use to generate the Page context
327		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		); // See file::split_frontmatter
333		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(); // Parse frontmatter as AHashMap (collection of key-value pairs)
340
341		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); // Get locale from Global context
403
404		let date_object = Date::value_to_date(frontmatter.get("date"), locale);
405
406		let page_path_io = Path::new(&page_path[..]); // Turn the path into a Path object for easy manipulation (to get page.directory and page.name)
407
408		// Define our Page
409		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			// Don't render the URL if the permalink is empty
448			"" => {}
449			_ => {
450				// Render the URL once the Page metadata has been generated
451				page.url = self.render(&page, &get_permalink(&permalink_string), false, false);
452			}
453		}
454
455		page
456	}
457
458	/// Returns a Liquid object with a `Page`'s Liquid contexts
459	///
460	/// # Arguments
461	///
462	/// * `page` - The `.mokkf` file's context as a `Page`
463	pub fn get_contexts(&self, page: &Page) -> Object {
464		/*
465		Layouts
466		*/
467		let layout_name = page.data.get("layout");
468
469		// Import layout context if Page has a layout
470		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	/// Returns a `String` with a `&str`'s Mokk file rendered
503	///
504	/// # Arguments
505	///
506	/// * `page` - A `.mokkf` file's context as a `Page`
507	///
508	/// * `text_to_render` - The text to be rendered
509	///
510	/// * `markdown` - Whether or not to render Markdown
511	///
512	/// * `math` - Whether or not to render LaTeX Math
513	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	/// Compiles a Mokk file; renders, makes note of the Mokk file (when, or if, the need arises)
561	///
562	/// # Arguments
563	///
564	/// * `page` - The `.mokkf` file's context as a `Page`
565	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		// If Page has a layout, render with layout(s)
570		// Otherwise, render with Page's contents
571		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); // Embed page in layout
579				self.render(&page, &layouts, false, false)
580				// Final render, to capture whatever layouts & snippets introduce
581			}
582		};
583
584		// When within a collection, append embeddable page to list of collection's entries
585		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	/// Render the layout(s) of a post recursively (should a layout have a layout of its own)
624	///
625	/// # Arguments
626	///
627	/// * `page` - The `.mokkf` file's context as a `Page`
628	///
629	/// * `layout` - The Mokk file's layout's context as a `Page`
630	pub fn render_layouts(&self, sub: &Page, layout: Page) -> String {
631		// Take layout's text, render it with sub's context
632
633		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
672/// Returns an expanded permalink value, for when shorthand is used
673///
674/// # Arguments
675///
676/// * `permalink` - A string slice that represents the permalink value specified in the Mokk file
677///
678/// # Shorthand
679///
680/// * `date` → `/{{ page.data.collection }}/{{ page.date.year }}/{{ page.date.month }}/{{ page.date.day }}/{{ page.data.title }}.html`
681///
682/// * `pretty` → `/{{ page.data.collection }}/{{ page.date.year }}/{{ page.date.month }}/{{ page.date.day }}/{{ page.data.title }}/index.html`
683///
684/// * `ordinal` → `/{{ page.data.collection }}/{{ page.date.year }}/{{ page.date.y_day }}/{{ page.data.title }}.html`
685///
686/// * `weekdate` → `/{{ page.data.collection }}/{{ page.date.year }}/W{{ page.date.week }}/{{ page.date.short_day }}/{{ page.data.title }}.html`
687///
688/// * `none` → `/{{ page.data.collection }}/{{ page.data.title }}.html`
689pub 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
713/// Returns a tuple with a Mokk file's frontmatter and contents, in that order
714///
715/// # Arguments
716///
717/// * `page_text` - The `.mokkf` file's data as a `String`
718pub 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			//frontmatter.push_str(&format!("{}\n", &line));
731			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			//contents.push_str(&format!("{}\n", &line));
737			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
751/// Creates a Liquid parser
752pub 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
799/// Render Markdown as HTML
800///
801/// # Arguments
802///
803/// * `text_to_render` - The Markdown text to render into HTML
804///
805/// * `math` - Whether or not Markdown is being rendered with LaTeX Math
806pub 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
842/// Get the global context
843pub 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() // Defined as variable as it required a type annotation
852		}
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() // Defined as variable as it required a type annotation
858		}
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 locale: chrono::Locale = chrono::Locale::try_from(locale_value.as_str()).unwrap(); // Get locale from Global context
874
875	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}