@miaskiewicz/turbo-dom 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -133,6 +133,49 @@ export class Node extends EventTarget {
133
133
  return false;
134
134
  }
135
135
 
136
+ get isConnected() {
137
+ let n = this;
138
+ while (n.parentNode) n = n.parentNode;
139
+ return n.nodeType === DOCUMENT_NODE;
140
+ }
141
+ getRootNode() {
142
+ let n = this;
143
+ while (n.parentNode) n = n.parentNode;
144
+ return n;
145
+ }
146
+ normalize() {
147
+ const kids = this.__children();
148
+ for (let i = kids.length - 1; i > 0; i--) {
149
+ if (kids[i].nodeType === TEXT_NODE && kids[i - 1].nodeType === TEXT_NODE) {
150
+ kids[i - 1]._data += kids[i].data; kids[i].parentNode = null; kids.splice(i, 1);
151
+ }
152
+ }
153
+ for (let i = kids.length - 1; i >= 0; i--) {
154
+ if (kids[i].nodeType === TEXT_NODE && kids[i].data === '') { kids[i].parentNode = null; kids.splice(i, 1); }
155
+ else if (kids[i].nodeType === ELEMENT_NODE) kids[i].normalize();
156
+ }
157
+ }
158
+ replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
159
+ // bitmask: 1 DISCONNECTED, 2 PRECEDING, 4 FOLLOWING, 8 CONTAINS, 16 CONTAINED_BY
160
+ compareDocumentPosition(other) {
161
+ if (other === this) return 0;
162
+ if (this.contains(other)) return 16 + 4;
163
+ if (other.contains(this)) return 8 + 2;
164
+ // document order via a flat walk from the common root
165
+ const root = this.getRootNode();
166
+ const order = [];
167
+ (function walk(n) { order.push(n); for (const c of (n.__children ? n.__children() : [])) walk(c); })(root);
168
+ const a = order.indexOf(this), b = order.indexOf(other);
169
+ if (a === -1 || b === -1) return 1;
170
+ return a < b ? 4 : 2;
171
+ }
172
+ cloneNode(deep = false) {
173
+ // base fallback; Element/Text/Comment override
174
+ const n = new this.constructor(this.ownerDocument);
175
+ if (deep) for (const c of this.__children()) n.appendChild(c.cloneNode(true));
176
+ return n;
177
+ }
178
+
136
179
  get textContent() {
137
180
  let s = '';
138
181
  for (const c of this.__children()) {
@@ -160,11 +203,33 @@ class CharacterData extends Node {
160
203
  get length() { return this._data.length; }
161
204
  get textContent() { return this._data; }
162
205
  set textContent(v) { this.data = v; }
206
+ substringData(offset, count) { return this._data.slice(offset, offset + count); }
207
+ appendData(s) { this.data = this._data + s; }
208
+ insertData(offset, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset); }
209
+ deleteData(offset, count) { this.data = this._data.slice(0, offset) + this._data.slice(offset + count); }
210
+ replaceData(offset, count, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset + count); }
211
+ before(...nodes) { const p = this.parentNode; if (p) for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, this); }
212
+ after(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
213
+ replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; this.remove(); for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, ref); }
163
214
  }
164
215
 
165
216
  export class Text extends CharacterData {
166
217
  get nodeType() { return TEXT_NODE; }
167
218
  get nodeName() { return '#text'; }
219
+ get wholeText() {
220
+ let s = this._data, n = this.previousSibling;
221
+ while (n && n.nodeType === TEXT_NODE) { s = n.data + s; n = n.previousSibling; }
222
+ n = this.nextSibling;
223
+ while (n && n.nodeType === TEXT_NODE) { s += n.data; n = n.nextSibling; }
224
+ return s;
225
+ }
226
+ splitText(offset) {
227
+ const rest = this._data.slice(offset);
228
+ this.data = this._data.slice(0, offset);
229
+ const node = new Text(this.ownerDocument, rest);
230
+ if (this.parentNode) this.parentNode.insertBefore(node, this.nextSibling);
231
+ return node;
232
+ }
168
233
  cloneNode() { return new Text(this.ownerDocument, this._data); }
169
234
  }
170
235
 
@@ -462,6 +527,54 @@ export class Element extends Node {
462
527
  getBoundingClientRect() { return zeroRect(); }
463
528
  getClientRects() { return []; }
464
529
  scrollIntoView() {}
530
+ scroll() {} scrollTo() {} scrollBy() {}
531
+ // honest zero geometry (no layout)
532
+ get offsetWidth() { return 0; } get offsetHeight() { return 0; }
533
+ get offsetTop() { return 0; } get offsetLeft() { return 0; }
534
+ get offsetParent() { return null; }
535
+ get clientWidth() { return 0; } get clientHeight() { return 0; }
536
+ get clientTop() { return 0; } get clientLeft() { return 0; }
537
+ get scrollWidth() { return 0; } get scrollHeight() { return 0; }
538
+ get scrollTop() { return 0; } set scrollTop(_v) {} get scrollLeft() { return 0; } set scrollLeft(_v) {}
539
+
540
+ // namespaced attributes
541
+ getAttributeNS(_ns, name) { return this.getAttribute(name); }
542
+ setAttributeNS(_ns, name, value) { this.setAttribute(name, value); }
543
+ hasAttributeNS(_ns, name) { return this.hasAttribute(name); }
544
+ removeAttributeNS(_ns, name) { this.removeAttribute(name); }
545
+ getAttributeNode(name) { const a = this.__attrs.find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
546
+
547
+ // adjacency
548
+ insertAdjacentElement(position, el) {
549
+ const p = this.parentNode;
550
+ switch (position) {
551
+ case 'beforebegin': if (p) p.insertBefore(el, this); break;
552
+ case 'afterbegin': this.insertBefore(el, this.firstChild); break;
553
+ case 'beforeend': this.appendChild(el); break;
554
+ case 'afterend': if (p) p.insertBefore(el, this.nextSibling); break;
555
+ }
556
+ return el;
557
+ }
558
+ insertAdjacentText(position, text) { this.insertAdjacentElement(position, this.ownerDocument.createTextNode(text)); }
559
+
560
+ // commonly-reflected properties
561
+ get tabIndex() { return this.hasAttribute('tabindex') ? parseInt(this.getAttribute('tabindex'), 10) || 0 : (/^(a|button|input|select|textarea)$/.test(this.localName) ? 0 : -1); }
562
+ set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
563
+ get title() { return this.getAttribute('title') ?? ''; } set title(v) { this.setAttribute('title', v); }
564
+ get lang() { return this.getAttribute('lang') ?? ''; } set lang(v) { this.setAttribute('lang', v); }
565
+ get dir() { return this.getAttribute('dir') ?? ''; } set dir(v) { this.setAttribute('dir', v); }
566
+ get hidden() { return this.hasAttribute('hidden'); } set hidden(v) { if (v) this.setAttribute('hidden', ''); else this.removeAttribute('hidden'); }
567
+ get role() { return this.getAttribute('role'); } set role(v) { this.setAttribute('role', v); }
568
+ get contentEditable() { return this.getAttribute('contenteditable') ?? 'inherit'; } set contentEditable(v) { this.setAttribute('contenteditable', v); }
569
+ get isContentEditable() { return this.getAttribute('contenteditable') === 'true' || this.getAttribute('contenteditable') === ''; }
570
+ get hreflang() { return this.getAttribute('hreflang') ?? ''; }
571
+ get target() { return this.getAttribute('target') ?? ''; } set target(v) { this.setAttribute('target', v); }
572
+
573
+ // pointer capture + animations (no-op honest stubs)
574
+ setPointerCapture() {} releasePointerCapture() {} hasPointerCapture() { return false; }
575
+ animate() { return { play() {}, pause() {}, cancel() {}, finish() {}, finished: Promise.resolve(), onfinish: null, cancel_: null }; }
576
+ getAnimations() { return []; }
577
+ requestFullscreen() { return Promise.resolve(); }
465
578
 
466
579
  // canvas (no raster backend — honest no-op context)
467
580
  getContext(type) { return this.localName === 'canvas' ? (this.__ctx ||= makeCanvasStub()) : null; }
@@ -586,6 +699,54 @@ function makeSelection() {
586
699
  };
587
700
  }
588
701
 
702
+ // TreeWalker / NodeIterator over the DOM (doubles for both — common subset).
703
+ class TreeWalker {
704
+ constructor(root, whatToShow, filter) {
705
+ this.root = root; this.whatToShow = whatToShow >>> 0; this.filter = filter; this.currentNode = root;
706
+ this.referenceNode = root; this.pointerBeforeReferenceNode = true;
707
+ }
708
+ __show(node) {
709
+ const bit = node.nodeType === ELEMENT_NODE ? 1 : node.nodeType === TEXT_NODE ? 4 : node.nodeType === COMMENT_NODE ? 128 : 0xffffffff;
710
+ if (!(this.whatToShow & bit) && this.whatToShow !== 0xffffffff) return 3; // FILTER_SKIP
711
+ if (this.filter) {
712
+ const fn = typeof this.filter === 'function' ? this.filter : this.filter.acceptNode;
713
+ return fn.call(this.filter, node);
714
+ }
715
+ return 1; // FILTER_ACCEPT
716
+ }
717
+ __flat() {
718
+ const out = [];
719
+ (function walk(n) { for (const c of (n.__children ? n.__children() : [])) { out.push(c); walk(c); } })(this.root);
720
+ return out.filter((n) => this.__show(n) === 1);
721
+ }
722
+ nextNode() { const all = this.__flat(); const i = all.indexOf(this.currentNode); const next = all[i + 1] ?? (this.currentNode === this.root ? all[0] : null); this.currentNode = next; this.referenceNode = next; return next ?? null; }
723
+ previousNode() { const all = this.__flat(); const i = all.indexOf(this.currentNode); const prev = i > 0 ? all[i - 1] : null; if (prev) { this.currentNode = prev; this.referenceNode = prev; } return prev ?? null; }
724
+ firstChild() { const k = (this.currentNode.__children ? this.currentNode.__children() : []).filter((n) => this.__show(n) === 1); if (k[0]) { this.currentNode = k[0]; return k[0]; } return null; }
725
+ lastChild() { const k = (this.currentNode.__children ? this.currentNode.__children() : []).filter((n) => this.__show(n) === 1); const last = k[k.length - 1]; if (last) { this.currentNode = last; return last; } return null; }
726
+ parentNode() { const p = this.currentNode.parentNode; if (p && p !== this.root.parentNode) { this.currentNode = p; return p; } return null; }
727
+ nextSibling() { let n = this.currentNode.nextSibling; while (n && this.__show(n) !== 1) n = n.nextSibling; if (n) this.currentNode = n; return n ?? null; }
728
+ previousSibling() { let n = this.currentNode.previousSibling; while (n && this.__show(n) !== 1) n = n.previousSibling; if (n) this.currentNode = n; return n ?? null; }
729
+ detach() {}
730
+ }
731
+
732
+ // DOMParser — parses a string into a real Document via the native parser.
733
+ export class DOMParser {
734
+ parseFromString(str, type = 'text/html') {
735
+ if (type === 'text/html') return parseDocument(String(str));
736
+ // XML-ish: parse as a fragment wrapped in a document
737
+ const doc = parseDocument(`<!doctype html><html><body>${str}</body></html>`);
738
+ return doc;
739
+ }
740
+ }
741
+
742
+ // XMLSerializer — serializes a node back to markup.
743
+ export class XMLSerializer {
744
+ serializeToString(node) {
745
+ if (node.nodeType === DOCUMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE) return serializeInner(node);
746
+ return serializeOuter(node);
747
+ }
748
+ }
749
+
589
750
  // minimal inline-style CSSOM (honest: only inline + explicitly set props)
590
751
  function makeStyle(el) {
591
752
  const parse = () => {
@@ -817,10 +978,44 @@ export class Document extends Node {
817
978
  createDocumentFragment() { return new DocumentFragment(this); }
818
979
  createEvent() { return new Event(''); }
819
980
  createRange() { return new Range(this); }
981
+ createAttribute(name) { return { name, value: '', ownerElement: null }; }
982
+ createComment(data) { return new Comment(this, String(data)); }
820
983
  getSelection() { if (!this.__selection) this.__selection = makeSelection(); return this.__selection; }
821
984
  importNode(node, deep) { return node.cloneNode(deep); }
822
985
  adoptNode(node) { if (node.parentNode) node.parentNode.removeChild(node); node.ownerDocument = this; return node; }
823
986
 
987
+ // TreeWalker / NodeIterator (whatToShow: 1 elements, 4 text, 0xFFFFFFFF all)
988
+ createTreeWalker(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
989
+ createNodeIterator(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
990
+
991
+ getElementsByName(name) {
992
+ const self = this; return liveHTMLCollection(() => collectByTag(self, '*').filter((e) => e.getAttribute('name') === name));
993
+ }
994
+ elementFromPoint() { return null; }
995
+ elementsFromPoint() { return []; }
996
+ execCommand() { return false; }
997
+ queryCommandSupported() { return false; }
998
+ queryCommandEnabled() { return false; }
999
+ hasFocus() { return true; }
1000
+ write() {} writeln() {} open() { return this; } close() {}
1001
+
1002
+ get title() { const t = this.querySelector('title'); return t ? t.textContent : (this.__title || ''); }
1003
+ set title(v) { const t = this.querySelector('title'); if (t) t.textContent = v; else this.__title = String(v); }
1004
+ get location() { return this.defaultView ? this.defaultView.location : null; }
1005
+ get baseURI() { return (this.defaultView && this.defaultView.location && this.defaultView.location.href) || 'about:blank'; }
1006
+ get URL() { return this.baseURI; }
1007
+ get documentURI() { return this.baseURI; }
1008
+ get scrollingElement() { return this.documentElement; }
1009
+ get fullscreenElement() { return null; }
1010
+ get implementation() {
1011
+ const self = this;
1012
+ return {
1013
+ createHTMLDocument(title) { const d = parseDocument(`<!doctype html><html><head><title>${title || ''}</title></head><body></body></html>`); return d; },
1014
+ createDocumentType: (name, pub, sys) => new DocumentType(self, name, pub, sys),
1015
+ hasFeature: () => true,
1016
+ };
1017
+ }
1018
+
824
1019
  // ---- queries ----
825
1020
  getElementById(id) {
826
1021
  let found = null;
@@ -12,7 +12,7 @@ import {
12
12
  } from './stubs.mjs';
13
13
  import {
14
14
  Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
15
- MutationObserver,
15
+ MutationObserver, DOMParser, XMLSerializer,
16
16
  } from './dom.mjs';
17
17
  import {
18
18
  EventTarget,
@@ -69,12 +69,29 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
69
69
  HTMLSpanElement: Element, HTMLParagraphElement: Element, HTMLUListElement: Element,
70
70
  HTMLLIElement: Element, HTMLHeadingElement: Element, HTMLBodyElement: Element,
71
71
  HTMLDocument: Document, DocumentFragment, ShadowRoot: DocumentFragment,
72
- MutationObserver,
72
+ MutationObserver, DOMParser, XMLSerializer,
73
73
  URL: makeURL(), URLSearchParams,
74
74
  Blob: globalThis.Blob, File: makeFile(), FileReader,
75
75
  customElements: makeCustomElements(),
76
76
  AbortController: globalThis.AbortController, AbortSignal: globalThis.AbortSignal,
77
77
  TextEncoder: globalThis.TextEncoder, TextDecoder: globalThis.TextDecoder,
78
+ // web platform globals Node already provides
79
+ fetch: globalThis.fetch ? (...a) => globalThis.fetch(...a) : undefined,
80
+ Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response,
81
+ FormData: globalThis.FormData, ReadableStream: globalThis.ReadableStream,
82
+ crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
83
+ btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
84
+ atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
85
+ MessageChannel: globalThis.MessageChannel, MessagePort: globalThis.MessagePort,
86
+ BroadcastChannel: globalThis.BroadcastChannel, EventSource: globalThis.EventSource,
87
+ reportError: (e) => { /* swallow; tests assert via handlers */ void e; },
88
+ requestIdleCallback: (cb) => hostSetTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0),
89
+ cancelIdleCallback: (id) => hostClearTimeout(id),
90
+ CSS: { supports: () => true, escape: (s) => String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c) },
91
+ XMLHttpRequest: makeXHR(),
92
+ Image: function Image(w, h) { const img = document.createElement('img'); if (w != null) img.setAttribute('width', w); if (h != null) img.setAttribute('height', h); return img; },
93
+ Audio: function Audio(src) { const a = document.createElement('audio'); if (src) a.setAttribute('src', src); return a; },
94
+ Worker: class Worker { constructor() {} postMessage() {} terminate() {} addEventListener() {} removeEventListener() {} },
78
95
  // timers delegate to the captured host fns (NOT the bare names — once these
79
96
  // are installed on globalThis the bare names resolve back here → recursion)
80
97
  setTimeout: (...a) => hostSetTimeout(...a),
@@ -104,12 +121,26 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
104
121
  // subsystem grouping: history co-materializes with (and shares) location
105
122
  location: () => makeLocation(url),
106
123
  history: () => makeHistory(windowProxy.location),
107
- navigator: () => ({ userAgent: 'turbo-dom/0.0.1', platform: 'turbo-dom', language: 'en-US', languages: ['en-US'], onLine: true }),
108
- performance: () => ({ now: performanceNow, timeOrigin: 0, mark() {}, measure() {} }),
124
+ navigator: () => ({
125
+ userAgent: 'Mozilla/5.0 (turbo-dom) AppleWebKit/537.36',
126
+ platform: 'turbo-dom', vendor: '', language: 'en-US', languages: ['en-US'],
127
+ onLine: true, cookieEnabled: true, doNotTrack: null, maxTouchPoints: 0,
128
+ hardwareConcurrency: 4, deviceMemory: 8, webdriver: false,
129
+ clipboard: { readText: async () => '', writeText: async () => {}, read: async () => [], write: async () => {} },
130
+ permissions: { query: async () => ({ state: 'prompt', addEventListener() {}, removeEventListener() {} }) },
131
+ sendBeacon: () => true, vibrate: () => false,
132
+ }),
133
+ performance: () => ({ now: performanceNow, timeOrigin: 0, mark() {}, measure() {}, getEntriesByName: () => [], getEntriesByType: () => [], clearMarks() {}, clearMeasures() {} }),
109
134
  Storage: () => Storage,
110
135
  devicePixelRatio: () => 1,
111
136
  innerWidth: () => 1024,
112
137
  innerHeight: () => 768,
138
+ outerWidth: () => 1024,
139
+ outerHeight: () => 768,
140
+ scrollX: () => 0, scrollY: () => 0, pageXOffset: () => 0, pageYOffset: () => 0,
141
+ screenX: () => 0, screenY: () => 0, screenLeft: () => 0, screenTop: () => 0,
142
+ screen: () => ({ width: 1024, height: 768, availWidth: 1024, availHeight: 768, colorDepth: 24, pixelDepth: 24, orientation: { type: 'landscape-primary', angle: 0, addEventListener() {}, removeEventListener() {} } }),
143
+ visualViewport: () => ({ width: 1024, height: 768, scale: 1, offsetLeft: 0, offsetTop: 0, pageLeft: 0, pageTop: 0, addEventListener() {}, removeEventListener() {} }),
113
144
  };
114
145
 
115
146
  windowProxy = new Proxy(base, {
@@ -166,3 +197,32 @@ function makeFile() {
166
197
  constructor(bits = [], name = 'file', opts = {}) { super(bits, opts); this.name = String(name); this.lastModified = opts.lastModified || 0; }
167
198
  };
168
199
  }
200
+
201
+ // Minimal XMLHttpRequest backed by fetch — enough that libraries that construct
202
+ // one and issue a request don't crash. No-network setups still get a clean object.
203
+ function makeXHR() {
204
+ return class XMLHttpRequest {
205
+ constructor() {
206
+ this.readyState = 0; this.status = 0; this.statusText = ''; this.response = ''; this.responseText = '';
207
+ this.responseType = ''; this.timeout = 0; this.withCredentials = false;
208
+ this.onreadystatechange = null; this.onload = null; this.onerror = null; this.onabort = null;
209
+ this.__headers = {}; this.__method = 'GET'; this.__url = ''; this.__listeners = new Map(); this.__aborted = false;
210
+ }
211
+ open(method, url) { this.__method = method; this.__url = url; this.readyState = 1; this.__fire('readystatechange'); }
212
+ setRequestHeader(k, v) { this.__headers[k] = v; }
213
+ getResponseHeader() { return null; }
214
+ getAllResponseHeaders() { return ''; }
215
+ addEventListener(t, cb) { const l = this.__listeners.get(t) || []; l.push(cb); this.__listeners.set(t, l); }
216
+ removeEventListener(t, cb) { const l = this.__listeners.get(t); if (l) this.__listeners.set(t, l.filter((x) => x !== cb)); }
217
+ __fire(type) { const ev = { type, target: this }; if (typeof this['on' + type] === 'function') this['on' + type](ev); for (const cb of this.__listeners.get(type) || []) cb(ev); }
218
+ abort() { this.__aborted = true; this.readyState = 0; this.__fire('abort'); }
219
+ send(body) {
220
+ if (!globalThis.fetch) { this.readyState = 4; this.status = 0; this.__fire('error'); this.__fire('loadend'); return; }
221
+ globalThis.fetch(this.__url, { method: this.__method, headers: this.__headers, body }).then(async (res) => {
222
+ if (this.__aborted) return;
223
+ this.status = res.status; this.statusText = res.statusText; this.responseText = await res.text(); this.response = this.responseText;
224
+ this.readyState = 4; this.__fire('readystatechange'); this.__fire('load'); this.__fire('loadend');
225
+ }).catch(() => { if (this.__aborted) return; this.readyState = 4; this.status = 0; this.__fire('error'); this.__fire('loadend'); });
226
+ }
227
+ };
228
+ }