markdown: Add support for `HTML` href elements (#42265)

Remco Smits and Bennet Bo Fenner created

This PR adds support for `HTML` href elements. It also refactored the
way we stored the regions, this was done because otherwise I had to add
2 extra arguments to each `HTML` parser method. It's now also more
inline with how we have done it for the highlights.

**Small note**: the markdown parser only supports HTML href tags inside
a paragraph tag. So adding them as a root node will result in just
showing the inner text. This is a limitation of the markdown parser we
use itself.

**Before**
<img width="935" height="174" alt="Screenshot 2025-11-08 at 15 40 28"
src="https://github.com/user-attachments/assets/42172222-ed49-4a4b-8957-a46330e54c69"
/>

**After**
<img width="1026" height="180" alt="Screenshot 2025-11-08 at 15 29 55"
src="https://github.com/user-attachments/assets/9e139c2d-d43a-4952-8d1f-15eb92966241"
/>

**Example code**
```markdown
<p>asd <a href="https://example.com">Link Text</a> more text</p>
<p><a href="https://example.com">Link Text</a></p>

[Duck Duck Go](https://duckduckgo.com)
```

**TODO**:
- [x] Add tests

cc @bennetbo

Release Notes:

- Markdown Preview: Add support for `HTML` href elements.

---------

Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>

Change summary

crates/markdown_preview/src/markdown_elements.rs |   4 
crates/markdown_preview/src/markdown_parser.rs   | 208 ++++++++++++-----
crates/markdown_preview/src/markdown_renderer.rs |  47 +--
3 files changed, 169 insertions(+), 90 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_elements.rs 🔗

@@ -171,10 +171,8 @@ pub struct ParsedMarkdownText {
     pub contents: SharedString,
     /// The list of highlights contained in the Markdown document.
     pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
-    /// The regions of the various ranges in the Markdown document.
-    pub region_ranges: Vec<Range<usize>>,
     /// The regions of the Markdown document.
-    pub regions: Vec<ParsedRegion>,
+    pub regions: Vec<(Range<usize>, ParsedRegion)>,
 }
 
 /// A run of highlighted Markdown text.

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -245,8 +245,7 @@ impl<'a> MarkdownParser<'a> {
         let mut strikethrough_depth = 0;
         let mut link: Option<Link> = None;
         let mut image: Option<Image> = None;
-        let mut region_ranges: Vec<Range<usize>> = vec![];
-        let mut regions: Vec<ParsedRegion> = vec![];
+        let mut regions: Vec<(Range<usize>, ParsedRegion)> = vec![];
         let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
         let mut link_urls: Vec<String> = vec![];
         let mut link_ranges: Vec<Range<usize>> = vec![];
@@ -291,11 +290,13 @@ impl<'a> MarkdownParser<'a> {
                     }
 
                     let last_run_len = if let Some(link) = link.clone() {
-                        region_ranges.push(prev_len..text.len());
-                        regions.push(ParsedRegion {
-                            code: false,
-                            link: Some(link),
-                        });
+                        regions.push((
+                            prev_len..text.len(),
+                            ParsedRegion {
+                                code: false,
+                                link: Some(link),
+                            },
+                        ));
                         style.link = true;
                         prev_len
                     } else {
@@ -325,13 +326,16 @@ impl<'a> MarkdownParser<'a> {
                                     ..style
                                 }),
                             ));
-                            region_ranges.push(range.clone());
-                            regions.push(ParsedRegion {
-                                code: false,
-                                link: Some(Link::Web {
-                                    url: link.as_str().to_string(),
-                                }),
-                            });
+
+                            regions.push((
+                                range.clone(),
+                                ParsedRegion {
+                                    code: false,
+                                    link: Some(Link::Web {
+                                        url: link.as_str().to_string(),
+                                    }),
+                                },
+                            ));
                             last_link_len = end;
                         }
                         last_link_len
@@ -356,21 +360,24 @@ impl<'a> MarkdownParser<'a> {
                 }
                 Event::Code(t) => {
                     text.push_str(t.as_ref());
-                    region_ranges.push(prev_len..text.len());
+                    let range = prev_len..text.len();
 
                     if link.is_some() {
                         highlights.push((
-                            prev_len..text.len(),
+                            range.clone(),
                             MarkdownHighlight::Style(MarkdownHighlightStyle {
                                 link: true,
                                 ..Default::default()
                             }),
                         ));
                     }
-                    regions.push(ParsedRegion {
-                        code: true,
-                        link: link.clone(),
-                    });
+                    regions.push((
+                        range,
+                        ParsedRegion {
+                            code: true,
+                            link: link.clone(),
+                        },
+                    ));
                 }
                 Event::Start(tag) => match tag {
                     Tag::Emphasis => italic_depth += 1,
@@ -388,7 +395,6 @@ impl<'a> MarkdownParser<'a> {
                                 source_range: source_range.clone(),
                                 contents: mem::take(&mut text).into(),
                                 highlights: mem::take(&mut highlights),
-                                region_ranges: mem::take(&mut region_ranges),
                                 regions: mem::take(&mut regions),
                             });
                             markdown_text_like.push(parsed_regions);
@@ -416,7 +422,6 @@ impl<'a> MarkdownParser<'a> {
                             if !text.is_empty() {
                                 image.set_alt_text(std::mem::take(&mut text).into());
                                 mem::take(&mut highlights);
-                                mem::take(&mut region_ranges);
                                 mem::take(&mut regions);
                             }
                             markdown_text_like.push(MarkdownParagraphChunk::Image(image));
@@ -443,7 +448,6 @@ impl<'a> MarkdownParser<'a> {
                 contents: text.into(),
                 highlights,
                 regions,
-                region_ranges,
             }));
         }
         markdown_text_like
@@ -869,7 +873,6 @@ impl<'a> MarkdownParser<'a> {
                     MarkdownParagraphChunk::Text(ParsedMarkdownText {
                         source_range,
                         regions: Vec::default(),
-                        region_ranges: Vec::default(),
                         highlights: Vec::default(),
                         contents: contents.borrow().to_string().into(),
                     }),
@@ -891,7 +894,13 @@ impl<'a> MarkdownParser<'a> {
                     }
                 } else if local_name!("p") == name.local {
                     let mut paragraph = MarkdownParagraph::new();
-                    self.parse_paragraph(source_range, node, &mut paragraph, &mut styles);
+                    self.parse_paragraph(
+                        source_range,
+                        node,
+                        &mut paragraph,
+                        &mut styles,
+                        &mut Vec::new(),
+                    );
 
                     if !paragraph.is_empty() {
                         elements.push(ParsedMarkdownElement::Paragraph(paragraph));
@@ -906,7 +915,13 @@ impl<'a> MarkdownParser<'a> {
                         | local_name!("h6")
                 ) {
                     let mut paragraph = MarkdownParagraph::new();
-                    self.consume_paragraph(source_range.clone(), node, &mut paragraph, &mut styles);
+                    self.consume_paragraph(
+                        source_range.clone(),
+                        node,
+                        &mut paragraph,
+                        &mut styles,
+                        &mut Vec::new(),
+                    );
 
                     if !paragraph.is_empty() {
                         elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
@@ -954,15 +969,15 @@ impl<'a> MarkdownParser<'a> {
         node: &Rc<markup5ever_rcdom::Node>,
         paragraph: &mut MarkdownParagraph,
         highlights: &mut Vec<MarkdownHighlight>,
+        regions: &mut Vec<(Range<usize>, ParsedRegion)>,
     ) {
-        fn add_highlight_range(
-            text: &String,
-            start: usize,
-            highlights: Vec<MarkdownHighlight>,
-        ) -> Vec<(Range<usize>, MarkdownHighlight)> {
-            highlights
+        fn items_with_range<T>(
+            range: Range<usize>,
+            items: impl IntoIterator<Item = T>,
+        ) -> Vec<(Range<usize>, T)> {
+            items
                 .into_iter()
-                .map(|style| (start..text.len(), style))
+                .map(|item| (range.clone(), item))
                 .collect()
         }
 
@@ -976,22 +991,30 @@ impl<'a> MarkdownParser<'a> {
                 }) {
                     let mut new_text = text.contents.to_string();
                     new_text.push_str(&contents.borrow());
-                    let highlights = add_highlight_range(
-                        &new_text,
-                        text.contents.len(),
-                        std::mem::take(highlights),
-                    );
 
+                    text.highlights.extend(items_with_range(
+                        text.contents.len()..new_text.len(),
+                        std::mem::take(highlights),
+                    ));
+                    text.regions.extend(items_with_range(
+                        text.contents.len()..new_text.len(),
+                        std::mem::take(regions)
+                            .into_iter()
+                            .map(|(_, region)| region),
+                    ));
                     text.contents = SharedString::from(new_text);
-                    text.highlights.extend(highlights);
                 } else {
                     let contents = contents.borrow().to_string();
                     paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
                         source_range,
-                        highlights: add_highlight_range(&contents, 0, std::mem::take(highlights)),
-                        regions: Vec::default(),
+                        highlights: items_with_range(0..contents.len(), std::mem::take(highlights)),
+                        regions: items_with_range(
+                            0..contents.len(),
+                            std::mem::take(regions)
+                                .into_iter()
+                                .map(|(_, region)| region),
+                        ),
                         contents: contents.into(),
-                        region_ranges: Vec::default(),
                     }));
                 }
             }
@@ -1006,37 +1029,57 @@ impl<'a> MarkdownParser<'a> {
                         ..Default::default()
                     }));
 
-                    self.consume_paragraph(source_range, node, paragraph, highlights);
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
                 } else if local_name!("i") == name.local {
                     highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
                         italic: true,
                         ..Default::default()
                     }));
 
-                    self.consume_paragraph(source_range, node, paragraph, highlights);
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
                 } else if local_name!("em") == name.local {
                     highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
                         oblique: true,
                         ..Default::default()
                     }));
 
-                    self.consume_paragraph(source_range, node, paragraph, highlights);
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
                 } else if local_name!("del") == name.local {
                     highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
                         strikethrough: true,
                         ..Default::default()
                     }));
 
-                    self.consume_paragraph(source_range, node, paragraph, highlights);
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
                 } else if local_name!("ins") == name.local {
                     highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
                         underline: true,
                         ..Default::default()
                     }));
 
-                    self.consume_paragraph(source_range, node, paragraph, highlights);
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
+                } else if local_name!("a") == name.local {
+                    if let Some(url) = Self::attr_value(attrs, local_name!("href"))
+                        && let Some(link) =
+                            Link::identify(self.file_location_directory.clone(), url)
+                    {
+                        highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
+                            link: true,
+                            ..Default::default()
+                        }));
+
+                        regions.push((
+                            source_range.clone(),
+                            ParsedRegion {
+                                code: false,
+                                link: Some(link),
+                            },
+                        ));
+                    }
+
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
                 } else {
-                    self.consume_paragraph(source_range, node, paragraph, highlights);
+                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
                 }
             }
             _ => {}
@@ -1049,9 +1092,10 @@ impl<'a> MarkdownParser<'a> {
         node: &Rc<markup5ever_rcdom::Node>,
         paragraph: &mut MarkdownParagraph,
         highlights: &mut Vec<MarkdownHighlight>,
+        regions: &mut Vec<(Range<usize>, ParsedRegion)>,
     ) {
         for node in node.children.borrow().iter() {
-            self.parse_paragraph(source_range.clone(), node, paragraph, highlights);
+            self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions);
         }
     }
 
@@ -1096,7 +1140,13 @@ impl<'a> MarkdownParser<'a> {
                 }
 
                 let mut children = MarkdownParagraph::new();
-                self.consume_paragraph(source_range, node, &mut children, &mut Vec::new());
+                self.consume_paragraph(
+                    source_range,
+                    node,
+                    &mut children,
+                    &mut Vec::new(),
+                    &mut Vec::new(),
+                );
 
                 let is_header = matches!(name.local, local_name!("th"));
 
@@ -1374,6 +1424,7 @@ impl<'a> MarkdownParser<'a> {
                             node,
                             &mut paragraph,
                             &mut Vec::new(),
+                            &mut Vec::new(),
                         );
                         caption = Some(paragraph);
                     }
@@ -1494,7 +1545,6 @@ mod tests {
                     source_range: 0..35,
                     contents: "Some bostrikethroughld text".into(),
                     highlights: Vec::new(),
-                    region_ranges: Vec::new(),
                     regions: Vec::new(),
                 }
             )])
@@ -1618,6 +1668,51 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_html_href_element() {
+        let parsed =
+            parse("<p>Some text <a href=\"https://example.com\">link</a> more text</p>").await;
+
+        assert_eq!(1, parsed.children.len());
+        let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
+            chunks
+        } else {
+            panic!("Expected a paragraph");
+        };
+
+        assert_eq!(1, chunks.len());
+        let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
+            text
+        } else {
+            panic!("Expected a paragraph");
+        };
+
+        assert_eq!(0..65, text.source_range);
+        assert_eq!("Some text link more text", text.contents.as_str(),);
+        assert_eq!(
+            vec![(
+                10..14,
+                MarkdownHighlight::Style(MarkdownHighlightStyle {
+                    link: true,
+                    ..Default::default()
+                },),
+            )],
+            text.highlights
+        );
+        assert_eq!(
+            vec![(
+                10..14,
+                ParsedRegion {
+                    code: false,
+                    link: Some(Link::Web {
+                        url: "https://example.com".into()
+                    })
+                }
+            )],
+            text.regions
+        )
+    }
+
     #[gpui::test]
     async fn test_text_with_inline_html() {
         let parsed = parse("This is a paragraph with an inline HTML <sometag>tag</sometag>.").await;
@@ -1768,7 +1863,6 @@ mod tests {
                     source_range: 0..81,
                     contents: " Lorem Ipsum ".into(),
                     highlights: Vec::new(),
-                    region_ranges: Vec::new(),
                     regions: Vec::new(),
                 }),
                 MarkdownParagraphChunk::Image(Image {
@@ -2029,7 +2123,6 @@ mod tests {
                         source_range: 0..71,
                         contents: "Some text".into(),
                         highlights: Default::default(),
-                        region_ranges: Default::default(),
                         regions: Default::default()
                     }),
                     MarkdownParagraphChunk::Image(Image {
@@ -2045,7 +2138,6 @@ mod tests {
                         source_range: 0..71,
                         contents: " some more text".into(),
                         highlights: Default::default(),
-                        region_ranges: Default::default(),
                         regions: Default::default()
                     }),
                 ])]
@@ -2221,7 +2313,6 @@ mod tests {
                         source_range: 0..280,
                         contents: "My Table".into(),
                         highlights: Default::default(),
-                        region_ranges: Default::default(),
                         regions: Default::default()
                     })]),
                     vec![],
@@ -2385,7 +2476,6 @@ mod tests {
                             source_range: 0..96,
                             contents: "Heading".into(),
                             highlights: Vec::default(),
-                            region_ranges: Vec::default(),
                             regions: Vec::default()
                         })],
                     }),
@@ -2396,7 +2486,6 @@ mod tests {
                             source_range: 0..96,
                             contents: "Heading".into(),
                             highlights: Vec::default(),
-                            region_ranges: Vec::default(),
                             regions: Vec::default()
                         })],
                     }),
@@ -2407,7 +2496,6 @@ mod tests {
                             source_range: 0..96,
                             contents: "Heading".into(),
                             highlights: Vec::default(),
-                            region_ranges: Vec::default(),
                             regions: Vec::default()
                         })],
                     }),
@@ -2418,7 +2506,6 @@ mod tests {
                             source_range: 0..96,
                             contents: "Heading".into(),
                             highlights: Vec::default(),
-                            region_ranges: Vec::default(),
                             regions: Vec::default()
                         })],
                     }),
@@ -2429,7 +2516,6 @@ mod tests {
                             source_range: 0..96,
                             contents: "Heading".into(),
                             highlights: Vec::default(),
-                            region_ranges: Vec::default(),
                             regions: Vec::default()
                         })],
                     }),
@@ -2440,7 +2526,6 @@ mod tests {
                             source_range: 0..96,
                             contents: "Heading".into(),
                             highlights: Vec::default(),
-                            region_ranges: Vec::default(),
                             regions: Vec::default()
                         })],
                     }),
@@ -3040,7 +3125,6 @@ fn main() {
     fn text(contents: &str, source_range: Range<usize>) -> MarkdownParagraph {
         vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
             highlights: Vec::new(),
-            region_ranges: Vec::new(),
             regions: Vec::new(),
             source_range,
             contents: contents.to_string().into(),

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -679,33 +679,31 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
                             .to_highlight_style(&syntax_theme)
                             .map(|style| (range.clone(), style))
                     }),
-                    parsed.regions.iter().zip(&parsed.region_ranges).filter_map(
-                        |(region, range)| {
-                            if region.code {
-                                Some((
-                                    range.clone(),
-                                    HighlightStyle {
-                                        background_color: Some(code_span_bg_color),
-                                        ..Default::default()
-                                    },
-                                ))
-                            } else if region.link.is_some() {
-                                Some((
-                                    range.clone(),
-                                    HighlightStyle {
-                                        color: Some(link_color),
-                                        ..Default::default()
-                                    },
-                                ))
-                            } else {
-                                None
-                            }
-                        },
-                    ),
+                    parsed.regions.iter().filter_map(|(range, region)| {
+                        if region.code {
+                            Some((
+                                range.clone(),
+                                HighlightStyle {
+                                    background_color: Some(code_span_bg_color),
+                                    ..Default::default()
+                                },
+                            ))
+                        } else if region.link.is_some() {
+                            Some((
+                                range.clone(),
+                                HighlightStyle {
+                                    color: Some(link_color),
+                                    ..Default::default()
+                                },
+                            ))
+                        } else {
+                            None
+                        }
+                    }),
                 );
                 let mut links = Vec::new();
                 let mut link_ranges = Vec::new();
-                for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) {
+                for (range, region) in parsed.regions.iter() {
                     if let Some(link) = region.link.clone() {
                         links.push(link);
                         link_ranges.push(range.clone());
@@ -927,7 +925,6 @@ mod tests {
             source_range: 0..text.len(),
             contents: SharedString::new(text),
             highlights: Default::default(),
-            region_ranges: Default::default(),
             regions: Default::default(),
         })
     }