use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct Frontmatter {
pub fields: HashMap<String, String>,
}
impl Frontmatter {
#[must_use]
pub fn new() -> Self {
Self {
fields: HashMap::new(),
}
}
fn parse<'a>(content: &'a str, delimiter: &str) -> Option<(Self, &'a str)> {
let delim_line = format!("{}\n", delimiter);
if !content.starts_with(&delim_line) {
return None;
}
let content_after_open = &content[delimiter.len() + 1..];
let close_pattern = format!("\n{}\n", delimiter);
if let Some(end_pos) = content_after_open.find(&close_pattern) {
let frontmatter_text = &content_after_open[..end_pos];
let remaining = &content_after_open[end_pos + close_pattern.len()..];
let fm = Self::parse_yaml(frontmatter_text);
Some((fm, remaining))
} else {
None
}
}
fn parse_yaml(text: &str) -> Self {
let mut fields = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim().to_string();
let value = value.trim();
let value = if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value[1..value.len() - 1].to_string()
} else {
value.to_string()
};
fields.insert(key, value);
}
}
Self { fields }
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&String> {
self.fields.get(key)
}
#[must_use]
pub fn contains(&self, key: &str) -> bool {
self.fields.contains_key(key)
}
#[must_use]
pub fn title(&self) -> Option<&String> {
self.get("title")
}
#[must_use]
pub fn description(&self) -> Option<&String> {
self.get("description")
}
}
#[must_use]
pub fn extract_frontmatter(
content: &str,
parse_yaml: bool,
parse_toml: bool,
) -> (Option<Frontmatter>, &str) {
if parse_yaml {
if let Some((fm, remaining)) = Frontmatter::parse(content, "---") {
return (Some(fm), remaining);
}
}
if parse_toml {
if let Some((fm, remaining)) = Frontmatter::parse(content, "+++") {
return (Some(fm), remaining);
}
}
(None, content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_yaml_frontmatter() {
let content = r#"---
title: My Document
description: A test document
---
# Content
Body text."#;
let (fm, remaining) = extract_frontmatter(content, true, false);
assert!(fm.is_some());
let fm = fm.unwrap();
assert_eq!(fm.title(), Some(&"My Document".to_string()));
assert_eq!(fm.description(), Some(&"A test document".to_string()));
assert!(remaining.trim_start().starts_with("# Content"));
}
#[test]
fn test_extract_quoted_values() {
let content = r#"---
title: "Quoted Title"
description: 'Single quoted'
---
Content"#;
let (fm, _) = extract_frontmatter(content, true, false);
assert!(fm.is_some());
let fm = fm.unwrap();
assert_eq!(fm.title(), Some(&"Quoted Title".to_string()));
assert_eq!(fm.description(), Some(&"Single quoted".to_string()));
}
#[test]
fn test_no_frontmatter() {
let content = "# No Frontmatter\n\nJust content.";
let (fm, remaining) = extract_frontmatter(content, true, false);
assert!(fm.is_none());
assert_eq!(remaining, content);
}
#[test]
fn test_incomplete_frontmatter() {
let content = "---\ntitle: Test\n\nNo closing delimiter";
let (fm, remaining) = extract_frontmatter(content, true, false);
assert!(fm.is_none());
assert_eq!(remaining, content);
}
#[test]
fn test_toml_frontmatter() {
let content = r#"+++
title = "TOML Doc"
+++
# Content"#;
let (fm, remaining) = extract_frontmatter(content, false, true);
assert!(fm.is_some());
assert!(remaining.trim_start().starts_with("# Content"));
}
}