use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Frontmatter {
pub title: String,
#[serde(default)]
pub date: Option<DateTime<Utc>>,
#[serde(default)]
pub updated: Option<DateTime<Utc>>,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub custom_js: Vec<String>,
#[serde(default)]
pub custom_css: Vec<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub weight: i32,
#[serde(default, flatten)]
pub extra: std::collections::HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrontmatterFormat {
Yaml,
Toml,
}
impl FrontmatterFormat {
pub fn delimiter(&self) -> &'static str {
match self {
Self::Yaml => "---",
Self::Toml => "+++",
}
}
}
pub fn split_frontmatter(content: &str) -> Option<(FrontmatterFormat, &str, &str)> {
let content = content.trim_start();
let format = if content.starts_with("---") {
FrontmatterFormat::Yaml
} else if content.starts_with("+++") {
FrontmatterFormat::Toml
} else {
return None;
};
let delimiter = format.delimiter();
let after_first = &content[delimiter.len()..];
let closing_pos = after_first.find(delimiter)?;
let frontmatter = after_first[..closing_pos].trim();
let body = after_first[closing_pos + delimiter.len()..].trim_start();
Some((format, frontmatter, body))
}
pub fn parse_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
let Some((format, fm_str, body)) = split_frontmatter(content) else {
return Ok((Frontmatter::default(), content.to_string()));
};
let frontmatter: Frontmatter = match format {
FrontmatterFormat::Yaml => {
serde_yaml::from_str(fm_str).map_err(|e| CoreError::frontmatter(path, e.to_string()))?
}
FrontmatterFormat::Toml => {
toml::from_str(fm_str).map_err(|e| CoreError::frontmatter(path, e.to_string()))?
}
};
Ok((frontmatter, body.to_string()))
}
pub fn parse_typst_frontmatter(content: &str, path: &Path) -> Result<(Frontmatter, String)> {
let mut fm_lines = Vec::new();
let mut body_start = 0;
let mut in_frontmatter = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "// typstify:frontmatter" {
in_frontmatter = true;
body_start += line.len() + 1; continue;
}
if in_frontmatter {
if let Some(stripped) = trimmed.strip_prefix("// ") {
fm_lines.push(stripped);
body_start += line.len() + 1;
} else if trimmed.starts_with("//") && trimmed.len() == 2 {
body_start += line.len() + 1;
} else {
break;
}
} else {
break;
}
}
if fm_lines.is_empty() {
return Ok((Frontmatter::default(), content.to_string()));
}
let fm_str = fm_lines.join("\n");
let frontmatter: Frontmatter =
serde_yaml::from_str(&fm_str).map_err(|e| CoreError::frontmatter(path, e.to_string()))?;
let body = if body_start < content.len() {
content[body_start..].trim_start().to_string()
} else {
String::new()
};
Ok((frontmatter, body))
}
impl Frontmatter {
pub fn validate(&self, path: &Path) -> Result<()> {
if self.title.is_empty() {
return Err(CoreError::frontmatter(path, "title is required"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_yaml_frontmatter() {
let content = r#"---
title: "Hello World"
date: 2024-01-14
---
This is the body content."#;
let (format, fm, body) = split_frontmatter(content).expect("split");
assert_eq!(format, FrontmatterFormat::Yaml);
assert!(fm.contains("title:"));
assert!(body.starts_with("This is the body"));
}
#[test]
fn test_split_toml_frontmatter() {
let content = r#"+++
title = "Hello World"
date = 2024-01-14
+++
This is the body content."#;
let (format, fm, body) = split_frontmatter(content).expect("split");
assert_eq!(format, FrontmatterFormat::Toml);
assert!(fm.contains("title ="));
assert!(body.starts_with("This is the body"));
}
#[test]
fn test_no_frontmatter() {
let content = "Just some content without frontmatter.";
assert!(split_frontmatter(content).is_none());
}
#[test]
fn test_parse_yaml_frontmatter() {
let content = r#"---
title: "Test Post"
date: 2024-01-14T10:00:00Z
draft: false
tags:
- rust
- test
---
Content here."#;
let (fm, body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
assert_eq!(fm.title, "Test Post");
assert!(fm.date.is_some());
assert!(!fm.draft);
assert_eq!(fm.tags, vec!["rust", "test"]);
assert_eq!(body, "Content here.");
}
#[test]
fn test_parse_toml_frontmatter() {
let content = r#"+++
title = "Test Post"
draft = true
tags = ["rust", "test"]
+++
Content here."#;
let (fm, body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
assert_eq!(fm.title, "Test Post");
assert!(fm.draft);
assert_eq!(fm.tags, vec!["rust", "test"]);
assert_eq!(body, "Content here.");
}
#[test]
fn test_parse_typst_frontmatter() {
let content = r#"// typstify:frontmatter
// title: "My Typst Document"
// date: "2024-01-14T00:00:00Z"
// tags: [typst, docs]
= Heading
Some typst content."#;
let (fm, body) = parse_typst_frontmatter(content, Path::new("test.typ")).expect("parse");
assert_eq!(fm.title, "My Typst Document");
assert_eq!(fm.tags, vec!["typst", "docs"]);
assert!(body.starts_with("= Heading"));
}
#[test]
fn test_frontmatter_with_extra_fields() {
let content = r#"---
title: "Test"
custom_field: "custom value"
---
Body"#;
let (fm, _body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
assert_eq!(fm.title, "Test");
assert!(fm.extra.contains_key("custom_field"));
}
#[test]
fn test_frontmatter_defaults() {
let content = r#"---
title: "Minimal"
---
Body"#;
let (fm, _body) = parse_frontmatter(content, Path::new("test.md")).expect("parse");
assert_eq!(fm.title, "Minimal");
assert!(!fm.draft);
assert!(fm.tags.is_empty());
assert!(fm.date.is_none());
}
#[test]
fn test_validate_missing_title() {
let fm = Frontmatter::default();
let result = fm.validate(Path::new("test.md"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("title"));
}
}