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 item_type: ParsedMarkdownListItemType,
pub content: Vec<ParsedMarkdownElement>,
/// Whether we can expect nested list items inside of this items `content`.
pub nested: bool,
}
#[derive(Debug)]

View file

@ -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,
})
}

View file

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