rusteerio 1.0.1 → 1.1.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.
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 +527 -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
@@ -0,0 +1,527 @@
1
+ //! A compact copy of every node record that JavaScript decodes lazily. One call
2
+ //! replaces a native round trip per node when a document is read node by node.
3
+ //!
4
+ //! Each node occupies `STRIDE` entries of `nodes`:
5
+ //! 0 type, 1 parent + 1, 2 previous sibling + 1, 3 next sibling + 1,
6
+ //! 4 first entry in `children`, 5 child count, 6 name + 1, 7 data + 1,
7
+ //! 8 first pair in `attributes`, 9 attribute count, 10 flags, 11 start index,
8
+ //! 12 end index, 13 namespace, 14 remaining fields as JSON + 1.
9
+ //! String references index pairs of UTF-16 offsets into `text`.
10
+ //!
11
+ //! JavaScript receives one `Uint32Array`: the lengths of `nodes`, `children`,
12
+ //! `attributes` and `strings`, whether any string holds codec escapes, then
13
+ //! the four lists. String offsets count UTF-16 units of the encoded text, so
14
+ //! JavaScript decodes only the strings it reads. Its memory belongs to V8,
15
+ //! which frees it as soon as the snapshot is collected; an external buffer
16
+ //! would add finalizer bookkeeping and wait for a later event-loop turn.
17
+ use html5ever::{LocalName, Namespace};
18
+ use napi::{Env, JsObject, JsTypedArray, Result, TypedArrayType};
19
+ use scraper::Node;
20
+ use serde_json::{Map, Value};
21
+
22
+ use crate::arena::DomNode;
23
+ use crate::id_hash::IdMap;
24
+ use crate::NativeDocument;
25
+
26
+ pub(crate) const STRIDE: usize = 15;
27
+ /// Text or attribute values longer than this stay out of snapshots: such
28
+ /// nodes, mostly inline scripts, styles, SVG paths and embedded data, are read
29
+ /// individually if JavaScript asks for them.
30
+ const LARGE_TEXT_BYTES: usize = 4096;
31
+ const ABSENT: u32 = u32::MAX;
32
+ const HAS_START_INDEX: u32 = 1;
33
+ const HAS_END_INDEX: u32 = 2;
34
+ const HAS_NAMESPACE: u32 = 4;
35
+ const EMPTY_ATTRIBUTE_MAPS: u32 = 8;
36
+ /// The record cannot be represented here; JavaScript reads it individually.
37
+ const OPAQUE: u32 = 16;
38
+ const RELEASED: u32 = 32;
39
+
40
+ pub(crate) struct NodeSnapshot {
41
+ is_encoded: bool,
42
+ nodes: Vec<u32>,
43
+ children: Vec<u32>,
44
+ attributes: Vec<u32>,
45
+ strings: Vec<u32>,
46
+ text: String,
47
+ }
48
+
49
+ impl NodeSnapshot {
50
+ pub(crate) fn into_js(self, env: &Env) -> Result<JsObject> {
51
+ let parts = [&self.nodes, &self.children, &self.attributes, &self.strings];
52
+ let lengths = parts.map(|part| part.len() as u32);
53
+ let header = [
54
+ lengths[0],
55
+ lengths[1],
56
+ lengths[2],
57
+ lengths[3],
58
+ u32::from(self.is_encoded),
59
+ ];
60
+ let length = header.len() + parts.iter().map(|part| part.len()).sum::<usize>();
61
+ let mut buffer = env.create_arraybuffer(length * 4)?;
62
+ // SAFETY: every bit pattern is a valid `u32`; `align_to_mut` puts any
63
+ // misaligned bytes in the prefix, which is checked to be empty.
64
+ let (prefix, words, _) = unsafe { buffer.align_to_mut::<u32>() };
65
+ if !prefix.is_empty() || words.len() < length {
66
+ return Err(napi::Error::from_reason("Unaligned snapshot buffer"));
67
+ }
68
+ let mut offset = 0;
69
+ for part in std::iter::once(&header[..]).chain(parts.iter().map(|part| part.as_slice())) {
70
+ words[offset..offset + part.len()].copy_from_slice(part);
71
+ offset += part.len();
72
+ }
73
+ let data: JsTypedArray =
74
+ buffer
75
+ .into_raw()
76
+ .into_typedarray(TypedArrayType::Uint32, length, 0)?;
77
+ let mut object = env.create_object()?;
78
+ object.set_named_property("data", data)?;
79
+ // V8 decodes UTF-8 slowly; text copies as Latin-1 when every
80
+ // character fits, which also keeps V8's compact one-byte string.
81
+ let text = if self.text.is_ascii() {
82
+ env.create_string_latin1(self.text.as_bytes())?
83
+ } else if let Some(latin1) = latin1_bytes(&self.text) {
84
+ env.create_string_latin1(&latin1)?
85
+ } else {
86
+ env.create_string_utf16(&utf16_units(&self.text))?
87
+ };
88
+ object.set_named_property("text", text)?;
89
+ Ok(object)
90
+ }
91
+ }
92
+
93
+ /// `text` in Latin-1, if every character is below U+0100: UTF-8 encodes
94
+ /// those with lead bytes below 0xC4.
95
+ fn latin1_bytes(text: &str) -> Option<Vec<u8>> {
96
+ if text.bytes().any(|byte| byte >= 0xC4) {
97
+ return None;
98
+ }
99
+ Some(text.chars().map(|character| character as u8).collect())
100
+ }
101
+
102
+ /// `text` as UTF-16, widening eight ASCII bytes at a time and decoding other
103
+ /// characters directly from their UTF-8 bytes, which `text` guarantees valid.
104
+ fn utf16_units(text: &str) -> Vec<u16> {
105
+ const HIGH_BITS: u64 = 0x8080_8080_8080_8080;
106
+ let bytes = text.as_bytes();
107
+ let mut units = Vec::with_capacity(bytes.len());
108
+ let mut index = 0;
109
+ while index < bytes.len() {
110
+ if let Some(chunk) = bytes.get(index..index + 8) {
111
+ let word = u64::from_le_bytes(chunk.try_into().unwrap());
112
+ if word & HIGH_BITS == 0 {
113
+ units.extend(chunk.iter().map(|byte| u16::from(*byte)));
114
+ index += 8;
115
+ continue;
116
+ }
117
+ }
118
+ let lead = u32::from(bytes[index]);
119
+ let continuation = |offset: usize| u32::from(bytes[index + offset] & 0x3F);
120
+ let (scalar, length) = match lead {
121
+ 0x00..=0x7F => (lead, 1),
122
+ 0x80..=0xDF => (((lead & 0x1F) << 6) | continuation(1), 2),
123
+ 0xE0..=0xEF => (
124
+ ((lead & 0x0F) << 12) | (continuation(1) << 6) | continuation(2),
125
+ 3,
126
+ ),
127
+ _ => (
128
+ ((lead & 0x07) << 18)
129
+ | (continuation(1) << 12)
130
+ | (continuation(2) << 6)
131
+ | continuation(3),
132
+ 4,
133
+ ),
134
+ };
135
+ if scalar < 0x1_0000 {
136
+ units.push(scalar as u16);
137
+ } else {
138
+ let offset = scalar - 0x1_0000;
139
+ units.push(0xD800 | (offset >> 10) as u16);
140
+ units.push(0xDC00 | (offset & 0x3FF) as u16);
141
+ }
142
+ index += length;
143
+ }
144
+ units
145
+ }
146
+
147
+ /// The UTF-16 length of `text`: one unit per character, two for characters
148
+ /// beyond the basic plane, counted from UTF-8 lead bytes.
149
+ fn utf16_length(text: &str) -> usize {
150
+ text.bytes()
151
+ .map(|byte| usize::from(byte & 0xC0 != 0x80) + usize::from(byte >= 0xF0))
152
+ .sum()
153
+ }
154
+
155
+ struct Builder {
156
+ nodes: Vec<u32>,
157
+ children: Vec<u32>,
158
+ attributes: Vec<u32>,
159
+ strings: Vec<u32>,
160
+ text: String,
161
+ units: u32,
162
+ interned: IdMap<String, u32>,
163
+ /// html5ever names are atoms that hash and compare by identity.
164
+ local_names: IdMap<LocalName, u32>,
165
+ namespaces: IdMap<Namespace, u32>,
166
+ codec: bool,
167
+ is_encoded: bool,
168
+ }
169
+
170
+ pub(crate) fn build(document: &NativeDocument) -> NodeSnapshot {
171
+ let codec = document.options.get("__utf16Codec") == Some(&Value::Bool(true))
172
+ || document.options.get("__losslessUnicode") == Some(&Value::Bool(true));
173
+ let count = document.slot_count() as usize;
174
+ let mut builder = Builder {
175
+ nodes: Vec::with_capacity(count * STRIDE),
176
+ children: Vec::with_capacity(count),
177
+ attributes: Vec::new(),
178
+ strings: Vec::with_capacity(count * 2),
179
+ text: String::with_capacity(document.content_bytes.get()),
180
+ units: 0,
181
+ interned: IdMap::with_capacity_and_hasher(32, Default::default()),
182
+ local_names: IdMap::with_capacity_and_hasher(32, Default::default()),
183
+ namespaces: IdMap::default(),
184
+ codec,
185
+ is_encoded: false,
186
+ };
187
+ if let Some(arena) = document.arena.borrow().as_ref() {
188
+ builder.nodes.reserve(arena.nodes.len() * STRIDE);
189
+ for id in 0..arena.nodes.len() {
190
+ match arena.node(id as u32) {
191
+ Some(node) => builder.encode(node),
192
+ None => builder.released(),
193
+ }
194
+ }
195
+ } else {
196
+ builder.encode_immutable(document);
197
+ }
198
+ NodeSnapshot {
199
+ is_encoded: builder.is_encoded,
200
+ nodes: builder.nodes,
201
+ children: builder.children,
202
+ attributes: builder.attributes,
203
+ strings: builder.strings,
204
+ text: builder.text,
205
+ }
206
+ }
207
+
208
+ /// The JSON fields a document root's record carries, as `convert_node` sets them.
209
+ fn quirks_mode_json(mode: html5ever::tree_builder::QuirksMode) -> &'static str {
210
+ match mode {
211
+ html5ever::tree_builder::QuirksMode::Quirks => r#"{"x-mode":"quirks"}"#,
212
+ html5ever::tree_builder::QuirksMode::LimitedQuirks => r#"{"x-mode":"limited-quirks"}"#,
213
+ html5ever::tree_builder::QuirksMode::NoQuirks => r#"{"x-mode":"no-quirks"}"#,
214
+ }
215
+ }
216
+
217
+ /// The JSON fields a doctype's record carries, as `convert_node` sets them.
218
+ fn doctype_fields_json(doctype: &scraper::node::Doctype) -> String {
219
+ let quoted = |value: &str| serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into());
220
+ format!(
221
+ r#"{{"x-name":{},"x-publicId":{},"x-systemId":{}}}"#,
222
+ quoted(doctype.name()),
223
+ quoted(doctype.public_id()),
224
+ quoted(doctype.system_id())
225
+ )
226
+ }
227
+
228
+ fn pointer(value: Option<u32>) -> u32 {
229
+ value.map_or(0, |id| id + 1)
230
+ }
231
+
232
+ impl Builder {
233
+ fn string(&mut self, value: &str) -> u32 {
234
+ let start = self.units;
235
+ self.text.push_str(value);
236
+ let units = if value.is_ascii() {
237
+ value.len()
238
+ } else {
239
+ self.is_encoded |= self.codec && crate::codec::has_encoding(value);
240
+ utf16_length(value)
241
+ };
242
+ self.units += units as u32;
243
+ self.strings.push(start);
244
+ self.strings.push(self.units);
245
+ (self.strings.len() / 2 - 1) as u32
246
+ }
247
+
248
+ fn interned(&mut self, value: &str) -> u32 {
249
+ if let Some(reference) = self.interned.get(value) {
250
+ return *reference;
251
+ }
252
+ let reference = self.string(value);
253
+ self.interned.insert(value.to_owned(), reference);
254
+ reference
255
+ }
256
+
257
+ fn local_name(&mut self, name: &LocalName) -> u32 {
258
+ if let Some(reference) = self.local_names.get(name) {
259
+ return *reference;
260
+ }
261
+ let reference = self.interned(name);
262
+ self.local_names.insert(name.clone(), reference);
263
+ reference
264
+ }
265
+
266
+ fn namespace(&mut self, namespace: &Namespace) -> u32 {
267
+ if let Some(reference) = self.namespaces.get(namespace) {
268
+ return *reference;
269
+ }
270
+ let reference = self.interned(namespace);
271
+ self.namespaces.insert(namespace.clone(), reference);
272
+ reference
273
+ }
274
+
275
+ fn released(&mut self) {
276
+ let mut fields = [0; STRIDE];
277
+ fields[10] = RELEASED;
278
+ self.nodes.extend_from_slice(&fields);
279
+ }
280
+
281
+ fn encode(&mut self, node: &DomNode) {
282
+ let mut fields = [0; STRIDE];
283
+ fields[0] = self.interned(&node.kind);
284
+ fields[1] = pointer(node.parent);
285
+ fields[2] = pointer(node.prev);
286
+ fields[3] = pointer(node.next);
287
+ fields[4] = self.children.len() as u32;
288
+ fields[5] = node.children.len() as u32;
289
+ self.children.extend_from_slice(&node.children);
290
+ fields[6] = node
291
+ .name
292
+ .as_deref()
293
+ .map_or(0, |name| self.interned(name) + 1);
294
+ let mut flags = 0;
295
+ match node.data.as_deref() {
296
+ Some(data) if data.len() > LARGE_TEXT_BYTES => flags |= OPAQUE,
297
+ Some(data) => fields[7] = self.string(data) + 1,
298
+ None => {}
299
+ }
300
+ fields[8] = (self.attributes.len() / 2) as u32;
301
+ let has_large_attribute = node.attribs.values().any(|value| {
302
+ value
303
+ .as_str()
304
+ .is_some_and(|value| value.len() > LARGE_TEXT_BYTES)
305
+ });
306
+ if has_large_attribute {
307
+ flags |= OPAQUE;
308
+ } else {
309
+ fields[9] = node.attribs.len() as u32;
310
+ }
311
+ for (name, value) in node.attribs.iter().filter(|_| !has_large_attribute) {
312
+ let Some(value) = value.as_str() else {
313
+ flags |= OPAQUE;
314
+ break;
315
+ };
316
+ let name = self.interned(name);
317
+ let value = self.string(value);
318
+ self.attributes.extend_from_slice(&[name, value]);
319
+ }
320
+ fields[11] = ABSENT;
321
+ fields[12] = ABSENT;
322
+ let mut rest = Map::new();
323
+ if let Some(namespace) = &node.namespace {
324
+ flags |= HAS_NAMESPACE;
325
+ fields[13] = self.interned(namespace);
326
+ }
327
+ match (&node.attribs_namespace, &node.attribs_prefix) {
328
+ (Some(namespaces), Some(prefixes)) if namespaces.is_empty() && prefixes.is_empty() => {
329
+ flags |= EMPTY_ATTRIBUTE_MAPS;
330
+ }
331
+ (namespaces, prefixes) => {
332
+ if let Some(namespaces) = namespaces {
333
+ rest.insert(
334
+ "attribsNamespace".into(),
335
+ Value::Object((**namespaces).clone()),
336
+ );
337
+ }
338
+ if let Some(prefixes) = prefixes {
339
+ rest.insert("attribsPrefix".into(), Value::Object((**prefixes).clone()));
340
+ }
341
+ }
342
+ }
343
+ for (key, value) in &node.extras {
344
+ let index = value.as_u64().filter(|index| *index < u64::from(ABSENT));
345
+ match (key.as_str(), value) {
346
+ ("startIndex", Value::Null) => flags |= HAS_START_INDEX,
347
+ ("startIndex", _) if index.is_some() => {
348
+ flags |= HAS_START_INDEX;
349
+ fields[11] = index.unwrap() as u32;
350
+ }
351
+ ("endIndex", Value::Null) => flags |= HAS_END_INDEX,
352
+ ("endIndex", _) if index.is_some() => {
353
+ flags |= HAS_END_INDEX;
354
+ fields[12] = index.unwrap() as u32;
355
+ }
356
+ _ => {
357
+ rest.insert(key.clone(), value.clone());
358
+ }
359
+ }
360
+ }
361
+ if !rest.is_empty() {
362
+ match serde_json::to_string(&rest) {
363
+ Ok(json) => fields[14] = self.interned(&json) + 1,
364
+ Err(_) => flags |= OPAQUE,
365
+ }
366
+ }
367
+ fields[10] = flags;
368
+ self.nodes.extend_from_slice(&fields);
369
+ }
370
+
371
+ /// Mirrors `NativeDocument::immutable_dom_node` without building records
372
+ /// for elements, text and comments; other nodes use that record directly.
373
+ fn encode_immutable(&mut self, document: &NativeDocument) {
374
+ let html = document.html.borrow();
375
+ let ids = document.ids.borrow();
376
+ let reverse = document.reverse.borrow();
377
+ let Some(html) = html.as_ref() else {
378
+ return;
379
+ };
380
+ let local = |node: Option<ego_tree::NodeRef<'_, Node>>| {
381
+ node.and_then(|node| reverse.get(&node.id()).copied())
382
+ };
383
+ let text_type = self.interned("text");
384
+ let comment_type = self.interned("comment");
385
+ let tag_type = self.interned("tag");
386
+ let script_type = self.interned("script");
387
+ let style_type = self.interned("style");
388
+ for (index, node_id) in ids.iter().enumerate() {
389
+ let Some(node) = html.tree.get(*node_id) else {
390
+ self.released();
391
+ continue;
392
+ };
393
+ let is_plain_element = matches!(node.value(), Node::Element(element)
394
+ if element.name.prefix.is_none()
395
+ && element.attrs.iter().all(|(name, _)| name.ns.is_empty() && name.prefix.is_none()));
396
+ let is_direct = is_plain_element
397
+ || matches!(
398
+ node.value(),
399
+ Node::Text(_)
400
+ | Node::Comment(_)
401
+ | Node::Document
402
+ | Node::Fragment
403
+ | Node::Doctype(_)
404
+ );
405
+ if !is_direct {
406
+ match document.immutable_dom_node(index as u32) {
407
+ Some(record) => self.encode(&record),
408
+ None => self.released(),
409
+ }
410
+ continue;
411
+ }
412
+ let mut fields = [0; STRIDE];
413
+ fields[1] = pointer(local(node.parent()));
414
+ fields[2] = pointer(local(node.prev_sibling()));
415
+ fields[3] = pointer(local(node.next_sibling()));
416
+ fields[4] = self.children.len() as u32;
417
+ let first_child = self.children.len();
418
+ self.children.extend(
419
+ node.children()
420
+ .filter_map(|child| reverse.get(&child.id()).copied()),
421
+ );
422
+ fields[5] = (self.children.len() - first_child) as u32;
423
+ fields[8] = (self.attributes.len() / 2) as u32;
424
+ fields[11] = ABSENT;
425
+ fields[12] = ABSENT;
426
+ let mut flags = HAS_START_INDEX | HAS_END_INDEX;
427
+ match node.value() {
428
+ Node::Document => {
429
+ fields[0] = self.interned("root");
430
+ fields[14] = self.interned(quirks_mode_json(html.quirks_mode)) + 1;
431
+ flags = 0;
432
+ }
433
+ Node::Fragment => {
434
+ fields[0] = self.interned("root");
435
+ flags = 0;
436
+ }
437
+ Node::Doctype(doctype) => {
438
+ fields[0] = self.interned("directive");
439
+ fields[6] = self.interned("!doctype") + 1;
440
+ fields[7] = self.string(&crate::doctype_content(doctype)) + 1;
441
+ fields[14] = self.interned(&doctype_fields_json(doctype)) + 1;
442
+ flags = 0;
443
+ }
444
+ Node::Text(text) => {
445
+ fields[0] = text_type;
446
+ if text.len() > LARGE_TEXT_BYTES {
447
+ flags |= OPAQUE;
448
+ } else {
449
+ fields[7] = self.string(text) + 1;
450
+ }
451
+ }
452
+ Node::Comment(comment) => {
453
+ fields[0] = comment_type;
454
+ if comment.len() > LARGE_TEXT_BYTES {
455
+ flags |= OPAQUE;
456
+ } else {
457
+ fields[7] = self.string(comment) + 1;
458
+ }
459
+ }
460
+ Node::Element(element) => {
461
+ fields[0] = match element.name() {
462
+ "script" => script_type,
463
+ "style" => style_type,
464
+ _ => tag_type,
465
+ };
466
+ fields[6] = self.local_name(&element.name.local) + 1;
467
+ if element
468
+ .attrs
469
+ .iter()
470
+ .any(|(_, value)| value.len() > LARGE_TEXT_BYTES)
471
+ {
472
+ flags |= OPAQUE;
473
+ } else {
474
+ for (attribute, value) in &element.attrs {
475
+ let attribute = self.local_name(&attribute.local);
476
+ let value = self.string(value);
477
+ self.attributes.extend_from_slice(&[attribute, value]);
478
+ }
479
+ fields[9] = element.attrs.len() as u32;
480
+ }
481
+ fields[13] = self.namespace(&element.name.ns);
482
+ flags |= HAS_NAMESPACE | EMPTY_ATTRIBUTE_MAPS;
483
+ }
484
+ _ => unreachable!("other node types use the record path"),
485
+ }
486
+ fields[10] = flags;
487
+ self.nodes.extend_from_slice(&fields);
488
+ }
489
+ }
490
+ }
491
+
492
+ #[cfg(test)]
493
+ mod tests {
494
+ use super::*;
495
+
496
+ #[test]
497
+ fn transcodes_text_like_the_standard_library() {
498
+ for text in [
499
+ "",
500
+ "ascii only",
501
+ "café ©",
502
+ "번개장터 “quoted” — dash",
503
+ "emoji 😀 and \u{e800}",
504
+ "\u{ff}\u{100}",
505
+ "a\u{10ffff}b",
506
+ ] {
507
+ let expected: Vec<u16> = text.encode_utf16().collect();
508
+ assert_eq!(utf16_units(text), expected, "{text}");
509
+ assert_eq!(utf16_length(text), expected.len(), "{text}");
510
+ let latin1 = latin1_bytes(text);
511
+ assert_eq!(
512
+ latin1.is_some(),
513
+ text.chars().all(|character| (character as u32) < 0x100),
514
+ "{text}"
515
+ );
516
+ if let Some(bytes) = latin1 {
517
+ assert_eq!(
518
+ bytes
519
+ .iter()
520
+ .map(|byte| u16::from(*byte))
521
+ .collect::<Vec<_>>(),
522
+ expected
523
+ );
524
+ }
525
+ }
526
+ }
527
+ }
@@ -211,7 +211,7 @@ impl Task for FinishTask {
211
211
  }
212
212
  enum Parser {
213
213
  Html(Box<html5ever::driver::Parser<sink::CompatibleSink>>),
214
- Xml(arena::XmlStream),
214
+ Xml(Box<arena::XmlStream>),
215
215
  }
216
216
  fn run(
217
217
  receiver: mpsc::Receiver<Message>,
@@ -222,7 +222,7 @@ fn run(
222
222
  let parser = if arena::xml_mode(&options)
223
223
  || options.get("_useHtmlParser2") == Some(&Value::Bool(true))
224
224
  {
225
- Parser::Xml(arena::XmlStream::new(options.clone()))
225
+ Parser::Xml(Box::new(arena::XmlStream::new(options.clone())))
226
226
  } else {
227
227
  let parse_options = html5ever::driver::ParseOpts {
228
228
  tokenizer: html5ever::tokenizer::TokenizerOpts {
@@ -23,6 +23,8 @@ struct Builder<'a> {
23
23
  xml: bool,
24
24
  lower_tags: bool,
25
25
  lower_attrs: bool,
26
+ with_end_indices: bool,
27
+ recognize_self_closing: Option<bool>,
26
28
  }
27
29
  impl<'a> Builder<'a> {
28
30
  fn new(source: &'a Source<'a>, options: Value) -> Self {
@@ -35,8 +37,13 @@ impl<'a> Builder<'a> {
35
37
  .get("lowerCaseAttributeNames")
36
38
  .and_then(Value::as_bool)
37
39
  .unwrap_or(!xml);
40
+ let with_end_indices = options.get("withEndIndices") == Some(&Value::Bool(true));
41
+ let recognize_self_closing = options.get("recognizeSelfClosing").and_then(Value::as_bool);
42
+ let mut arena = DomArena::from_records(vec![DomNode::new("root")], options);
43
+ // Growing the arena one doubling at a time copies every node record again.
44
+ arena.nodes.reserve(source.units.len() / 48);
38
45
  Self {
39
- arena: DomArena::from_records(vec![DomNode::new("root")], options),
46
+ arena,
40
47
  source,
41
48
  parents: vec![0],
42
49
  foreign: vec![xml],
@@ -48,6 +55,8 @@ impl<'a> Builder<'a> {
48
55
  xml,
49
56
  lower_tags,
50
57
  lower_attrs,
58
+ with_end_indices,
59
+ recognize_self_closing,
51
60
  }
52
61
  }
53
62
  fn slice(&self, start: usize, end: usize) -> String {
@@ -63,7 +72,7 @@ impl<'a> Builder<'a> {
63
72
  }
64
73
  fn close(&mut self) {
65
74
  if let Some(id) = (self.parents.len() > 1).then(|| self.parents.pop().unwrap()) {
66
- if self.arena.options.get("withEndIndices") == Some(&Value::Bool(true)) {
75
+ if self.with_end_indices {
67
76
  self.arena.nodes[id as usize]
68
77
  .extras
69
78
  .insert("endIndex".into(), json!(self.end));
@@ -109,12 +118,14 @@ impl<'a> Builder<'a> {
109
118
  fn end_open(&mut self, self_closing: bool) {
110
119
  if let Some(pending) = self.pending.take() {
111
120
  let is_void = !self.xml && html_parser_void(&pending.name);
112
- let kind = if !self.xml && matches!(pending.name.as_str(), "script" | "style") {
113
- pending.name.as_str()
114
- } else {
115
- "tag"
121
+ let kind = match pending.name.as_str() {
122
+ "script" if !self.xml => "script",
123
+ "style" if !self.xml => "style",
124
+ _ => "tag",
116
125
  };
117
126
  let mut node = DomNode::new(kind);
127
+ let lowercase_name = (pending.name.bytes().any(|byte| byte.is_ascii_uppercase()))
128
+ .then(|| pending.name.to_ascii_lowercase());
118
129
  node.name = Some(pending.name);
119
130
  node.attribs = pending.attrs;
120
131
  let id = append_parsed(
@@ -124,6 +135,17 @@ impl<'a> Builder<'a> {
124
135
  pending.start,
125
136
  self.end,
126
137
  );
138
+ let name = lowercase_name
139
+ .as_deref()
140
+ .or(self.arena.nodes[id as usize].name.as_deref())
141
+ .unwrap_or("");
142
+ let index = self.arena.name_index.get_or_insert_with(Default::default);
143
+ match index.get_mut(name) {
144
+ Some(ids) => ids.push(id),
145
+ None => {
146
+ index.insert(name.to_owned(), vec![id]);
147
+ }
148
+ }
127
149
  if !is_void && !self_closing {
128
150
  self.parents.push(id);
129
151
  }
@@ -370,12 +392,7 @@ impl Builder<'_> {
370
392
  }
371
393
  fn on_self_closing_tag(&mut self, end: usize) {
372
394
  self.end = end;
373
- let self_closing = self
374
- .arena
375
- .options
376
- .get("recognizeSelfClosing")
377
- .and_then(Value::as_bool)
378
- .unwrap_or(self.xml)
395
+ let self_closing = self.recognize_self_closing.unwrap_or(self.xml)
379
396
  || *self.foreign.last().unwrap_or(&self.xml);
380
397
  self.end_open(self_closing);
381
398
  self.start = end + 1;