1 //! Shared parsing for Markdown files with a leading YAML-ish frontmatter
2 //! block, e.g.:
3 //!
4 //! ```markdown
5 //! ---
6 //! name: foo
7 //! description: bar
8 //! ---
9 //!
10 //! Body text.
11 //! ```
12 //!
13 //! Used by [`crate::skills`] (`SKILL.md`) and [`crate::commands`]
14 //! (`.sigit/commands/*.md`) — both need the same "top-level `key: value`
15 //! scalars, then a Markdown body" shape, so the parsing lives here once
16 //! rather than being copied per format.
17
18 /// Extract the YAML frontmatter block from `contents`: the text between a
19 /// leading `---` line and the next `---` line. Returns `None` if absent.
20 pub fn extract_frontmatter(contents: &str) -> Option<&str> {
21 // Strip an optional UTF-8 BOM and leading blank lines before the opener.
22 let trimmed = contents.trim_start_matches('\u{feff}');
23 let mut rest = trimmed;
24 loop {
25 let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
26 let (line, after) = rest.split_at(line_end);
27 if line.trim().is_empty() {
28 rest = after;
29 continue;
30 }
31 if line.trim() != "---" {
32 return None;
33 }
34 // `after` now begins just past the opening `---` line.
35 let body = after;
36 let mut search = body;
37 let mut offset = 0;
38 loop {
39 let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
40 let (l, a) = search.split_at(end);
41 if l.trim() == "---" {
42 return Some(&body[..offset]);
43 }
44 if a.is_empty() {
45 return None;
46 }
47 offset += end;
48 search = a;
49 }
50 }
51 }
52
53 /// Parse top-level `key: value` scalar pairs from frontmatter, skipping
54 /// nested mappings (indented lines) and comments. Quoted values are
55 /// unquoted.
56 pub fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
57 let mut fields = Vec::new();
58 for line in frontmatter.lines() {
59 // Indented lines belong to a nested mapping/sequence — skip them.
60 if line.starts_with(char::is_whitespace) {
61 continue;
62 }
63 let line = line.trim_end();
64 if line.is_empty() || line.starts_with('#') {
65 continue;
66 }
67 let Some((key, value)) = line.split_once(':') else {
68 continue;
69 };
70 let key = key.trim();
71 if key.is_empty() {
72 continue;
73 }
74 let value = unquote(value.trim());
75 fields.push((key.to_string(), value));
76 }
77 fields
78 }
79
80 /// Strip a single layer of matching single or double quotes; otherwise
81 /// return the value unchanged.
82 pub fn unquote(value: &str) -> String {
83 if value.len() >= 2 {
84 let bytes = value.as_bytes();
85 let first = bytes[0];
86 let last = bytes[value.len() - 1];
87 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
88 return value[1..value.len() - 1].to_string();
89 }
90 }
91 value.to_string()
92 }
93
94 /// Return the content after the frontmatter block, or the whole input if
95 /// there is no frontmatter.
96 pub fn strip_frontmatter(contents: &str) -> &str {
97 // Mirror `extract_frontmatter`'s leniency exactly: tolerate a UTF-8 BOM,
98 // leading blank lines before the opener, and CRLF line endings (compare
99 // trimmed lines). If the two disagree, a file whose frontmatter parses
100 // still leaks that raw block into the body — see the round-trip test.
101 let trimmed = contents.trim_start_matches('\u{feff}');
102 let mut rest = trimmed;
103 loop {
104 let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
105 let (line, after) = rest.split_at(line_end);
106 if line.trim().is_empty() {
107 rest = after;
108 continue;
109 }
110 if line.trim() != "---" {
111 return contents;
112 }
113 // `after` begins just past the opening `---` line; find the closing one.
114 let mut search = after;
115 loop {
116 let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
117 let (l, a) = search.split_at(end);
118 if l.trim() == "---" {
119 return a;
120 }
121 if a.is_empty() {
122 return contents;
123 }
124 search = a;
125 }
126 }
127 }
128
129 #[cfg(test)]
130 mod tests {
131 use super::*;
132
133 #[test]
134 fn extract_frontmatter_reads_block() {
135 let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";
136 let fm = extract_frontmatter(md).expect("frontmatter");
137 assert!(fm.contains("name: foo"));
138 assert!(fm.contains("description: bar"));
139 assert!(!fm.contains("Body here"));
140 }
141
142 #[test]
143 fn extract_frontmatter_none_without_delimiter() {
144 assert!(extract_frontmatter("no frontmatter here").is_none());
145 assert!(extract_frontmatter("---\nname: foo\n").is_none());
146 }
147
148 #[test]
149 fn parse_fields_handles_quotes_and_nesting() {
150 let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";
151 let fields = parse_frontmatter_fields(fm);
152 let get = |k: &str| {
153 fields
154 .iter()
155 .find(|(key, _)| key == k)
156 .map(|(_, v)| v.clone())
157 };
158 assert_eq!(get("name").as_deref(), Some("pdf-processing"));
159 assert_eq!(get("description").as_deref(), Some("Extract PDF text"));
160 assert_eq!(get("license").as_deref(), Some("Apache-2.0"));
161 // Nested keys under `metadata:` are skipped.
162 assert!(get("author").is_none());
163 assert!(get("version").is_none());
164 }
165
166 #[test]
167 fn strip_frontmatter_returns_body() {
168 let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";
169 assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");
170 }
171
172 #[test]
173 fn strip_frontmatter_passes_through_without_block() {
174 assert_eq!(strip_frontmatter("just body"), "just body");
175 }
176
177 #[test]
178 fn extract_and_strip_agree_on_crlf_and_leading_blanks() {
179 // Whenever extract_frontmatter finds a block, strip_frontmatter must
180 // remove exactly that block and never leak it into the body. These
181 // inputs (CRLF endings, a leading blank line, a BOM) parse fine in
182 // extract but used to defeat strip's byte-exact opener check.
183 for md in [
184 "---\r\nname: foo\r\ndescription: bar\r\n---\r\n\r\nBody.\r\n",
185 "\n---\nname: foo\ndescription: bar\n---\n\nBody.\n",
186 "\u{feff}---\nname: foo\ndescription: bar\n---\n\nBody.\n",
187 ] {
188 assert!(
189 extract_frontmatter(md).is_some(),
190 "extract should find frontmatter in {md:?}"
191 );
192 let body = strip_frontmatter(md);
193 assert!(
194 !body.contains("name: foo"),
195 "frontmatter leaked into body for {md:?}: {body:?}"
196 );
197 assert_eq!(body.trim(), "Body.");
198 }
199 }
200 }