mirror of
https://github.com/lexmount/moli.git
synced 2026-10-04 16:00:47 +00:00
501 lines
16 KiB
Rust
501 lines
16 KiB
Rust
use moli_web_mime::{is_dom_parser_xml_mime, is_html_document_mime};
|
|
use moli_webapi_declare::WebApiFunctionTemplate;
|
|
use url::Url;
|
|
|
|
use crate::{
|
|
dom::native::{DomHost, NativeDom, NativeNodeId},
|
|
parser::{HtmlParser, XmlParser},
|
|
webidl,
|
|
};
|
|
|
|
use super::{
|
|
native_bridge::document::{
|
|
build_detached_document_object_from_dom_host,
|
|
build_detached_document_object_from_dom_host_with_content_type,
|
|
},
|
|
util::{context_host_ptr_from_global_bridge, get_private_object, throw_type_error},
|
|
};
|
|
|
|
pub(crate) const DOM_PARSER_FOREIGN_NODE_SLOT: &str = "__moliDomParserForeignNode";
|
|
const HTML_NAMESPACE: &str = "http://www.w3.org/1999/xhtml";
|
|
const PARSER_ERROR_STYLE: &str = "display: block; white-space: pre; border: 2px solid #c77; padding: 0 1em 0 1em; margin: 1em; background-color: #fdd; color: black";
|
|
const PARSER_ERROR_DETAIL_STYLE: &str = "font-family:monospace;font-size:12px";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum DetachedDocumentKind {
|
|
Html,
|
|
Xml,
|
|
}
|
|
|
|
#[derive(Clone, Copy, webidl::WebIdlEnum)]
|
|
#[webidl(name = "SupportedType")]
|
|
enum DomParserSupportedType {
|
|
#[webidl(token = "text/html")]
|
|
Html,
|
|
#[webidl(token = "text/xml")]
|
|
TextXml,
|
|
#[webidl(token = "application/xml")]
|
|
ApplicationXml,
|
|
#[webidl(token = "application/xhtml+xml")]
|
|
ApplicationXhtmlXml,
|
|
#[webidl(token = "image/svg+xml")]
|
|
ImageSvgXml,
|
|
}
|
|
|
|
impl DomParserSupportedType {
|
|
fn as_mime(self) -> &'static str {
|
|
match self {
|
|
Self::Html => "text/html",
|
|
Self::TextXml => "text/xml",
|
|
Self::ApplicationXml => "application/xml",
|
|
Self::ApplicationXhtmlXml => "application/xhtml+xml",
|
|
Self::ImageSvgXml => "image/svg+xml",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(webidl::WebIdlArgs)]
|
|
#[webidl(prefix = "DOMParser.parseFromString")]
|
|
struct DomParserParseFromStringArgs {
|
|
#[webidl(required)]
|
|
source: String,
|
|
#[webidl(required, converter = "enum")]
|
|
mime: DomParserSupportedType,
|
|
}
|
|
|
|
#[derive(WebApiFunctionTemplate)]
|
|
#[webapi(name = "DOMParser", enumerable)]
|
|
struct DomParserPrototypeMethodsDeclaration {
|
|
#[webapi(method, length = 2, callback = dom_parser_parse_from_string_callback)]
|
|
parse_from_string: (),
|
|
}
|
|
|
|
pub(super) fn dom_parser_constructor_callback(
|
|
scope: &mut v8::PinScope<'_, '_>,
|
|
args: v8::FunctionCallbackArguments<'_>,
|
|
mut rv: v8::ReturnValue<'_, v8::Value>,
|
|
) {
|
|
if !args.is_construct_call() {
|
|
throw_type_error(scope, "DOMParser constructor must be called with new");
|
|
return;
|
|
}
|
|
rv.set(args.this().into());
|
|
}
|
|
|
|
pub(super) fn dom_parser_parse_from_string_callback<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
args: v8::FunctionCallbackArguments<'s>,
|
|
mut rv: v8::ReturnValue<'_, v8::Value>,
|
|
) {
|
|
let Some(parsed) = webidl::parse_args::<DomParserParseFromStringArgs>(scope, &args) else {
|
|
return;
|
|
};
|
|
let Some(obj) =
|
|
parse_detached_document_from_string(scope, &parsed.source, parsed.mime.as_mime())
|
|
else {
|
|
rv.set(v8::null(scope).into());
|
|
return;
|
|
};
|
|
rv.set(obj.into());
|
|
}
|
|
|
|
pub(crate) fn install_dom_parser_template_bindings<'s>(
|
|
scope: &mut v8::PinScope<'s, '_, ()>,
|
|
template: v8::Local<'s, v8::FunctionTemplate>,
|
|
interface_name: &str,
|
|
) {
|
|
if interface_name != "DOMParser" {
|
|
return;
|
|
}
|
|
DomParserPrototypeMethodsDeclaration::initialize_prototype_template(
|
|
scope,
|
|
template.prototype_template(scope),
|
|
);
|
|
}
|
|
|
|
pub(super) fn parse_detached_document_from_string<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
source: &str,
|
|
mime: &str,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
let is_html = is_html_document_mime(mime);
|
|
let is_xml = is_dom_parser_xml_mime(mime);
|
|
if !is_html && !is_xml {
|
|
return None;
|
|
}
|
|
|
|
let host_ptr = context_host_ptr_from_global_bridge(scope)?;
|
|
let runtime = unsafe { &*host_ptr };
|
|
if is_html {
|
|
return parse_detached_html_document_from_source(
|
|
scope,
|
|
runtime.document_url().clone(),
|
|
source,
|
|
);
|
|
}
|
|
|
|
let parser = XmlParser;
|
|
let parsed = parser.parse(runtime.document_url().clone(), source.to_owned());
|
|
let parsed = if parsed.parse_errors().is_empty() && native_document_has_element_child(&parsed) {
|
|
parsed
|
|
} else {
|
|
materialize_xml_parser_error_document(parsed)
|
|
};
|
|
build_detached_document(scope, parsed, DetachedDocumentKind::Xml, false)
|
|
}
|
|
|
|
fn materialize_xml_parser_error_document(parsed: NativeDom) -> NativeDom {
|
|
let error_detail = parsed
|
|
.parse_errors()
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| "XML document has no document element".to_owned());
|
|
let mut host = DomHost::from_dom(parsed);
|
|
let document = host.document_handle();
|
|
let document_element = host
|
|
.child_handles(document)
|
|
.find(|handle| host.node(*handle).is_some_and(|node| node.is_element()));
|
|
|
|
let parser_error = create_dom_parser_error_element(&mut host, document, &error_detail);
|
|
if let Some(document_element) = document_element {
|
|
let first_child = host
|
|
.node(document_element)
|
|
.and_then(|node| node.first_child());
|
|
let _ = host.insert_before(document_element, parser_error, first_child);
|
|
return host.snapshot_document();
|
|
}
|
|
|
|
for child in host.child_handles(document).collect::<Vec<_>>() {
|
|
let _ = host.remove_child(document, child);
|
|
}
|
|
let html = host.create_parser_element_without_attributes_for_document(
|
|
document,
|
|
"html".to_owned(),
|
|
HTML_NAMESPACE.to_owned(),
|
|
None,
|
|
);
|
|
let body = host.create_parser_element_without_attributes_for_document(
|
|
document,
|
|
"body".to_owned(),
|
|
HTML_NAMESPACE.to_owned(),
|
|
None,
|
|
);
|
|
let _ = host.append_child(document, html);
|
|
let _ = host.append_child(html, body);
|
|
let _ = host.append_child(body, parser_error);
|
|
host.snapshot_document()
|
|
}
|
|
|
|
fn create_dom_parser_error_element(
|
|
host: &mut DomHost,
|
|
document: NativeNodeId,
|
|
error_detail: &str,
|
|
) -> NativeNodeId {
|
|
let parser_error = host.create_parser_element_without_attributes_for_document(
|
|
document,
|
|
"parsererror".to_owned(),
|
|
HTML_NAMESPACE.to_owned(),
|
|
None,
|
|
);
|
|
let _ = host.set_attribute(parser_error, "style", PARSER_ERROR_STYLE);
|
|
|
|
let heading = create_dom_parser_error_child(host, document, "h3", None);
|
|
append_dom_parser_error_text(
|
|
host,
|
|
document,
|
|
heading,
|
|
"This page contains the following errors:",
|
|
);
|
|
let detail =
|
|
create_dom_parser_error_child(host, document, "div", Some(PARSER_ERROR_DETAIL_STYLE));
|
|
append_dom_parser_error_text(host, document, detail, error_detail);
|
|
let footer = create_dom_parser_error_child(host, document, "h3", None);
|
|
append_dom_parser_error_text(
|
|
host,
|
|
document,
|
|
footer,
|
|
"Below is a rendering of the page up to the first error.",
|
|
);
|
|
let _ = host.append_child(parser_error, heading);
|
|
let _ = host.append_child(parser_error, detail);
|
|
let _ = host.append_child(parser_error, footer);
|
|
parser_error
|
|
}
|
|
|
|
fn create_dom_parser_error_child(
|
|
host: &mut DomHost,
|
|
document: NativeNodeId,
|
|
local_name: &str,
|
|
style: Option<&str>,
|
|
) -> NativeNodeId {
|
|
let element = host.create_parser_element_without_attributes_for_document(
|
|
document,
|
|
local_name.to_owned(),
|
|
HTML_NAMESPACE.to_owned(),
|
|
None,
|
|
);
|
|
if let Some(style) = style {
|
|
let _ = host.set_attribute(element, "style", style);
|
|
}
|
|
element
|
|
}
|
|
|
|
fn append_dom_parser_error_text(
|
|
host: &mut DomHost,
|
|
document: NativeNodeId,
|
|
parent: NativeNodeId,
|
|
text: &str,
|
|
) {
|
|
let text = host.create_text_node_for_document(document, text);
|
|
let _ = host.append_child(parent, text);
|
|
}
|
|
|
|
/// Builds a detached HTML document wrapper from raw markup and an explicit document URL.
|
|
///
|
|
/// This helper exists so non-DOMParser callers can materialize a queryable `Document`
|
|
/// snapshot without inventing a fake live browsing context. The returned object behaves
|
|
/// like other detached DOMParser documents:
|
|
/// - it is queryable (`getElementById`, `querySelector`, `body`, ...)
|
|
/// - it is *not* a live page VM
|
|
/// - scripts inside the markup do not execute
|
|
///
|
|
/// That boundary is intentional. Live child-frame surfaces such as `iframe.contentDocument`
|
|
/// must wrap the frame's current native document instead of materializing this detached
|
|
/// snapshot helper.
|
|
pub(crate) fn parse_detached_html_document_from_source<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
document_url: Url,
|
|
source: &str,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
let parsed = HtmlParser::with_scripting_enabled(false)
|
|
.parse_without_declarative_shadow_roots(document_url, source.to_owned());
|
|
build_detached_document(scope, parsed, DetachedDocumentKind::Html, false)
|
|
}
|
|
|
|
pub(crate) fn parse_detached_html_document_from_source_with_declarative_shadow_roots<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
document_url: Url,
|
|
source: &str,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
let parsed =
|
|
HtmlParser::with_scripting_enabled(false).parse_dom_host(document_url, source.to_owned());
|
|
build_detached_document_from_dom_host(scope, parsed, DetachedDocumentKind::Html, true)
|
|
}
|
|
|
|
/// Builds the DOM projection used for a non-top browsing-context document.
|
|
///
|
|
/// The projection never executes scripts itself. `html_parser` carries the
|
|
/// target Document's scripting state solely for parser behavior such as
|
|
/// `<noscript>` tokenization; every caller must select that state explicitly.
|
|
pub(crate) fn parse_browsing_context_document_projection_from_source<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
document_url: Url,
|
|
source: &str,
|
|
content_type: Option<&str>,
|
|
character_set: Option<&str>,
|
|
html_parser: HtmlParser,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
let source = preserve_decoded_bom_only_browsing_context_body(source, content_type);
|
|
let (parsed, kind) =
|
|
parse_browsing_context_document_snapshot(document_url, &source, content_type, html_parser);
|
|
build_detached_document_from_dom_host_with_content_type(
|
|
scope,
|
|
parsed,
|
|
kind,
|
|
true,
|
|
content_type,
|
|
character_set,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn preserve_decoded_bom_only_browsing_context_body<'a>(
|
|
source: &'a str,
|
|
content_type: Option<&str>,
|
|
) -> std::borrow::Cow<'a, str> {
|
|
if source == "\u{feff}" && !content_type.is_some_and(is_dom_parser_xml_mime) {
|
|
std::borrow::Cow::Borrowed("<body>\u{feff}</body>")
|
|
} else {
|
|
std::borrow::Cow::Borrowed(source)
|
|
}
|
|
}
|
|
|
|
fn parse_browsing_context_document_snapshot(
|
|
document_url: Url,
|
|
source: &str,
|
|
content_type: Option<&str>,
|
|
html_parser: HtmlParser,
|
|
) -> (DomHost, DetachedDocumentKind) {
|
|
if content_type.is_some_and(is_dom_parser_xml_mime)
|
|
|| child_document_url_is_xml_like(&document_url)
|
|
{
|
|
let parser = XmlParser;
|
|
return (
|
|
DomHost::from_dom(parser.parse(document_url, source.to_owned())),
|
|
DetachedDocumentKind::Xml,
|
|
);
|
|
}
|
|
(
|
|
html_parser.parse_dom_host(document_url, source.to_owned()),
|
|
DetachedDocumentKind::Html,
|
|
)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
fn build_detached_document<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
parsed: NativeDom,
|
|
kind: DetachedDocumentKind,
|
|
expose_declarative_shadow_roots: bool,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
build_detached_document_from_dom_host(
|
|
scope,
|
|
DomHost::from_dom(parsed),
|
|
kind,
|
|
expose_declarative_shadow_roots,
|
|
)
|
|
}
|
|
|
|
fn build_detached_document_from_dom_host<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
parsed: DomHost,
|
|
kind: DetachedDocumentKind,
|
|
expose_declarative_shadow_roots: bool,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
let _ = expose_declarative_shadow_roots;
|
|
let kind = match kind {
|
|
DetachedDocumentKind::Html => "html",
|
|
DetachedDocumentKind::Xml => "xml",
|
|
};
|
|
build_detached_document_object_from_dom_host(scope, kind, parsed)
|
|
}
|
|
|
|
fn build_detached_document_from_dom_host_with_content_type<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
parsed: DomHost,
|
|
kind: DetachedDocumentKind,
|
|
expose_declarative_shadow_roots: bool,
|
|
content_type: Option<&str>,
|
|
character_set: Option<&str>,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
let _ = expose_declarative_shadow_roots;
|
|
let kind = match kind {
|
|
DetachedDocumentKind::Html => "html",
|
|
DetachedDocumentKind::Xml => "xml",
|
|
};
|
|
build_detached_document_object_from_dom_host_with_content_type(
|
|
scope,
|
|
kind,
|
|
parsed,
|
|
content_type,
|
|
character_set,
|
|
)
|
|
}
|
|
|
|
fn dom_parser_foreign_wrapper_for_live_object<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
object: v8::Local<'s, v8::Object>,
|
|
) -> Option<v8::Local<'s, v8::Object>> {
|
|
get_private_object(scope, object, DOM_PARSER_FOREIGN_NODE_SLOT)
|
|
}
|
|
|
|
pub(crate) fn map_live_value_to_foreign<'s>(
|
|
scope: &mut v8::PinScope<'s, '_>,
|
|
value: v8::Local<'s, v8::Value>,
|
|
) -> v8::Local<'s, v8::Value> {
|
|
let Ok(object) = v8::Local::<v8::Object>::try_from(value) else {
|
|
return value;
|
|
};
|
|
dom_parser_foreign_wrapper_for_live_object(scope, object)
|
|
.map(Into::into)
|
|
.unwrap_or(value)
|
|
}
|
|
|
|
fn native_document_has_element_child(dom: &NativeDom) -> bool {
|
|
dom.child_ids(dom.document_node_id()).any(|handle| {
|
|
dom.node(handle)
|
|
.and_then(|node| node.as_element())
|
|
.is_some()
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dom::native::NativeNodeId;
|
|
|
|
fn first_document_element(dom: &NativeDom) -> NativeNodeId {
|
|
dom.find_child(dom.document_node_id(), |handle| {
|
|
dom.node(handle)
|
|
.and_then(|node| node.as_element())
|
|
.is_some()
|
|
})
|
|
.expect("document element")
|
|
}
|
|
|
|
#[test]
|
|
fn child_document_snapshot_uses_xml_parser_for_xml_like_urls() {
|
|
let (xml, xml_kind) = parse_browsing_context_document_snapshot(
|
|
Url::parse("https://example.test/common/dummy.xml").unwrap(),
|
|
"<foo>Dummy XML document</foo>\n",
|
|
None,
|
|
HtmlParser::SCRIPTING_ENABLED,
|
|
);
|
|
let xml_root = first_document_element(&xml);
|
|
assert_eq!(xml_kind, DetachedDocumentKind::Xml);
|
|
assert_eq!(
|
|
xml.text_content(xml_root).as_deref(),
|
|
Some("Dummy XML document")
|
|
);
|
|
|
|
let (xhtml, xhtml_kind) = parse_browsing_context_document_snapshot(
|
|
Url::parse("https://example.test/common/dummy.xhtml").unwrap(),
|
|
r#"<!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Dummy XHTML document</title></head><body /></html>
|
|
"#,
|
|
None,
|
|
HtmlParser::SCRIPTING_ENABLED,
|
|
);
|
|
let xhtml_root = first_document_element(&xhtml);
|
|
assert_eq!(xhtml_kind, DetachedDocumentKind::Xml);
|
|
assert_eq!(
|
|
xhtml.text_content(xhtml_root).as_deref(),
|
|
Some("Dummy XHTML document")
|
|
);
|
|
|
|
let (html, html_kind) = parse_browsing_context_document_snapshot(
|
|
Url::parse("https://example.test/common/dummy.html").unwrap(),
|
|
"<p>Dummy HTML document</p>\n",
|
|
None,
|
|
HtmlParser::SCRIPTING_ENABLED,
|
|
);
|
|
let html_root = html.document_element_handle().expect("html root");
|
|
assert_eq!(html_kind, DetachedDocumentKind::Html);
|
|
assert_eq!(
|
|
html.text_content(html_root).as_deref(),
|
|
Some("Dummy HTML document\n")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn browsing_context_document_projection_uses_explicit_scripting_mode() {
|
|
let parse = |parser| {
|
|
parse_browsing_context_document_snapshot(
|
|
Url::parse("https://example.test/child.html").expect("test URL"),
|
|
"<noscript><span id='fallback'></span></noscript>",
|
|
Some("text/html"),
|
|
parser,
|
|
)
|
|
.0
|
|
};
|
|
|
|
let enabled = parse(HtmlParser::SCRIPTING_ENABLED);
|
|
assert!(enabled.element_handle_by_id("fallback").is_none());
|
|
|
|
let disabled = parse(HtmlParser::SCRIPTING_DISABLED);
|
|
assert!(disabled.element_handle_by_id("fallback").is_some());
|
|
}
|
|
}
|