1use dioxus_mdx::{DocNode, YamlLiteError, parse_yaml_lite};
2use serde::Deserialize;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Deserialize)]
7pub struct BlogManifest {
8 #[serde(default)]
9 pub authors: HashMap<String, Author>,
10 #[serde(default)]
12 pub categories: HashMap<String, BlogCategoryMetadata>,
13 pub posts: Vec<String>,
14}
15
16#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
18#[serde(default)]
19pub struct BlogCategoryMetadata {
20 pub slug: Option<String>,
21 pub title: Option<String>,
22 pub description: Option<String>,
23 pub image: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct BlogCategory {
29 pub tag: String,
30 pub slug: String,
31 pub title: String,
32 pub description: String,
33 pub image: Option<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Deserialize)]
38pub struct Author {
39 pub name: String,
40 #[serde(default)]
41 pub avatar: Option<String>,
42 #[serde(default)]
43 pub bio: Option<String>,
44 #[serde(default)]
45 pub url: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct BlogFrontmatter {
51 pub title: String,
52 pub description: Option<String>,
53 pub date: String,
55 pub author: String,
57 pub tags: Vec<String>,
58 pub cover_image: Option<String>,
60 pub draft: bool,
62 pub featured: bool,
64}
65
66impl BlogFrontmatter {
67 fn from_yaml(yaml: &str) -> Result<Self, YamlLiteError> {
68 let map = parse_yaml_lite(yaml)?;
69 Ok(Self {
70 title: map.require_str("title")?,
71 description: map.optional_str("description")?,
72 date: map.require_str("date")?,
73 author: map.require_str("author")?,
74 tags: map.optional_str_seq("tags")?,
75 cover_image: map.optional_str("coverImage")?,
76 draft: map.optional_bool("draft")?,
77 featured: map.optional_bool("featured")?,
78 })
79 }
80}
81
82#[derive(Debug, Clone, PartialEq)]
84pub struct BlogPost {
85 pub slug: String,
87 pub frontmatter: BlogFrontmatter,
88 pub content: Vec<DocNode>,
90 pub raw_markdown: String,
92 pub reading_time_minutes: u32,
94}
95
96#[derive(PartialEq)]
101pub struct BlogSearchEntry {
102 pub slug: String,
103 pub title: String,
104 pub description: String,
105 pub body: String,
107 pub date: String,
108 pub tags: Vec<String>,
109 pub(crate) title_lower: String,
110 pub(crate) description_lower: String,
111 pub(crate) body_lower: String,
112}
113
114pub fn extract_blog_frontmatter(content: &str) -> Result<(BlogFrontmatter, &str), String> {
119 let content = content.trim();
120
121 if !content.starts_with("---") {
122 return Err("missing frontmatter block (expected leading ---)".to_string());
123 }
124
125 let after_first_delim = &content[3..];
126 let end_idx = after_first_delim
127 .find("\n---")
128 .ok_or_else(|| "unclosed frontmatter block (missing closing ---)".to_string())?;
129 let yaml_content = after_first_delim[..end_idx].trim();
130 let remaining = after_first_delim[end_idx + 4..].trim_start();
131
132 let fm = BlogFrontmatter::from_yaml(yaml_content)
133 .map_err(|e| format!("invalid frontmatter: {e}"))?;
134 Ok((fm, remaining))
135}
136
137pub fn calculate_reading_time(text: &str) -> u32 {
139 let word_count = text.split_whitespace().count();
140 ((word_count as f64 / 200.0).ceil() as u32).max(1)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn extracts_valid_frontmatter() {
149 let content =
150 "---\ntitle: Hello\ndate: \"2026-03-15\"\nauthor: jane\ntags: [rust]\n---\n\nBody text";
151 let (fm, body) = extract_blog_frontmatter(content).unwrap();
152 assert_eq!(fm.title, "Hello");
153 assert_eq!(fm.date, "2026-03-15");
154 assert_eq!(fm.author, "jane");
155 assert_eq!(fm.tags, vec!["rust".to_string()]);
156 assert!(!fm.draft);
157 assert!(body.starts_with("Body text"));
158 }
159
160 #[test]
161 fn extracts_block_sequence_tags_booleans_and_comments() {
162 let content = "---\n# post metadata\ntitle: 'It''s fine'\ndate: \"2026-03-15\"\nauthor: jane\ntags:\n - rust\n - \"dioxus\"\ndraft: true\nfeatured: false\ncoverImage: /img/cover.png # relative to assets/\n---\n\nBody";
163 let (fm, body) = extract_blog_frontmatter(content).unwrap();
164 assert_eq!(fm.title, "It's fine");
165 assert_eq!(fm.tags, vec!["rust".to_string(), "dioxus".to_string()]);
166 assert!(fm.draft);
167 assert!(!fm.featured);
168 assert_eq!(fm.cover_image, Some("/img/cover.png".to_string()));
169 assert!(body.starts_with("Body"));
170 }
171
172 #[test]
173 fn unsupported_yaml_shape_errors() {
174 let err = extract_blog_frontmatter(
176 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor:\n name: jane\n---\nBody",
177 )
178 .unwrap_err();
179 assert!(err.contains("invalid frontmatter"), "got: {err}");
180 }
181
182 #[test]
183 fn wrongly_typed_field_errors() {
184 let err = extract_blog_frontmatter(
186 "---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: rust\n---\nBody",
187 )
188 .unwrap_err();
189 assert!(err.contains("tags"), "got: {err}");
190 }
191
192 #[test]
193 fn missing_frontmatter_block_errors() {
194 let err = extract_blog_frontmatter("Just body text").unwrap_err();
195 assert!(err.contains("missing frontmatter"), "got: {err}");
196 }
197
198 #[test]
199 fn unclosed_frontmatter_errors() {
200 let err = extract_blog_frontmatter("---\ntitle: Hello\nno closing").unwrap_err();
201 assert!(err.contains("unclosed"), "got: {err}");
202 }
203
204 #[test]
205 fn missing_required_field_errors_with_detail() {
206 let err =
208 extract_blog_frontmatter("---\ntitle: Hello\nauthor: jane\n---\nBody").unwrap_err();
209 assert!(err.contains("invalid frontmatter"), "got: {err}");
210 assert!(
211 err.contains("date"),
212 "expected serde detail naming the missing field, got: {err}"
213 );
214 }
215
216 #[test]
217 fn reading_time_rounds_up_with_minimum() {
218 assert_eq!(calculate_reading_time("a few words"), 1);
219 let long = "word ".repeat(400);
220 assert_eq!(calculate_reading_time(&long), 2);
221 }
222}