From fd4e7b90fed419c059bd7d353ca69eb24abd55a9 Mon Sep 17 00:00:00 2001 From: shiwyang Date: Fri, 24 Apr 2026 10:52:54 +0800 Subject: [PATCH] Support GFM callouts in Markdown (formerly GitHub admonitions) (#54661) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #10022 Release Notes: - Added support for GFM alert callouts (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`) in markdown preview, rendering each type with a colored left border, icon, and bold label. --------- Co-authored-by: Conrad Irwin --- crates/markdown/src/html/html_rendering.rs | 7 +- crates/markdown/src/markdown.rs | 94 +++++++++++++++++++--- crates/markdown/src/parser.rs | 33 +++++++- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 6ae25eff0b..27e9b70e8e 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -115,7 +115,12 @@ impl MarkdownElement { self.render_html_list(list, source_allocator, builder, markdown_end, cx); } ParsedHtmlElement::BlockQuote(block_quote) => { - self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end); + self.push_markdown_block_quote( + builder, + None, + &block_quote.source_range, + markdown_end, + ); self.render_html_elements( &block_quote.children, source_allocator, diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index e07592f093..fce79630c8 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -44,7 +44,7 @@ use parser::CodeBlockMetadata; use parser::{ MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, }; -use pulldown_cmark::Alignment; +use pulldown_cmark::{Alignment, BlockQuoteKind}; use sum_tree::TreeMap; use theme::SyntaxTheme; use ui::{ScrollAxes, Scrollbars, WithScrollbar, prelude::*}; @@ -57,7 +57,29 @@ use crate::parser::CodeBlockKind; type LinkStyleCallback = Rc Option>; type SourceClickCallback = Box bool>; type CheckboxToggleCallback = Rc, bool, &mut Window, &mut App)>; -/// Defines custom style refinements for each heading level (H1-H6) + +#[derive(Clone, Copy, Default)] +pub struct BlockQuoteKindColors { + pub note: Hsla, + pub tip: Hsla, + pub important: Hsla, + pub warning: Hsla, + pub caution: Hsla, +} + +impl BlockQuoteKindColors { + fn for_kind(&self, kind: Option, default: Hsla) -> Hsla { + match kind { + Some(BlockQuoteKind::Note) => self.note, + Some(BlockQuoteKind::Tip) => self.tip, + Some(BlockQuoteKind::Important) => self.important, + Some(BlockQuoteKind::Warning) => self.warning, + Some(BlockQuoteKind::Caution) => self.caution, + None => default, + } + } +} + #[derive(Clone, Default)] pub struct HeadingLevelStyles { pub h1: Option, @@ -80,6 +102,7 @@ pub struct MarkdownStyle { pub link_callback: Option, pub rule_color: Hsla, pub block_quote_border_color: Hsla, + pub block_quote_kind_colors: BlockQuoteKindColors, pub syntax: Arc, pub selection_background_color: Hsla, pub heading: StyleRefinement, @@ -102,6 +125,7 @@ impl Default for MarkdownStyle { link_callback: None, rule_color: Default::default(), block_quote_border_color: Default::default(), + block_quote_kind_colors: Default::default(), syntax: Arc::new(SyntaxTheme::default()), selection_background_color: Default::default(), heading: Default::default(), @@ -179,6 +203,16 @@ impl MarkdownStyle { selection_background_color: colors.element_selection_background, rule_color: colors.border, block_quote_border_color: colors.border, + block_quote_kind_colors: { + let status = cx.theme().status(); + BlockQuoteKindColors { + note: status.info, + tip: status.success, + important: status.info, + warning: status.warning, + caution: status.error, + } + }, code_block_overflow_x_scroll: true, code_block: StyleRefinement { padding: EdgesRefinement { @@ -1149,19 +1183,48 @@ impl MarkdownElement { fn push_markdown_block_quote( &self, builder: &mut MarkdownElementBuilder, + kind: Option, range: &Range, markdown_end: usize, ) { + let border_color = self + .style + .block_quote_kind_colors + .for_kind(kind, self.style.block_quote_border_color); + + let header = kind.map(|kind| { + let (icon_name, label) = match kind { + BlockQuoteKind::Note => (IconName::Info, "Note"), + BlockQuoteKind::Tip => (IconName::Sparkle, "Tip"), + BlockQuoteKind::Important => (IconName::Chat, "Important"), + BlockQuoteKind::Warning => (IconName::Warning, "Warning"), + BlockQuoteKind::Caution => (IconName::Stop, "Caution"), + }; + h_flex() + .gap_1() + .items_center() + .mb_1() + .child( + Icon::new(icon_name) + .size(IconSize::Small) + .color(Color::Custom(border_color)), + ) + .child( + Label::new(label) + .color(Color::Custom(border_color)) + .weight(FontWeight::BOLD), + ) + .into_any_element() + }); + + let block_div = div().pl_4().mb_2().border_l_4().border_color(border_color); + let block_div = match header { + Some(header) => block_div.child(header), + None => block_div, + }; + builder.push_text_style(self.style.block_quote.clone()); - builder.push_div( - div() - .pl_4() - .mb_2() - .border_l_4() - .border_color(self.style.block_quote_border_color), - range, - markdown_end, - ); + builder.push_div(block_div, range, markdown_end); } fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) { @@ -1698,8 +1761,13 @@ impl Element for MarkdownElement { None, ); } - MarkdownTag::BlockQuote => { - self.push_markdown_block_quote(&mut builder, range, markdown_end); + MarkdownTag::BlockQuote(kind) => { + self.push_markdown_block_quote( + &mut builder, + *kind, + range, + markdown_end, + ); } MarkdownTag::CodeBlock { kind, .. } => { if render_mermaid_diagrams diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index 641b43a139..c22c46f67d 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -293,7 +293,7 @@ pub(crate) fn parse_markdown_with_options( attrs, } } - pulldown_cmark::Tag::BlockQuote(_kind) => MarkdownTag::BlockQuote, + pulldown_cmark::Tag::BlockQuote(kind) => MarkdownTag::BlockQuote(kind), pulldown_cmark::Tag::List(start_number) => MarkdownTag::List(start_number), pulldown_cmark::Tag::Item => MarkdownTag::Item, pulldown_cmark::Tag::FootnoteDefinition(label) => { @@ -652,7 +652,7 @@ pub enum MarkdownTag { attrs: Vec<(SharedString, Option)>, }, - BlockQuote, + BlockQuote(Option), /// A code block. CodeBlock { @@ -1300,4 +1300,33 @@ mod tests { assert!(parsed.heading_slugs.contains_key("foo-1")); assert!(parsed.heading_slugs.contains_key("foo-1-1")); } + + #[test] + fn test_gfm_alert_block_quote_kinds() { + 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 block_quote_kinds: Vec<_> = parsed + .events + .iter() + .filter_map(|(_, event)| match event { + Start(BlockQuote(kind)) => Some(*kind), + _ => None, + }) + .collect(); + + assert_eq!( + block_quote_kinds, + vec![ + Some(BlockQuoteKind::Note), + Some(BlockQuoteKind::Tip), + Some(BlockQuoteKind::Important), + Some(BlockQuoteKind::Warning), + Some(BlockQuoteKind::Caution), + None, + ] + ); + } }