rusteerio 0.1.1 → 1.0.1

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.
@@ -10,6 +10,9 @@ pub(crate) fn has_encoding(value: &str) -> bool {
10
10
  value.chars().any(is_reserved)
11
11
  }
12
12
  pub(crate) fn from_units(units: &[u16]) -> String {
13
+ if units.iter().all(|unit| !(0xd800..=0xe800).contains(unit)) {
14
+ return String::from_utf16_lossy(units);
15
+ }
13
16
  let mut output = String::with_capacity(units.len());
14
17
  let mut index = 0;
15
18
  while index < units.len() {
@@ -38,28 +41,69 @@ pub(crate) fn from_units(units: &[u16]) -> String {
38
41
  }
39
42
  output
40
43
  }
41
- pub(crate) fn to_units(value: &str) -> Vec<u16> {
42
- let mut output = Vec::with_capacity(value.len());
43
- let mut chars = value.chars().peekable();
44
- while let Some(ch) = chars.next() {
45
- if ch == ESCAPE && chars.peek().copied().is_some_and(is_reserved) {
46
- output.push(chars.next().unwrap() as u16);
47
- } else if ('\u{e000}'..'\u{e800}').contains(&ch) {
48
- output.push(0xd800 + (ch as u16 - 0xe000));
49
- } else {
50
- let mut encoded = [0; 2];
51
- output.extend_from_slice(ch.encode_utf16(&mut encoded));
52
- }
44
+ /// Receives the UTF-16 units of a string from [`visit_units`].
45
+ pub(crate) trait UnitSink {
46
+ /// A run of ASCII bytes starting at byte offset `start`, one unit each.
47
+ fn ascii(&mut self, start: usize, run: &[u8]);
48
+ /// One unit and the byte offset of the character that starts there, or
49
+ /// `None` for the low half of a surrogate pair.
50
+ fn unit(&mut self, offset: Option<usize>, unit: u16);
51
+ }
52
+
53
+ impl UnitSink for Vec<u16> {
54
+ fn ascii(&mut self, _: usize, run: &[u8]) {
55
+ self.extend(run.iter().map(|value| u16::from(*value)));
56
+ }
57
+ fn unit(&mut self, _: Option<usize>, unit: u16) {
58
+ self.push(unit);
53
59
  }
54
- output
55
60
  }
56
- pub(crate) fn logical_len(value: &str) -> usize {
57
- if has_encoding(value) {
58
- to_units(value).len()
59
- } else {
60
- value.encode_utf16().count()
61
+
62
+ /// Feeds the UTF-16 units of `value` to `sink`. With `framed`, lossless frames
63
+ /// decode to their original units.
64
+ pub(crate) fn visit_units(value: &str, framed: bool, sink: &mut impl UnitSink) {
65
+ let bytes = value.as_bytes();
66
+ let mut byte = 0;
67
+ while byte < bytes.len() {
68
+ let ascii = bytes[byte..]
69
+ .iter()
70
+ .position(|value| !value.is_ascii())
71
+ .unwrap_or(bytes.len() - byte);
72
+ sink.ascii(byte, &bytes[byte..byte + ascii]);
73
+ byte += ascii;
74
+ let Some(ch) = value[byte..].chars().next() else {
75
+ break;
76
+ };
77
+ let start = byte;
78
+ byte += ch.len_utf8();
79
+ if framed && ch == ESCAPE {
80
+ if let Some(next) = value[byte..]
81
+ .chars()
82
+ .next()
83
+ .filter(|next| is_reserved(*next))
84
+ {
85
+ sink.unit(Some(start), next as u16);
86
+ byte += next.len_utf8();
87
+ continue;
88
+ }
89
+ }
90
+ if framed && ('\u{e000}'..ESCAPE).contains(&ch) {
91
+ sink.unit(Some(start), 0xd800 + (ch as u16 - 0xe000));
92
+ continue;
93
+ }
94
+ let mut encoded = [0; 2];
95
+ let encoded = ch.encode_utf16(&mut encoded);
96
+ sink.unit(Some(start), encoded[0]);
97
+ if let Some(low) = encoded.get(1) {
98
+ sink.unit(None, *low);
99
+ }
61
100
  }
62
101
  }
102
+ pub(crate) fn to_units(value: &str) -> Vec<u16> {
103
+ let mut output = Vec::with_capacity(value.len());
104
+ visit_units(value, has_encoding(value), &mut output);
105
+ output
106
+ }
63
107
  pub(crate) fn to_usv(value: &str) -> String {
64
108
  if has_encoding(value) {
65
109
  String::from_utf16_lossy(&to_units(value))
@@ -1,7 +1,7 @@
1
1
  //! An active user-supplied tree adapter. Rust runs the HTML5 algorithm; the
2
2
  //! adapter supplies every construction, mutation and tree-inspection decision.
3
3
  use std::borrow::Cow;
4
- use std::cell::{Ref, RefCell};
4
+ use std::cell::{Cell, Ref, RefCell};
5
5
  use std::collections::HashMap;
6
6
  use std::rc::Rc;
7
7
 
@@ -196,6 +196,26 @@ struct HookSink {
196
196
  context: Option<Rc<crate::diagnostics::DiagnosticContext>>,
197
197
  diagnostics: RefCell<Vec<Value>>,
198
198
  locations: Option<crate::tracker::Locations<u32>>,
199
+ pending_escape: Cell<Option<TextTarget>>,
200
+ }
201
+
202
+ /// Where a text chunk is inserted: appended to `parent`, or before `sibling`.
203
+ #[derive(Clone, Copy, PartialEq, Eq)]
204
+ struct TextTarget {
205
+ parent: u32,
206
+ sibling: Option<u32>,
207
+ }
208
+
209
+ /// Whether the final character is a codec escape whose character has not
210
+ /// arrived yet. Escapes pair with the following character.
211
+ fn ends_with_unpaired_escape(text: &str) -> bool {
212
+ let mut chars = text.chars();
213
+ while let Some(ch) = chars.next() {
214
+ if ch == crate::codec::ESCAPE && chars.next().is_none() {
215
+ return true;
216
+ }
217
+ }
218
+ false
199
219
  }
200
220
 
201
221
  impl HookSink {
@@ -203,6 +223,17 @@ impl HookSink {
203
223
  if self.error.borrow().is_some() {
204
224
  return Value::Null;
205
225
  }
226
+ if matches!(
227
+ method,
228
+ "appendChild"
229
+ | "insertBefore"
230
+ | "detachNode"
231
+ | "setDocumentType"
232
+ | "setTemplateContent"
233
+ | "__snapshot"
234
+ ) {
235
+ self.flush_escape();
236
+ }
206
237
  let result = self.bridge.call(method, args);
207
238
  match result {
208
239
  Ok(value) => value,
@@ -215,6 +246,38 @@ impl HookSink {
215
246
  fn node(&self, method: &str, args: Value) -> u32 {
216
247
  self.call(method, args).as_u64().unwrap_or(0) as u32
217
248
  }
249
+ /// The tokenizer may split a codec escape from its character across text
250
+ /// chunks. Adapters decode each chunk separately, so a trailing escape is
251
+ /// held until the next chunk for the same target.
252
+ fn insert_text(&self, target: TextTarget, text: &str) {
253
+ let mut data = String::with_capacity(text.len() + crate::codec::ESCAPE.len_utf8());
254
+ if let Some(pending) = self.pending_escape.take() {
255
+ if pending == target {
256
+ data.push(crate::codec::ESCAPE);
257
+ } else {
258
+ self.send_text(pending, &crate::codec::ESCAPE.to_string());
259
+ }
260
+ }
261
+ data.push_str(text);
262
+ if ends_with_unpaired_escape(&data) {
263
+ data.pop();
264
+ self.pending_escape.set(Some(target));
265
+ }
266
+ if !data.is_empty() {
267
+ self.send_text(target, &data);
268
+ }
269
+ }
270
+ fn send_text(&self, target: TextTarget, data: &str) {
271
+ match target.sibling {
272
+ None => self.call("insertText", json!([target.parent, data])),
273
+ Some(sibling) => self.call("insertTextBefore", json!([target.parent, data, sibling])),
274
+ };
275
+ }
276
+ fn flush_escape(&self) {
277
+ if let Some(pending) = self.pending_escape.take() {
278
+ self.send_text(pending, &crate::codec::ESCAPE.to_string());
279
+ }
280
+ }
218
281
  fn attrs(attrs: Vec<Attribute>) -> Value {
219
282
  Value::Array(
220
283
  attrs
@@ -463,7 +526,11 @@ impl TreeSink for HookSink {
463
526
  }
464
527
  }
465
528
  NodeOrText::AppendText(text) => {
466
- self.call("insertText", json!([parent, text.as_ref()]));
529
+ let target = TextTarget {
530
+ parent: *parent,
531
+ sibling: None,
532
+ };
533
+ self.insert_text(target, text.as_ref());
467
534
  self.mark_text(*parent, None, text.as_ref());
468
535
  }
469
536
  }
@@ -530,7 +597,11 @@ impl TreeSink for HookSink {
530
597
  self.call("insertBefore", json!([parent, node, sibling]));
531
598
  }
532
599
  NodeOrText::AppendText(text) => {
533
- self.call("insertTextBefore", json!([parent, text.as_ref(), sibling]));
600
+ let target = TextTarget {
601
+ parent,
602
+ sibling: Some(*sibling),
603
+ };
604
+ self.insert_text(target, text.as_ref());
534
605
  self.mark_text(parent, Some(*sibling), text.as_ref());
535
606
  }
536
607
  }
@@ -589,6 +660,7 @@ fn parser(
589
660
  context: None,
590
661
  diagnostics: RefCell::new(Vec::new()),
591
662
  locations: None,
663
+ pending_escape: Cell::new(None),
592
664
  };
593
665
  sink.call(
594
666
  if is_document {
@@ -659,6 +731,7 @@ pub(crate) fn parse(
659
731
  diagnostics: RefCell::new(Vec::new()),
660
732
  locations: (options.get("sourceCodeLocationInfo") == Some(&Value::Bool(true)))
661
733
  .then(|| crate::tracker::Locations::new(context.clone())),
734
+ pending_escape: Cell::new(None),
662
735
  };
663
736
  sink.call(
664
737
  if is_document {
@@ -706,6 +779,7 @@ where
706
779
  diagnostics: RefCell::new(Vec::new()),
707
780
  locations: (options.get("sourceCodeLocationInfo") == Some(&Value::Bool(true)))
708
781
  .then(|| crate::tracker::Locations::new(context.clone())),
782
+ pending_escape: Cell::new(None),
709
783
  };
710
784
  sink.call(
711
785
  if is_document {
package/native/src/lib.rs CHANGED
@@ -23,6 +23,7 @@ mod gc;
23
23
  mod hook_sink;
24
24
  mod hook_stream;
25
25
  mod location;
26
+ mod markup_tokenizer;
26
27
  mod runtime;
27
28
  mod selector;
28
29
  mod sink;
@@ -35,8 +36,90 @@ pub use runtime::{configure_worker_pool, worker_pool_info, WorkerPoolInfo};
35
36
  pub use stream::NativeStream;
36
37
 
37
38
  const HTML_NS: &str = "http://www.w3.org/1999/xhtml";
39
+ const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
40
+ const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/";
41
+ const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
42
+
43
+ /// Writes an attribute's serialized name. Like parse5, the namespace rather
44
+ /// than the stored prefix selects the qualifier: HTML5 gives foreign `xmlns`
45
+ /// attributes an empty prefix, which must not become `:xmlns`. Other namespaces
46
+ /// keep a non-empty prefix; an empty or missing one writes the bare name.
47
+ pub(crate) fn push_attribute_name(
48
+ output: &mut String,
49
+ local: &str,
50
+ namespace: Option<&str>,
51
+ prefix: Option<&str>,
52
+ ) {
53
+ match namespace.filter(|namespace| !namespace.is_empty()) {
54
+ None => {}
55
+ Some(XML_NS) => output.push_str("xml:"),
56
+ Some(XMLNS_NS) => {
57
+ if local != "xmlns" {
58
+ output.push_str("xmlns:");
59
+ }
60
+ }
61
+ Some(XLINK_NS) => output.push_str("xlink:"),
62
+ Some(_) => {
63
+ if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) {
64
+ output.push_str(prefix);
65
+ output.push(':');
66
+ }
67
+ }
68
+ }
69
+ output.push_str(local);
70
+ }
38
71
 
39
- #[napi]
72
+ /// Orders attributes the way a JavaScript object enumerates its keys: integer
73
+ /// keys first, ascending, then insertion order. Cheerio serializes attributes
74
+ /// from such an object.
75
+ pub(crate) fn js_property_order<'a, T, I>(entries: I) -> JsPropertyOrder<I>
76
+ where
77
+ I: Iterator<Item = (&'a str, T)> + Clone,
78
+ {
79
+ if !entries.clone().any(|(name, _)| array_index(name).is_some()) {
80
+ return JsPropertyOrder::Unchanged(entries);
81
+ }
82
+ let mut keyed: Vec<_> = entries
83
+ .map(|entry| {
84
+ (
85
+ array_index(entry.0).map_or((1, 0), |index| (0, index)),
86
+ entry,
87
+ )
88
+ })
89
+ .collect();
90
+ keyed.sort_by_key(|(key, _)| *key);
91
+ let sorted: Vec<_> = keyed.into_iter().map(|(_, entry)| entry).collect();
92
+ JsPropertyOrder::Sorted(sorted.into_iter())
93
+ }
94
+
95
+ pub(crate) enum JsPropertyOrder<I: Iterator> {
96
+ Unchanged(I),
97
+ Sorted(std::vec::IntoIter<I::Item>),
98
+ }
99
+
100
+ impl<I: Iterator> Iterator for JsPropertyOrder<I> {
101
+ type Item = I::Item;
102
+ fn next(&mut self) -> Option<Self::Item> {
103
+ match self {
104
+ Self::Unchanged(entries) => entries.next(),
105
+ Self::Sorted(entries) => entries.next(),
106
+ }
107
+ }
108
+ }
109
+
110
+ fn array_index(name: &str) -> Option<u32> {
111
+ let is_canonical = !name.is_empty()
112
+ && name.len() <= 10
113
+ && name.bytes().all(|byte| byte.is_ascii_digit())
114
+ && (name == "0" || !name.starts_with('0'));
115
+ is_canonical
116
+ .then(|| name.parse::<u64>().ok())
117
+ .flatten()
118
+ .filter(|value| *value < u64::from(u32::MAX))
119
+ .map(|value| value as u32)
120
+ }
121
+
122
+ #[napi(custom_finalize)]
40
123
  pub struct NativeDocument {
41
124
  html: RefCell<Option<Html>>,
42
125
  // Stable public IDs are preorder positions, rather than parser allocation
@@ -51,10 +134,43 @@ pub struct NativeDocument {
51
134
  managed: Cell<bool>,
52
135
  diagnostics: RefCell<Vec<Value>>,
53
136
  locations: RefCell<HashMap<u32, Value>>,
137
+ // V8 cannot see native allocations. Reporting them lets its GC collect
138
+ // unreachable documents under native memory pressure.
139
+ content_bytes: Cell<usize>,
140
+ reported_heap_bytes: Cell<i64>,
141
+ }
142
+
143
+ impl ObjectFinalize for NativeDocument {
144
+ fn finalize(self, mut env: Env) -> Result<()> {
145
+ let reported = self.reported_heap_bytes.get();
146
+ if reported != 0 {
147
+ // Accounting is advisory; a failure must not throw from a finalizer.
148
+ let _ = env.adjust_external_memory(-reported);
149
+ }
150
+ Ok(())
151
+ }
54
152
  }
55
153
 
154
+ fn html_node_content_bytes(node: &Node) -> usize {
155
+ match node {
156
+ Node::Text(text) => text.len(),
157
+ Node::Comment(comment) => comment.len(),
158
+ Node::Element(element) => element.attrs.iter().map(|(_, value)| value.len()).sum(),
159
+ _ => 0,
160
+ }
161
+ }
162
+
163
+ /// Approximate retained bytes per node in each representation, excluding the
164
+ /// text and attribute content counted separately. V8 treats the total only as
165
+ /// allocation pressure, so these err on the high side.
166
+ const IMMUTABLE_NODE_BYTES: usize = 256;
167
+ const ARENA_NODE_BYTES: usize = 512;
168
+ /// Smaller changes to an already reported size are skipped to keep operations cheap.
169
+ const HEAP_REPORT_STEP: i64 = 64 * 1024;
170
+
56
171
  impl NativeDocument {
57
172
  pub(crate) fn from_arena(arena: arena::DomArena, options: Value) -> Self {
173
+ let content_bytes = arena.nodes.iter().map(arena::DomNode::content_bytes).sum();
58
174
  Self {
59
175
  html: RefCell::new(None),
60
176
  ids: RefCell::new(Vec::new()),
@@ -67,6 +183,8 @@ impl NativeDocument {
67
183
  managed: Cell::new(false),
68
184
  diagnostics: RefCell::new(Vec::new()),
69
185
  locations: RefCell::new(HashMap::new()),
186
+ content_bytes: Cell::new(content_bytes),
187
+ reported_heap_bytes: Cell::new(0),
70
188
  }
71
189
  }
72
190
  pub(crate) fn set_diagnostic_records(&self, records: Vec<Value>) {
@@ -82,6 +200,39 @@ impl NativeDocument {
82
200
  Self::parse_options(source, is_document, serde_json::json!({}), None)
83
201
  }
84
202
 
203
+ fn estimated_heap_bytes(&self) -> i64 {
204
+ let live_arena_nodes = self.arena.borrow().as_ref().map_or(0, |arena| {
205
+ arena.nodes.len().saturating_sub(arena.free.len())
206
+ });
207
+ let immutable_nodes = self.ids.borrow().len();
208
+ let retained_source = self.source.borrow().as_ref().map_or(0, String::capacity);
209
+ let content = if live_arena_nodes + immutable_nodes == 0 {
210
+ 0
211
+ } else {
212
+ self.content_bytes.get()
213
+ };
214
+ let bytes = content
215
+ + retained_source
216
+ + immutable_nodes * IMMUTABLE_NODE_BYTES
217
+ + live_arena_nodes * ARENA_NODE_BYTES;
218
+ i64::try_from(bytes).unwrap_or(i64::MAX)
219
+ }
220
+
221
+ fn report_heap_bytes(&self, mut env: Env) {
222
+ let reported = self.reported_heap_bytes.get();
223
+ let estimated = self.estimated_heap_bytes();
224
+ let change = estimated - reported;
225
+ let is_small_update = reported != 0 && estimated != 0 && change.abs() < HEAP_REPORT_STEP;
226
+ if change != 0 && !is_small_update && env.adjust_external_memory(change).is_ok() {
227
+ self.reported_heap_bytes.set(estimated);
228
+ }
229
+ }
230
+
231
+ fn reported<T>(&self, env: Env, value: T) -> T {
232
+ self.report_heap_bytes(env);
233
+ value
234
+ }
235
+
85
236
  pub(crate) fn parse_options(
86
237
  source: &str,
87
238
  is_document: bool,
@@ -262,6 +413,7 @@ impl NativeDocument {
262
413
  && value != &&serde_json::json!("")
263
414
  })
264
415
  .and(source);
416
+ let content_bytes = html.tree.values().map(html_node_content_bytes).sum();
265
417
  Self {
266
418
  html: RefCell::new(Some(html)),
267
419
  ids: RefCell::new(ids),
@@ -274,6 +426,8 @@ impl NativeDocument {
274
426
  managed: Cell::new(false),
275
427
  diagnostics: RefCell::new(diagnostics),
276
428
  locations: RefCell::new(locations),
429
+ content_bytes: Cell::new(content_bytes),
430
+ reported_heap_bytes: Cell::new(0),
277
431
  }
278
432
  }
279
433
 
@@ -583,7 +737,16 @@ impl NativeDocument {
583
737
  }
584
738
  }
585
739
  }
586
- #[napi]
740
+ #[napi(js_name = "collectGarbage")]
741
+ pub fn collect_garbage_and_report(&self, env: Env) -> MemoryStats {
742
+ let stats = self.collect_garbage();
743
+ self.report_heap_bytes(env);
744
+ MemoryStats {
745
+ external_bytes: self.reported_heap_bytes.get(),
746
+ ..stats
747
+ }
748
+ }
749
+
587
750
  pub fn collect_garbage(&self) -> MemoryStats {
588
751
  self.needs_collect.set(false);
589
752
  if self.arena.borrow().is_none() && self.managed.get() && self.retained.borrow().is_empty()
@@ -599,6 +762,7 @@ impl NativeDocument {
599
762
  retained: 0,
600
763
  reclaimed,
601
764
  immutable_nodes: 0,
765
+ external_bytes: self.reported_heap_bytes.get(),
602
766
  };
603
767
  }
604
768
  if let Some(arena) = self.arena.borrow_mut().as_mut() {
@@ -610,6 +774,7 @@ impl NativeDocument {
610
774
  retained: stats.retained,
611
775
  reclaimed: stats.reclaimed,
612
776
  immutable_nodes: 0,
777
+ external_bytes: self.reported_heap_bytes.get(),
613
778
  }
614
779
  } else {
615
780
  let count = self.ids.borrow().len() as u32;
@@ -619,6 +784,7 @@ impl NativeDocument {
619
784
  retained: self.retained.borrow().len() as u32,
620
785
  reclaimed: 0,
621
786
  immutable_nodes: count,
787
+ external_bytes: self.reported_heap_bytes.get(),
622
788
  }
623
789
  }
624
790
  }
@@ -629,19 +795,30 @@ impl NativeDocument {
629
795
  }
630
796
 
631
797
  #[napi]
632
- pub fn root_id(&self) -> u32 {
798
+ pub fn root_id(&self, env: Env) -> u32 {
799
+ self.report_heap_bytes(env);
633
800
  0
634
801
  }
635
802
 
636
- #[napi]
803
+ #[napi(js_name = "select")]
804
+ pub fn select_and_report(
805
+ &self,
806
+ env: Env,
807
+ selector: String,
808
+ contexts: Option<Vec<u32>>,
809
+ ) -> Result<Vec<u32>> {
810
+ self.reported(env, self.select(selector, contexts))
811
+ }
812
+
637
813
  pub fn select(&self, selector: String, contexts: Option<Vec<u32>>) -> Result<Vec<u32>> {
638
814
  self.select_ids(&selector, contexts.as_deref())
639
815
  }
640
816
 
641
817
  #[napi]
642
- pub fn matches(&self, selector: String, ids: Vec<u32>) -> Result<Vec<u32>> {
818
+ pub fn matches(&self, env: Env, selector: String, ids: Vec<u32>) -> Result<Vec<u32>> {
643
819
  self.ensure_arena()?;
644
- selector::filter(self.arena.borrow().as_ref().unwrap(), &selector, &ids)
820
+ let matched = selector::filter(self.arena.borrow().as_ref().unwrap(), &selector, &ids);
821
+ self.reported(env, matched)
645
822
  }
646
823
 
647
824
  #[napi]
@@ -671,7 +848,11 @@ impl NativeDocument {
671
848
  self.text_ids(&ids)
672
849
  }
673
850
 
674
- #[napi]
851
+ #[napi(js_name = "innerHtml")]
852
+ pub fn inner_html_and_report(&self, env: Env, id: u32) -> String {
853
+ self.reported(env, self.inner_html(id))
854
+ }
855
+
675
856
  pub fn inner_html(&self, id: u32) -> String {
676
857
  if (self.tag_name(id).as_deref() == Some("template") || self.arena.borrow().is_some())
677
858
  && self.ensure_arena().is_ok()
@@ -704,7 +885,11 @@ impl NativeDocument {
704
885
  .flatten()
705
886
  }
706
887
 
707
- #[napi]
888
+ #[napi(js_name = "exportTree")]
889
+ pub fn export_tree_and_report(&self, env: Env) -> Result<String> {
890
+ self.reported(env, self.export_tree())
891
+ }
892
+
708
893
  pub fn export_tree(&self) -> Result<String> {
709
894
  if self.source.borrow().is_some() {
710
895
  self.ensure_arena()?;
@@ -718,7 +903,11 @@ impl NativeDocument {
718
903
  serde_json::to_string(&records).map_err(json_error)
719
904
  }
720
905
 
721
- #[napi]
906
+ #[napi(js_name = "node")]
907
+ pub fn node_and_report(&self, env: Env, id: u32) -> Result<String> {
908
+ self.reported(env, self.node(id))
909
+ }
910
+
722
911
  pub fn node(&self, id: u32) -> Result<String> {
723
912
  if self.source.borrow().is_some() {
724
913
  self.ensure_arena()?;
@@ -729,13 +918,26 @@ impl NativeDocument {
729
918
  serde_json::to_string(&self.arena.borrow().as_ref().unwrap().node(id)).map_err(json_error)
730
919
  }
731
920
 
732
- #[napi]
921
+ #[napi(js_name = "create")]
922
+ pub fn create_and_report(
923
+ &self,
924
+ env: Env,
925
+ html: String,
926
+ context_id: Option<u32>,
927
+ options_json: Option<String>,
928
+ ) -> Result<Vec<u32>> {
929
+ let created = self.create(html, context_id, options_json);
930
+ self.reported(env, created)
931
+ }
932
+
733
933
  pub fn create(
734
934
  &self,
735
935
  html: String,
736
936
  context_id: Option<u32>,
737
937
  _options_json: Option<String>,
738
938
  ) -> Result<Vec<u32>> {
939
+ self.content_bytes
940
+ .set(self.content_bytes.get().saturating_add(html.len()));
739
941
  self.ensure_arena()?;
740
942
  self.arena
741
943
  .borrow_mut()
@@ -746,7 +948,19 @@ impl NativeDocument {
746
948
 
747
949
  /// Move a branch between native documents. JavaScript only redirects its
748
950
  /// existing stable handles using the returned complete descendant mapping.
749
- #[napi]
951
+ #[napi(js_name = "adopt")]
952
+ pub fn adopt_and_report(
953
+ &self,
954
+ env: Env,
955
+ source: &NativeDocument,
956
+ ids: Vec<u32>,
957
+ detach: Option<bool>,
958
+ ) -> Result<String> {
959
+ let adopted = self.adopt(source, ids, detach);
960
+ source.report_heap_bytes(env);
961
+ self.reported(env, adopted)
962
+ }
963
+
750
964
  pub fn adopt(
751
965
  &self,
752
966
  source: &NativeDocument,
@@ -840,7 +1054,11 @@ impl NativeDocument {
840
1054
  .map_err(json_error)
841
1055
  }
842
1056
 
843
- #[napi]
1057
+ #[napi(js_name = "op")]
1058
+ pub fn op_and_report(&self, env: Env, command: String, args_json: String) -> Result<String> {
1059
+ self.reported(env, self.op(command, args_json))
1060
+ }
1061
+
844
1062
  pub fn op(&self, command: String, args_json: String) -> Result<String> {
845
1063
  self.maybe_collect();
846
1064
  let args: Value = serde_json::from_str(&args_json).map_err(json_error)?;
@@ -964,6 +1182,8 @@ pub struct MemoryStats {
964
1182
  pub retained: u32,
965
1183
  pub reclaimed: u32,
966
1184
  pub immutable_nodes: u32,
1185
+ /// Native bytes currently reported to V8 for this document.
1186
+ pub external_bytes: i64,
967
1187
  }
968
1188
 
969
1189
  /// The Cheerio DOM adapter exposes foreign attributes by local name, whereas
@@ -1154,13 +1374,20 @@ fn serialize_node(node: NodeRef<'_, Node>, output: &mut String) {
1154
1374
  Node::Element(element) => {
1155
1375
  output.push('<');
1156
1376
  output.push_str(element.name());
1157
- for (name, value) in &element.attrs {
1377
+ let attributes = js_property_order(
1378
+ element
1379
+ .attrs
1380
+ .iter()
1381
+ .map(|(name, value)| (name.local.as_ref(), (name, value))),
1382
+ );
1383
+ for (_, (name, value)) in attributes {
1158
1384
  output.push(' ');
1159
- if let Some(prefix) = &name.prefix {
1160
- output.push_str(prefix);
1161
- output.push(':');
1162
- }
1163
- output.push_str(&name.local);
1385
+ push_attribute_name(
1386
+ output,
1387
+ &name.local,
1388
+ Some(name.ns.as_ref()),
1389
+ name.prefix.as_deref(),
1390
+ );
1164
1391
  output.push_str("=\"");
1165
1392
  escape_html(value, true, output);
1166
1393
  output.push('"');