diff --git a/moli-html2md/src/machine.rs b/moli-html2md/src/machine.rs index 62ff7cd9ee6..be30a64b29b 100644 --- a/moli-html2md/src/machine.rs +++ b/moli-html2md/src/machine.rs @@ -9,12 +9,13 @@ enum Task<'a, Id> { Children(Option, usize), Boundary, PopStyle, - EndLink(usize), + EndLink(usize, Option<&'a str>), EndQuote, EndHeading(usize), EndList(usize), EndItem(String), RawChildren(Option, usize, bool), + EndPreLink(&'a str, usize), EndRawBlock, EndCode, EndPre(Option<&'a str>), @@ -37,6 +38,7 @@ struct Machine<'a, D: Dom + ?Sized> { lists: Vec, tables: Vec>, raw: String, + pre_links: Vec<(String, &'a str)>, serial: usize, } @@ -49,6 +51,7 @@ pub(crate) fn convert(dom: &D, root: D::NodeId, options: &Optio lists: Vec::new(), tables: Vec::new(), raw: String::new(), + pre_links: Vec::new(), serial: 0, }; machine.run(); @@ -70,7 +73,9 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { Task::Children(None, _) | Task::RawChildren(None, _, _) => {} Task::Boundary => self.writer().boundary(2), Task::PopStyle => self.writer().pop_style(), - Task::EndLink(serial) => self.writer().end_link(serial), + Task::EndLink(serial, fallback_label) => { + self.writer().end_link(serial, fallback_label) + } Task::EndQuote => { let content = self.take_writer().into_string(); let mut quote = String::new(); @@ -132,6 +137,9 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { self.writer().block(item.into(), separation, separation); } Task::RawChildren(Some(node), depth, inline) => self.raw_node(node, depth, inline), + Task::EndPreLink(href, start) => { + self.pre_links.push((self.raw[start..].to_owned(), href)); + } Task::EndRawBlock => { if !self.raw.is_empty() && !self.raw.ends_with('\n') { self.raw.push('\n'); @@ -166,6 +174,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { let writer = self.writers.pop().expect("capture has an output"); if let Some(parent) = self.writers.last_mut() { parent.last_link = parent.last_link.max(writer.last_link); + parent.last_link_content = parent.last_link_content.max(writer.last_link_content); } writer.finish() } @@ -232,7 +241,10 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { title: self.dom.attribute(node, "title"), }; if self.writer().push_style(style) { - self.tasks.push(Task::EndLink(serial)); + self.tasks.push(Task::EndLink( + serial, + self.dom.attribute(node, "aria-label"), + )); } } } @@ -248,8 +260,35 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { } return; } + "video" | "audio" => { + self.writer().boundary(2); + if tag == "video" + && let Some(poster) = self.dom.attribute(node, "poster").and_then(resource_url) + { + self.writer().image("Video poster", poster, None); + self.writer().boundary(1); + } + if let Some(src) = self.dom.attribute(node, "src").and_then(resource_url) { + self.resource_link(src, if tag == "video" { "Video" } else { "Audio" }); + } + self.tasks.push(Task::Boundary); + } + "source" | "track" => { + if let Some(src) = self.dom.attribute(node, "src").and_then(resource_url) { + self.resource_link( + src, + if tag == "source" { + "Media source" + } else { + "Media track" + }, + ); + } + return; + } "code" | "pre" => { self.raw.clear(); + self.pre_links.clear(); let end = if tag == "pre" { Task::EndPre(self.language(node)) } else { @@ -315,6 +354,20 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { } } + fn resource_link(&mut self, href: &'a str, label: &str) { + self.serial += 1; + let serial = self.serial; + if self.writer().push_style(Style::Link { + serial, + href, + title: None, + }) { + self.writer().text(label); + self.writer().end_link(serial, None); + } + self.writer().boundary(1); + } + fn start_list(&mut self, node: D::NodeId, ordered: bool) { let before = if self .lists @@ -401,6 +454,12 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { } self.tasks.push(Task::EndRawBlock); } + if !inline + && let NodeKind::Element("a") = self.dom.node_kind(node) + && let Some(href) = self.dom.attribute(node, "href").and_then(resource_url) + { + self.tasks.push(Task::EndPreLink(href, self.raw.len())); + } self.tasks.push(Task::RawChildren( self.dom.first_child(node), depth + 1, @@ -415,6 +474,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { let text = std::mem::take(&mut self.raw); if text.trim().is_empty() { self.writer().boundary(2); + self.write_pre_links(); return; } // CommonMark code blocks normalize CRLF/CR and require a closing fence @@ -446,9 +506,41 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> { } block.push_str(&fence); self.writer().block(block.into(), 2, 2); + self.write_pre_links(); + } + + fn write_pre_links(&mut self) { + // A preformatted block may contain either source or a directory listing. + // Keep its literal text intact and expose destinations after the fence. + for (label, href) in std::mem::take(&mut self.pre_links) { + let label = label.trim(); + self.resource_link(href, if label.is_empty() { href } else { label }); + } } } +fn resource_url(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() || value.starts_with('#') || value.eq_ignore_ascii_case("about:blank") { + return None; + } + let normalized = value + .chars() + .filter(|ch| !ch.is_ascii_control()) + .collect::(); + let Some((scheme, _)) = normalized.split_once(':') else { + return Some(value); + }; + if scheme.contains(['/', '#', '?']) { + return Some(value); + } + matches!( + scheme.to_ascii_lowercase().as_str(), + "http" | "https" | "mailto" | "tel" | "ftp" + ) + .then_some(value) +} + fn class_language(class: Option<&str>) -> Option<&str> { class? .split_ascii_whitespace() diff --git a/moli-html2md/src/writer.rs b/moli-html2md/src/writer.rs index 7417fdf72c5..df7fb9e5ed7 100644 --- a/moli-html2md/src/writer.rs +++ b/moli-html2md/src/writer.rs @@ -40,6 +40,7 @@ pub(crate) struct Writer<'a> { heading: bool, single_line_attributes: bool, pub(crate) last_link: usize, + pub(crate) last_link_content: usize, // Block events in this output buffer, used when finalizing a list item. pub(crate) has_blocks: bool, } @@ -86,12 +87,21 @@ impl<'a> Writer<'a> { self.desired.pop(); } - pub(crate) fn end_link(&mut self, serial: usize) { + pub(crate) fn end_link(&mut self, serial: usize, fallback_label: Option<&str>) { self.flush_code(); if self.last_link < serial { - // Empty anchors still carry a destination. Materialize the link - // after its leading whitespace, even without a text event. - self.prepare_inline('['); + if let Some(label) = fallback_label + .filter(|label| self.last_link_content < serial && !label.trim().is_empty()) + { + // An empty anchor can still have an author-provided accessible + // name. Preserve that name inside the link instead of emitting + // an unusable empty label. Visible descendants remain primary. + self.text(label); + } else { + // Empty anchors still carry a destination. Materialize the link + // after its leading whitespace, even without a text event. + self.prepare_inline('['); + } } self.pop_style(); } @@ -250,6 +260,11 @@ impl<'a> Writer<'a> { self.boundary(before.max(after)); return; } + for style in &self.desired { + if let Style::Link { serial, .. } = style { + self.last_link_content = self.last_link_content.max(*serial); + } + } // Closing styles before detaching the tail keeps all inline edit // offsets local to the current String, never inside a finished block. self.boundary(before); @@ -320,6 +335,7 @@ impl<'a> Writer<'a> { Style::Strike => self.output.push_str(if html { "" } else { "~~" }), Style::Link { serial, .. } => { self.last_link = self.last_link.max(serial); + self.last_link_content = self.last_link_content.max(serial); if self.output.ends_with('!') { self.output.insert(self.output.len() - 1, '\\'); } diff --git a/moli-html2md/tests/regressions.rs b/moli-html2md/tests/regressions.rs index f5e12486a8b..c6f537d6e2c 100644 --- a/moli-html2md/tests/regressions.rs +++ b/moli-html2md/tests/regressions.rs @@ -57,6 +57,40 @@ fn empty_links_keep_destinations_across_adjacent_and_nested_elements() { } } +#[test] +fn empty_links_use_their_own_accessible_label_without_replacing_visible_text() { + for (html, expected) in [ + ( + "", + "[Open report](/report)", + ), + ( + "Visible report", + "[Visible report](/report)", + ), + ( + " ", + "[Open report](/report)", + ), + ("", "[](/report)"), + ( + "", + r"[\[Open\] \*report\*](/report)", + ), + ( + "", + "[One](/one)[Two](/two)", + ), + ( + "Cover", + "[![Cover](/cover.png)](/report)", + ), + ("", ""), + ] { + assert_eq!(markdown(html, false), expected, "{html}"); + } +} + #[test] fn href_without_a_destination_does_not_create_an_empty_markdown_link() { for html in [ @@ -445,6 +479,91 @@ fn embedded_image_references_survive_regardless_of_pixel_content() { } } +#[test] +fn native_media_elements_keep_declared_resource_urls() { + let result = markdown( + "", + false, + ); + for expected in [ + "![Video poster](/poster.jpg)", + "[Video](/direct.mp4)", + "[Media source](/movie.webm)", + "[Media source](/movie.mp4)", + "[Media track](/captions.vtt)", + "[Audio](/episode.ogg)", + ] { + assert!(result.contains(expected), "missing {expected}: {result}"); + } +} + +#[test] +fn media_elements_do_not_emit_active_script_urls() { + for unsafe_url in [ + "javascript:alert(1)", + "java script:alert(1)", + "vbscript:bad", + "data:text/html,bad", + "about:blank", + "#fragment", + ] { + let source = format!(""); + let result = markdown(&source, false); + assert!(result.is_empty(), "{source}: {result}"); + } +} + +#[test] +fn preformatted_directory_links_keep_destinations_outside_literal_text() { + let html = "
file.mp4 checksum
"; + for preformatted in [false, true] { + assert_eq!( + markdown(html, preformatted), + "```\nfile.mp4 checksum\n```\n\n[file.mp4](release/file.mp4)\n[checksum](release/checksum.txt)" + ); + } +} + +#[test] +fn preformatted_code_cross_references_preserve_literal_source() { + let html = "
println!("hello");\nlet values = Vec::new();
"; + for preformatted in [false, true] { + assert_eq!( + markdown(html, preformatted), + "```rust\nprintln!(\"hello\");\nlet values = Vec::new();\n```\n\n[println!](/macros/println)\n[Vec](/types/Vec)" + ); + } +} + +#[test] +fn preformatted_directory_links_survive_around_code_examples() { + let html = "
before\nValue value;\nafter
"; + for preformatted in [false, true] { + assert_eq!( + markdown(html, preformatted), + "```\nbefore\nValue value;\nafter\n```\n\n[before](before.txt)\n[Value](/types/Value)\n[after](after.txt)" + ); + } +} + +#[test] +fn preformatted_signatures_and_language_blocks_keep_source_without_code_wrappers() { + for (html, expected) in [ + ( + "
public String getText()
", + "```\npublic String getText()\n```\n\n[String](https://docs.example/String)", + ), + ( + "
let value: String = String::new();
", + "```rust\nlet value: String = String::new();\n```\n\n[String](/types/String)", + ), + ] { + for preformatted in [false, true] { + assert_eq!(markdown(html, preformatted), expected, "{html}"); + } + } +} + #[test] fn list_paragraphs_and_code_blocks_require_blank_lines_between_items() { for (html, expected) in [ @@ -488,3 +607,45 @@ fn fallback_block_elements_separate_neighboring_words() { ); } } + +#[test] +fn accessible_link_fallback_never_duplicates_visible_inline_or_block_descendants() { + for content in [ + "Visible", + "Cover", + "code", + "
preformatted
", + "
nested code
", + "

Paragraph

", + "

Heading

", + "
  • Item
", + "
Quotation
", + "
Cell
", + ] { + let plain = format!("{content}"); + let labeled = format!("{content}"); + assert_eq!( + markdown(&labeled, false), + markdown(&plain, false), + "{content}" + ); + } +} + +#[test] +fn empty_link_fallback_belongs_only_to_that_anchor() { + assert_eq!( + markdown( + "
", + false + ), + "[](/one)[Own](/two)" + ); + assert_eq!( + markdown( + "", + false + ), + "[Own](/target)" + ); +}