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,2095 @@
1
+ //! Immutable HTML5 documents, exposed to Node.js through Node-API.
2
+ //!
3
+ //! Parsing and extraction tasks own their input and output. In particular, no
4
+ //! JavaScript value or borrowed DOM crosses into the libuv or Rayon workers.
5
+
6
+ use std::borrow::Cow;
7
+ use std::cell::{Cell, RefCell};
8
+ use std::collections::{HashMap, HashSet};
9
+
10
+ use ego_tree::{NodeId, NodeRef};
11
+ use html5ever::tendril::TendrilSink;
12
+ use napi::bindgen_prelude::*;
13
+ use napi_derive::napi;
14
+ use scraper::{Html, Node, Selector};
15
+ use serde_json::{Map, Value};
16
+
17
+ mod arena;
18
+ mod attributes;
19
+ mod codec;
20
+ mod diagnostics;
21
+ mod extraction;
22
+ mod gc;
23
+ mod hook_sink;
24
+ mod hook_stream;
25
+ mod location;
26
+ mod runtime;
27
+ mod selector;
28
+ mod sink;
29
+ mod stream;
30
+ mod tokenizer;
31
+ mod tracker;
32
+
33
+ pub use hook_stream::NativeHookStream;
34
+ pub use runtime::{configure_worker_pool, worker_pool_info, WorkerPoolInfo};
35
+ pub use stream::NativeStream;
36
+
37
+ const HTML_NS: &str = "http://www.w3.org/1999/xhtml";
38
+
39
+ #[napi]
40
+ pub struct NativeDocument {
41
+ html: RefCell<Option<Html>>,
42
+ // Stable public IDs are preorder positions, rather than parser allocation
43
+ // positions: HTML5 foster parenting can change the latter's ordering.
44
+ ids: RefCell<Vec<NodeId>>,
45
+ reverse: RefCell<HashMap<NodeId, u32>>,
46
+ pub(crate) arena: RefCell<Option<arena::DomArena>>,
47
+ options: Value,
48
+ source: RefCell<Option<String>>,
49
+ retained: RefCell<HashMap<u32, usize>>,
50
+ needs_collect: Cell<bool>,
51
+ managed: Cell<bool>,
52
+ diagnostics: RefCell<Vec<Value>>,
53
+ locations: RefCell<HashMap<u32, Value>>,
54
+ }
55
+
56
+ impl NativeDocument {
57
+ pub(crate) fn from_arena(arena: arena::DomArena, options: Value) -> Self {
58
+ Self {
59
+ html: RefCell::new(None),
60
+ ids: RefCell::new(Vec::new()),
61
+ reverse: RefCell::new(HashMap::new()),
62
+ arena: RefCell::new(Some(arena)),
63
+ options,
64
+ source: RefCell::new(None),
65
+ retained: RefCell::new(HashMap::new()),
66
+ needs_collect: Cell::new(false),
67
+ managed: Cell::new(false),
68
+ diagnostics: RefCell::new(Vec::new()),
69
+ locations: RefCell::new(HashMap::new()),
70
+ }
71
+ }
72
+ pub(crate) fn set_diagnostic_records(&self, records: Vec<Value>) {
73
+ *self.diagnostics.borrow_mut() = records;
74
+ }
75
+ fn maybe_collect(&self) {
76
+ if self.needs_collect.get() {
77
+ self.collect_garbage();
78
+ }
79
+ }
80
+ #[cfg(test)]
81
+ fn parse(source: &str, is_document: bool) -> Self {
82
+ Self::parse_options(source, is_document, serde_json::json!({}), None)
83
+ }
84
+
85
+ pub(crate) fn parse_options(
86
+ source: &str,
87
+ is_document: bool,
88
+ parser_options: Value,
89
+ context_name: Option<&str>,
90
+ ) -> Self {
91
+ let mut parser_options = arena::normalized_options(parser_options);
92
+ if codec::has_encoding(source) {
93
+ parser_options["__losslessUnicode"] = Value::Bool(true);
94
+ }
95
+ if let Some(events) = parser_options
96
+ .as_object_mut()
97
+ .and_then(|options| options.remove("__tokenEvents"))
98
+ {
99
+ return Self::from_arena(
100
+ tokenizer::parse(source, &events, parser_options.clone()),
101
+ parser_options,
102
+ );
103
+ }
104
+ if arena::xml_mode(&parser_options)
105
+ || parser_options.get("_useHtmlParser2") == Some(&Value::Bool(true))
106
+ {
107
+ return Self::from_arena(
108
+ arena::parse_xml(source, parser_options.clone()),
109
+ parser_options,
110
+ );
111
+ }
112
+
113
+ let options = html5ever::driver::ParseOpts {
114
+ tokenizer: html5ever::tokenizer::TokenizerOpts {
115
+ discard_bom: false,
116
+ rusteerio_codec: parser_options.get("__utf16Codec") == Some(&Value::Bool(true)),
117
+ ..Default::default()
118
+ },
119
+ tree_builder: html5ever::tree_builder::TreeBuilderOpts {
120
+ scripting_enabled: parser_options.get("scriptingEnabled")
121
+ != Some(&Value::Bool(false)),
122
+ ..Default::default()
123
+ },
124
+ };
125
+ let html = if is_document {
126
+ html5ever::driver::parse_document(
127
+ sink::CompatibleSink::new(Html::new_document()),
128
+ options,
129
+ )
130
+ .one(source)
131
+ } else {
132
+ // parse5's default fragment context is a template. A body context
133
+ // discards valid standalone table fragments such as <td> and <tr>.
134
+ html5ever::driver::parse_fragment(
135
+ sink::CompatibleSink::new(Html::new_fragment()),
136
+ options,
137
+ html5ever::QualName::new(
138
+ None,
139
+ html5ever::Namespace::from(
140
+ parser_options
141
+ .get("__contextNamespace")
142
+ .and_then(Value::as_str)
143
+ .unwrap_or(HTML_NS),
144
+ ),
145
+ html5ever::LocalName::from(context_name.unwrap_or("template")),
146
+ ),
147
+ Vec::new(),
148
+ false,
149
+ )
150
+ .one(source)
151
+ };
152
+
153
+ let capture = parser_options
154
+ .get("sourceCodeLocationInfo")
155
+ .is_some_and(|value| !matches!(value, Value::Null | Value::Bool(false)))
156
+ || parser_options.get("__collectParseErrors") == Some(&Value::Bool(true));
157
+ Self::from_html(
158
+ html,
159
+ is_document,
160
+ parser_options,
161
+ capture.then(|| source.to_string()),
162
+ )
163
+ }
164
+
165
+ pub(crate) fn from_html(
166
+ mut html: Html,
167
+ is_document: bool,
168
+ parser_options: Value,
169
+ source: Option<String>,
170
+ ) -> Self {
171
+ if !is_document {
172
+ // html5ever's fragment sink creates a synthetic html element.
173
+ // Cheerio exposes the fragment's actual children at its root.
174
+ let wrapper = html.tree.root().children().find_map(|node| {
175
+ node.value()
176
+ .as_element()
177
+ .filter(|element| element.name() == "html")
178
+ .map(|_| node.id())
179
+ });
180
+ if let Some(wrapper) = wrapper {
181
+ html.tree.root_mut().reparent_from_id_append(wrapper);
182
+ html.tree.get_mut(wrapper).unwrap().detach();
183
+ }
184
+ }
185
+
186
+ let mut locations = HashMap::new();
187
+ if parser_options.get("sourceCodeLocationInfo") == Some(&Value::Bool(true)) {
188
+ if let Some(source) = source.as_ref() {
189
+ let context = std::rc::Rc::new(diagnostics::DiagnosticContext::new(source));
190
+ let tracker = std::rc::Rc::new(tracker::Locations::new(context.clone()));
191
+ let mut tracked = diagnostics::parse_with_sink(
192
+ source,
193
+ is_document,
194
+ &parser_options,
195
+ sink::CompatibleSink::with_locations(
196
+ if is_document {
197
+ Html::new_document()
198
+ } else {
199
+ Html::new_fragment()
200
+ },
201
+ tracker.clone(),
202
+ ),
203
+ context,
204
+ |_, _| {},
205
+ );
206
+ if !is_document {
207
+ let wrapper = tracked
208
+ .tree
209
+ .root()
210
+ .children()
211
+ .find(|node| {
212
+ node.value()
213
+ .as_element()
214
+ .is_some_and(|element| element.name() == "html")
215
+ })
216
+ .map(|node| node.id());
217
+ if let Some(wrapper) = wrapper {
218
+ tracked.tree.root_mut().reparent_from_id_append(wrapper);
219
+ tracked.tree.get_mut(wrapper).unwrap().detach();
220
+ }
221
+ }
222
+ let order: HashMap<_, _> = tracked
223
+ .tree
224
+ .root()
225
+ .descendants()
226
+ .enumerate()
227
+ .map(|(index, node)| (node.id(), index as u32))
228
+ .collect();
229
+ locations = tracker
230
+ .take()
231
+ .into_iter()
232
+ .filter_map(|(id, value)| order.get(&id).map(|id| (*id, value)))
233
+ .collect();
234
+ }
235
+ }
236
+
237
+ let ids: Vec<_> = html
238
+ .tree
239
+ .root()
240
+ .descendants()
241
+ .map(|node| node.id())
242
+ .collect();
243
+ let reverse = ids
244
+ .iter()
245
+ .enumerate()
246
+ .map(|(index, id)| (*id, index as u32))
247
+ .collect();
248
+ let diagnostics = if parser_options.get("__collectParseErrors") == Some(&Value::Bool(true))
249
+ {
250
+ source
251
+ .as_ref()
252
+ .map(|source| diagnostics::collect(source, is_document, &parser_options))
253
+ .unwrap_or_default()
254
+ } else {
255
+ Vec::new()
256
+ };
257
+ let source = parser_options
258
+ .get("sourceCodeLocationInfo")
259
+ .filter(|value| {
260
+ !matches!(value, Value::Null | Value::Bool(false))
261
+ && value != &&serde_json::json!(0)
262
+ && value != &&serde_json::json!("")
263
+ })
264
+ .and(source);
265
+ Self {
266
+ html: RefCell::new(Some(html)),
267
+ ids: RefCell::new(ids),
268
+ reverse: RefCell::new(reverse),
269
+ arena: RefCell::new(None),
270
+ options: parser_options,
271
+ source: RefCell::new(source),
272
+ retained: RefCell::new(HashMap::new()),
273
+ needs_collect: Cell::new(false),
274
+ managed: Cell::new(false),
275
+ diagnostics: RefCell::new(diagnostics),
276
+ locations: RefCell::new(locations),
277
+ }
278
+ }
279
+
280
+ pub(crate) fn ensure_arena(&self) -> Result<()> {
281
+ let missing = self.arena.borrow().is_none();
282
+ if missing {
283
+ let records: Vec<arena::DomNode> = (0..self.ids.borrow().len())
284
+ .filter_map(|id| self.immutable_dom_node(id as u32))
285
+ .collect();
286
+ let mut arena = arena::DomArena::from_records(records, self.options.clone());
287
+ if let Some(source) = self.source.borrow().as_ref() {
288
+ location::annotate(
289
+ source,
290
+ &mut arena.nodes,
291
+ self.options.get("scriptingEnabled") != Some(&Value::Bool(false)),
292
+ self.options.get("__utf16Codec") == Some(&Value::Bool(true)),
293
+ );
294
+ }
295
+ for (id, patch) in std::mem::take(&mut *self.locations.borrow_mut()) {
296
+ if let Some(node) = arena.nodes.get_mut(id as usize) {
297
+ if let Value::Object(patch) = patch {
298
+ let existing = node
299
+ .extras
300
+ .entry("sourceCodeLocation")
301
+ .or_insert_with(|| serde_json::json!({}));
302
+ if !existing.is_object() {
303
+ *existing = serde_json::json!({});
304
+ }
305
+ existing.as_object_mut().unwrap().extend(patch);
306
+ } else {
307
+ node.extras.insert("sourceCodeLocation".into(), patch);
308
+ }
309
+ }
310
+ }
311
+ *self.arena.borrow_mut() = Some(arena);
312
+ self.html.borrow_mut().take();
313
+ self.source.borrow_mut().take();
314
+ *self.ids.borrow_mut() = Vec::new();
315
+ *self.reverse.borrow_mut() = HashMap::new();
316
+ }
317
+ Ok(())
318
+ }
319
+
320
+ fn with_node<T>(
321
+ &self,
322
+ id: u32,
323
+ operation: impl FnOnce(NodeRef<'_, Node>, &Html) -> T,
324
+ ) -> Option<T> {
325
+ let html = self.html.borrow();
326
+ let html = html.as_ref()?;
327
+ let node = html.tree.get(*self.ids.borrow().get(id as usize)?)?;
328
+ Some(operation(node, html))
329
+ }
330
+
331
+ fn immutable_dom_node(&self, id: u32) -> Option<arena::DomNode> {
332
+ self.with_node(id, |node, html| {
333
+ let pointer = |node: Option<NodeRef<'_, Node>>| {
334
+ node.and_then(|node| self.reverse.borrow().get(&node.id()).copied())
335
+ };
336
+ let mut record = arena::DomNode::new("root");
337
+ record.id = id;
338
+ record.children = node
339
+ .children()
340
+ .filter_map(|child| self.reverse.borrow().get(&child.id()).copied())
341
+ .collect();
342
+ record.parent = pointer(node.parent());
343
+ record.prev = pointer(node.prev_sibling());
344
+ record.next = pointer(node.next_sibling());
345
+ record.extras.insert("startIndex".into(), Value::Null);
346
+ record.extras.insert("endIndex".into(), Value::Null);
347
+ match node.value() {
348
+ Node::Document => {
349
+ record.extras.insert(
350
+ "x-mode".into(),
351
+ Value::String(
352
+ match html.quirks_mode {
353
+ html5ever::tree_builder::QuirksMode::Quirks => "quirks",
354
+ html5ever::tree_builder::QuirksMode::LimitedQuirks => {
355
+ "limited-quirks"
356
+ }
357
+ html5ever::tree_builder::QuirksMode::NoQuirks => "no-quirks",
358
+ }
359
+ .into(),
360
+ ),
361
+ );
362
+ }
363
+ Node::Fragment => {}
364
+ Node::Text(data) => {
365
+ record.kind = "text".into();
366
+ record.data = Some(data.to_string());
367
+ }
368
+ Node::Comment(data) => {
369
+ record.kind = "comment".into();
370
+ record.data = Some(data.to_string());
371
+ }
372
+ Node::Doctype(data) => {
373
+ record.kind = "directive".into();
374
+ record.name = Some("!doctype".into());
375
+ record
376
+ .extras
377
+ .insert("x-name".into(), Value::String(data.name().into()));
378
+ record
379
+ .extras
380
+ .insert("x-publicId".into(), Value::String(data.public_id().into()));
381
+ record
382
+ .extras
383
+ .insert("x-systemId".into(), Value::String(data.system_id().into()));
384
+ let mut content = format!("!DOCTYPE {}", data.name());
385
+ if !data.public_id().is_empty() {
386
+ content.push_str(" PUBLIC ");
387
+ content.push_str(&quote_doctype(data.public_id()));
388
+ } else if !data.system_id().is_empty() {
389
+ content.push_str(" SYSTEM");
390
+ }
391
+ if !data.system_id().is_empty() {
392
+ content.push(' ');
393
+ content.push_str(&quote_doctype(data.system_id()));
394
+ }
395
+ record.data = Some(content);
396
+ }
397
+ Node::ProcessingInstruction(data) => {
398
+ record.kind = "directive".into();
399
+ record.name = Some(data.target.to_string());
400
+ record.data = Some(data.data.to_string());
401
+ }
402
+ Node::Element(element) => {
403
+ record.kind = match element.name() {
404
+ "script" => "script",
405
+ "style" => "style",
406
+ _ => "tag",
407
+ }
408
+ .into();
409
+ record.name = Some(element.name().into());
410
+ record.extras.insert(
411
+ "namespace".into(),
412
+ Value::String(element.name.ns.to_string()),
413
+ );
414
+ if let Some(prefix) = &element.name.prefix {
415
+ record
416
+ .extras
417
+ .insert("prefix".into(), Value::String(prefix.to_string()));
418
+ }
419
+ let (mut namespaces, mut prefixes) = (Map::new(), Map::new());
420
+ for (name, value) in &element.attrs {
421
+ let key = name.local.to_string();
422
+ record
423
+ .attribs
424
+ .insert(key.clone(), Value::String(value.to_string()));
425
+ if !name.ns.is_empty() {
426
+ namespaces.insert(key.clone(), Value::String(name.ns.to_string()));
427
+ }
428
+ if let Some(prefix) = &name.prefix {
429
+ prefixes.insert(key, Value::String(prefix.to_string()));
430
+ }
431
+ }
432
+ record
433
+ .extras
434
+ .insert("attribsNamespace".into(), Value::Object(namespaces));
435
+ record
436
+ .extras
437
+ .insert("attribsPrefix".into(), Value::Object(prefixes));
438
+ }
439
+ }
440
+ record
441
+ })
442
+ }
443
+
444
+ fn immutable_record(&self, id: u32) -> Option<Value> {
445
+ serde_json::to_value(self.immutable_dom_node(id)?).ok()
446
+ }
447
+
448
+ fn select_ids(&self, source: &str, contexts: Option<&[u32]>) -> Result<Vec<u32>> {
449
+ self.maybe_collect();
450
+ let contexts = contexts.filter(|contexts| *contexts != [0]);
451
+ if self.arena.borrow().is_some()
452
+ || contexts.is_some()
453
+ || self.options.get("__losslessUnicode") == Some(&Value::Bool(true))
454
+ || codec::has_encoding(source)
455
+ || source.contains(':')
456
+ || source.contains('\\')
457
+ || source.chars().any(char::is_uppercase)
458
+ || source
459
+ .trim_start()
460
+ .chars()
461
+ .next()
462
+ .is_some_and(|ch| matches!(ch, '>' | '+' | '~'))
463
+ || source.contains("!=")
464
+ {
465
+ self.ensure_arena()?;
466
+ return selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts);
467
+ }
468
+ let normalized = normalize_attribute_namespaces(source);
469
+ let selector = match Selector::parse(&normalized) {
470
+ Ok(selector) => selector,
471
+ Err(_) => {
472
+ self.ensure_arena()?;
473
+ return selector::select(self.arena.borrow().as_ref().unwrap(), source, contexts);
474
+ }
475
+ };
476
+ let mut matched = Vec::new();
477
+
478
+ if let Some(html) = self.html.borrow().as_ref() {
479
+ for element in html.select(&selector) {
480
+ if let Some(id) = self.reverse.borrow().get(&element.id()) {
481
+ matched.push(*id);
482
+ }
483
+ }
484
+ }
485
+ matched.sort_unstable();
486
+ matched.dedup();
487
+ Ok(matched)
488
+ }
489
+
490
+ fn text_ids(&self, ids: &[u32]) -> String {
491
+ if let Some(arena) = self.arena.borrow().as_ref() {
492
+ return arena.text(ids);
493
+ }
494
+ let mut text = String::new();
495
+ for id in ids {
496
+ self.with_node(*id, |node, _| {
497
+ for descendant in node.descendants() {
498
+ if let Node::Text(data) = descendant.value() {
499
+ text.push_str(data);
500
+ }
501
+ }
502
+ });
503
+ }
504
+ text
505
+ }
506
+
507
+ fn attribute(&self, id: u32, name: &str) -> Option<String> {
508
+ if let Some(arena) = self.arena.borrow().as_ref() {
509
+ return arena.attr(id, name).map(str::to_owned);
510
+ }
511
+ self.with_node(id, |node, _| {
512
+ node.value().as_element().and_then(|element| {
513
+ element.attrs.iter().find_map(|(key, value)| {
514
+ (key.local.as_ref() == name).then(|| value.to_string())
515
+ })
516
+ })
517
+ })
518
+ .flatten()
519
+ }
520
+
521
+ fn serialize_nodes(&self, ids: &[u32], children_only: bool) -> String {
522
+ if let Some(arena) = self.arena.borrow().as_ref() {
523
+ return if children_only {
524
+ ids.iter().map(|id| arena.inner_html(*id)).collect()
525
+ } else {
526
+ arena.outer_html(ids)
527
+ };
528
+ }
529
+ let mut output = String::new();
530
+ for id in ids {
531
+ self.with_node(*id, |node, _| {
532
+ if children_only {
533
+ for child in node.children() {
534
+ serialize_node(child, &mut output);
535
+ }
536
+ } else {
537
+ serialize_node(node, &mut output);
538
+ }
539
+ });
540
+ }
541
+ output
542
+ }
543
+ }
544
+
545
+ #[napi]
546
+ impl NativeDocument {
547
+ #[napi(constructor)]
548
+ pub fn new(html: String, is_document: bool, options_json: Option<String>) -> Self {
549
+ let options = options_json
550
+ .and_then(|value| serde_json::from_str(&value).ok())
551
+ .unwrap_or_else(|| serde_json::json!({}));
552
+ Self::parse_options(&html, is_document, options, None)
553
+ }
554
+
555
+ #[napi]
556
+ pub fn retain(&self, ids: Vec<u32>) {
557
+ self.managed.set(true);
558
+ let mut pins = self.retained.borrow_mut();
559
+ for id in ids {
560
+ if self
561
+ .arena
562
+ .borrow()
563
+ .as_ref()
564
+ .map_or((id as usize) < self.ids.borrow().len(), |arena| {
565
+ arena.node(id).is_some()
566
+ })
567
+ {
568
+ *pins.entry(id).or_insert(0) += 1;
569
+ }
570
+ }
571
+ }
572
+ #[napi]
573
+ pub fn release(&self, ids: Vec<u32>) {
574
+ let mut pins = self.retained.borrow_mut();
575
+ for id in ids {
576
+ if let Some(count) = pins.get_mut(&id) {
577
+ if *count > 1 {
578
+ *count -= 1;
579
+ } else {
580
+ pins.remove(&id);
581
+ self.needs_collect.set(true);
582
+ }
583
+ }
584
+ }
585
+ }
586
+ #[napi]
587
+ pub fn collect_garbage(&self) -> MemoryStats {
588
+ self.needs_collect.set(false);
589
+ if self.arena.borrow().is_none() && self.managed.get() && self.retained.borrow().is_empty()
590
+ {
591
+ self.html.borrow_mut().take();
592
+ self.source.borrow_mut().take();
593
+ let reclaimed = self.ids.borrow().len() as u32;
594
+ *self.ids.borrow_mut() = Vec::new();
595
+ *self.reverse.borrow_mut() = HashMap::new();
596
+ return MemoryStats {
597
+ live: 0,
598
+ slots: 0,
599
+ retained: 0,
600
+ reclaimed,
601
+ immutable_nodes: 0,
602
+ };
603
+ }
604
+ if let Some(arena) = self.arena.borrow_mut().as_mut() {
605
+ let stats = gc::collect_managed(arena, &self.retained.borrow(), !self.managed.get());
606
+ arena.free = stats.free;
607
+ MemoryStats {
608
+ live: stats.live,
609
+ slots: stats.slots,
610
+ retained: stats.retained,
611
+ reclaimed: stats.reclaimed,
612
+ immutable_nodes: 0,
613
+ }
614
+ } else {
615
+ let count = self.ids.borrow().len() as u32;
616
+ MemoryStats {
617
+ live: count,
618
+ slots: count,
619
+ retained: self.retained.borrow().len() as u32,
620
+ reclaimed: 0,
621
+ immutable_nodes: count,
622
+ }
623
+ }
624
+ }
625
+
626
+ #[napi]
627
+ pub fn parser_diagnostics(&self) -> Result<String> {
628
+ serde_json::to_string(&*self.diagnostics.borrow()).map_err(json_error)
629
+ }
630
+
631
+ #[napi]
632
+ pub fn root_id(&self) -> u32 {
633
+ 0
634
+ }
635
+
636
+ #[napi]
637
+ pub fn select(&self, selector: String, contexts: Option<Vec<u32>>) -> Result<Vec<u32>> {
638
+ self.select_ids(&selector, contexts.as_deref())
639
+ }
640
+
641
+ #[napi]
642
+ pub fn matches(&self, selector: String, ids: Vec<u32>) -> Result<Vec<u32>> {
643
+ self.ensure_arena()?;
644
+ selector::filter(self.arena.borrow().as_ref().unwrap(), &selector, &ids)
645
+ }
646
+
647
+ #[napi]
648
+ pub fn element_ids(&self) -> Vec<u32> {
649
+ if let Some(arena) = self.arena.borrow().as_ref() {
650
+ return arena
651
+ .nodes
652
+ .iter()
653
+ .filter(|node| node.is_element())
654
+ .map(|node| node.id)
655
+ .collect();
656
+ }
657
+ self.ids
658
+ .borrow()
659
+ .iter()
660
+ .enumerate()
661
+ .filter_map(|(id, _)| {
662
+ self.with_node(id as u32, |node, _| node.value().is_element())
663
+ .unwrap_or(false)
664
+ .then_some(id as u32)
665
+ })
666
+ .collect()
667
+ }
668
+
669
+ #[napi]
670
+ pub fn text(&self, ids: Vec<u32>) -> String {
671
+ self.text_ids(&ids)
672
+ }
673
+
674
+ #[napi]
675
+ pub fn inner_html(&self, id: u32) -> String {
676
+ if (self.tag_name(id).as_deref() == Some("template") || self.arena.borrow().is_some())
677
+ && self.ensure_arena().is_ok()
678
+ {
679
+ return self.arena.borrow_mut().as_mut().unwrap().inner_html_mut(id);
680
+ }
681
+ self.serialize_nodes(&[id], true)
682
+ }
683
+
684
+ #[napi]
685
+ pub fn outer_html(&self, ids: Vec<u32>) -> String {
686
+ self.serialize_nodes(&ids, false)
687
+ }
688
+
689
+ #[napi]
690
+ pub fn attr(&self, id: u32, name: String) -> Option<String> {
691
+ self.attribute(id, &name)
692
+ }
693
+
694
+ #[napi]
695
+ pub fn tag_name(&self, id: u32) -> Option<String> {
696
+ if let Some(arena) = self.arena.borrow().as_ref() {
697
+ return arena.node(id).and_then(|node| node.name.clone());
698
+ }
699
+ self.with_node(id, |node, _| {
700
+ node.value()
701
+ .as_element()
702
+ .map(|element| element.name().to_string())
703
+ })
704
+ .flatten()
705
+ }
706
+
707
+ #[napi]
708
+ pub fn export_tree(&self) -> Result<String> {
709
+ if self.source.borrow().is_some() {
710
+ self.ensure_arena()?;
711
+ }
712
+ if let Some(arena) = self.arena.borrow().as_ref() {
713
+ return serde_json::to_string(&arena.nodes).map_err(json_error);
714
+ }
715
+ let records: Vec<_> = (0..self.ids.borrow().len())
716
+ .filter_map(|id| self.immutable_dom_node(id as u32))
717
+ .collect();
718
+ serde_json::to_string(&records).map_err(json_error)
719
+ }
720
+
721
+ #[napi]
722
+ pub fn node(&self, id: u32) -> Result<String> {
723
+ if self.source.borrow().is_some() {
724
+ self.ensure_arena()?;
725
+ }
726
+ if self.arena.borrow().is_none() {
727
+ return serde_json::to_string(&self.immutable_record(id)).map_err(json_error);
728
+ }
729
+ serde_json::to_string(&self.arena.borrow().as_ref().unwrap().node(id)).map_err(json_error)
730
+ }
731
+
732
+ #[napi]
733
+ pub fn create(
734
+ &self,
735
+ html: String,
736
+ context_id: Option<u32>,
737
+ _options_json: Option<String>,
738
+ ) -> Result<Vec<u32>> {
739
+ self.ensure_arena()?;
740
+ self.arena
741
+ .borrow_mut()
742
+ .as_mut()
743
+ .unwrap()
744
+ .create_fragment(&html, context_id)
745
+ }
746
+
747
+ /// Move a branch between native documents. JavaScript only redirects its
748
+ /// existing stable handles using the returned complete descendant mapping.
749
+ #[napi]
750
+ pub fn adopt(
751
+ &self,
752
+ source: &NativeDocument,
753
+ ids: Vec<u32>,
754
+ detach: Option<bool>,
755
+ ) -> Result<String> {
756
+ if std::ptr::eq(self, source) {
757
+ return serde_json::to_string(&serde_json::json!({"roots":ids,"mapping":ids.iter().map(|id|[*id,*id]).collect::<Vec<_>>()})).map_err(json_error);
758
+ }
759
+ self.ensure_arena()?;
760
+ source.ensure_arena()?;
761
+ let mut source_arena = source.arena.borrow_mut();
762
+ let source_arena = source_arena.as_mut().unwrap();
763
+ // Detach all requested roots first: overlapping roots become siblings
764
+ // and each requested descendant is imported exactly once.
765
+ if detach.unwrap_or(true) {
766
+ source_arena.detach_many(&ids);
767
+ }
768
+ let mut seen = HashSet::new();
769
+ let mut moved = Vec::new();
770
+ let mut stack: Vec<_> = ids.iter().rev().copied().collect();
771
+ while let Some(id) = stack.pop() {
772
+ if !seen.insert(id) {
773
+ continue;
774
+ }
775
+ if let Some(node) = source_arena.node(id) {
776
+ moved.push(id);
777
+ stack.extend(node.children.iter().rev().copied());
778
+ }
779
+ }
780
+ let mut target_arena = self.arena.borrow_mut();
781
+ let target_arena = target_arena.as_mut().unwrap();
782
+ target_arena.nodes.reserve(moved.len());
783
+ let (roots, mapping) = if detach.unwrap_or(true) {
784
+ let mapping: Vec<_> = moved
785
+ .iter()
786
+ .map(|id| [*id, target_arena.push(arena::DomNode::new("released"))])
787
+ .collect();
788
+ let remap: HashMap<_, _> = mapping.iter().map(|pair| (pair[0], pair[1])).collect();
789
+ for id in &moved {
790
+ let mut tombstone = arena::DomNode::new("released");
791
+ tombstone.id = *id;
792
+ let mut node = std::mem::replace(&mut source_arena.nodes[*id as usize], tombstone);
793
+ source_arena.free.push(*id);
794
+ node.id = remap[id];
795
+ node.children.retain_mut(|child| {
796
+ if let Some(mapped) = remap.get(child) {
797
+ *child = *mapped;
798
+ true
799
+ } else {
800
+ false
801
+ }
802
+ });
803
+ node.parent = node.parent.and_then(|id| remap.get(&id).copied());
804
+ node.prev = node.prev.and_then(|id| remap.get(&id).copied());
805
+ node.next = node.next.and_then(|id| remap.get(&id).copied());
806
+ let new_id = node.id;
807
+ target_arena.nodes[new_id as usize] = node;
808
+ if let Some(count) = source.retained.borrow_mut().remove(id) {
809
+ *self.retained.borrow_mut().entry(new_id).or_insert(0) += count;
810
+ }
811
+ }
812
+ for pair in &mapping {
813
+ target_arena.relink(pair[1]);
814
+ }
815
+ (
816
+ ids.iter()
817
+ .filter_map(|id| remap.get(id).copied())
818
+ .collect::<Vec<_>>(),
819
+ mapping,
820
+ )
821
+ } else {
822
+ // Imported roots use the original preorder allocation. Preserve
823
+ // a complete mapping even when this arena has recyclable slots.
824
+ let free = std::mem::take(&mut target_arena.free);
825
+ let first = target_arena.nodes.len() as u32;
826
+ let records: Vec<_> = moved
827
+ .iter()
828
+ .filter_map(|id| source_arena.node(*id).cloned())
829
+ .collect();
830
+ let roots = target_arena.import(&records, &ids);
831
+ let mapping = moved
832
+ .iter()
833
+ .enumerate()
834
+ .map(|(index, id)| [*id, first + index as u32])
835
+ .collect::<Vec<_>>();
836
+ target_arena.free = free;
837
+ (roots, mapping)
838
+ };
839
+ serde_json::to_string(&serde_json::json!({"roots":roots,"mapping":mapping}))
840
+ .map_err(json_error)
841
+ }
842
+
843
+ #[napi]
844
+ pub fn op(&self, command: String, args_json: String) -> Result<String> {
845
+ self.maybe_collect();
846
+ let args: Value = serde_json::from_str(&args_json).map_err(json_error)?;
847
+ if command == "node" {
848
+ return self.node(args["id"].as_u64().unwrap_or(0) as u32);
849
+ }
850
+ if self.arena.borrow().is_none() {
851
+ let ids = arena::value_ids(&args["ids"]);
852
+ let effective_options = arena::normalized_options(
853
+ args.get("options")
854
+ .filter(|options| options.is_object())
855
+ .cloned()
856
+ .unwrap_or_else(|| self.options.clone()),
857
+ );
858
+ if command == "attr" && args.get("value").is_none() {
859
+ let name = args["name"].as_str().unwrap_or("");
860
+ let result = ids
861
+ .first()
862
+ .and_then(|id| {
863
+ let element_name = self.tag_name(*id)?;
864
+ if name.is_empty() {
865
+ return self
866
+ .immutable_record(*id)
867
+ .and_then(|value| value.get("attribs").cloned());
868
+ }
869
+ if let Some(value) = self.attribute(*id, name) {
870
+ let boolean = matches!(
871
+ name.to_ascii_lowercase().as_str(),
872
+ "autofocus"
873
+ | "autoplay"
874
+ | "async"
875
+ | "checked"
876
+ | "controls"
877
+ | "defer"
878
+ | "disabled"
879
+ | "hidden"
880
+ | "loop"
881
+ | "multiple"
882
+ | "open"
883
+ | "readonly"
884
+ | "required"
885
+ | "scoped"
886
+ | "selected"
887
+ );
888
+ return Some(Value::String(
889
+ if boolean && !arena::xml_mode(&effective_options) {
890
+ name.into()
891
+ } else {
892
+ value
893
+ },
894
+ ));
895
+ }
896
+ if name == "value" {
897
+ if element_name == "option" {
898
+ return Some(Value::String(self.text_ids(&[*id])));
899
+ }
900
+ if element_name == "input"
901
+ && matches!(
902
+ self.attribute(*id, "type").as_deref(),
903
+ Some("radio" | "checkbox")
904
+ )
905
+ {
906
+ return Some(serde_json::json!("on"));
907
+ }
908
+ }
909
+ None
910
+ })
911
+ .unwrap_or(Value::Null);
912
+ return serde_json::to_string(&result).map_err(json_error);
913
+ }
914
+ if command == "render" || command == "outerHtml" {
915
+ let mut options = self.options.clone();
916
+ if let Some(overrides) = args.get("options").and_then(Value::as_object) {
917
+ for (key, value) in overrides {
918
+ options[key] = value.clone();
919
+ }
920
+ }
921
+ let options = arena::normalized_options(options);
922
+ if !arena::xml_mode(&options)
923
+ && options.get("_useHtmlParser2") != Some(&Value::Bool(true))
924
+ {
925
+ return serde_json::to_string(&self.serialize_nodes(&ids, false))
926
+ .map_err(json_error);
927
+ }
928
+ }
929
+ }
930
+ self.ensure_arena()?;
931
+ let mut arena = self.arena.borrow_mut();
932
+ let arena = arena.as_mut().unwrap();
933
+ let previous_options = arena.options.clone();
934
+ if let Some(options) = args.get("options").filter(|options| options.is_object()) {
935
+ arena.options = arena::normalized_options(options.clone());
936
+ if self.options.get("__utf16Codec") == Some(&Value::Bool(true)) {
937
+ arena.options["__utf16Codec"] = Value::Bool(true);
938
+ }
939
+ }
940
+ let result = (|| -> Result<Value> {
941
+ if command == "extractPlan" {
942
+ return extraction::execute_arena(
943
+ arena,
944
+ &args["plan"],
945
+ &arena::value_ids(&args["ids"]),
946
+ );
947
+ }
948
+ if let Some(result) = attributes::execute(arena, &command, &args)? {
949
+ Ok(result)
950
+ } else {
951
+ arena::primitive(arena, &command, &args)
952
+ }
953
+ })();
954
+ arena.options = previous_options;
955
+ let result = result?;
956
+ serde_json::to_string(&result).map_err(json_error)
957
+ }
958
+ }
959
+
960
+ #[napi(object)]
961
+ pub struct MemoryStats {
962
+ pub live: u32,
963
+ pub slots: u32,
964
+ pub retained: u32,
965
+ pub reclaimed: u32,
966
+ pub immutable_nodes: u32,
967
+ }
968
+
969
+ /// The Cheerio DOM adapter exposes foreign attributes by local name, whereas
970
+ /// CSS normally limits unprefixed attribute selectors to the empty namespace.
971
+ /// A wildcard prefix restores the adapter's matching rules. Preserve explicit
972
+ /// namespaces, quoted values, and escapes while scanning selector syntax.
973
+ fn normalize_attribute_namespaces(source: &str) -> Cow<'_, str> {
974
+ let bytes = source.as_bytes();
975
+ let mut insertions = Vec::new();
976
+ let mut quote = None;
977
+ let mut index = 0;
978
+ while index < bytes.len() {
979
+ let current = bytes[index];
980
+ if current == b'\\' {
981
+ index += 2;
982
+ continue;
983
+ }
984
+ if let Some(quoted) = quote {
985
+ if current == quoted {
986
+ quote = None;
987
+ }
988
+ } else if current == b'\'' || current == b'"' {
989
+ quote = Some(current);
990
+ } else if current == b'[' {
991
+ let mut start = index + 1;
992
+ while start < bytes.len() && bytes[start].is_ascii_whitespace() {
993
+ start += 1;
994
+ }
995
+ let mut cursor = start;
996
+ let mut explicit_namespace = false;
997
+ while cursor < bytes.len() {
998
+ let ch = bytes[cursor];
999
+ if ch == b'\\' {
1000
+ cursor += 2;
1001
+ continue;
1002
+ }
1003
+ if ch == b'|' && bytes.get(cursor + 1) != Some(&b'=') {
1004
+ explicit_namespace = true;
1005
+ break;
1006
+ }
1007
+ if ch == b']'
1008
+ || ch == b'='
1009
+ || ch.is_ascii_whitespace()
1010
+ || (matches!(ch, b'~' | b'^' | b'$' | b'*' | b'!' | b'|')
1011
+ && bytes.get(cursor + 1) == Some(&b'='))
1012
+ {
1013
+ break;
1014
+ }
1015
+ cursor += 1;
1016
+ }
1017
+ if !explicit_namespace && start < bytes.len() {
1018
+ insertions.push(start);
1019
+ }
1020
+ }
1021
+ index += 1;
1022
+ }
1023
+ if insertions.is_empty() {
1024
+ return Cow::Borrowed(source);
1025
+ }
1026
+ let mut normalized = String::with_capacity(source.len() + insertions.len() * 2);
1027
+ let mut previous = 0;
1028
+ for insertion in insertions {
1029
+ normalized.push_str(&source[previous..insertion]);
1030
+ normalized.push_str("*|");
1031
+ previous = insertion;
1032
+ }
1033
+ normalized.push_str(&source[previous..]);
1034
+ Cow::Owned(normalized)
1035
+ }
1036
+
1037
+ fn quote_doctype(value: &str) -> String {
1038
+ let quote = if value.contains('"') { '\'' } else { '"' };
1039
+ format!("{quote}{value}{quote}")
1040
+ }
1041
+
1042
+ fn json_error(error: serde_json::Error) -> Error {
1043
+ Error::new(Status::GenericFailure, error.to_string())
1044
+ }
1045
+
1046
+ fn escape_html(value: &str, attribute: bool, output: &mut String) {
1047
+ let mut start = 0;
1048
+ for (offset, ch) in value.char_indices() {
1049
+ let replacement = match ch {
1050
+ '&' => "&amp;",
1051
+ '\u{a0}' => "&nbsp;",
1052
+ '"' if attribute => "&quot;",
1053
+ '<' if !attribute => "&lt;",
1054
+ '>' if !attribute => "&gt;",
1055
+ _ => continue,
1056
+ };
1057
+ output.push_str(&value[start..offset]);
1058
+ output.push_str(replacement);
1059
+ start = offset + ch.len_utf8();
1060
+ }
1061
+ output.push_str(&value[start..]);
1062
+ }
1063
+
1064
+ fn is_void(element: &scraper::node::Element) -> bool {
1065
+ element.name.ns.as_ref() == HTML_NS && void_name(element.name())
1066
+ }
1067
+
1068
+ pub(crate) fn void_name(name: &str) -> bool {
1069
+ matches!(
1070
+ name,
1071
+ "area"
1072
+ | "base"
1073
+ | "basefont"
1074
+ | "bgsound"
1075
+ | "br"
1076
+ | "col"
1077
+ | "embed"
1078
+ | "frame"
1079
+ | "hr"
1080
+ | "img"
1081
+ | "input"
1082
+ | "keygen"
1083
+ | "link"
1084
+ | "meta"
1085
+ | "param"
1086
+ | "source"
1087
+ | "track"
1088
+ | "wbr"
1089
+ )
1090
+ }
1091
+
1092
+ fn serialize_node(node: NodeRef<'_, Node>, output: &mut String) {
1093
+ // Use the tree's iterative traversal so deeply nested untrusted HTML does
1094
+ // not consume the native stack during serialization.
1095
+ for edge in node.traverse() {
1096
+ let node = match edge {
1097
+ ego_tree::iter::Edge::Open(node) => node,
1098
+ ego_tree::iter::Edge::Close(node) => {
1099
+ if let Some(element) = node
1100
+ .value()
1101
+ .as_element()
1102
+ .filter(|element| !is_void(element))
1103
+ {
1104
+ output.push_str("</");
1105
+ output.push_str(element.name());
1106
+ output.push('>');
1107
+ }
1108
+ continue;
1109
+ }
1110
+ };
1111
+ match node.value() {
1112
+ Node::Document | Node::Fragment => {}
1113
+ Node::Text(text) => {
1114
+ let raw = node
1115
+ .parent()
1116
+ .and_then(|parent| parent.value().as_element())
1117
+ .is_some_and(|element| {
1118
+ element.name.ns.as_ref() == HTML_NS
1119
+ && matches!(
1120
+ element.name(),
1121
+ "script"
1122
+ | "style"
1123
+ | "xmp"
1124
+ | "iframe"
1125
+ | "noembed"
1126
+ | "noframes"
1127
+ | "plaintext"
1128
+ | "noscript"
1129
+ )
1130
+ });
1131
+ if raw {
1132
+ output.push_str(text);
1133
+ } else {
1134
+ escape_html(text, false, output);
1135
+ }
1136
+ }
1137
+ Node::Comment(comment) => {
1138
+ output.push_str("<!--");
1139
+ output.push_str(comment);
1140
+ output.push_str("-->");
1141
+ }
1142
+ Node::Doctype(doctype) => {
1143
+ output.push_str("<!DOCTYPE ");
1144
+ output.push_str(doctype.name());
1145
+ output.push('>');
1146
+ }
1147
+ Node::ProcessingInstruction(instruction) => {
1148
+ output.push_str("<?");
1149
+ output.push_str(&instruction.target);
1150
+ output.push(' ');
1151
+ output.push_str(&instruction.data);
1152
+ output.push_str("?>");
1153
+ }
1154
+ Node::Element(element) => {
1155
+ output.push('<');
1156
+ output.push_str(element.name());
1157
+ for (name, value) in &element.attrs {
1158
+ output.push(' ');
1159
+ if let Some(prefix) = &name.prefix {
1160
+ output.push_str(prefix);
1161
+ output.push(':');
1162
+ }
1163
+ output.push_str(&name.local);
1164
+ output.push_str("=\"");
1165
+ escape_html(value, true, output);
1166
+ output.push('"');
1167
+ }
1168
+ output.push('>');
1169
+ }
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ pub struct ParseTask {
1175
+ html: String,
1176
+ is_document: bool,
1177
+ options: Value,
1178
+ }
1179
+
1180
+ #[napi]
1181
+ impl Task for ParseTask {
1182
+ type Output = NativeDocument;
1183
+ type JsValue = NativeDocument;
1184
+
1185
+ fn compute(&mut self) -> Result<Self::Output> {
1186
+ let _batch = runtime::batch();
1187
+ let _permit = runtime::permit()?;
1188
+ Ok(NativeDocument::parse_options(
1189
+ &self.html,
1190
+ self.is_document,
1191
+ self.options.clone(),
1192
+ None,
1193
+ ))
1194
+ }
1195
+
1196
+ fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
1197
+ Ok(output)
1198
+ }
1199
+ }
1200
+
1201
+ #[napi]
1202
+ pub fn parse_async(
1203
+ html: String,
1204
+ is_document: bool,
1205
+ options_json: Option<String>,
1206
+ ) -> AsyncTask<ParseTask> {
1207
+ let options = options_json
1208
+ .and_then(|value| serde_json::from_str(&value).ok())
1209
+ .unwrap_or_else(|| serde_json::json!({}));
1210
+ AsyncTask::new(ParseTask {
1211
+ html,
1212
+ is_document,
1213
+ options,
1214
+ })
1215
+ }
1216
+
1217
+ fn thread_count(requested: Option<u32>, jobs: usize) -> Result<usize> {
1218
+ if matches!(requested, Some(0)) || requested.is_some_and(|threads| threads > 1024) {
1219
+ return Err(Error::new(
1220
+ Status::InvalidArg,
1221
+ "threads must be an integer between 1 and 1024",
1222
+ ));
1223
+ }
1224
+ let requested = requested
1225
+ .map(|n| n as usize)
1226
+ .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, usize::from));
1227
+ Ok(requested.min(jobs.max(1)))
1228
+ }
1229
+
1230
+ pub struct ParseManyTask {
1231
+ htmls: Vec<String>,
1232
+ is_document: bool,
1233
+ threads: usize,
1234
+ options: Value,
1235
+ }
1236
+
1237
+ #[napi]
1238
+ impl Task for ParseManyTask {
1239
+ type Output = Vec<NativeDocument>;
1240
+ type JsValue = Vec<NativeDocument>;
1241
+
1242
+ fn compute(&mut self) -> Result<Self::Output> {
1243
+ runtime::parallel_map(&self.htmls, self.threads, |html| {
1244
+ Ok(NativeDocument::parse_options(
1245
+ html,
1246
+ self.is_document,
1247
+ self.options.clone(),
1248
+ None,
1249
+ ))
1250
+ })
1251
+ }
1252
+
1253
+ fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
1254
+ Ok(output)
1255
+ }
1256
+ }
1257
+
1258
+ #[napi]
1259
+ pub fn parse_many_async(
1260
+ htmls: Vec<String>,
1261
+ is_document: bool,
1262
+ threads: Option<u32>,
1263
+ options_json: Option<String>,
1264
+ ) -> Result<AsyncTask<ParseManyTask>> {
1265
+ let threads = thread_count(threads, htmls.len())?;
1266
+ Ok(AsyncTask::new(ParseManyTask {
1267
+ htmls,
1268
+ is_document,
1269
+ threads,
1270
+ options: options_json
1271
+ .and_then(|value| serde_json::from_str(&value).ok())
1272
+ .unwrap_or_else(|| serde_json::json!({})),
1273
+ }))
1274
+ }
1275
+
1276
+ #[napi(object)]
1277
+ #[derive(Clone)]
1278
+ pub struct ExtractQuery {
1279
+ pub key: String,
1280
+ pub selector: String,
1281
+ pub attr: Option<String>,
1282
+ pub all: Option<bool>,
1283
+ }
1284
+
1285
+ pub struct ExtractManyTask {
1286
+ htmls: Vec<String>,
1287
+ queries: Vec<ExtractQuery>,
1288
+ threads: usize,
1289
+ is_document: bool,
1290
+ options: Value,
1291
+ }
1292
+
1293
+ #[napi]
1294
+ impl Task for ExtractManyTask {
1295
+ type Output = String;
1296
+ type JsValue = String;
1297
+
1298
+ fn compute(&mut self) -> Result<Self::Output> {
1299
+ // Validate before scheduling any documents. Each worker compiles its
1300
+ // selectors independently, because selector internals need not be Sync.
1301
+ let mut keys = HashSet::new();
1302
+ for query in &self.queries {
1303
+ // Full selector validation happens through the native selector
1304
+ // engine for each document, including jQuery-only pseudo classes.
1305
+ if !keys.insert(&query.key) {
1306
+ return Err(Error::new(
1307
+ Status::InvalidArg,
1308
+ format!("Duplicate extraction key {:?}", query.key),
1309
+ ));
1310
+ }
1311
+ }
1312
+ let records: Result<Vec<_>> = runtime::parallel_map(&self.htmls, self.threads, |html| {
1313
+ let document =
1314
+ NativeDocument::parse_options(html, self.is_document, self.options.clone(), None);
1315
+ let mut record = Map::new();
1316
+ for query in &self.queries {
1317
+ let ids = document.select_ids(&query.selector, None)?;
1318
+ let extract = |id| -> Option<Value> {
1319
+ match &query.attr {
1320
+ Some(attr) => document.attribute(id, attr).map(Value::String),
1321
+ None => Some(Value::String(document.text_ids(&[id]))),
1322
+ }
1323
+ };
1324
+ if query.all.unwrap_or(false) {
1325
+ // Cheerio's map().get() drops absent attribute values.
1326
+ record.insert(
1327
+ query.key.clone(),
1328
+ Value::Array(ids.into_iter().filter_map(extract).collect()),
1329
+ );
1330
+ } else if let Some(value) = ids.first().and_then(|id| extract(*id)) {
1331
+ record.insert(query.key.clone(), value);
1332
+ }
1333
+ }
1334
+ Ok(Value::Object(record))
1335
+ });
1336
+ serde_json::to_string(&records?).map_err(json_error)
1337
+ }
1338
+
1339
+ fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
1340
+ Ok(output)
1341
+ }
1342
+ }
1343
+
1344
+ #[napi]
1345
+ pub fn extract_many_async(
1346
+ htmls: Vec<String>,
1347
+ queries: Vec<ExtractQuery>,
1348
+ threads: Option<u32>,
1349
+ is_document: Option<bool>,
1350
+ options_json: Option<String>,
1351
+ ) -> Result<AsyncTask<ExtractManyTask>> {
1352
+ let threads = thread_count(threads, htmls.len())?;
1353
+ Ok(AsyncTask::new(ExtractManyTask {
1354
+ htmls,
1355
+ queries,
1356
+ threads,
1357
+ is_document: is_document.unwrap_or(true),
1358
+ options: options_json
1359
+ .and_then(|value| serde_json::from_str(&value).ok())
1360
+ .unwrap_or_else(|| serde_json::json!({})),
1361
+ }))
1362
+ }
1363
+
1364
+ #[napi]
1365
+ pub fn parse_with_tree_adapter(
1366
+ env: Env,
1367
+ source: String,
1368
+ is_document: bool,
1369
+ options_json: String,
1370
+ bridge: napi::JsFunction,
1371
+ ) -> Result<NativeDocument> {
1372
+ hook_sink::parse(env, source, is_document, options_json, bridge)
1373
+ }
1374
+
1375
+ pub struct ExtractPlanTask {
1376
+ htmls: Vec<String>,
1377
+ plan: Value,
1378
+ threads: usize,
1379
+ options: Value,
1380
+ }
1381
+ #[napi]
1382
+ impl Task for ExtractPlanTask {
1383
+ type Output = String;
1384
+ type JsValue = String;
1385
+ fn compute(&mut self) -> Result<String> {
1386
+ let rows = runtime::parallel_map(&self.htmls, self.threads, |html| {
1387
+ let document = NativeDocument::parse_options(html, true, self.options.clone(), None);
1388
+ extraction::execute(&document, &self.plan)
1389
+ })?;
1390
+ serde_json::to_string(&rows).map_err(json_error)
1391
+ }
1392
+ fn resolve(&mut self, _: Env, output: String) -> Result<String> {
1393
+ Ok(output)
1394
+ }
1395
+ }
1396
+ #[napi]
1397
+ pub fn extract_plan_many_async(
1398
+ htmls: Vec<String>,
1399
+ plan_json: String,
1400
+ threads: Option<u32>,
1401
+ options_json: Option<String>,
1402
+ ) -> Result<AsyncTask<ExtractPlanTask>> {
1403
+ let threads = thread_count(threads, htmls.len())?;
1404
+ let plan = serde_json::from_str(&plan_json).map_err(json_error)?;
1405
+ let options = options_json
1406
+ .map(|value| serde_json::from_str(&value))
1407
+ .transpose()
1408
+ .map_err(json_error)?
1409
+ .unwrap_or_else(|| serde_json::json!({}));
1410
+ Ok(AsyncTask::new(ExtractPlanTask {
1411
+ htmls,
1412
+ plan,
1413
+ threads,
1414
+ options,
1415
+ }))
1416
+ }
1417
+
1418
+ #[cfg(test)]
1419
+ mod tests {
1420
+ use super::*;
1421
+
1422
+ #[test]
1423
+ fn managed_detached_payloads_are_reclaimed_and_slots_are_reused() {
1424
+ let document = NativeDocument::parse("<main></main>", false);
1425
+ document.retain(vec![0]);
1426
+ let main = document.select_ids("main", None).unwrap()[0];
1427
+ document
1428
+ .op(
1429
+ "attr".into(),
1430
+ serde_json::json!({"ids":[main],"name":"a","value":"b"}).to_string(),
1431
+ )
1432
+ .unwrap();
1433
+ assert_eq!(document.collect_garbage().immutable_nodes, 0);
1434
+ assert!(document.ids.borrow().is_empty());
1435
+ assert!(document.reverse.borrow().is_empty());
1436
+ for _ in 0..1000 {
1437
+ let id = document
1438
+ .create("<p>temporary payload</p>".into(), None, None)
1439
+ .unwrap()[0];
1440
+ document.retain(vec![id]);
1441
+ document.release(vec![id]);
1442
+ let stats = document.collect_garbage();
1443
+ assert_eq!(stats.live, 2);
1444
+ assert!(stats.slots <= 2);
1445
+ }
1446
+ let held = document
1447
+ .create(
1448
+ "<section><b>held</b><i>sibling</i></section>".into(),
1449
+ None,
1450
+ None,
1451
+ )
1452
+ .unwrap()[0];
1453
+ let child = document.select_ids("b", Some(&[held])).unwrap()[0];
1454
+ document.retain(vec![child]);
1455
+ document.release(vec![0]);
1456
+ document.collect_garbage();
1457
+ assert_eq!(document.text_ids(&[held]), "heldsibling");
1458
+ assert!(document.arena.borrow().as_ref().unwrap().node(0).is_none());
1459
+ document.release(vec![child]);
1460
+ assert_eq!(document.collect_garbage().live, 0);
1461
+ }
1462
+
1463
+ #[test]
1464
+ fn adopting_pinned_nodes_transfers_ownership_without_retaining_source_payloads() {
1465
+ let source = NativeDocument::parse("<p><b>move</b></p><aside>stay</aside>", false);
1466
+ let target = NativeDocument::parse("<main></main>", false);
1467
+ source.retain(vec![0]);
1468
+ target.retain(vec![0]);
1469
+ let p = source.select_ids("p", None).unwrap()[0];
1470
+ let b = source.select_ids("b", None).unwrap()[0];
1471
+ source.retain(vec![b]);
1472
+ let result: Value =
1473
+ serde_json::from_str(&target.adopt(&source, vec![p], None).unwrap()).unwrap();
1474
+ let mapping: Vec<[u32; 2]> = serde_json::from_value(result["mapping"].clone()).unwrap();
1475
+ let moved_b = mapping.iter().find(|pair| pair[0] == b).unwrap()[1];
1476
+ source.collect_garbage();
1477
+ target.collect_garbage();
1478
+ assert_eq!(target.text_ids(&[moved_b]), "move");
1479
+ assert!(!source.retained.borrow().contains_key(&b));
1480
+ assert_eq!(target.retained.borrow().get(&moved_b), Some(&1));
1481
+ target.release(vec![moved_b]);
1482
+ target.collect_garbage();
1483
+ assert_eq!(target.collect_garbage().live, 2);
1484
+ }
1485
+
1486
+ #[test]
1487
+ fn text_comment_doctype_locations_include_entities_and_foster_parenting() {
1488
+ let source = "<!doctype html><p>A&amp;B<!--c-->😀</p><table>A<tr><td>B</td></tr>C</table>";
1489
+ let document = NativeDocument::parse_options(
1490
+ source,
1491
+ true,
1492
+ serde_json::json!({"sourceCodeLocationInfo":true}),
1493
+ None,
1494
+ );
1495
+ document.ensure_arena().unwrap();
1496
+ let arena = document.arena.borrow();
1497
+ let nodes = &arena.as_ref().unwrap().nodes;
1498
+ let location = |data: &str| {
1499
+ nodes
1500
+ .iter()
1501
+ .find(|node| node.data.as_deref() == Some(data))
1502
+ .unwrap()
1503
+ .extras
1504
+ .get("sourceCodeLocation")
1505
+ .cloned()
1506
+ .unwrap()
1507
+ };
1508
+ assert_eq!(location("A&B")["startOffset"], 18);
1509
+ assert_eq!(location("A&B")["endOffset"], 25);
1510
+ assert_eq!(location("c")["startOffset"], 25);
1511
+ assert_eq!(location("c")["endOffset"], 33);
1512
+ assert_eq!(location("😀")["startOffset"], 33);
1513
+ assert_eq!(location("😀")["endOffset"], 35);
1514
+ let foster = location("AC");
1515
+ assert_eq!(foster["startOffset"], 46);
1516
+ assert_eq!(foster["endOffset"], 67);
1517
+ let html = nodes
1518
+ .iter()
1519
+ .find(|node| node.name.as_deref() == Some("html"))
1520
+ .unwrap();
1521
+ assert_eq!(html.extras.get("sourceCodeLocation"), Some(&Value::Null));
1522
+ }
1523
+
1524
+ #[test]
1525
+ fn htmlparser2_cdata_and_xml_element_types_match_parser_options() {
1526
+ let xml = NativeDocument::parse_options(
1527
+ "<script>x</script><style>y</style>",
1528
+ true,
1529
+ serde_json::json!({"xmlMode":true}),
1530
+ None,
1531
+ );
1532
+ xml.ensure_arena().unwrap();
1533
+ assert!(xml
1534
+ .arena
1535
+ .borrow()
1536
+ .as_ref()
1537
+ .unwrap()
1538
+ .nodes
1539
+ .iter()
1540
+ .filter(|node| node.name.is_some())
1541
+ .all(|node| node.kind == "tag"));
1542
+ for (options, expected) in [
1543
+ (
1544
+ serde_json::json!({"_useHtmlParser2":true}),
1545
+ "<root><!--[CDATA[x<y]]--></root>",
1546
+ ),
1547
+ (
1548
+ serde_json::json!({"_useHtmlParser2":true,"recognizeCDATA":true}),
1549
+ "<root><![CDATA[x<y]]></root>",
1550
+ ),
1551
+ (
1552
+ serde_json::json!({"xmlMode":true}),
1553
+ "<root><![CDATA[x<y]]></root>",
1554
+ ),
1555
+ ] {
1556
+ assert_eq!(
1557
+ NativeDocument::parse_options("<root><![CDATA[x<y]]></root>", true, options, None)
1558
+ .outer_html(vec![0]),
1559
+ expected
1560
+ );
1561
+ }
1562
+ }
1563
+
1564
+ #[test]
1565
+ fn live_token_locations_do_not_reuse_ignored_identical_whitespace() {
1566
+ let source =
1567
+ "\n<html>\n<head></head>\n<body>\n<p a=one b=\"two\" c>Text</p>\n</body></html>";
1568
+ let document = NativeDocument::parse_options(
1569
+ source,
1570
+ true,
1571
+ serde_json::json!({"sourceCodeLocationInfo":true}),
1572
+ None,
1573
+ );
1574
+ document.ensure_arena().unwrap();
1575
+ let arena = document.arena.borrow();
1576
+ let arena = arena.as_ref().unwrap();
1577
+ let body = arena
1578
+ .nodes
1579
+ .iter()
1580
+ .find(|node| node.name.as_deref() == Some("body"))
1581
+ .unwrap();
1582
+ let first = arena.node(body.children[0]).unwrap();
1583
+ assert_eq!(first.data.as_deref(), Some("\n"));
1584
+ assert_eq!(first.extras["sourceCodeLocation"]["startOffset"], 28);
1585
+ let p = arena
1586
+ .nodes
1587
+ .iter()
1588
+ .find(|node| node.name.as_deref() == Some("p"))
1589
+ .unwrap();
1590
+ assert_eq!(
1591
+ p.extras["sourceCodeLocation"]["attrs"]["a"]["startOffset"],
1592
+ 32
1593
+ );
1594
+ assert_eq!(
1595
+ p.extras["sourceCodeLocation"]["attrs"]["a"]["endOffset"],
1596
+ 37
1597
+ );
1598
+ }
1599
+
1600
+ #[test]
1601
+ fn reconstructed_formatting_and_template_fragments_keep_native_source_locations() {
1602
+ let document = NativeDocument::parse_options(
1603
+ "<p><b>A</p>B",
1604
+ true,
1605
+ serde_json::json!({"sourceCodeLocationInfo":true}),
1606
+ None,
1607
+ );
1608
+ document.ensure_arena().unwrap();
1609
+ let records = document.arena.borrow();
1610
+ let locations: Vec<_> = records
1611
+ .as_ref()
1612
+ .unwrap()
1613
+ .nodes
1614
+ .iter()
1615
+ .filter(|node| node.name.as_deref() == Some("b"))
1616
+ .map(|node| node.extras["sourceCodeLocation"].clone())
1617
+ .collect();
1618
+ assert_eq!(locations.len(), 2);
1619
+ assert_eq!(locations[0]["startOffset"], 3);
1620
+ assert_eq!(locations[0]["endOffset"], 7);
1621
+ assert_eq!(locations[1]["startOffset"], 3);
1622
+ assert_eq!(locations[1]["endOffset"], 12);
1623
+ let template = NativeDocument::parse_options(
1624
+ "<template><p>T</template>",
1625
+ true,
1626
+ serde_json::json!({"sourceCodeLocationInfo":true}),
1627
+ None,
1628
+ );
1629
+ template.ensure_arena().unwrap();
1630
+ let records = template.arena.borrow();
1631
+ let fragment = records
1632
+ .as_ref()
1633
+ .unwrap()
1634
+ .nodes
1635
+ .iter()
1636
+ .find(|node| node.kind == "root" && node.parent.is_some())
1637
+ .unwrap();
1638
+ assert_eq!(
1639
+ fragment.extras.get("sourceCodeLocation"),
1640
+ Some(&Value::Null)
1641
+ );
1642
+ }
1643
+
1644
+ #[test]
1645
+ fn fragment_root_and_html_serialization() {
1646
+ let document = NativeDocument::parse("hello &amp; <b z='x' a='y'>world</b>", false);
1647
+ assert_eq!(document.element_ids().len(), 1);
1648
+ assert_eq!(
1649
+ document.outer_html(vec![0]),
1650
+ "hello &amp; <b z=\"x\" a=\"y\">world</b>"
1651
+ );
1652
+ assert_eq!(document.text(vec![0]), "hello & world");
1653
+ let table_fragment = NativeDocument::parse("<td>A</td><td>B</td>", false);
1654
+ assert_eq!(table_fragment.outer_html(vec![0]), "<td>A</td><td>B</td>");
1655
+ }
1656
+
1657
+ #[test]
1658
+ fn raw_text_and_html5_recovery() {
1659
+ let document = NativeDocument::parse(
1660
+ "<title>A &amp; B</title><script>1 < 2 && 3 > 0</script><p>one<p>two",
1661
+ true,
1662
+ );
1663
+ assert_eq!(document.select_ids("p", None).unwrap().len(), 2);
1664
+ let script = document.select_ids("script", None).unwrap();
1665
+ assert_eq!(document.text_ids(&script), "1 < 2 && 3 > 0");
1666
+ assert_eq!(document.inner_html(script[0]), "1 < 2 && 3 > 0");
1667
+ }
1668
+
1669
+ #[test]
1670
+ fn selector_contexts_and_document_order() {
1671
+ let document = NativeDocument::parse(
1672
+ "<table>outside<tr><td>A</table><div><p class='x'>B</p><p>C</p></div>",
1673
+ false,
1674
+ );
1675
+ let div = document.select_ids("div", None).unwrap();
1676
+ assert_eq!(document.select_ids("p", Some(&div)).unwrap().len(), 2);
1677
+ assert_eq!(document.select_ids("div", Some(&div)).unwrap().len(), 0);
1678
+ let nodes = document.select_ids("*", None).unwrap();
1679
+ assert!(nodes.windows(2).all(|pair| pair[0] < pair[1]));
1680
+ }
1681
+
1682
+ #[test]
1683
+ fn export_includes_template_fragments_and_doctype() {
1684
+ let document = NativeDocument::parse("<!DOCTYPE html><template><p>x</p></template>", true);
1685
+ let records: Value = serde_json::from_str(&document.export_tree().unwrap()).unwrap();
1686
+ assert_eq!(records[1]["x-name"], "html");
1687
+ let template = document.select_ids("template", None).unwrap()[0];
1688
+ let fragment = records[template as usize]["children"][0].as_u64().unwrap() as usize;
1689
+ assert_eq!(records[fragment]["type"], "root");
1690
+ assert_eq!(document.select_ids("p", None).unwrap().len(), 1);
1691
+ assert_eq!(
1692
+ document.select_ids("p", Some(&[template])).unwrap().len(),
1693
+ 0
1694
+ );
1695
+ }
1696
+
1697
+ #[test]
1698
+ fn native_documents_can_move_to_worker_threads() {
1699
+ fn assert_send<T: Send>() {}
1700
+ assert_send::<NativeDocument>();
1701
+ }
1702
+
1703
+ #[test]
1704
+ fn unicode_bom_and_mathml_integration_points() {
1705
+ for is_document in [true, false] {
1706
+ let document = NativeDocument::parse("\u{feff}<h1>UTF8</h1>", is_document);
1707
+ assert_eq!(document.text(vec![0]), "\u{feff}UTF8");
1708
+ let document = NativeDocument::parse("<math><annotation-xml encoding='text/html'><div>HTML</div></annotation-xml></math>", is_document);
1709
+ assert_eq!(
1710
+ document
1711
+ .select_ids("annotation-xml > div", None)
1712
+ .unwrap()
1713
+ .len(),
1714
+ 1
1715
+ );
1716
+ assert!(document.outer_html(vec![0]).contains(
1717
+ "<annotation-xml encoding=\"text/html\"><div>HTML</div></annotation-xml>"
1718
+ ));
1719
+ }
1720
+ }
1721
+
1722
+ #[test]
1723
+ fn foreign_attributes_match_the_cheerio_dom_adapter() {
1724
+ for (attrs, expected_html, expected_href) in [
1725
+ (
1726
+ "href='plain' xlink:href='linked'",
1727
+ "xlink:href=\"linked\"",
1728
+ "linked",
1729
+ ),
1730
+ (
1731
+ "xlink:href='linked' href='plain'",
1732
+ "href=\"plain\"",
1733
+ "plain",
1734
+ ),
1735
+ ] {
1736
+ let document = NativeDocument::parse(&format!("<svg><a {attrs}>x</a></svg>"), false);
1737
+ let ids = document.select_ids("[href]", None).unwrap();
1738
+ assert_eq!(ids.len(), 1);
1739
+ assert_eq!(
1740
+ document.attribute(ids[0], "href").as_deref(),
1741
+ Some(expected_href)
1742
+ );
1743
+ assert_eq!(
1744
+ document.outer_html(vec![0]),
1745
+ format!("<svg><a {expected_html}>x</a></svg>")
1746
+ );
1747
+ let mut task = ExtractManyTask {
1748
+ htmls: vec![format!("<svg><a {attrs}>x</a></svg>")],
1749
+ queries: vec![ExtractQuery {
1750
+ key: "href".into(),
1751
+ selector: "[href]".into(),
1752
+ attr: Some("href".into()),
1753
+ all: None,
1754
+ }],
1755
+ threads: 1,
1756
+ is_document: true,
1757
+ options: serde_json::json!({}),
1758
+ };
1759
+ let result: Value = serde_json::from_str(&task.compute().unwrap()).unwrap();
1760
+ assert_eq!(result[0]["href"], expected_href);
1761
+ }
1762
+ }
1763
+
1764
+ #[test]
1765
+ fn attribute_namespace_scanner_respects_css_syntax() {
1766
+ for (source, expected) in [
1767
+ ("a[href][lang|=en]", "a[*|href][*|lang|=en]"),
1768
+ (
1769
+ "a[*|href][xlink|href][|href]",
1770
+ "a[*|href][xlink|href][|href]",
1771
+ ),
1772
+ (
1773
+ "[title='[quoted]'][data-x=\"x\\\"[y]\"]",
1774
+ "[*|title='[quoted]'][*|data-x=\"x\\\"[y]\"]",
1775
+ ),
1776
+ ("#a\\[b[data\\|name]", "#a\\[b[*|data\\|name]"),
1777
+ ("[ href = 'x' ]", "[ *|href = 'x' ]"),
1778
+ ] {
1779
+ assert_eq!(normalize_attribute_namespaces(source), expected);
1780
+ }
1781
+ }
1782
+
1783
+ #[test]
1784
+ fn export_document_mode_omits_fragment_metadata() {
1785
+ for (source, mode) in [
1786
+ ("<p>x", "quirks"),
1787
+ ("<!doctype html><p>x", "no-quirks"),
1788
+ ("<!doctype html public '-//W3C//DTD XHTML 1.0 Transitional//EN' 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd'><p>x", "limited-quirks"),
1789
+ ] {
1790
+ let document: Value = serde_json::from_str(&NativeDocument::parse(source, true).export_tree().unwrap()).unwrap();
1791
+ assert_eq!(document[0]["x-mode"], mode);
1792
+ let fragment: Value = serde_json::from_str(&NativeDocument::parse(source, false).export_tree().unwrap()).unwrap();
1793
+ assert!(fragment[0].get("x-mode").is_none());
1794
+ }
1795
+ }
1796
+
1797
+ #[test]
1798
+ fn native_mutations_preserve_ids_and_query_live_tree() {
1799
+ let document = NativeDocument::parse("<ul><li>A</li><li>B</li></ul>", false);
1800
+ let ul = document.select_ids("ul", None).unwrap()[0];
1801
+ let first = document.select_ids("li", None).unwrap()[0];
1802
+ document
1803
+ .op(
1804
+ "text".into(),
1805
+ serde_json::json!({"ids":[first],"value":"changed"}).to_string(),
1806
+ )
1807
+ .unwrap();
1808
+ assert_eq!(document.text_ids(&[first]), "changed");
1809
+ let added = document
1810
+ .create("<li>C</li>".into(), Some(ul), None)
1811
+ .unwrap();
1812
+ document
1813
+ .op(
1814
+ "insert".into(),
1815
+ serde_json::json!({"targetIds":[ul],"nodeIds":added,"position":"append"})
1816
+ .to_string(),
1817
+ )
1818
+ .unwrap();
1819
+ assert_eq!(document.select_ids("li", None).unwrap().len(), 3);
1820
+ assert_eq!(document.select_ids("li:first", None).unwrap(), vec![first]);
1821
+ assert_eq!(
1822
+ document.outer_html(vec![ul]),
1823
+ "<ul><li>changed</li><li>B</li><li>C</li></ul>"
1824
+ );
1825
+ document
1826
+ .op(
1827
+ "insert".into(),
1828
+ serde_json::json!({"targetIds":[first],"nodeIds":[first],"position":"replace"})
1829
+ .to_string(),
1830
+ )
1831
+ .unwrap();
1832
+ assert_eq!(document.select_ids("li", None).unwrap().len(), 3);
1833
+ let detached = document
1834
+ .create("<div>detached</div>".into(), None, None)
1835
+ .unwrap()[0];
1836
+ let sorted = document
1837
+ .op(
1838
+ "uniqueSort".into(),
1839
+ serde_json::json!({"ids":[detached,ul,detached]}).to_string(),
1840
+ )
1841
+ .unwrap();
1842
+ assert_eq!(
1843
+ serde_json::from_str::<Vec<u32>>(&sorted).unwrap(),
1844
+ vec![detached, ul]
1845
+ );
1846
+ }
1847
+
1848
+ #[test]
1849
+ fn batched_moves_preserve_sibling_order_and_last_duplicate_position() {
1850
+ let document = NativeDocument::parse(
1851
+ "<div><i id='a'></i><i id='b'></i><i id='c'></i></div>",
1852
+ false,
1853
+ );
1854
+ let parent = document.select_ids("div", None).unwrap()[0];
1855
+ let ids = document.select_ids("i", None).unwrap();
1856
+ let (a, b, c) = (ids[0], ids[1], ids[2]);
1857
+ document
1858
+ .op(
1859
+ "insert".into(),
1860
+ serde_json::json!({"targetIds":[b],"nodeIds":[c,a],"position":"before"})
1861
+ .to_string(),
1862
+ )
1863
+ .unwrap();
1864
+ assert_eq!(document.select_ids("i", None).unwrap(), vec![c, a, b]);
1865
+ document
1866
+ .op(
1867
+ "insert".into(),
1868
+ serde_json::json!({"targetIds":[parent],"nodeIds":[c,b,c],"position":"append"})
1869
+ .to_string(),
1870
+ )
1871
+ .unwrap();
1872
+ assert_eq!(document.select_ids("i", None).unwrap(), vec![a, b, c]);
1873
+ document
1874
+ .op(
1875
+ "remove".into(),
1876
+ serde_json::json!({"ids":[a,c]}).to_string(),
1877
+ )
1878
+ .unwrap();
1879
+ let node: Value = serde_json::from_str(&document.node(b).unwrap()).unwrap();
1880
+ assert!(node["prev"].is_null());
1881
+ assert!(node["next"].is_null());
1882
+ assert_eq!(document.select_ids("i", None).unwrap(), vec![b]);
1883
+ }
1884
+
1885
+ #[test]
1886
+ fn jquery_numeric_ids_recover_from_browser_css_parse_restrictions() {
1887
+ let document = NativeDocument::parse("<a id='1'>First</a><a id='2'>Second</a>", false);
1888
+ let selected = document.select_ids("#1", None).unwrap();
1889
+ assert_eq!(selected.len(), 1);
1890
+ assert_eq!(document.text_ids(&selected), "First");
1891
+ }
1892
+
1893
+ #[test]
1894
+ fn cloning_nodes_drops_api_data_caches() {
1895
+ let document = NativeDocument::parse("<p data-from-attribute='value'>x</p>", false);
1896
+ let id = document.select_ids("p", None).unwrap()[0];
1897
+ document
1898
+ .op(
1899
+ "data".into(),
1900
+ serde_json::json!({"ids":[id],"name":"cacheOnly","value":"cached"}).to_string(),
1901
+ )
1902
+ .unwrap();
1903
+ let clone: Vec<u32> = serde_json::from_str(
1904
+ &document
1905
+ .op("clone".into(), serde_json::json!({"ids":[id]}).to_string())
1906
+ .unwrap(),
1907
+ )
1908
+ .unwrap();
1909
+ let data: Value = serde_json::from_str(
1910
+ &document
1911
+ .op(
1912
+ "data".into(),
1913
+ serde_json::json!({"ids":clone,"name":"cacheOnly"}).to_string(),
1914
+ )
1915
+ .unwrap(),
1916
+ )
1917
+ .unwrap();
1918
+ assert_eq!(data["found"], false);
1919
+ }
1920
+
1921
+ #[test]
1922
+ fn adopting_branches_between_native_documents_detaches_the_source() {
1923
+ let target = NativeDocument::parse("<div></div>", false);
1924
+ let source = NativeDocument::parse("<p><b>moved</b></p><span>kept</span>", false);
1925
+ let id = source.select_ids("p", None).unwrap()[0];
1926
+ source
1927
+ .op(
1928
+ "data".into(),
1929
+ serde_json::json!({"ids":[id],"name":"cache","value":{"preserved":true}})
1930
+ .to_string(),
1931
+ )
1932
+ .unwrap();
1933
+ let adopted: Value =
1934
+ serde_json::from_str(&target.adopt(&source, vec![id], None).unwrap()).unwrap();
1935
+ let roots = arena::value_ids(&adopted["roots"]);
1936
+ let div = target.select_ids("div", None).unwrap()[0];
1937
+ target
1938
+ .op(
1939
+ "insert".into(),
1940
+ serde_json::json!({"targetIds":[div],"nodeIds":roots,"position":"append"})
1941
+ .to_string(),
1942
+ )
1943
+ .unwrap();
1944
+ assert_eq!(source.outer_html(vec![0]), "<span>kept</span>");
1945
+ assert_eq!(target.outer_html(vec![0]), "<div><p><b>moved</b></p></div>");
1946
+ assert_eq!(adopted["mapping"].as_array().unwrap().len(), 3);
1947
+ let data: Value = serde_json::from_str(
1948
+ &target
1949
+ .op(
1950
+ "data".into(),
1951
+ serde_json::json!({"ids":roots,"name":"cache"}).to_string(),
1952
+ )
1953
+ .unwrap(),
1954
+ )
1955
+ .unwrap();
1956
+ assert_eq!(data["value"]["preserved"], true);
1957
+ let source = NativeDocument::parse("<p><b>nested</b></p>", false);
1958
+ let ids = source.select_ids("p,b", None).unwrap();
1959
+ let adopted: Value =
1960
+ serde_json::from_str(&target.adopt(&source, ids, None).unwrap()).unwrap();
1961
+ let roots = arena::value_ids(&adopted["roots"]);
1962
+ assert_eq!(target.outer_html(roots), "<p></p><b>nested</b>");
1963
+ assert_eq!(adopted["mapping"].as_array().unwrap().len(), 3);
1964
+ let source = NativeDocument::parse("<em>copied</em>", false);
1965
+ let id = source.select_ids("em", None).unwrap()[0];
1966
+ target.adopt(&source, vec![id], Some(false)).unwrap();
1967
+ assert_eq!(source.outer_html(vec![0]), "<em>copied</em>");
1968
+ let xml = NativeDocument::parse_options(
1969
+ "<Item ID='case'>XML</Item>",
1970
+ true,
1971
+ serde_json::json!({"xml":true}),
1972
+ None,
1973
+ );
1974
+ let ids = xml.select_ids("Item", None).unwrap();
1975
+ let adopted: Value = serde_json::from_str(&target.adopt(&xml, ids, None).unwrap()).unwrap();
1976
+ let roots = arena::value_ids(&adopted["roots"]);
1977
+ let matched: Vec<u32> = serde_json::from_str(
1978
+ &target
1979
+ .op(
1980
+ "matches".into(),
1981
+ serde_json::json!({"ids":roots,"selector":"Item","options":{"xmlMode":true}})
1982
+ .to_string(),
1983
+ )
1984
+ .unwrap(),
1985
+ )
1986
+ .unwrap();
1987
+ assert_eq!(matched.len(), 1);
1988
+ }
1989
+
1990
+ #[test]
1991
+ fn per_operation_selection_options_restore_after_success_and_errors() {
1992
+ let document = NativeDocument::parse("<input checked><a href='/path'>link</a>", false);
1993
+ let input = document.select_ids("input", None).unwrap()[0];
1994
+ let xml = document
1995
+ .op(
1996
+ "attr".into(),
1997
+ serde_json::json!({"ids":[input],"name":"checked","options":{"xmlMode":true}})
1998
+ .to_string(),
1999
+ )
2000
+ .unwrap();
2001
+ assert_eq!(xml, "\"\"");
2002
+ assert!(document
2003
+ .op(
2004
+ "matches".into(),
2005
+ serde_json::json!({"ids":[input],"selector":":unknown","options":{"xmlMode":true}})
2006
+ .to_string()
2007
+ )
2008
+ .is_err());
2009
+ let html = document
2010
+ .op(
2011
+ "attr".into(),
2012
+ serde_json::json!({"ids":[input],"name":"checked"}).to_string(),
2013
+ )
2014
+ .unwrap();
2015
+ assert_eq!(html, "\"checked\"");
2016
+ }
2017
+
2018
+ #[test]
2019
+ fn native_xml_preserves_case_cdata_and_decoding_options() {
2020
+ let document = NativeDocument::parse_options(
2021
+ "<?xml version='1.0'?><Root><Item ID='x'>&amp;<![CDATA[a<b]]></Item><Empty/></Root>",
2022
+ true,
2023
+ serde_json::json!({"xml":true}),
2024
+ None,
2025
+ );
2026
+ let item = document.select_ids("Item", None).unwrap();
2027
+ assert_eq!(item.len(), 1);
2028
+ assert_eq!(document.attribute(item[0], "ID").as_deref(), Some("x"));
2029
+ assert_eq!(document.text_ids(&item), "&a<b");
2030
+ assert_eq!(document.select_ids("item", None).unwrap().len(), 0);
2031
+ assert!(document
2032
+ .outer_html(vec![0])
2033
+ .contains("<Item ID=\"x\">&amp;<![CDATA[a<b]]></Item><Empty/>"));
2034
+ let raw = NativeDocument::parse_options(
2035
+ "<tag>&amp;</tag>",
2036
+ true,
2037
+ serde_json::json!({"xml":{"decodeEntities":false}}),
2038
+ None,
2039
+ );
2040
+ assert_eq!(raw.text_ids(&[0]), "&amp;");
2041
+ assert_eq!(raw.outer_html(vec![0]), "<tag>&amp;</tag>");
2042
+ }
2043
+
2044
+ #[test]
2045
+ fn htmlparser_mode_preserves_raw_text_and_implied_closing_rules() {
2046
+ for (source, expected) in [
2047
+ (
2048
+ "<script>if(a < b) value = \"&amp;\";</script>",
2049
+ "<script>if(a < b) value = \"&amp;\";</script>",
2050
+ ),
2051
+ ("<p>A<p>B", "<p>A</p><p>B</p>"),
2052
+ (
2053
+ "<textarea>&lt;b&gt; &amp;</textarea>",
2054
+ "<textarea>&amp;lt;b&amp;gt; &amp;amp;</textarea>",
2055
+ ),
2056
+ ("<div/>A", "<div>A</div>"),
2057
+ (
2058
+ "<title>&lt;b&gt; &amp;</title>",
2059
+ "<title>&lt;b&gt; &amp;</title>",
2060
+ ),
2061
+ ("a < 2", "a &lt; 2"),
2062
+ ] {
2063
+ let document = NativeDocument::parse_options(
2064
+ source,
2065
+ true,
2066
+ serde_json::json!({"xml":{"xmlMode":false}}),
2067
+ None,
2068
+ );
2069
+ assert_eq!(document.outer_html(vec![0]), expected, "{source}");
2070
+ }
2071
+ let document = NativeDocument::parse_options(
2072
+ "<div/>A",
2073
+ true,
2074
+ serde_json::json!({"xml":{"xmlMode":false,"recognizeSelfClosing":true}}),
2075
+ None,
2076
+ );
2077
+ assert_eq!(document.outer_html(vec![0]), "<div></div>A");
2078
+ }
2079
+
2080
+ #[test]
2081
+ fn native_html_source_locations_use_utf16_offsets() {
2082
+ let source = "<div id='x'>😀<span>x</span></div>";
2083
+ let document = NativeDocument::parse_options(
2084
+ source,
2085
+ false,
2086
+ serde_json::json!({"sourceCodeLocationInfo":true}),
2087
+ None,
2088
+ );
2089
+ let span = document.select_ids("span", None).unwrap()[0];
2090
+ let node: Value = serde_json::from_str(&document.node(span).unwrap()).unwrap();
2091
+ assert_eq!(node["sourceCodeLocation"]["startOffset"], 14);
2092
+ assert_eq!(node["sourceCodeLocation"]["endOffset"], 28);
2093
+ assert_eq!(node["sourceCodeLocation"]["startTag"]["endOffset"], 20);
2094
+ }
2095
+ }