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