fix(render): properly capture image alt text from markdown

pulldown-cmark emits alt text as Text events between Image start/end
tags. Previously, images were rendered immediately on the Start event
with empty alt text, losing the user-provided description.

Refactored to accumulate alt text using the same pattern as code block
handling: state variables track image attributes and alt content, then
the full <img> tag is rendered on the End event.

Also omits the title attribute entirely when no title is provided,
producing cleaner HTML output.

Fixes Lighthouse "Image elements have [alt] attributes" audit issue.
This commit is contained in:
Timothy DeHerrera
2026-01-31 22:50:05 -07:00
parent 0bb59dd1d5
commit 3752cc5234

View File

@@ -16,6 +16,10 @@ pub fn markdown_to_html(markdown: &str) -> String {
let mut code_block_lang: Option<String> = None; let mut code_block_lang: Option<String> = None;
let mut code_block_content = String::new(); let mut code_block_content = String::new();
// Image alt text accumulation state
let mut image_alt_content: Option<String> = None;
let mut image_attrs: Option<(String, String)> = None; // (src, title)
for event in parser { for event in parser {
match event { match event {
Event::Start(Tag::CodeBlock(kind)) => { Event::Start(Tag::CodeBlock(kind)) => {
@@ -33,14 +37,14 @@ pub fn markdown_to_html(markdown: &str) -> String {
}; };
code_block_content.clear(); code_block_content.clear();
} }
Event::Text(text) if code_block_lang.is_some() || !code_block_content.is_empty() => { Event::Text(text) if code_block_lang.is_some() => {
// Accumulate code block content // Accumulate code block content
// Note: we're in a code block if we have a lang OR we've started accumulating code_block_content.push_str(&text);
if code_block_lang.is_some() { }
code_block_content.push_str(&text); Event::Text(text) if image_alt_content.is_some() => {
} else { // Accumulate image alt text
// Regular text, render normally if let Some(ref mut alt) = image_alt_content {
html_output.push_str(&html_escape(&text)); alt.push_str(&text);
} }
} }
Event::End(TagEnd::CodeBlock) => { Event::End(TagEnd::CodeBlock) => {
@@ -96,9 +100,36 @@ pub fn markdown_to_html(markdown: &str) -> String {
html_output.push_str(&html_escape(&text)); html_output.push_str(&html_escape(&text));
html_output.push_str("</code>"); html_output.push_str("</code>");
} }
Event::Start(Tag::Image {
dest_url, title, ..
}) => {
// Begin accumulating alt text; defer rendering to End event
image_alt_content = Some(String::new());
image_attrs = Some((dest_url.to_string(), title.to_string()));
}
Event::Start(tag) => { Event::Start(tag) => {
html_output.push_str(&start_tag_to_html(&tag)); html_output.push_str(&start_tag_to_html(&tag));
} }
Event::End(TagEnd::Image) => {
// Render image with accumulated alt text
let alt = image_alt_content.take().unwrap_or_default();
if let Some((src, title)) = image_attrs.take() {
if title.is_empty() {
html_output.push_str(&format!(
"<img src=\"{}\" alt=\"{}\" />",
src,
html_escape(&alt)
));
} else {
html_output.push_str(&format!(
"<img src=\"{}\" alt=\"{}\" title=\"{}\" />",
src,
html_escape(&alt),
html_escape(&title)
));
}
}
}
Event::End(tag) => { Event::End(tag) => {
html_output.push_str(&end_tag_to_html(&tag)); html_output.push_str(&end_tag_to_html(&tag));
} }
@@ -191,11 +222,7 @@ fn start_tag_to_html(tag: &Tag) -> String {
format!("<a href=\"{}\" title=\"{}\">", dest_url, title) format!("<a href=\"{}\" title=\"{}\">", dest_url, title)
} }
} }
Tag::Image { Tag::Image { .. } => String::new(), // Handled separately in main loop
dest_url, title, ..
} => {
format!("<img src=\"{}\" alt=\"\" title=\"{}\" />", dest_url, title)
}
Tag::HtmlBlock => String::new(), Tag::HtmlBlock => String::new(),
Tag::MetadataBlock(_) => String::new(), Tag::MetadataBlock(_) => String::new(),
Tag::DefinitionListTitle => "<dt>".to_string(), Tag::DefinitionListTitle => "<dt>".to_string(),
@@ -227,7 +254,7 @@ fn end_tag_to_html(tag: &TagEnd) -> String {
TagEnd::Strong => "</strong>".to_string(), TagEnd::Strong => "</strong>".to_string(),
TagEnd::Strikethrough => "</del>".to_string(), TagEnd::Strikethrough => "</del>".to_string(),
TagEnd::Link => "</a>".to_string(), TagEnd::Link => "</a>".to_string(),
TagEnd::Image => String::new(), TagEnd::Image => String::new(), // Handled separately in main loop
TagEnd::HtmlBlock => String::new(), TagEnd::HtmlBlock => String::new(),
TagEnd::MetadataBlock(_) => String::new(), TagEnd::MetadataBlock(_) => String::new(),
TagEnd::DefinitionListTitle => "</dt>\n".to_string(), TagEnd::DefinitionListTitle => "</dt>\n".to_string(),
@@ -278,4 +305,24 @@ mod tests {
assert!(html.contains("<code>cargo run</code>")); assert!(html.contains("<code>cargo run</code>"));
} }
#[test]
fn test_image_alt_text() {
let md = "![Beautiful sunset](sunset.jpg \"Evening sky\")";
let html = markdown_to_html(md);
assert!(html.contains("alt=\"Beautiful sunset\""));
assert!(html.contains("title=\"Evening sky\""));
assert!(html.contains("src=\"sunset.jpg\""));
}
#[test]
fn test_image_alt_text_no_title() {
let md = "![Logo image](logo.png)";
let html = markdown_to_html(md);
assert!(html.contains("alt=\"Logo image\""));
assert!(html.contains("src=\"logo.png\""));
assert!(!html.contains("title="));
}
} }