rusteerio 1.0.0 → 1.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 (65) hide show
  1. package/Cargo.lock +42 -1
  2. package/Cargo.toml +8 -1
  3. package/README.md +90 -51
  4. package/THIRD_PARTY_NOTICES.md +138 -2
  5. package/dist/{chunk-DMRD3FGC.mjs → chunk-FPRP6UIX.mjs} +2233 -751
  6. package/dist/chunk-FPRP6UIX.mjs.map +1 -0
  7. package/dist/index.cjs +2232 -750
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.mts +2 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.mjs +1 -1
  12. package/dist/slim.cjs +2226 -745
  13. package/dist/slim.cjs.map +1 -1
  14. package/dist/slim.d.mts +2 -2
  15. package/dist/slim.d.ts +2 -2
  16. package/dist/slim.mjs +1 -1
  17. package/dist/{types-BP6TpxE5.d.mts → types-Cs9UP1Vv.d.mts} +3 -3
  18. package/dist/{types-BP6TpxE5.d.ts → types-Cs9UP1Vv.d.ts} +3 -3
  19. package/dist/utils.d.mts +1 -1
  20. package/dist/utils.d.ts +1 -1
  21. package/native/src/arena.rs +231 -602
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/codec.rs +62 -18
  24. package/native/src/document_order.rs +711 -0
  25. package/native/src/extraction.rs +15 -3
  26. package/native/src/gc.rs +12 -3
  27. package/native/src/hook_sink.rs +77 -3
  28. package/native/src/id_hash.rs +42 -0
  29. package/native/src/immutable.rs +256 -0
  30. package/native/src/lib.rs +1097 -172
  31. package/native/src/markup_tokenizer.rs +668 -0
  32. package/native/src/runtime.rs +189 -33
  33. package/native/src/selector.rs +346 -44
  34. package/native/src/sink.rs +14 -1
  35. package/native/src/snapshot.rs +509 -0
  36. package/native/src/stream.rs +2 -2
  37. package/native/src/tokenizer.rs +377 -204
  38. package/native/vendor/README.md +3 -2
  39. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  40. package/native/vendor/html5ever-0.35.0/src/tokenizer/mod.rs +23 -13
  41. package/package.json +3 -1
  42. package/rusteerio.darwin-arm64.node +0 -0
  43. package/rusteerio.darwin-x64.node +0 -0
  44. package/rusteerio.linux-arm64-gnu.node +0 -0
  45. package/rusteerio.linux-arm64-musl.node +0 -0
  46. package/rusteerio.linux-x64-gnu.node +0 -0
  47. package/rusteerio.linux-x64-musl.node +0 -0
  48. package/rusteerio.win32-arm64-msvc.node +0 -0
  49. package/rusteerio.win32-x64-msvc.node +0 -0
  50. package/scripts/generate-notices.mjs +11 -0
  51. package/scripts/notices/htmlparser2-MIT.txt +18 -0
  52. package/scripts/notices/sources.json +6 -0
  53. package/src/attributes.ts +78 -44
  54. package/src/codec.ts +22 -3
  55. package/src/extract.ts +86 -2
  56. package/src/hooks.ts +148 -2
  57. package/src/index.ts +51 -40
  58. package/src/match.ts +207 -0
  59. package/src/native.ts +9 -1
  60. package/src/nodes.ts +531 -183
  61. package/src/router.ts +792 -229
  62. package/src/selection.ts +239 -125
  63. package/src/snapshot.ts +255 -0
  64. package/src/transport.ts +51 -42
  65. package/dist/chunk-DMRD3FGC.mjs.map +0 -1
@@ -1,4 +1,5 @@
1
1
  //! Mutable, stable-ID DOM storage. All mutations stay on the native side.
2
+ use std::borrow::Cow;
2
3
  use std::collections::{HashMap, HashSet};
3
4
 
4
5
  use napi::{Error, Result, Status};
@@ -10,7 +11,7 @@ pub(crate) struct DomNode {
10
11
  #[serde(default)]
11
12
  pub id: u32,
12
13
  #[serde(rename = "type", default = "root_kind")]
13
- pub kind: String,
14
+ pub kind: Cow<'static, str>,
14
15
  #[serde(default, skip_serializing_if = "Option::is_none")]
15
16
  pub name: Option<String>,
16
17
  #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -25,16 +26,65 @@ pub(crate) struct DomNode {
25
26
  pub prev: Option<u32>,
26
27
  #[serde(default)]
27
28
  pub next: Option<u32>,
29
+ /// Element namespace; borrowed for the namespaces HTML parsing produces.
30
+ #[serde(default, skip_serializing_if = "Option::is_none")]
31
+ pub namespace: Option<Cow<'static, str>>,
32
+ /// parse5's per-attribute namespace and prefix maps, when the node has them.
33
+ #[serde(
34
+ rename = "attribsNamespace",
35
+ default,
36
+ skip_serializing_if = "Option::is_none"
37
+ )]
38
+ pub attribs_namespace: Option<Box<Map<String, Value>>>,
39
+ #[serde(
40
+ rename = "attribsPrefix",
41
+ default,
42
+ skip_serializing_if = "Option::is_none"
43
+ )]
44
+ pub attribs_prefix: Option<Box<Map<String, Value>>>,
28
45
  #[serde(flatten)]
29
46
  pub extras: Map<String, Value>,
30
47
  }
31
48
 
32
- fn root_kind() -> String {
33
- "root".into()
49
+ fn root_kind() -> Cow<'static, str> {
50
+ Cow::Borrowed("root")
51
+ }
52
+
53
+ pub(crate) const HTML_NAMESPACE: &str = "http://www.w3.org/1999/xhtml";
54
+
55
+ /// A namespace, borrowed for the ones HTML parsing produces.
56
+ pub(crate) fn namespace_name(namespace: &str) -> Cow<'static, str> {
57
+ match namespace {
58
+ HTML_NAMESPACE => Cow::Borrowed(HTML_NAMESPACE),
59
+ "http://www.w3.org/2000/svg" => Cow::Borrowed("http://www.w3.org/2000/svg"),
60
+ "http://www.w3.org/1998/Math/MathML" => Cow::Borrowed("http://www.w3.org/1998/Math/MathML"),
61
+ "http://www.w3.org/1999/xlink" => Cow::Borrowed("http://www.w3.org/1999/xlink"),
62
+ "http://www.w3.org/XML/1998/namespace" => {
63
+ Cow::Borrowed("http://www.w3.org/XML/1998/namespace")
64
+ }
65
+ "http://www.w3.org/2000/xmlns/" => Cow::Borrowed("http://www.w3.org/2000/xmlns/"),
66
+ other => Cow::Owned(other.to_owned()),
67
+ }
68
+ }
69
+
70
+ /// A node type, borrowed for the types the DOM defines.
71
+ pub(crate) fn node_kind(kind: &str) -> Cow<'static, str> {
72
+ match kind {
73
+ "tag" => Cow::Borrowed("tag"),
74
+ "text" => Cow::Borrowed("text"),
75
+ "root" => Cow::Borrowed("root"),
76
+ "script" => Cow::Borrowed("script"),
77
+ "style" => Cow::Borrowed("style"),
78
+ "comment" => Cow::Borrowed("comment"),
79
+ "directive" => Cow::Borrowed("directive"),
80
+ "cdata" => Cow::Borrowed("cdata"),
81
+ "doctype" => Cow::Borrowed("doctype"),
82
+ other => Cow::Owned(other.to_owned()),
83
+ }
34
84
  }
35
85
 
36
86
  impl DomNode {
37
- pub(crate) fn new(kind: &str) -> Self {
87
+ pub(crate) fn new(kind: impl Into<Cow<'static, str>>) -> Self {
38
88
  Self {
39
89
  id: 0,
40
90
  kind: kind.into(),
@@ -45,11 +95,14 @@ impl DomNode {
45
95
  parent: None,
46
96
  prev: None,
47
97
  next: None,
98
+ namespace: None,
99
+ attribs_namespace: None,
100
+ attribs_prefix: None,
48
101
  extras: Map::new(),
49
102
  }
50
103
  }
51
104
  pub(crate) fn is_element(&self) -> bool {
52
- matches!(self.kind.as_str(), "tag" | "script" | "style")
105
+ matches!(self.kind.as_ref(), "tag" | "script" | "style")
53
106
  }
54
107
  pub(crate) fn content_bytes(&self) -> usize {
55
108
  let attributes: usize = self
@@ -67,25 +120,106 @@ pub(crate) struct DomArena {
67
120
  pub nodes: Vec<DomNode>,
68
121
  pub options: Value,
69
122
  pub free: Vec<u32>,
123
+ /// Parents whose child lists changed since JavaScript last asked, so it
124
+ /// can refresh the child arrays it holds without rereading every node.
125
+ pub touched: Vec<u32>,
126
+ /// Too many changes were recorded to list; every parent counts as touched.
127
+ pub touched_overflow: bool,
128
+ /// Whether parsing records start and end indices, read once per parse.
129
+ pub parse_indices: Option<(bool, bool)>,
130
+ /// Nodes removed from a parent since the last collection.
131
+ pub detached_since_collection: u32,
132
+ /// Live nodes after the last collection, or when the arena was built.
133
+ pub collected_live: u32,
134
+ /// Elements by ASCII-lowercased name, in document order, as parsing
135
+ /// created them; dropped before any write, which may reorder ids.
136
+ pub name_index: Option<NameIndex>,
70
137
  }
71
138
 
139
+ pub(crate) type NameIndex = crate::id_hash::IdMap<String, Vec<u32>>;
140
+
141
+ const TOUCHED_LIMIT: usize = 1 << 16;
142
+
72
143
  impl DomArena {
144
+ /// An arena over records whose ids and links are already consistent, as
145
+ /// a parse tree's conversion produces them.
146
+ pub(crate) fn from_linked_records(nodes: Vec<DomNode>, options: Value) -> Self {
147
+ let slots = nodes.len() as u32;
148
+ Self {
149
+ nodes,
150
+ options,
151
+ free: Vec::new(),
152
+ touched: Vec::new(),
153
+ touched_overflow: false,
154
+ parse_indices: None,
155
+ detached_since_collection: 0,
156
+ collected_live: slots,
157
+ name_index: None,
158
+ }
159
+ }
73
160
  pub(crate) fn from_records(mut nodes: Vec<DomNode>, options: Value) -> Self {
74
161
  for (id, node) in nodes.iter_mut().enumerate() {
75
162
  node.id = id as u32;
76
- node.extras.entry("startIndex").or_insert(Value::Null);
77
- node.extras.entry("endIndex").or_insert(Value::Null);
78
163
  }
164
+ let slots = nodes.len() as u32;
79
165
  let mut arena = Self {
80
166
  nodes,
81
167
  options,
82
168
  free: Vec::new(),
169
+ touched: Vec::new(),
170
+ touched_overflow: false,
171
+ parse_indices: None,
172
+ detached_since_collection: 0,
173
+ collected_live: slots,
174
+ name_index: None,
83
175
  };
84
176
  for id in 0..arena.nodes.len() {
85
177
  arena.relink(id as u32);
86
178
  }
179
+ arena.take_touched();
87
180
  arena
88
181
  }
182
+ pub(crate) fn touch(&mut self, parent: u32) {
183
+ if self.touched_overflow {
184
+ return;
185
+ }
186
+ if self.touched.len() >= TOUCHED_LIMIT {
187
+ self.touched = Vec::new();
188
+ self.touched_overflow = true;
189
+ } else {
190
+ self.touched.push(parent);
191
+ }
192
+ }
193
+ /// The parents touched since the last call, or `None` when too many were.
194
+ pub(crate) fn take_touched(&mut self) -> Option<Vec<u32>> {
195
+ let touched = std::mem::take(&mut self.touched);
196
+ let overflow = std::mem::replace(&mut self.touched_overflow, false);
197
+ (!overflow).then_some(touched)
198
+ }
199
+ fn note_detached(&mut self) {
200
+ self.detached_since_collection = self.detached_since_collection.saturating_add(1);
201
+ }
202
+ /// Every live node that `root` does not reach through child links.
203
+ pub(crate) fn detached_slots(&self, root: u32) -> Vec<u32> {
204
+ let mut attached = vec![false; self.nodes.len()];
205
+ let mut stack = vec![root];
206
+ while let Some(id) = stack.pop() {
207
+ let Some(node) = self.node(id) else { continue };
208
+ if std::mem::replace(&mut attached[id as usize], true) {
209
+ continue;
210
+ }
211
+ stack.extend(node.children.iter().copied());
212
+ }
213
+ (0..self.nodes.len() as u32)
214
+ .filter(|id| !attached[*id as usize] && self.node(*id).is_some())
215
+ .collect()
216
+ }
217
+ /// Whether removed nodes may have left enough garbage to collect: the
218
+ /// live slots have grown by half since the last collection.
219
+ pub(crate) fn is_collection_due(&self) -> bool {
220
+ let live = self.nodes.len().saturating_sub(self.free.len()) as u64;
221
+ self.detached_since_collection > 0 && live >= u64::from(self.collected_live) * 3 / 2 + 256
222
+ }
89
223
  pub(crate) fn node(&self, id: u32) -> Option<&DomNode> {
90
224
  self.nodes
91
225
  .get(id as usize)
@@ -124,7 +258,7 @@ impl DomArena {
124
258
  .insert(name.into(), Value::String(value.into()));
125
259
  }
126
260
  None => {
127
- node.attribs.remove(name);
261
+ node.attribs.shift_remove(name);
128
262
  }
129
263
  }
130
264
  }
@@ -132,6 +266,7 @@ impl DomArena {
132
266
  }
133
267
  }
134
268
  pub(crate) fn relink(&mut self, parent: u32) {
269
+ self.touch(parent);
135
270
  let children = self
136
271
  .nodes
137
272
  .get(parent as usize)
@@ -150,6 +285,7 @@ impl DomArena {
150
285
  }
151
286
  pub(crate) fn detach(&mut self, id: u32) {
152
287
  if let Some(parent) = self.node(id).and_then(|node| node.parent) {
288
+ self.note_detached();
153
289
  if let Some(node) = self.nodes.get_mut(parent as usize) {
154
290
  node.children.retain(|child| *child != id);
155
291
  }
@@ -169,6 +305,11 @@ impl DomArena {
169
305
  return;
170
306
  }
171
307
  let removed: HashSet<_> = ids.iter().copied().collect();
308
+ for id in ids {
309
+ if self.node(*id).is_some_and(|node| node.parent.is_some()) {
310
+ self.note_detached();
311
+ }
312
+ }
172
313
  let parents: HashSet<_> = ids
173
314
  .iter()
174
315
  .filter_map(|id| self.node(*id).and_then(|node| node.parent))
@@ -270,6 +411,7 @@ impl DomArena {
270
411
  index += 1;
271
412
  }
272
413
  self.nodes[id as usize].children = children;
414
+ self.touch(id);
273
415
  }
274
416
  }
275
417
  self.inner_html(id)
@@ -280,8 +422,12 @@ impl DomArena {
280
422
  pub(crate) fn serialize(&self, ids: &[u32], options: &Value) -> String {
281
423
  let mut output = String::new();
282
424
  let xml = xml_mode(options);
283
- let encode = options.get("decodeEntities") != Some(&Value::Bool(false))
284
- && options.get("encodeEntities") != Some(&Value::Bool(false));
425
+ // Cheerio serializes HTML with parse5, which always escapes; only the
426
+ // htmlparser2 serializer leaves entities alone when asked to.
427
+ let is_parse5 = !xml && options.get("_useHtmlParser2") != Some(&Value::Bool(true));
428
+ let encode = is_parse5
429
+ || (options.get("decodeEntities") != Some(&Value::Bool(false))
430
+ && options.get("encodeEntities") != Some(&Value::Bool(false)));
285
431
  let mut stack: Vec<(u32, bool)> = ids.iter().rev().map(|id| (*id, false)).collect();
286
432
  let mut budget = self.nodes.len().saturating_mul(8).saturating_add(1024);
287
433
  while let Some((id, close)) = stack.pop() {
@@ -298,7 +444,7 @@ impl DomArena {
298
444
  output.push('>');
299
445
  continue;
300
446
  }
301
- match node.kind.as_str() {
447
+ match node.kind.as_ref() {
302
448
  "root" => stack.extend(node.children.iter().rev().map(|id| (*id, false))),
303
449
  "text" => {
304
450
  let raw = !xml
@@ -366,13 +512,16 @@ impl DomArena {
366
512
  // rendering write the stored attribute names unchanged.
367
513
  let qualifiers = (!xml
368
514
  && options.get("_useHtmlParser2") != Some(&Value::Bool(true)))
369
- .then(|| {
370
- (
371
- node.extras.get("attribsNamespace"),
372
- node.extras.get("attribsPrefix"),
373
- )
374
- });
375
- for (name, value) in &node.attribs {
515
+ .then_some((
516
+ node.attribs_namespace.as_deref(),
517
+ node.attribs_prefix.as_deref(),
518
+ ));
519
+ let attributes = super::js_property_order(
520
+ node.attribs
521
+ .iter()
522
+ .map(|(name, value)| (name.as_str(), value)),
523
+ );
524
+ for (name, value) in attributes {
376
525
  output.push(' ');
377
526
  match qualifiers {
378
527
  Some((namespaces, prefixes)) => super::push_attribute_name(
@@ -415,9 +564,8 @@ impl DomArena {
415
564
  }
416
565
  let is_void = !xml
417
566
  && (node
418
- .extras
419
- .get("namespace")
420
- .and_then(Value::as_str)
567
+ .namespace
568
+ .as_deref()
421
569
  .is_none_or(|namespace| namespace == super::HTML_NS))
422
570
  && super::void_name(name);
423
571
  if xml
@@ -529,28 +677,44 @@ impl DomArena {
529
677
  roots
530
678
  }
531
679
  pub(crate) fn create_fragment(&mut self, html: &str, context: Option<u32>) -> Result<Vec<u32>> {
532
- let context_name = context
533
- .and_then(|id| self.node(id))
534
- .and_then(|node| node.name.clone());
680
+ let parsed = self.parse_fragment(html, context)?;
681
+ let roots = parsed.nodes[0].children.clone();
682
+ Ok(self.import_internal(&parsed.nodes, &roots, false))
683
+ }
684
+ /// Parses `html` as the children of `context`; only the context's name
685
+ /// and namespace affect the result.
686
+ fn parse_fragment(&self, html: &str, context: Option<u32>) -> Result<DomArena> {
687
+ let (context_name, namespace) = self.fragment_context(context);
535
688
  let mut options = self.options.clone();
536
- if let Some(namespace) = context
537
- .and_then(|id| self.node(id))
538
- .and_then(|node| node.extras.get("namespace"))
539
- .cloned()
540
- {
541
- options["__contextNamespace"] = namespace;
689
+ if let Some(namespace) = namespace {
690
+ options["__contextNamespace"] = Value::String(namespace);
542
691
  }
543
692
  let document =
544
693
  super::NativeDocument::parse_options(html, false, options, context_name.as_deref());
545
694
  document.ensure_arena()?;
546
- let arena = document.arena.borrow();
547
- let parsed = arena.as_ref().unwrap();
548
- let roots = parsed.nodes[0].children.clone();
549
- Ok(self.import_internal(&parsed.nodes, &roots, false))
695
+ let parsed = document.arena.borrow_mut().take().unwrap();
696
+ Ok(parsed)
697
+ }
698
+ fn fragment_context(&self, context: Option<u32>) -> (Option<String>, Option<String>) {
699
+ let node = context.and_then(|id| self.node(id));
700
+ (
701
+ node.and_then(|node| node.name.clone()),
702
+ node.and_then(|node| node.namespace.as_deref().map(str::to_owned)),
703
+ )
550
704
  }
551
705
  pub(crate) fn set_html(&mut self, ids: &[u32], html: &str) -> Result<()> {
706
+ // Each target receives its own copy; targets of the same element type
707
+ // share one parse, since the result depends only on that context.
708
+ let mut parsed: HashMap<(Option<String>, Option<String>), DomArena> = HashMap::new();
552
709
  for id in ids {
553
- let children = self.create_fragment(html, Some(*id))?;
710
+ let key = self.fragment_context(Some(*id));
711
+ if !parsed.contains_key(&key) {
712
+ let fragment = self.parse_fragment(html, Some(*id))?;
713
+ parsed.insert(key.clone(), fragment);
714
+ }
715
+ let fragment = &parsed[&key];
716
+ let roots = fragment.nodes[0].children.clone();
717
+ let children = self.import_internal(&fragment.nodes, &roots, false);
554
718
  self.set_children(*id, &children);
555
719
  }
556
720
  Ok(())
@@ -720,11 +884,18 @@ impl DomArena {
720
884
  match field {
721
885
  "name" | "tagName" => node.name = value.as_str().map(str::to_owned),
722
886
  "data" | "nodeValue" => node.data = value.as_str().map(str::to_owned),
723
- "type" => node.kind = value.as_str().unwrap_or("tag").into(),
887
+ "type" => node.kind = node_kind(value.as_str().unwrap_or("tag")),
724
888
  "attribs" => node.attribs = value.as_object().cloned().unwrap_or_default(),
725
889
  "parent" => node.parent = value.as_u64().map(|id| id as u32),
726
890
  "prev" => node.prev = value.as_u64().map(|id| id as u32),
727
891
  "next" => node.next = value.as_u64().map(|id| id as u32),
892
+ "namespace" => node.namespace = value.as_str().map(namespace_name),
893
+ "attribsNamespace" | "x-attribsNamespace" => {
894
+ node.attribs_namespace = value.as_object().cloned().map(Box::new)
895
+ }
896
+ "attribsPrefix" | "x-attribsPrefix" => {
897
+ node.attribs_prefix = value.as_object().cloned().map(Box::new)
898
+ }
728
899
  _ => {
729
900
  if value.is_null() {
730
901
  node.extras.remove(field);
@@ -739,7 +910,7 @@ impl DomArena {
739
910
 
740
911
  impl super::selector::SelectDom for DomArena {
741
912
  fn kind(&self, id: u32) -> &str {
742
- self.node(id).map_or("", |node| node.kind.as_str())
913
+ self.node(id).map_or("", |node| node.kind.as_ref())
743
914
  }
744
915
  fn name(&self, id: u32) -> Option<&str> {
745
916
  self.node(id)?.name.as_deref()
@@ -782,6 +953,13 @@ impl super::selector::SelectDom for DomArena {
782
953
  fn pseudo_alias(&self, name: &str) -> Option<&str> {
783
954
  self.options.get("pseudos")?.get(name)?.as_str()
784
955
  }
956
+ fn elements_named(&self, lowercase_name: &str) -> Option<&[u32]> {
957
+ let index = self.name_index.as_ref()?;
958
+ Some(index.get(lowercase_name).map_or(&[], Vec::as_slice))
959
+ }
960
+ fn is_id_order_document_order(&self) -> bool {
961
+ self.name_index.is_some()
962
+ }
785
963
  }
786
964
 
787
965
  pub(crate) fn value_ids(value: &Value) -> Vec<u32> {
@@ -880,481 +1058,30 @@ fn escape(value: &str, attribute: bool, xml: bool, utf8: bool, output: &mut Stri
880
1058
  }
881
1059
 
882
1060
  pub(crate) fn parse_xml(source: &str, options: Value) -> DomArena {
883
- let mut arena = DomArena::from_records(vec![DomNode::new("root")], options);
884
- let mut parents = vec![0u32];
885
- parse_xml_segment(source, &mut arena, &mut parents, 0);
886
- arena
887
- }
888
-
889
- fn parse_xml_segment(source: &str, arena: &mut DomArena, parents: &mut Vec<u32>, base: usize) {
890
- let xml = arena.xml_mode();
891
- let indices = arena.options.get("withStartIndices") == Some(&Value::Bool(true))
892
- || arena.options.get("withEndIndices") == Some(&Value::Bool(true));
893
- let mut positions = if indices {
894
- vec![0usize; source.len() + 1]
895
- } else {
896
- Vec::new()
897
- };
898
- let mut utf16 = 0;
899
- let codec = arena.options.get("__utf16Codec") == Some(&Value::Bool(true));
900
- let mut escaped = false;
901
- if indices {
902
- for (at, ch) in source.char_indices() {
903
- for position in &mut positions[at..at + ch.len_utf8()] {
904
- *position = utf16;
905
- }
906
- if codec && ch == crate::codec::ESCAPE && !escaped {
907
- escaped = true;
908
- } else {
909
- utf16 += ch.len_utf16();
910
- escaped = false;
911
- }
912
- positions[at + ch.len_utf8()] = utf16;
913
- }
914
- }
915
- let source_position = |index: usize| {
916
- base + if indices {
917
- positions[index.min(source.len())]
918
- } else {
919
- index
920
- }
921
- };
922
- macro_rules! append_parsed {
923
- ($arena:expr,$parent:expr,$node:expr,$start:expr,$end:expr $(,)?) => {
924
- append_parsed(
925
- $arena,
926
- $parent,
927
- $node,
928
- source_position($start),
929
- source_position(($end).saturating_add(1)).saturating_sub(1),
930
- )
931
- };
932
- }
933
- let mut index = 0;
934
- let bytes = source.as_bytes();
935
- let decode = arena.options.get("decodeEntities") != Some(&Value::Bool(false));
936
- while index < bytes.len() {
937
- let start = index;
938
- if !xml {
939
- if let Some(name) = parents
940
- .last()
941
- .and_then(|id| arena.node(*id))
942
- .and_then(|node| node.name.as_deref())
943
- .filter(|name| matches!(*name, "script" | "style" | "xmp" | "textarea" | "title"))
944
- .map(str::to_owned)
945
- {
946
- let closing = format!("</{name}");
947
- let end = source.as_bytes()[index..]
948
- .windows(closing.len())
949
- .enumerate()
950
- .find(|(offset, candidate)| {
951
- candidate.eq_ignore_ascii_case(closing.as_bytes())
952
- && bytes
953
- .get(index + offset + closing.len())
954
- .is_none_or(|ch| *ch == b'>' || ch.is_ascii_whitespace())
955
- })
956
- .map_or(bytes.len(), |(offset, _)| index + offset);
957
- if end > index {
958
- let text = &source[index..end];
959
- let mut node = DomNode::new("text");
960
- node.data = Some(if decode && name == "title" {
961
- decode_entities_mode(
962
- text,
963
- false,
964
- false,
965
- arena.options.get("__utf16Codec") == Some(&Value::Bool(true)),
966
- )
967
- } else {
968
- text.into()
969
- });
970
- append_parsed!(
971
- arena,
972
- *parents.last().unwrap(),
973
- node,
974
- index,
975
- end.saturating_sub(1),
976
- );
977
- index = end;
978
- continue;
979
- }
980
- }
981
- if bytes[index] == b'<'
982
- && bytes.get(index + 1).is_some_and(|ch| {
983
- !ch.is_ascii_alphabetic() && !matches!(*ch, b'!' | b'?' | b'/')
984
- })
985
- {
986
- let mut node = DomNode::new("text");
987
- node.data = Some("<".into());
988
- append_parsed!(arena, *parents.last().unwrap(), node, index, index);
989
- index += 1;
990
- continue;
991
- }
992
- }
993
- if bytes[index] != b'<' {
994
- index = source[index..]
995
- .find('<')
996
- .map_or(bytes.len(), |offset| index + offset);
997
- let text = &source[start..index];
998
- let mut node = DomNode::new("text");
999
- node.data = Some(if decode {
1000
- decode_entities_mode(
1001
- text,
1002
- xml,
1003
- false,
1004
- arena.options.get("__utf16Codec") == Some(&Value::Bool(true)),
1005
- )
1006
- } else {
1007
- text.into()
1008
- });
1009
- append_parsed!(
1010
- arena,
1011
- *parents.last().unwrap(),
1012
- node,
1013
- start,
1014
- index.saturating_sub(1),
1015
- );
1016
- continue;
1017
- }
1018
- if source[index..].starts_with("<!--") {
1019
- let end = source[index + 4..]
1020
- .find("-->")
1021
- .map(|offset| index + 4 + offset);
1022
- let data_end = end.unwrap_or(bytes.len());
1023
- index = end.map_or(bytes.len(), |end| end + 3);
1024
- let mut node = DomNode::new("comment");
1025
- node.data = Some(source[start + 4..data_end].into());
1026
- append_parsed!(
1027
- arena,
1028
- *parents.last().unwrap(),
1029
- node,
1030
- start,
1031
- index.saturating_sub(1),
1032
- );
1033
- continue;
1034
- }
1035
- if source[index..].starts_with("<![CDATA[") {
1036
- let end = source[index + 9..]
1037
- .find("]]>")
1038
- .map(|offset| index + 9 + offset);
1039
- let data_end = end.unwrap_or(bytes.len());
1040
- index = end.map_or(bytes.len(), |end| end + 3);
1041
- if !xml && arena.options.get("recognizeCDATA") != Some(&Value::Bool(true)) {
1042
- let mut node = DomNode::new("comment");
1043
- node.data = Some(format!("[CDATA[{}]]", &source[start + 9..data_end]));
1044
- append_parsed!(
1045
- arena,
1046
- *parents.last().unwrap(),
1047
- node,
1048
- start,
1049
- index.saturating_sub(1)
1050
- );
1051
- continue;
1052
- }
1053
- let id = append_parsed!(
1054
- arena,
1055
- *parents.last().unwrap(),
1056
- DomNode::new("cdata"),
1057
- start,
1058
- index.saturating_sub(1),
1059
- );
1060
- let mut text = DomNode::new("text");
1061
- text.data = Some(source[start + 9..data_end].into());
1062
- append_parsed!(arena, id, text, start + 9, data_end.saturating_sub(1));
1063
- continue;
1064
- }
1065
- let mut cursor = index + 1;
1066
- let mut quote = None;
1067
- while cursor < bytes.len() {
1068
- let ch = bytes[cursor];
1069
- if let Some(q) = quote {
1070
- if ch == q {
1071
- quote = None;
1072
- }
1073
- } else if ch == b'\'' || ch == b'"' {
1074
- quote = Some(ch);
1075
- } else if ch == b'>' {
1076
- break;
1077
- }
1078
- cursor += 1;
1079
- }
1080
- if cursor >= bytes.len() {
1081
- let mut text = DomNode::new("text");
1082
- text.data = Some(source[index..].into());
1083
- append_parsed!(
1084
- arena,
1085
- *parents.last().unwrap(),
1086
- text,
1087
- start,
1088
- bytes.len().saturating_sub(1),
1089
- );
1090
- break;
1091
- }
1092
- index = cursor + 1;
1093
- let token = source[start + 1..cursor].trim();
1094
- if token.starts_with('!') || token.starts_with('?') {
1095
- let mut node = DomNode::new("directive");
1096
- node.name = Some(token.split_whitespace().next().unwrap_or("").into());
1097
- node.data = Some(token.into());
1098
- append_parsed!(arena, *parents.last().unwrap(), node, start, cursor);
1099
- continue;
1100
- }
1101
- if let Some(close) = token.strip_prefix('/') {
1102
- let close = close.split_whitespace().next().unwrap_or("");
1103
- if let Some(position) = parents.iter().rposition(|id| {
1104
- arena.nodes[*id as usize]
1105
- .name
1106
- .as_deref()
1107
- .is_some_and(|name| {
1108
- if xml {
1109
- name == close
1110
- } else {
1111
- name.eq_ignore_ascii_case(close)
1112
- }
1113
- })
1114
- }) {
1115
- for id in parents.drain(position..) {
1116
- if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1117
- arena.nodes[id as usize]
1118
- .extras
1119
- .insert("endIndex".into(), json!(source_position(cursor)));
1120
- }
1121
- }
1122
- }
1123
- continue;
1124
- }
1125
- let slash = token.ends_with('/');
1126
- let content = token.strip_suffix('/').unwrap_or(token);
1127
- let name_end = content.find(char::is_whitespace).unwrap_or(content.len());
1128
- let name = &content[..name_end];
1129
- if name.is_empty() {
1130
- continue;
1131
- }
1132
- let lower = arena
1133
- .options
1134
- .get("lowerCaseTags")
1135
- .and_then(Value::as_bool)
1136
- .unwrap_or(!xml);
1137
- let name = if lower {
1138
- name.to_ascii_lowercase()
1139
- } else {
1140
- name.into()
1141
- };
1142
- if !xml {
1143
- while parents
1144
- .last()
1145
- .and_then(|id| arena.node(*id))
1146
- .and_then(|node| node.name.as_deref())
1147
- .is_some_and(|open| implied_close(&name, open))
1148
- {
1149
- let id = parents.pop().unwrap();
1150
- if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1151
- arena.nodes[id as usize].extras.insert(
1152
- "endIndex".into(),
1153
- json!(source_position(start.saturating_sub(1))),
1154
- );
1155
- }
1156
- }
1157
- }
1158
- let foreign = matches!(name.as_str(), "svg" | "math")
1159
- || parents
1160
- .iter()
1161
- .rev()
1162
- .filter_map(|id| arena.node(*id))
1163
- .filter_map(|node| node.name.as_deref())
1164
- .find_map(|name| {
1165
- if matches!(
1166
- name,
1167
- "mi" | "mo"
1168
- | "mn"
1169
- | "ms"
1170
- | "mtext"
1171
- | "annotation-xml"
1172
- | "foreignobject"
1173
- | "desc"
1174
- | "title"
1175
- ) {
1176
- Some(false)
1177
- } else if matches!(name, "svg" | "math") {
1178
- Some(true)
1179
- } else {
1180
- None
1181
- }
1182
- })
1183
- .unwrap_or(false);
1184
- let self_close = slash
1185
- && (xml
1186
- || foreign
1187
- || arena.options.get("recognizeSelfClosing") == Some(&Value::Bool(true)));
1188
- let mut node = DomNode::new(if !xml && name == "script" {
1189
- "script"
1190
- } else if !xml && name == "style" {
1191
- "style"
1192
- } else {
1193
- "tag"
1194
- });
1195
- node.name = Some(name.clone());
1196
- node.attribs = parse_attributes(
1197
- &content[name_end..],
1198
- decode,
1199
- arena
1200
- .options
1201
- .get("lowerCaseAttributeNames")
1202
- .and_then(Value::as_bool)
1203
- .unwrap_or(!xml),
1204
- xml,
1205
- arena.options.get("__utf16Codec") == Some(&Value::Bool(true)),
1206
- );
1207
- let id = append_parsed!(arena, *parents.last().unwrap(), node, start, cursor);
1208
- let void = !xml && html_parser_void(&name);
1209
- if !self_close && !void {
1210
- parents.push(id);
1211
- } else if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1212
- arena.nodes[id as usize]
1213
- .extras
1214
- .insert("endIndex".into(), json!(source_position(cursor)));
1215
- }
1216
- }
1061
+ crate::tokenizer::parse_markup(source, options)
1217
1062
  }
1218
1063
 
1219
- /// Incremental HTML-parser2/XML state; only incomplete markup/entity tokens are
1220
- /// buffered, while completed input is discarded after native tree construction.
1064
+ /// Incremental htmlparser2/XML input. The complete input is tokenized once, so
1065
+ /// tags and entities split across chunks parse exactly as one string does.
1221
1066
  pub(crate) struct XmlStream {
1222
1067
  pub arena: DomArena,
1223
- parents: Vec<u32>,
1224
1068
  pending: String,
1225
- offset: usize,
1226
1069
  }
1227
1070
  impl XmlStream {
1228
1071
  pub(crate) fn new(options: Value) -> Self {
1229
1072
  Self {
1230
1073
  arena: DomArena::from_records(vec![DomNode::new("root")], options),
1231
- parents: vec![0],
1232
1074
  pending: String::new(),
1233
- offset: 0,
1234
1075
  }
1235
1076
  }
1236
1077
  pub(crate) fn feed(&mut self, chunk: &str, finish: bool) {
1237
1078
  self.pending.push_str(chunk);
1238
- let mut consumed = 0;
1239
- loop {
1240
- let input = &self.pending[consumed..];
1241
- let mut length = if finish {
1242
- input.len()
1243
- } else {
1244
- self.complete_prefix(input)
1245
- };
1246
- if !finish
1247
- && self.arena.options.get("__utf16Codec") == Some(&Value::Bool(true))
1248
- && input[..length]
1249
- .chars()
1250
- .rev()
1251
- .take_while(|ch| *ch == crate::codec::ESCAPE)
1252
- .count()
1253
- % 2
1254
- == 1
1255
- {
1256
- length -= crate::codec::ESCAPE.len_utf8();
1257
- }
1258
- if length == 0 {
1259
- break;
1260
- }
1261
- let segment = &input[..length];
1262
- parse_xml_segment(segment, &mut self.arena, &mut self.parents, self.offset);
1263
- self.offset += if self.arena.options.get("__utf16Codec") == Some(&Value::Bool(true)) {
1264
- crate::codec::logical_len(segment)
1265
- } else {
1266
- segment.encode_utf16().count()
1267
- };
1268
- consumed += length;
1269
- if consumed == self.pending.len() || finish {
1270
- break;
1271
- }
1079
+ if finish {
1080
+ let options = self.arena.options.clone();
1081
+ self.arena = parse_xml(&std::mem::take(&mut self.pending), options);
1272
1082
  }
1273
- self.pending.drain(..consumed);
1274
- }
1275
- fn complete_prefix(&self, input: &str) -> usize {
1276
- let bytes = input.as_bytes();
1277
- if input.is_empty() {
1278
- return 0;
1279
- }
1280
- if !self.arena.xml_mode() {
1281
- if let Some(name) = self
1282
- .parents
1283
- .last()
1284
- .and_then(|id| self.arena.node(*id))
1285
- .and_then(|node| node.name.as_deref())
1286
- .filter(|name| matches!(*name, "script" | "style" | "xmp" | "textarea" | "title"))
1287
- {
1288
- let close = format!("</{name}");
1289
- let closing = bytes
1290
- .windows(close.len())
1291
- .enumerate()
1292
- .find(|(at, value)| {
1293
- value.eq_ignore_ascii_case(close.as_bytes())
1294
- && bytes
1295
- .get(at + close.len())
1296
- .is_none_or(|ch| *ch == b'>' || ch.is_ascii_whitespace())
1297
- })
1298
- .map(|(at, _)| at);
1299
- match closing {
1300
- Some(0) if bytes.len() == close.len() => return 0,
1301
- Some(0) => {}
1302
- Some(index) => return safe_entity_prefix(input, index, name == "title"),
1303
- None => {
1304
- let mut end = input.len().saturating_sub(close.len() + 1);
1305
- while !input.is_char_boundary(end) {
1306
- end -= 1;
1307
- }
1308
- return safe_entity_prefix(input, end, name == "title");
1309
- }
1310
- }
1311
- }
1312
- }
1313
- if bytes[0] != b'<' {
1314
- let end = input.find('<').unwrap_or(input.len());
1315
- return safe_entity_prefix(
1316
- input,
1317
- end,
1318
- self.arena.options.get("decodeEntities") != Some(&Value::Bool(false)),
1319
- );
1320
- }
1321
- if input.starts_with("<!--") {
1322
- return input.find("-->").map_or(0, |index| index + 3);
1323
- }
1324
- if input.starts_with("<![CDATA[") {
1325
- return input.find("]]>").map_or(0, |index| index + 3);
1326
- }
1327
- let mut quote = None;
1328
- for (index, ch) in bytes.iter().copied().enumerate().skip(1) {
1329
- if let Some(q) = quote {
1330
- if ch == q {
1331
- quote = None;
1332
- }
1333
- } else if ch == b'\'' || ch == b'"' {
1334
- quote = Some(ch);
1335
- } else if ch == b'>' {
1336
- return index + 1;
1337
- }
1338
- }
1339
- 0
1340
1083
  }
1341
1084
  }
1342
- fn safe_entity_prefix(source: &str, end: usize, decode: bool) -> usize {
1343
- if decode {
1344
- if let Some(start) = source[..end].rfind('&') {
1345
- let tail = &source[start + 1..end];
1346
- if tail.len() < 64
1347
- && tail
1348
- .chars()
1349
- .all(|ch| ch.is_ascii_alphanumeric() || ch == '#')
1350
- {
1351
- return start;
1352
- }
1353
- }
1354
- }
1355
- end
1356
- }
1357
-
1358
1085
  pub(crate) fn append_parsed(
1359
1086
  arena: &mut DomArena,
1360
1087
  parent: u32,
@@ -1362,6 +1089,12 @@ pub(crate) fn append_parsed(
1362
1089
  start: usize,
1363
1090
  end: usize,
1364
1091
  ) -> u32 {
1092
+ let (start_indices, end_indices) = *arena.parse_indices.get_or_insert_with(|| {
1093
+ (
1094
+ arena.options.get("withStartIndices") == Some(&Value::Bool(true)),
1095
+ arena.options.get("withEndIndices") == Some(&Value::Bool(true)),
1096
+ )
1097
+ });
1365
1098
  if node.kind == "text" {
1366
1099
  if let Some(previous) = arena.nodes[parent as usize]
1367
1100
  .children
@@ -1373,7 +1106,7 @@ pub(crate) fn append_parsed(
1373
1106
  .data
1374
1107
  .get_or_insert_with(String::new)
1375
1108
  .push_str(node.data.as_deref().unwrap_or(""));
1376
- if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1109
+ if end_indices {
1377
1110
  arena.nodes[previous as usize]
1378
1111
  .extras
1379
1112
  .insert("endIndex".into(), json!(end));
@@ -1381,10 +1114,10 @@ pub(crate) fn append_parsed(
1381
1114
  return previous;
1382
1115
  }
1383
1116
  }
1384
- if arena.options.get("withStartIndices") == Some(&Value::Bool(true)) {
1117
+ if start_indices {
1385
1118
  node.extras.insert("startIndex".into(), json!(start));
1386
1119
  }
1387
- if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1120
+ if end_indices {
1388
1121
  node.extras.insert("endIndex".into(), json!(end));
1389
1122
  }
1390
1123
  let previous = arena.nodes[parent as usize].children.last().copied();
@@ -1424,70 +1157,6 @@ pub(crate) fn implied_close(name: &str, open: &str) -> bool {
1424
1157
  _ => false,
1425
1158
  }
1426
1159
  }
1427
- fn parse_attributes(
1428
- source: &str,
1429
- decode: bool,
1430
- lower: bool,
1431
- xml: bool,
1432
- codec: bool,
1433
- ) -> Map<String, Value> {
1434
- let bytes = source.as_bytes();
1435
- let mut index = 0;
1436
- let mut attrs = Map::new();
1437
- while index < bytes.len() {
1438
- while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1439
- index += 1;
1440
- }
1441
- let start = index;
1442
- while index < bytes.len() && !bytes[index].is_ascii_whitespace() && bytes[index] != b'=' {
1443
- index += 1;
1444
- }
1445
- if start == index {
1446
- index += 1;
1447
- continue;
1448
- }
1449
- let name = &source[start..index];
1450
- while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1451
- index += 1;
1452
- }
1453
- let mut value = "";
1454
- if bytes.get(index) == Some(&b'=') {
1455
- index += 1;
1456
- while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1457
- index += 1;
1458
- }
1459
- if matches!(bytes.get(index), Some(b'\'' | b'"')) {
1460
- let quote = bytes[index];
1461
- index += 1;
1462
- let start = index;
1463
- while index < bytes.len() && bytes[index] != quote {
1464
- index += 1;
1465
- }
1466
- value = &source[start..index];
1467
- index += (index < bytes.len()) as usize;
1468
- } else {
1469
- let start = index;
1470
- while index < bytes.len() && !bytes[index].is_ascii_whitespace() {
1471
- index += 1;
1472
- }
1473
- value = &source[start..index];
1474
- }
1475
- }
1476
- let name = if lower {
1477
- name.to_ascii_lowercase()
1478
- } else {
1479
- name.into()
1480
- };
1481
- attrs.entry(name).or_insert_with(|| {
1482
- Value::String(if decode {
1483
- decode_entities_mode(value, xml, true, codec)
1484
- } else {
1485
- value.into()
1486
- })
1487
- });
1488
- }
1489
- attrs
1490
- }
1491
1160
  pub(crate) fn decode_entities_mode(
1492
1161
  source: &str,
1493
1162
  xml: bool,
@@ -1734,47 +1403,7 @@ pub(crate) fn primitive(arena: &mut DomArena, command: &str, args: &Value) -> Re
1734
1403
  }
1735
1404
  Value::Null
1736
1405
  }
1737
- "uniqueSort" => {
1738
- let mut ids = ids;
1739
- let mut unique = HashSet::new();
1740
- ids.retain(|id| unique.insert(*id));
1741
- let ancestry = |id: u32| {
1742
- let mut path = vec![id];
1743
- let mut seen = HashSet::from([id]);
1744
- let mut parent = arena.node(id).and_then(|node| node.parent);
1745
- while let Some(id) = parent {
1746
- if !seen.insert(id) {
1747
- break;
1748
- }
1749
- path.push(id);
1750
- parent = arena.node(id).and_then(|node| node.parent);
1751
- }
1752
- path.reverse();
1753
- path
1754
- };
1755
- let paths: HashMap<_, _> = ids.iter().map(|id| (*id, ancestry(*id))).collect();
1756
- ids.sort_by(|a, b| {
1757
- use std::cmp::Ordering;
1758
- let (a, b) = (&paths[a], &paths[b]);
1759
- if a.first() != b.first() {
1760
- return Ordering::Equal;
1761
- }
1762
- for index in 0..a.len().min(b.len()) {
1763
- if a[index] != b[index] {
1764
- let siblings = arena
1765
- .node(a[index - 1])
1766
- .map(|node| node.children.as_slice())
1767
- .unwrap_or(&[]);
1768
- return siblings
1769
- .iter()
1770
- .position(|id| *id == a[index])
1771
- .cmp(&siblings.iter().position(|id| *id == b[index]));
1772
- }
1773
- }
1774
- a.len().cmp(&b.len())
1775
- });
1776
- json!(ids)
1777
- }
1406
+ "uniqueSort" => json!(crate::document_order::unique_sort(&*arena, &ids)),
1778
1407
  "contains" => {
1779
1408
  let parent = args
1780
1409
  .get("container")