use crate::frontmatter;
use crate::permalink;
use anyhow::{Context, Result};
use chrono::{DateTime, NaiveDate, Utc};
use serde::Serialize;
use serde_yaml_ng::{Mapping, Value};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Serialize)]
pub struct Doc {
pub id_path: PathBuf,
pub output_path: PathBuf,
pub template: Option<String>,
pub title: String,
pub summary: String,
pub draft: bool,
pub content: String,
pub terms: BTreeMap<String, BTreeMap<String, String>>,
pub date: DateTime<Utc>,
pub updated: DateTime<Utc>,
pub data: Mapping,
pub links: Vec<PathBuf>,
}
#[derive(Clone, Serialize)]
pub struct DocMeta {
pub id_path: PathBuf,
pub output_path: PathBuf,
pub title: String,
pub summary: String,
pub terms: BTreeMap<String, BTreeMap<String, String>>,
pub date: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
impl From<&Doc> for DocMeta {
fn from(doc: &Doc) -> DocMeta {
DocMeta {
id_path: doc.id_path.clone(),
output_path: doc.output_path.clone(),
title: doc.title.clone(),
summary: doc.summary.clone(),
terms: doc.terms.clone(),
date: doc.date,
updated: doc.updated,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocKind {
Markdown,
Raw,
Yaml,
}
impl From<&Path> for DocKind {
fn from(value: &Path) -> Self {
match value.extension().and_then(|e| e.to_str()) {
Some("md") => DocKind::Markdown,
Some("yaml") => DocKind::Yaml,
Some("yml") => DocKind::Yaml,
_ => DocKind::Raw,
}
}
}
impl Default for Doc {
fn default() -> Doc {
Doc {
id_path: PathBuf::new(),
output_path: PathBuf::new(),
template: None,
title: String::new(),
summary: String::new(),
draft: false,
content: String::new(),
terms: BTreeMap::new(),
date: DateTime::<Utc>::UNIX_EPOCH,
updated: DateTime::<Utc>::UNIX_EPOCH,
data: Mapping::new(),
links: Vec::new(),
}
}
}
impl Doc {
pub fn new(id_path: PathBuf, content: String, data: Mapping) -> Doc {
let output_path = permalink::default_for(&id_path);
Doc {
id_path,
output_path,
content,
data,
..Doc::default()
}
}
pub fn uplift_frontmatter(&mut self, taxonomies: &[String]) {
self.title = uplift_string(&self.data, "title").unwrap_or_default();
self.summary = uplift_string(&self.data, "summary").unwrap_or_default();
self.draft = self
.data
.get("draft")
.and_then(Value::as_bool)
.unwrap_or(false);
self.template = uplift_string(&self.data, "template");
self.terms = uplift_terms(&self.data, taxonomies);
if let Some(date) = parse_date(self.data.get("date")) {
self.date = date;
}
if let Some(updated) = parse_date(self.data.get("updated")) {
self.updated = updated;
}
self.output_path = resolve_output_path(&self.id_path, &self.data, &self.date);
}
pub fn parse(id_path: PathBuf, source: &str) -> Result<Doc> {
let (data, body) = frontmatter::parse(source)?;
Ok(Doc::new(id_path, body, data))
}
pub fn parse_yaml(id_path: PathBuf, source: &str) -> Result<Doc> {
let (data, content) = frontmatter::parse_yaml(source)?;
Ok(Doc::new(id_path, content, data))
}
pub fn kind(&self) -> DocKind {
DocKind::from(self.id_path.as_path())
}
pub fn load(content_dir: &Path, id_path: &Path, taxonomies: &[String]) -> Result<Doc> {
let fs_path = content_dir.join(id_path);
let source = fs::read_to_string(&fs_path)
.with_context(|| format!("could not read {}", fs_path.display()))?;
let meta = fs::metadata(&fs_path)
.with_context(|| format!("could not stat {}", fs_path.display()))?;
let (data, body) = match DocKind::from(id_path) {
DocKind::Yaml => frontmatter::parse_yaml(&source),
_ => frontmatter::parse(&source),
}
.with_context(|| format!("could not parse {}", fs_path.display()))?;
let mut doc = Doc::new(id_path.to_path_buf(), body, data);
doc.date = meta
.created()
.ok()
.map(DateTime::<Utc>::from)
.or_else(|| meta.modified().ok().map(DateTime::<Utc>::from))
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
doc.updated = meta
.modified()
.ok()
.map(DateTime::<Utc>::from)
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
doc.uplift_frontmatter(taxonomies);
Ok(doc)
}
pub fn apply_defaults(&mut self, defaults: &Mapping) {
for (k, v) in defaults {
if !self.data.contains_key(k) {
self.data.insert(k.clone(), v.clone());
}
}
}
}
fn resolve_output_path(id_path: &Path, data: &Mapping, date: &DateTime<Utc>) -> PathBuf {
match data.get("permalink").and_then(Value::as_str) {
Some(pattern) => permalink::expand(pattern, id_path, date, None),
None => permalink::default_for(id_path),
}
}
fn uplift_string(data: &Mapping, key: &str) -> Option<String> {
data.get(key).and_then(Value::as_str).map(str::to_string)
}
fn uplift_terms(
data: &Mapping,
taxonomies: &[String],
) -> BTreeMap<String, BTreeMap<String, String>> {
let mut terms: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for name in taxonomies {
let bucket = terms.entry(name.clone()).or_default();
if let Some(Value::Sequence(seq)) = data.get(name.as_str()) {
for v in seq {
if let Some(text) = v.as_str() {
insert_term(bucket, text);
}
}
}
}
terms
}
pub(crate) fn insert_term(bucket: &mut BTreeMap<String, String>, text: &str) {
let slug = slug::slugify(text);
if slug.is_empty() {
return;
}
bucket.entry(slug).or_insert_with(|| text.to_string());
}
pub(crate) fn parse_date(value: Option<&Value>) -> Option<DateTime<Utc>> {
let s = value?.as_str()?;
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::tempdir;
use serde_yaml_ng::Value;
use std::fs;
use std::io::Write;
fn map_from(pairs: &[(&str, Value)]) -> Mapping {
let mut m = Mapping::new();
for (k, v) in pairs {
m.insert(Value::String((*k).to_string()), v.clone());
}
m
}
fn tax() -> Vec<String> {
vec!["tags".to_string()]
}
fn uplifted(id_path: &str, content: &str, data: Mapping, taxonomies: &[String]) -> Doc {
let mut d = Doc::new(PathBuf::from(id_path), content.to_string(), data);
d.uplift_frontmatter(taxonomies);
d
}
#[test]
fn default_doc_uses_unix_epoch() {
let d = Doc::default();
assert_eq!(d.date, DateTime::<Utc>::UNIX_EPOCH);
assert_eq!(d.updated, DateTime::<Utc>::UNIX_EPOCH);
assert_eq!(d.title, "");
assert_eq!(d.summary, "");
assert!(d.terms.is_empty());
assert!(d.template.is_none());
assert!(!d.draft);
}
#[test]
fn uplift_frontmatter_summary_from_frontmatter() {
let data = map_from(&[("summary", Value::String("Hand-written blurb.".into()))]);
let d = uplifted("p.md", "", data, &tax());
assert_eq!(d.summary, "Hand-written blurb.");
}
#[test]
fn uplift_frontmatter_defaults_summary_to_empty_when_missing() {
let d = uplifted("p.md", "", Mapping::new(), &tax());
assert_eq!(d.summary, "");
}
#[test]
fn uplift_frontmatter_title_and_template() {
let data = map_from(&[
("title", Value::String("Hi".into())),
("template", Value::String("base.html".into())),
]);
let d = uplifted("post.md", "", data, &tax());
assert_eq!(d.title, "Hi");
assert_eq!(d.template.as_deref(), Some("base.html"));
}
#[test]
fn uplift_frontmatter_draft_true() {
let data = map_from(&[("draft", Value::Bool(true))]);
let d = uplifted("p.md", "", data, &tax());
assert!(d.draft);
}
#[test]
fn uplift_frontmatter_draft_false_absent_or_non_bool() {
let explicit = uplifted(
"p.md",
"",
map_from(&[("draft", Value::Bool(false))]),
&tax(),
);
assert!(!explicit.draft);
let missing = uplifted("p.md", "", Mapping::new(), &tax());
assert!(!missing.draft);
let non_bool = uplifted(
"p.md",
"",
map_from(&[("draft", Value::String("yes".into()))]),
&tax(),
);
assert!(!non_bool.draft);
}
#[test]
fn uplift_frontmatter_tags_as_string_sequence() {
let data = map_from(&[(
"tags",
Value::Sequence(vec![
Value::String("rust".into()),
Value::String("ssg".into()),
Value::Number(serde_yaml_ng::Number::from(7)),
]),
)]);
let d = uplifted("p.md", "", data, &tax());
let tags = &d.terms["tags"];
assert_eq!(tags.len(), 2);
assert_eq!(tags["rust"], "rust");
assert_eq!(tags["ssg"], "ssg");
}
#[test]
fn uplift_frontmatter_custom_taxonomy_field() {
let data = map_from(&[(
"categories",
Value::Sequence(vec![Value::String("Tech".into())]),
)]);
let taxonomies = vec!["tags".to_string(), "categories".to_string()];
let d = uplifted("p.md", "", data, &taxonomies);
assert_eq!(d.terms["categories"]["tech"], "Tech");
assert!(d.terms["tags"].is_empty());
}
#[test]
fn uplift_frontmatter_defaults_when_keys_missing() {
let d = uplifted("p.md", "", Mapping::new(), &tax());
assert_eq!(d.title, "");
assert!(d.terms["tags"].is_empty());
assert!(d.template.is_none());
assert_eq!(d.date, DateTime::<Utc>::UNIX_EPOCH);
}
#[test]
fn new_preserves_keys_in_data() {
let data = map_from(&[("title", Value::String("Hi".into()))]);
let d = Doc::new(PathBuf::from("p.md"), String::new(), data);
assert_eq!(d.data.get("title").and_then(Value::as_str), Some("Hi"));
}
#[test]
fn output_path_swaps_extension_to_html() {
let d = Doc::new(PathBuf::from("blog/post.md"), String::new(), Mapping::new());
assert_eq!(d.output_path, PathBuf::from("blog/post.html"));
}
#[test]
fn parse_date_accepts_rfc3339() {
let v = Value::String("2025-10-31T12:00:00Z".into());
let dt = parse_date(Some(&v)).unwrap();
assert_eq!(dt.to_rfc3339(), "2025-10-31T12:00:00+00:00");
}
#[test]
fn parse_date_accepts_date_only() {
let v = Value::String("2025-10-31".into());
let dt = parse_date(Some(&v)).unwrap();
assert_eq!(dt.to_rfc3339(), "2025-10-31T00:00:00+00:00");
}
#[test]
fn parse_date_rejects_garbage() {
let v = Value::String("not a date".into());
assert!(parse_date(Some(&v)).is_none());
assert!(parse_date(None).is_none());
}
#[test]
fn parse_returns_err_on_malformed_yaml() {
let source = "---\ntitle: [unterminated\n---\nbody";
assert!(Doc::parse(PathBuf::from("p.md"), source).is_err());
}
#[test]
fn parse_treats_missing_frontmatter_as_empty() {
let d = Doc::parse(PathBuf::from("p.md"), "# Body\n").unwrap();
assert_eq!(d.content, "# Body\n");
assert!(d.data.is_empty());
}
#[test]
fn load_uses_frontmatter_date_when_present() {
let dir = tempdir("doc");
let path = dir.join("post.md");
let mut f = fs::File::create(&path).unwrap();
writeln!(f, "---\ndate: 2025-10-31\n---\nbody").unwrap();
drop(f);
let d = Doc::load(&dir, Path::new("post.md"), &tax()).unwrap();
assert_eq!(d.date.to_rfc3339(), "2025-10-31T00:00:00+00:00");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn kind_dispatches_on_extension() {
let md = Doc::new(PathBuf::from("p.md"), String::new(), Mapping::new());
let html = Doc::new(PathBuf::from("p.html"), String::new(), Mapping::new());
let yaml = Doc::new(PathBuf::from("p.yaml"), String::new(), Mapping::new());
let xml = Doc::new(PathBuf::from("p.xml"), String::new(), Mapping::new());
let extless = Doc::new(PathBuf::from("p"), String::new(), Mapping::new());
assert_eq!(md.kind(), DocKind::Markdown);
assert_eq!(html.kind(), DocKind::Raw);
assert_eq!(yaml.kind(), DocKind::Yaml);
assert_eq!(xml.kind(), DocKind::Raw);
assert_eq!(extless.kind(), DocKind::Raw);
}
#[test]
fn parse_yaml_extracts_content_and_uplifts() {
let source = "title: Hi\ntags: [a, b]\ndate: 2025-10-31\ncontent: \"<p>body</p>\"\n";
let mut d = Doc::parse_yaml(PathBuf::from("p.yaml"), source).unwrap();
d.uplift_frontmatter(&tax());
assert_eq!(d.title, "Hi");
assert_eq!(d.terms["tags"]["a"], "a");
assert_eq!(d.terms["tags"]["b"], "b");
assert_eq!(d.date.to_rfc3339(), "2025-10-31T00:00:00+00:00");
assert_eq!(d.content, "<p>body</p>");
}
#[test]
fn parse_yaml_defaults_content_when_missing() {
let d = Doc::parse_yaml(PathBuf::from("p.yaml"), "title: Hi\n").unwrap();
assert_eq!(d.content, "");
}
#[test]
fn parse_yaml_defaults_content_when_non_string() {
let d = Doc::parse_yaml(PathBuf::from("p.yaml"), "content: 42\n").unwrap();
assert_eq!(d.content, "");
}
#[test]
fn parse_yaml_errors_on_malformed_yaml() {
assert!(Doc::parse_yaml(PathBuf::from("p.yaml"), "title: [unterminated").is_err());
}
#[test]
fn parse_yaml_output_path_swaps_to_html() {
let d = Doc::parse_yaml(PathBuf::from("page.yaml"), "title: Hi\n").unwrap();
assert_eq!(d.output_path, PathBuf::from("page.html"));
}
#[test]
fn load_dispatches_yaml_to_parse_yaml() {
let dir = tempdir("doc");
let path = dir.join("doc.yaml");
fs::write(&path, "title: From YAML\ncontent: \"<p>hi</p>\"\n").unwrap();
let d = Doc::load(&dir, Path::new("doc.yaml"), &tax()).unwrap();
assert_eq!(d.title, "From YAML");
assert_eq!(d.content, "<p>hi</p>");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_falls_back_to_fs_metadata_when_no_date() {
let dir = tempdir("doc");
let path = dir.join("post.md");
fs::write(&path, "# Hello\n").unwrap();
let d = Doc::load(&dir, Path::new("post.md"), &tax()).unwrap();
assert!(d.date > DateTime::<Utc>::UNIX_EPOCH);
assert!(d.updated > DateTime::<Utc>::UNIX_EPOCH);
let _ = fs::remove_dir_all(&dir);
}
fn mapping(s: &str) -> Mapping {
serde_yaml_ng::from_str(s).unwrap()
}
#[test]
fn apply_defaults_fills_missing_and_recomputes_output_path() {
let mut d = uplifted("posts/hello.md", "", mapping("date: 2025-03-07\n"), &tax());
d.apply_defaults(&mapping("permalink: \":yyyy/:mm/:dd/:slug/\"\n"));
d.uplift_frontmatter(&tax());
assert_eq!(d.output_path, PathBuf::from("2025/03/07/hello/index.html"));
}
#[test]
fn apply_defaults_own_frontmatter_wins() {
let mut d = uplifted(
"posts/hello.md",
"",
mapping("permalink: /custom/\n"),
&tax(),
);
d.apply_defaults(&mapping("permalink: \":slug/\"\n"));
d.uplift_frontmatter(&tax());
assert_eq!(d.output_path, PathBuf::from("custom/index.html"));
}
#[test]
fn apply_defaults_picks_up_template_and_taxonomy_field() {
let taxonomies = vec!["tags".to_string(), "categories".to_string()];
let mut d = uplifted("posts/hello.md", "", Mapping::new(), &taxonomies);
d.apply_defaults(&mapping("template: post.html\ncategories: [Tech]\n"));
d.uplift_frontmatter(&taxonomies);
assert_eq!(d.template.as_deref(), Some("post.html"));
assert_eq!(d.terms["categories"]["tech"], "Tech");
}
}