rusteerio 1.0.1 → 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 (57) 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 +107 -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 +219 -80
  22. package/native/src/attributes.rs +11 -5
  23. package/native/src/document_order.rs +711 -0
  24. package/native/src/extraction.rs +15 -3
  25. package/native/src/gc.rs +12 -3
  26. package/native/src/id_hash.rs +42 -0
  27. package/native/src/immutable.rs +256 -0
  28. package/native/src/lib.rs +1039 -171
  29. package/native/src/runtime.rs +189 -33
  30. package/native/src/selector.rs +346 -44
  31. package/native/src/sink.rs +14 -1
  32. package/native/src/snapshot.rs +509 -0
  33. package/native/src/stream.rs +2 -2
  34. package/native/src/tokenizer.rs +29 -12
  35. package/native/vendor/html5ever-0.35.0/src/tokenizer/char_ref/mod.rs +79 -0
  36. package/package.json +3 -1
  37. package/rusteerio.darwin-arm64.node +0 -0
  38. package/rusteerio.darwin-x64.node +0 -0
  39. package/rusteerio.linux-arm64-gnu.node +0 -0
  40. package/rusteerio.linux-arm64-musl.node +0 -0
  41. package/rusteerio.linux-x64-gnu.node +0 -0
  42. package/rusteerio.linux-x64-musl.node +0 -0
  43. package/rusteerio.win32-arm64-msvc.node +0 -0
  44. package/rusteerio.win32-x64-msvc.node +0 -0
  45. package/src/attributes.ts +78 -44
  46. package/src/codec.ts +22 -3
  47. package/src/extract.ts +86 -2
  48. package/src/hooks.ts +148 -2
  49. package/src/index.ts +51 -40
  50. package/src/match.ts +207 -0
  51. package/src/native.ts +9 -1
  52. package/src/nodes.ts +531 -183
  53. package/src/router.ts +792 -229
  54. package/src/selection.ts +239 -125
  55. package/src/snapshot.ts +255 -0
  56. package/src/transport.ts +51 -42
  57. package/dist/chunk-DMRD3FGC.mjs.map +0 -1
package/dist/slim.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { contains, dom, merge } from './index.mjs';
2
- import { L as LoadContent, C as CheerioOptions, a as CheerioAPI } from './types-BP6TpxE5.mjs';
3
- export { A as AcceptedElems, b as AcceptedFilters, c as AnyNode, B as BasicAcceptedElems, d as CDATA, type e as Cheerio, f as CheerioRequestOptions, g as ChildNode, h as Comment, D as DataNode, i as DecodeStreamOptions, j as Document, k as DocumentMode, E as Element, type l as ElementType, m as EncodingOptions, n as ExtractDescriptor, o as ExtractMap, p as ExtractedMap, q as ExtractedValue, r as ExtractionDescriptor, s as ExtractionMap, t as ExtractionResult, F as FilterFunction, u as FormDataLike, H as HTMLParser2Options, I as InternalOptions, N as NamespaceURI, v as Node, w as NodeWithChildren, P as ParallelOptions, x as ParentNode, y as ParserError, z as ParserErrorCode, G as ParserErrorHandler, J as ProcessingInstruction, S as SelectorType, K as SerializedField, M as SourceCodeLocation, T as TagSourceCodeLocation, O as Text, Q as Tokenizer, R as TokenizerCallbacks, U as TokenizerConstructor, V as TokenizerOptions, W as TreeAdapter, X as TreeAdapterAttribute } from './types-BP6TpxE5.mjs';
2
+ import { L as LoadContent, C as CheerioOptions, a as CheerioAPI } from './types-Cs9UP1Vv.mjs';
3
+ export { A as AcceptedElems, b as AcceptedFilters, c as AnyNode, B as BasicAcceptedElems, d as CDATA, type e as Cheerio, f as CheerioRequestOptions, g as ChildNode, h as Comment, D as DataNode, i as DecodeStreamOptions, j as Document, k as DocumentMode, E as Element, type l as ElementType, m as EncodingOptions, n as ExtractDescriptor, o as ExtractMap, p as ExtractedMap, q as ExtractedValue, r as ExtractionDescriptor, s as ExtractionMap, t as ExtractionResult, F as FilterFunction, u as FormDataLike, H as HTMLParser2Options, I as InternalOptions, N as NamespaceURI, v as Node, w as NodeWithChildren, P as ParallelOptions, x as ParentNode, y as ParserError, z as ParserErrorCode, G as ParserErrorHandler, J as ProcessingInstruction, S as SelectorType, K as SerializedField, M as SourceCodeLocation, T as TagSourceCodeLocation, O as Text, Q as Tokenizer, R as TokenizerCallbacks, U as TokenizerConstructor, V as TokenizerOptions, W as TreeAdapter, X as TreeAdapterAttribute } from './types-Cs9UP1Vv.mjs';
4
4
  import 'node:stream';
5
5
  import 'node:querystring';
6
6
  import 'node:buffer';
package/dist/slim.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { contains, dom, merge } from './index.js';
2
- import { L as LoadContent, C as CheerioOptions, a as CheerioAPI } from './types-BP6TpxE5.js';
3
- export { A as AcceptedElems, b as AcceptedFilters, c as AnyNode, B as BasicAcceptedElems, d as CDATA, type e as Cheerio, f as CheerioRequestOptions, g as ChildNode, h as Comment, D as DataNode, i as DecodeStreamOptions, j as Document, k as DocumentMode, E as Element, type l as ElementType, m as EncodingOptions, n as ExtractDescriptor, o as ExtractMap, p as ExtractedMap, q as ExtractedValue, r as ExtractionDescriptor, s as ExtractionMap, t as ExtractionResult, F as FilterFunction, u as FormDataLike, H as HTMLParser2Options, I as InternalOptions, N as NamespaceURI, v as Node, w as NodeWithChildren, P as ParallelOptions, x as ParentNode, y as ParserError, z as ParserErrorCode, G as ParserErrorHandler, J as ProcessingInstruction, S as SelectorType, K as SerializedField, M as SourceCodeLocation, T as TagSourceCodeLocation, O as Text, Q as Tokenizer, R as TokenizerCallbacks, U as TokenizerConstructor, V as TokenizerOptions, W as TreeAdapter, X as TreeAdapterAttribute } from './types-BP6TpxE5.js';
2
+ import { L as LoadContent, C as CheerioOptions, a as CheerioAPI } from './types-Cs9UP1Vv.js';
3
+ export { A as AcceptedElems, b as AcceptedFilters, c as AnyNode, B as BasicAcceptedElems, d as CDATA, type e as Cheerio, f as CheerioRequestOptions, g as ChildNode, h as Comment, D as DataNode, i as DecodeStreamOptions, j as Document, k as DocumentMode, E as Element, type l as ElementType, m as EncodingOptions, n as ExtractDescriptor, o as ExtractMap, p as ExtractedMap, q as ExtractedValue, r as ExtractionDescriptor, s as ExtractionMap, t as ExtractionResult, F as FilterFunction, u as FormDataLike, H as HTMLParser2Options, I as InternalOptions, N as NamespaceURI, v as Node, w as NodeWithChildren, P as ParallelOptions, x as ParentNode, y as ParserError, z as ParserErrorCode, G as ParserErrorHandler, J as ProcessingInstruction, S as SelectorType, K as SerializedField, M as SourceCodeLocation, T as TagSourceCodeLocation, O as Text, Q as Tokenizer, R as TokenizerCallbacks, U as TokenizerConstructor, V as TokenizerOptions, W as TreeAdapter, X as TreeAdapterAttribute } from './types-Cs9UP1Vv.js';
4
4
  import 'node:stream';
5
5
  import 'node:querystring';
6
6
  import 'node:buffer';
package/dist/slim.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  dom,
4
4
  load,
5
5
  merge
6
- } from "./chunk-DMRD3FGC.mjs";
6
+ } from "./chunk-FPRP6UIX.mjs";
7
7
  import "./chunk-OHPGEY3E.mjs";
8
8
 
9
9
  // src/slim.ts
@@ -69,14 +69,14 @@ interface AttributeMethods<T = AnyNode> {
69
69
  serialize<U extends AnyNode>(this: Cheerio<U>): string;
70
70
  }
71
71
 
72
- /** An array-like selection of stable handles into the Rust document arena. */
72
+ /** An array-like selection of native nodes. */
73
73
  declare class Cheerio<T = AnyNode> implements ArrayLike<T>, Iterable<T> {
74
74
  [index: number]: T;
75
+ length: number;
75
76
  options: CheerioOptions;
76
77
  _root: Cheerio<Document> | null;
77
78
  prevObject: Cheerio<any> | undefined;
78
- length: number;
79
- cheerio: "[cheerio object]";
79
+ cheerio: '[cheerio object]';
80
80
  constructor(api: CheerioAPI, document: NativeDocument, ids?: number[], root?: Cheerio<Document> | null, options?: CheerioOptions, values?: T[]);
81
81
  _make<R>(value: ArrayLike<R> | R | string, context?: BasicAcceptedElems<AnyNode>): Cheerio<R>;
82
82
  _parse(content: any, options: CheerioOptions, isDocument: boolean, context: ParentNode | null): Document;
@@ -69,14 +69,14 @@ interface AttributeMethods<T = AnyNode> {
69
69
  serialize<U extends AnyNode>(this: Cheerio<U>): string;
70
70
  }
71
71
 
72
- /** An array-like selection of stable handles into the Rust document arena. */
72
+ /** An array-like selection of native nodes. */
73
73
  declare class Cheerio<T = AnyNode> implements ArrayLike<T>, Iterable<T> {
74
74
  [index: number]: T;
75
+ length: number;
75
76
  options: CheerioOptions;
76
77
  _root: Cheerio<Document> | null;
77
78
  prevObject: Cheerio<any> | undefined;
78
- length: number;
79
- cheerio: "[cheerio object]";
79
+ cheerio: '[cheerio object]';
80
80
  constructor(api: CheerioAPI, document: NativeDocument, ids?: number[], root?: Cheerio<Document> | null, options?: CheerioOptions, values?: T[]);
81
81
  _make<R>(value: ArrayLike<R> | R | string, context?: BasicAcceptedElems<AnyNode>): Cheerio<R>;
82
82
  _parse(content: any, options: CheerioOptions, isDocument: boolean, context: ParentNode | null): Document;
package/dist/utils.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { e as Cheerio, c as AnyNode, E as Element } from './types-BP6TpxE5.mjs';
1
+ import { e as Cheerio, c as AnyNode, E as Element } from './types-Cs9UP1Vv.mjs';
2
2
  import 'node:querystring';
3
3
  import 'node:stream';
4
4
  import 'node:buffer';
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { e as Cheerio, c as AnyNode, E as Element } from './types-BP6TpxE5.js';
1
+ import { e as Cheerio, c as AnyNode, E as Element } from './types-Cs9UP1Vv.js';
2
2
  import 'node:querystring';
3
3
  import 'node:stream';
4
4
  import 'node:buffer';
@@ -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,12 +512,10 @@ 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
- });
515
+ .then_some((
516
+ node.attribs_namespace.as_deref(),
517
+ node.attribs_prefix.as_deref(),
518
+ ));
375
519
  let attributes = super::js_property_order(
376
520
  node.attribs
377
521
  .iter()
@@ -420,9 +564,8 @@ impl DomArena {
420
564
  }
421
565
  let is_void = !xml
422
566
  && (node
423
- .extras
424
- .get("namespace")
425
- .and_then(Value::as_str)
567
+ .namespace
568
+ .as_deref()
426
569
  .is_none_or(|namespace| namespace == super::HTML_NS))
427
570
  && super::void_name(name);
428
571
  if xml
@@ -534,28 +677,44 @@ impl DomArena {
534
677
  roots
535
678
  }
536
679
  pub(crate) fn create_fragment(&mut self, html: &str, context: Option<u32>) -> Result<Vec<u32>> {
537
- let context_name = context
538
- .and_then(|id| self.node(id))
539
- .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);
540
688
  let mut options = self.options.clone();
541
- if let Some(namespace) = context
542
- .and_then(|id| self.node(id))
543
- .and_then(|node| node.extras.get("namespace"))
544
- .cloned()
545
- {
546
- options["__contextNamespace"] = namespace;
689
+ if let Some(namespace) = namespace {
690
+ options["__contextNamespace"] = Value::String(namespace);
547
691
  }
548
692
  let document =
549
693
  super::NativeDocument::parse_options(html, false, options, context_name.as_deref());
550
694
  document.ensure_arena()?;
551
- let arena = document.arena.borrow();
552
- let parsed = arena.as_ref().unwrap();
553
- let roots = parsed.nodes[0].children.clone();
554
- 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
+ )
555
704
  }
556
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();
557
709
  for id in ids {
558
- 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);
559
718
  self.set_children(*id, &children);
560
719
  }
561
720
  Ok(())
@@ -725,11 +884,18 @@ impl DomArena {
725
884
  match field {
726
885
  "name" | "tagName" => node.name = value.as_str().map(str::to_owned),
727
886
  "data" | "nodeValue" => node.data = value.as_str().map(str::to_owned),
728
- "type" => node.kind = value.as_str().unwrap_or("tag").into(),
887
+ "type" => node.kind = node_kind(value.as_str().unwrap_or("tag")),
729
888
  "attribs" => node.attribs = value.as_object().cloned().unwrap_or_default(),
730
889
  "parent" => node.parent = value.as_u64().map(|id| id as u32),
731
890
  "prev" => node.prev = value.as_u64().map(|id| id as u32),
732
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
+ }
733
899
  _ => {
734
900
  if value.is_null() {
735
901
  node.extras.remove(field);
@@ -744,7 +910,7 @@ impl DomArena {
744
910
 
745
911
  impl super::selector::SelectDom for DomArena {
746
912
  fn kind(&self, id: u32) -> &str {
747
- self.node(id).map_or("", |node| node.kind.as_str())
913
+ self.node(id).map_or("", |node| node.kind.as_ref())
748
914
  }
749
915
  fn name(&self, id: u32) -> Option<&str> {
750
916
  self.node(id)?.name.as_deref()
@@ -787,6 +953,13 @@ impl super::selector::SelectDom for DomArena {
787
953
  fn pseudo_alias(&self, name: &str) -> Option<&str> {
788
954
  self.options.get("pseudos")?.get(name)?.as_str()
789
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
+ }
790
963
  }
791
964
 
792
965
  pub(crate) fn value_ids(value: &Value) -> Vec<u32> {
@@ -916,6 +1089,12 @@ pub(crate) fn append_parsed(
916
1089
  start: usize,
917
1090
  end: usize,
918
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
+ });
919
1098
  if node.kind == "text" {
920
1099
  if let Some(previous) = arena.nodes[parent as usize]
921
1100
  .children
@@ -927,7 +1106,7 @@ pub(crate) fn append_parsed(
927
1106
  .data
928
1107
  .get_or_insert_with(String::new)
929
1108
  .push_str(node.data.as_deref().unwrap_or(""));
930
- if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1109
+ if end_indices {
931
1110
  arena.nodes[previous as usize]
932
1111
  .extras
933
1112
  .insert("endIndex".into(), json!(end));
@@ -935,10 +1114,10 @@ pub(crate) fn append_parsed(
935
1114
  return previous;
936
1115
  }
937
1116
  }
938
- if arena.options.get("withStartIndices") == Some(&Value::Bool(true)) {
1117
+ if start_indices {
939
1118
  node.extras.insert("startIndex".into(), json!(start));
940
1119
  }
941
- if arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
1120
+ if end_indices {
942
1121
  node.extras.insert("endIndex".into(), json!(end));
943
1122
  }
944
1123
  let previous = arena.nodes[parent as usize].children.last().copied();
@@ -1224,47 +1403,7 @@ pub(crate) fn primitive(arena: &mut DomArena, command: &str, args: &Value) -> Re
1224
1403
  }
1225
1404
  Value::Null
1226
1405
  }
1227
- "uniqueSort" => {
1228
- let mut ids = ids;
1229
- let mut unique = HashSet::new();
1230
- ids.retain(|id| unique.insert(*id));
1231
- let ancestry = |id: u32| {
1232
- let mut path = vec![id];
1233
- let mut seen = HashSet::from([id]);
1234
- let mut parent = arena.node(id).and_then(|node| node.parent);
1235
- while let Some(id) = parent {
1236
- if !seen.insert(id) {
1237
- break;
1238
- }
1239
- path.push(id);
1240
- parent = arena.node(id).and_then(|node| node.parent);
1241
- }
1242
- path.reverse();
1243
- path
1244
- };
1245
- let paths: HashMap<_, _> = ids.iter().map(|id| (*id, ancestry(*id))).collect();
1246
- ids.sort_by(|a, b| {
1247
- use std::cmp::Ordering;
1248
- let (a, b) = (&paths[a], &paths[b]);
1249
- if a.first() != b.first() {
1250
- return Ordering::Equal;
1251
- }
1252
- for index in 0..a.len().min(b.len()) {
1253
- if a[index] != b[index] {
1254
- let siblings = arena
1255
- .node(a[index - 1])
1256
- .map(|node| node.children.as_slice())
1257
- .unwrap_or(&[]);
1258
- return siblings
1259
- .iter()
1260
- .position(|id| *id == a[index])
1261
- .cmp(&siblings.iter().position(|id| *id == b[index]));
1262
- }
1263
- }
1264
- a.len().cmp(&b.len())
1265
- });
1266
- json!(ids)
1267
- }
1406
+ "uniqueSort" => json!(crate::document_order::unique_sort(&*arena, &ids)),
1268
1407
  "contains" => {
1269
1408
  let parent = args
1270
1409
  .get("container")
@@ -173,7 +173,7 @@ fn set_css(arena: &mut DomArena, ids: &[u32], name: &str, value: &Value) {
173
173
  }
174
174
  let mut properties = parse_css(arena.attr(*id, "style").unwrap_or(""));
175
175
  if value.as_str() == Some("") {
176
- properties.remove(name);
176
+ properties.shift_remove(name);
177
177
  } else if !value.is_null() {
178
178
  properties.insert(name.into(), json!(js_string(value)));
179
179
  }
@@ -207,9 +207,15 @@ fn node_property(arena: &DomArena, id: u32, name: &str) -> bool {
207
207
  | "type"
208
208
  | "startIndex"
209
209
  | "endIndex"
210
- ) || arena
211
- .node(id)
212
- .is_some_and(|node| node.extras.contains_key(name))
210
+ ) || arena.node(id).is_some_and(|node| {
211
+ node.extras.contains_key(name)
212
+ || match name {
213
+ "namespace" => node.namespace.is_some(),
214
+ "attribsNamespace" => node.attribs_namespace.is_some(),
215
+ "attribsPrefix" => node.attribs_prefix.is_some(),
216
+ _ => false,
217
+ }
218
+ })
213
219
  }
214
220
 
215
221
  fn get_prop(arena: &DomArena, id: u32, name: &str) -> napi::Result<Value> {
@@ -682,7 +688,7 @@ pub(crate) fn execute(
682
688
  .and_then(|node| node.extras.get_mut("data"))
683
689
  .and_then(Value::as_object_mut)
684
690
  {
685
- data.remove(name);
691
+ data.shift_remove(name);
686
692
  }
687
693
  }
688
694
  }