use dioxus_mdx::DocNode;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct BlogManifest {
#[serde(default)]
pub authors: HashMap<String, Author>,
pub posts: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Author {
pub name: String,
#[serde(default)]
pub avatar: Option<String>,
#[serde(default)]
pub bio: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BlogFrontmatter {
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub date: String,
pub author: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, rename = "coverImage")]
pub cover_image: Option<String>,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub featured: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BlogPost {
pub slug: String,
pub frontmatter: BlogFrontmatter,
pub content: Vec<DocNode>,
pub raw_markdown: String,
pub reading_time_minutes: u32,
}
#[derive(PartialEq)]
pub struct BlogSearchEntry {
pub slug: String,
pub title: String,
pub description: String,
pub body: String,
pub date: String,
pub tags: Vec<String>,
pub(crate) title_lower: String,
pub(crate) description_lower: String,
pub(crate) body_lower: String,
}
pub fn extract_blog_frontmatter(content: &str) -> Result<(BlogFrontmatter, &str), String> {
let content = content.trim();
if !content.starts_with("---") {
return Err("missing frontmatter block (expected leading ---)".to_string());
}
let after_first_delim = &content[3..];
let end_idx = after_first_delim
.find("\n---")
.ok_or_else(|| "unclosed frontmatter block (missing closing ---)".to_string())?;
let yaml_content = after_first_delim[..end_idx].trim();
let remaining = after_first_delim[end_idx + 4..].trim_start();
let fm: BlogFrontmatter =
serde_yaml::from_str(yaml_content).map_err(|e| format!("invalid frontmatter: {e}"))?;
Ok((fm, remaining))
}
pub fn calculate_reading_time(text: &str) -> u32 {
let word_count = text.split_whitespace().count();
((word_count as f64 / 200.0).ceil() as u32).max(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_valid_frontmatter() {
let content =
"---\ntitle: Hello\ndate: \"2026-03-15\"\nauthor: jane\ntags: [rust]\n---\n\nBody text";
let (fm, body) = extract_blog_frontmatter(content).unwrap();
assert_eq!(fm.title, "Hello");
assert_eq!(fm.date, "2026-03-15");
assert_eq!(fm.author, "jane");
assert_eq!(fm.tags, vec!["rust".to_string()]);
assert!(!fm.draft);
assert!(body.starts_with("Body text"));
}
#[test]
fn missing_frontmatter_block_errors() {
let err = extract_blog_frontmatter("Just body text").unwrap_err();
assert!(err.contains("missing frontmatter"), "got: {err}");
}
#[test]
fn unclosed_frontmatter_errors() {
let err = extract_blog_frontmatter("---\ntitle: Hello\nno closing").unwrap_err();
assert!(err.contains("unclosed"), "got: {err}");
}
#[test]
fn missing_required_field_errors_with_detail() {
let err =
extract_blog_frontmatter("---\ntitle: Hello\nauthor: jane\n---\nBody").unwrap_err();
assert!(err.contains("invalid frontmatter"), "got: {err}");
assert!(
err.contains("date"),
"expected serde detail naming the missing field, got: {err}"
);
}
#[test]
fn reading_time_rounds_up_with_minimum() {
assert_eq!(calculate_reading_time("a few words"), 1);
let long = "word ".repeat(400);
assert_eq!(calculate_reading_time(&long), 2);
}
}