Skip to main content

max / docengine

4.1 KB · 134 lines History Blame Raw
1 use std::collections::HashMap;
2
3 use serde::Deserialize;
4
5 /// Parsed TOML frontmatter from a markdown document.
6 #[derive(Debug, Clone, Default, Deserialize)]
7 pub struct Frontmatter {
8 pub title: Option<String>,
9 pub date: Option<String>,
10 pub tags: Option<Vec<String>>,
11 pub section: Option<String>,
12 pub draft: Option<bool>,
13 #[serde(flatten)]
14 pub extra: HashMap<String, toml::Value>,
15 }
16
17 /// Parse TOML frontmatter delimited by `+++` from the beginning of a document.
18 ///
19 /// Returns the parsed frontmatter (if present) and the remaining markdown
20 /// content.
21 pub fn parse_frontmatter(input: &str) -> (Option<Frontmatter>, &str) {
22 let trimmed = input.trim_start();
23 if !trimmed.starts_with("+++") {
24 return (None, input);
25 }
26
27 // Find the closing +++
28 let after_opening = &trimmed[3..];
29 let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening);
30
31 if let Some(end_pos) = after_opening.find("\n+++") {
32 let toml_content = &after_opening[..end_pos];
33 let rest_start = end_pos + 4; // skip \n+++
34 let rest = &after_opening[rest_start..];
35 let rest = rest.strip_prefix('\n').unwrap_or(rest);
36
37 // Calculate the actual offset into the original input
38 let rest_offset = input.len() - rest.len();
39 let rest_slice = &input[rest_offset..];
40
41 match toml::from_str::<Frontmatter>(toml_content) {
42 Ok(fm) => (Some(fm), rest_slice),
43 Err(e) => {
44 tracing::warn!(error = %e, "Failed to parse TOML frontmatter");
45 (None, input)
46 }
47 }
48 } else {
49 (None, input)
50 }
51 }
52
53 #[cfg(test)]
54 mod tests {
55 use super::*;
56
57 #[test]
58 fn parse_basic_frontmatter() {
59 let input = "+++\ntitle = \"Hello\"\ndate = \"2026-01-01\"\n+++\n\n# Body";
60 let (fm, rest) = parse_frontmatter(input);
61 let fm = fm.unwrap();
62 assert_eq!(fm.title.as_deref(), Some("Hello"));
63 assert_eq!(fm.date.as_deref(), Some("2026-01-01"));
64 assert!(rest.contains("# Body"));
65 }
66
67 #[test]
68 fn parse_with_tags() {
69 let input = "+++\ntitle = \"Post\"\ntags = [\"rust\", \"web\"]\n+++\nContent";
70 let (fm, _rest) = parse_frontmatter(input);
71 let fm = fm.unwrap();
72 assert_eq!(fm.tags.as_deref(), Some(&["rust".to_string(), "web".to_string()][..]));
73 }
74
75 #[test]
76 fn parse_with_draft() {
77 let input = "+++\ndraft = true\n+++\nContent";
78 let (fm, _rest) = parse_frontmatter(input);
79 assert_eq!(fm.unwrap().draft, Some(true));
80 }
81
82 #[test]
83 fn parse_with_extra_fields() {
84 let input = "+++\ntitle = \"Test\"\ncustom_key = \"custom_value\"\n+++\nBody";
85 let (fm, _) = parse_frontmatter(input);
86 let fm = fm.unwrap();
87 assert_eq!(fm.title.as_deref(), Some("Test"));
88 assert_eq!(
89 fm.extra.get("custom_key").and_then(|v| v.as_str()),
90 Some("custom_value")
91 );
92 }
93
94 #[test]
95 fn no_frontmatter() {
96 let input = "# Just Markdown\n\nBody text";
97 let (fm, rest) = parse_frontmatter(input);
98 assert!(fm.is_none());
99 assert_eq!(rest, input);
100 }
101
102 #[test]
103 fn unclosed_frontmatter() {
104 let input = "+++\ntitle = \"Oops\"\nNo closing delimiter";
105 let (fm, rest) = parse_frontmatter(input);
106 assert!(fm.is_none());
107 assert_eq!(rest, input);
108 }
109
110 #[test]
111 fn invalid_toml_returns_none() {
112 let input = "+++\nnot valid toml {{{\n+++\nBody";
113 let (fm, rest) = parse_frontmatter(input);
114 assert!(fm.is_none());
115 assert_eq!(rest, input);
116 }
117
118 #[test]
119 fn empty_frontmatter() {
120 let input = "+++\n\n+++\nBody";
121 let (fm, rest) = parse_frontmatter(input);
122 let fm = fm.unwrap();
123 assert!(fm.title.is_none());
124 assert!(rest.contains("Body"));
125 }
126
127 #[test]
128 fn frontmatter_with_section() {
129 let input = "+++\nsection = \"guide\"\n+++\nContent";
130 let (fm, _) = parse_frontmatter(input);
131 assert_eq!(fm.unwrap().section.as_deref(), Some("guide"));
132 }
133 }
134