Skip to main content

dioxus_docs_kit/blog/
types.rs

1use dioxus_mdx::{DocNode, YamlLiteError, parse_yaml_lite};
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    /// Optional category metadata keyed by the exact post tag.
11    #[serde(default)]
12    pub categories: HashMap<String, BlogCategoryMetadata>,
13    pub posts: Vec<String>,
14}
15
16/// Optional presentation and URL overrides for a tag's category page.
17#[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/// A topic with at least one published post. Membership comes from post tags.
27#[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/// Author definition from the blog manifest.
37#[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/// Blog post frontmatter extracted from MDX files.
49#[derive(Debug, Clone, PartialEq)]
50pub struct BlogFrontmatter {
51    pub title: String,
52    pub description: Option<String>,
53    /// ISO 8601 date string, e.g. "2026-03-15"
54    pub date: String,
55    /// Author ID referencing `_blog.json` authors map
56    pub author: String,
57    pub tags: Vec<String>,
58    /// Cover image path (relative to assets/), from the `coverImage` key
59    pub cover_image: Option<String>,
60    /// Set to true to hide from listing
61    pub draft: bool,
62    /// Set to true to pin this post to the featured section
63    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/// A fully parsed blog post.
83#[derive(Debug, Clone, PartialEq)]
84pub struct BlogPost {
85    /// URL slug (from filename)
86    pub slug: String,
87    pub frontmatter: BlogFrontmatter,
88    /// Parsed MDX content nodes
89    pub content: Vec<DocNode>,
90    /// Raw markdown for search indexing and reading time calculation
91    pub raw_markdown: String,
92    /// Estimated reading time in minutes
93    pub reading_time_minutes: u32,
94}
95
96/// A searchable entry in the blog (one per post — blog search has no sections).
97///
98/// The `*_lower` fields are lowercased once at build time so search never
99/// re-lowercases per keystroke.
100#[derive(PartialEq)]
101pub struct BlogSearchEntry {
102    pub slug: String,
103    pub title: String,
104    pub description: String,
105    /// Cleaned post body used for matching and snippet extraction.
106    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
114/// Extract blog frontmatter from MDX content.
115///
116/// Returns the parsed frontmatter and the remaining content after the frontmatter block,
117/// or a description of why the frontmatter is invalid.
118pub 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
137/// Calculate reading time from raw text (words / 200 WPM, minimum 1 minute).
138pub 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        // A nested mapping is outside the supported frontmatter subset.
175        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        // `tags` must be a sequence, not a scalar.
185        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        // No `date` field.
207        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}