Cargo.lock 🔗
@@ -9953,9 +9953,11 @@ dependencies = [
"editor",
"fs",
"gpui",
+ "html5ever 0.27.0",
"language",
"linkify",
"log",
+ "markup5ever_rcdom",
"pretty_assertions",
"pulldown-cmark 0.12.2",
"settings",
Remco Smits created
Closes #21992
<img width="1406" height="1184" alt="Screenshot 2025-08-21 at 18 09 24"
src="https://github.com/user-attachments/assets/5f14a0d8-c4d9-48ad-b10d-fadfaca258ea"
/>
Code example:
```markdown
# Html Tag
<img src="https://picsum.photos/200/300" alt="Description of image" />
# Html Tag with width and height
<img src="https://picsum.photos/200/300" alt="Description of image" width="100" height="200" />
# Html Tag with style attribute with width and height
<img src="https://picsum.photos/200/300" alt="Description of image" style="width: 100px; height: 200px" />
# Normal Tag

```
Release Notes:
- Markdown: Added HTML `<img src="/some-image.svg">` tag support
Cargo.lock | 2
crates/markdown_preview/Cargo.toml | 6
crates/markdown_preview/src/markdown_elements.rs | 17
crates/markdown_preview/src/markdown_parser.rs | 372 +++++++++++++++++
crates/markdown_preview/src/markdown_renderer.rs | 137 +++---
5 files changed, 456 insertions(+), 78 deletions(-)
@@ -9953,9 +9953,11 @@ dependencies = [
"editor",
"fs",
"gpui",
+ "html5ever 0.27.0",
"language",
"linkify",
"log",
+ "markup5ever_rcdom",
"pretty_assertions",
"pulldown-cmark 0.12.2",
"settings",
@@ -19,19 +19,21 @@ anyhow.workspace = true
async-recursion.workspace = true
collections.workspace = true
editor.workspace = true
+fs.workspace = true
gpui.workspace = true
+html5ever.workspace = true
language.workspace = true
linkify.workspace = true
log.workspace = true
+markup5ever_rcdom.workspace = true
pretty_assertions.workspace = true
pulldown-cmark.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
-workspace.workspace = true
workspace-hack.workspace = true
-fs.workspace = true
+workspace.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
@@ -1,5 +1,6 @@
use gpui::{
- FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, UnderlineStyle, px,
+ DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle,
+ UnderlineStyle, px,
};
use language::HighlightId;
use std::{fmt::Display, ops::Range, path::PathBuf};
@@ -15,6 +16,7 @@ pub enum ParsedMarkdownElement {
/// A paragraph of text and other inline elements.
Paragraph(MarkdownParagraph),
HorizontalRule(Range<usize>),
+ Image(Image),
}
impl ParsedMarkdownElement {
@@ -30,6 +32,7 @@ impl ParsedMarkdownElement {
MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
},
Self::HorizontalRule(range) => range.clone(),
+ Self::Image(image) => image.source_range.clone(),
})
}
@@ -290,6 +293,8 @@ pub struct Image {
pub link: Link,
pub source_range: Range<usize>,
pub alt_text: Option<SharedString>,
+ pub width: Option<DefiniteLength>,
+ pub height: Option<DefiniteLength>,
}
impl Image {
@@ -303,10 +308,20 @@ impl Image {
source_range,
link,
alt_text: None,
+ width: None,
+ height: None,
})
}
pub fn set_alt_text(&mut self, alt_text: SharedString) {
self.alt_text = Some(alt_text);
}
+
+ pub fn set_width(&mut self, width: DefiniteLength) {
+ self.width = Some(width);
+ }
+
+ pub fn set_height(&mut self, height: DefiniteLength) {
+ self.height = Some(height);
+ }
}
@@ -1,10 +1,12 @@
use crate::markdown_elements::*;
use async_recursion::async_recursion;
use collections::FxHashMap;
-use gpui::FontWeight;
+use gpui::{DefiniteLength, FontWeight, px, relative};
+use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink};
use language::LanguageRegistry;
+use markup5ever_rcdom::RcDom;
use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
-use std::{ops::Range, path::PathBuf, sync::Arc, vec};
+use std::{cell::RefCell, collections::HashMap, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec};
pub async fn parse_markdown(
markdown_input: &str,
@@ -172,9 +174,14 @@ impl<'a> MarkdownParser<'a> {
self.cursor += 1;
- let code_block = self.parse_code_block(language).await;
+ let code_block = self.parse_code_block(language).await?;
Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
}
+ Tag::HtmlBlock => {
+ self.cursor += 1;
+
+ Some(self.parse_html_block().await)
+ }
_ => None,
},
Event::Rule => {
@@ -378,7 +385,7 @@ impl<'a> MarkdownParser<'a> {
TagEnd::Image => {
if let Some(mut image) = image.take() {
if !text.is_empty() {
- image.alt_text = Some(std::mem::take(&mut text).into());
+ image.set_alt_text(std::mem::take(&mut text).into());
}
markdown_text_like.push(MarkdownParagraphChunk::Image(image));
}
@@ -695,13 +702,22 @@ impl<'a> MarkdownParser<'a> {
}
}
- async fn parse_code_block(&mut self, language: Option<String>) -> ParsedMarkdownCodeBlock {
- let (_event, source_range) = self.previous().unwrap();
+ async fn parse_code_block(
+ &mut self,
+ language: Option<String>,
+ ) -> Option<ParsedMarkdownCodeBlock> {
+ let Some((_event, source_range)) = self.previous() else {
+ return None;
+ };
+
let source_range = source_range.clone();
let mut code = String::new();
while !self.eof() {
- let (current, _source_range) = self.current().unwrap();
+ let Some((current, _source_range)) = self.current() else {
+ break;
+ };
+
match current {
Event::Text(text) => {
code.push_str(text);
@@ -734,23 +750,190 @@ impl<'a> MarkdownParser<'a> {
None
};
- ParsedMarkdownCodeBlock {
+ Some(ParsedMarkdownCodeBlock {
source_range,
contents: code.into(),
language,
highlights,
+ })
+ }
+
+ async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
+ let mut elements = Vec::new();
+ let Some((_event, _source_range)) = self.previous() else {
+ return elements;
+ };
+
+ while !self.eof() {
+ let Some((current, source_range)) = self.current() else {
+ break;
+ };
+ let source_range = source_range.clone();
+ match current {
+ Event::Html(html) => {
+ let mut cursor = std::io::Cursor::new(html.as_bytes());
+ let Some(dom) = parse_document(RcDom::default(), ParseOpts::default())
+ .from_utf8()
+ .read_from(&mut cursor)
+ .ok()
+ else {
+ self.cursor += 1;
+ continue;
+ };
+
+ self.cursor += 1;
+
+ self.parse_html_node(source_range, &dom.document, &mut elements);
+ }
+ Event::End(TagEnd::CodeBlock) => {
+ self.cursor += 1;
+ break;
+ }
+ _ => {
+ break;
+ }
+ }
+ }
+
+ elements
+ }
+
+ fn parse_html_node(
+ &self,
+ source_range: Range<usize>,
+ node: &Rc<markup5ever_rcdom::Node>,
+ elements: &mut Vec<ParsedMarkdownElement>,
+ ) {
+ match &node.data {
+ markup5ever_rcdom::NodeData::Document => {
+ self.consume_children(source_range, node, elements);
+ }
+ markup5ever_rcdom::NodeData::Doctype { .. } => {}
+ markup5ever_rcdom::NodeData::Text { contents } => {
+ elements.push(ParsedMarkdownElement::Paragraph(vec![
+ MarkdownParagraphChunk::Text(ParsedMarkdownText {
+ source_range,
+ contents: contents.borrow().to_string(),
+ highlights: Vec::default(),
+ region_ranges: Vec::default(),
+ regions: Vec::default(),
+ }),
+ ]));
+ }
+ markup5ever_rcdom::NodeData::Comment { .. } => {}
+ markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
+ if local_name!("img") == name.local {
+ if let Some(image) = self.extract_image(source_range, attrs) {
+ elements.push(ParsedMarkdownElement::Image(image));
+ }
+ } else {
+ self.consume_children(source_range, node, elements);
+ }
+ }
+ markup5ever_rcdom::NodeData::ProcessingInstruction { .. } => {}
+ }
+ }
+
+ fn consume_children(
+ &self,
+ source_range: Range<usize>,
+ node: &Rc<markup5ever_rcdom::Node>,
+ elements: &mut Vec<ParsedMarkdownElement>,
+ ) {
+ for node in node.children.borrow().iter() {
+ self.parse_html_node(source_range.clone(), node, elements);
+ }
+ }
+
+ fn attr_value(
+ attrs: &RefCell<Vec<html5ever::Attribute>>,
+ name: html5ever::LocalName,
+ ) -> Option<String> {
+ attrs.borrow().iter().find_map(|attr| {
+ if attr.name.local == name {
+ Some(attr.value.to_string())
+ } else {
+ None
+ }
+ })
+ }
+
+ fn extract_styles_from_attributes(
+ attrs: &RefCell<Vec<html5ever::Attribute>>,
+ ) -> HashMap<String, String> {
+ let mut styles = HashMap::new();
+
+ if let Some(style) = Self::attr_value(attrs, local_name!("style")) {
+ for decl in style.split(';') {
+ let mut parts = decl.splitn(2, ':');
+ if let Some((key, value)) = parts.next().zip(parts.next()) {
+ styles.insert(
+ key.trim().to_lowercase().to_string(),
+ value.trim().to_string(),
+ );
+ }
+ }
+ }
+
+ styles
+ }
+
+ fn extract_image(
+ &self,
+ source_range: Range<usize>,
+ attrs: &RefCell<Vec<html5ever::Attribute>>,
+ ) -> Option<Image> {
+ let src = Self::attr_value(attrs, local_name!("src"))?;
+
+ let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?;
+
+ if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) {
+ image.set_alt_text(alt.into());
+ }
+
+ let styles = Self::extract_styles_from_attributes(attrs);
+
+ if let Some(width) = Self::attr_value(attrs, local_name!("width"))
+ .or_else(|| styles.get("width").cloned())
+ .and_then(|width| Self::parse_length(&width))
+ {
+ image.set_width(width);
+ }
+
+ if let Some(height) = Self::attr_value(attrs, local_name!("height"))
+ .or_else(|| styles.get("height").cloned())
+ .and_then(|height| Self::parse_length(&height))
+ {
+ image.set_height(height);
+ }
+
+ Some(image)
+ }
+
+ /// Parses the width/height attribute value of an html element (e.g. img element)
+ fn parse_length(value: &str) -> Option<DefiniteLength> {
+ if value.ends_with("%") {
+ value
+ .trim_end_matches("%")
+ .parse::<f32>()
+ .ok()
+ .map(|value| relative(value / 100.))
+ } else {
+ value
+ .trim_end_matches("px")
+ .parse()
+ .ok()
+ .map(|value| px(value).into())
}
}
}
#[cfg(test)]
mod tests {
- use core::panic;
-
use super::*;
-
use ParsedMarkdownListItemType::*;
- use gpui::BackgroundExecutor;
+ use core::panic;
+ use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength};
use language::{
HighlightId, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, tree_sitter_rust,
};
@@ -925,6 +1108,8 @@ mod tests {
url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(),
},
alt_text: Some("test".into()),
+ height: None,
+ width: None,
},)
);
}
@@ -946,6 +1131,8 @@ mod tests {
url: "http://example.com/foo.png".to_string(),
},
alt_text: None,
+ height: None,
+ width: None,
},)
);
}
@@ -965,6 +1152,8 @@ mod tests {
url: "http://example.com/foo.png".to_string(),
},
alt_text: Some("foo bar baz".into()),
+ height: None,
+ width: None,
}),],
);
}
@@ -990,6 +1179,8 @@ mod tests {
url: "http://example.com/foo.png".to_string(),
},
alt_text: Some("foo".into()),
+ height: None,
+ width: None,
}),
MarkdownParagraphChunk::Text(ParsedMarkdownText {
source_range: 0..81,
@@ -1004,11 +1195,168 @@ mod tests {
url: "http://example.com/bar.png".to_string(),
},
alt_text: Some("bar".into()),
+ height: None,
+ width: None,
})
]
);
}
+ #[test]
+ fn test_parse_length() {
+ // Test percentage values
+ assert_eq!(
+ MarkdownParser::parse_length("50%"),
+ Some(DefiniteLength::Fraction(0.5))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("100%"),
+ Some(DefiniteLength::Fraction(1.0))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("25%"),
+ Some(DefiniteLength::Fraction(0.25))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("0%"),
+ Some(DefiniteLength::Fraction(0.0))
+ );
+
+ // Test pixel values
+ assert_eq!(
+ MarkdownParser::parse_length("100px"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("50px"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0))))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("0px"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0))))
+ );
+
+ // Test values without units (should be treated as pixels)
+ assert_eq!(
+ MarkdownParser::parse_length("100"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("42"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
+ );
+
+ // Test invalid values
+ assert_eq!(MarkdownParser::parse_length("invalid"), None);
+ assert_eq!(MarkdownParser::parse_length("px"), None);
+ assert_eq!(MarkdownParser::parse_length("%"), None);
+ assert_eq!(MarkdownParser::parse_length(""), None);
+ assert_eq!(MarkdownParser::parse_length("abc%"), None);
+ assert_eq!(MarkdownParser::parse_length("abcpx"), None);
+
+ // Test decimal values
+ assert_eq!(
+ MarkdownParser::parse_length("50.5%"),
+ Some(DefiniteLength::Fraction(0.505))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("100.25px"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25))))
+ );
+ assert_eq!(
+ MarkdownParser::parse_length("42.0"),
+ Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
+ );
+ }
+
+ #[gpui::test]
+ async fn test_html_image_tag() {
+ let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;
+
+ let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+ panic!("Expected a image element");
+ };
+ assert_eq!(
+ image.clone(),
+ Image {
+ source_range: 0..40,
+ link: Link::Web {
+ url: "http://example.com/foo.png".to_string(),
+ },
+ alt_text: None,
+ height: None,
+ width: None,
+ },
+ );
+ }
+
+ #[gpui::test]
+ async fn test_html_image_tag_with_alt_text() {
+ let parsed = parse("<img src=\"http://example.com/foo.png\" alt=\"Foo\" />").await;
+
+ let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+ panic!("Expected a image element");
+ };
+ assert_eq!(
+ image.clone(),
+ Image {
+ source_range: 0..50,
+ link: Link::Web {
+ url: "http://example.com/foo.png".to_string(),
+ },
+ alt_text: Some("Foo".into()),
+ height: None,
+ width: None,
+ },
+ );
+ }
+
+ #[gpui::test]
+ async fn test_html_image_tag_with_height_and_width() {
+ let parsed =
+ parse("<img src=\"http://example.com/foo.png\" height=\"100\" width=\"200\" />").await;
+
+ let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+ panic!("Expected a image element");
+ };
+ assert_eq!(
+ image.clone(),
+ Image {
+ source_range: 0..65,
+ link: Link::Web {
+ url: "http://example.com/foo.png".to_string(),
+ },
+ alt_text: None,
+ height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
+ width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
+ },
+ );
+ }
+
+ #[gpui::test]
+ async fn test_html_image_style_tag_with_height_and_width() {
+ let parsed = parse(
+ "<img src=\"http://example.com/foo.png\" style=\"height:100px; width:200px;\" />",
+ )
+ .await;
+
+ let ParsedMarkdownElement::Image(image) = &parsed.children[0] else {
+ panic!("Expected a image element");
+ };
+ assert_eq!(
+ image.clone(),
+ Image {
+ source_range: 0..75,
+ link: Link::Web {
+ url: "http://example.com/foo.png".to_string(),
+ },
+ alt_text: None,
+ height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
+ width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
+ },
+ );
+ }
+
#[gpui::test]
async fn test_header_only_table() {
let markdown = "\
@@ -1,5 +1,5 @@
use crate::markdown_elements::{
- HeadingLevel, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
+ HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
@@ -164,6 +164,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte
BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
HorizontalRule(_) => render_markdown_rule(cx),
+ Image(image) => render_markdown_image(image, cx),
}
}
@@ -722,65 +723,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
}
MarkdownParagraphChunk::Image(image) => {
- let image_resource = match image.link.clone() {
- Link::Web { url } => Resource::Uri(url.into()),
- Link::Path { path, .. } => Resource::Path(Arc::from(path)),
- };
-
- let element_id = cx.next_id(&image.source_range);
-
- let image_element = div()
- .id(element_id)
- .cursor_pointer()
- .child(
- img(ImageSource::Resource(image_resource))
- .max_w_full()
- .with_fallback({
- let alt_text = image.alt_text.clone();
- move || div().children(alt_text.clone()).into_any_element()
- }),
- )
- .tooltip({
- let link = image.link.clone();
- move |_, cx| {
- InteractiveMarkdownElementTooltip::new(
- Some(link.to_string()),
- "open image",
- cx,
- )
- .into()
- }
- })
- .on_click({
- let workspace = workspace_clone.clone();
- let link = image.link.clone();
- move |_, window, cx| {
- if window.modifiers().secondary() {
- match &link {
- Link::Web { url } => cx.open_url(url),
- Link::Path { path, .. } => {
- if let Some(workspace) = &workspace {
- _ = workspace.update(cx, |workspace, cx| {
- workspace
- .open_abs_path(
- path.clone(),
- OpenOptions {
- visible: Some(OpenVisible::None),
- ..Default::default()
- },
- window,
- cx,
- )
- .detach();
- });
- }
- }
- }
- }
- }
- })
- .into_any();
- any_element.push(image_element);
+ any_element.push(render_markdown_image(image, cx));
}
}
}
@@ -793,18 +736,86 @@ fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
div().py(cx.scaled_rems(0.5)).child(rule).into_any()
}
+fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
+ let image_resource = match image.link.clone() {
+ Link::Web { url } => Resource::Uri(url.into()),
+ Link::Path { path, .. } => Resource::Path(Arc::from(path)),
+ };
+
+ let element_id = cx.next_id(&image.source_range);
+ let workspace = cx.workspace.clone();
+
+ div()
+ .id(element_id)
+ .cursor_pointer()
+ .child(
+ img(ImageSource::Resource(image_resource))
+ .max_w_full()
+ .with_fallback({
+ let alt_text = image.alt_text.clone();
+ move || div().children(alt_text.clone()).into_any_element()
+ })
+ .when_some(image.height, |this, height| this.h(height))
+ .when_some(image.width, |this, width| this.w(width)),
+ )
+ .tooltip({
+ let link = image.link.clone();
+ let alt_text = image.alt_text.clone();
+ move |_, cx| {
+ InteractiveMarkdownElementTooltip::new(
+ Some(alt_text.clone().unwrap_or(link.to_string().into())),
+ "open image",
+ cx,
+ )
+ .into()
+ }
+ })
+ .on_click({
+ let link = image.link.clone();
+ move |_, window, cx| {
+ if window.modifiers().secondary() {
+ match &link {
+ Link::Web { url } => cx.open_url(url),
+ Link::Path { path, .. } => {
+ if let Some(workspace) = &workspace {
+ _ = workspace.update(cx, |workspace, cx| {
+ workspace
+ .open_abs_path(
+ path.clone(),
+ OpenOptions {
+ visible: Some(OpenVisible::None),
+ ..Default::default()
+ },
+ window,
+ cx,
+ )
+ .detach();
+ });
+ }
+ }
+ }
+ }
+ }
+ })
+ .into_any()
+}
+
struct InteractiveMarkdownElementTooltip {
tooltip_text: Option<SharedString>,
- action_text: String,
+ action_text: SharedString,
}
impl InteractiveMarkdownElementTooltip {
- pub fn new(tooltip_text: Option<String>, action_text: &str, cx: &mut App) -> Entity<Self> {
+ pub fn new(
+ tooltip_text: Option<SharedString>,
+ action_text: impl Into<SharedString>,
+ cx: &mut App,
+ ) -> Entity<Self> {
let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
cx.new(|_cx| Self {
tooltip_text,
- action_text: action_text.to_string(),
+ action_text: action_text.into(),
})
}
}