rusteerio 1.1.0 → 1.1.2

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 (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +37 -37
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/dist/{chunk-FPRP6UIX.mjs → chunk-HN63P3U7.mjs} +625 -401
  6. package/dist/chunk-HN63P3U7.mjs.map +1 -0
  7. package/dist/index.cjs +624 -400
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.mts +3 -2
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.mjs +1 -1
  12. package/dist/slim.cjs +555 -407
  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-Cs9UP1Vv.d.mts → types-CJsc0PH-.d.mts} +12 -0
  18. package/dist/{types-Cs9UP1Vv.d.ts → types-CJsc0PH-.d.ts} +12 -0
  19. package/dist/utils.d.mts +1 -1
  20. package/dist/utils.d.ts +1 -1
  21. package/native/src/arena.rs +163 -7
  22. package/native/src/attributes.rs +18 -1
  23. package/native/src/codec.rs +79 -0
  24. package/native/src/lib.rs +117 -30
  25. package/native/src/snapshot.rs +144 -89
  26. package/native/src/tokenizer.rs +34 -20
  27. package/package.json +1 -1
  28. package/rusteerio.darwin-arm64.node +0 -0
  29. package/rusteerio.darwin-x64.node +0 -0
  30. package/rusteerio.linux-arm64-gnu.node +0 -0
  31. package/rusteerio.linux-arm64-musl.node +0 -0
  32. package/rusteerio.linux-x64-gnu.node +0 -0
  33. package/rusteerio.linux-x64-musl.node +0 -0
  34. package/rusteerio.win32-arm64-msvc.node +0 -0
  35. package/rusteerio.win32-x64-msvc.node +0 -0
  36. package/src/extract.ts +117 -24
  37. package/src/index.ts +27 -6
  38. package/src/match.ts +4 -4
  39. package/src/native.ts +4 -0
  40. package/src/nodes.ts +70 -10
  41. package/src/router.ts +80 -4
  42. package/src/selection.ts +18 -17
  43. package/src/snapshot.ts +37 -0
  44. package/src/transport.ts +1 -1
  45. package/dist/chunk-FPRP6UIX.mjs.map +0 -1
package/native/src/lib.rs CHANGED
@@ -14,6 +14,8 @@ use napi_derive::napi;
14
14
  use scraper::{Html, Node, Selector};
15
15
  use serde_json::{Map, Value};
16
16
 
17
+ use codec::JsText;
18
+
17
19
  #[cfg(target_os = "linux")]
18
20
  #[global_allocator]
19
21
  static ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
@@ -158,6 +160,11 @@ pub struct NativeDocument {
158
160
  // unreachable documents under native memory pressure.
159
161
  content_bytes: Cell<usize>,
160
162
  reported_heap_bytes: Cell<i64>,
163
+ /// Results of extraction plans run on the parsing thread, in request
164
+ /// order, until JavaScript takes them.
165
+ prepared_plans: RefCell<Option<Vec<Option<String>>>>,
166
+ /// A snapshot built on the parsing thread, until JavaScript takes it.
167
+ prepared_snapshot: RefCell<Option<snapshot::NodeSnapshot>>,
161
168
  }
162
169
 
163
170
  /// Node-API calls the finalizers of a module that declares the experimental
@@ -279,6 +286,8 @@ impl NativeDocument {
279
286
  locations: RefCell::new(HashMap::new()),
280
287
  content_bytes: Cell::new(content_bytes),
281
288
  reported_heap_bytes: Cell::new(0),
289
+ prepared_plans: RefCell::new(None),
290
+ prepared_snapshot: RefCell::new(None),
282
291
  }
283
292
  }
284
293
  pub(crate) fn set_diagnostic_records(&self, records: Vec<Value>) {
@@ -527,6 +536,8 @@ impl NativeDocument {
527
536
  locations: RefCell::new(locations),
528
537
  content_bytes: Cell::new(content_bytes),
529
538
  reported_heap_bytes: Cell::new(0),
539
+ prepared_plans: RefCell::new(None),
540
+ prepared_snapshot: RefCell::new(None),
530
541
  }
531
542
  }
532
543
 
@@ -540,6 +551,14 @@ impl NativeDocument {
540
551
  arena
541
552
  }
542
553
 
554
+ /// The arena borrowed for a write that changes only attributes or data,
555
+ /// which keeps the name index valid.
556
+ pub(crate) fn arena_for_attribute_write(
557
+ &self,
558
+ ) -> std::cell::RefMut<'_, Option<arena::DomArena>> {
559
+ self.arena.borrow_mut()
560
+ }
561
+
543
562
  pub(crate) fn ensure_arena(&self) -> Result<()> {
544
563
  let missing = self.arena.borrow().is_none();
545
564
  if missing {
@@ -735,7 +754,9 @@ impl NativeDocument {
735
754
  }
736
755
  .into();
737
756
  record.name = Some(element.name().into());
738
- record.attribs = Map::with_capacity(element.attrs.len());
757
+ if !element.attrs.is_empty() {
758
+ *record.attribs = Map::with_capacity(element.attrs.len());
759
+ }
739
760
  for (name, value) in &element.attrs {
740
761
  record
741
762
  .attribs
@@ -775,7 +796,7 @@ impl NativeDocument {
775
796
  .map(|id| self.attribute_node(*id))
776
797
  .collect::<Option<_>>()?;
777
798
  let before: Vec<Map<String, Value>> =
778
- nodes.iter().map(|node| node.attribs.clone()).collect();
799
+ nodes.iter().map(|node| (*node.attribs).clone()).collect();
779
800
  let mut scratch = arena::DomArena::from_records(nodes, options.clone());
780
801
  if self.options.get("__utf16Codec") == Some(&Value::Bool(true)) {
781
802
  scratch.options["__utf16Codec"] = Value::Bool(true);
@@ -795,7 +816,7 @@ impl NativeDocument {
795
816
  let node_ids = self.ids.borrow();
796
817
  for (index, id) in targets.iter().enumerate() {
797
818
  let after = &scratch.nodes[index].attribs;
798
- if *after == before[index] {
819
+ if **after == before[index] {
799
820
  continue;
800
821
  }
801
822
  let mut node = html.tree.get_mut(*node_ids.get(*id as usize)?)?;
@@ -1037,7 +1058,7 @@ impl NativeDocument {
1037
1058
  }
1038
1059
  }
1039
1060
  self.ensure_arena()?;
1040
- if let Some(arena) = self.arena_for_write().as_mut() {
1061
+ if let Some(arena) = self.arena_for_attribute_write().as_mut() {
1041
1062
  arena.set_attr(&ids, &name, value.as_deref());
1042
1063
  }
1043
1064
  Ok(())
@@ -1293,14 +1314,46 @@ impl NativeDocument {
1293
1314
  .collect()
1294
1315
  }
1295
1316
 
1296
- #[napi]
1317
+ /// The results of the extraction plans `parseManyAsync` ran on the
1318
+ /// document's root while parsing it, each `null` if it failed; `null`
1319
+ /// after the first call.
1320
+ #[napi(
1321
+ js_name = "takePreparedPlans",
1322
+ ts_return_type = "Array<string | null> | null"
1323
+ )]
1324
+ pub fn take_prepared_plans(&self) -> Option<Vec<Option<JsText>>> {
1325
+ self.prepared_plans.borrow_mut().take().map(|results| {
1326
+ results
1327
+ .into_iter()
1328
+ .map(|result| result.map(JsText))
1329
+ .collect()
1330
+ })
1331
+ }
1332
+
1333
+ /// The snapshot `parseManyAsync` built while parsing the document, if it
1334
+ /// did; `null` after the first call, which discards it unless `is_wanted`.
1335
+ /// It describes the parsed tree, so JavaScript discards it on its first write.
1336
+ #[napi(js_name = "takePreparedSnapshot")]
1337
+ pub fn take_prepared_snapshot(&self, env: Env, is_wanted: bool) -> Result<Option<Object>> {
1338
+ let prepared = self.prepared_snapshot.borrow_mut().take();
1339
+ match prepared {
1340
+ Some(snapshot) if is_wanted => snapshot.into_js(&env).map(Some),
1341
+ _ => Ok(None),
1342
+ }
1343
+ }
1344
+
1345
+ #[napi(js_name = "text", ts_return_type = "string")]
1346
+ pub fn text_for_js(&self, ids: Vec<u32>) -> JsText {
1347
+ JsText(self.text(ids))
1348
+ }
1349
+
1297
1350
  pub fn text(&self, ids: Vec<u32>) -> String {
1298
1351
  self.text_ids(&ids)
1299
1352
  }
1300
1353
 
1301
- #[napi(js_name = "innerHtml")]
1302
- pub fn inner_html_and_report(&self, env: Env, id: u32) -> String {
1303
- self.reported(env, self.inner_html(id))
1354
+ #[napi(js_name = "innerHtml", ts_return_type = "string")]
1355
+ pub fn inner_html_and_report(&self, env: Env, id: u32) -> JsText {
1356
+ JsText(self.reported(env, self.inner_html(id)))
1304
1357
  }
1305
1358
 
1306
1359
  pub fn inner_html(&self, id: u32) -> String {
@@ -1312,20 +1365,24 @@ impl NativeDocument {
1312
1365
  self.serialize_nodes(&[id], true)
1313
1366
  }
1314
1367
 
1315
- #[napi]
1368
+ #[napi(js_name = "outerHtml", ts_return_type = "string")]
1369
+ pub fn outer_html_for_js(&self, ids: Vec<u32>) -> JsText {
1370
+ JsText(self.outer_html(ids))
1371
+ }
1372
+
1316
1373
  pub fn outer_html(&self, ids: Vec<u32>) -> String {
1317
1374
  self.serialize_nodes(&ids, false)
1318
1375
  }
1319
1376
 
1320
1377
  /// Serializes nodes as the `render` command does, returning the markup
1321
1378
  /// itself rather than markup encoded as JSON.
1322
- #[napi(js_name = "render")]
1379
+ #[napi(js_name = "render", ts_return_type = "string")]
1323
1380
  pub fn render_and_report(
1324
1381
  &self,
1325
1382
  env: Env,
1326
1383
  ids: Vec<u32>,
1327
1384
  options_json: Option<String>,
1328
- ) -> Result<String> {
1385
+ ) -> Result<JsText> {
1329
1386
  self.maybe_collect();
1330
1387
  let overrides = match options_json {
1331
1388
  Some(json) => Some(serde_json::from_str::<Value>(&json).map_err(json_error)?),
@@ -1333,7 +1390,7 @@ impl NativeDocument {
1333
1390
  }
1334
1391
  .filter(Value::is_object);
1335
1392
  let rendered = self.render_nodes(&ids, overrides)?;
1336
- Ok(self.reported(env, rendered))
1393
+ Ok(JsText(self.reported(env, rendered)))
1337
1394
  }
1338
1395
 
1339
1396
  fn render_nodes(&self, ids: &[u32], overrides: Option<Value>) -> Result<String> {
@@ -1364,7 +1421,11 @@ impl NativeDocument {
1364
1421
  Ok(arena.serialize(ids, &arena::normalized_options(options)))
1365
1422
  }
1366
1423
 
1367
- #[napi]
1424
+ #[napi(js_name = "attr", ts_return_type = "string | null")]
1425
+ pub fn attr_for_js(&self, id: u32, name: String) -> Option<JsText> {
1426
+ self.attr(id, name).map(JsText)
1427
+ }
1428
+
1368
1429
  pub fn attr(&self, id: u32, name: String) -> Option<String> {
1369
1430
  self.attribute(id, &name)
1370
1431
  }
@@ -1382,9 +1443,9 @@ impl NativeDocument {
1382
1443
  .flatten()
1383
1444
  }
1384
1445
 
1385
- #[napi(js_name = "exportTree")]
1386
- pub fn export_tree_and_report(&self, env: Env) -> Result<String> {
1387
- self.reported(env, self.export_tree())
1446
+ #[napi(js_name = "exportTree", ts_return_type = "string")]
1447
+ pub fn export_tree_and_report(&self, env: Env) -> Result<JsText> {
1448
+ self.reported(env, self.export_tree()).map(JsText)
1388
1449
  }
1389
1450
 
1390
1451
  pub fn export_tree(&self) -> Result<String> {
@@ -1412,9 +1473,9 @@ impl NativeDocument {
1412
1473
  built.into_js(&env)
1413
1474
  }
1414
1475
 
1415
- #[napi(js_name = "node")]
1416
- pub fn node_and_report(&self, env: Env, id: u32) -> Result<String> {
1417
- self.reported(env, self.node(id))
1476
+ #[napi(js_name = "node", ts_return_type = "string")]
1477
+ pub fn node_and_report(&self, env: Env, id: u32) -> Result<JsText> {
1478
+ self.reported(env, self.node(id)).map(JsText)
1418
1479
  }
1419
1480
 
1420
1481
  pub fn node(&self, id: u32) -> Result<String> {
@@ -1563,9 +1624,9 @@ impl NativeDocument {
1563
1624
  .map_err(json_error)
1564
1625
  }
1565
1626
 
1566
- #[napi(js_name = "op")]
1567
- pub fn op_and_report(&self, env: Env, command: String, args_json: String) -> Result<String> {
1568
- self.reported(env, self.op(command, args_json))
1627
+ #[napi(js_name = "op", ts_return_type = "string")]
1628
+ pub fn op_and_report(&self, env: Env, command: String, args_json: String) -> Result<JsText> {
1629
+ self.reported(env, self.op(command, args_json)).map(JsText)
1569
1630
  }
1570
1631
 
1571
1632
  pub fn op(&self, command: String, args_json: String) -> Result<String> {
@@ -1671,7 +1732,11 @@ impl NativeDocument {
1671
1732
  }
1672
1733
  }
1673
1734
  self.ensure_arena()?;
1674
- let mut arena = self.arena_for_write();
1735
+ let mut arena = if attributes::keeps_element_names(&command) {
1736
+ self.arena_for_attribute_write()
1737
+ } else {
1738
+ self.arena_for_write()
1739
+ };
1675
1740
  let arena = arena.as_mut().unwrap();
1676
1741
  let previous_options = arena.options.clone();
1677
1742
  if let Some(options) = args.get("options").filter(|options| options.is_object()) {
@@ -2058,7 +2123,9 @@ fn convert_node(
2058
2123
  .insert("prefix".into(), Value::String(String::from(&**prefix)));
2059
2124
  }
2060
2125
  let (mut namespaces, mut prefixes) = (Map::new(), Map::new());
2061
- record.attribs = Map::with_capacity(element.attrs.len());
2126
+ if !element.attrs.is_empty() {
2127
+ *record.attribs = Map::with_capacity(element.attrs.len());
2128
+ }
2062
2129
  for (name, value) in &element.attrs {
2063
2130
  let key = String::from(&*name.local);
2064
2131
  if !name.ns.is_empty() {
@@ -2299,6 +2366,9 @@ pub struct ParseManyTask {
2299
2366
  is_document: bool,
2300
2367
  threads: usize,
2301
2368
  options: Value,
2369
+ /// Extraction plans to run on each root after parsing; with them, each
2370
+ /// document also gets its snapshot built on the parsing thread.
2371
+ plans: Option<Vec<Value>>,
2302
2372
  }
2303
2373
 
2304
2374
  #[napi]
@@ -2308,12 +2378,24 @@ impl Task for ParseManyTask {
2308
2378
 
2309
2379
  fn compute(&mut self) -> Result<Self::Output> {
2310
2380
  runtime::parallel_map(&self.htmls, self.threads, |html| {
2311
- Ok(NativeDocument::parse_options(
2312
- html,
2313
- self.is_document,
2314
- self.options.clone(),
2315
- None,
2316
- ))
2381
+ let document =
2382
+ NativeDocument::parse_options(html, self.is_document, self.options.clone(), None);
2383
+ if let Some(plans) = &self.plans {
2384
+ let results = plans
2385
+ .iter()
2386
+ .map(|plan| {
2387
+ let args =
2388
+ serde_json::json!({"ids": [0], "plan": plan, "options": self.options});
2389
+ document.op("extractPlan".into(), args.to_string()).ok()
2390
+ })
2391
+ .collect();
2392
+ *document.prepared_plans.borrow_mut() = Some(results);
2393
+ // With source locations the snapshot is built from the mutable tree instead.
2394
+ if document.source.borrow().is_none() {
2395
+ *document.prepared_snapshot.borrow_mut() = Some(snapshot::build(&document));
2396
+ }
2397
+ }
2398
+ Ok(document)
2317
2399
  })
2318
2400
  }
2319
2401
 
@@ -2328,8 +2410,12 @@ pub fn parse_many_async(
2328
2410
  is_document: bool,
2329
2411
  threads: Option<u32>,
2330
2412
  options_json: Option<String>,
2413
+ plans_json: Option<String>,
2331
2414
  ) -> Result<AsyncTask<ParseManyTask>> {
2332
2415
  let threads = thread_count(threads, htmls.len())?;
2416
+ let plans = plans_json
2417
+ .map(|json| serde_json::from_str(&json).map_err(json_error))
2418
+ .transpose()?;
2333
2419
  Ok(AsyncTask::new(ParseManyTask {
2334
2420
  htmls,
2335
2421
  is_document,
@@ -2337,6 +2423,7 @@ pub fn parse_many_async(
2337
2423
  options: options_json
2338
2424
  .and_then(|value| serde_json::from_str(&value).ok())
2339
2425
  .unwrap_or_else(|| serde_json::json!({})),
2426
+ plans,
2340
2427
  }))
2341
2428
  }
2342
2429
 
@@ -15,7 +15,7 @@
15
15
  //! which frees it as soon as the snapshot is collected; an external buffer
16
16
  //! would add finalizer bookkeeping and wait for a later event-loop turn.
17
17
  use html5ever::{LocalName, Namespace};
18
- use napi::{Env, JsObject, JsTypedArray, Result, TypedArrayType};
18
+ use napi::{check_status, sys, Env, JsObject, JsTypedArray, NapiValue, Result};
19
19
  use scraper::Node;
20
20
  use serde_json::{Map, Value};
21
21
 
@@ -24,8 +24,9 @@ use crate::id_hash::IdMap;
24
24
  use crate::NativeDocument;
25
25
 
26
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.
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.
29
30
  const LARGE_TEXT_BYTES: usize = 4096;
30
31
  const ABSENT: u32 = u32::MAX;
31
32
  const HAS_START_INDEX: u32 = 1;
@@ -35,6 +36,17 @@ const EMPTY_ATTRIBUTE_MAPS: u32 = 8;
35
36
  /// The record cannot be represented here; JavaScript reads it individually.
36
37
  const OPAQUE: u32 = 16;
37
38
  const RELEASED: u32 = 32;
39
+ const KINDS: [&str; 8] = [
40
+ "tag",
41
+ "text",
42
+ "comment",
43
+ "root",
44
+ "script",
45
+ "style",
46
+ "directive",
47
+ "cdata",
48
+ ];
49
+ const SHORT_NAME_SLOTS: usize = 256;
38
50
 
39
51
  pub(crate) struct NodeSnapshot {
40
52
  is_encoded: bool,
@@ -57,90 +69,74 @@ impl NodeSnapshot {
57
69
  u32::from(self.is_encoded),
58
70
  ];
59
71
  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)?;
72
+ let data = uninitialized_words(env, length, |words| {
73
+ let mut offset = 0;
74
+ for part in std::iter::once(&header[..]).chain(parts.iter().map(|part| part.as_slice()))
75
+ {
76
+ words[offset..offset + part.len()].copy_from_slice(part);
77
+ offset += part.len();
78
+ }
79
+ })?;
76
80
  let mut object = env.create_object()?;
77
81
  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)?;
82
+ object.set_named_property("text", crate::codec::js_string(env, &self.text)?)?;
88
83
  Ok(object)
89
84
  }
90
85
  }
91
86
 
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);
87
+ /// A `Uint32Array` of `length` words that `fill` writes. A Node buffer is
88
+ /// used for its storage because, unlike an `ArrayBuffer`, it is not
89
+ /// zero-filled first; `fill` writes every word before JavaScript can read it.
90
+ fn uninitialized_words(
91
+ env: &Env,
92
+ length: usize,
93
+ fill: impl FnOnce(&mut [u32]),
94
+ ) -> Result<JsTypedArray> {
95
+ let raw_env = env.raw();
96
+ let mut buffer = std::ptr::null_mut();
97
+ let mut memory = std::ptr::null_mut();
98
+ // SAFETY: Node-API calls on the current environment with valid out
99
+ // pointers. `memory` is the buffer's own allocation of `length` words,
100
+ // checked to be aligned, and the buffer stays alive in this handle scope.
101
+ unsafe {
102
+ check_status!(sys::napi_create_buffer(
103
+ raw_env,
104
+ length * 4,
105
+ &mut memory,
106
+ &mut buffer
107
+ ))?;
108
+ let mut kind = 0;
109
+ let mut bytes = 0;
110
+ let mut view_memory = std::ptr::null_mut();
111
+ let mut arraybuffer = std::ptr::null_mut();
112
+ let mut byte_offset = 0;
113
+ check_status!(sys::napi_get_typedarray_info(
114
+ raw_env,
115
+ buffer,
116
+ &mut kind,
117
+ &mut bytes,
118
+ &mut view_memory,
119
+ &mut arraybuffer,
120
+ &mut byte_offset,
121
+ ))?;
122
+ if !byte_offset.is_multiple_of(4)
123
+ || !(memory as usize).is_multiple_of(4)
124
+ || bytes < length * 4
125
+ {
126
+ return Err(napi::Error::from_reason("Unaligned snapshot buffer"));
140
127
  }
141
- index += length;
128
+ let mut words = std::ptr::null_mut();
129
+ check_status!(sys::napi_create_typedarray(
130
+ raw_env,
131
+ sys::TypedarrayType::uint32_array,
132
+ length,
133
+ arraybuffer,
134
+ byte_offset,
135
+ &mut words,
136
+ ))?;
137
+ fill(std::slice::from_raw_parts_mut(memory.cast::<u32>(), length));
138
+ Ok(JsTypedArray::from_raw_unchecked(raw_env, words))
142
139
  }
143
- units
144
140
  }
145
141
 
146
142
  /// The UTF-16 length of `text`: one unit per character, two for characters
@@ -159,6 +155,11 @@ struct Builder {
159
155
  text: String,
160
156
  units: u32,
161
157
  interned: IdMap<String, u32>,
158
+ /// References of the common node types, resolved without hashing.
159
+ kinds: [Option<u32>; KINDS.len()],
160
+ /// Recently interned names of up to eight bytes, keyed by their bytes:
161
+ /// element and attribute names repeat across nodes.
162
+ short_names: Box<[(u64, u8, u32); SHORT_NAME_SLOTS]>,
162
163
  /// html5ever names are atoms that hash and compare by identity.
163
164
  local_names: IdMap<LocalName, u32>,
164
165
  namespaces: IdMap<Namespace, u32>,
@@ -178,6 +179,8 @@ pub(crate) fn build(document: &NativeDocument) -> NodeSnapshot {
178
179
  text: String::with_capacity(document.content_bytes.get()),
179
180
  units: 0,
180
181
  interned: IdMap::with_capacity_and_hasher(32, Default::default()),
182
+ kinds: [None; KINDS.len()],
183
+ short_names: Box::new([(0, 0, 0); SHORT_NAME_SLOTS]),
181
184
  local_names: IdMap::with_capacity_and_hasher(32, Default::default()),
182
185
  namespaces: IdMap::default(),
183
186
  codec,
@@ -244,6 +247,40 @@ impl Builder {
244
247
  (self.strings.len() / 2 - 1) as u32
245
248
  }
246
249
 
250
+ fn kind(&mut self, kind: &str) -> u32 {
251
+ let Some(slot) = KINDS.iter().position(|known| *known == kind) else {
252
+ return self.interned(kind);
253
+ };
254
+ if let Some(reference) = self.kinds[slot] {
255
+ return reference;
256
+ }
257
+ let reference = self.interned(kind);
258
+ self.kinds[slot] = Some(reference);
259
+ reference
260
+ }
261
+
262
+ fn interned_name(&mut self, name: &str) -> u32 {
263
+ let bytes = name.as_bytes();
264
+ if bytes.len() > 8 {
265
+ return self.interned(name);
266
+ }
267
+ let mut word = [0; 8];
268
+ word[..bytes.len()].copy_from_slice(bytes);
269
+ let key = u64::from_le_bytes(word);
270
+ let length = bytes.len() as u8;
271
+ let slot = (key
272
+ .wrapping_add(u64::from(length))
273
+ .wrapping_mul(0x9E37_79B9_7F4A_7C15)
274
+ >> 56) as usize;
275
+ let (cached_key, cached_length, cached) = self.short_names[slot];
276
+ if cached != 0 && cached_key == key && cached_length == length {
277
+ return cached - 1;
278
+ }
279
+ let reference = self.interned(name);
280
+ self.short_names[slot] = (key, length, reference + 1);
281
+ reference
282
+ }
283
+
247
284
  fn interned(&mut self, value: &str) -> u32 {
248
285
  if let Some(reference) = self.interned.get(value) {
249
286
  return *reference;
@@ -279,7 +316,7 @@ impl Builder {
279
316
 
280
317
  fn encode(&mut self, node: &DomNode) {
281
318
  let mut fields = [0; STRIDE];
282
- fields[0] = self.interned(&node.kind);
319
+ fields[0] = self.kind(&node.kind);
283
320
  fields[1] = pointer(node.parent);
284
321
  fields[2] = pointer(node.prev);
285
322
  fields[3] = pointer(node.next);
@@ -289,7 +326,7 @@ impl Builder {
289
326
  fields[6] = node
290
327
  .name
291
328
  .as_deref()
292
- .map_or(0, |name| self.interned(name) + 1);
329
+ .map_or(0, |name| self.interned_name(name) + 1);
293
330
  let mut flags = 0;
294
331
  match node.data.as_deref() {
295
332
  Some(data) if data.len() > LARGE_TEXT_BYTES => flags |= OPAQUE,
@@ -297,13 +334,22 @@ impl Builder {
297
334
  None => {}
298
335
  }
299
336
  fields[8] = (self.attributes.len() / 2) as u32;
300
- fields[9] = node.attribs.len() as u32;
301
- for (name, value) in &node.attribs {
337
+ let has_large_attribute = node.attribs.values().any(|value| {
338
+ value
339
+ .as_str()
340
+ .is_some_and(|value| value.len() > LARGE_TEXT_BYTES)
341
+ });
342
+ if has_large_attribute {
343
+ flags |= OPAQUE;
344
+ } else {
345
+ fields[9] = node.attribs.len() as u32;
346
+ }
347
+ for (name, value) in node.attribs.iter().filter(|_| !has_large_attribute) {
302
348
  let Some(value) = value.as_str() else {
303
349
  flags |= OPAQUE;
304
350
  break;
305
351
  };
306
- let name = self.interned(name);
352
+ let name = self.interned_name(name);
307
353
  let value = self.string(value);
308
354
  self.attributes.extend_from_slice(&[name, value]);
309
355
  }
@@ -454,12 +500,20 @@ impl Builder {
454
500
  _ => tag_type,
455
501
  };
456
502
  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]);
503
+ if element
504
+ .attrs
505
+ .iter()
506
+ .any(|(_, value)| value.len() > LARGE_TEXT_BYTES)
507
+ {
508
+ flags |= OPAQUE;
509
+ } else {
510
+ for (attribute, value) in &element.attrs {
511
+ let attribute = self.local_name(&attribute.local);
512
+ let value = self.string(value);
513
+ self.attributes.extend_from_slice(&[attribute, value]);
514
+ }
515
+ fields[9] = element.attrs.len() as u32;
461
516
  }
462
- fields[9] = element.attrs.len() as u32;
463
517
  fields[13] = self.namespace(&element.name.ns);
464
518
  flags |= HAS_NAMESPACE | EMPTY_ATTRIBUTE_MAPS;
465
519
  }
@@ -474,6 +528,7 @@ impl Builder {
474
528
  #[cfg(test)]
475
529
  mod tests {
476
530
  use super::*;
531
+ use crate::codec::{latin1_bytes, utf16_units};
477
532
 
478
533
  #[test]
479
534
  fn transcodes_text_like_the_standard_library() {