diff --git a/moli-renderer-v8/src/dom_parser.rs b/moli-renderer-v8/src/dom_parser.rs
index e551e84c0b..9fbb5f9e37 100644
--- a/moli-renderer-v8/src/dom_parser.rs
+++ b/moli-renderer-v8/src/dom_parser.rs
@@ -1,3 +1,4 @@
+use html5ever::tree_builder::QuirksMode;
use moli_web_mime::{is_dom_parser_xml_mime, is_html_document_mime};
use moli_webapi_declare::WebApiFunctionTemplate;
use url::Url;
@@ -483,12 +484,35 @@ fn parse_browsing_context_document_snapshot(
DetachedDocumentKind::Xml,
);
}
+ if content_type.is_some_and(|mime| mime.eq_ignore_ascii_case("text/plain")) {
+ let mut document =
+ html_parser.parse_dom_host(document_url, plain_text_document_parser_input(source));
+ // Text documents are HTML Documents whose mode is explicitly no-quirks,
+ // despite having no doctype that would select that mode through parsing.
+ document.set_html_quirks_mode_for_parser(QuirksMode::NoQuirks);
+ return (document, DetachedDocumentKind::Html);
+ }
(
html_parser.parse_dom_host(document_url, source.to_owned()),
DetachedDocumentKind::Html,
)
}
+pub(crate) fn plain_text_document_parser_input(source: &str) -> String {
+ let mut input = String::with_capacity(source.len().saturating_add(64));
+ input.push_str("
");
+ for character in source.chars() {
+ match character {
+ '&' => input.push_str("&"),
+ '<' => input.push_str("<"),
+ '\0' => input.push('\u{fffd}'),
+ _ => input.push(character),
+ }
+ }
+ input.push_str("");
+ input
+}
+
fn child_document_url_is_xml_like(url: &Url) -> bool {
let path = url.path().to_ascii_lowercase();
path.ends_with(".xml") || path.ends_with(".xhtml") || path.ends_with(".svg")
@@ -656,4 +680,42 @@ mod tests {
let disabled = parse(HtmlParser::SCRIPTING_DISABLED);
assert!(disabled.element_handle_by_id("fallback").is_some());
}
+
+ #[test]
+ fn child_plain_text_document_uses_pre_and_no_quirks_mode() {
+ let (document, kind) = parse_browsing_context_document_snapshot(
+ Url::parse("https://example.test/sample.txt").expect("test URL"),
+ "alpha<&\r\nbeta\rgamma\0",
+ Some("text/plain"),
+ HtmlParser::SCRIPTING_ENABLED,
+ );
+ let document_handle = document.document_handle();
+ let document_children = document.child_handles(document_handle).collect::>();
+
+ assert_eq!(kind, DetachedDocumentKind::Html);
+ assert_eq!(document_children.len(), 1);
+ assert!(document_children.iter().all(|child| {
+ document
+ .node(*child)
+ .is_none_or(|node| node.as_document_type().is_none())
+ }));
+ assert_eq!(
+ document.document_quirks_mode_for_handle(document_handle),
+ Some(selectors::matching::QuirksMode::NoQuirks)
+ );
+
+ let html = document_children[0];
+ let html_children = document.child_handles(html).collect::>();
+ assert_eq!(html_children.len(), 2);
+ assert!(document.is_html_element_named(html_children[0], "head"));
+ assert!(document.is_html_element_named(html_children[1], "body"));
+
+ let body_children = document.child_handles(html_children[1]).collect::>();
+ assert_eq!(body_children.len(), 1);
+ assert!(document.is_html_element_named(body_children[0], "pre"));
+ assert_eq!(
+ document.text_content(body_children[0]).as_deref(),
+ Some("alpha<&\nbeta\ngamma\u{fffd}")
+ );
+ }
}
diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs
index b8c321a3f1..67f2387ab6 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs
@@ -170,8 +170,16 @@ impl JsContextHost {
navigation_loader: Option,
is_xml_document: bool,
) -> Option {
+ let is_plain_text_document = snapshot
+ .content_type
+ .as_deref()
+ .is_some_and(|mime| mime.eq_ignore_ascii_case("text/plain"));
let source = if is_xml_document {
std::borrow::Cow::Borrowed(snapshot.markup.as_str())
+ } else if is_plain_text_document {
+ std::borrow::Cow::Owned(crate::dom_parser::plain_text_document_parser_input(
+ &snapshot.markup,
+ ))
} else {
crate::dom_parser::preserve_decoded_bom_only_browsing_context_body(
&snapshot.markup,
diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs
index 8964ec92a5..f22e1247f3 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs
@@ -917,6 +917,14 @@ impl JsContextHost {
let _ = parser.admit_delayed_finish_at_local_owner_boundary();
let finish_signals =
self.with_live_child_parser_step(scope, document_handle, |owner| parser.finish(owner));
+ if self
+ .dom_host()
+ .document_content_type_for_handle(document_handle)
+ .is_some_and(|mime| mime.eq_ignore_ascii_case("text/plain"))
+ {
+ self.dom_host_mut()
+ .set_html_quirks_mode_for_parser_document(document_handle, QuirksMode::NoQuirks);
+ }
let host_ptr = self as *mut JsContextHost;
self.run_pending_child_parser_post_step_runtime_work(scope, host_ptr);
self.queue_live_child_parser_discovery_signals(
diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs
index 2ab339924b..bc61faff61 100644
--- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs
+++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs
@@ -610,6 +610,62 @@ fn child_document_content_type_uses_resource_mime() {
assert_eq!(result, "text/html|image/png");
}
+
+#[test]
+fn child_plain_text_document_uses_pre_and_standards_mode() {
+ let mut vm = new_storage_test_vm("https://child-plain-text-document.test/");
+
+ vm.eval(
+ r#"
+(() => {
+ const root = document.documentElement || document.appendChild(document.createElement('html'));
+ const body = document.body || root.appendChild(document.createElement('body'));
+ const frame = document.createElement('iframe');
+ frame.id = 'plain';
+ frame.src = 'data:text/plain,alpha%3Cb%3E%26amp%3B%3C%2Fb%3E%0D%0Abeta';
+ body.appendChild(frame);
+})()
+"#,
+ )
+ .expect("plain-text child document setup should evaluate");
+ vm.drain_pending_child_frame_work_for_test();
+ let child_context_id = vm
+ .live_child_default_runtime_realm_inventory()
+ .into_iter()
+ .map(|realm| realm.context_id)
+ .next()
+ .expect("plain-text child default realm should materialize");
+
+ let result = vm
+ .eval_in_child_default_context(
+ child_context_id,
+ r#"
+(() => {
+ const doc = document;
+ const pre = doc.body.firstChild;
+ return [
+ doc.compatMode,
+ doc.contentType,
+ doc.doctype === null,
+ doc.childNodes.length,
+ doc.documentElement.children.length,
+ doc.head.tagName,
+ doc.body.tagName,
+ doc.body.childNodes.length,
+ pre.tagName,
+ pre.children.length,
+ pre.firstChild.data
+ ].join('|');
+})()
+"#,
+ )
+ .expect("plain-text child document should evaluate");
+
+ assert_eq!(
+ result,
+ "CSS1Compat|text/plain|true|1|2|HEAD|BODY|1|PRE|0|alpha&\nbeta"
+ );
+}
#[test]
fn node_move_before_parent_node_surface_and_validation() {
let mut vm = new_storage_test_vm("https://node-move-before.test/");