use serde_json::{Map, Value};
use crate::error::DocError;
const DELIMITER: &str = "---";
pub(crate) fn split_frontmatter(input: &str) -> Result<(Value, &str), DocError> {
let Some(after_open) = opening_delimiter_body(input) else {
return Ok((Value::Object(Map::new()), input));
};
let closing = find_closing_delimiter(after_open)
.ok_or_else(|| DocError::Frontmatter("missing closing --- delimiter".to_string()))?;
let block = &after_open[..closing.block_end];
let body = &after_open[closing.body_start..];
let mut map = Map::new();
for line in block.lines() {
if line.trim().is_empty() {
continue;
}
let (key, value) = parse_line(line)?;
if map.insert(key.clone(), value).is_some() {
return Err(DocError::Frontmatter(format!("duplicate key: {key}")));
}
}
Ok((Value::Object(map), body))
}
fn opening_delimiter_body(input: &str) -> Option<&str> {
let after_marker = input.strip_prefix(DELIMITER)?;
let newline_pos = after_marker.find('\n')?;
let rest_of_line = after_marker[..newline_pos].trim_end_matches('\r');
if !rest_of_line.is_empty() {
return None;
}
Some(&after_marker[newline_pos + 1..])
}
struct ClosingDelimiter {
block_end: usize,
body_start: usize,
}
fn find_closing_delimiter(text: &str) -> Option<ClosingDelimiter> {
let mut offset = 0;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed == DELIMITER {
return Some(ClosingDelimiter {
block_end: offset,
body_start: offset + line.len(),
});
}
offset += line.len();
}
None
}
fn parse_line(line: &str) -> Result<(String, Value), DocError> {
let (key, raw_value) = line
.split_once(':')
.ok_or_else(|| DocError::Frontmatter(format!("expected 'key: value', got: {line}")))?;
let key = key.trim();
if key.is_empty() {
return Err(DocError::Frontmatter(format!("empty key in line: {line}")));
}
let value = parse_value(raw_value.trim())?;
Ok((key.to_string(), value))
}
fn parse_value(raw: &str) -> Result<Value, DocError> {
if let Some(inner) = raw.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let items = inner
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(parse_scalar)
.collect::<Result<Vec<_>, _>>()?;
return Ok(Value::Array(items));
}
parse_scalar(raw)
}
fn parse_scalar(raw: &str) -> Result<Value, DocError> {
if raw.is_empty() {
return Err(DocError::Frontmatter("empty value".to_string()));
}
if let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
if inner.contains('"') {
return Err(DocError::Frontmatter(format!(
"unescaped quote in string: {raw}"
)));
}
return Ok(Value::String(inner.to_string()));
}
match raw {
"true" => Ok(Value::Bool(true)),
"false" => Ok(Value::Bool(false)),
_ => Ok(Value::String(raw.to_string())),
}
}
#[cfg(test)]
#[path = "../tests/unit/frontmatter.rs"]
mod tests;