use crate::error::{Error, Result};
use serde::{de::DeserializeOwned, Serialize};
use std::path::Path;
pub fn parse<'a, T>(path: &Path, text: &'a str) -> Result<(T, &'a str)>
where
T: DeserializeOwned,
{
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let after_open = strip_fence_line(text).ok_or_else(|| Error::InvalidFormat {
path: path.to_path_buf(),
message: "missing opening `---` frontmatter fence".into(),
})?;
let (yaml, body) = split_at_closing_fence(after_open).ok_or_else(|| Error::InvalidFormat {
path: path.to_path_buf(),
message: "missing closing `---` frontmatter fence".into(),
})?;
let parsed: T = serde_yml::from_str(yaml).map_err(|source| Error::YamlParse {
path: path.to_path_buf(),
source,
})?;
Ok((parsed, body))
}
pub fn render<T: Serialize>(path: &Path, frontmatter: &T, body: &str) -> Result<String> {
let yaml = serde_yml::to_string(frontmatter).map_err(|e| Error::InvalidFormat {
path: path.to_path_buf(),
message: format!("frontmatter serialization failed: {e}"),
})?;
let mut out = String::with_capacity(yaml.len() + body.len() + 16);
out.push_str("---\n");
out.push_str(&yaml);
if !yaml.ends_with('\n') {
out.push('\n');
}
out.push_str("---\n");
out.push_str(body);
Ok(out)
}
fn strip_fence_line(text: &str) -> Option<&str> {
if let Some(rest) = text.strip_prefix("---\n") {
return Some(rest);
}
if let Some(rest) = text.strip_prefix("---\r\n") {
return Some(rest);
}
None
}
fn split_at_closing_fence(text: &str) -> Option<(&str, &str)> {
let mut cursor = 0usize;
let bytes = text.as_bytes();
while cursor < bytes.len() {
let line_start = cursor;
let line_end = match bytes[cursor..].iter().position(|&b| b == b'\n') {
Some(off) => cursor + off,
None => bytes.len(),
};
let line_bytes_end = if line_end > line_start && bytes[line_end - 1] == b'\r' {
line_end - 1
} else {
line_end
};
if &bytes[line_start..line_bytes_end] == b"---" {
let yaml = &text[..line_start];
let body_start = if line_end < bytes.len() {
line_end + 1
} else {
bytes.len()
};
return Some((yaml, &text[body_start..]));
}
cursor = if line_end < bytes.len() {
line_end + 1
} else {
bytes.len()
};
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize, PartialEq)]
struct Meta {
name: String,
#[serde(default)]
tags: Vec<String>,
}
fn p() -> PathBuf {
PathBuf::from("test.liquid")
}
#[test]
fn parse_minimal() {
let text = "---\nname: hi\n---\nbody line\n";
let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
assert_eq!(
meta,
Meta {
name: "hi".into(),
tags: vec![]
}
);
assert_eq!(body, "body line\n");
}
#[test]
fn parse_empty_body_after_fence() {
let text = "---\nname: empty\n---\n";
let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
assert_eq!(meta.name, "empty");
assert_eq!(body, "");
}
#[test]
fn parse_no_trailing_newline_on_body() {
let text = "---\nname: x\n---\nfinal";
let (_meta, body): (Meta, &str) = parse(&p(), text).unwrap();
assert_eq!(body, "final");
}
#[test]
fn parse_crlf_line_endings_tolerated() {
let text = "---\r\nname: crlf\r\n---\r\nbody\r\n";
let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
assert_eq!(meta.name, "crlf");
assert_eq!(body, "body\r\n");
}
#[test]
fn parse_bom_stripped() {
let text = "\u{feff}---\nname: bom\n---\nbody\n";
let (meta, _): (Meta, &str) = parse(&p(), text).unwrap();
assert_eq!(meta.name, "bom");
}
#[test]
fn parse_body_containing_triple_dash_is_preserved() {
let text = "---\nname: x\n---\nintro\n---\nmore body\n";
let (_, body): (Meta, &str) = parse(&p(), text).unwrap();
assert_eq!(body, "intro\n---\nmore body\n");
}
#[test]
fn parse_missing_opening_fence_errors() {
let text = "name: x\n---\nbody\n";
let err = parse::<Meta>(&p(), text).unwrap_err();
match err {
Error::InvalidFormat { message, .. } => assert!(message.contains("opening")),
other => panic!("expected InvalidFormat, got {other:?}"),
}
}
#[test]
fn parse_missing_closing_fence_errors() {
let text = "---\nname: x\nbody never closes\n";
let err = parse::<Meta>(&p(), text).unwrap_err();
match err {
Error::InvalidFormat { message, .. } => assert!(message.contains("closing")),
other => panic!("expected InvalidFormat, got {other:?}"),
}
}
#[test]
fn parse_invalid_yaml_in_frontmatter_errors() {
let text = "---\nname: [unterminated\n---\nbody\n";
let err = parse::<Meta>(&p(), text).unwrap_err();
assert!(matches!(err, Error::YamlParse { .. }), "got {err:?}");
}
#[test]
fn render_round_trip() {
let meta = Meta {
name: "round".into(),
tags: vec!["a".into(), "b".into()],
};
let body = "hello\nworld\n";
let text = render(&p(), &meta, body).unwrap();
assert!(text.starts_with("---\n"));
assert!(text.contains("name: round"));
assert!(text.ends_with("hello\nworld\n"));
let (parsed, parsed_body): (Meta, &str) = parse(&p(), &text).unwrap();
assert_eq!(parsed, meta);
assert_eq!(parsed_body, body);
}
#[test]
fn render_empty_body_round_trips() {
let meta = Meta {
name: "empty".into(),
tags: vec![],
};
let text = render(&p(), &meta, "").unwrap();
let (parsed, body): (Meta, &str) = parse(&p(), &text).unwrap();
assert_eq!(parsed, meta);
assert_eq!(body, "");
}
}