1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
use crate::dom::node::{NodeData, NodeId};
use crate::dom::Dom;
/// Information about a <script> element found in the DOM.
pub struct ScriptInfo {
pub code: String,
pub src: Option<String>,
/// Value of the `nonce` attribute, if any. Required by CSP3
/// `'nonce-...'` source matching — when the active policy uses
/// `'strict-dynamic'`, only nonce-tagged parser-inserted scripts
/// are authorized to load. Captured here at HTML-walk time so the
/// fetch path (`page.rs::navigate_with_init`) can pass it to
/// `crate::net::csp::CheckCtx`.
pub nonce: Option<String>,
/// `<script type="module">` — must be executed via the ES-module path
/// (`load_main_es_module` + `mod_evaluate`) NOT classic `execute_script`,
/// which throws `SyntaxError: Cannot use import statement outside a module`
/// and silently drops modern Vite/React/Vue bundles. (P2 / thin-render fix.)
pub is_module: bool,
/// Raw `NodeId` of the `<script>` element in the arena DOM. Used to set
/// `document.currentScript` to this element's wrapper for the duration of
/// the script's execution (the standard web-API contract). Scripts that
/// locate their own `<script>` element via `document.currentScript` (e.g.
/// to read a `data-*` attribute or resolve a relative path) depend on it;
/// without it set, `currentScript` is `null` and such scripts stall.
pub node_id: u32,
}
/// Find all <script> elements in the DOM and extract their content.
/// Returns both inline scripts (code) and external scripts (src URL).
pub fn find_scripts(dom: &Dom) -> Vec<ScriptInfo> {
let mut scripts = Vec::new();
collect_scripts(dom, NodeId::DOCUMENT, &mut scripts);
for (i, s) in scripts.iter().enumerate() {
if let Some(src) = &s.src {
tracing::debug!(index = i, src = %src, "Found external script");
} else {
tracing::debug!(index = i, code_len = s.code.len(), "Found inline script");
}
}
scripts
}
fn collect_scripts(dom: &Dom, node_id: NodeId, scripts: &mut Vec<ScriptInfo>) {
let children = dom.children(node_id);
for child_id in children {
if let Some(node) = dom.get(child_id) {
if let NodeData::Element(elem) = &node.data {
if elem.name.local.eq_ignore_ascii_case("script") {
// Skip non-JS script types (JSON-LD, templates, etc.)
let script_type = elem
.attrs
.iter()
.find(|a| a.name.local == "type")
.map(|a| a.value.as_str());
match script_type {
Some("application/ld+json")
| Some("application/json")
| Some("text/template")
| Some("text/html")
| Some("text/x-template") => {
collect_scripts(dom, child_id, scripts);
continue;
}
_ => {}
}
let src = elem
.attrs
.iter()
.find(|a| a.name.local == "src")
.map(|a| decode_html_entities(a.value.as_str()));
let nonce = elem
.attrs
.iter()
.find(|a| a.name.local == "nonce")
.map(|a| a.value.to_string())
.filter(|n| !n.is_empty());
// `type="module"` (and the rarer `type="text/javascript;
// version=module"` is not a thing — only the exact "module"
// token) routes to the ES-module path. `type="importmap"`
// is handled separately (skipped here, not executable code).
let is_module = script_type == Some("module");
if script_type == Some("importmap") || script_type == Some("speculationrules") {
collect_scripts(dom, child_id, scripts);
continue;
}
if src.is_some() {
// External script — store the URL for fetching
scripts.push(ScriptInfo {
code: String::new(),
src,
nonce,
is_module,
node_id: child_id.to_raw(),
});
} else {
// Inline script
let code = dom.text_content(child_id);
if !code.trim().is_empty() {
scripts.push(ScriptInfo {
code,
src: None,
nonce,
is_module,
node_id: child_id.to_raw(),
});
}
}
}
}
collect_scripts(dom, child_id, scripts);
}
}
}
fn decode_html_entities(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}