markdown: Add support for HTML lists (#39553)

Remco Smits and Bennet Bo Fenner created

This PR adds support for **HTML** both ordered and unordered lists.

<img width="1441" height="805" alt="Screenshot 2025-10-07 at 21 40 17"
src="https://github.com/user-attachments/assets/8a54aec1-75aa-48fb-bf9f-c153cca48682"
/>

See code example used inside the screenshot:

```html
<ol>
  <li>First item</li>
  <li>Second item</li>
  <li>Third item
    <ol>
      <li>Indented item</li>
      <li>Indented item</li>
    </ol>
  </li>
  <li>Fourth item</li>
</ol>
```

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 <bennetbo@gmx.de>

Change summary

crates/markdown_preview/src/markdown_elements.rs |   2 
crates/markdown_preview/src/markdown_parser.rs   | 292 +++++++++++++++++
crates/markdown_preview/src/markdown_renderer.rs |  17 
3 files changed, 288 insertions(+), 23 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -64,6 +64,8 @@ pub struct ParsedMarkdownListItem {
     pub depth: u16,
     pub item_type: ParsedMarkdownListItemType,
     pub content: Vec<ParsedMarkdownElement>,
+    /// Whether we can expect nested list items inside of this items `content`.
+    pub nested: bool,
 }
 
 #[derive(Debug)]

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -61,6 +61,17 @@ struct MarkdownParser<'a> {
     language_registry: Option<Arc<LanguageRegistry>>,
 }
 
+#[derive(Debug)]
+struct ParseHtmlNodeContext {
+    list_item_depth: u16,
+}
+
+impl Default for ParseHtmlNodeContext {
+    fn default() -> Self {
+        Self { list_item_depth: 1 }
+    }
+}
+
 struct MarkdownListItem {
     content: Vec<ParsedMarkdownElement>,
     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<usize>,
         node: &Rc<markup5ever_rcdom::Node>,
         elements: &mut Vec<ParsedMarkdownElement>,
+        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<usize>,
         node: &Rc<markup5ever_rcdom::Node>,
         elements: &mut Vec<ParsedMarkdownElement>,
+        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<markup5ever_rcdom::Node>,
+        ordered: bool,
+        depth: u16,
+        source_range: Range<usize>,
+    ) -> Option<Vec<ParsedMarkdownElement>> {
+        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<DefiniteLength> {
         if value.ends_with("%") {
             value
@@ -1129,7 +1208,12 @@ impl<'a> MarkdownParser<'a> {
         source_range: Range<usize>,
     ) -> Option<ParsedMarkdownBlockQuote> {
         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(
+            "<ul>
+              <li>Item 1</li>
+              <li>Item 2</li>
+            </ul>",
+        )
+        .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(
+            "<ol>
+              <li>Item 1</li>
+              <li>Item 2</li>
+            </ol>",
+        )
+        .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(
+            "<ol>
+              <li>Item 1</li>
+              <li>Item 2
+                <ol>
+                  <li>Sub-Item 1</li>
+                  <li>Sub-Item 2</li>
+                </ol>
+              </li>
+            </ol>",
+        )
+        .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(
+            "<ul>
+              <li>Item 1</li>
+              <li>Item 2
+                <ul>
+                  <li>Sub-Item 1</li>
+                  <li>Sub-Item 2</li>
+                </ul>
+              </li>
+            </ul>",
+        )
+        .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(
             "<blockquote>
-              <p>some description</p>
+                <p>some description</p>
             </blockquote>",
         )
         .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(
             "<blockquote>
-              <p>some description</p>
-              <blockquote>
+                <p>some description</p>
+                <blockquote>
                 <p>second description</p>
-              </blockquote>
+                </blockquote>
             </blockquote>",
         )
         .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<usize>,
+        depth: u16,
+        item_type: ParsedMarkdownListItemType,
+        content: Vec<ParsedMarkdownElement>,
+    ) -> ParsedMarkdownElement {
+        ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
+            source_range,
+            item_type,
+            depth,
+            content,
+            nested: true,
         })
     }
 

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(),
         ]);