dioxus_docs_kit/blog/
types.rs1use dioxus_mdx::DocNode;
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 pub posts: Vec<String>,
11}
12
13#[derive(Debug, Clone, PartialEq, Deserialize)]
15pub struct Author {
16 pub name: String,
17 #[serde(default)]
18 pub avatar: Option<String>,
19 #[serde(default)]
20 pub bio: Option<String>,
21 #[serde(default)]
22 pub url: Option<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Deserialize)]
27pub struct BlogFrontmatter {
28 pub title: String,
29 #[serde(default)]
30 pub description: Option<String>,
31 pub date: String,
33 pub author: String,
35 #[serde(default)]
36 pub tags: Vec<String>,
37 #[serde(default, rename = "coverImage")]
39 pub cover_image: Option<String>,
40 #[serde(default)]
42 pub draft: bool,
43 #[serde(default)]
45 pub featured: bool,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct BlogPost {
51 pub slug: String,
53 pub frontmatter: BlogFrontmatter,
54 pub content: Vec<DocNode>,
56 pub raw_markdown: String,
58 pub reading_time_minutes: u32,
60}
61
62#[derive(PartialEq)]
67pub struct BlogSearchEntry {
68 pub slug: String,
69 pub title: String,
70 pub description: String,
71 pub body: String,
73 pub date: String,
74 pub tags: Vec<String>,
75 pub(crate) title_lower: String,
76 pub(crate) description_lower: String,
77 pub(crate) body_lower: String,
78}
79
80pub fn extract_blog_frontmatter(content: &str) -> Result<(BlogFrontmatter, &str), String> {
85 let content = content.trim();
86
87 if !content.starts_with("---") {
88 return Err("missing frontmatter block (expected leading ---)".to_string());
89 }
90
91 let after_first_delim = &content[3..];
92 let end_idx = after_first_delim
93 .find("\n---")
94 .ok_or_else(|| "unclosed frontmatter block (missing closing ---)".to_string())?;
95 let yaml_content = after_first_delim[..end_idx].trim();
96 let remaining = after_first_delim[end_idx + 4..].trim_start();
97
98 let fm: BlogFrontmatter =
99 serde_yaml::from_str(yaml_content).map_err(|e| format!("invalid frontmatter: {e}"))?;
100 Ok((fm, remaining))
101}
102
103pub fn calculate_reading_time(text: &str) -> u32 {
105 let word_count = text.split_whitespace().count();
106 ((word_count as f64 / 200.0).ceil() as u32).max(1)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn extracts_valid_frontmatter() {
115 let content =
116 "---\ntitle: Hello\ndate: \"2026-03-15\"\nauthor: jane\ntags: [rust]\n---\n\nBody text";
117 let (fm, body) = extract_blog_frontmatter(content).unwrap();
118 assert_eq!(fm.title, "Hello");
119 assert_eq!(fm.date, "2026-03-15");
120 assert_eq!(fm.author, "jane");
121 assert_eq!(fm.tags, vec!["rust".to_string()]);
122 assert!(!fm.draft);
123 assert!(body.starts_with("Body text"));
124 }
125
126 #[test]
127 fn missing_frontmatter_block_errors() {
128 let err = extract_blog_frontmatter("Just body text").unwrap_err();
129 assert!(err.contains("missing frontmatter"), "got: {err}");
130 }
131
132 #[test]
133 fn unclosed_frontmatter_errors() {
134 let err = extract_blog_frontmatter("---\ntitle: Hello\nno closing").unwrap_err();
135 assert!(err.contains("unclosed"), "got: {err}");
136 }
137
138 #[test]
139 fn missing_required_field_errors_with_detail() {
140 let err =
142 extract_blog_frontmatter("---\ntitle: Hello\nauthor: jane\n---\nBody").unwrap_err();
143 assert!(err.contains("invalid frontmatter"), "got: {err}");
144 assert!(
145 err.contains("date"),
146 "expected serde detail naming the missing field, got: {err}"
147 );
148 }
149
150 #[test]
151 fn reading_time_rounds_up_with_minimum() {
152 assert_eq!(calculate_reading_time("a few words"), 1);
153 let long = "word ".repeat(400);
154 assert_eq!(calculate_reading_time(&long), 2);
155 }
156}