1use gpui::{App, IntoElement, SharedString, Window};
2use markdown::mdast;
3
4use crate::description_list::{DescriptionItem, DescriptionList};
5
6use super::{MarkdownNode, MarkdownParseContext, MarkdownPlugin};
7
8const NODE_NAME: &str = "frontmatter";
9
10#[derive(Debug, Clone, PartialEq)]
11struct FrontmatterEntry {
12 key: SharedString,
13 value: SharedString,
14}
15
16#[derive(Debug, Clone, PartialEq)]
17struct Frontmatter {
18 entries: Vec<FrontmatterEntry>,
19}
20
21impl Frontmatter {
22 fn text(&self) -> String {
23 self.entries
24 .iter()
25 .map(|entry| format!("{}: {}", entry.key, entry.value))
26 .collect::<Vec<_>>()
27 .join("\n")
28 }
29}
30
31#[derive(Default)]
39pub struct FrontmatterPlugin;
40
41impl FrontmatterPlugin {
42 pub fn new() -> Self {
43 Self
44 }
45}
46
47impl MarkdownPlugin for FrontmatterPlugin {
48 fn is_block(&self) -> bool {
49 true
50 }
51
52 fn name(&self) -> &str {
53 NODE_NAME
54 }
55
56 fn parse(&self, node: &mdast::Node, cx: &MarkdownParseContext<'_>) -> Option<MarkdownNode> {
57 let mdast::Node::Yaml(yaml) = node else {
58 return None;
59 };
60 let frontmatter = parse_frontmatter(&yaml.value)?;
61 let text = frontmatter.text();
62
63 Some(
64 MarkdownNode::new(NODE_NAME, frontmatter)
65 .text(text)
66 .markdown(cx.node_source(node).unwrap_or(yaml.value.as_str())),
67 )
68 }
69
70 fn render(&self, node: &MarkdownNode, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
71 let frontmatter = node.data::<Frontmatter>().expect("frontmatter node data");
72
73 DescriptionList::horizontal()
74 .label_width(gpui::rems(12.))
75 .columns(1)
76 .children(
77 frontmatter.entries.iter().map(|entry| {
78 DescriptionItem::new(entry.key.clone()).value(entry.value.clone())
79 }),
80 )
81 }
82}
83
84fn parse_frontmatter(value: &str) -> Option<Frontmatter> {
85 #[derive(Clone, Copy)]
86 enum ScalarStyle {
87 Plain,
88 Folded,
89 Literal,
90 Empty,
91 }
92
93 struct Entry {
94 key: String,
95 value: String,
96 style: ScalarStyle,
97 indent: Option<usize>,
98 lines: usize,
99 }
100
101 fn push_continuation(entry: &mut Entry, line: &str) -> Option<()> {
102 let line = if line.is_empty() {
104 ""
105 } else {
106 let indent = line.bytes().take_while(|byte| *byte == b' ').count();
107 if indent == line.len() && entry.indent.is_none() {
108 return None;
110 }
111 let required = *entry.indent.get_or_insert(indent);
112 if required == 0 || (indent < required && indent != line.len()) {
113 return None;
114 }
115 &line[required.min(line.len())..]
116 };
117 match entry.style {
118 ScalarStyle::Folded => {
119 if line.starts_with([' ', '\t']) {
121 return None;
122 }
123 if line.is_empty() {
124 entry.value.push('\n');
125 entry.lines += 1;
126 return Some(());
127 }
128 if !entry.value.is_empty() && !entry.value.ends_with('\n') {
129 entry.value.push(' ');
130 }
131 }
132 ScalarStyle::Literal => {
133 if entry.lines > 0 {
134 entry.value.push('\n');
135 }
136 }
137 ScalarStyle::Plain | ScalarStyle::Empty => return None,
138 }
139 entry.value.push_str(line);
140 entry.lines += 1;
141 Some(())
142 }
143
144 fn is_plain_key(key: &str) -> bool {
145 !key.is_empty()
146 && key
147 .chars()
148 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
149 }
150
151 let mut entries = Vec::new();
152 let mut current: Option<Entry> = None;
153
154 for line in value.lines() {
155 let trimmed = line.trim();
156 if trimmed.is_empty() {
157 if current.as_ref().is_some_and(|entry| {
158 matches!(entry.style, ScalarStyle::Folded | ScalarStyle::Literal)
159 }) {
160 push_continuation(current.as_mut()?, line)?;
161 }
162 continue;
163 }
164
165 let is_top_level = !line.starts_with([' ', '\t']);
166 if trimmed.starts_with('#')
167 && (is_top_level
168 || !current.as_ref().is_some_and(|entry| {
169 matches!(entry.style, ScalarStyle::Folded | ScalarStyle::Literal)
170 }))
171 {
172 continue;
173 }
174
175 if is_top_level {
176 let (key, raw_value) = line.split_once(':')?;
177 if !raw_value.is_empty() && !raw_value.starts_with([' ', '\t']) {
178 return None;
179 }
180 let key = key.trim();
181 if !is_plain_key(key) {
182 return None;
183 }
184
185 if let Some(entry) = current.take() {
186 entries.push(entry);
187 }
188
189 let raw_value = raw_value.trim();
190 let (value, style) = match raw_value {
191 ">-" => (String::new(), ScalarStyle::Folded),
192 "|-" => (String::new(), ScalarStyle::Literal),
193 "" => (String::new(), ScalarStyle::Empty),
194 _ => {
195 if raw_value.starts_with([
198 '\'', '"', '[', ']', '{', '}', '&', '*', '!', '|', '>', '#', '%', '@', '`',
199 ]) || ["- ", "? ", ": ", "-\t", "?\t", ":\t"]
200 .iter()
201 .any(|prefix| raw_value.starts_with(prefix))
202 || raw_value.ends_with(':')
203 || [" #", "\t#", ": ", ":\t"]
204 .iter()
205 .any(|pattern| raw_value.contains(pattern))
206 {
207 return None;
208 }
209 (raw_value.to_string(), ScalarStyle::Plain)
210 }
211 };
212 current = Some(Entry {
213 key: key.to_string(),
214 value,
215 style,
216 indent: None,
217 lines: 0,
218 });
219 } else if current
220 .as_ref()
221 .is_some_and(|entry| matches!(entry.style, ScalarStyle::Folded | ScalarStyle::Literal))
222 {
223 push_continuation(current.as_mut()?, line)?;
224 } else {
225 return None;
226 }
227 }
228
229 if let Some(entry) = current {
230 entries.push(entry);
231 }
232 if entries.is_empty() {
233 return None;
234 }
235
236 Some(Frontmatter {
237 entries: entries
238 .into_iter()
239 .map(|entry| FrontmatterEntry {
240 key: entry.key.into(),
241 value: if matches!(entry.style, ScalarStyle::Folded | ScalarStyle::Literal) {
242 entry.value.trim_end_matches('\n').to_owned().into()
243 } else {
244 entry.value.into()
245 },
246 })
247 .collect(),
248 })
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn parses_plain_mapping_as_metadata() {
257 let frontmatter = parse_frontmatter(
258 "name: gpui-component-dev\ndescription: Contributing to `crates/ui`.",
259 )
260 .expect("frontmatter mapping");
261
262 assert_eq!(frontmatter.entries.len(), 2);
263 assert_eq!(frontmatter.entries[0].key.as_ref(), "name");
264 assert_eq!(frontmatter.entries[0].value.as_ref(), "gpui-component-dev");
265 assert_eq!(
266 frontmatter.entries[1].value.as_ref(),
267 "Contributing to `crates/ui`."
268 );
269 }
270
271 #[test]
272 fn parses_block_scalars() {
273 let frontmatter = parse_frontmatter(
274 "description: >-\n First line\n second line.\nnotes: |-\n # literal content\n\n second line",
275 )
276 .expect("frontmatter mapping");
277
278 assert_eq!(
279 frontmatter.entries[0].value.as_ref(),
280 "First line second line."
281 );
282 assert_eq!(
283 frontmatter.entries[1].value.as_ref(),
284 "# literal content\n\nsecond line"
285 );
286 }
287
288 #[test]
289 fn preserves_blank_lines_in_folded_scalars() {
290 let frontmatter =
291 parse_frontmatter("description: >-\n First paragraph.\n\n Second paragraph.")
292 .expect("frontmatter mapping");
293
294 assert_eq!(
295 frontmatter.entries[0].value.as_ref(),
296 "First paragraph.\nSecond paragraph."
297 );
298 }
299
300 #[test]
301 fn preserves_indented_hashes_in_folded_scalars() {
302 let frontmatter =
303 parse_frontmatter("description: >-\n First line\n # not a comment\n last line")
304 .expect("frontmatter mapping");
305
306 assert_eq!(
307 frontmatter.entries[0].value.as_ref(),
308 "First line # not a comment last line"
309 );
310 }
311
312 #[test]
313 fn rejects_nested_mappings() {
314 assert!(parse_frontmatter("config:\n theme: dark").is_none());
315 }
316
317 #[test]
318 fn rejects_non_mapping_yaml() {
319 assert!(parse_frontmatter("- name: example").is_none());
320 }
321
322 #[test]
323 fn preserves_literal_scalar_whitespace() {
324 let parsed = parse_frontmatter("notes: |-\n\n first \n indented\n \n last\n\n")
325 .expect("literal scalar");
326 assert_eq!(
327 parsed.entries[0].value.as_ref(),
328 "\nfirst \n indented\n \nlast"
329 );
330 }
331
332 #[test]
333 fn rejects_unsupported_scalar_syntax() {
334 for source in [
335 "title: Hello # comment",
336 "title: \"Hello\\nworld\"",
337 "title: 'Hello'",
338 "tags: [one, two]",
339 "config: {theme: dark}",
340 "value: &anchor hello",
341 "value: *anchor",
342 "value: !!str hello",
343 "value: a: b",
344 "notes: >-\n first\n indented\n last",
345 "notes: |-\n first\n invalid indentation",
346 "notes: |2-\n text",
347 "notes: |\n text",
348 "notes: >+\n text",
349 ] {
350 assert!(parse_frontmatter(source).is_none(), "{source:?}");
351 }
352 }
353
354 #[test]
355 fn preserves_plain_scalar_punctuation() {
356 let parsed = parse_frontmatter("url: https://example.com/#section\nvalue: -42")
357 .expect("plain values");
358 assert_eq!(
359 parsed.entries[0].value.as_ref(),
360 "https://example.com/#section"
361 );
362 assert_eq!(parsed.entries[1].value.as_ref(), "-42");
363 }
364}