mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 08:08:10 +00:00
fix(renderer): omit empty hidden structures and synthetic control defaults
This commit is contained in:
+24
-21
@@ -19,7 +19,8 @@ let markdown = moli_html2md::Converter::new(moli_html2md::Options {
|
||||
|
||||
## DOM interface
|
||||
|
||||
Implement `Dom` with a copyable node ID and four required borrowed queries and four optional hooks:
|
||||
Implement `Dom` with a copyable node ID, four required borrowed queries and four
|
||||
optional hooks:
|
||||
|
||||
- `node_kind`: document/fragment, lowercase element tag, decoded text, or ignored node.
|
||||
- `first_child` and `next_sibling`: ordered traversal of a stable, acyclic tree.
|
||||
@@ -107,15 +108,25 @@ default is `false`, which collapses ordinary HTML whitespace and moves boundary
|
||||
spaces outside code delimiters. Fenced blocks always preserve nonempty code's
|
||||
spaces and blank lines. Empty blocks still separate surrounding text.
|
||||
|
||||
## Content policy
|
||||
|
||||
This is a structural content dump. It omits non-rendered serialized state blobs
|
||||
but retains content in collapsed panels and inactive tabs. It does not evaluate
|
||||
stylesheets, layout, or JavaScript. Callers may supply computed block boundaries
|
||||
through the optional boundary hooks. Moli's browser exporter also uses current CSS
|
||||
visibility, retaining disclosure panels and collapsed lines in visible source
|
||||
blocks while excluding hidden UI templates. The converter cannot preserve all
|
||||
HTML presentation (for example, arbitrary ordered-list numbering).
|
||||
DOM snapshots used by Moli's optional
|
||||
strip/base/frame transformations remain the responsibility of the caller.
|
||||
through the optional boundary hooks.
|
||||
|
||||
Moli's browser export uses current CSS visibility. It retains explicit disclosure
|
||||
targets, content tabpanels, searchable hidden content and collapsed lines inside
|
||||
visible preformatted source. Hidden navigation, menus, dialogs and popovers do
|
||||
not become disclosure content. An inherited root visibility veil does not discard
|
||||
the document; explicitly hidden descendants remain excluded. Hidden table cells
|
||||
retain their positions, while wholly invisible tables and collapsed rows are
|
||||
omitted. Computed boxes supplement document structure without changing a tight
|
||||
list into a loose one merely because each item contains a single block box.
|
||||
|
||||
The converter cannot preserve all HTML presentation (for example, arbitrary
|
||||
ordered-list numbering). DOM snapshots used by Moli's optional strip/base/frame
|
||||
transformations remain the responsibility of the caller.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -150,12 +161,12 @@ 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.
|
||||
`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.
|
||||
@@ -179,11 +190,3 @@ positions. Its language marker and physical newlines are preserved; a standalone
|
||||
cell boundaries without blank lines. Plain heading wrappers remain Markdown
|
||||
headings. Captured headings, list items and quotations contain their own fragment
|
||||
targets rather than appending them to the preceding block.
|
||||
|
||||
Moli's browser export retains explicit disclosure targets, content tabpanels,
|
||||
searchable hidden content and collapsed lines inside visible preformatted source.
|
||||
Hidden navigation, menus, dialogs and popovers do not become disclosure content.
|
||||
A root visibility veil can be inherited without discarding the document; explicit
|
||||
hidden descendants remain excluded. Hidden table cells retain their positions.
|
||||
Computed boundaries supplement ordinary document structure; they do not turn
|
||||
native lists into loose lists or inline form controls into separate paragraphs.
|
||||
|
||||
@@ -116,6 +116,7 @@ impl<'a> MarkdownDom<'a> {
|
||||
let mut unchecked_controls = HashSet::new();
|
||||
let mut visibility_modes = HashMap::new();
|
||||
let mut source_regions = HashSet::new();
|
||||
let node_order: Vec<_> = styles.iter().map(|(node, _)| *node).collect();
|
||||
for (node, values) in styles {
|
||||
debug_assert!(
|
||||
dom.parent_node(node).is_none_or(|parent| {
|
||||
@@ -138,7 +139,10 @@ impl<'a> MarkdownDom<'a> {
|
||||
// A clean textarea gets its default from child text, not the
|
||||
// control-state slot (which is initially empty). Once dirty,
|
||||
// even an empty live value is authoritative.
|
||||
if element.is_html_input() || element.input_value_dirty() {
|
||||
let synthetic_default = Dom::attribute(dom, node, "type").is_some_and(|kind| {
|
||||
kind.eq_ignore_ascii_case("range") || kind.eq_ignore_ascii_case("color")
|
||||
}) && Dom::attribute(dom, node, "value").is_none();
|
||||
if (element.is_html_input() && !synthetic_default) || element.input_value_dirty() {
|
||||
attribute_overrides
|
||||
.entry(node)
|
||||
.or_insert_with(HashMap::new)
|
||||
@@ -192,7 +196,13 @@ impl<'a> MarkdownDom<'a> {
|
||||
parent_visibility
|
||||
};
|
||||
visibility_modes.insert(node, visibility_mode);
|
||||
if (values.display == "none" && !in_source && !disclosure_root && !disclosure_path)
|
||||
let collapsed_table_part = values.visibility == "collapse"
|
||||
&& matches!(
|
||||
Dom::node_kind(dom, node),
|
||||
NodeKind::Element("tr" | "thead" | "tbody" | "tfoot" | "col" | "colgroup")
|
||||
);
|
||||
if collapsed_table_part
|
||||
|| (values.display == "none" && !in_source && !disclosure_root && !disclosure_path)
|
||||
|| (parent_is_disclosure_path && !disclosure_root && !disclosure_path)
|
||||
|| dom
|
||||
.parent_node(node)
|
||||
@@ -210,13 +220,7 @@ impl<'a> MarkdownDom<'a> {
|
||||
}
|
||||
let display = values.display.as_str();
|
||||
if !matches!(display, "" | "none" | "contents" | "inline" | "inline flow") {
|
||||
let native_control = matches!(
|
||||
Dom::node_kind(dom, node),
|
||||
NodeKind::Element("input" | "textarea" | "select" | "button")
|
||||
);
|
||||
if native_control && display == "inline-block" {
|
||||
// Their inline control semantics already separate readable values.
|
||||
} else if display.starts_with("inline") {
|
||||
if display.starts_with("inline") {
|
||||
inline_boundaries.insert(node);
|
||||
} else {
|
||||
blocks.insert(node);
|
||||
@@ -259,6 +263,24 @@ impl<'a> MarkdownDom<'a> {
|
||||
invisible.insert(node);
|
||||
}
|
||||
}
|
||||
// Invisible cells still occupy columns. A wholly invisible table/list
|
||||
// has no structure to export, unless a descendant restores visibility.
|
||||
let mut visible_subtrees = HashSet::new();
|
||||
for node in node_order.into_iter().rev() {
|
||||
if suppressed.contains(&node) {
|
||||
continue;
|
||||
}
|
||||
if !invisible.contains(&node) || visible_subtrees.contains(&node) {
|
||||
if let Some(parent) = dom.parent_node(node) {
|
||||
visible_subtrees.insert(parent);
|
||||
}
|
||||
} else if matches!(
|
||||
Dom::node_kind(dom, node),
|
||||
NodeKind::Element("table" | "ul" | "ol" | "dl")
|
||||
) {
|
||||
suppressed.insert(node);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
dom,
|
||||
suppressed,
|
||||
|
||||
@@ -482,6 +482,12 @@ async fn markdown_live_structure_and_visibility_have_exact_outputs() {
|
||||
("<table><tr><th>Name</th><th>Status</th><th>Owner</th></tr><tr><td>A</td><td style='visibility:hidden'>private</td><td>alice</td></tr></table>", "| Name | Status | Owner |\n| --- | --- | --- |\n| A | | alice |"),
|
||||
("<a href='../docs'>Docs</a><img src='../a.png' alt='A'>", "[Docs](https://example.test/docs)"),
|
||||
("<dialog><section role=tabpanel>Cookie preferences</section></dialog><div popover><section role=tabpanel>Hidden menu</section></div><p>Article</p>", "Article"),
|
||||
("<ul><li><a href='/a' style='display:block'>A</a></li><li><a href='/b' style='display:block'>B</a></li></ul>", "- [A](https://example.test/a)\n- [B](https://example.test/b)"),
|
||||
("<table style='visibility:hidden'><tr><th>Plan</th><th>Price</th></tr><tr><td colspan=2>Loading</td></tr></table><p>After</p>", "After"),
|
||||
("<table><tr><th>A</th><th>B</th></tr><tr style='visibility:collapse'><td>Secret</td><td>Hidden</td></tr><tr><td>X</td><td>Y</td></tr></table>", "| A | B |\n| --- | --- |\n| X | Y |"),
|
||||
("<label>Volume <input type=range></label> <input type=color>", "Volume"),
|
||||
("<input type=range value=30><input type=color value='#ff0000'>", "30 #ff0000"),
|
||||
("<table><colgroup><col><col></colgroup><tr><th colspan=2>H</th></tr><tr><td>a</td><td>b</td></tr></table>", "<table>\n<colgroup>\n<col>\n<col></colgroup>\n<tbody>\n<tr>\n<th colspan=\"2\">H</th>\n</tr>\n<tr>\n<td>a</td>\n<td>b</td>\n</tr>\n</tbody>\n</table>"),
|
||||
] {
|
||||
page.vm_mut().eval(&format!("document.body.innerHTML={}", serde_json::to_string(source).unwrap())).unwrap();
|
||||
for strip_css in [false, true] {
|
||||
@@ -490,3 +496,16 @@ async fn markdown_live_structure_and_visibility_have_exact_outputs() {
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn markdown_hidden_structure_keeps_restored_descendants_and_changed_controls() {
|
||||
run_page_vm_async_test(async move {
|
||||
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap();
|
||||
let mut page = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), Url::parse("https://example.test/").unwrap());
|
||||
page.vm_mut().eval("document.body.innerHTML = `<table style='visibility:hidden'><tr><th style='visibility:visible'>Visible</th></tr><tr><td colspan=2>Hidden</td></tr></table><input id=r type=range><input id=c type=color>`; document.querySelector('#r').value='25'; document.querySelector('#c').value='#00ff00';").unwrap();
|
||||
let result = page.render_page_dump(options(false));
|
||||
assert!(result.contains("Visible"), "{result}");
|
||||
assert!(!result.contains("Hidden"), "{result}");
|
||||
assert!(result.contains("25 #00ff00"), "{result}");
|
||||
}).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user