diff --git a/moli-html2md/README.md b/moli-html2md/README.md
index 133f4be7577..cf7ee6fe394 100644
--- a/moli-html2md/README.md
+++ b/moli-html2md/README.md
@@ -75,7 +75,8 @@ another conversion path. Complex layouts remain readable blocks instead of raw
HTML tables.
Supported output includes headings, paragraphs, emphasis, strikethrough, links,
-images, lists, blockquotes, hard breaks, and fenced code. Inline HTML is used when
+images, lists, blockquotes, hard breaks, media source links, readable form values,
+separated form choices, and fenced code. Inline HTML is used when
Markdown delimiters cannot express an emphasis boundary.
Script/style/head/noscript/template subtrees are omitted.
@@ -99,7 +100,7 @@ strip/base/frame transformations remain the responsibility of the caller.
whitespace, escaping, code, tables, depth limits, and 20,000 nested nodes on a
64 KiB thread stack. The [Turndown differential suite](tests/turndown/README.md)
pins 147 upstream fixtures and 50 additional cases. It compares rendered meaning,
-checks DOM immutability, and explicitly asserts 12 documented differences that
+checks DOM immutability, and explicitly asserts 13 documented differences that
preserve Moli's handling of literal text, code, emphasis, and list boundaries.
Targeted regressions also cover empty elements, links, attributes in headings and
tables, code whitespace and language names, list numbering, and loose lists.
@@ -116,3 +117,19 @@ The HTML parser, reference JSON reader, and `pulldown-cmark` renderer are all
**test-only** dependencies. Tests use checked-in reference outputs and require
neither Node nor network access. NativeDom integration and real HTML fixtures
are tested in `moli-renderer-v8`.
+
+Unavailable images retain their text alternatives. Media references use standard
+source declarations before lazy-loading fallbacks. Password values and hidden or
+file input values are omitted.
+
+Plain DOM attributes describe the supplied snapshot, including default form
+values and checked state. Browser adapters can expose live attributes and
+`control_value` instead. Placeholder hints are not field values.
+Empty text-entry controls emit no value; button-like
+controls may use accessible labels; generic containers do not acquire text
+from tooltip titles or prohibited names. Child output takes precedence over
+any fallback label. Links retain application protocols while executable
+JavaScript and inline HTML document URLs are excluded.
+
+SVG title children are tooltip metadata, not body text. They supply a name only
+when an SVG has no emitted content; title elements outside SVG are omitted.
diff --git a/moli-html2md/src/content.rs b/moli-html2md/src/content.rs
new file mode 100644
index 00000000000..2b52c378098
--- /dev/null
+++ b/moli-html2md/src/content.rs
@@ -0,0 +1,102 @@
+use crate::{Dom, NodeKind};
+use std::borrow::Cow;
+
+pub(crate) fn fallback_text(dom: &D, node: D::NodeId) -> Option {
+ if excluded_from_naming(dom, node) {
+ return None;
+ }
+ let named_tag = matches!(
+ dom.node_kind(node),
+ NodeKind::Element(
+ "a" | "button"
+ | "svg"
+ | "textarea"
+ | "select"
+ | "option"
+ | "optgroup"
+ | "h1"
+ | "h2"
+ | "h3"
+ | "h4"
+ | "h5"
+ | "h6"
+ )
+ );
+ let named_role =
+ primary_role(dom, node).is_some_and(|role| role_matches(role, NAMED_WIDGET_ROLES));
+ if !named_tag && !named_role {
+ return None;
+ }
+ for name in ["aria-label", "title"] {
+ if let Some(value) = dom
+ .attribute(node, name)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ return Some(value.to_owned());
+ }
+ }
+ if matches!(dom.node_kind(node), NodeKind::Element("svg")) {
+ let mut child = dom.first_child(node);
+ while let Some(id) = child {
+ if dom.node_kind(id) == NodeKind::Element("title") {
+ let mut text = String::new();
+ let mut title_child = dom.first_child(id);
+ while let Some(part) = title_child {
+ if let NodeKind::Text(value) = dom.node_kind(part) {
+ text.push_str(value);
+ }
+ title_child = dom.next_sibling(part);
+ }
+ if !text.trim().is_empty() {
+ return Some(text);
+ }
+ }
+ child = dom.next_sibling(id);
+ }
+ }
+ None
+}
+
+const PRESENTATIONAL_ROLES: &[&str] = &["none", "presentation"];
+const NAMED_WIDGET_ROLES: &[&str] = &[
+ "button", "link", "img", "heading", "checkbox", "radio", "textbox", "combobox", "switch", "tab",
+];
+
+// This structural converter uses the first authored token as a conservative
+// naming hint; it does not implement an accessibility-tree role resolver.
+fn primary_role(dom: &D, node: D::NodeId) -> Option<&str> {
+ dom.attribute(node, "role")?.split_ascii_whitespace().next()
+}
+fn role_matches(role: &str, candidates: &[&str]) -> bool {
+ candidates
+ .iter()
+ .any(|candidate| role.eq_ignore_ascii_case(candidate))
+}
+
+fn excluded_from_naming(dom: &D, node: D::NodeId) -> bool {
+ dom.attribute(node, "aria-hidden")
+ .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
+ || dom.attribute(node, "hidden").is_some()
+ || primary_role(dom, node).is_some_and(|role| role_matches(role, PRESENTATIONAL_ROLES))
+}
+
+pub(crate) fn image_label(dom: &D, node: D::NodeId) -> Cow<'_, str> {
+ let alt = dom.attribute(node, "alt");
+ if let Some(alt) = alt.filter(|alt| !alt.is_empty()) {
+ return Cow::Borrowed(alt);
+ }
+ if excluded_from_naming(dom, node) {
+ return Cow::Borrowed("");
+ }
+ ["aria-label", "title"]
+ .into_iter()
+ .filter(|name| *name != "title" || alt.is_none())
+ .find_map(|name| {
+ dom.attribute(node, name)
+ .map(str::trim)
+ .filter(|v| !v.is_empty())
+ })
+ .map(Cow::Borrowed)
+ .unwrap_or(Cow::Borrowed(""))
+}
diff --git a/moli-html2md/src/dom.rs b/moli-html2md/src/dom.rs
index 583e68467fb..15a86903a37 100644
--- a/moli-html2md/src/dom.rs
+++ b/moli-html2md/src/dom.rs
@@ -26,4 +26,10 @@ pub trait Dom {
fn first_child(&self, node: Self::NodeId) -> Option;
fn next_sibling(&self, node: Self::NodeId) -> Option;
fn attribute(&self, node: Self::NodeId, name: &str) -> Option<&str>;
+ /// Current input/textarea value supplied by an adapter. None uses the
+ /// source default; Some("") suppresses that default after a clear. Empty
+ /// text-entry controls do not emit their name as a field value.
+ fn control_value(&self, _node: Self::NodeId) -> Option<&str> {
+ None
+ }
}
diff --git a/moli-html2md/src/form.rs b/moli-html2md/src/form.rs
new file mode 100644
index 00000000000..0553504c489
--- /dev/null
+++ b/moli-html2md/src/form.rs
@@ -0,0 +1,56 @@
+use std::borrow::Cow;
+
+use crate::Dom;
+
+pub(crate) fn input_text<'a, D: Dom + ?Sized>(dom: &'a D, node: D::NodeId) -> Option> {
+ let kind = dom.attribute(node, "type").unwrap_or("text");
+ if kind.eq_ignore_ascii_case("checkbox") || kind.eq_ignore_ascii_case("radio") {
+ return Some(Cow::Borrowed(if dom.attribute(node, "checked").is_some() {
+ "☑"
+ } else {
+ "☐"
+ }));
+ }
+ if ["hidden", "file"]
+ .iter()
+ .any(|excluded| kind.eq_ignore_ascii_case(excluded))
+ {
+ return None;
+ }
+ if kind.eq_ignore_ascii_case("image") {
+ return dom
+ .attribute(node, "alt")
+ .filter(|value| !value.is_empty())
+ .map(Cow::Borrowed);
+ }
+ let button = ["submit", "button", "reset"]
+ .iter()
+ .any(|v| kind.eq_ignore_ascii_case(v));
+ let value = (!kind.eq_ignore_ascii_case("password"))
+ .then(|| {
+ dom.control_value(node)
+ .or_else(|| dom.attribute(node, "value"))
+ })
+ .flatten()
+ .filter(|value| !value.is_empty())
+ .or_else(|| {
+ button
+ .then(|| dom.attribute(node, "aria-label"))
+ .flatten()
+ .filter(|value| !value.is_empty())
+ })
+ .or_else(|| {
+ button
+ .then(|| dom.attribute(node, "title"))
+ .flatten()
+ .filter(|value| !value.is_empty())
+ });
+ value.map(Cow::Borrowed)
+}
+
+pub(crate) fn textarea_text<'a, D: Dom + ?Sized>(
+ dom: &'a D,
+ node: D::NodeId,
+) -> Option> {
+ dom.control_value(node).map(Cow::Borrowed)
+}
diff --git a/moli-html2md/src/lib.rs b/moli-html2md/src/lib.rs
index 2562a5125da..7c835cd837b 100644
--- a/moli-html2md/src/lib.rs
+++ b/moli-html2md/src/lib.rs
@@ -1,6 +1,9 @@
+mod content;
mod converter;
mod dom;
+mod form;
mod machine;
+mod media;
mod options;
mod output;
mod table;
@@ -16,3 +19,5 @@ extern crate self as moli_html2md;
#[cfg(test)]
mod copy_tests;
+
+pub use media::{SrcsetCandidate, parse_srcset};
diff --git a/moli-html2md/src/machine.rs b/moli-html2md/src/machine.rs
index 62ff7cd9ee6..0abe1c24b3d 100644
--- a/moli-html2md/src/machine.rs
+++ b/moli-html2md/src/machine.rs
@@ -8,6 +8,8 @@ enum Task<'a, Id> {
Visit(Id, usize),
Children(Option, usize),
Boundary,
+ ChoiceBoundary,
+ Fallback(String, usize),
PopStyle,
EndLink(usize),
EndQuote,
@@ -69,6 +71,14 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
Task::Children(None, _) | Task::RawChildren(None, _, _) => {}
Task::Boundary => self.writer().boundary(2),
+ Task::ChoiceBoundary => self.writer().boundary(1),
+ Task::Fallback(label, before) => {
+ if self.writer().content_mark() == before {
+ self.writer().text(" ");
+ self.writer().text(&label);
+ self.writer().text(" ");
+ }
+ }
Task::PopStyle => self.writer().pop_style(),
Task::EndLink(serial) => self.writer().end_link(serial),
Task::EndQuote => {
@@ -207,6 +217,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
match tag {
"head" | "script" | "style" | "noscript" | "template" => return,
+ "title" => return,
"br" => {
self.writer().hard_break();
return;
@@ -215,6 +226,19 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
self.writer().block("---".into(), 2, 2);
return;
}
+ "select" | "optgroup" | "option" => {
+ self.writer().boundary(1);
+ if matches!(tag, "optgroup" | "option")
+ && let Some(label) = self.dom.attribute(node, "label")
+ {
+ self.writer().text(label);
+ self.writer().boundary(1);
+ if tag == "option" {
+ return;
+ }
+ }
+ self.tasks.push(Task::ChoiceBoundary);
+ }
"strong" | "b" => self.style(Style::Strong),
"em" | "i" => self.style(Style::Emphasis),
"del" | "s" | "strike" => self.style(Style::Strike),
@@ -222,7 +246,7 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
if let Some(href) = self
.dom
.attribute(node, "href")
- .filter(|href| !href.is_empty())
+ .filter(|href| !href.is_empty() && crate::media::safe_link_url(href))
{
self.serial += 1;
let serial = self.serial;
@@ -237,17 +261,38 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
}
"img" => {
- let alt = self.dom.attribute(node, "alt").unwrap_or_default();
+ let alt = crate::content::image_label(self.dom, node);
let title = self.dom.attribute(node, "title");
- if let Some(src) = self
- .dom
- .attribute(node, "src")
- .filter(|src| !src.is_empty())
- {
- self.writer().image(alt, src, title);
+ if let Some(src) = crate::media::source(self.dom, node) {
+ self.writer().image(&alt, &src, title);
+ } else {
+ // A failed or absent resource must not erase its textual
+ // alternative, especially when it is a link's only label.
+ self.writer().text(&alt);
+ }
+ return;
+ }
+ "input" => {
+ if let Some(value) = crate::form::input_text(self.dom, node) {
+ self.writer().text(&value);
}
return;
}
+ "textarea" => {
+ if let Some(value) = crate::form::textarea_text(self.dom, node) {
+ self.writer().text(&value);
+ return;
+ }
+ }
+ "iframe" | "video" | "audio" => {
+ if self.media(node, tag, depth) {
+ return;
+ }
+ if is_block(tag) {
+ self.writer().boundary(2);
+ self.tasks.push(Task::Boundary);
+ }
+ }
"code" | "pre" => {
self.raw.clear();
let end = if tag == "pre" {
@@ -298,9 +343,62 @@ impl<'a, D: Dom + ?Sized> Machine<'a, D> {
}
_ => {}
}
+ if let Some(label) = crate::content::fallback_text(self.dom, node) {
+ let before = self.writer().content_mark();
+ self.tasks.push(Task::Fallback(label, before));
+ }
self.children(node, depth + 1);
}
+ fn media(&mut self, node: D::NodeId, tag: &str, depth: usize) -> bool {
+ let urls = crate::media::sources(self.dom, node, tag, depth, self.options.max_depth);
+ let poster = (tag == "video")
+ .then(|| crate::media::direct_attribute(self.dom, node, "poster", true))
+ .flatten();
+ if urls.is_empty() && poster.is_none() {
+ return false;
+ }
+ let block = is_block(tag);
+ if block {
+ self.writer().boundary(2);
+ } else {
+ self.writer().text(" ");
+ }
+ if let Some(poster) = poster {
+ let poster_label = crate::media::poster_label(self.dom, node);
+ self.writer().image(&poster_label, poster, None);
+ self.writer().text(" ");
+ }
+ let label = crate::media::label(self.dom, node, tag);
+ let has_source = !urls.is_empty();
+ for href in urls {
+ self.serial += 1;
+ let serial = self.serial;
+ let opened = self.writer().push_style(Style::Link {
+ serial,
+ href,
+ title: None,
+ });
+ self.writer().text(label);
+ if opened {
+ self.writer().end_link(serial);
+ } else {
+ // Markdown cannot nest a media link inside a surrounding link.
+ // Retain its destination as text rather than silently losing it.
+ self.writer().text(" (");
+ self.writer().text(href);
+ self.writer().text(")");
+ }
+ self.writer().text(" ");
+ }
+ if block {
+ self.writer().boundary(2);
+ } else {
+ self.writer().text(" ");
+ }
+ has_source
+ }
+
fn table_cell(&mut self) {
let table = self.tables.last_mut().expect("table conversion is active");
if let Some(cell) = table.next_cell() {
diff --git a/moli-html2md/src/media.rs b/moli-html2md/src/media.rs
new file mode 100644
index 00000000000..a16003863fc
--- /dev/null
+++ b/moli-html2md/src/media.rs
@@ -0,0 +1,312 @@
+use std::borrow::Cow;
+
+use crate::{Dom, NodeKind};
+
+/// Private lazy-loading conventions, used only when standard sources are absent.
+const LAZY_SOURCE_ATTRIBUTES: [&str; 3] = ["data-src", "data-lazy-src", "data-original"];
+
+/// Frozen DOMs can still carry lazy-loading placeholders. These conventional
+/// attributes declare the resource the page intends to load, not the spacer.
+pub(crate) fn source<'a, D: Dom + ?Sized>(dom: &'a D, node: D::NodeId) -> Option> {
+ // A responsive declaration determines the rendered resource even when a
+ // fallback `src` is present. Private lazy-load attributes remain fallback
+ // inputs and never override a valid standard declaration.
+ if let Some(value) = dom
+ .attribute(node, "srcset")
+ .and_then(|value| largest_srcset_candidate(value, true))
+ {
+ return Some(value);
+ }
+ let src = direct_attribute(dom, node, "src", true);
+ if src.is_some() {
+ return src.map(Cow::Borrowed);
+ }
+ if let Some(value) = dom
+ .attribute(node, "data-srcset")
+ .and_then(|value| largest_srcset_candidate(value, true))
+ {
+ return Some(value);
+ }
+ LAZY_SOURCE_ATTRIBUTES
+ .into_iter()
+ .find_map(|name| direct_attribute(dom, node, name, true))
+ .map(Cow::Borrowed)
+}
+
+fn direct_source(dom: &D, node: D::NodeId, image: bool) -> Option<&str> {
+ let src = direct_attribute(dom, node, "src", image);
+ src.or_else(|| {
+ LAZY_SOURCE_ATTRIBUTES
+ .into_iter()
+ .find_map(|name| direct_attribute(dom, node, name, image))
+ })
+}
+
+pub(crate) fn direct_attribute<'a, D: Dom + ?Sized>(
+ dom: &'a D,
+ node: D::NodeId,
+ name: &str,
+ image: bool,
+) -> Option<&'a str> {
+ dom.attribute(node, name).map(str::trim).filter(|value| {
+ !value.is_empty()
+ && !value.starts_with('#')
+ && *value != "about:blank"
+ && safe_url(value, image)
+ })
+}
+
+/// A validated HTML responsive-image candidate. Dimensions are descriptor
+/// values, not an image-selection policy; consumers choose using their viewport.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SrcsetCandidate<'a> {
+ pub url: &'a str,
+ pub descriptor: &'a str,
+ pub width: Option,
+ pub height: Option,
+ pub density: Option,
+}
+
+/// Parse HTML srcset syntax, discarding malformed candidates.
+/// Non-ASCII spaces are resource/descriptor characters, not separators.
+pub fn parse_srcset(srcset: &str) -> Vec> {
+ let bytes = srcset.as_bytes();
+ let mut offset = 0;
+ let mut candidates = Vec::new();
+ while offset < bytes.len() {
+ while offset < bytes.len() && (bytes[offset].is_ascii_whitespace() || bytes[offset] == b',')
+ {
+ offset += 1;
+ }
+ let url_start = offset;
+ while offset < bytes.len() && !bytes[offset].is_ascii_whitespace() {
+ offset += 1;
+ }
+ if url_start == offset {
+ break;
+ }
+ let mut url_end = offset;
+ while url_end > url_start && bytes[url_end - 1] == b',' {
+ url_end -= 1;
+ }
+ let url = &srcset[url_start..url_end];
+ let ended_with_comma = url_end < offset;
+ if ended_with_comma {
+ if !url.is_empty() {
+ candidates.push(SrcsetCandidate {
+ url,
+ descriptor: "",
+ width: None,
+ height: None,
+ density: None,
+ });
+ }
+ continue;
+ }
+
+ let descriptor_start = offset;
+ let mut in_parentheses = false;
+ while offset < bytes.len() {
+ match bytes[offset] {
+ b'(' => in_parentheses = true,
+ b')' => in_parentheses = false,
+ b',' if !in_parentheses => break,
+ _ => {}
+ }
+ offset += 1;
+ }
+ let descriptor = srcset[descriptor_start..offset].trim_ascii();
+ let parsed = descriptor_values(descriptor);
+ if let Some((width, height, density)) = parsed
+ && !url.is_empty()
+ {
+ candidates.push(SrcsetCandidate {
+ url,
+ descriptor,
+ width,
+ height,
+ density,
+ });
+ }
+ }
+ candidates
+}
+
+fn descriptor_values(descriptor: &str) -> Option<(Option, Option, Option)> {
+ if descriptor.is_empty() {
+ return Some((None, None, None));
+ }
+ let mut width = None;
+ let mut height = None;
+ let mut density = None;
+ for token in descriptor.split_ascii_whitespace() {
+ if let Some(value) = token.strip_suffix('w') {
+ if width.is_some() {
+ return None;
+ }
+ width = Some(valid_positive_integer(value)?);
+ } else if let Some(value) = token.strip_suffix('h') {
+ if height.is_some() {
+ return None;
+ }
+ height = Some(valid_positive_integer(value)?);
+ } else if let Some(value) = token.strip_suffix('x') {
+ if density.is_some() {
+ return None;
+ }
+ density = Some(valid_positive_float(value)?);
+ } else {
+ return None;
+ }
+ }
+ match (width, height, density) {
+ (Some(_), _, None) | (None, None, Some(_)) => Some((width, height, density)),
+ _ => None,
+ }
+}
+
+fn valid_positive_integer(value: &str) -> Option {
+ (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
+ .then(|| value.parse::().ok())
+ .flatten()
+ .filter(|value| *value > 0)
+}
+
+fn valid_positive_float(value: &str) -> Option {
+ let bytes = value.as_bytes();
+ if bytes.is_empty() || bytes[0] == b'+' {
+ return None;
+ }
+ let mut offset = usize::from(bytes[0] == b'-');
+ if offset == bytes.len() {
+ return None;
+ }
+ let integer_start = offset;
+ while offset < bytes.len() && bytes[offset].is_ascii_digit() {
+ offset += 1;
+ }
+ let has_integer = offset > integer_start;
+ let mut has_fraction = false;
+ if bytes.get(offset) == Some(&b'.') {
+ offset += 1;
+ let fraction = offset;
+ while offset < bytes.len() && bytes[offset].is_ascii_digit() {
+ offset += 1;
+ }
+ if fraction == offset {
+ return None;
+ }
+ has_fraction = true;
+ }
+ if !has_integer && !has_fraction {
+ return None;
+ }
+ if matches!(bytes.get(offset), Some(b'e' | b'E')) {
+ offset += 1;
+ if matches!(bytes.get(offset), Some(b'+' | b'-')) {
+ offset += 1;
+ }
+ let exponent = offset;
+ while offset < bytes.len() && bytes[offset].is_ascii_digit() {
+ offset += 1;
+ }
+ if exponent == offset {
+ return None;
+ }
+ }
+ (offset == bytes.len())
+ .then(|| value.parse::().ok())
+ .flatten()
+ .filter(|value| value.is_finite() && *value >= 0.0)
+}
+
+fn largest_srcset_candidate(srcset: &str, image: bool) -> Option> {
+ parse_srcset(srcset)
+ .into_iter()
+ .filter(|candidate| safe_url(candidate.url, image))
+ .max_by(|left, right| {
+ let score = |candidate: &SrcsetCandidate<'_>| {
+ candidate
+ .width
+ .map(|v| v as f64)
+ .or(candidate.density)
+ .unwrap_or(1.0)
+ };
+ score(left).total_cmp(&score(right))
+ })
+ .map(|candidate| Cow::Borrowed(candidate.url))
+}
+
+pub(crate) fn safe_url(value: &str, image: bool) -> bool {
+ let normalized: String = value
+ .trim()
+ .chars()
+ .filter(|ch| !ch.is_ascii_control())
+ .collect();
+ let Some((scheme, _)) = normalized.split_once(':') else {
+ return true;
+ };
+ if scheme.contains(['/', '#', '?']) {
+ return true;
+ }
+ matches!(
+ scheme.to_ascii_lowercase().as_str(),
+ "http" | "https" | "mailto" | "tel" | "ftp"
+ ) || (image && normalized.to_ascii_lowercase().starts_with("data:image/"))
+}
+
+pub(crate) fn sources<'a, D: Dom + ?Sized>(
+ dom: &'a D,
+ node: D::NodeId,
+ tag: &str,
+ depth: usize,
+ limit: usize,
+) -> Vec<&'a str> {
+ let mut urls = Vec::new();
+ let mut seen = std::collections::HashSet::new();
+ if let Some(src) = direct_source(dom, node, false) {
+ seen.insert(src);
+ urls.push(src);
+ }
+ if tag != "iframe" && depth + 1 < limit {
+ let mut child = dom.first_child(node);
+ while let Some(id) = child {
+ child = dom.next_sibling(id);
+ if dom.node_kind(id) == NodeKind::Element("source")
+ && let Some(src) = direct_source(dom, id, false)
+ && seen.insert(src)
+ {
+ urls.push(src);
+ }
+ }
+ }
+ urls
+}
+
+pub(crate) fn label<'a, D: Dom + ?Sized>(dom: &'a D, node: D::NodeId, tag: &str) -> &'a str {
+ dom.attribute(node, "title")
+ .filter(|label| !label.trim().is_empty())
+ .unwrap_or(match tag {
+ "video" => "Video",
+ "audio" => "Audio",
+ _ => "Embedded content",
+ })
+}
+
+/// Links preserve application protocols; executable inline-document schemes do not.
+pub(crate) fn safe_link_url(value: &str) -> bool {
+ let normalized: String = value
+ .trim()
+ .chars()
+ .filter(|c| !c.is_ascii_control())
+ .collect();
+ let normalized = normalized.to_ascii_lowercase();
+ !normalized.starts_with("javascript:")
+ && !normalized.starts_with("vbscript:")
+ && !normalized.starts_with("data:text/html")
+ && !normalized.starts_with("data:application/xhtml+xml")
+}
+
+pub(crate) fn poster_label(dom: &D, node: D::NodeId) -> String {
+ format!("{} poster", label(dom, node, "video"))
+}
diff --git a/moli-html2md/src/writer.rs b/moli-html2md/src/writer.rs
index 7417fdf72c5..18277faa810 100644
--- a/moli-html2md/src/writer.rs
+++ b/moli-html2md/src/writer.rs
@@ -26,6 +26,7 @@ struct OpenStyle<'a> {
pub(crate) struct Writer<'a> {
// Blocks are immutable fragments; inline edits only touch the local tail.
prefix: Output,
+ content_revision: usize,
output: String,
desired: Vec