use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FrontMatter {
pub title: Option<String>,
pub date: Option<String>,
pub slug: Option<String>,
pub description: Option<String>,
pub category: Option<String>,
pub canonical: Option<String>,
pub node_id: Option<String>,
pub tags: Vec<String>,
pub comments: Option<bool>,
pub hero: Option<bool>,
pub raw: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FrontMatterKind {
None,
Yaml,
Toml,
}
impl FrontMatterKind {
pub fn is_present(self) -> bool {
!matches!(self, FrontMatterKind::None)
}
}
pub fn strip(input: &str) -> (FrontMatterKind, FrontMatter, &str) {
let (kind, fm_raw, body) = split_delim(input);
let fm = match (&kind, fm_raw) {
(FrontMatterKind::Yaml, Some(raw)) => parse_yaml(raw).unwrap_or_default(),
(FrontMatterKind::Toml, Some(raw)) => parse_toml(raw).unwrap_or_default(),
_ => FrontMatter::default(),
};
(kind, fm, body)
}
fn split_delim(input: &str) -> (FrontMatterKind, Option<&str>, &str) {
let trimmed_start = input.strip_prefix('\u{feff}').unwrap_or(input);
let first_newline = trimmed_start.find('\n').map(|i| i + 1);
let first_line = match first_newline {
Some(n) => &trimmed_start[..n],
None => trimmed_start,
};
let trimmed_line = first_line.trim_end_matches(['\n', '\r']);
let (open, close, kind) = if trimmed_line == "---" {
("---", "---", FrontMatterKind::Yaml)
} else if trimmed_line == "+++" {
("+++", "+++", FrontMatterKind::Toml)
} else {
return (FrontMatterKind::None, None, input);
};
let body_start = first_newline.unwrap_or(trimmed_start.len());
let rest = &trimmed_start[body_start..];
let mut close_pos = None;
for (idx, line) in rest.split_inclusive('\n').enumerate() {
let bare = line.trim_end_matches(['\n', '\r']);
if bare == close {
let line_start_byte: usize =
rest.split_inclusive('\n').take(idx).map(|l| l.len()).sum();
close_pos = Some(line_start_byte);
break;
}
}
let Some(close_byte) = close_pos else {
return (FrontMatterKind::None, None, input);
};
let fm_text = &rest[..close_byte];
let after_fence = &rest[close_byte..];
let body = after_fence
.find('\n')
.map(|i| &after_fence[i + 1..])
.unwrap_or("");
let _ = open; (kind, Some(fm_text), body)
}
#[derive(Deserialize)]
struct YamlFront {
#[serde(default)]
title: Option<String>,
#[serde(default)]
date: Option<serde_yaml::Value>,
#[serde(default)]
slug: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
canonical: Option<String>,
#[serde(default)]
node_id: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
#[serde(default)]
comments: Option<bool>,
#[serde(default)]
hero: Option<bool>,
}
fn parse_yaml(text: &str) -> Option<FrontMatter> {
let raw: serde_json::Value = serde_yaml::from_str(text)
.ok()
.and_then(yaml_value_to_json)
.unwrap_or(serde_json::Value::Null);
let typed: YamlFront = serde_yaml::from_str(text).ok()?;
Some(FrontMatter {
title: typed.title,
date: typed.date.as_ref().map(date_value_to_string),
slug: typed.slug,
description: typed.description,
category: typed.category,
canonical: typed.canonical,
node_id: typed.node_id,
tags: typed.tags.unwrap_or_default(),
comments: typed.comments,
hero: typed.hero,
raw,
})
}
#[derive(Deserialize)]
struct TomlFront {
#[serde(default)]
title: Option<String>,
#[serde(default)]
date: Option<String>,
#[serde(default)]
slug: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
canonical: Option<String>,
#[serde(default)]
node_id: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
#[serde(default)]
comments: Option<bool>,
#[serde(default)]
hero: Option<bool>,
}
fn parse_toml(text: &str) -> Option<FrontMatter> {
let raw: toml::Value = toml::from_str(text).ok()?;
let raw_json = toml_to_json(&raw);
let typed: TomlFront = toml::from_str(text).ok()?;
Some(FrontMatter {
title: typed.title,
date: typed.date,
slug: typed.slug,
description: typed.description,
category: typed.category,
canonical: typed.canonical,
node_id: typed.node_id,
tags: typed.tags.unwrap_or_default(),
comments: typed.comments,
hero: typed.hero,
raw: raw_json,
})
}
fn date_value_to_string(v: &serde_yaml::Value) -> String {
match v {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
other => format!("{other:?}"),
}
}
fn yaml_value_to_json(v: serde_yaml::Value) -> Option<serde_json::Value> {
serde_json::to_value(&v).ok()
}
fn toml_to_json(v: &toml::Value) -> serde_json::Value {
match v {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
toml::Value::Datetime(d) => serde_json::Value::String(d.to_string()),
toml::Value::Array(a) => serde_json::Value::Array(a.iter().map(toml_to_json).collect()),
toml::Value::Table(t) => {
let mut obj = serde_json::Map::new();
for (k, val) in t {
obj.insert(k.clone(), toml_to_json(val));
}
serde_json::Value::Object(obj)
}
}
}
impl FrontMatter {
pub fn effective_node_id(&self) -> Option<&str> {
self.node_id.as_deref().or(self.slug.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_frontmatter_passes_through() {
let (kind, fm, body) = strip("# Hello\n\nBody.");
assert_eq!(kind, FrontMatterKind::None);
assert_eq!(fm, FrontMatter::default());
assert_eq!(body, "# Hello\n\nBody.");
}
#[test]
fn empty_input_is_safe() {
let (kind, _fm, body) = strip("");
assert_eq!(kind, FrontMatterKind::None);
assert_eq!(body, "");
}
#[test]
fn yaml_frontmatter_stripped() {
let doc = "---\ntitle: Hi\ntags: [a, b]\n---\n# Body\n";
let (kind, fm, body) = strip(doc);
assert_eq!(kind, FrontMatterKind::Yaml);
assert_eq!(fm.title.as_deref(), Some("Hi"));
assert_eq!(fm.tags, vec!["a".to_string(), "b".to_string()]);
assert_eq!(body, "# Body\n");
}
#[test]
fn toml_frontmatter_stripped() {
let doc = "+++\ntitle = \"Hi\"\nslug = \"hi\"\n+++\nBody.\n";
let (kind, fm, body) = strip(doc);
assert_eq!(kind, FrontMatterKind::Toml);
assert_eq!(fm.title.as_deref(), Some("Hi"));
assert_eq!(fm.slug.as_deref(), Some("hi"));
assert_eq!(body, "Body.\n");
}
#[test]
fn unclosed_fence_falls_back_to_body() {
let doc = "---\ntitle: dangling\n# heading\n";
let (kind, _fm, body) = strip(doc);
assert_eq!(kind, FrontMatterKind::None);
assert_eq!(body, doc);
}
#[test]
fn closing_fence_not_on_own_line_is_ignored() {
let doc = "---\ntitle: x\n--- trailing\nbody\n";
let (kind, _fm, body) = strip(doc);
assert_eq!(kind, FrontMatterKind::None);
assert_eq!(body, doc);
}
#[test]
fn yaml_comments_field_round_trips() {
let doc = "---\ntitle: X\ncomments: false\n---\nbody\n";
let (_, fm, _) = strip(doc);
assert_eq!(fm.comments, Some(false));
}
#[test]
fn unknown_keys_kept_in_raw() {
let doc = "---\ntitle: X\ncustom_field: hello\nwordpress_id: 42\n---\nbody\n";
let (_, fm, _) = strip(doc);
assert_eq!(fm.raw["custom_field"], serde_json::json!("hello"));
assert_eq!(fm.raw["wordpress_id"], serde_json::json!(42));
}
#[test]
fn effective_node_id_prefers_explicit() {
let fm = FrontMatter {
slug: Some("slug-value".into()),
..Default::default()
};
assert_eq!(fm.effective_node_id(), Some("slug-value"));
let fm = FrontMatter {
slug: Some("slug-value".into()),
node_id: Some("node-value".into()),
..Default::default()
};
assert_eq!(fm.effective_node_id(), Some("node-value"));
}
#[test]
fn crlf_line_endings_handled() {
let doc = "---\r\ntitle: Hi\r\n---\r\n# Body\r\n";
let (kind, fm, body) = strip(doc);
assert_eq!(kind, FrontMatterKind::Yaml);
assert_eq!(fm.title.as_deref(), Some("Hi"));
assert!(body.starts_with("# Body"));
}
}