Skip to main content

gpui_component/text/
frontmatter.rs

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/// Renders top-level YAML frontmatter mappings as a description list.
32///
33/// Enable frontmatter parsing with [`super::MarkdownExtensions::frontmatter`]
34/// before registering this plugin. Values are rendered as plain text; YAML
35/// block scalars with `|-` and `>-` are supported. Quoted/compound values,
36/// comments after values, other block headers, and more-indented folded lines
37/// use TextView's YAML code block fallback.
38#[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        // Remove only the structural indentation, never scalar content spaces.
103        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                // Its significance depends on the indentation of a later line.
109                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                // Folding more-indented lines requires a wider YAML grammar.
120                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                    // Decline syntax we cannot interpret faithfully. The caller
196                    // preserves it using the existing YAML code block fallback.
197                    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}