use dioxus_mdx::{DocNode, YamlLiteError, parse_yaml_lite};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct BlogManifest {
#[serde(default)]
pub authors: HashMap<String, Author>,
#[serde(default)]
pub categories: HashMap<String, BlogCategoryMetadata>,
pub posts: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct BlogCategoryMetadata {
pub slug: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BlogCategory {
pub tag: String,
pub slug: String,
pub title: String,
pub description: String,
pub image: Option<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)]
pub struct BlogFrontmatter {
pub title: String,
pub description: Option<String>,
pub date: String,
pub author: String,
pub tags: Vec<String>,
pub cover_image: Option<String>,
pub draft: bool,
pub featured: bool,
}
impl BlogFrontmatter {
fn from_yaml(yaml: &str) -> Result<Self, YamlLiteError> {
let map = parse_yaml_lite(yaml)?;
Ok(Self {
title: map.require_str("title")?,
description: map.optional_str("description")?,
date: map.require_str("date")?,
author: map.require_str("author")?,
tags: map.optional_str_seq("tags")?,
cover_image: map.optional_str("coverImage")?,
draft: map.optional_bool("draft")?,
featured: map.optional_bool("featured")?,
})
}
}
#[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::from_yaml(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 extracts_block_sequence_tags_booleans_and_comments() {
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";
let (fm, body) = extract_blog_frontmatter(content).unwrap();
assert_eq!(fm.title, "It's fine");
assert_eq!(fm.tags, vec!["rust".to_string(), "dioxus".to_string()]);
assert!(fm.draft);
assert!(!fm.featured);
assert_eq!(fm.cover_image, Some("/img/cover.png".to_string()));
assert!(body.starts_with("Body"));
}
#[test]
fn unsupported_yaml_shape_errors() {
let err = extract_blog_frontmatter(
"---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor:\n name: jane\n---\nBody",
)
.unwrap_err();
assert!(err.contains("invalid frontmatter"), "got: {err}");
}
#[test]
fn wrongly_typed_field_errors() {
let err = extract_blog_frontmatter(
"---\ntitle: Hi\ndate: \"2026-01-01\"\nauthor: jane\ntags: rust\n---\nBody",
)
.unwrap_err();
assert!(err.contains("tags"), "got: {err}");
}
#[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);
}
}