markdown: Add support for HTML lists (#39553)

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>
This commit is contained in:
Remco Smits 2025-10-25 22:15:17 +02:00 committed by GitHub
parent 06e1db54a7
commit 45983e11e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 288 additions and 23 deletions

View file

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

View file

@ -61,6 +61,17 @@ struct MarkdownParser<'a> {
language_registry: Option<Arc<LanguageRegistry>>, 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 { struct MarkdownListItem {
content: Vec<ParsedMarkdownElement>, content: Vec<ParsedMarkdownElement>,
item_type: ParsedMarkdownListItemType, item_type: ParsedMarkdownListItemType,
@ -646,6 +657,7 @@ impl<'a> MarkdownParser<'a> {
content: list_item.content, content: list_item.content,
depth, depth,
item_type: list_item.item_type, item_type: list_item.item_type,
nested: false,
}); });
if let Some(index) = insertion_indices.get(&depth) { if let Some(index) = insertion_indices.get(&depth) {
@ -828,7 +840,12 @@ impl<'a> MarkdownParser<'a> {
.read_from(&mut cursor) .read_from(&mut cursor)
&& let Some((start, end)) = html_source_range_start.zip(html_source_range_end) && 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 elements
@ -839,10 +856,11 @@ impl<'a> MarkdownParser<'a> {
source_range: Range<usize>, source_range: Range<usize>,
node: &Rc<markup5ever_rcdom::Node>, node: &Rc<markup5ever_rcdom::Node>,
elements: &mut Vec<ParsedMarkdownElement>, elements: &mut Vec<ParsedMarkdownElement>,
context: &ParseHtmlNodeContext,
) { ) {
match &node.data { match &node.data {
markup5ever_rcdom::NodeData::Document => { markup5ever_rcdom::NodeData::Document => {
self.consume_children(source_range, node, elements); self.consume_children(source_range, node, elements, context);
} }
markup5ever_rcdom::NodeData::Text { contents } => { markup5ever_rcdom::NodeData::Text { contents } => {
elements.push(ParsedMarkdownElement::Paragraph(vec![ elements.push(ParsedMarkdownElement::Paragraph(vec![
@ -895,6 +913,15 @@ impl<'a> MarkdownParser<'a> {
contents: paragraph, 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 { } else if local_name!("blockquote") == name.local {
if let Some(blockquote) = self.extract_html_blockquote(node, source_range) { if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
elements.push(ParsedMarkdownElement::BlockQuote(blockquote)); elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
@ -904,7 +931,7 @@ impl<'a> MarkdownParser<'a> {
elements.push(ParsedMarkdownElement::Table(table)); elements.push(ParsedMarkdownElement::Table(table));
} }
} else { } 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>, source_range: Range<usize>,
node: &Rc<markup5ever_rcdom::Node>, node: &Rc<markup5ever_rcdom::Node>,
elements: &mut Vec<ParsedMarkdownElement>, elements: &mut Vec<ParsedMarkdownElement>,
context: &ParseHtmlNodeContext,
) { ) {
for node in node.children.borrow().iter() { 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) 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> { fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
if value.ends_with("%") { if value.ends_with("%") {
value value
@ -1129,7 +1208,12 @@ impl<'a> MarkdownParser<'a> {
source_range: Range<usize>, source_range: Range<usize>,
) -> Option<ParsedMarkdownBlockQuote> { ) -> Option<ParsedMarkdownBlockQuote> {
let mut children = Vec::new(); 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() { if children.is_empty() {
None 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] #[gpui::test]
async fn test_inline_html_image_tag() { async fn test_inline_html_image_tag() {
let parsed = let parsed =
@ -1594,7 +1840,7 @@ mod tests {
async fn test_html_block_quote() { async fn test_html_block_quote() {
let parsed = parse( let parsed = parse(
"<blockquote> "<blockquote>
<p>some description</p> <p>some description</p>
</blockquote>", </blockquote>",
) )
.await; .await;
@ -1604,9 +1850,9 @@ mod tests {
children: vec![block_quote( children: vec![block_quote(
vec![ParsedMarkdownElement::Paragraph(text( vec![ParsedMarkdownElement::Paragraph(text(
"some description", "some description",
0..76 0..78
))], ))],
0..76, 0..78,
)] )]
}, },
parsed parsed
@ -1617,10 +1863,10 @@ mod tests {
async fn test_html_nested_block_quote() { async fn test_html_nested_block_quote() {
let parsed = parse( let parsed = parse(
"<blockquote> "<blockquote>
<p>some description</p> <p>some description</p>
<blockquote> <blockquote>
<p>second description</p> <p>second description</p>
</blockquote> </blockquote>
</blockquote>", </blockquote>",
) )
.await; .await;
@ -1629,16 +1875,16 @@ mod tests {
ParsedMarkdown { ParsedMarkdown {
children: vec![block_quote( children: vec![block_quote(
vec![ vec![
ParsedMarkdownElement::Paragraph(text("some description", 0..173)), ParsedMarkdownElement::Paragraph(text("some description", 0..179)),
block_quote( block_quote(
vec![ParsedMarkdownElement::Paragraph(text( vec![ParsedMarkdownElement::Paragraph(text(
"second description", "second description",
0..173 0..179
))], ))],
0..173, 0..179,
) )
], ],
0..173, 0..179,
)] )]
}, },
parsed parsed
@ -2542,6 +2788,22 @@ fn main() {
item_type, item_type,
depth, depth,
content, 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,
}) })
} }

View file

@ -6,10 +6,10 @@ use crate::markdown_elements::{
}; };
use fs::normalize_path; use fs::normalize_path;
use gpui::{ use gpui::{
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div, AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element,
Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke,
Keystroke, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle,
TextStyle, WeakEntity, Window, div, img, rems, WeakEntity, Window, div, img, rems,
}; };
use settings::Settings; use settings::Settings;
use std::{ use std::{
@ -234,8 +234,6 @@ fn render_markdown_list_item(
) -> AnyElement { ) -> AnyElement {
use ParsedMarkdownListItemType::*; use ParsedMarkdownListItemType::*;
let padding = cx.scaled_rems((parsed.depth - 1) as f32);
let bullet = match &parsed.item_type { let bullet = match &parsed.item_type {
Ordered(order) => format!("{}.", order).into_any_element(), Ordered(order) => format!("{}.", order).into_any_element(),
Unordered => "".into_any_element(), Unordered => "".into_any_element(),
@ -294,13 +292,16 @@ fn render_markdown_list_item(
.collect(); .collect();
let item = h_flex() 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() .items_start()
.children(vec![ .children(vec![
bullet, bullet,
v_flex() v_flex()
.children(contents) .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)) .pr(cx.scaled_rems(1.0))
.w_full(), .w_full(),
]); ]);