use url::Url; use crate::ParserStreamDocumentSnapshot; use moli_dom::{ NodeId, native::{DomHost, NativeDom, NativeNodeId, Node}, }; use moli_fetch::FetchPriorityHint; use moli_page_types::{ScriptKind, ScriptMode, ScriptSourceKind}; use moli_script::{ ScriptElementClassificationInput, ScriptPreparationClassificationInput, ScriptPreparationDisposition, classify_script_preparation, }; pub struct ParserScriptRead { pub parser_inserted: bool, pub parser_inserted_for_prepare: bool, pub already_started: bool, pub has_nomodule: bool, pub script_type: Option, pub script_language: Option, pub script_event: Option, pub script_for: Option, pub script_src: Option, pub script_text: Option, pub script_async: bool, pub async_attribute_present: bool, pub defer_attribute_present: bool, pub fetch_metadata: ScriptFetchMetadata, } fn parser_script_read_from_node( node: &Node, script_src: Option, script_text: Option, ) -> Option { let element = node.as_element()?; if !element.is_script_element() { return None; } // `type` and `async` belong to both HTMLScriptElement and // SVGScriptElement. The remaining classification/scheduling attributes // below are HTML-only; accepting them on SVG scripts would, for example, // make a harmless `nomodule` suppress execution or make `defer` change the // parser lane. let is_html_script = element.is_html_script(); Some(ParserScriptRead { parser_inserted: node.flags().parser_created(), parser_inserted_for_prepare: element.script_parser_inserted_for_prepare(), already_started: element.script_already_started(), has_nomodule: is_html_script && element.attribute("nomodule").is_some(), script_type: element.attribute("type").map(str::to_owned), script_language: if is_html_script { element.attribute("language").map(str::to_owned) } else { None }, script_event: if is_html_script { element.attribute("event").map(str::to_owned) } else { None }, script_for: if is_html_script { element.attribute("for").map(str::to_owned) } else { None }, script_src, script_text, script_async: element.script_async(), async_attribute_present: element.attribute("async").is_some(), defer_attribute_present: is_html_script && element.attribute("defer").is_some(), fetch_metadata: ScriptFetchMetadata::from_script_attributes( element.attribute("crossorigin"), element.attribute("referrerpolicy"), element.attribute("charset"), element.attribute("integrity"), element .cryptographic_nonce() .or_else(|| element.attribute("nonce")), element.attribute("fetchpriority"), ) .with_parser_inserted(node.flags().parser_created()), }) } pub trait ParserPlanningReadView { fn parser_script_read(&self, node_id: NativeNodeId) -> Option; fn is_connected(&self, node_id: NativeNodeId) -> bool; fn script_handles(&self) -> Vec; fn document_order_script_handles(&self) -> Vec; fn document_order_position(&self, node_id: NativeNodeId) -> Option { self.document_order_script_handles() .into_iter() .position(|handle| handle == node_id) } fn final_url_clone(&self) -> Option; fn document_base_url_clone(&self) -> Option; fn script_start_line(&self, _node_id: NativeNodeId) -> Option { None } } fn collect_script_handles_in_dom_host( handles: &mut Vec, host: &DomHost, current: NativeNodeId, ) { let Some(node) = host.node(current) else { return; }; if node.is_script_element() { handles.push(current); } if let Some(template_contents) = node .as_element() .and_then(|element| element.template_contents()) { collect_script_handles_in_dom_host(handles, host, template_contents); } for child in host.child_handles(current) { collect_script_handles_in_dom_host(handles, host, child); } } impl ParserPlanningReadView for NativeDom { fn parser_script_read(&self, node_id: NativeNodeId) -> Option { let node = self.node(node_id)?; parser_script_read_from_node( node, self.script_src(node_id).map(str::to_owned), self.script_text(node_id), ) } fn document_order_script_handles(&self) -> Vec { NativeDom::document_order_script_handles(self) } fn is_connected(&self, node_id: NativeNodeId) -> bool { self.node(node_id).is_some_and(Node::is_connected) } fn script_handles(&self) -> Vec { self.script_handles() } fn final_url_clone(&self) -> Option { self.final_url().cloned() } fn document_base_url_clone(&self) -> Option { self.document().map(|document| document.base_url().clone()) } } impl ParserPlanningReadView for DomHost { fn parser_script_read(&self, node_id: NativeNodeId) -> Option { let node = self.node(node_id)?; if !node.is_script_element() { return None; } let script_text = self .dom() .direct_text_content(node_id) .and_then(|text| (!text.is_empty()).then_some(text)); parser_script_read_from_node( node, node.as_element() .and_then(|element| element.script_source_attribute()) .map(str::to_owned), script_text, ) } fn document_order_script_handles(&self) -> Vec { self.script_handles_in_light_subtree(self.document_handle()) } fn is_connected(&self, node_id: NativeNodeId) -> bool { DomHost::is_connected(self, node_id) } fn script_handles(&self) -> Vec { let mut handles = Vec::new(); collect_script_handles_in_dom_host(&mut handles, self, self.document_handle()); handles } fn final_url_clone(&self) -> Option { self.node(self.document_handle()) .and_then(Node::as_document) .map(|document| document.url().clone()) } fn document_base_url_clone(&self) -> Option { self.document_base_url() } } impl ParserPlanningReadView for ParserStreamDocumentSnapshot { fn parser_script_read(&self, node_id: NativeNodeId) -> Option { let node = self.node(node_id)?; parser_script_read_from_node( node, self.script_src(node_id).map(str::to_owned), self.script_text(node_id), ) } fn document_order_script_handles(&self) -> Vec { ParserStreamDocumentSnapshot::document_order_script_handles(self) } fn is_connected(&self, node_id: NativeNodeId) -> bool { self.node(node_id).is_some_and(Node::is_connected) } fn script_handles(&self) -> Vec { self.script_handles() } fn final_url_clone(&self) -> Option { self.final_url().cloned() } fn document_base_url_clone(&self) -> Option { self.document_base_url() } } #[derive(Debug, Clone)] pub struct PreparedScript { pub position: usize, pub node_id: NodeId, pub kind: ScriptKind, pub mode: ScriptMode, pub source_kind: ScriptSourceKind, pub fetch_metadata: ScriptFetchMetadata, pub source: ScriptSource, pub url: Url, pub base_url: Url, pub initiator_url: Url, pub host_script_handle: Option, } #[derive(Debug, Clone)] pub struct PreparedImportMap { pub position: usize, pub node_id: NodeId, pub source: PreparedImportMapSource, pub nonce: Option, pub base_url: Url, pub initiator_url: Url, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PreparedImportMapSource { Inline(String), ExternalUnsupported, } #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] pub struct ScriptFetchMetadata { pub cross_origin: Option, pub referrer_policy: Option, pub charset: Option, pub integrity: Option, pub nonce: Option, pub fetch_priority: Option, /// HTML's script fetch options capture parser metadata at prepare time. /// Module descendants and dynamic imports inherit this exact value; it is /// not equivalent to whether the eventual fetch runs on a parser-owned /// scheduler lane. pub parser_inserted: bool, } impl ScriptFetchMetadata { pub fn from_script_attributes( cross_origin: Option<&str>, referrer_policy: Option<&str>, charset: Option<&str>, integrity: Option<&str>, nonce: Option<&str>, fetch_priority: Option<&str>, ) -> Self { Self { cross_origin: normalize_cross_origin(cross_origin), referrer_policy: normalize_referrer_policy(referrer_policy), charset: normalize_attr_token(charset), // Attribute presence is significant for module scripts: an explicit // empty value suppresses integrity metadata supplied by an import map. integrity: integrity.map(str::trim).map(str::to_owned), nonce: normalize_non_empty_attr(nonce), fetch_priority: FetchPriorityHint::from_attribute(fetch_priority), parser_inserted: false, } } pub fn with_parser_inserted(mut self, parser_inserted: bool) -> Self { self.parser_inserted = parser_inserted; self } } fn normalize_cross_origin(value: Option<&str>) -> Option { let value = value?; let normalized = value.trim().to_ascii_lowercase(); if normalized == "use-credentials" { Some("use-credentials".to_owned()) } else { Some("anonymous".to_owned()) } } fn normalize_attr_token(value: Option<&str>) -> Option { let normalized = value?.trim().to_ascii_lowercase(); (!normalized.is_empty()).then_some(normalized) } fn normalize_referrer_policy(value: Option<&str>) -> Option { let normalized = normalize_attr_token(value)?; match normalized.as_str() { "no-referrer" | "no-referrer-when-downgrade" | "origin" | "origin-when-cross-origin" | "same-origin" | "strict-origin" | "strict-origin-when-cross-origin" | "unsafe-url" => Some(normalized), _ => None, } } fn normalize_non_empty_attr(value: Option<&str>) -> Option { let value = value?.trim(); (!value.is_empty()).then(|| value.to_owned()) } impl PreparedScript { pub fn with_loaded_source(mut self, source: String) -> Self { self.source = ScriptSource::Loaded(source); self } pub fn with_loaded_binary_source(mut self, source: String, bytes: Vec) -> Self { self.source = ScriptSource::LoadedBinary { source, bytes }; self } pub fn waits_for_blocking_stylesheets(&self) -> bool { match self.kind { ScriptKind::Classic => { self.source_kind == ScriptSourceKind::External && matches!( self.mode, ScriptMode::Normal | ScriptMode::Defer | ScriptMode::InOrder | ScriptMode::ImportMapInOrder ) } ScriptKind::Module => matches!( self.mode, ScriptMode::ModuleDefer | ScriptMode::ModuleInOrder ), ScriptKind::ImportMap | ScriptKind::DataBlock => false, } } } #[derive(Debug, Clone)] pub enum ScriptSource { Inline(String), Loaded(String), LoadedBinary { source: String, bytes: Vec }, External, } /// Classification result from reading and classifying a parser-visible script. pub struct ScriptClassification { pub script: ParserScriptRead, pub disposition: ScriptPreparationDisposition, pub source_kind: ScriptSourceKind, pub legacy_event_for_mismatch: bool, } /// Reason a classified script should be skipped rather than prepared. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScriptFilterSkipReason { AlreadyStarted, NoModule, DataBlock, LegacyEventForMismatch, } impl ScriptClassification { pub fn kind(&self) -> ScriptKind { self.disposition.kind() } pub fn executable(&self) -> Option<(ScriptKind, ScriptMode)> { self.disposition.executable() } pub fn mode(&self) -> Option { self.executable().map(|(_, mode)| mode) } /// Returns `None` if this script should be prepared; returns a skip reason otherwise. /// Centralizes the shared filter: already_started → classic nomodule → DataBlock. pub fn skip_reason(&self) -> Option { if self.script.already_started { return Some(ScriptFilterSkipReason::AlreadyStarted); } if self.script.has_nomodule && self.kind() == ScriptKind::Classic { return Some(ScriptFilterSkipReason::NoModule); } if self.legacy_event_for_mismatch { return Some(ScriptFilterSkipReason::LegacyEventForMismatch); } match self.kind() { ScriptKind::DataBlock => Some(ScriptFilterSkipReason::DataBlock), ScriptKind::Classic | ScriptKind::Module | ScriptKind::ImportMap => None, } } } /// Reads a script node via `ParserPlanningReadView` and classifies its kind, mode, /// and source kind. Returns `None` if the node is not a script element. pub fn classify_parser_script( document: &impl ParserPlanningReadView, node_id: NativeNodeId, ) -> Option { let script = document.parser_script_read(node_id)?; let source_kind = ScriptSourceKind::from_script_src(&script.script_src); let classification = classify_script_preparation(ScriptPreparationClassificationInput { element: ScriptElementClassificationInput { script_type: script.script_type.as_deref(), language: script.script_language.as_deref(), event: script.script_event.as_deref(), for_attribute: script.script_for.as_deref(), }, parser_inserted: script.parser_inserted, allow_parser_blocking_modes: true, force_async: script.script_async && !script.async_attribute_present, async_attribute_present: script.async_attribute_present, defer_attribute_present: script.defer_attribute_present, source_kind, }); Some(ScriptClassification { script, disposition: classification.disposition, source_kind, legacy_event_for_mismatch: classification.legacy_event_for_mismatch, }) } /// Outcome of building a `PreparedScript` from a classified script node. pub enum PrepareScriptOutcome { /// Script is ready to prepare. Prepared(Box), /// External script src URL could not be resolved. UrlResolutionFailed(String), /// External script src was present but empty, so it is not fetchable. EmptyExternalSource(String), /// Inline script had no text content or was empty. EmptyInlineSource, /// Import maps and data blocks do not produce executable script payloads. NonExecutableKind(ScriptKind), } /// Builds a `PreparedScript` from an already-classified script node. /// /// `position` is caller-supplied because different contexts compute document /// order differently (parser uses `document_order_position`, scheduler uses /// enumeration index, written-document uses a separate hash map). pub fn build_prepared_script( classification: &ScriptClassification, document_url: Url, document_base_url: Url, node_id: NativeNodeId, position: usize, ) -> PrepareScriptOutcome { let Some((kind, mode)) = classification.executable() else { return PrepareScriptOutcome::NonExecutableKind(classification.kind()); }; if let Some(src) = classification.script.script_src.as_deref() { if src.trim().is_empty() { return PrepareScriptOutcome::EmptyExternalSource( "empty script src is not fetchable".to_owned(), ); } match document_base_url.join(src) { Ok(url) => PrepareScriptOutcome::Prepared(Box::new(PreparedScript { position, node_id: NodeId::new(node_id.index()), kind, mode, source_kind: classification.source_kind, fetch_metadata: classification.script.fetch_metadata.clone(), source: ScriptSource::External, base_url: url.clone(), url, initiator_url: document_url, host_script_handle: None, })), Err(error) => PrepareScriptOutcome::UrlResolutionFailed(format!( "failed to resolve script src `{src}`: {error}" )), } } else { match classification.script.script_text.clone() { Some(source) if !source.is_empty() => { PrepareScriptOutcome::Prepared(Box::new(PreparedScript { position, node_id: NodeId::new(node_id.index()), kind, mode, source_kind: classification.source_kind, fetch_metadata: classification.script.fetch_metadata.clone(), source: ScriptSource::Inline(source), url: document_url.clone(), base_url: document_base_url, initiator_url: document_url, host_script_handle: None, })) } _ => PrepareScriptOutcome::EmptyInlineSource, } } } pub fn build_prepared_import_map( classification: &ScriptClassification, document_url: Url, document_base_url: Url, node_id: NativeNodeId, position: usize, ) -> Option { if classification.disposition != ScriptPreparationDisposition::ImportMap { return None; } let source = if classification.script.script_src.is_some() { PreparedImportMapSource::ExternalUnsupported } else { PreparedImportMapSource::Inline( classification .script .script_text .clone() .unwrap_or_default(), ) }; Some(PreparedImportMap { position, node_id: NodeId::new(node_id.index()), source, nonce: classification.script.fetch_metadata.nonce.clone(), base_url: document_base_url, initiator_url: document_url, }) } #[cfg(test)] mod tests { use super::{ ParserPlanningReadView, PrepareScriptOutcome, ScriptFetchMetadata, ScriptFilterSkipReason, build_prepared_script, classify_parser_script, }; use crate::HtmlParser; use moli_dom::native::DomHost; use moli_page_types::{ScriptKind, ScriptMode, ScriptSourceKind}; use url::Url; #[test] fn script_fetch_metadata_ignores_invalid_referrer_policy() { let metadata = ScriptFetchMetadata::from_script_attributes( None, Some(" always "), None, None, None, None, ); assert_eq!(metadata.referrer_policy, None); } #[test] fn script_fetch_metadata_preserves_explicit_empty_integrity() { let metadata = ScriptFetchMetadata::from_script_attributes(None, None, None, Some(" "), None, None); assert_eq!(metadata.integrity.as_deref(), Some("")); } #[test] fn parser_script_classification_uses_language_when_type_is_absent() { let document = HtmlParser::SCRIPTING_ENABLED.parse( Url::parse("https://example.test/").unwrap(), "".to_owned(), ); let handles = document.script_handles(); let first = classify_parser_script(&document, handles[0]).expect("first script should classify"); assert_eq!(first.kind(), ScriptKind::Classic); assert_eq!(first.skip_reason(), None); let second = classify_parser_script(&document, handles[1]).expect("second script should classify"); assert_eq!(second.kind(), ScriptKind::DataBlock); assert!(matches!( second.skip_reason(), Some(ScriptFilterSkipReason::DataBlock) )); } #[test] fn parser_script_classification_applies_legacy_for_event_gate() { let document = HtmlParser::SCRIPTING_ENABLED.parse( Url::parse("https://example.test/").unwrap(), "".to_owned(), ); let handles = document.script_handles(); let matching = classify_parser_script(&document, handles[0]).expect("matching script should classify"); assert_eq!(matching.kind(), ScriptKind::Classic); assert_eq!(matching.skip_reason(), None); let mismatching = classify_parser_script(&document, handles[1]) .expect("mismatching script should classify"); assert!(matches!( mismatching.skip_reason(), Some(ScriptFilterSkipReason::LegacyEventForMismatch) )); } #[test] fn parser_script_classification_applies_nomodule_only_to_classic_scripts() { let document = HtmlParser::SCRIPTING_ENABLED.parse( Url::parse("https://example.test/").unwrap(), "" .to_owned(), ); let handles = document.script_handles(); let classic = classify_parser_script(&document, handles[0]).expect("classic script should classify"); assert_eq!(classic.kind(), ScriptKind::Classic); assert!(matches!( classic.skip_reason(), Some(ScriptFilterSkipReason::NoModule) )); let module = classify_parser_script(&document, handles[1]).expect("module script should classify"); assert_eq!(module.kind(), ScriptKind::Module); assert_eq!(module.skip_reason(), None); } #[test] fn parser_svg_script_ignores_html_only_classification_and_scheduling_attributes() { let document = HtmlParser::SCRIPTING_ENABLED.parse( Url::parse("https://example.test/").unwrap(), "" .to_owned(), ); let handle = document.script_handles()[0]; let classification = classify_parser_script(&document, handle).expect("SVG script should classify"); assert_eq!(classification.kind(), ScriptKind::Classic); assert_eq!(classification.mode(), Some(ScriptMode::Normal)); assert_eq!(classification.skip_reason(), None); assert!(!classification.script.has_nomodule); assert!(!classification.script.defer_attribute_present); assert_eq!(classification.script.script_language, None); assert_eq!(classification.script.script_event, None); assert_eq!(classification.script.script_for, None); } #[test] fn empty_external_script_src_is_not_resolved_to_document_url() { let final_url = Url::parse("https://example.test/page.html").unwrap(); let document = HtmlParser::SCRIPTING_ENABLED.parse( final_url.clone(), "".to_owned(), ); let handle = document.script_handles()[0]; let classification = classify_parser_script(&document, handle).expect("script should classify"); assert_eq!(classification.source_kind, ScriptSourceKind::External); match build_prepared_script(&classification, final_url.clone(), final_url, handle, 0) { PrepareScriptOutcome::EmptyExternalSource(message) => { assert!(message.contains("empty script src")); } PrepareScriptOutcome::Prepared(script) => { panic!("empty src must not prepare a fetch for {}", script.url); } _ => panic!("empty external src should be reported distinctly"), } } #[test] fn parser_script_source_uses_direct_text_children_only() { let document = HtmlParser::SCRIPTING_ENABLED.parse( Url::parse("https://example.test/").unwrap(), "".to_owned(), ); let mut host = DomHost::from_dom(document.clone()); let script = host.create_element("script"); let span = host.create_element("span"); let descendant_text = host.create_text_node("window.descendant = true;"); let direct_text = host.create_text_node("window.direct = true;"); assert!(host.append_child(span, descendant_text)); assert!(host.append_child(script, span)); assert!(host.append_child(script, direct_text)); let classification = classify_parser_script(&host, script).expect("script should classify"); assert_eq!( classification.script.script_text.as_deref(), Some("window.direct = true;") ); } #[test] fn external_script_src_resolves_against_document_base_url() { let final_url = Url::parse("https://example.test/fetch-src/alpha/base.html").unwrap(); let document = HtmlParser::SCRIPTING_ENABLED.parse( final_url.clone(), "".to_owned(), ); let handle = document.script_handles()[0]; let classification = classify_parser_script(&document, handle).expect("script should classify"); let document_base_url = document .document_base_url_clone() .expect("parsed document should expose base URL"); match build_prepared_script( &classification, final_url.clone(), document_base_url, handle, 0, ) { PrepareScriptOutcome::Prepared(script) => { assert_eq!( script.url.as_str(), "https://example.test/fetch-src/beta/test.js" ); assert_eq!(script.initiator_url, final_url); assert!( script.fetch_metadata.parser_inserted, "parser metadata must be captured when the script element is prepared" ); } PrepareScriptOutcome::UrlResolutionFailed(error) => { panic!("script URL should resolve against document base URL: {error}"); } _ => panic!("external src should prepare a fetch"), } } }