@miaskiewicz/turbo-dom 0.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.
package/src/core.rs ADDED
@@ -0,0 +1,492 @@
1
+ //! Shared parser core. No binding deps — html5ever in, plain nested tree out.
2
+ //! Both front-ends (napi-rs native, wasm-bindgen fallback) wrap this unchanged.
3
+ //!
4
+ //! Plan note: this is the "full marshaling" version — a *complete* tree.
5
+ //! The SoA flat-buffer (architecture Layer 1) is a later optimization, gated on
6
+ //! marshaling proving to be the actual cost. Do not SoA-ify here yet.
7
+
8
+ use html5ever::driver::ParseOpts;
9
+ use html5ever::tendril::TendrilSink;
10
+ use html5ever::tree_builder::TreeBuilderOpts;
11
+ use html5ever::{parse_document, parse_fragment, local_name, namespace_url, ns, LocalName, QualName};
12
+ use markup5ever_rcdom::{Handle, NodeData, RcDom};
13
+
14
+ /// DOM nodeType constants (subset html5ever can emit).
15
+ pub const ELEMENT_NODE: u8 = 1;
16
+ pub const TEXT_NODE: u8 = 3;
17
+ pub const PROCESSING_INSTRUCTION_NODE: u8 = 7;
18
+ pub const COMMENT_NODE: u8 = 8;
19
+ pub const DOCUMENT_NODE: u8 = 9;
20
+ pub const DOCUMENT_TYPE_NODE: u8 = 10;
21
+ pub const DOCUMENT_FRAGMENT_NODE: u8 = 11;
22
+
23
+ #[cfg_attr(feature = "wasm-bind", derive(serde::Serialize))]
24
+ #[derive(Debug, Clone)]
25
+ pub struct Attr {
26
+ pub name: String,
27
+ pub value: String,
28
+ /// Namespace prefix for foreign-content attrs ("xlink", "xml", "xmlns"); empty otherwise.
29
+ pub prefix: String,
30
+ }
31
+
32
+ /// One marshaled DOM node. Nested children = complete tree, no lazy indices.
33
+ #[cfg_attr(feature = "wasm-bind", derive(serde::Serialize))]
34
+ #[derive(Debug, Clone)]
35
+ pub struct Node {
36
+ /// DOM nodeType.
37
+ pub node_type: u8,
38
+ /// Tag name (element) lowercased, or "#text"/"#comment"/"#document"/doctype name.
39
+ pub name: String,
40
+ /// Text/comment/PI data; empty for elements.
41
+ pub value: String,
42
+ /// Element namespace, html5lib-style short form: "" (html), "svg", "math". Empty for non-elements.
43
+ pub namespace: String,
44
+ /// Doctype PUBLIC id; empty otherwise.
45
+ pub public_id: String,
46
+ /// Doctype SYSTEM id; empty otherwise.
47
+ pub system_id: String,
48
+ pub attrs: Vec<Attr>,
49
+ pub children: Vec<Node>,
50
+ }
51
+
52
+ /// Parse options matching html5lib-tests defaults (scripting flag off, so
53
+ /// `<noscript>` content is parsed as markup rather than rawtext).
54
+ fn opts() -> ParseOpts {
55
+ ParseOpts {
56
+ tree_builder: TreeBuilderOpts { scripting_enabled: false, ..Default::default() },
57
+ ..Default::default()
58
+ }
59
+ }
60
+
61
+ /// Parse a full HTML document. Always yields a Document root (nodeType 9).
62
+ pub fn parse_html_document(html: &str) -> Node {
63
+ let dom = parse_document(RcDom::default(), opts())
64
+ .from_utf8()
65
+ .read_from(&mut html.as_bytes())
66
+ .expect("RcDom read_from is infallible over a byte slice");
67
+ walk(&dom.document)
68
+ }
69
+
70
+ // ============================ SoA flat buffer ============================
71
+ // Structure-of-Arrays: parser emits compact parallel arrays once, crossed over
72
+ // the boundary as cheap typed-array copies. JS reads tree structure straight from
73
+ // the arrays and inflates node objects only on access (no eager full-tree alloc).
74
+
75
+ /// Index-addressed flat tree. Node index IS its id; node 0 is the document.
76
+ #[cfg_attr(feature = "wasm-bind", derive(serde::Serialize))]
77
+ #[derive(Default)]
78
+ pub struct Soa {
79
+ pub node_type: Vec<u8>, // DOM nodeType
80
+ pub ns: Vec<u8>, // 0 html, 1 svg, 2 math
81
+ pub tag_id: Vec<u32>, // index into tag_names (elements); 0 otherwise
82
+ pub parent: Vec<i32>, // -1 for root
83
+ pub first_child: Vec<i32>, // -1 if none
84
+ pub next_sib: Vec<i32>, // -1 if none
85
+ pub text_id: Vec<i32>, // index into strings for text/comment/doctype-name; -1
86
+ pub pub_id: Vec<i32>, // doctype PUBLIC id index into strings; -1
87
+ pub sys_id: Vec<i32>, // doctype SYSTEM id index into strings; -1
88
+ pub attr_start: Vec<i32>, // offset into attr_* tables; -1 if none
89
+ pub attr_count: Vec<u16>, // attrs for this node
90
+ // flat attr tables — names/prefixes interned (highly repetitive), values pooled
91
+ pub attr_name_id: Vec<u32>, // index into attr_names
92
+ pub attr_value: Vec<String>,
93
+ pub attr_prefix_id: Vec<u32>, // index into attr_prefixes
94
+ // string tables (interned, deduped)
95
+ pub tag_names: Vec<String>,
96
+ pub attr_names: Vec<String>,
97
+ pub attr_prefixes: Vec<String>,
98
+ pub strings: Vec<String>, // text/comment/doctype data, pooled
99
+ }
100
+
101
+ struct SoaBuilder {
102
+ soa: Soa,
103
+ tag_map: std::collections::HashMap<String, u32>,
104
+ attr_name_map: std::collections::HashMap<String, u32>,
105
+ attr_prefix_map: std::collections::HashMap<String, u32>,
106
+ }
107
+
108
+ impl SoaBuilder {
109
+ fn intern_tag(&mut self, name: &str) -> u32 {
110
+ if let Some(&id) = self.tag_map.get(name) {
111
+ return id;
112
+ }
113
+ let id = self.soa.tag_names.len() as u32;
114
+ self.soa.tag_names.push(name.to_string());
115
+ self.tag_map.insert(name.to_string(), id);
116
+ id
117
+ }
118
+ fn intern_attr_name(&mut self, name: &str) -> u32 {
119
+ if let Some(&id) = self.attr_name_map.get(name) {
120
+ return id;
121
+ }
122
+ let id = self.soa.attr_names.len() as u32;
123
+ self.soa.attr_names.push(name.to_string());
124
+ self.attr_name_map.insert(name.to_string(), id);
125
+ id
126
+ }
127
+ fn intern_attr_prefix(&mut self, prefix: &str) -> u32 {
128
+ if let Some(&id) = self.attr_prefix_map.get(prefix) {
129
+ return id;
130
+ }
131
+ let id = self.soa.attr_prefixes.len() as u32;
132
+ self.soa.attr_prefixes.push(prefix.to_string());
133
+ self.attr_prefix_map.insert(prefix.to_string(), id);
134
+ id
135
+ }
136
+ fn push_string(&mut self, s: &str) -> i32 {
137
+ let id = self.soa.strings.len() as i32;
138
+ self.soa.strings.push(s.to_string());
139
+ id
140
+ }
141
+
142
+ // Allocate this node + descendants; return its index.
143
+ fn alloc(&mut self, handle: &Handle, parent: i32) -> i32 {
144
+ let idx = self.soa.node_type.len();
145
+ // push placeholders; fill scalars below
146
+ self.soa.node_type.push(0);
147
+ self.soa.ns.push(0);
148
+ self.soa.tag_id.push(0);
149
+ self.soa.parent.push(parent);
150
+ self.soa.first_child.push(-1);
151
+ self.soa.next_sib.push(-1);
152
+ self.soa.text_id.push(-1);
153
+ self.soa.pub_id.push(-1);
154
+ self.soa.sys_id.push(-1);
155
+ self.soa.attr_start.push(-1);
156
+ self.soa.attr_count.push(0);
157
+
158
+ let mut template_content: Option<Handle> = None;
159
+
160
+ match &handle.data {
161
+ NodeData::Document => self.soa.node_type[idx] = DOCUMENT_NODE,
162
+ NodeData::Doctype { name, public_id, system_id } => {
163
+ self.soa.node_type[idx] = DOCUMENT_TYPE_NODE;
164
+ self.soa.text_id[idx] = self.push_string(name);
165
+ self.soa.pub_id[idx] = self.push_string(public_id);
166
+ self.soa.sys_id[idx] = self.push_string(system_id);
167
+ }
168
+ NodeData::Text { contents } => {
169
+ self.soa.node_type[idx] = TEXT_NODE;
170
+ self.soa.text_id[idx] = self.push_string(&contents.borrow());
171
+ }
172
+ NodeData::Comment { contents } => {
173
+ self.soa.node_type[idx] = COMMENT_NODE;
174
+ self.soa.text_id[idx] = self.push_string(contents);
175
+ }
176
+ NodeData::ProcessingInstruction { target, contents } => {
177
+ self.soa.node_type[idx] = PROCESSING_INSTRUCTION_NODE;
178
+ let combined = format!("{} {}", target, contents);
179
+ self.soa.text_id[idx] = self.push_string(&combined);
180
+ }
181
+ NodeData::Element { name, attrs, template_contents, .. } => {
182
+ self.soa.node_type[idx] = ELEMENT_NODE;
183
+ self.soa.ns[idx] = if name.ns == ns!(svg) { 1 } else if name.ns == ns!(mathml) { 2 } else { 0 };
184
+ self.soa.tag_id[idx] = self.intern_tag(&name.local);
185
+ let borrowed = attrs.borrow();
186
+ if !borrowed.is_empty() {
187
+ self.soa.attr_start[idx] = self.soa.attr_value.len() as i32;
188
+ self.soa.attr_count[idx] = borrowed.len() as u16;
189
+ for attr in borrowed.iter() {
190
+ let nid = self.intern_attr_name(&attr.name.local);
191
+ self.soa.attr_name_id.push(nid);
192
+ self.soa.attr_value.push(attr.value.to_string());
193
+ let pfx = attr.name.prefix.as_ref().map(|p| p.to_string()).unwrap_or_default();
194
+ let pid = self.intern_attr_prefix(&pfx);
195
+ self.soa.attr_prefix_id.push(pid);
196
+ }
197
+ }
198
+ if &*name.local == "template" {
199
+ if let Some(c) = &*template_contents.borrow() {
200
+ template_content = Some(c.clone());
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // children, linking first_child / next_sib (inline; no closure to avoid borrow churn)
207
+ let mut prev = -1i32;
208
+ for child in handle.children.borrow().iter() {
209
+ let cidx = self.alloc(child, idx as i32);
210
+ if prev == -1 { self.soa.first_child[idx] = cidx; } else { self.soa.next_sib[prev as usize] = cidx; }
211
+ prev = cidx;
212
+ }
213
+ // <template> content as a synthetic document-fragment child named "content"
214
+ if let Some(content) = template_content {
215
+ let cidx = self.soa.node_type.len() as i32;
216
+ self.soa.node_type.push(DOCUMENT_FRAGMENT_NODE);
217
+ self.soa.ns.push(0);
218
+ let content_tag = self.intern_tag("content");
219
+ self.soa.tag_id.push(content_tag);
220
+ self.soa.parent.push(idx as i32);
221
+ self.soa.first_child.push(-1);
222
+ self.soa.next_sib.push(-1);
223
+ self.soa.text_id.push(-1);
224
+ self.soa.pub_id.push(-1);
225
+ self.soa.sys_id.push(-1);
226
+ self.soa.attr_start.push(-1);
227
+ self.soa.attr_count.push(0);
228
+ let mut cprev = -1i32;
229
+ for gc in content.children.borrow().iter() {
230
+ let gcidx = self.alloc(gc, cidx);
231
+ if cprev == -1 { self.soa.first_child[cidx as usize] = gcidx; } else { self.soa.next_sib[cprev as usize] = gcidx; }
232
+ cprev = gcidx;
233
+ }
234
+ // link the content fragment as a child of the template
235
+ if prev == -1 { self.soa.first_child[idx] = cidx; } else { self.soa.next_sib[prev as usize] = cidx; }
236
+ prev = cidx;
237
+ }
238
+ let _ = prev;
239
+
240
+ idx as i32
241
+ }
242
+ }
243
+
244
+ /// Parse a full document into the SoA flat buffer.
245
+ pub fn parse_html_soa(html: &str) -> Soa {
246
+ let dom = parse_document(RcDom::default(), opts())
247
+ .from_utf8()
248
+ .read_from(&mut html.as_bytes())
249
+ .expect("RcDom read_from is infallible over a byte slice");
250
+ let mut b = SoaBuilder {
251
+ soa: Soa::default(),
252
+ tag_map: std::collections::HashMap::new(),
253
+ attr_name_map: std::collections::HashMap::new(),
254
+ attr_prefix_map: std::collections::HashMap::new(),
255
+ };
256
+ b.alloc(&dom.document, -1);
257
+ b.soa
258
+ }
259
+
260
+ /// Parse a document and return only the node count — no `core::Node` tree, no
261
+ /// marshaling. Isolates raw html5ever parse cost from tree-build + boundary cost.
262
+ pub fn parse_html_document_count(html: &str) -> u32 {
263
+ let dom = parse_document(RcDom::default(), opts())
264
+ .from_utf8()
265
+ .read_from(&mut html.as_bytes())
266
+ .expect("RcDom read_from is infallible over a byte slice");
267
+ count(&dom.document)
268
+ }
269
+
270
+ fn count(handle: &Handle) -> u32 {
271
+ 1 + handle.children.borrow().iter().map(count).sum::<u32>()
272
+ }
273
+
274
+ /// Parse an HTML fragment (e.g. an `innerHTML=` set) in `<body>` context.
275
+ /// Returns a synthetic Document-fragment-ish root whose children are the parsed nodes.
276
+ pub fn parse_html_fragment(html: &str) -> Node {
277
+ parse_html_fragment_in(html, "", "body")
278
+ }
279
+
280
+ /// Parse a fragment given an html5lib-style context string:
281
+ /// "" / "body" / "td" (html ns) or "svg path" / "math ms" (foreign ns).
282
+ pub fn parse_html_fragment_context(html: &str, context: &str) -> Node {
283
+ let (ns, local) = match context {
284
+ "" => ("", "body"),
285
+ s => match s.split_once(' ') {
286
+ Some(("svg", local)) => ("svg", local),
287
+ Some(("math", local)) => ("math", local),
288
+ Some((_, local)) => ("", local),
289
+ None => ("", s),
290
+ },
291
+ };
292
+ parse_html_fragment_in(html, ns, local)
293
+ }
294
+
295
+ /// Parse a fragment in an explicit context element.
296
+ /// `context_ns`: "" (html), "svg", or "math"; `context_local`: the element local name.
297
+ pub fn parse_html_fragment_in(html: &str, context_ns: &str, context_local: &str) -> Node {
298
+ let ns = match context_ns {
299
+ "svg" => ns!(svg),
300
+ "math" => ns!(mathml),
301
+ _ => ns!(html),
302
+ };
303
+ let ctx = QualName::new(None, ns, LocalName::from(context_local));
304
+ let dom = parse_fragment(RcDom::default(), opts(), ctx, vec![], false)
305
+ .from_utf8()
306
+ .read_from(&mut html.as_bytes())
307
+ .expect("RcDom read_from is infallible over a byte slice");
308
+ // parse_fragment wraps results under a synthetic <html> element; unwrap it.
309
+ let root = walk(&dom.document);
310
+ let mut frag = Node {
311
+ node_type: DOCUMENT_NODE,
312
+ name: "#document-fragment".to_string(),
313
+ value: String::new(),
314
+ namespace: String::new(),
315
+ public_id: String::new(),
316
+ system_id: String::new(),
317
+ attrs: Vec::new(),
318
+ children: Vec::new(),
319
+ };
320
+ for child in root.children {
321
+ if child.node_type == ELEMENT_NODE && child.name == "html" {
322
+ frag.children.extend(child.children);
323
+ } else {
324
+ frag.children.push(child);
325
+ }
326
+ }
327
+ frag
328
+ }
329
+
330
+ fn walk(handle: &Handle) -> Node {
331
+ let node = handle;
332
+ let mut public_id = String::new();
333
+ let mut system_id = String::new();
334
+ let (node_type, name, value, namespace, attrs) = match &node.data {
335
+ NodeData::Document => {
336
+ (DOCUMENT_NODE, "#document".to_string(), String::new(), String::new(), Vec::new())
337
+ }
338
+ NodeData::Doctype { name, public_id: pub_id, system_id: sys_id } => {
339
+ public_id = pub_id.to_string();
340
+ system_id = sys_id.to_string();
341
+ (DOCUMENT_TYPE_NODE, name.to_string(), String::new(), String::new(), Vec::new())
342
+ }
343
+ NodeData::Text { contents } => (
344
+ TEXT_NODE,
345
+ "#text".to_string(),
346
+ contents.borrow().to_string(),
347
+ String::new(),
348
+ Vec::new(),
349
+ ),
350
+ NodeData::Comment { contents } => (
351
+ COMMENT_NODE,
352
+ "#comment".to_string(),
353
+ contents.to_string(),
354
+ String::new(),
355
+ Vec::new(),
356
+ ),
357
+ NodeData::ProcessingInstruction { target, contents } => (
358
+ PROCESSING_INSTRUCTION_NODE,
359
+ target.to_string(),
360
+ contents.to_string(),
361
+ String::new(),
362
+ Vec::new(),
363
+ ),
364
+ NodeData::Element { name, attrs, .. } => {
365
+ let a = attrs
366
+ .borrow()
367
+ .iter()
368
+ .map(|attr| Attr {
369
+ name: attr.name.local.to_string(),
370
+ value: attr.value.to_string(),
371
+ prefix: attr.name.prefix.as_ref().map(|p| p.to_string()).unwrap_or_default(),
372
+ })
373
+ .collect();
374
+ // html5lib short namespace form: "" (html), "svg", "math".
375
+ let ns = if name.ns == ns!(svg) {
376
+ "svg".to_string()
377
+ } else if name.ns == ns!(mathml) {
378
+ "math".to_string()
379
+ } else {
380
+ String::new()
381
+ };
382
+ (ELEMENT_NODE, name.local.to_string(), String::new(), ns, a)
383
+ }
384
+ };
385
+
386
+ let mut children: Vec<Node> = node.children.borrow().iter().map(walk).collect();
387
+
388
+ // <template> content lives in a separate document fragment, not in children.
389
+ // html5lib prints it as a synthetic `content` fragment node holding the parsed tree.
390
+ if let NodeData::Element { template_contents, .. } = &node.data {
391
+ if let Some(contents) = &*template_contents.borrow() {
392
+ let inner = contents.children.borrow().iter().map(walk).collect();
393
+ children.push(Node {
394
+ node_type: DOCUMENT_FRAGMENT_NODE,
395
+ name: "content".to_string(),
396
+ value: String::new(),
397
+ namespace: String::new(),
398
+ public_id: String::new(),
399
+ system_id: String::new(),
400
+ attrs: Vec::new(),
401
+ children: inner,
402
+ });
403
+ }
404
+ }
405
+
406
+ Node { node_type, name, value, namespace, public_id, system_id, attrs, children }
407
+ }
408
+
409
+ #[cfg(test)]
410
+ mod tests {
411
+ use super::*;
412
+
413
+ fn first<'a>(n: &'a Node, pred: &dyn Fn(&Node) -> bool) -> Option<&'a Node> {
414
+ if pred(n) {
415
+ return Some(n);
416
+ }
417
+ for c in &n.children {
418
+ if let Some(h) = first(c, pred) {
419
+ return Some(h);
420
+ }
421
+ }
422
+ None
423
+ }
424
+
425
+ #[test]
426
+ fn document_has_html_head_body() {
427
+ let doc = parse_html_document("<div>hi</div>");
428
+ assert_eq!(doc.node_type, DOCUMENT_NODE);
429
+ assert!(first(&doc, &|n| n.node_type == ELEMENT_NODE && n.name == "html").is_some());
430
+ assert!(first(&doc, &|n| n.node_type == ELEMENT_NODE && n.name == "head").is_some());
431
+ assert!(first(&doc, &|n| n.node_type == ELEMENT_NODE && n.name == "body").is_some());
432
+ }
433
+
434
+ #[test]
435
+ fn attrs_marshaled_in_order() {
436
+ let doc = parse_html_document("<div id=\"a\" class=\"x\"></div>");
437
+ let div = first(&doc, &|n| n.name == "div").unwrap();
438
+ assert_eq!(div.attrs[0].name, "id");
439
+ assert_eq!(div.attrs[0].value, "a");
440
+ assert_eq!(div.attrs[1].name, "class");
441
+ }
442
+
443
+ #[test]
444
+ fn doctype_public_system_captured() {
445
+ let doc = parse_html_document(
446
+ "<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://x/strict.dtd\"><p>x",
447
+ );
448
+ let dt = first(&doc, &|n| n.node_type == DOCUMENT_TYPE_NODE).unwrap();
449
+ assert_eq!(dt.name, "html");
450
+ assert_eq!(dt.public_id, "-//W3C//DTD HTML 4.01//EN");
451
+ assert_eq!(dt.system_id, "http://x/strict.dtd");
452
+ }
453
+
454
+ #[test]
455
+ fn foster_parenting_table() {
456
+ let doc = parse_html_document("<table>oops<tr><td>x</td></tr></table>");
457
+ let body = first(&doc, &|n| n.name == "body").unwrap();
458
+ let fostered = body
459
+ .children
460
+ .iter()
461
+ .any(|c| c.node_type == TEXT_NODE && c.value.contains("oops"));
462
+ assert!(fostered, "stray text must be fostered out of <table>");
463
+ }
464
+
465
+ #[test]
466
+ fn svg_namespace_short_form() {
467
+ let doc = parse_html_document("<svg><rect/></svg>");
468
+ let svg = first(&doc, &|n| n.name == "svg").unwrap();
469
+ assert_eq!(svg.namespace, "svg");
470
+ }
471
+
472
+ #[test]
473
+ fn template_content_separated() {
474
+ let frag = parse_html_fragment("<template>Hello</template>");
475
+ let tmpl = first(&frag, &|n| n.name == "template").unwrap();
476
+ let content = tmpl
477
+ .children
478
+ .iter()
479
+ .find(|c| c.node_type == DOCUMENT_FRAGMENT_NODE)
480
+ .expect("template has a content fragment");
481
+ assert_eq!(content.name, "content");
482
+ assert_eq!(content.children[0].value, "Hello");
483
+ }
484
+
485
+ #[test]
486
+ fn fragment_context_namespace() {
487
+ let frag = parse_html_fragment_context("<rect/>", "svg path");
488
+ // context is svg path, so a bare <rect> parses in the SVG namespace
489
+ let rect = first(&frag, &|n| n.name == "rect");
490
+ assert!(rect.map(|n| n.namespace == "svg").unwrap_or(false));
491
+ }
492
+ }
@@ -0,0 +1,34 @@
1
+ // Install a turbo-dom window/document onto a global object (the shared core of
2
+ // the vitest + jest environment adapters). Globals are defined as getters that
3
+ // pull from the lazy window Proxy, so laziness + the touch-tracer are preserved.
4
+
5
+ import { createEnvironment } from '../runtime/index.mjs';
6
+
7
+ const DEFAULT_HTML = '<!doctype html><html><head></head><body></body></html>';
8
+
9
+ // Globals that point at the window itself.
10
+ const SELF_KEYS = ['window', 'self', 'globalThis', 'parent', 'top', 'frames'];
11
+
12
+ export function installGlobals(target, { html = DEFAULT_HTML, url } = {}) {
13
+ const env = createEnvironment(html, url ? { url } : {});
14
+ const { window } = env;
15
+
16
+ const define = (name, getter) => {
17
+ Object.defineProperty(target, name, { configurable: true, get: getter, set(v) { Object.defineProperty(target, name, { configurable: true, writable: true, value: v }); } });
18
+ };
19
+
20
+ // window self-references
21
+ for (const k of SELF_KEYS) define(k, () => window);
22
+ // document is eager + universal
23
+ Object.defineProperty(target, 'document', { configurable: true, writable: true, value: env.document });
24
+
25
+ // every other window global → lazy getter (materializes + traces on first read)
26
+ for (const name of env.globalKeys) {
27
+ if (name === 'document' || SELF_KEYS.includes(name)) continue;
28
+ define(name, () => window[name]);
29
+ }
30
+
31
+ // handy escape hatches for adapters / per-test reset
32
+ target.__turboDom = env;
33
+ return env;
34
+ }
@@ -0,0 +1,40 @@
1
+ // Jest environment adapter. Use in jest config:
2
+ //
3
+ // // jest.config.js
4
+ // module.exports = { testEnvironment: '@miaskiewicz/turbo-dom/jest' }
5
+ //
6
+ // or point directly at this file:
7
+ //
8
+ // testEnvironment: './node_modules/@miaskiewicz/turbo-dom/dist/environment/jest.cjs'
9
+ //
10
+ // Per-file / project options:
11
+ // testEnvironmentOptions: { html: '<!doctype html>...', url: 'http://localhost/' }
12
+ //
13
+ // Requires `jest-environment-node` (a jest dependency) to be resolvable.
14
+
15
+ const nodeEnv = require('jest-environment-node');
16
+ const NodeEnvironment = nodeEnv.TestEnvironment || nodeEnv.default || nodeEnv;
17
+
18
+ class TurboDomEnvironment extends NodeEnvironment {
19
+ constructor(config, context) {
20
+ super(config, context);
21
+ const projectConfig = config.projectConfig || config;
22
+ this.__opts = projectConfig.testEnvironmentOptions || {};
23
+ }
24
+
25
+ async setup() {
26
+ await super.setup();
27
+ // runtime is ESM; load it dynamically from this CJS environment
28
+ const { installGlobals } = await import('./install.mjs');
29
+ installGlobals(this.global, this.__opts.turboDom || this.__opts);
30
+ }
31
+
32
+ async teardown() {
33
+ if (this.global && this.global.__turboDom) this.global.__turboDom.reset();
34
+ await super.teardown();
35
+ }
36
+ }
37
+
38
+ module.exports = TurboDomEnvironment;
39
+ module.exports.default = TurboDomEnvironment;
40
+ module.exports.TestEnvironment = TurboDomEnvironment;
@@ -0,0 +1,31 @@
1
+ // Vitest environment adapter. Use in vitest config:
2
+ //
3
+ // // vitest.config.ts
4
+ // export default defineConfig({ test: { environment: 'turbo-dom' } })
5
+ //
6
+ // (resolves to the package "vitest-environment-turbo-dom", which re-exports this),
7
+ // or point directly at this file:
8
+ //
9
+ // test: { environment: './node_modules/@miaskiewicz/turbo-dom/dist/environment/vitest.mjs' }
10
+ //
11
+ // Per-file options via environmentOptions:
12
+ // test: { environmentOptions: { turboDom: { html: '<!doctype html>...', url: 'http://localhost/' } } }
13
+
14
+ import { installGlobals } from './install.mjs';
15
+
16
+ export default {
17
+ name: 'turbo-dom',
18
+ // tests run as web/browser-style modules
19
+ transformMode: 'web',
20
+
21
+ setup(global, options) {
22
+ const opts = (options && options.turboDom) || {};
23
+ const env = installGlobals(global, opts);
24
+ return {
25
+ teardown() {
26
+ // drop overlay + materialized globals; nothing leaks across files
27
+ env.reset();
28
+ },
29
+ };
30
+ },
31
+ };