@miaskiewicz/turbo-dom 0.5.4 → 0.5.5

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.
package/Cargo.lock CHANGED
@@ -541,9 +541,10 @@ dependencies = [
541
541
 
542
542
  [[package]]
543
543
  name = "turbo-dom"
544
- version = "0.5.4"
544
+ version = "0.5.5"
545
545
  dependencies = [
546
546
  "html5ever",
547
+ "log",
547
548
  "markup5ever_rcdom",
548
549
  "rustc-hash",
549
550
  "serde_json",
@@ -553,7 +554,7 @@ dependencies = [
553
554
 
554
555
  [[package]]
555
556
  name = "turbo-dom-parser"
556
- version = "0.5.4"
557
+ version = "0.5.5"
557
558
  dependencies = [
558
559
  "html5ever",
559
560
  "markup5ever_rcdom",
package/Cargo.toml CHANGED
@@ -5,7 +5,7 @@ members = ["crates/turbo-dom"]
5
5
 
6
6
  [package]
7
7
  name = "turbo-dom-parser"
8
- version = "0.5.4"
8
+ version = "0.5.5"
9
9
  edition = "2021"
10
10
  description = "html5ever-backed native parser for turbo-dom (Layer 1)"
11
11
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # turbo-dom
2
2
 
3
- Current release: **0.5.4** (npm package, native parser and Rust DOM crate).
3
+ Current release: **0.5.5** (npm package, native parser and Rust DOM crate).
4
4
 
5
5
  A faster, more spec-correct DOM for test runners — a drop-in-style alternative to
6
6
  **jsdom** and **happy-dom** for **vitest** and **jest**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
package/pkg/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "turbo-dom-parser",
3
3
  "description": "html5ever-backed native parser for turbo-dom (Layer 1)",
4
- "version": "0.5.4",
4
+ "version": "0.5.5",
5
5
  "files": [
6
6
  "turbo_dom_parser_bg.wasm",
7
7
  "turbo_dom_parser.js",
Binary file
@@ -2,7 +2,7 @@
2
2
  "name": "turbo-dom-parser",
3
3
  "type": "module",
4
4
  "description": "html5ever-backed native parser for turbo-dom (Layer 1)",
5
- "version": "0.5.4",
5
+ "version": "0.5.5",
6
6
  "files": [
7
7
  "turbo_dom_parser_bg.wasm",
8
8
  "turbo_dom_parser.js",
Binary file
package/src/core.rs CHANGED
@@ -8,7 +8,7 @@
8
8
  use html5ever::driver::ParseOpts;
9
9
  use html5ever::tendril::TendrilSink;
10
10
  use html5ever::tree_builder::TreeBuilderOpts;
11
- use html5ever::{parse_document, parse_fragment, ns, LocalName, QualName};
11
+ use html5ever::{ns, parse_document, parse_fragment, LocalName, QualName};
12
12
  use markup5ever_rcdom::{Handle, NodeData, RcDom};
13
13
 
14
14
  /// DOM nodeType constants (subset html5ever can emit).
@@ -20,7 +20,11 @@ pub const DOCUMENT_NODE: u8 = 9;
20
20
  pub const DOCUMENT_TYPE_NODE: u8 = 10;
21
21
  pub const DOCUMENT_FRAGMENT_NODE: u8 = 11;
22
22
 
23
- #[cfg_attr(feature = "wasm-bind", derive(serde::Serialize), serde(rename_all = "camelCase"))]
23
+ #[cfg_attr(
24
+ feature = "wasm-bind",
25
+ derive(serde::Serialize),
26
+ serde(rename_all = "camelCase")
27
+ )]
24
28
  #[derive(Debug, Clone)]
25
29
  pub struct Attr {
26
30
  pub name: String,
@@ -30,7 +34,11 @@ pub struct Attr {
30
34
  }
31
35
 
32
36
  /// One marshaled DOM node. Nested children = complete tree, no lazy indices.
33
- #[cfg_attr(feature = "wasm-bind", derive(serde::Serialize), serde(rename_all = "camelCase"))]
37
+ #[cfg_attr(
38
+ feature = "wasm-bind",
39
+ derive(serde::Serialize),
40
+ serde(rename_all = "camelCase")
41
+ )]
34
42
  #[derive(Debug, Clone)]
35
43
  pub struct Node {
36
44
  /// DOM nodeType.
@@ -53,7 +61,10 @@ pub struct Node {
53
61
  /// `<noscript>` content is parsed as markup rather than rawtext).
54
62
  fn opts() -> ParseOpts {
55
63
  ParseOpts {
56
- tree_builder: TreeBuilderOpts { scripting_enabled: false, ..Default::default() },
64
+ tree_builder: TreeBuilderOpts {
65
+ scripting_enabled: false,
66
+ ..Default::default()
67
+ },
57
68
  ..Default::default()
58
69
  }
59
70
  }
@@ -74,7 +85,11 @@ pub fn parse_html_document(html: &str) -> Node {
74
85
  // the arrays and inflates node objects only on access (no eager full-tree alloc).
75
86
 
76
87
  /// Index-addressed flat tree. Node index IS its id; node 0 is the document.
77
- #[cfg_attr(feature = "wasm-bind", derive(serde::Serialize), serde(rename_all = "camelCase"))]
88
+ #[cfg_attr(
89
+ feature = "wasm-bind",
90
+ derive(serde::Serialize),
91
+ serde(rename_all = "camelCase")
92
+ )]
78
93
  #[derive(Default)]
79
94
  pub struct Soa {
80
95
  pub node_type: Vec<u8>, // DOM nodeType
@@ -89,15 +104,15 @@ pub struct Soa {
89
104
  pub attr_start: Vec<i32>, // offset into attr_* tables; -1 if none
90
105
  pub attr_count: Vec<u16>, // attrs for this node
91
106
  // flat attr tables — names/prefixes interned (highly repetitive), values pooled
92
- pub attr_name_id: Vec<u32>, // index into attr_names
93
- pub attr_value_id: Vec<u32>, // index into attr_values (interned, deduped)
107
+ pub attr_name_id: Vec<u32>, // index into attr_names
108
+ pub attr_value_id: Vec<u32>, // index into attr_values (interned, deduped)
94
109
  pub attr_prefix_id: Vec<u32>, // index into attr_prefixes
95
110
  // string tables (interned, deduped)
96
111
  pub tag_names: Vec<String>,
97
112
  pub attr_names: Vec<String>,
98
113
  pub attr_prefixes: Vec<String>,
99
114
  pub attr_values: Vec<String>, // interned attr value dictionary
100
- pub strings: Vec<String>, // text/comment/doctype data, pooled
115
+ pub strings: Vec<String>, // text/comment/doctype data, pooled
101
116
  }
102
117
 
103
118
  struct SoaBuilder {
@@ -152,7 +167,7 @@ impl SoaBuilder {
152
167
  }
153
168
 
154
169
  // Allocate this node + descendants; return its index.
155
- fn alloc(&mut self, handle: &Handle, parent: i32) -> i32 {
170
+ fn alloc_node(&mut self, handle: &Handle, parent: i32) -> (i32, Option<Handle>) {
156
171
  let idx = self.soa.node_type.len();
157
172
  // push placeholders; fill scalars below
158
173
  self.soa.node_type.push(0);
@@ -171,7 +186,11 @@ impl SoaBuilder {
171
186
 
172
187
  match &handle.data {
173
188
  NodeData::Document => self.soa.node_type[idx] = DOCUMENT_NODE,
174
- NodeData::Doctype { name, public_id, system_id } => {
189
+ NodeData::Doctype {
190
+ name,
191
+ public_id,
192
+ system_id,
193
+ } => {
175
194
  self.soa.node_type[idx] = DOCUMENT_TYPE_NODE;
176
195
  self.soa.text_id[idx] = self.push_string(name);
177
196
  self.soa.pub_id[idx] = self.push_string(public_id);
@@ -187,12 +206,23 @@ impl SoaBuilder {
187
206
  }
188
207
  NodeData::ProcessingInstruction { target, contents } => {
189
208
  self.soa.node_type[idx] = PROCESSING_INSTRUCTION_NODE;
190
- let combined = format!("{target} {contents}");
191
- self.soa.text_id[idx] = self.push_string(&combined);
209
+ self.soa.tag_id[idx] = self.intern_tag(target);
210
+ self.soa.text_id[idx] = self.push_string(contents);
192
211
  }
193
- NodeData::Element { name, attrs, template_contents, .. } => {
212
+ NodeData::Element {
213
+ name,
214
+ attrs,
215
+ template_contents,
216
+ ..
217
+ } => {
194
218
  self.soa.node_type[idx] = ELEMENT_NODE;
195
- self.soa.ns[idx] = if name.ns == ns!(svg) { 1 } else if name.ns == ns!(mathml) { 2 } else { 0 };
219
+ self.soa.ns[idx] = if name.ns == ns!(svg) {
220
+ 1
221
+ } else if name.ns == ns!(mathml) {
222
+ 2
223
+ } else {
224
+ 0
225
+ };
196
226
  self.soa.tag_id[idx] = self.intern_tag(&name.local);
197
227
  let borrowed = attrs.borrow();
198
228
  if !borrowed.is_empty() {
@@ -203,7 +233,12 @@ impl SoaBuilder {
203
233
  self.soa.attr_name_id.push(nid);
204
234
  let vid = self.intern_attr_value(&attr.value);
205
235
  self.soa.attr_value_id.push(vid);
206
- let pfx = attr.name.prefix.as_ref().map(std::string::ToString::to_string).unwrap_or_default();
236
+ let pfx = attr
237
+ .name
238
+ .prefix
239
+ .as_ref()
240
+ .map(std::string::ToString::to_string)
241
+ .unwrap_or_default();
207
242
  let pid = self.intern_attr_prefix(&pfx);
208
243
  self.soa.attr_prefix_id.push(pid);
209
244
  }
@@ -216,47 +251,49 @@ impl SoaBuilder {
216
251
  }
217
252
  }
218
253
 
219
- // children, linking first_child / next_sib (inline; no closure to avoid borrow churn)
220
- let mut prev = -1i32;
221
- for child in handle.children.borrow().iter() {
222
- let cidx = self.alloc(child, idx as i32);
223
- if prev == -1 { self.soa.first_child[idx] = cidx; } else { self.soa.next_sib[prev as usize] = cidx; }
224
- prev = cidx;
225
- }
226
- // <template> content as a synthetic document-fragment child named "content"
227
- if let Some(content) = template_content {
228
- let cidx = self.soa.node_type.len() as i32;
229
- self.soa.node_type.push(DOCUMENT_FRAGMENT_NODE);
230
- self.soa.ns.push(0);
231
- let content_tag = self.intern_tag("content");
232
- self.soa.tag_id.push(content_tag);
233
- self.soa.parent.push(idx as i32);
234
- self.soa.first_child.push(-1);
235
- self.soa.next_sib.push(-1);
236
- self.soa.text_id.push(-1);
237
- self.soa.pub_id.push(-1);
238
- self.soa.sys_id.push(-1);
239
- self.soa.attr_start.push(-1);
240
- self.soa.attr_count.push(0);
241
- let mut cprev = -1i32;
242
- for gc in content.children.borrow().iter() {
243
- let gcidx = self.alloc(gc, cidx);
244
- if cprev == -1 { self.soa.first_child[cidx as usize] = gcidx; } else { self.soa.next_sib[cprev as usize] = gcidx; }
245
- cprev = gcidx;
254
+ (idx as i32, template_content)
255
+ }
256
+
257
+ fn alloc(&mut self, root: &Handle, parent: i32) -> i32 {
258
+ let mut pending = vec![(root.clone(), parent, false)];
259
+ let mut last_child = Vec::new();
260
+ let first = self.soa.node_type.len() as i32;
261
+ while let Some((handle, parent, content)) = pending.pop() {
262
+ let (index, template) = self.alloc_node(&handle, parent);
263
+ last_child.push(-1);
264
+ if content {
265
+ self.soa.node_type[index as usize] = DOCUMENT_FRAGMENT_NODE;
266
+ self.soa.tag_id[index as usize] = self.intern_tag("content");
267
+ }
268
+ if parent >= 0 {
269
+ let previous = last_child[parent as usize];
270
+ if previous < 0 {
271
+ self.soa.first_child[parent as usize] = index;
272
+ } else {
273
+ self.soa.next_sib[previous as usize] = index;
274
+ }
275
+ last_child[parent as usize] = index;
276
+ }
277
+ if let Some(template) = template {
278
+ pending.push((template, index, true));
279
+ }
280
+ for child in handle.children.borrow().iter().rev() {
281
+ pending.push((child.clone(), index, false));
246
282
  }
247
- // link the content fragment as a child of the template
248
- if prev == -1 { self.soa.first_child[idx] = cidx; } else { self.soa.next_sib[prev as usize] = cidx; }
249
- prev = cidx;
250
283
  }
251
- let _ = prev;
252
-
253
- idx as i32
284
+ first
254
285
  }
255
286
  }
256
287
 
257
288
  /// Parse a full document into the `SoA` flat buffer.
258
289
  #[must_use]
259
290
  pub fn parse_html_soa(html: &str) -> Soa {
291
+ parse_html_soa_with_compat_mode(html).0
292
+ }
293
+
294
+ /// Parse once and retain html5ever's document compatibility mode.
295
+ #[must_use]
296
+ pub fn parse_html_soa_with_compat_mode(html: &str) -> (Soa, &'static str) {
260
297
  let dom = parse_document(RcDom::default(), opts())
261
298
  .from_utf8()
262
299
  .read_from(&mut html.as_bytes())
@@ -266,12 +303,26 @@ pub fn parse_html_soa(html: &str) -> Soa {
266
303
  // pre-reserve typical intern-table sizes so the build loop doesn't rehash:
267
304
  // tag/attr-name sets are small + bounded; values vary more.
268
305
  tag_map: rustc_hash::FxHashMap::with_capacity_and_hasher(32, rustc_hash::FxBuildHasher),
269
- attr_name_map: rustc_hash::FxHashMap::with_capacity_and_hasher(64, rustc_hash::FxBuildHasher),
270
- attr_prefix_map: rustc_hash::FxHashMap::with_capacity_and_hasher(8, rustc_hash::FxBuildHasher),
271
- attr_value_map: rustc_hash::FxHashMap::with_capacity_and_hasher(128, rustc_hash::FxBuildHasher),
306
+ attr_name_map: rustc_hash::FxHashMap::with_capacity_and_hasher(
307
+ 64,
308
+ rustc_hash::FxBuildHasher,
309
+ ),
310
+ attr_prefix_map: rustc_hash::FxHashMap::with_capacity_and_hasher(
311
+ 8,
312
+ rustc_hash::FxBuildHasher,
313
+ ),
314
+ attr_value_map: rustc_hash::FxHashMap::with_capacity_and_hasher(
315
+ 128,
316
+ rustc_hash::FxBuildHasher,
317
+ ),
272
318
  };
273
319
  b.alloc(&dom.document, -1);
274
- b.soa
320
+ let mode = if dom.quirks_mode.get() == html5ever::tree_builder::QuirksMode::Quirks {
321
+ "BackCompat"
322
+ } else {
323
+ "CSS1Compat"
324
+ };
325
+ (b.soa, mode)
275
326
  }
276
327
 
277
328
  /// Parse a document and return only the node count — no `core::Node` tree, no
@@ -348,18 +399,32 @@ pub fn parse_html_fragment_in(html: &str, context_ns: &str, context_local: &str)
348
399
  frag
349
400
  }
350
401
 
351
- fn walk(handle: &Handle) -> Node {
402
+ fn shallow_node(handle: &Handle) -> Node {
352
403
  let node = handle;
353
404
  let mut public_id = String::new();
354
405
  let mut system_id = String::new();
355
406
  let (node_type, name, value, namespace, attrs) = match &node.data {
356
- NodeData::Document => {
357
- (DOCUMENT_NODE, "#document".to_string(), String::new(), String::new(), Vec::new())
358
- }
359
- NodeData::Doctype { name, public_id: pub_id, system_id: sys_id } => {
407
+ NodeData::Document => (
408
+ DOCUMENT_NODE,
409
+ "#document".to_string(),
410
+ String::new(),
411
+ String::new(),
412
+ Vec::new(),
413
+ ),
414
+ NodeData::Doctype {
415
+ name,
416
+ public_id: pub_id,
417
+ system_id: sys_id,
418
+ } => {
360
419
  public_id = pub_id.to_string();
361
420
  system_id = sys_id.to_string();
362
- (DOCUMENT_TYPE_NODE, name.to_string(), String::new(), String::new(), Vec::new())
421
+ (
422
+ DOCUMENT_TYPE_NODE,
423
+ name.to_string(),
424
+ String::new(),
425
+ String::new(),
426
+ Vec::new(),
427
+ )
363
428
  }
364
429
  NodeData::Text { contents } => (
365
430
  TEXT_NODE,
@@ -389,7 +454,12 @@ fn walk(handle: &Handle) -> Node {
389
454
  .map(|attr| Attr {
390
455
  name: attr.name.local.to_string(),
391
456
  value: attr.value.to_string(),
392
- prefix: attr.name.prefix.as_ref().map(std::string::ToString::to_string).unwrap_or_default(),
457
+ prefix: attr
458
+ .name
459
+ .prefix
460
+ .as_ref()
461
+ .map(std::string::ToString::to_string)
462
+ .unwrap_or_default(),
393
463
  })
394
464
  .collect();
395
465
  // html5lib short namespace form: "" (html), "svg", "math".
@@ -404,27 +474,67 @@ fn walk(handle: &Handle) -> Node {
404
474
  }
405
475
  };
406
476
 
407
- let mut children: Vec<Node> = node.children.borrow().iter().map(walk).collect();
408
-
409
- // <template> content lives in a separate document fragment, not in children.
410
- // html5lib prints it as a synthetic `content` fragment node holding the parsed tree.
411
- if let NodeData::Element { template_contents, .. } = &node.data {
412
- if let Some(contents) = &*template_contents.borrow() {
413
- let inner = contents.children.borrow().iter().map(walk).collect();
414
- children.push(Node {
415
- node_type: DOCUMENT_FRAGMENT_NODE,
416
- name: "content".to_string(),
417
- value: String::new(),
418
- namespace: String::new(),
419
- public_id: String::new(),
420
- system_id: String::new(),
421
- attrs: Vec::new(),
422
- children: inner,
423
- });
477
+ Node {
478
+ node_type,
479
+ name,
480
+ value,
481
+ namespace,
482
+ public_id,
483
+ system_id,
484
+ attrs,
485
+ children: Vec::new(),
486
+ }
487
+ }
488
+
489
+ fn walk(handle: &Handle) -> Node {
490
+ enum Task {
491
+ Enter(Handle, bool),
492
+ Finish(Node, usize),
493
+ }
494
+ let mut tasks = vec![Task::Enter(handle.clone(), false)];
495
+ let mut complete = Vec::new();
496
+ while let Some(task) = tasks.pop() {
497
+ match task {
498
+ Task::Enter(handle, content) => {
499
+ let mut node = shallow_node(&handle);
500
+ if content {
501
+ node.node_type = DOCUMENT_FRAGMENT_NODE;
502
+ node.name = "content".into();
503
+ }
504
+ let children = handle.children.borrow();
505
+ let template = match &handle.data {
506
+ NodeData::Element {
507
+ template_contents, ..
508
+ } => template_contents.borrow().clone(),
509
+ _ => None,
510
+ };
511
+ tasks.push(Task::Finish(
512
+ node,
513
+ children.len() + usize::from(template.is_some()),
514
+ ));
515
+ if let Some(template) = template {
516
+ tasks.push(Task::Enter(template, true));
517
+ }
518
+ for child in children.iter().rev() {
519
+ tasks.push(Task::Enter(child.clone(), false));
520
+ }
521
+ }
522
+ Task::Finish(mut node, count) => {
523
+ node.children = complete.split_off(complete.len() - count);
524
+ complete.push(node);
525
+ }
424
526
  }
425
527
  }
528
+ complete.pop().expect("one parsed root")
529
+ }
426
530
 
427
- Node { node_type, name, value, namespace, public_id, system_id, attrs, children }
531
+ /// Drain nested parser intermediates without recursive destructor calls.
532
+ /// This leaves the public marshaled Node representation unchanged.
533
+ pub fn discard_node(mut root: Node) {
534
+ let mut pending = std::mem::take(&mut root.children);
535
+ while let Some(mut node) = pending.pop() {
536
+ pending.append(&mut node.children);
537
+ }
428
538
  }
429
539
 
430
540
  #[cfg(test)]
Binary file
Binary file