mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-30 03:34:30 +00:00
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:
parent
92b0efeee0
commit
f0ed342c19
5 changed files with 523 additions and 27 deletions
|
|
@ -775,6 +775,7 @@ impl ContentBlock {
|
|||
None,
|
||||
MarkdownOptions {
|
||||
render_mermaid_diagrams: true,
|
||||
render_metadata_blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ use gpui::{
|
|||
use language::{CharClassifier, Language, LanguageRegistry, Rope};
|
||||
use parser::CodeBlockMetadata;
|
||||
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 sum_tree::TreeMap;
|
||||
|
|
@ -350,6 +351,7 @@ pub struct MarkdownOptions {
|
|||
pub parse_html: bool,
|
||||
pub render_mermaid_diagrams: bool,
|
||||
pub parse_heading_slugs: bool,
|
||||
pub render_metadata_blocks: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -847,6 +849,7 @@ impl Markdown {
|
|||
let should_parse_html = self.options.parse_html;
|
||||
let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
|
||||
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 fallback = self.fallback_code_block_language.clone();
|
||||
|
||||
|
|
@ -860,6 +863,7 @@ impl Markdown {
|
|||
languages_by_path: TreeMap::default(),
|
||||
root_block_starts: Arc::default(),
|
||||
html_blocks: BTreeMap::default(),
|
||||
metadata_blocks: BTreeMap::default(),
|
||||
mermaid_diagrams: BTreeMap::default(),
|
||||
heading_slugs: HashMap::default(),
|
||||
footnote_definitions: HashMap::default(),
|
||||
|
|
@ -868,13 +872,18 @@ impl Markdown {
|
|||
);
|
||||
}
|
||||
|
||||
let parsed =
|
||||
parse_markdown_with_options(&source, should_parse_html, should_parse_heading_slugs);
|
||||
let parsed = parse_markdown_with_options(
|
||||
&source,
|
||||
should_parse_html,
|
||||
should_parse_heading_slugs,
|
||||
should_parse_metadata_blocks,
|
||||
);
|
||||
let events = parsed.events;
|
||||
let language_names = parsed.language_names;
|
||||
let paths = parsed.language_paths;
|
||||
let root_block_starts = parsed.root_block_starts;
|
||||
let html_blocks = parsed.html_blocks;
|
||||
let metadata_blocks = parsed.metadata_blocks;
|
||||
let heading_slugs = parsed.heading_slugs;
|
||||
let footnote_definitions = parsed.footnote_definitions;
|
||||
let mermaid_diagrams = if should_render_mermaid_diagrams {
|
||||
|
|
@ -942,6 +951,7 @@ impl Markdown {
|
|||
languages_by_path,
|
||||
root_block_starts: Arc::from(root_block_starts),
|
||||
html_blocks,
|
||||
metadata_blocks,
|
||||
mermaid_diagrams,
|
||||
heading_slugs,
|
||||
footnote_definitions,
|
||||
|
|
@ -1070,6 +1080,7 @@ pub struct ParsedMarkdown {
|
|||
pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
|
||||
pub root_block_starts: Arc<[usize]>,
|
||||
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 heading_slugs: HashMap<SharedString, usize>,
|
||||
pub footnote_definitions: HashMap<SharedString, usize>,
|
||||
|
|
@ -1398,6 +1409,114 @@ impl MarkdownElement {
|
|||
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(
|
||||
&self,
|
||||
builder: &mut MarkdownElementBuilder,
|
||||
|
|
@ -1809,6 +1928,7 @@ impl Element for MarkdownElement {
|
|||
let mut current_img_block_range: Option<Range<usize>> = None;
|
||||
let mut handled_html_block = false;
|
||||
let mut rendered_mermaid_block = false;
|
||||
let mut rendered_metadata_block = false;
|
||||
for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
|
||||
// Skip alt text for images that rendered
|
||||
if let Some(current_img_block_range) = ¤t_img_block_range
|
||||
|
|
@ -1832,6 +1952,13 @@ impl Element for MarkdownElement {
|
|||
continue;
|
||||
}
|
||||
|
||||
if rendered_metadata_block {
|
||||
if matches!(event, MarkdownEvent::End(MarkdownTagEnd::MetadataBlock(_))) {
|
||||
rendered_metadata_block = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match event {
|
||||
MarkdownEvent::RootStart => {
|
||||
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);
|
||||
}
|
||||
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) => {
|
||||
builder.table.start(alignments.clone());
|
||||
|
||||
|
|
@ -2359,6 +2499,7 @@ impl Element for MarkdownElement {
|
|||
builder.pop_div();
|
||||
builder.pop_div();
|
||||
}
|
||||
MarkdownTagEnd::MetadataBlock(_) => {}
|
||||
_ => log::debug!("unsupported markdown tag end: {:?}", tag),
|
||||
},
|
||||
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 {
|
||||
div_stack: Vec<AnyDiv>,
|
||||
rendered_lines: Vec<RenderedLine>,
|
||||
|
|
@ -3586,6 +3732,34 @@ mod tests {
|
|||
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(
|
||||
markdown: &str,
|
||||
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
|
||||
|
|
@ -3873,7 +4047,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_table_checkbox_detection() {
|
||||
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 cell_texts: Vec<String> = Vec::new();
|
||||
|
|
@ -3915,7 +4089,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_table_checkbox_marker_source_range() {
|
||||
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 pending_text = String::new();
|
||||
|
|
@ -4192,7 +4366,7 @@ mod tests {
|
|||
}
|
||||
|
||||
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
|
||||
.events
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -686,7 +686,8 @@ mod tests {
|
|||
#[test]
|
||||
fn test_extract_mermaid_diagrams_parses_scale() {
|
||||
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);
|
||||
|
||||
assert_eq!(diagrams.len(), 1);
|
||||
|
|
@ -702,7 +703,8 @@ mod tests {
|
|||
"```mermaid\nblock-beta\n```\n\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);
|
||||
assert_eq!(
|
||||
diagrams.len(),
|
||||
|
|
|
|||
|
|
@ -37,10 +37,23 @@ pub(crate) struct ParsedMarkdownData {
|
|||
pub language_paths: HashSet<Arc<str>>,
|
||||
pub root_block_starts: Vec<usize>,
|
||||
pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
|
||||
pub metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
|
||||
pub heading_slugs: 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 {
|
||||
fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) {
|
||||
match &event {
|
||||
|
|
@ -149,27 +162,83 @@ fn build_heading_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(
|
||||
text: &str,
|
||||
parse_html: bool,
|
||||
parse_heading_slugs: bool,
|
||||
parse_metadata_blocks: bool,
|
||||
) -> ParsedMarkdownData {
|
||||
let mut state = ParseState::default();
|
||||
let mut language_names = HashSet::default();
|
||||
let mut language_paths = HashSet::default();
|
||||
let mut html_blocks = BTreeMap::default();
|
||||
let mut metadata_blocks = BTreeMap::default();
|
||||
let mut within_link = false;
|
||||
let mut within_code_block = 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()
|
||||
.peekable();
|
||||
while let Some((pulldown_event, range)) = parser.next() {
|
||||
if within_metadata {
|
||||
if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) =
|
||||
if within_metadata && !parse_metadata_blocks {
|
||||
if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock(_)) =
|
||||
pulldown_event
|
||||
{
|
||||
within_metadata = false;
|
||||
current_metadata_block_start = None;
|
||||
metadata_block_content_range = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -216,9 +285,14 @@ pub(crate) fn parse_markdown_with_options(
|
|||
id: SharedString::from(id.into_string()),
|
||||
}
|
||||
}
|
||||
pulldown_cmark::Tag::MetadataBlock(_kind) => {
|
||||
pulldown_cmark::Tag::MetadataBlock(kind) => {
|
||||
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) => {
|
||||
within_code_block = true;
|
||||
|
|
@ -347,6 +421,25 @@ pub(crate) fn parse_markdown_with_options(
|
|||
within_link = false;
|
||||
} else if let pulldown_cmark::TagEnd::CodeBlock = tag {
|
||||
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));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
let (range, event) = event_for(text, range, &parsed);
|
||||
state.push_event(range, event);
|
||||
|
|
@ -541,6 +646,7 @@ pub(crate) fn parse_markdown_with_options(
|
|||
language_paths,
|
||||
root_block_starts: state.root_block_starts,
|
||||
html_blocks,
|
||||
metadata_blocks,
|
||||
heading_slugs,
|
||||
footnote_definitions,
|
||||
}
|
||||
|
|
@ -798,8 +904,8 @@ mod tests {
|
|||
use super::MarkdownTag::*;
|
||||
use super::*;
|
||||
|
||||
const UNWANTED_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
|
||||
.union(Options::ENABLE_MATH)
|
||||
const CONDITIONAL_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
|
||||
const UNWANTED_OPTIONS: Options = Options::ENABLE_MATH
|
||||
.union(Options::ENABLE_DEFINITION_LIST)
|
||||
.union(Options::ENABLE_WIKILINKS);
|
||||
|
||||
|
|
@ -807,21 +913,174 @@ mod tests {
|
|||
fn all_options_considered() {
|
||||
// The purpose of this is to fail when new options are added to pulldown_cmark, so that they
|
||||
// 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]
|
||||
fn wanted_and_unwanted_options_disjoint() {
|
||||
assert_eq!(
|
||||
PARSE_OPTIONS.intersection(UNWANTED_OPTIONS),
|
||||
PARSE_OPTIONS
|
||||
.union(CONDITIONAL_OPTIONS)
|
||||
.intersection(UNWANTED_OPTIONS),
|
||||
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]
|
||||
fn test_html_comments() {
|
||||
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 {
|
||||
events: vec
|
||||
.events,
|
||||
vec![
|
||||
|
|
@ -925,6 +1186,7 @@ mod tests {
|
|||
"-- --- ... \"double quoted\" 'single quoted' ----------",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
ParsedMarkdownData {
|
||||
events: vec![
|
||||
|
|
@ -957,7 +1219,12 @@ mod tests {
|
|||
#[test]
|
||||
fn test_code_block_metadata() {
|
||||
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 {
|
||||
events: vec![
|
||||
(0..37, RootStart),
|
||||
|
|
@ -986,7 +1253,7 @@ mod tests {
|
|||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_markdown_with_options(" fn main() {}", false, false),
|
||||
parse_markdown_with_options(" fn main() {}", false, false, false),
|
||||
ParsedMarkdownData {
|
||||
events: vec![
|
||||
(4..16, RootStart),
|
||||
|
|
@ -1012,7 +1279,7 @@ mod tests {
|
|||
}
|
||||
|
||||
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_count = 0;
|
||||
let mut saw_text_inside_code_block = false;
|
||||
|
|
@ -1064,9 +1331,54 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_blocks_do_not_affect_root_blocks() {
|
||||
fn test_metadata_blocks_are_root_blocks() {
|
||||
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 {
|
||||
events: vec![
|
||||
(27..36, RootStart),
|
||||
|
|
@ -1088,7 +1400,7 @@ mod tests {
|
|||
|------|---------|
|
||||
| [x] | Fix bug |
|
||||
| [ ] | 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 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.",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
|
|
@ -1194,6 +1507,7 @@ mod tests {
|
|||
"Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(parsed.footnote_definitions.len(), 2);
|
||||
assert!(parsed.footnote_definitions.contains_key("a"));
|
||||
|
|
@ -1211,6 +1525,7 @@ mod tests {
|
|||
"https:/\\/example.com is equivalent to https://example.com!",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.events,
|
||||
vec![
|
||||
|
|
@ -1253,6 +1568,7 @@ mod tests {
|
|||
"Visit https://example.com/cat\\/é‍☕ for coffee!",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.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",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(parsed.heading_slugs.len(), 5);
|
||||
assert!(parsed.heading_slugs.contains_key("hello-world"));
|
||||
|
|
@ -1301,6 +1618,7 @@ mod tests {
|
|||
"# Duplicate\n\nText\n\n## Duplicate\n\nMore text",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let first = parsed.heading_slugs.get("duplicate").copied();
|
||||
let second = parsed.heading_slugs.get("duplicate-1").copied();
|
||||
|
|
@ -1311,7 +1629,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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!(parsed.heading_slugs.contains_key("foo"));
|
||||
assert!(parsed.heading_slugs.contains_key("foo-1"));
|
||||
|
|
@ -1323,7 +1641,7 @@ mod tests {
|
|||
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 parsed = parse_markdown_with_options(markdown, false, false);
|
||||
let parsed = parse_markdown_with_options(markdown, false, false, false);
|
||||
|
||||
let block_quote_kinds: Vec<_> = parsed
|
||||
.events
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ impl MarkdownPreviewView {
|
|||
parse_html: true,
|
||||
render_mermaid_diagrams: true,
|
||||
parse_heading_slugs: true,
|
||||
render_metadata_blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue