markdown: Add frontmatter metadata block rendering (#57845)

Adds opt-in rendering for Markdown frontmatter metadata blocks in
Markdown Preview and agent markdown.

- Simple `key: value` metadata blocks now render as a two-column table,
while more complex metadata falls back to a code-style block.
- Metadata block content and key/value rows are parsed in the parser
step, and the request layout simply takes over rendering.

<img width="1288" height="436" alt="image"
src="https://github.com/user-attachments/assets/b35b949a-8bc4-47db-82ef-ed835e9ac06f"
/>

Release Notes:

- Added support for rendering Markdown frontmatter metadata blocks in
Markdown Preview and Agent Panel.
This commit is contained in:
Smit Barmase 2026-05-28 19:17:58 +05:30 committed by GitHub
parent 92b0efeee0
commit f0ed342c19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 523 additions and 27 deletions

View file

@ -775,6 +775,7 @@ impl ContentBlock {
None, None,
MarkdownOptions { MarkdownOptions {
render_mermaid_diagrams: true, render_mermaid_diagrams: true,
render_metadata_blocks: true,
..Default::default() ..Default::default()
}, },
cx, cx,

View file

@ -40,7 +40,8 @@ use gpui::{
use language::{CharClassifier, Language, LanguageRegistry, Rope}; use language::{CharClassifier, Language, LanguageRegistry, Rope};
use parser::CodeBlockMetadata; use parser::CodeBlockMetadata;
use parser::{ use parser::{
MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only,
parse_markdown_with_options,
}; };
use pulldown_cmark::{Alignment, BlockQuoteKind}; use pulldown_cmark::{Alignment, BlockQuoteKind};
use sum_tree::TreeMap; use sum_tree::TreeMap;
@ -350,6 +351,7 @@ pub struct MarkdownOptions {
pub parse_html: bool, pub parse_html: bool,
pub render_mermaid_diagrams: bool, pub render_mermaid_diagrams: bool,
pub parse_heading_slugs: bool, pub parse_heading_slugs: bool,
pub render_metadata_blocks: bool,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@ -847,6 +849,7 @@ impl Markdown {
let should_parse_html = self.options.parse_html; let should_parse_html = self.options.parse_html;
let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams; let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
let should_parse_heading_slugs = self.options.parse_heading_slugs; let should_parse_heading_slugs = self.options.parse_heading_slugs;
let should_parse_metadata_blocks = self.options.render_metadata_blocks;
let language_registry = self.language_registry.clone(); let language_registry = self.language_registry.clone();
let fallback = self.fallback_code_block_language.clone(); let fallback = self.fallback_code_block_language.clone();
@ -860,6 +863,7 @@ impl Markdown {
languages_by_path: TreeMap::default(), languages_by_path: TreeMap::default(),
root_block_starts: Arc::default(), root_block_starts: Arc::default(),
html_blocks: BTreeMap::default(), html_blocks: BTreeMap::default(),
metadata_blocks: BTreeMap::default(),
mermaid_diagrams: BTreeMap::default(), mermaid_diagrams: BTreeMap::default(),
heading_slugs: HashMap::default(), heading_slugs: HashMap::default(),
footnote_definitions: HashMap::default(), footnote_definitions: HashMap::default(),
@ -868,13 +872,18 @@ impl Markdown {
); );
} }
let parsed = let parsed = parse_markdown_with_options(
parse_markdown_with_options(&source, should_parse_html, should_parse_heading_slugs); &source,
should_parse_html,
should_parse_heading_slugs,
should_parse_metadata_blocks,
);
let events = parsed.events; let events = parsed.events;
let language_names = parsed.language_names; let language_names = parsed.language_names;
let paths = parsed.language_paths; let paths = parsed.language_paths;
let root_block_starts = parsed.root_block_starts; let root_block_starts = parsed.root_block_starts;
let html_blocks = parsed.html_blocks; let html_blocks = parsed.html_blocks;
let metadata_blocks = parsed.metadata_blocks;
let heading_slugs = parsed.heading_slugs; let heading_slugs = parsed.heading_slugs;
let footnote_definitions = parsed.footnote_definitions; let footnote_definitions = parsed.footnote_definitions;
let mermaid_diagrams = if should_render_mermaid_diagrams { let mermaid_diagrams = if should_render_mermaid_diagrams {
@ -942,6 +951,7 @@ impl Markdown {
languages_by_path, languages_by_path,
root_block_starts: Arc::from(root_block_starts), root_block_starts: Arc::from(root_block_starts),
html_blocks, html_blocks,
metadata_blocks,
mermaid_diagrams, mermaid_diagrams,
heading_slugs, heading_slugs,
footnote_definitions, footnote_definitions,
@ -1070,6 +1080,7 @@ pub struct ParsedMarkdown {
pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>, pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
pub root_block_starts: Arc<[usize]>, pub root_block_starts: Arc<[usize]>,
pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>, pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
pub(crate) metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>, pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>,
pub heading_slugs: HashMap<SharedString, usize>, pub heading_slugs: HashMap<SharedString, usize>,
pub footnote_definitions: HashMap<SharedString, usize>, pub footnote_definitions: HashMap<SharedString, usize>,
@ -1398,6 +1409,114 @@ impl MarkdownElement {
builder.pop_text_style(); builder.pop_text_style();
} }
fn push_metadata_block(
&self,
builder: &mut MarkdownElementBuilder,
source: &str,
metadata_block: &ParsedMetadataBlock,
markdown_end: usize,
cx: &App,
) {
let content_range = &metadata_block.content_range;
if let Some(rows) = metadata_block.rows.as_deref() {
builder.push_div(
div()
.grid()
.grid_cols(2)
.w_full()
.mb_2()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.overflow_hidden(),
content_range,
markdown_end,
);
for (row_index, row) in rows.iter().enumerate() {
self.push_metadata_cell(
builder,
source,
row.key.clone(),
content_range,
markdown_end,
MetadataCellStyle {
row_index,
is_key: true,
},
cx,
);
self.push_metadata_cell(
builder,
source,
row.value.clone(),
content_range,
markdown_end,
MetadataCellStyle {
row_index,
is_key: false,
},
cx,
);
}
builder.pop_div();
} else {
let mut metadata_block = div().w_full().rounded_md();
metadata_block.style().refine(&self.style.code_block);
builder.push_text_style(self.style.code_block.text.to_owned());
builder.push_code_block(None);
builder.push_div(metadata_block, content_range, markdown_end);
builder.push_text(&source[content_range.clone()], content_range.clone());
builder.trim_trailing_newline();
builder.pop_div();
builder.pop_code_block();
builder.pop_text_style();
}
}
fn push_metadata_cell(
&self,
builder: &mut MarkdownElementBuilder,
source: &str,
text_range: Range<usize>,
block_range: &Range<usize>,
markdown_end: usize,
cell_style: MetadataCellStyle,
cx: &App,
) {
builder.push_div(
div()
.flex()
.flex_col()
.min_w_0()
.px_2()
.py_1()
.border_color(cx.theme().colors().border)
.when(cell_style.row_index > 0, |this| this.border_t_1())
.when(!cell_style.is_key, |this| this.border_l_1())
.when(cell_style.is_key, |this| {
this.bg(cx.theme().colors().panel_background)
}),
block_range,
markdown_end,
);
let text_style = if cell_style.is_key {
TextStyleRefinement {
color: Some(cx.theme().colors().text_muted),
font_weight: Some(FontWeight::SEMIBOLD),
..Default::default()
}
} else {
TextStyleRefinement::default()
};
builder.push_text_style(text_style);
builder.push_text(&source[text_range.clone()], text_range);
builder.pop_text_style();
builder.pop_div();
}
fn push_markdown_list_item( fn push_markdown_list_item(
&self, &self,
builder: &mut MarkdownElementBuilder, builder: &mut MarkdownElementBuilder,
@ -1809,6 +1928,7 @@ impl Element for MarkdownElement {
let mut current_img_block_range: Option<Range<usize>> = None; let mut current_img_block_range: Option<Range<usize>> = None;
let mut handled_html_block = false; let mut handled_html_block = false;
let mut rendered_mermaid_block = false; let mut rendered_mermaid_block = false;
let mut rendered_metadata_block = false;
for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
// Skip alt text for images that rendered // Skip alt text for images that rendered
if let Some(current_img_block_range) = &current_img_block_range if let Some(current_img_block_range) = &current_img_block_range
@ -1832,6 +1952,13 @@ impl Element for MarkdownElement {
continue; continue;
} }
if rendered_metadata_block {
if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) {
rendered_metadata_block = false;
}
continue;
}
match event { match event {
MarkdownEvent::RootStart => { MarkdownEvent::RootStart => {
if self.show_root_block_markers { if self.show_root_block_markers {
@ -2147,7 +2274,20 @@ impl Element for MarkdownElement {
); );
builder.push_div(div().flex_1().w_0(), range, markdown_end); builder.push_div(div().flex_1().w_0(), range, markdown_end);
} }
MarkdownTag::MetadataBlock(_) => {} MarkdownTag::MetadataBlock(_) => {
if let Some(metadata_block) =
parsed_markdown.metadata_blocks.get(&range.start)
{
self.push_metadata_block(
&mut builder,
&parsed_markdown.source,
metadata_block,
markdown_end,
cx,
);
rendered_metadata_block = true;
}
}
MarkdownTag::Table(alignments) => { MarkdownTag::Table(alignments) => {
builder.table.start(alignments.clone()); builder.table.start(alignments.clone());
@ -2359,6 +2499,7 @@ impl Element for MarkdownElement {
builder.pop_div(); builder.pop_div();
builder.pop_div(); builder.pop_div();
} }
MarkdownTagEnd::MetadataBlock(_) => {}
_ => log::debug!("unsupported markdown tag end: {:?}", tag), _ => log::debug!("unsupported markdown tag end: {:?}", tag),
}, },
MarkdownEvent::Text => { MarkdownEvent::Text => {
@ -2752,6 +2893,11 @@ fn alignment_to_text_align(alignment: Alignment) -> Option<TextAlign> {
} }
} }
struct MetadataCellStyle {
row_index: usize,
is_key: bool,
}
struct MarkdownElementBuilder { struct MarkdownElementBuilder {
div_stack: Vec<AnyDiv>, div_stack: Vec<AnyDiv>,
rendered_lines: Vec<RenderedLine>, rendered_lines: Vec<RenderedLine>,
@ -3586,6 +3732,34 @@ mod tests {
render_markdown_with_language_registry(markdown, None, cx) render_markdown_with_language_registry(markdown, None, cx)
} }
#[gpui::test]
fn test_frontmatter_renders_without_delimiters(cx: &mut TestAppContext) {
let rendered = render_markdown_with_options(
"---\ntitle: Post\n---\nBody",
None,
MarkdownOptions {
render_metadata_blocks: true,
..Default::default()
},
cx,
);
assert_eq!(rendered.text_for_range(0..24), "title\nPost\nBody");
}
#[gpui::test]
fn test_frontmatter_falls_back_to_code_block_for_nested_yaml(cx: &mut TestAppContext) {
let rendered = render_markdown_with_options(
"---\ntags:\n - zed\n---\nBody",
None,
MarkdownOptions {
render_metadata_blocks: true,
..Default::default()
},
cx,
);
assert_eq!(rendered.text_for_range(0..26), "tags:\n - zed\nBody");
}
fn render_markdown_with_code_span_link( fn render_markdown_with_code_span_link(
markdown: &str, markdown: &str,
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static, callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
@ -3873,7 +4047,7 @@ mod tests {
#[test] #[test]
fn test_table_checkbox_detection() { fn test_table_checkbox_detection() {
let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
let events = crate::parser::parse_markdown_with_options(md, false, false).events; let events = crate::parser::parse_markdown_with_options(md, false, false, false).events;
let mut in_table = false; let mut in_table = false;
let mut cell_texts: Vec<String> = Vec::new(); let mut cell_texts: Vec<String> = Vec::new();
@ -3915,7 +4089,7 @@ mod tests {
#[test] #[test]
fn test_table_checkbox_marker_source_range() { fn test_table_checkbox_marker_source_range() {
let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
let events = crate::parser::parse_markdown_with_options(md, false, false).events; let events = crate::parser::parse_markdown_with_options(md, false, false, false).events;
let mut in_cell = false; let mut in_cell = false;
let mut pending_text = String::new(); let mut pending_text = String::new();
@ -4192,7 +4366,7 @@ mod tests {
} }
fn has_code_block(markdown: &str) -> bool { fn has_code_block(markdown: &str) -> bool {
let parsed_data = parse_markdown_with_options(markdown, false, false); let parsed_data = parse_markdown_with_options(markdown, false, false, false);
parsed_data parsed_data
.events .events
.iter() .iter()

View file

@ -686,7 +686,8 @@ mod tests {
#[test] #[test]
fn test_extract_mermaid_diagrams_parses_scale() { fn test_extract_mermaid_diagrams_parses_scale() {
let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```"; let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```";
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; let events =
crate::parser::parse_markdown_with_options(markdown, false, false, false).events;
let diagrams = extract_mermaid_diagrams(markdown, &events); let diagrams = extract_mermaid_diagrams(markdown, &events);
assert_eq!(diagrams.len(), 1); assert_eq!(diagrams.len(), 1);
@ -702,7 +703,8 @@ mod tests {
"```mermaid\nblock-beta\n```\n\n", "```mermaid\nblock-beta\n```\n\n",
"```mermaid\nflowchart TD\n A --> B\n```", "```mermaid\nflowchart TD\n A --> B\n```",
); );
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; let events =
crate::parser::parse_markdown_with_options(markdown, false, false, false).events;
let diagrams = extract_mermaid_diagrams(markdown, &events); let diagrams = extract_mermaid_diagrams(markdown, &events);
assert_eq!( assert_eq!(
diagrams.len(), diagrams.len(),

View file

@ -37,10 +37,23 @@ pub(crate) struct ParsedMarkdownData {
pub language_paths: HashSet<Arc<str>>, pub language_paths: HashSet<Arc<str>>,
pub root_block_starts: Vec<usize>, pub root_block_starts: Vec<usize>,
pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>, pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
pub metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
pub heading_slugs: HashMap<SharedString, usize>, pub heading_slugs: HashMap<SharedString, usize>,
pub footnote_definitions: HashMap<SharedString, usize>, pub footnote_definitions: HashMap<SharedString, usize>,
} }
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ParsedMetadataBlock {
pub content_range: Range<usize>,
pub rows: Option<Vec<MetadataRow>>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct MetadataRow {
pub key: Range<usize>,
pub value: Range<usize>,
}
impl ParseState { impl ParseState {
fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) { fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) {
match &event { match &event {
@ -149,27 +162,83 @@ fn build_heading_slugs(
slugs slugs
} }
fn parse_metadata_table_rows(source: &str, source_range: Range<usize>) -> Option<Vec<MetadataRow>> {
let mut rows = Vec::new();
let mut line_start = source_range.start;
for line in source[source_range].split_inclusive('\n') {
let line_end = line_start + line.len();
let content_end = line_start + line.trim_end_matches(['\r', '\n']).len();
let content_range = line_start..content_end;
let line_text = &source[content_range.clone()];
if line_text.is_empty()
|| line_text
.chars()
.next()
.is_some_and(|character| character.is_whitespace())
{
return None;
}
let delimiter = line_text.find(':')?;
let key = trim_metadata_range(source, content_range.start..content_range.start + delimiter);
let value = trim_metadata_range(
source,
content_range.start + delimiter + 1..content_range.end,
);
if key.is_empty() || value.is_empty() {
return None;
}
rows.push(MetadataRow { key, value });
line_start = line_end;
}
if rows.is_empty() { None } else { Some(rows) }
}
fn trim_metadata_range(source: &str, range: Range<usize>) -> Range<usize> {
let text = &source[range.clone()];
let start_offset = text.len() - text.trim_start().len();
let end_offset = text.trim_end().len();
let start = range.start + start_offset;
let end = (range.start + end_offset).max(start);
start..end
}
pub(crate) fn parse_markdown_with_options( pub(crate) fn parse_markdown_with_options(
text: &str, text: &str,
parse_html: bool, parse_html: bool,
parse_heading_slugs: bool, parse_heading_slugs: bool,
parse_metadata_blocks: bool,
) -> ParsedMarkdownData { ) -> ParsedMarkdownData {
let mut state = ParseState::default(); let mut state = ParseState::default();
let mut language_names = HashSet::default(); let mut language_names = HashSet::default();
let mut language_paths = HashSet::default(); let mut language_paths = HashSet::default();
let mut html_blocks = BTreeMap::default(); let mut html_blocks = BTreeMap::default();
let mut metadata_blocks = BTreeMap::default();
let mut within_link = false; let mut within_link = false;
let mut within_code_block = false; let mut within_code_block = false;
let mut within_metadata = false; let mut within_metadata = false;
let mut parser = Parser::new_ext(text, PARSE_OPTIONS) let mut current_metadata_block_start = None;
let mut metadata_block_content_range: Option<Range<usize>> = None;
let parse_options = if parse_metadata_blocks {
PARSE_OPTIONS.union(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS)
} else {
PARSE_OPTIONS
};
let mut parser = Parser::new_ext(text, parse_options)
.into_offset_iter() .into_offset_iter()
.peekable(); .peekable();
while let Some((pulldown_event, range)) = parser.next() { while let Some((pulldown_event, range)) = parser.next() {
if within_metadata { if within_metadata && !parse_metadata_blocks {
if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) = if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock(_)) =
pulldown_event pulldown_event
{ {
within_metadata = false; within_metadata = false;
current_metadata_block_start = None;
metadata_block_content_range = None;
} }
continue; continue;
} }
@ -216,9 +285,14 @@ pub(crate) fn parse_markdown_with_options(
id: SharedString::from(id.into_string()), id: SharedString::from(id.into_string()),
} }
} }
pulldown_cmark::Tag::MetadataBlock(_kind) => { pulldown_cmark::Tag::MetadataBlock(kind) => {
within_metadata = true; within_metadata = true;
continue; current_metadata_block_start = Some(range.start);
metadata_block_content_range = None;
if !parse_metadata_blocks {
continue;
}
MarkdownTag::MetadataBlock(kind)
} }
pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => { pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => {
within_code_block = true; within_code_block = true;
@ -347,6 +421,25 @@ pub(crate) fn parse_markdown_with_options(
within_link = false; within_link = false;
} else if let pulldown_cmark::TagEnd::CodeBlock = tag { } else if let pulldown_cmark::TagEnd::CodeBlock = tag {
within_code_block = false; within_code_block = false;
} else if let pulldown_cmark::TagEnd::MetadataBlock(_) = tag {
within_metadata = false;
let block_start = current_metadata_block_start.take();
let content_range = metadata_block_content_range.take();
if parse_metadata_blocks
&& let (Some(block_start), Some(content_range)) =
(block_start, content_range)
{
metadata_blocks.insert(
block_start,
ParsedMetadataBlock {
rows: parse_metadata_table_rows(text, content_range.clone()),
content_range,
},
);
}
if !parse_metadata_blocks {
continue;
}
} }
state.push_event(range, MarkdownEvent::End(tag)); state.push_event(range, MarkdownEvent::End(tag));
} }
@ -363,6 +456,18 @@ pub(crate) fn parse_markdown_with_options(
} }
} }
if within_metadata {
match &mut metadata_block_content_range {
Some(content_range) => {
content_range.start = content_range.start.min(range.start);
content_range.end = content_range.end.max(range.end);
}
None => metadata_block_content_range = Some(range.clone()),
}
state.push_event(range, MarkdownEvent::Text);
continue;
}
if within_code_block { if within_code_block {
let (range, event) = event_for(text, range, &parsed); let (range, event) = event_for(text, range, &parsed);
state.push_event(range, event); state.push_event(range, event);
@ -541,6 +646,7 @@ pub(crate) fn parse_markdown_with_options(
language_paths, language_paths,
root_block_starts: state.root_block_starts, root_block_starts: state.root_block_starts,
html_blocks, html_blocks,
metadata_blocks,
heading_slugs, heading_slugs,
footnote_definitions, footnote_definitions,
} }
@ -798,8 +904,8 @@ mod tests {
use super::MarkdownTag::*; use super::MarkdownTag::*;
use super::*; use super::*;
const UNWANTED_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS const CONDITIONAL_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
.union(Options::ENABLE_MATH) const UNWANTED_OPTIONS: Options = Options::ENABLE_MATH
.union(Options::ENABLE_DEFINITION_LIST) .union(Options::ENABLE_DEFINITION_LIST)
.union(Options::ENABLE_WIKILINKS); .union(Options::ENABLE_WIKILINKS);
@ -807,21 +913,174 @@ mod tests {
fn all_options_considered() { fn all_options_considered() {
// The purpose of this is to fail when new options are added to pulldown_cmark, so that they // The purpose of this is to fail when new options are added to pulldown_cmark, so that they
// can be evaluated for inclusion. // can be evaluated for inclusion.
assert_eq!(PARSE_OPTIONS.union(UNWANTED_OPTIONS), Options::all()); assert_eq!(
PARSE_OPTIONS
.union(CONDITIONAL_OPTIONS)
.union(UNWANTED_OPTIONS),
Options::all()
);
} }
#[test] #[test]
fn wanted_and_unwanted_options_disjoint() { fn wanted_and_unwanted_options_disjoint() {
assert_eq!( assert_eq!(
PARSE_OPTIONS.intersection(UNWANTED_OPTIONS), PARSE_OPTIONS
.union(CONDITIONAL_OPTIONS)
.intersection(UNWANTED_OPTIONS),
Options::empty() Options::empty()
); );
} }
#[test]
fn test_yaml_style_metadata_block() {
assert_eq!(
parse_markdown_with_options("---\ntitle: Post\n---\n# Heading", false, false, true),
ParsedMarkdownData {
events: vec![
(0..19, RootStart),
(0..19, Start(MetadataBlock(MetadataBlockKind::YamlStyle))),
(4..16, Text),
(
0..19,
End(MarkdownTagEnd::MetadataBlock(MetadataBlockKind::YamlStyle))
),
(0..19, RootEnd(0)),
(20..29, RootStart),
(
20..29,
Start(Heading {
level: HeadingLevel::H1,
id: None,
classes: Vec::new(),
attrs: Vec::new(),
})
),
(22..29, Text),
(20..29, End(MarkdownTagEnd::Heading(HeadingLevel::H1))),
(20..29, RootEnd(1)),
],
root_block_starts: vec![0, 20],
metadata_blocks: BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..16,
rows: Some(vec![MetadataRow {
key: 4..9,
value: 11..15,
}]),
},
)]),
..Default::default()
}
)
}
#[test]
fn test_metadata_block_text_is_verbatim() {
let parsed =
parse_markdown_with_options("---\nurl: https://zed.dev\n---\nBody", false, false, true);
assert!(
parsed
.events
.iter()
.all(|(_, event)| !matches!(event, Start(Link { .. })))
);
}
#[test]
fn test_metadata_blocks_store_table_rows() {
let parsed = parse_markdown_with_options(
"---\ntitle: Post\nauthor: Zed\n---\nBody",
false,
false,
true,
);
assert_eq!(
parsed.metadata_blocks,
BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..28,
rows: Some(vec![
MetadataRow {
key: 4..9,
value: 11..15,
},
MetadataRow {
key: 16..22,
value: 24..27,
},
]),
},
)])
);
}
#[test]
fn test_metadata_blocks_store_fallback_for_nested_yaml() {
let parsed =
parse_markdown_with_options("---\ntags:\n - zed\n---\nBody", false, false, true);
assert_eq!(
parsed.metadata_blocks,
BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..18,
rows: None,
},
)])
);
}
#[test]
fn test_metadata_table_rows_parse_simple_colon_pairs() {
let source = "title: Post\nauthor: Zed\n";
let Some(rows) = parse_metadata_table_rows(source, 0..source.len()) else {
panic!("expected metadata rows");
};
let pairs = rows
.into_iter()
.map(|row| (&source[row.key], &source[row.value]))
.collect::<Vec<_>>();
assert_eq!(pairs, vec![("title", "Post"), ("author", "Zed")]);
}
#[test]
fn test_metadata_table_rows_reject_non_simple_colon_pairs() {
for source in [
"tags:\n - zed\n",
"title = Post\n",
"title:\n",
"title: \n",
": Post\n",
" title: Post\n",
"\n",
] {
assert!(parse_metadata_table_rows(source, 0..source.len()).is_none());
}
}
#[test]
fn test_trim_metadata_range_returns_valid_empty_range() {
let source = "key: \n";
let trimmed = trim_metadata_range(source, 4..7);
assert_eq!(trimmed, 7..7);
assert!(source[trimmed].is_empty());
}
#[test] #[test]
fn test_html_comments() { fn test_html_comments() {
assert_eq!( assert_eq!(
parse_markdown_with_options(" <!--\nrdoc-file=string.c\n-->\nReturns", false, false), parse_markdown_with_options(
" <!--\nrdoc-file=string.c\n-->\nReturns",
false,
false,
false
),
ParsedMarkdownData { ParsedMarkdownData {
events: vec![ events: vec![
(2..30, RootStart), (2..30, RootStart),
@ -851,6 +1110,7 @@ mod tests {
"&nbsp;&nbsp; https://some.url some \\`&#9658;\\` text", "&nbsp;&nbsp; https://some.url some \\`&#9658;\\` text",
false, false,
false, false,
false,
), ),
ParsedMarkdownData { ParsedMarkdownData {
events: vec![ events: vec![
@ -891,6 +1151,7 @@ mod tests {
"You can use the [GitHub Search API](https://docs.github.com/en", "You can use the [GitHub Search API](https://docs.github.com/en",
false, false,
false, false,
false,
) )
.events, .events,
vec![ vec![
@ -925,6 +1186,7 @@ mod tests {
"-- --- ... \"double quoted\" 'single quoted' ----------", "-- --- ... \"double quoted\" 'single quoted' ----------",
false, false,
false, false,
false,
), ),
ParsedMarkdownData { ParsedMarkdownData {
events: vec![ events: vec![
@ -957,7 +1219,12 @@ mod tests {
#[test] #[test]
fn test_code_block_metadata() { fn test_code_block_metadata() {
assert_eq!( assert_eq!(
parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false, false), parse_markdown_with_options(
"```rust\nfn main() {\n let a = 1;\n}\n```",
false,
false,
false
),
ParsedMarkdownData { ParsedMarkdownData {
events: vec![ events: vec![
(0..37, RootStart), (0..37, RootStart),
@ -986,7 +1253,7 @@ mod tests {
} }
); );
assert_eq!( assert_eq!(
parse_markdown_with_options(" fn main() {}", false, false), parse_markdown_with_options(" fn main() {}", false, false, false),
ParsedMarkdownData { ParsedMarkdownData {
events: vec![ events: vec![
(4..16, RootStart), (4..16, RootStart),
@ -1012,7 +1279,7 @@ mod tests {
} }
fn assert_code_block_does_not_emit_links(markdown: &str) { fn assert_code_block_does_not_emit_links(markdown: &str) {
let parsed = parse_markdown_with_options(markdown, false, false); let parsed = parse_markdown_with_options(markdown, false, false, false);
let mut code_block_depth = 0; let mut code_block_depth = 0;
let mut code_block_count = 0; let mut code_block_count = 0;
let mut saw_text_inside_code_block = false; let mut saw_text_inside_code_block = false;
@ -1064,9 +1331,54 @@ mod tests {
} }
#[test] #[test]
fn test_metadata_blocks_do_not_affect_root_blocks() { fn test_metadata_blocks_are_root_blocks() {
assert_eq!( assert_eq!(
parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false, false), parse_markdown_with_options(
"+++\ntitle = \"Example\"\n+++\n\nParagraph",
false,
false,
true
),
ParsedMarkdownData {
events: vec![
(0..25, RootStart),
(0..25, Start(MetadataBlock(MetadataBlockKind::PlusesStyle))),
(4..22, Text),
(
0..25,
End(MarkdownTagEnd::MetadataBlock(
MetadataBlockKind::PlusesStyle
))
),
(0..25, RootEnd(0)),
(27..36, RootStart),
(27..36, Start(Paragraph)),
(27..36, Text),
(27..36, End(MarkdownTagEnd::Paragraph)),
(27..36, RootEnd(1)),
],
root_block_starts: vec![0, 27],
metadata_blocks: BTreeMap::from_iter([(
0,
ParsedMetadataBlock {
content_range: 4..22,
rows: None,
},
)]),
..Default::default()
}
);
}
#[test]
fn test_metadata_blocks_are_omitted_by_default() {
assert_eq!(
parse_markdown_with_options(
"+++\ntitle = \"Example\"\n+++\n\nParagraph",
false,
false,
false
),
ParsedMarkdownData { ParsedMarkdownData {
events: vec![ events: vec![
(27..36, RootStart), (27..36, RootStart),
@ -1088,7 +1400,7 @@ mod tests {
|------|---------| |------|---------|
| [x] | Fix bug | | [x] | Fix bug |
| [ ] | Add feature |"; | [ ] | Add feature |";
let parsed = parse_markdown_with_options(markdown, false, false); let parsed = parse_markdown_with_options(markdown, false, false, false);
let mut in_table = false; let mut in_table = false;
let mut saw_task_list_marker = false; let mut saw_task_list_marker = false;
@ -1164,6 +1476,7 @@ mod tests {
"Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.", "Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.",
false, false,
false, false,
false,
); );
assert_eq!( assert_eq!(
parsed.events, parsed.events,
@ -1194,6 +1507,7 @@ mod tests {
"Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.", "Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.",
false, false,
false, false,
false,
); );
assert_eq!(parsed.footnote_definitions.len(), 2); assert_eq!(parsed.footnote_definitions.len(), 2);
assert!(parsed.footnote_definitions.contains_key("a")); assert!(parsed.footnote_definitions.contains_key("a"));
@ -1211,6 +1525,7 @@ mod tests {
"https:/\\/example.com is equivalent to https://example&#46;com!", "https:/\\/example.com is equivalent to https://example&#46;com!",
false, false,
false, false,
false,
) )
.events, .events,
vec![ vec![
@ -1253,6 +1568,7 @@ mod tests {
"Visit https://example.com/cat\\/é&#8205;☕ for coffee!", "Visit https://example.com/cat\\/é&#8205;☕ for coffee!",
false, false,
false, false,
false,
) )
.events, .events,
[ [
@ -1286,6 +1602,7 @@ mod tests {
"# Hello World\n\n## Code `block`\n\n### Third Level\n\n#### Fourth Level\n\n## Hello World", "# Hello World\n\n## Code `block`\n\n### Third Level\n\n#### Fourth Level\n\n## Hello World",
false, false,
true, true,
false,
); );
assert_eq!(parsed.heading_slugs.len(), 5); assert_eq!(parsed.heading_slugs.len(), 5);
assert!(parsed.heading_slugs.contains_key("hello-world")); assert!(parsed.heading_slugs.contains_key("hello-world"));
@ -1301,6 +1618,7 @@ mod tests {
"# Duplicate\n\nText\n\n## Duplicate\n\nMore text", "# Duplicate\n\nText\n\n## Duplicate\n\nMore text",
false, false,
true, true,
false,
); );
let first = parsed.heading_slugs.get("duplicate").copied(); let first = parsed.heading_slugs.get("duplicate").copied();
let second = parsed.heading_slugs.get("duplicate-1").copied(); let second = parsed.heading_slugs.get("duplicate-1").copied();
@ -1311,7 +1629,7 @@ mod tests {
#[test] #[test]
fn test_heading_slug_collision_with_dedup_suffix() { fn test_heading_slug_collision_with_dedup_suffix() {
let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true); let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true, false);
assert_eq!(parsed.heading_slugs.len(), 3); assert_eq!(parsed.heading_slugs.len(), 3);
assert!(parsed.heading_slugs.contains_key("foo")); assert!(parsed.heading_slugs.contains_key("foo"));
assert!(parsed.heading_slugs.contains_key("foo-1")); assert!(parsed.heading_slugs.contains_key("foo-1"));
@ -1323,7 +1641,7 @@ mod tests {
use pulldown_cmark::BlockQuoteKind; use pulldown_cmark::BlockQuoteKind;
let markdown = "\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!IMPORTANT]\n> Important.\n\n> [!WARNING]\n> A warning.\n\n> [!CAUTION]\n> A caution.\n\n> Plain quote.\n"; let markdown = "\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!IMPORTANT]\n> Important.\n\n> [!WARNING]\n> A warning.\n\n> [!CAUTION]\n> A caution.\n\n> Plain quote.\n";
let parsed = parse_markdown_with_options(markdown, false, false); let parsed = parse_markdown_with_options(markdown, false, false, false);
let block_quote_kinds: Vec<_> = parsed let block_quote_kinds: Vec<_> = parsed
.events .events

View file

@ -223,6 +223,7 @@ impl MarkdownPreviewView {
parse_html: true, parse_html: true,
render_mermaid_diagrams: true, render_mermaid_diagrams: true,
parse_heading_slugs: true, parse_heading_slugs: true,
render_metadata_blocks: true,
..Default::default() ..Default::default()
}, },
cx, cx,