Skip to main content

dioxus_docs_kit/blog/
types.rs

1use dioxus_mdx::DocNode;
2use serde::Deserialize;
3use std::collections::HashMap;
4
5/// Blog manifest parsed from `_blog.json`.
6#[derive(Debug, Clone, Deserialize)]
7pub struct BlogManifest {
8    #[serde(default)]
9    pub authors: HashMap<String, Author>,
10    pub posts: Vec<String>,
11}
12
13/// Author definition from the blog manifest.
14#[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/// Blog post frontmatter extracted from MDX files.
26#[derive(Debug, Clone, PartialEq, Deserialize)]
27pub struct BlogFrontmatter {
28    pub title: String,
29    #[serde(default)]
30    pub description: Option<String>,
31    /// ISO 8601 date string, e.g. "2026-03-15"
32    pub date: String,
33    /// Author ID referencing `_blog.json` authors map
34    pub author: String,
35    #[serde(default)]
36    pub tags: Vec<String>,
37    /// Cover image path (relative to assets/)
38    #[serde(default, rename = "coverImage")]
39    pub cover_image: Option<String>,
40    /// Set to true to hide from listing
41    #[serde(default)]
42    pub draft: bool,
43    /// Set to true to pin this post to the featured section
44    #[serde(default)]
45    pub featured: bool,
46}
47
48/// A fully parsed blog post.
49#[derive(Debug, Clone, PartialEq)]
50pub struct BlogPost {
51    /// URL slug (from filename)
52    pub slug: String,
53    pub frontmatter: BlogFrontmatter,
54    /// Parsed MDX content nodes
55    pub content: Vec<DocNode>,
56    /// Raw markdown for search indexing and reading time calculation
57    pub raw_markdown: String,
58    /// Estimated reading time in minutes
59    pub reading_time_minutes: u32,
60}
61
62/// A searchable entry in the blog (one per post — blog search has no sections).
63///
64/// The `*_lower` fields are lowercased once at build time so search never
65/// re-lowercases per keystroke.
66#[derive(PartialEq)]
67pub struct BlogSearchEntry {
68    pub slug: String,
69    pub title: String,
70    pub description: String,
71    /// Cleaned post body used for matching and snippet extraction.
72    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
80/// Extract blog frontmatter from MDX content.
81///
82/// Returns the parsed frontmatter and the remaining content after the frontmatter block,
83/// or a description of why the frontmatter is invalid.
84pub 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
103/// Calculate reading time from raw text (words / 200 WPM, minimum 1 minute).
104pub 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        // No `date` field.
141        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}