rusteerio 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/CONTRIBUTING.md +73 -0
  2. package/Cargo.lock +1051 -0
  3. package/Cargo.toml +36 -0
  4. package/LICENSE +21 -0
  5. package/README.md +252 -2
  6. package/RELEASING.md +138 -0
  7. package/THIRD_PARTY_NOTICES.md +7443 -0
  8. package/build.rs +3 -0
  9. package/dist/chunk-DKULX6VO.mjs +3185 -0
  10. package/dist/chunk-DKULX6VO.mjs.map +1 -0
  11. package/dist/chunk-OHPGEY3E.mjs +32 -0
  12. package/dist/chunk-OHPGEY3E.mjs.map +1 -0
  13. package/dist/index.cjs +3230 -0
  14. package/dist/index.cjs.map +1 -0
  15. package/dist/index.d.mts +113 -0
  16. package/dist/index.d.ts +113 -0
  17. package/dist/index.mjs +38 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/slim.cjs +2441 -0
  20. package/dist/slim.cjs.map +1 -0
  21. package/dist/slim.d.mts +11 -0
  22. package/dist/slim.d.ts +11 -0
  23. package/dist/slim.mjs +19 -0
  24. package/dist/slim.mjs.map +1 -0
  25. package/dist/types-BP6TpxE5.d.mts +532 -0
  26. package/dist/types-BP6TpxE5.d.ts +532 -0
  27. package/dist/utils.cjs +61 -0
  28. package/dist/utils.cjs.map +1 -0
  29. package/dist/utils.d.mts +13 -0
  30. package/dist/utils.d.ts +13 -0
  31. package/dist/utils.mjs +17 -0
  32. package/dist/utils.mjs.map +1 -0
  33. package/native/src/arena.rs +1781 -0
  34. package/native/src/attributes.rs +923 -0
  35. package/native/src/codec.rs +172 -0
  36. package/native/src/diagnostics.rs +1249 -0
  37. package/native/src/extraction.rs +226 -0
  38. package/native/src/gc.rs +166 -0
  39. package/native/src/hook_sink.rs +727 -0
  40. package/native/src/hook_stream.rs +513 -0
  41. package/native/src/lib.rs +2095 -0
  42. package/native/src/location.rs +392 -0
  43. package/native/src/runtime.rs +189 -0
  44. package/native/src/selector.rs +1287 -0
  45. package/native/src/sink.rs +261 -0
  46. package/native/src/stream.rs +334 -0
  47. package/native/src/tokenizer.rs +355 -0
  48. package/native/src/tracker.rs +232 -0
  49. package/native/vendor/README.md +25 -0
  50. package/native/vendor/html5ever-0.35.0/.cargo_vcs_info.json +6 -0
  51. package/native/vendor/html5ever-0.35.0/Cargo.lock +828 -0
  52. package/native/vendor/html5ever-0.35.0/Cargo.toml +85 -0
  53. package/native/vendor/html5ever-0.35.0/LICENSE-APACHE +201 -0
  54. package/native/vendor/html5ever-0.35.0/LICENSE-MIT +25 -0
  55. package/native/vendor/html5ever-0.35.0/README.md +60 -0
  56. package/native/vendor/html5ever-0.35.0/benches/html5ever.rs +86 -0
  57. package/native/vendor/html5ever-0.35.0/data/bench/lipsum-zh.html +19 -0
  58. package/native/vendor/html5ever-0.35.0/data/bench/lipsum.html +40 -0
  59. package/native/vendor/html5ever-0.35.0/data/bench/medium-fragment.html +24 -0
  60. package/native/vendor/html5ever-0.35.0/data/bench/small-fragment.html +7 -0
  61. package/native/vendor/html5ever-0.35.0/data/bench/strong.html +1 -0
  62. package/native/vendor/html5ever-0.35.0/data/bench/tiny-fragment.html +1 -0
  63. package/native/vendor/html5ever-0.35.0/examples/arena.rs +356 -0
  64. package/native/vendor/html5ever-0.35.0/examples/capi/tokenize.c +74 -0
  65. package/native/vendor/html5ever-0.35.0/examples/noop-tokenize.rs +47 -0
  66. package/native/vendor/html5ever-0.35.0/examples/noop-tree-builder.rs +132 -0
  67. package/native/vendor/html5ever-0.35.0/examples/print-tree-actions.rs +185 -0
  68. package/native/vendor/html5ever-0.35.0/examples/tokenize.rs +111 -0
  69. package/native/vendor/html5ever-0.35.0/src/driver.rs +148 -0
  70. package/native/vendor/html5ever-0.35.0/src/lib.rs +30 -0
  71. package/native/vendor/html5ever-0.35.0/src/macros.rs +34 -0
  72. package/native/vendor/html5ever-0.35.0/src/serialize/mod.rs +255 -0
  73. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +451 -0
  74. package/native/vendor/html5ever-0.35.0/src/tokenizer/interface.rs +99 -0
  75. package/native/vendor/html5ever-0.35.0/src/tokenizer/mod.rs +2305 -0
  76. package/native/vendor/html5ever-0.35.0/src/tokenizer/states.rs +97 -0
  77. package/native/vendor/html5ever-0.35.0/src/tree_builder/data.rs +170 -0
  78. package/native/vendor/html5ever-0.35.0/src/tree_builder/mod.rs +1848 -0
  79. package/native/vendor/html5ever-0.35.0/src/tree_builder/rules.rs +1494 -0
  80. package/native/vendor/html5ever-0.35.0/src/tree_builder/tag_sets.rs +114 -0
  81. package/native/vendor/html5ever-0.35.0/src/tree_builder/types.rs +91 -0
  82. package/native/vendor/html5ever-0.35.0/src/util/str.rs +52 -0
  83. package/native/vendor/html5gum-0.8.4/Cargo.lock +1006 -0
  84. package/native/vendor/html5gum-0.8.4/Cargo.toml +109 -0
  85. package/native/vendor/html5gum-0.8.4/LICENSE +19 -0
  86. package/native/vendor/html5gum-0.8.4/README.md +110 -0
  87. package/native/vendor/html5gum-0.8.4/benches/files/0050.html +2131 -0
  88. package/native/vendor/html5gum-0.8.4/benches/files/6D30.html +1004 -0
  89. package/native/vendor/html5gum-0.8.4/benches/files.rs +32 -0
  90. package/native/vendor/html5gum-0.8.4/benches/patterns.rs +31 -0
  91. package/native/vendor/html5gum-0.8.4/src/arrayvec.rs +29 -0
  92. package/native/vendor/html5gum-0.8.4/src/char_validator.rs +134 -0
  93. package/native/vendor/html5gum-0.8.4/src/emitters/callback.rs +676 -0
  94. package/native/vendor/html5gum-0.8.4/src/emitters/default.rs +207 -0
  95. package/native/vendor/html5gum-0.8.4/src/emitters/emitter.rs +344 -0
  96. package/native/vendor/html5gum-0.8.4/src/emitters/html5ever.rs +306 -0
  97. package/native/vendor/html5gum-0.8.4/src/emitters/mod.rs +25 -0
  98. package/native/vendor/html5gum-0.8.4/src/entities.rs +3342 -0
  99. package/native/vendor/html5gum-0.8.4/src/error.rs +96 -0
  100. package/native/vendor/html5gum-0.8.4/src/htmlstring.rs +130 -0
  101. package/native/vendor/html5gum-0.8.4/src/lib.rs +66 -0
  102. package/native/vendor/html5gum-0.8.4/src/machine.rs +2136 -0
  103. package/native/vendor/html5gum-0.8.4/src/machine_helper.rs +277 -0
  104. package/native/vendor/html5gum-0.8.4/src/read_helper.rs +244 -0
  105. package/native/vendor/html5gum-0.8.4/src/reader.rs +440 -0
  106. package/native/vendor/html5gum-0.8.4/src/span.rs +98 -0
  107. package/native/vendor/html5gum-0.8.4/src/state.rs +16 -0
  108. package/native/vendor/html5gum-0.8.4/src/testutils.rs +71 -0
  109. package/native/vendor/html5gum-0.8.4/src/tokenizer.rs +189 -0
  110. package/native/vendor/html5gum-0.8.4/src/utils.rs +88 -0
  111. package/native/vendor/markup5ever-0.35.0/.cargo_vcs_info.json +6 -0
  112. package/native/vendor/markup5ever-0.35.0/Cargo.toml +45 -0
  113. package/native/vendor/markup5ever-0.35.0/LICENSE-APACHE +201 -0
  114. package/native/vendor/markup5ever-0.35.0/LICENSE-MIT +25 -0
  115. package/native/vendor/markup5ever-0.35.0/interface/mod.rs +422 -0
  116. package/native/vendor/markup5ever-0.35.0/interface/tree_builder.rs +280 -0
  117. package/native/vendor/markup5ever-0.35.0/lib.rs +59 -0
  118. package/native/vendor/markup5ever-0.35.0/serialize.rs +77 -0
  119. package/native/vendor/markup5ever-0.35.0/util/buffer_queue.rs +333 -0
  120. package/native/vendor/markup5ever-0.35.0/util/smallcharset.rs +88 -0
  121. package/package.json +137 -4
  122. package/rusteerio.darwin-arm64.node +0 -0
  123. package/rusteerio.darwin-x64.node +0 -0
  124. package/rusteerio.linux-arm64-gnu.node +0 -0
  125. package/rusteerio.linux-arm64-musl.node +0 -0
  126. package/rusteerio.linux-x64-gnu.node +0 -0
  127. package/rusteerio.linux-x64-musl.node +0 -0
  128. package/rusteerio.win32-arm64-msvc.node +0 -0
  129. package/rusteerio.win32-x64-msvc.node +0 -0
  130. package/scripts/check-native.mjs +15 -0
  131. package/scripts/check-release.mjs +235 -0
  132. package/scripts/fix-declarations.mjs +27 -0
  133. package/scripts/generate-notices.mjs +176 -0
  134. package/scripts/notices/README.md +13 -0
  135. package/scripts/notices/cheerio-MIT.txt +21 -0
  136. package/scripts/notices/html5ever-AUTHORS.txt +20 -0
  137. package/scripts/notices/html5ever-COPYRIGHT.txt +8 -0
  138. package/scripts/notices/html5ever-LICENSE-APACHE.txt +201 -0
  139. package/scripts/notices/html5ever-LICENSE-MIT.txt +25 -0
  140. package/scripts/notices/napi-rs-MIT.txt +43 -0
  141. package/scripts/notices/neon-MIT.txt +19 -0
  142. package/scripts/notices/runtime-01c266bced4a434da005.txt +3 -0
  143. package/scripts/notices/runtime-0a54e647fe54104658b5.txt +14 -0
  144. package/scripts/notices/runtime-0b74dfa0bcee5c420c6b.txt +25 -0
  145. package/scripts/notices/runtime-0d542e0c8804e39aa7f3.txt +177 -0
  146. package/scripts/notices/runtime-0f96a83840e146e43c0e.txt +21 -0
  147. package/scripts/notices/runtime-123a331b5dbf04c30097.txt +25 -0
  148. package/scripts/notices/runtime-172020dbfd5b53a226df.txt +34 -0
  149. package/scripts/notices/runtime-209fbbe0ad52d9235e37.txt +26 -0
  150. package/scripts/notices/runtime-23f18e03dc49df91622f.txt +23 -0
  151. package/scripts/notices/runtime-268872b9816f90fd8e85.txt +220 -0
  152. package/scripts/notices/runtime-35242e7a83f69875e6ed.txt +176 -0
  153. package/scripts/notices/runtime-378f5840b258e2779c39.txt +25 -0
  154. package/scripts/notices/runtime-4108245a1f2df9d4e94d.txt +25 -0
  155. package/scripts/notices/runtime-4455bf75a91154108304.txt +21 -0
  156. package/scripts/notices/runtime-553fffcd9b1cb158bc3e.txt +13 -0
  157. package/scripts/notices/runtime-62c7a1e35f56406896d7.txt +176 -0
  158. package/scripts/notices/runtime-6485b8ed310d3f0340bf.txt +25 -0
  159. package/scripts/notices/runtime-69fef7b0f322a6555415.txt +201 -0
  160. package/scripts/notices/runtime-6df43f6f4b5d4587f3d8.txt +187 -0
  161. package/scripts/notices/runtime-799e9ca9d179295ef372.txt +24 -0
  162. package/scripts/notices/runtime-7b63ecd5f1902af1b637.txt +25 -0
  163. package/scripts/notices/runtime-7bc7d5de2a0b793d4e1a.txt +21 -0
  164. package/scripts/notices/runtime-7e12e5df4bae12cb2158.txt +24 -0
  165. package/scripts/notices/runtime-861399f8c21c042b1105.txt +12 -0
  166. package/scripts/notices/runtime-8ada45cd9f843acf64e4.txt +201 -0
  167. package/scripts/notices/runtime-90eb64f0279b0d9432ac.txt +12 -0
  168. package/scripts/notices/runtime-a60eea817514531668d7.txt +201 -0
  169. package/scripts/notices/runtime-a7c936ff1ed8fa340172.txt +25 -0
  170. package/scripts/notices/runtime-ab6eec6caf0fa5775e41.txt +275 -0
  171. package/scripts/notices/runtime-b1181a40b2a7b25cf66f.txt +19 -0
  172. package/scripts/notices/runtime-b71bd43a069ca0641a9e.txt +25 -0
  173. package/scripts/notices/runtime-c16f8dcf1a368b83be78.txt +201 -0
  174. package/scripts/notices/runtime-c2cfccb812fe482101a8.txt +21 -0
  175. package/scripts/notices/runtime-e99d88d232bf57d70f0f.txt +25 -0
  176. package/scripts/notices/runtime-f32fb3b417a194167cfa.txt +9 -0
  177. package/scripts/notices/runtime-f5062c9a188d81dfe66b.txt +39 -0
  178. package/scripts/notices/runtime-f9070a832833fa7488fd.txt +4 -0
  179. package/scripts/notices/runtime-fab3dd6bdab226f1c086.txt +373 -0
  180. package/scripts/notices/runtime-fba3318d0c600380177e.txt +62 -0
  181. package/scripts/notices/runtime-ff8f68cb076caf8cefe7.txt +25 -0
  182. package/scripts/notices/runtime-ff92bed461f50338dd70.txt +74 -0
  183. package/scripts/notices/runtime.json +1632 -0
  184. package/scripts/notices/sources.json +555 -0
  185. package/scripts/notices/xray-MIT.txt +21 -0
  186. package/src/attributes.ts +271 -0
  187. package/src/codec.ts +74 -0
  188. package/src/dom.ts +87 -0
  189. package/src/extract-plan.ts +97 -0
  190. package/src/extract.ts +130 -0
  191. package/src/hooks.ts +220 -0
  192. package/src/index.ts +223 -0
  193. package/src/io.ts +491 -0
  194. package/src/native.ts +135 -0
  195. package/src/nodes.ts +290 -0
  196. package/src/parse.ts +53 -0
  197. package/src/parser-types.ts +157 -0
  198. package/src/router.ts +327 -0
  199. package/src/selection.ts +566 -0
  200. package/src/slim.ts +12 -0
  201. package/src/transport.ts +104 -0
  202. package/src/types.ts +186 -0
  203. package/src/utils.ts +29 -0
  204. package/src/workers.ts +21 -0
  205. package/tsconfig.json +15 -0
  206. package/tsup.config.ts +16 -0
@@ -0,0 +1,923 @@
1
+ //! Cheerio attribute, property, style, data, class and form semantics.
2
+ //! JavaScript only supplies callbacks and preserves opaque JS value identities.
3
+ use crate::arena::{value_ids, DomArena};
4
+ use crate::codec;
5
+ use serde_json::{json, Map, Number, Value};
6
+ use std::collections::HashSet;
7
+
8
+ fn element(arena: &DomArena, id: u32) -> bool {
9
+ arena.node(id).is_some_and(|node| node.is_element())
10
+ }
11
+
12
+ fn boolean_attribute(name: &str) -> bool {
13
+ matches!(
14
+ name.to_ascii_lowercase().as_str(),
15
+ "autofocus"
16
+ | "autoplay"
17
+ | "async"
18
+ | "checked"
19
+ | "controls"
20
+ | "defer"
21
+ | "disabled"
22
+ | "hidden"
23
+ | "loop"
24
+ | "multiple"
25
+ | "open"
26
+ | "readonly"
27
+ | "required"
28
+ | "scoped"
29
+ | "selected"
30
+ )
31
+ }
32
+
33
+ fn js_string(value: &Value) -> String {
34
+ match value {
35
+ Value::String(value) => value.clone(),
36
+ Value::Null => "null".into(),
37
+ Value::Array(values) => values
38
+ .iter()
39
+ .map(|value| {
40
+ if value.is_null() {
41
+ String::new()
42
+ } else {
43
+ js_string(value)
44
+ }
45
+ })
46
+ .collect::<Vec<_>>()
47
+ .join(","),
48
+ Value::Object(value)
49
+ if value.get("__undefinedJavaScriptValue") == Some(&Value::Bool(true)) =>
50
+ {
51
+ "undefined".into()
52
+ }
53
+ Value::Object(_) => "[object Object]".into(),
54
+ Value::Number(number) => number
55
+ .as_f64()
56
+ .map(js_number)
57
+ .unwrap_or_else(|| number.to_string()),
58
+ Value::Bool(value) => value.to_string(),
59
+ }
60
+ }
61
+
62
+ fn js_number(number: f64) -> String {
63
+ if number == 0.0 {
64
+ return "0".into();
65
+ }
66
+ if number.abs() >= 1e21 || number.abs() < 1e-6 {
67
+ let scientific = format!("{number:e}");
68
+ if let Some((mantissa, exponent)) = scientific.split_once('e') {
69
+ if let Ok(exponent) = exponent.parse::<i32>() {
70
+ return format!(
71
+ "{mantissa}e{}{exponent}",
72
+ if exponent >= 0 { "+" } else { "" }
73
+ );
74
+ }
75
+ }
76
+ scientific
77
+ } else {
78
+ number.to_string()
79
+ }
80
+ }
81
+
82
+ fn truthy(value: &Value) -> bool {
83
+ match value {
84
+ Value::Null => false,
85
+ Value::Bool(value) => *value,
86
+ Value::String(value) => !value.is_empty(),
87
+ Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
88
+ _ => true,
89
+ }
90
+ }
91
+
92
+ fn get_attr(arena: &DomArena, id: u32, name: &str) -> Value {
93
+ let Some(node) = arena.node(id).filter(|node| node.is_element()) else {
94
+ return Value::Null;
95
+ };
96
+ if name.is_empty() {
97
+ return Value::Object(node.attribs.clone());
98
+ }
99
+ if let Some(value) = node.attribs.get(name) {
100
+ return if !arena.xml_mode() && boolean_attribute(name) {
101
+ json!(name)
102
+ } else {
103
+ value.clone()
104
+ };
105
+ }
106
+ if name == "value" {
107
+ if node.name.as_deref() == Some("option") {
108
+ return json!(arena.text(&node.children));
109
+ }
110
+ if node.name.as_deref() == Some("input")
111
+ && matches!(arena.attr(id, "type"), Some("radio" | "checkbox"))
112
+ {
113
+ return json!("on");
114
+ }
115
+ }
116
+ Value::Null
117
+ }
118
+
119
+ fn inner_text(arena: &DomArena, id: u32) -> String {
120
+ let mut output = String::new();
121
+ let mut stack = vec![id];
122
+ let mut seen = HashSet::new();
123
+ while let Some(id) = stack.pop() {
124
+ if !seen.insert(id) {
125
+ continue;
126
+ }
127
+ let Some(node) = arena.node(id) else {
128
+ continue;
129
+ };
130
+ if matches!(node.name.as_deref(), Some("script" | "style")) || node.kind == "comment" {
131
+ continue;
132
+ }
133
+ if node.kind == "text" {
134
+ output.push_str(node.data.as_deref().unwrap_or(""));
135
+ } else if node.name.as_deref() == Some("br") {
136
+ output.push('\n');
137
+ } else {
138
+ stack.extend(node.children.iter().rev().copied());
139
+ }
140
+ }
141
+ output
142
+ }
143
+
144
+ fn parse_css(style: &str) -> Map<String, Value> {
145
+ let mut properties = Map::new();
146
+ let mut previous: Option<String> = None;
147
+ for segment in style.trim().split(';') {
148
+ let colon = segment.find(':');
149
+ if colon.is_none_or(|index| index == 0 || index + 1 == segment.len()) {
150
+ let continuation = segment.trim_end();
151
+ if !continuation.is_empty() {
152
+ if let Some(key) = &previous {
153
+ let mut value = properties.get(key).map(js_string).unwrap_or_default();
154
+ value.push(';');
155
+ value.push_str(continuation);
156
+ properties.insert(key.clone(), json!(value));
157
+ }
158
+ }
159
+ } else if let Some(colon) = colon {
160
+ let key = segment[..colon].trim().to_owned();
161
+ let value = segment[colon + 1..].trim();
162
+ properties.insert(key.clone(), json!(value));
163
+ previous = Some(key);
164
+ }
165
+ }
166
+ properties
167
+ }
168
+
169
+ fn set_css(arena: &mut DomArena, ids: &[u32], name: &str, value: &Value) {
170
+ for id in ids {
171
+ if !element(arena, *id) {
172
+ continue;
173
+ }
174
+ let mut properties = parse_css(arena.attr(*id, "style").unwrap_or(""));
175
+ if value.as_str() == Some("") {
176
+ properties.remove(name);
177
+ } else if !value.is_null() {
178
+ properties.insert(name.into(), json!(js_string(value)));
179
+ }
180
+ let style = properties
181
+ .iter()
182
+ .map(|(key, value)| format!("{key}: {};", js_string(value)))
183
+ .collect::<Vec<_>>()
184
+ .join(" ");
185
+ arena.set_attr(&[*id], "style", Some(&style));
186
+ }
187
+ }
188
+
189
+ fn node_property(arena: &DomArena, id: u32, name: &str) -> bool {
190
+ matches!(
191
+ name,
192
+ "attribs"
193
+ | "children"
194
+ | "childNodes"
195
+ | "parent"
196
+ | "parentNode"
197
+ | "prev"
198
+ | "previousSibling"
199
+ | "next"
200
+ | "nextSibling"
201
+ | "firstChild"
202
+ | "lastChild"
203
+ | "nodeType"
204
+ | "nodeValue"
205
+ | "name"
206
+ | "tagName"
207
+ | "type"
208
+ | "startIndex"
209
+ | "endIndex"
210
+ ) || arena
211
+ .node(id)
212
+ .is_some_and(|node| node.extras.contains_key(name))
213
+ }
214
+
215
+ fn get_prop(arena: &DomArena, id: u32, name: &str) -> napi::Result<Value> {
216
+ let Some(node) = arena.node(id) else {
217
+ return Ok(Value::Null);
218
+ };
219
+ Ok(match name {
220
+ "style" if node.is_element() => {
221
+ let mut properties = parse_css(arena.attr(id, "style").unwrap_or(""));
222
+ let names: Vec<_> = properties.keys().cloned().collect();
223
+ for (index, name) in names.iter().enumerate() {
224
+ properties.insert(index.to_string(), json!(name));
225
+ }
226
+ properties.insert("length".into(), json!(names.len()));
227
+ Value::Object(properties)
228
+ }
229
+ "tagName" | "nodeName" if node.is_element() => {
230
+ json!(node.name.as_deref().unwrap_or("").to_uppercase())
231
+ }
232
+ "textContent" => json!(arena.text(&[id])),
233
+ "innerText" => json!(if arena.xml_mode() {
234
+ arena.text(&[id])
235
+ } else {
236
+ inner_text(arena, id)
237
+ }),
238
+ "outerHTML" => json!(arena.outer_html(&[id])),
239
+ "innerHTML" => json!(arena.inner_html(id)),
240
+ "href" | "src" if node.is_element() => {
241
+ let value = arena.attr(id, name);
242
+ let tag = node.name.as_deref().unwrap_or("");
243
+ let resolves = name == "href" && matches!(tag, "a" | "link")
244
+ || name == "src" && matches!(tag, "img" | "iframe" | "audio" | "video" | "source");
245
+ match (value, arena.options.get("baseURI").and_then(Value::as_str)) {
246
+ (Some(value), Some(base)) if resolves => json!(crate::codec::encode_literal(
247
+ url::Url::parse(&crate::codec::to_usv(base))
248
+ .and_then(|base| base.join(&crate::codec::to_usv(value)))
249
+ .map_err(|error| napi::Error::from_reason(error.to_string()))?
250
+ .as_str()
251
+ )),
252
+ (Some(value), _) => json!(value),
253
+ _ => Value::Null,
254
+ }
255
+ }
256
+ _ if !node.is_element() => Value::Null,
257
+ _ if node_property(arena, id, name) => json!({"__nativeNodeProperty": name}),
258
+ _ if !arena.xml_mode() && boolean_attribute(name) => json!(node.attribs.contains_key(name)),
259
+ _ => get_attr(arena, id, name),
260
+ })
261
+ }
262
+
263
+ fn set_prop(arena: &mut DomArena, ids: &[u32], name: &str, value: &Value) -> napi::Result<()> {
264
+ for id in ids {
265
+ if !element(arena, *id) {
266
+ continue;
267
+ }
268
+ if node_property(arena, *id, name) || matches!(name, "namespace" | "prefix") {
269
+ let field = match name {
270
+ "childNodes" => "children",
271
+ "parentNode" => "parent",
272
+ "previousSibling" => "prev",
273
+ "nextSibling" => "next",
274
+ _ => name,
275
+ };
276
+ arena.set_node(*id, field, value)?;
277
+ } else if !arena.xml_mode() && boolean_attribute(name) {
278
+ arena.set_attr(&[*id], name, if truthy(value) { Some("") } else { None });
279
+ } else {
280
+ arena.set_attr(&[*id], name, Some(&js_string(value)));
281
+ }
282
+ }
283
+ Ok(())
284
+ }
285
+
286
+ fn camel_case(value: &str) -> String {
287
+ let mut result = String::new();
288
+ let mut chars = value.chars().peekable();
289
+ while let Some(character) = chars.next() {
290
+ if matches!(character, '.' | '_' | '-') {
291
+ if chars.peek().is_none() {
292
+ continue;
293
+ }
294
+ if chars
295
+ .peek()
296
+ .is_some_and(|character| character.is_ascii_alphanumeric() || *character == '_')
297
+ {
298
+ result.push(chars.next().unwrap().to_ascii_uppercase());
299
+ continue;
300
+ }
301
+ }
302
+ result.push(character);
303
+ }
304
+ result
305
+ }
306
+
307
+ fn css_case(value: &str) -> String {
308
+ let mut result = String::new();
309
+ for character in value.chars() {
310
+ if character.is_ascii_uppercase() {
311
+ result.push('-');
312
+ }
313
+ result.push(character.to_ascii_lowercase());
314
+ }
315
+ result
316
+ }
317
+
318
+ fn data_value(value: &str) -> Value {
319
+ match value {
320
+ "null" => return Value::Null,
321
+ "true" => return json!(true),
322
+ "false" => return json!(false),
323
+ "NaN" | "Infinity" | "-Infinity" => return json!({"__nativeNumber": value}),
324
+ _ => {}
325
+ }
326
+ if let Ok(number) = value.parse::<f64>() {
327
+ let rendered = js_number(number);
328
+ if rendered == value {
329
+ if let Some(number) = Number::from_f64(number) {
330
+ return Value::Number(number);
331
+ }
332
+ }
333
+ }
334
+ if value.starts_with('{') && value.ends_with('}')
335
+ || value.starts_with('[') && value.ends_with(']')
336
+ {
337
+ if let Ok(value) = crate::codec::parse_json(value) {
338
+ return value;
339
+ }
340
+ }
341
+ json!(value)
342
+ }
343
+
344
+ fn read_data(arena: &mut DomArena, id: u32, name: Option<&str>) -> Value {
345
+ if !element(arena, id) {
346
+ return json!({"found":false});
347
+ }
348
+ let node = &mut arena.nodes[id as usize];
349
+ let attributes = node.attribs.clone();
350
+ let entry = node.extras.entry("data").or_insert_with(|| json!({}));
351
+ if !entry.is_object() {
352
+ *entry = json!({});
353
+ }
354
+ let data = entry.as_object_mut().unwrap();
355
+ if let Some(name) = name {
356
+ if !data.contains_key(name) {
357
+ if let Some(value) = attributes
358
+ .get(&format!("data-{}", css_case(name)))
359
+ .and_then(Value::as_str)
360
+ {
361
+ data.insert(name.into(), data_value(value));
362
+ }
363
+ }
364
+ match data.get(name) {
365
+ Some(value) => json!({"found":true,"value":value}),
366
+ None => json!({"found":false}),
367
+ }
368
+ } else {
369
+ for (key, value) in attributes {
370
+ if let Some(key) = key.strip_prefix("data-") {
371
+ data.entry(camel_case(key))
372
+ .or_insert_with(|| data_value(value.as_str().unwrap_or("")));
373
+ }
374
+ }
375
+ json!({"found":true,"value":data})
376
+ }
377
+ }
378
+
379
+ fn descendants(arena: &DomArena, roots: &[u32], tag: Option<&str>) -> Vec<u32> {
380
+ let mut output = Vec::new();
381
+ for root in roots {
382
+ let mut stack = arena
383
+ .node(*root)
384
+ .map(|node| node.children.iter().rev().copied().collect::<Vec<_>>())
385
+ .unwrap_or_default();
386
+ let mut seen = HashSet::new();
387
+ while let Some(id) = stack.pop() {
388
+ if !seen.insert(id) {
389
+ continue;
390
+ }
391
+ let Some(node) = arena.node(id) else {
392
+ continue;
393
+ };
394
+ if node.is_element() && tag.is_none_or(|tag| node.name.as_deref() == Some(tag)) {
395
+ output.push(id);
396
+ }
397
+ stack.extend(node.children.iter().rev().copied());
398
+ }
399
+ }
400
+ output
401
+ }
402
+
403
+ fn selected_options(arena: &DomArena, select: u32) -> Vec<u32> {
404
+ let options = descendants(arena, &[select], Some("option"));
405
+ let selected: Vec<_> = options
406
+ .iter()
407
+ .copied()
408
+ .filter(|id| arena.attr(*id, "selected").is_some())
409
+ .collect();
410
+ if selected.is_empty() && arena.attr(select, "multiple").is_none() {
411
+ options.into_iter().take(1).collect()
412
+ } else {
413
+ selected
414
+ }
415
+ }
416
+
417
+ fn get_value(arena: &DomArena, ids: &[u32]) -> Value {
418
+ let Some(id) = ids.first().copied() else {
419
+ return Value::Null;
420
+ };
421
+ let Some(node) = arena.node(id).filter(|node| node.is_element()) else {
422
+ return Value::Null;
423
+ };
424
+ match node.name.as_deref() {
425
+ Some("textarea") => json!(arena.text(ids)),
426
+ Some("input" | "option" | "button") => get_attr(arena, id, "value"),
427
+ Some("select") => {
428
+ let selected = selected_options(arena, id);
429
+ if get_attr(arena, id, "multiple")
430
+ .as_str()
431
+ .is_some_and(|value| !value.is_empty())
432
+ {
433
+ json!(selected
434
+ .iter()
435
+ .map(|id| arena.text(&[*id]))
436
+ .collect::<Vec<_>>())
437
+ } else {
438
+ selected
439
+ .first()
440
+ .map(|id| get_attr(arena, *id, "value"))
441
+ .unwrap_or(Value::Null)
442
+ }
443
+ }
444
+ _ => Value::Null,
445
+ }
446
+ }
447
+
448
+ fn set_value(arena: &mut DomArena, ids: &[u32], value: &Value) -> bool {
449
+ let Some(id) = ids.first().copied() else {
450
+ return true;
451
+ };
452
+ let Some(node) = arena.node(id).filter(|node| node.is_element()) else {
453
+ return true;
454
+ };
455
+ match node.name.as_deref() {
456
+ Some("textarea") => arena.set_text(ids, &js_string(value)),
457
+ Some("input" | "option" | "button") => {
458
+ if value.get("__undefinedJavaScriptValue") == Some(&Value::Bool(true)) {
459
+ return true;
460
+ }
461
+ let string = js_string(value);
462
+ arena.set_attr(
463
+ ids,
464
+ "value",
465
+ if value.is_null() { None } else { Some(&string) },
466
+ );
467
+ }
468
+ Some("select") => {
469
+ if arena.attr(id, "multiple").is_none() && value.is_array() {
470
+ return true;
471
+ }
472
+ let values = value
473
+ .as_array()
474
+ .cloned()
475
+ .unwrap_or_else(|| vec![value.clone()])
476
+ .iter()
477
+ .map(js_string)
478
+ .collect::<HashSet<_>>();
479
+ let options = descendants(arena, ids, Some("option"));
480
+ for id in options {
481
+ let selected = arena
482
+ .attr(id, "value")
483
+ .is_some_and(|value| values.contains(value));
484
+ arena.set_attr(&[id], "selected", if selected { Some("") } else { None });
485
+ }
486
+ }
487
+ _ => return false,
488
+ }
489
+ true
490
+ }
491
+
492
+ fn disabled(arena: &DomArena, id: u32) -> bool {
493
+ // Cheerio's selector alias checks the control's own attribute. It does not
494
+ // propagate a disabled fieldset to descendants as a browser does.
495
+ arena.attr(id, "disabled").is_some()
496
+ }
497
+
498
+ fn serialize_array(arena: &DomArena, ids: &[u32]) -> Value {
499
+ let mut controls = Vec::new();
500
+ for id in ids {
501
+ if arena
502
+ .node(*id)
503
+ .is_some_and(|node| node.name.as_deref() == Some("form"))
504
+ {
505
+ controls.extend(descendants(arena, &[*id], None));
506
+ } else {
507
+ controls.push(*id);
508
+ }
509
+ }
510
+ let mut output = Vec::new();
511
+ for id in controls {
512
+ let Some(node) = arena.node(id) else {
513
+ continue;
514
+ };
515
+ if !matches!(
516
+ node.name.as_deref(),
517
+ Some("input" | "select" | "textarea" | "keygen")
518
+ ) || disabled(arena, id)
519
+ {
520
+ continue;
521
+ }
522
+ let Some(name) = arena.attr(id, "name").filter(|name| !name.is_empty()) else {
523
+ continue;
524
+ };
525
+ let control_type = arena.attr(id, "type").unwrap_or("").to_ascii_lowercase();
526
+ if matches!(
527
+ control_type.as_str(),
528
+ "submit" | "button" | "image" | "reset" | "file"
529
+ ) || matches!(control_type.as_str(), "checkbox" | "radio")
530
+ && arena.attr(id, "checked").is_none()
531
+ {
532
+ continue;
533
+ }
534
+ let value = get_value(arena, &[id]);
535
+ let values = value
536
+ .as_array()
537
+ .cloned()
538
+ .unwrap_or_else(|| vec![if value.is_null() { json!("") } else { value }]);
539
+ for value in values {
540
+ output.push(json!({"name":name,"value":js_string(&value).replace("\r\n", "\n").replace('\n', "\r\n")}));
541
+ }
542
+ }
543
+ Value::Array(output)
544
+ }
545
+
546
+ fn encode_component(value: &str, encoded: bool) -> Result<String, ()> {
547
+ let decoded;
548
+ let value = if encoded && codec::has_encoding(value) {
549
+ decoded = String::from_utf16(&codec::to_units(value)).map_err(|_| ())?;
550
+ decoded.as_str()
551
+ } else {
552
+ value
553
+ };
554
+ let mut output = String::new();
555
+ for byte in value.bytes() {
556
+ if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
557
+ output.push(byte as char);
558
+ } else if byte == b' ' {
559
+ output.push('+');
560
+ } else {
561
+ output.push_str(&format!("%{byte:02X}"));
562
+ }
563
+ }
564
+ Ok(output)
565
+ }
566
+
567
+ pub(crate) fn execute(
568
+ arena: &mut DomArena,
569
+ command: &str,
570
+ args: &Value,
571
+ ) -> napi::Result<Option<Value>> {
572
+ let ids = value_ids(&args["ids"]);
573
+ let first = ids.first().copied();
574
+ let name = args.get("name").and_then(Value::as_str);
575
+ let value = args.get("value");
576
+ let result = match command {
577
+ "attr" => {
578
+ if let Some(value) = value {
579
+ if let Some(name) = name {
580
+ let string = js_string(value);
581
+ arena.set_attr(
582
+ &ids,
583
+ name,
584
+ if value.is_null() { None } else { Some(&string) },
585
+ );
586
+ }
587
+ Value::Null
588
+ } else {
589
+ first
590
+ .map(|id| get_attr(arena, id, name.unwrap_or("")))
591
+ .unwrap_or(Value::Null)
592
+ }
593
+ }
594
+ "removeAttr" => {
595
+ for name in name.unwrap_or("").split_whitespace() {
596
+ arena.set_attr(&ids, name, None);
597
+ }
598
+ Value::Null
599
+ }
600
+ "prop" => {
601
+ if let Some(value) = value {
602
+ if let Some(name) = name {
603
+ set_prop(arena, &ids, name, value)?;
604
+ }
605
+ Value::Null
606
+ } else if let (Some(id), Some(name)) = (first, name) {
607
+ if args.get("raw").and_then(Value::as_bool) == Some(true) {
608
+ if !element(arena, id) {
609
+ Value::Null
610
+ } else if node_property(arena, id, name) {
611
+ json!({"__nativeNodeProperty":name})
612
+ } else if !arena.xml_mode() && boolean_attribute(name) {
613
+ json!(arena.attr(id, name).is_some())
614
+ } else {
615
+ get_attr(arena, id, name)
616
+ }
617
+ } else {
618
+ get_prop(arena, id, name)?
619
+ }
620
+ } else {
621
+ Value::Null
622
+ }
623
+ }
624
+ "css" => {
625
+ if let (Some(name), Some(value)) = (name, value) {
626
+ set_css(arena, &ids, name, value);
627
+ Value::Null
628
+ } else if let Some(id) = first.filter(|id| element(arena, *id)) {
629
+ let properties = parse_css(arena.attr(id, "style").unwrap_or(""));
630
+ if let Some(name) = name {
631
+ properties.get(name).cloned().unwrap_or(Value::Null)
632
+ } else if let Some(names) = args.get("name").and_then(Value::as_array) {
633
+ Value::Object(
634
+ names
635
+ .iter()
636
+ .filter_map(|name| name.as_str())
637
+ .filter_map(|name| {
638
+ properties
639
+ .get(name)
640
+ .map(|value| (name.into(), value.clone()))
641
+ })
642
+ .collect(),
643
+ )
644
+ } else {
645
+ Value::Object(properties)
646
+ }
647
+ } else {
648
+ Value::Null
649
+ }
650
+ }
651
+ "data" => {
652
+ if let (Some(name), Some(value)) = (name, value) {
653
+ for id in &ids {
654
+ if !element(arena, *id) {
655
+ continue;
656
+ }
657
+ let entry = arena.nodes[*id as usize]
658
+ .extras
659
+ .entry("data")
660
+ .or_insert_with(|| json!({}));
661
+ if !entry.is_object() {
662
+ *entry = json!({});
663
+ }
664
+ entry
665
+ .as_object_mut()
666
+ .unwrap()
667
+ .insert(name.into(), value.clone());
668
+ }
669
+ Value::Null
670
+ } else {
671
+ first
672
+ .map(|id| read_data(arena, id, name))
673
+ .unwrap_or_else(|| json!({"found":false}))
674
+ }
675
+ }
676
+ "removeData" => {
677
+ if let Some(name) = name {
678
+ for id in &ids {
679
+ if let Some(data) = arena
680
+ .nodes
681
+ .get_mut(*id as usize)
682
+ .and_then(|node| node.extras.get_mut("data"))
683
+ .and_then(Value::as_object_mut)
684
+ {
685
+ data.remove(name);
686
+ }
687
+ }
688
+ }
689
+ Value::Null
690
+ }
691
+ "hasClass" => {
692
+ let needle = name.unwrap_or("");
693
+ json!(
694
+ !needle.is_empty()
695
+ && ids.iter().any(|id| element(arena, *id)
696
+ && arena.attr(*id, "class").is_some_and(|classes| classes
697
+ .match_indices(needle)
698
+ .any(|(index, _)| {
699
+ (index == 0
700
+ || classes[..index]
701
+ .chars()
702
+ .next_back()
703
+ .is_some_and(char::is_whitespace))
704
+ && (index + needle.len() == classes.len()
705
+ || classes[index + needle.len()..]
706
+ .chars()
707
+ .next()
708
+ .is_some_and(char::is_whitespace))
709
+ })))
710
+ )
711
+ }
712
+ "addClass" | "removeClass" | "toggleClass" => {
713
+ let names: Vec<_> = name.unwrap_or("").split_whitespace().collect();
714
+ for id in &ids {
715
+ if !element(arena, *id) {
716
+ continue;
717
+ }
718
+ let old = arena.attr(*id, "class").unwrap_or("").to_owned();
719
+ if command == "addClass" && !names.is_empty() {
720
+ let mut classes = format!(" {old} ");
721
+ for name in &names {
722
+ if !classes.contains(&format!(" {name} ")) {
723
+ classes.push_str(name);
724
+ classes.push(' ');
725
+ }
726
+ }
727
+ arena.set_attr(&[*id], "class", Some(classes.trim()));
728
+ continue;
729
+ }
730
+ let mut classes: Vec<String> = old.split_whitespace().map(str::to_owned).collect();
731
+ let mut changed = false;
732
+ if command == "removeClass"
733
+ && args.get("all").and_then(Value::as_bool) == Some(true)
734
+ {
735
+ classes.clear();
736
+ changed = true;
737
+ }
738
+ for name in &names {
739
+ let present = classes.iter().any(|class| class == name);
740
+ let add = if command == "addClass" {
741
+ true
742
+ } else if command == "removeClass" {
743
+ false
744
+ } else {
745
+ args.get("state")
746
+ .and_then(Value::as_bool)
747
+ .unwrap_or(!present)
748
+ };
749
+ if add && !present {
750
+ classes.push((*name).into());
751
+ changed = true;
752
+ } else if !add && present {
753
+ classes.retain(|class| class != name);
754
+ changed = true;
755
+ }
756
+ }
757
+ if changed || command == "toggleClass" && !names.is_empty() {
758
+ arena.set_attr(&[*id], "class", Some(&classes.join(" ")));
759
+ }
760
+ }
761
+ Value::Null
762
+ }
763
+ "val" => {
764
+ if let Some(value) = value {
765
+ if value.get("__undefinedJavaScriptValue") == Some(&Value::Bool(true))
766
+ && first
767
+ .and_then(|id| arena.node(id))
768
+ .is_some_and(|node| node.name.as_deref() == Some("textarea"))
769
+ {
770
+ json!({"chain":false,"returnValue":get_value(arena,&ids)})
771
+ } else {
772
+ json!(set_value(arena, &ids, value))
773
+ }
774
+ } else {
775
+ get_value(arena, &ids)
776
+ }
777
+ }
778
+ "serializeArray" => serialize_array(arena, &ids),
779
+ "serialize" => {
780
+ let fields = serialize_array(arena, &ids);
781
+ let encoded = arena.options.get("__utf16Codec") == Some(&Value::Bool(true));
782
+ let result = fields
783
+ .as_array()
784
+ .unwrap()
785
+ .iter()
786
+ .map(|field| {
787
+ Ok(format!(
788
+ "{}={}",
789
+ encode_component(field["name"].as_str().unwrap_or(""), encoded)?,
790
+ encode_component(field["value"].as_str().unwrap_or(""), encoded)?
791
+ ))
792
+ })
793
+ .collect::<Result<Vec<_>, ()>>();
794
+ match result {
795
+ Ok(fields) => json!(fields.join("&")),
796
+ Err(()) => json!({"__nativeError":{"name":"URIError","message":"URI malformed"}}),
797
+ }
798
+ }
799
+ _ => return Ok(None),
800
+ };
801
+ Ok(Some(result))
802
+ }
803
+
804
+ #[cfg(test)]
805
+ mod tests {
806
+ use super::*;
807
+
808
+ fn fixture(source: &str) -> DomArena {
809
+ crate::arena::parse_xml(
810
+ source,
811
+ json!({"xmlMode":false,"baseURI":"https://example.com/path/page"}),
812
+ )
813
+ }
814
+
815
+ fn tag(arena: &DomArena, name: &str) -> u32 {
816
+ arena
817
+ .nodes
818
+ .iter()
819
+ .position(|node| node.name.as_deref() == Some(name))
820
+ .unwrap() as u32
821
+ }
822
+
823
+ fn run(arena: &mut DomArena, command: &str, args: Value) -> Value {
824
+ execute(arena, command, &args).unwrap().unwrap()
825
+ }
826
+
827
+ #[test]
828
+ fn form_serialization_matches_cheerio_fieldset_and_control_semantics() {
829
+ let mut arena = fixture("<form><fieldset disabled><legend><input name='allowed' value='yes'></legend><input name='child' value='no'></fieldset><input name='own' disabled value='gone'><input name='space' value='a b!'><input name='tick' type='checkbox' checked><textarea name='lines'>one\ntwo</textarea></form>");
830
+ let form = tag(&arena, "form");
831
+ assert_eq!(
832
+ run(&mut arena, "serialize", json!({"ids":[form]})),
833
+ json!("allowed=yes&child=no&space=a+b!&tick=on&lines=one%0D%0Atwo")
834
+ );
835
+ }
836
+
837
+ #[test]
838
+ fn form_percent_encoding_uses_original_unicode_and_rejects_lone_surrogates() {
839
+ let mut arena = fixture("<form><input name='value'></form>");
840
+ arena.options["__utf16Codec"] = Value::Bool(true);
841
+ let form = tag(&arena, "form");
842
+ let input = tag(&arena, "input");
843
+ arena.set_attr(
844
+ &[input],
845
+ "value",
846
+ Some(&codec::from_units(&[0xe000, 0xd83d, 0xde00])),
847
+ );
848
+ assert_eq!(
849
+ run(&mut arena, "serialize", json!({"ids":[form]})),
850
+ json!("value=%EE%80%80%F0%9F%98%80")
851
+ );
852
+ arena.set_attr(&[input], "value", Some(&codec::from_units(&[0xd800])));
853
+ assert_eq!(
854
+ run(&mut arena, "serialize", json!({"ids":[form]})),
855
+ json!({"__nativeError":{"name":"URIError","message":"URI malformed"}})
856
+ );
857
+ assert_eq!(
858
+ run(&mut arena, "serializeArray", json!({"ids":[form]}))[0]["value"],
859
+ codec::from_units(&[0xd800])
860
+ );
861
+ }
862
+
863
+ #[test]
864
+ fn data_values_are_typed_cached_and_keep_canonical_number_rules() {
865
+ let mut arena = fixture("<div data-null='null' data-ok='true' data-number='23' data-sci='1E10' data-json='[1,2]'></div>");
866
+ let id = tag(&arena, "div");
867
+ assert_eq!(
868
+ run(&mut arena, "data", json!({"ids":[id],"name":"null"})),
869
+ json!({"found":true,"value":null})
870
+ );
871
+ assert_eq!(
872
+ run(&mut arena, "data", json!({"ids":[id],"name":"number"}))["value"],
873
+ json!(23.0)
874
+ );
875
+ arena.set_attr(&[id], "data-number", Some("99"));
876
+ let values = run(&mut arena, "data", json!({"ids":[id]}));
877
+ assert_eq!(values["value"]["number"], json!(23.0));
878
+ assert_eq!(values["value"]["sci"], json!("1E10"));
879
+ assert_eq!(values["value"]["json"], json!([1, 2]));
880
+ }
881
+
882
+ #[test]
883
+ fn styles_preserve_data_uri_semicolons_and_class_changes_preserve_whitespace() {
884
+ let mut arena = fixture("<div class='one\ttwo' style='background: url(data:image/png;base64,AAA); margin: 0;'></div>");
885
+ let id = tag(&arena, "div");
886
+ assert_eq!(
887
+ run(&mut arena, "css", json!({"ids":[id],"name":"background"})),
888
+ json!("url(data:image/png;base64,AAA)")
889
+ );
890
+ run(&mut arena, "addClass", json!({"ids":[id],"name":"three"}));
891
+ assert_eq!(arena.attr(id, "class"), Some("one\ttwo three"));
892
+ run(
893
+ &mut arena,
894
+ "css",
895
+ json!({"ids":[id],"name":"margin","value":""}),
896
+ );
897
+ assert_eq!(
898
+ arena.attr(id, "style"),
899
+ Some("background: url(data:image/png;base64,AAA);")
900
+ );
901
+ }
902
+
903
+ #[test]
904
+ fn properties_resolve_urls_and_boolean_attributes_stay_mode_specific() {
905
+ let mut arena = fixture("<a href='../result'>Result</a><input checked>");
906
+ let link = tag(&arena, "a");
907
+ let input = tag(&arena, "input");
908
+ assert_eq!(
909
+ run(&mut arena, "prop", json!({"ids":[link],"name":"href"})),
910
+ json!("https://example.com/result")
911
+ );
912
+ assert_eq!(
913
+ run(&mut arena, "prop", json!({"ids":[input],"name":"checked"})),
914
+ json!(true)
915
+ );
916
+ run(
917
+ &mut arena,
918
+ "prop",
919
+ json!({"ids":[input],"name":"checked","value":false}),
920
+ );
921
+ assert_eq!(arena.attr(input, "checked"), None);
922
+ }
923
+ }