plates_render/frontmatter.rs
1//! Reading the metadata block of a source document.
2//!
3//! A thin reading layer over [`prov::Document`]: rendering only ever *reads*
4//! frontmatter, so the write half — serialize, set or remove a property, splice
5//! a body — has no counterpart here. A caller that needs it has prov's editor.
6
7use prov::{Mapping, Value};
8
9/// A path handed to [`prov::Document::parse`] purely to steer it away from
10/// *whole-file* metadata detection (`.yaml`/`.json`/`.figl` extensions — see
11/// `prov::document::whole_file_format`). A source document's own path is not
12/// used: rendering is handed text whose metadata is always a fenced block,
13/// never a bare config file.
14///
15/// It stays `.md` even now that a body may be Djot or HTML, and that is not an
16/// oversight. The collector re-fences every gathered source's metadata as delimited
17/// YAML regardless of the carrier the document had on disk (`plates::collect`),
18/// so what arrives here is always a `---` block — which is exactly what this extension tells prov to expect. The
19/// *body's* grammar is read from the real path, one layer up in
20/// [`crate::site`], and never from this.
21const DOC_PATH: &str = "frontmatter.md";
22
23/// A document split into its metadata mapping and its body.
24#[derive(Debug, Clone, Default)]
25pub struct ParsedFile {
26 /// The metadata block as an ordered map. Empty when the document has none.
27 pub frontmatter: Mapping,
28 /// Everything after the metadata block.
29 pub body: String,
30}
31
32/// Parse a document, treating "no metadata block" as an empty one.
33///
34/// Only a malformed metadata block is an error; a document with none at all
35/// parses to an empty mapping and a body of the whole text.
36pub fn parse_or_empty(content: &str) -> Result<ParsedFile, prov::Error> {
37 let doc = prov::Document::parse(DOC_PATH, content)?;
38 Ok(ParsedFile {
39 frontmatter: doc.meta.as_mapping().cloned().unwrap_or_default(),
40 body: doc.body,
41 })
42}
43
44/// A string-valued property, when present and actually a string.
45pub fn get_string<'a>(frontmatter: &'a Mapping, key: &str) -> Option<&'a str> {
46 frontmatter.get(key).and_then(|v| v.as_str())
47}
48
49/// A sequence-valued property, as its string elements. Empty when the key is
50/// absent, is not a sequence, or holds no strings.
51pub fn get_string_array(frontmatter: &Mapping, key: &str) -> Vec<String> {
52 match frontmatter.get(key) {
53 Some(Value::Sequence(seq)) => seq
54 .iter()
55 .filter_map(|v| v.as_str().map(String::from))
56 .collect(),
57 _ => Vec::new(),
58 }
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64
65 #[test]
66 fn no_metadata_block_is_an_empty_mapping_and_a_whole_body() {
67 let parsed = parse_or_empty("# Just a heading\n").unwrap();
68 assert!(parsed.frontmatter.is_empty());
69 assert_eq!(parsed.body, "# Just a heading\n");
70 }
71
72 #[test]
73 fn reads_scalars_and_sequences() {
74 let parsed =
75 parse_or_empty("---\ntitle: Hi\ncontents:\n - a.md\n - b.md\n---\n\nbody\n").unwrap();
76 assert_eq!(get_string(&parsed.frontmatter, "title"), Some("Hi"));
77 assert_eq!(
78 get_string_array(&parsed.frontmatter, "contents"),
79 vec!["a.md".to_string(), "b.md".to_string()]
80 );
81 assert_eq!(parsed.body.trim(), "body");
82 }
83
84 #[test]
85 fn a_missing_or_wrongly_typed_key_reads_as_absent() {
86 let parsed = parse_or_empty("---\ntitle: Hi\ncount: 3\n---\n").unwrap();
87 assert_eq!(get_string(&parsed.frontmatter, "nope"), None);
88 // A non-string scalar is not a string.
89 assert_eq!(get_string(&parsed.frontmatter, "count"), None);
90 // A non-sequence is not a sequence.
91 assert!(get_string_array(&parsed.frontmatter, "title").is_empty());
92 }
93}