Skip to main content

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}