From 45983e11e9c0d78f61da9b076263da0c43e0dae7 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Sat, 25 Oct 2025 22:15:17 +0200 Subject: [PATCH] markdown: Add support for HTML lists (#39553) This PR adds support for **HTML** both ordered and unordered lists. Screenshot 2025-10-07 at 21 40 17 See code example used inside the screenshot: ```html
  1. First item
  2. Second item
  3. Third item
    1. Indented item
    2. Indented item
  4. Fourth item
``` TODO: - [x] Add examples - [x] update description (screenshots, add small description) - [x] fix displaying of nested lists cc @bennetbo Release Notes: - markdown preview: Added support for HTML lists --------- Co-authored-by: Bennet Bo Fenner --- .../markdown_preview/src/markdown_elements.rs | 2 + .../markdown_preview/src/markdown_parser.rs | 292 +++++++++++++++++- .../markdown_preview/src/markdown_renderer.rs | 17 +- 3 files changed, 288 insertions(+), 23 deletions(-) diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index 993c52910e704ed4f0f05194b8bf3974350d4d0c..8d2175ab98621fed7e989bbb121cade3afcdf894 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -64,6 +64,8 @@ pub struct ParsedMarkdownListItem { pub depth: u16, pub item_type: ParsedMarkdownListItemType, pub content: Vec, + /// Whether we can expect nested list items inside of this items `content`. + pub nested: bool, } #[derive(Debug)] diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index fd3e21272674d96f70ba4103087bfe6248c3c6c0..8f2203c25b9a7193759668a35016c2d3203310b6 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -61,6 +61,17 @@ struct MarkdownParser<'a> { language_registry: Option>, } +#[derive(Debug)] +struct ParseHtmlNodeContext { + list_item_depth: u16, +} + +impl Default for ParseHtmlNodeContext { + fn default() -> Self { + Self { list_item_depth: 1 } + } +} + struct MarkdownListItem { content: Vec, item_type: ParsedMarkdownListItemType, @@ -646,6 +657,7 @@ impl<'a> MarkdownParser<'a> { content: list_item.content, depth, item_type: list_item.item_type, + nested: false, }); if let Some(index) = insertion_indices.get(&depth) { @@ -828,7 +840,12 @@ impl<'a> MarkdownParser<'a> { .read_from(&mut cursor) && let Some((start, end)) = html_source_range_start.zip(html_source_range_end) { - self.parse_html_node(start..end, &dom.document, &mut elements); + self.parse_html_node( + start..end, + &dom.document, + &mut elements, + &ParseHtmlNodeContext::default(), + ); } elements @@ -839,10 +856,11 @@ impl<'a> MarkdownParser<'a> { source_range: Range, node: &Rc, elements: &mut Vec, + context: &ParseHtmlNodeContext, ) { match &node.data { markup5ever_rcdom::NodeData::Document => { - self.consume_children(source_range, node, elements); + self.consume_children(source_range, node, elements, context); } markup5ever_rcdom::NodeData::Text { contents } => { elements.push(ParsedMarkdownElement::Paragraph(vec![ @@ -895,6 +913,15 @@ impl<'a> MarkdownParser<'a> { contents: paragraph, })); } + } else if local_name!("ul") == name.local || local_name!("ol") == name.local { + if let Some(list_items) = self.extract_html_list( + node, + local_name!("ol") == name.local, + context.list_item_depth, + source_range, + ) { + elements.extend(list_items); + } } else if local_name!("blockquote") == name.local { if let Some(blockquote) = self.extract_html_blockquote(node, source_range) { elements.push(ParsedMarkdownElement::BlockQuote(blockquote)); @@ -904,7 +931,7 @@ impl<'a> MarkdownParser<'a> { elements.push(ParsedMarkdownElement::Table(table)); } } else { - self.consume_children(source_range, node, elements); + self.consume_children(source_range, node, elements, context); } } _ => {} @@ -1036,9 +1063,10 @@ impl<'a> MarkdownParser<'a> { source_range: Range, node: &Rc, elements: &mut Vec, + context: &ParseHtmlNodeContext, ) { for node in node.children.borrow().iter() { - self.parse_html_node(source_range.clone(), node, elements); + self.parse_html_node(source_range.clone(), node, elements, context); } } @@ -1107,6 +1135,57 @@ impl<'a> MarkdownParser<'a> { Some(image) } + fn extract_html_list( + &self, + node: &Rc, + ordered: bool, + depth: u16, + source_range: Range, + ) -> Option> { + let mut list_items = Vec::with_capacity(node.children.borrow().len()); + + for (index, node) in node.children.borrow().iter().enumerate() { + match &node.data { + markup5ever_rcdom::NodeData::Element { name, .. } => { + if local_name!("li") != name.local { + continue; + } + + let mut content = Vec::new(); + self.consume_children( + source_range.clone(), + node, + &mut content, + &ParseHtmlNodeContext { + list_item_depth: depth + 1, + }, + ); + + if !content.is_empty() { + list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { + depth, + source_range: source_range.clone(), + item_type: if ordered { + ParsedMarkdownListItemType::Ordered(index as u64 + 1) + } else { + ParsedMarkdownListItemType::Unordered + }, + content, + nested: true, + })); + } + } + _ => {} + } + } + + if list_items.is_empty() { + None + } else { + Some(list_items) + } + } + fn parse_html_element_dimension(value: &str) -> Option { if value.ends_with("%") { value @@ -1129,7 +1208,12 @@ impl<'a> MarkdownParser<'a> { source_range: Range, ) -> Option { let mut children = Vec::new(); - self.consume_children(source_range.clone(), node, &mut children); + self.consume_children( + source_range.clone(), + node, + &mut children, + &ParseHtmlNodeContext::default(), + ); if children.is_empty() { None @@ -1552,6 +1636,168 @@ mod tests { ); } + #[gpui::test] + async fn test_html_unordered_list() { + let parsed = parse( + "
    +
  • Item 1
  • +
  • Item 2
  • +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] + ), + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_ordered_list() { + let parsed = parse( + "
    +
  1. Item 1
  2. +
  3. Item 2
  4. +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Ordered(1), + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] + ), + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Ordered(2), + vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_nested_ordered_list() { + let parsed = parse( + "
    +
  1. Item 1
  2. +
  3. Item 2 +
      +
    1. Sub-Item 1
    2. +
    3. Sub-Item 2
    4. +
    +
  4. +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Ordered(1), + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Ordered(2), + vec![ + ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Ordered(1), + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Ordered(2), + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] + ), + ] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_nested_unordered_list() { + let parsed = parse( + "
    +
  • Item 1
  • +
  • Item 2 +
      +
    • Sub-Item 1
    • +
    • Sub-Item 2
    • +
    +
  • +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ + ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] + ), + ] + ), + ] + }, + parsed + ); + } + #[gpui::test] async fn test_inline_html_image_tag() { let parsed = @@ -1594,7 +1840,7 @@ mod tests { async fn test_html_block_quote() { let parsed = parse( "
-

some description

+

some description

", ) .await; @@ -1604,9 +1850,9 @@ mod tests { children: vec![block_quote( vec![ParsedMarkdownElement::Paragraph(text( "some description", - 0..76 + 0..78 ))], - 0..76, + 0..78, )] }, parsed @@ -1617,10 +1863,10 @@ mod tests { async fn test_html_nested_block_quote() { let parsed = parse( "
-

some description

-
+

some description

+

second description

-
+
", ) .await; @@ -1629,16 +1875,16 @@ mod tests { ParsedMarkdown { children: vec![block_quote( vec![ - ParsedMarkdownElement::Paragraph(text("some description", 0..173)), + ParsedMarkdownElement::Paragraph(text("some description", 0..179)), block_quote( vec![ParsedMarkdownElement::Paragraph(text( "second description", - 0..173 + 0..179 ))], - 0..173, + 0..179, ) ], - 0..173, + 0..179, )] }, parsed @@ -2542,6 +2788,22 @@ fn main() { item_type, depth, content, + nested: false, + }) + } + + fn nested_list_item( + source_range: Range, + depth: u16, + item_type: ParsedMarkdownListItemType, + content: Vec, + ) -> ParsedMarkdownElement { + ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { + source_range, + item_type, + depth, + content, + nested: true, }) } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 0996e40811f3d42afe748d6ee4a53a0c53757d9e..ce63cf96099a3f5eb0973a6ee97263e2734d3225 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -6,10 +6,10 @@ use crate::markdown_elements::{ }; use fs::normalize_path; use gpui::{ - AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div, - Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, - Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, - TextStyle, WeakEntity, Window, div, img, rems, + AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element, + ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, + Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, + WeakEntity, Window, div, img, rems, }; use settings::Settings; use std::{ @@ -234,8 +234,6 @@ fn render_markdown_list_item( ) -> AnyElement { use ParsedMarkdownListItemType::*; - let padding = cx.scaled_rems((parsed.depth - 1) as f32); - let bullet = match &parsed.item_type { Ordered(order) => format!("{}.", order).into_any_element(), Unordered => "•".into_any_element(), @@ -294,13 +292,16 @@ fn render_markdown_list_item( .collect(); let item = h_flex() - .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding))) + .when(!parsed.nested, |this| { + this.pl(cx.scaled_rems(parsed.depth.saturating_sub(1) as f32)) + }) + .when(parsed.nested && parsed.depth > 1, |this| this.ml_neg_1p5()) .items_start() .children(vec![ bullet, v_flex() .children(contents) - .gap(cx.scaled_rems(1.0)) + .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0))) .pr(cx.scaled_rems(1.0)) .w_full(), ]);