1use crate::error::{Error, Result};
10use serde::{de::DeserializeOwned, Serialize};
11use std::path::Path;
12
13pub fn parse<'a, T>(path: &Path, text: &'a str) -> Result<(T, &'a str)>
19where
20 T: DeserializeOwned,
21{
22 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
24
25 let after_open = strip_fence_line(text).ok_or_else(|| Error::InvalidFormat {
26 path: path.to_path_buf(),
27 message: "missing opening `---` frontmatter fence".into(),
28 })?;
29
30 let (yaml, body) = split_at_closing_fence(after_open).ok_or_else(|| Error::InvalidFormat {
32 path: path.to_path_buf(),
33 message: "missing closing `---` frontmatter fence".into(),
34 })?;
35
36 let parsed: T = serde_norway::from_str(yaml).map_err(|source| Error::YamlParse {
37 path: path.to_path_buf(),
38 source,
39 })?;
40 Ok((parsed, body))
41}
42
43pub fn render<T: Serialize>(path: &Path, frontmatter: &T, body: &str) -> Result<String> {
47 let yaml = serde_norway::to_string(frontmatter).map_err(|e| Error::InvalidFormat {
48 path: path.to_path_buf(),
49 message: format!("frontmatter serialization failed: {e}"),
50 })?;
51
52 let mut out = String::with_capacity(yaml.len() + body.len() + 16);
53 out.push_str("---\n");
54 out.push_str(&yaml);
55 if !yaml.ends_with('\n') {
56 out.push('\n');
57 }
58 out.push_str("---\n");
59 out.push_str(body);
60 Ok(out)
61}
62
63fn strip_fence_line(text: &str) -> Option<&str> {
66 if let Some(rest) = text.strip_prefix("---\n") {
67 return Some(rest);
68 }
69 if let Some(rest) = text.strip_prefix("---\r\n") {
70 return Some(rest);
71 }
72 None
73}
74
75fn split_at_closing_fence(text: &str) -> Option<(&str, &str)> {
79 let mut cursor = 0usize;
80 let bytes = text.as_bytes();
81 while cursor < bytes.len() {
82 let line_start = cursor;
83 let line_end = match bytes[cursor..].iter().position(|&b| b == b'\n') {
84 Some(off) => cursor + off,
85 None => bytes.len(),
86 };
87 let line_bytes_end = if line_end > line_start && bytes[line_end - 1] == b'\r' {
89 line_end - 1
90 } else {
91 line_end
92 };
93 if &bytes[line_start..line_bytes_end] == b"---" {
94 let yaml = &text[..line_start];
95 let body_start = if line_end < bytes.len() {
97 line_end + 1
98 } else {
99 bytes.len()
100 };
101 return Some((yaml, &text[body_start..]));
102 }
103 cursor = if line_end < bytes.len() {
104 line_end + 1
105 } else {
106 bytes.len()
107 };
108 }
109 None
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use serde::Deserialize;
116 use std::path::PathBuf;
117
118 #[derive(Debug, Deserialize, Serialize, PartialEq)]
119 struct Meta {
120 name: String,
121 #[serde(default)]
122 tags: Vec<String>,
123 }
124
125 fn p() -> PathBuf {
126 PathBuf::from("test.liquid")
127 }
128
129 #[test]
130 fn parse_minimal() {
131 let text = "---\nname: hi\n---\nbody line\n";
132 let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
133 assert_eq!(
134 meta,
135 Meta {
136 name: "hi".into(),
137 tags: vec![]
138 }
139 );
140 assert_eq!(body, "body line\n");
141 }
142
143 #[test]
144 fn parse_empty_body_after_fence() {
145 let text = "---\nname: empty\n---\n";
146 let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
147 assert_eq!(meta.name, "empty");
148 assert_eq!(body, "");
149 }
150
151 #[test]
152 fn parse_no_trailing_newline_on_body() {
153 let text = "---\nname: x\n---\nfinal";
154 let (_meta, body): (Meta, &str) = parse(&p(), text).unwrap();
155 assert_eq!(body, "final");
156 }
157
158 #[test]
159 fn parse_crlf_line_endings_tolerated() {
160 let text = "---\r\nname: crlf\r\n---\r\nbody\r\n";
161 let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
162 assert_eq!(meta.name, "crlf");
163 assert_eq!(body, "body\r\n");
164 }
165
166 #[test]
167 fn parse_bom_stripped() {
168 let text = "\u{feff}---\nname: bom\n---\nbody\n";
169 let (meta, _): (Meta, &str) = parse(&p(), text).unwrap();
170 assert_eq!(meta.name, "bom");
171 }
172
173 #[test]
174 fn parse_body_containing_triple_dash_is_preserved() {
175 let text = "---\nname: x\n---\nintro\n---\nmore body\n";
178 let (_, body): (Meta, &str) = parse(&p(), text).unwrap();
179 assert_eq!(body, "intro\n---\nmore body\n");
180 }
181
182 #[test]
183 fn parse_missing_opening_fence_errors() {
184 let text = "name: x\n---\nbody\n";
185 let err = parse::<Meta>(&p(), text).unwrap_err();
186 match err {
187 Error::InvalidFormat { message, .. } => assert!(message.contains("opening")),
188 other => panic!("expected InvalidFormat, got {other:?}"),
189 }
190 }
191
192 #[test]
193 fn parse_missing_closing_fence_errors() {
194 let text = "---\nname: x\nbody never closes\n";
195 let err = parse::<Meta>(&p(), text).unwrap_err();
196 match err {
197 Error::InvalidFormat { message, .. } => assert!(message.contains("closing")),
198 other => panic!("expected InvalidFormat, got {other:?}"),
199 }
200 }
201
202 #[test]
203 fn parse_invalid_yaml_in_frontmatter_errors() {
204 let text = "---\nname: [unterminated\n---\nbody\n";
205 let err = parse::<Meta>(&p(), text).unwrap_err();
206 assert!(matches!(err, Error::YamlParse { .. }), "got {err:?}");
207 }
208
209 #[test]
210 fn render_round_trip() {
211 let meta = Meta {
212 name: "round".into(),
213 tags: vec!["a".into(), "b".into()],
214 };
215 let body = "hello\nworld\n";
216 let text = render(&p(), &meta, body).unwrap();
217 assert!(text.starts_with("---\n"));
218 assert!(text.contains("name: round"));
219 assert!(text.ends_with("hello\nworld\n"));
220
221 let (parsed, parsed_body): (Meta, &str) = parse(&p(), &text).unwrap();
222 assert_eq!(parsed, meta);
223 assert_eq!(parsed_body, body);
224 }
225
226 #[test]
227 fn render_empty_body_round_trips() {
228 let meta = Meta {
229 name: "empty".into(),
230 tags: vec![],
231 };
232 let text = render(&p(), &meta, "").unwrap();
233 let (parsed, body): (Meta, &str) = parse(&p(), &text).unwrap();
234 assert_eq!(parsed, meta);
235 assert_eq!(body, "");
236 }
237}