markdown: Add support for HTML href elements (#42265)
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>
This commit is contained in:
co-authored by
Bennet Bo Fenner
parent
430b55405a
commit
f318bb5fd7
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user