@miaskiewicz/turbo-dom 0.1.2 → 0.1.4
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/README.md +7 -1
- package/package.json +5 -3
- package/src/environment/install.mjs +37 -10
- package/src/environment/jest.cjs +1 -1
- package/src/environment/vitest.mjs +16 -11
- package/src/runtime/dom.mjs +207 -0
- package/src/runtime/window.mjs +64 -4
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/README.md
CHANGED
|
@@ -25,14 +25,20 @@ npm install -D @miaskiewicz/turbo-dom
|
|
|
25
25
|
```ts
|
|
26
26
|
// vitest.config.ts
|
|
27
27
|
import { defineConfig } from 'vitest/config';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
|
|
30
|
+
const envPath = createRequire(import.meta.url).resolve('@miaskiewicz/turbo-dom/environment/vitest');
|
|
28
31
|
|
|
29
32
|
export default defineConfig({
|
|
30
33
|
test: {
|
|
31
|
-
environment:
|
|
34
|
+
environment: envPath, // vitest resolves a bare name only for `vitest-environment-*`
|
|
35
|
+
// packages, so a scoped package is referenced by file path
|
|
32
36
|
},
|
|
33
37
|
});
|
|
34
38
|
```
|
|
35
39
|
|
|
40
|
+
Works on vitest 1–4.
|
|
41
|
+
|
|
36
42
|
### jest
|
|
37
43
|
|
|
38
44
|
```js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
"bench:suite": "node bench/suite.mjs",
|
|
65
65
|
"bench:wasm": "node bench/wasm.mjs",
|
|
66
66
|
"bench:all": "node bench/parse.mjs && node bench/construct.mjs && node bench/suite.mjs && node bench/wasm.mjs",
|
|
67
|
-
"prepublishOnly": "napi build --platform --release"
|
|
67
|
+
"prepublishOnly": "napi build --platform --release",
|
|
68
|
+
"test:vitest": "vitest run -c test-vitest/vitest.config.mjs"
|
|
68
69
|
},
|
|
69
70
|
"keywords": [
|
|
70
71
|
"dom",
|
|
@@ -116,6 +117,7 @@
|
|
|
116
117
|
"jsdom": "^29.1.1",
|
|
117
118
|
"parse5": "^8.0.1",
|
|
118
119
|
"react": "^19.2.7",
|
|
119
|
-
"react-dom": "^19.2.7"
|
|
120
|
+
"react-dom": "^19.2.7",
|
|
121
|
+
"vitest": "^4.1.8"
|
|
120
122
|
}
|
|
121
123
|
}
|
|
@@ -6,29 +6,56 @@ import { createEnvironment } from '../runtime/index.mjs';
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_HTML = '<!doctype html><html><head></head><body></body></html>';
|
|
8
8
|
|
|
9
|
-
// Globals that point at the window itself.
|
|
10
|
-
|
|
9
|
+
// Globals that point at the window itself. Deliberately NOT `globalThis`/`global`
|
|
10
|
+
// — redefining those breaks test runners (vitest builds its module-runner vm
|
|
11
|
+
// primitives off globalThis; pointing it at our window Proxy hides Symbol et al).
|
|
12
|
+
const SELF_KEYS = ['window', 'self', 'parent', 'top', 'frames'];
|
|
11
13
|
|
|
12
14
|
export function installGlobals(target, { html = DEFAULT_HTML, url } = {}) {
|
|
13
15
|
const env = createEnvironment(html, url ? { url } : {});
|
|
14
16
|
const { window } = env;
|
|
15
17
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
+
const installed = []; // keys we defined
|
|
19
|
+
const originals = new Map(); // prior descriptors to restore on teardown
|
|
20
|
+
|
|
21
|
+
const define = (name, descriptor) => {
|
|
22
|
+
const prior = Object.getOwnPropertyDescriptor(target, name);
|
|
23
|
+
if (prior) originals.set(name, prior);
|
|
24
|
+
Object.defineProperty(target, name, descriptor);
|
|
25
|
+
installed.push(name);
|
|
18
26
|
};
|
|
19
27
|
|
|
20
28
|
// window self-references
|
|
21
|
-
for (const k of SELF_KEYS)
|
|
29
|
+
for (const k of SELF_KEYS) {
|
|
30
|
+
define(k, {
|
|
31
|
+
configurable: true,
|
|
32
|
+
get: () => window,
|
|
33
|
+
set(v) { Object.defineProperty(target, k, { configurable: true, writable: true, value: v }); },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
22
36
|
// document is eager + universal
|
|
23
|
-
|
|
37
|
+
define('document', { configurable: true, writable: true, enumerable: true, value: env.document });
|
|
24
38
|
|
|
25
39
|
// every other window global → lazy getter (materializes + traces on first read)
|
|
26
40
|
for (const name of env.globalKeys) {
|
|
27
41
|
if (name === 'document' || SELF_KEYS.includes(name)) continue;
|
|
28
|
-
define(name,
|
|
42
|
+
define(name, {
|
|
43
|
+
configurable: true,
|
|
44
|
+
get: () => window[name],
|
|
45
|
+
set(v) { Object.defineProperty(target, name, { configurable: true, writable: true, value: v }); },
|
|
46
|
+
});
|
|
29
47
|
}
|
|
30
48
|
|
|
31
|
-
// handy escape
|
|
32
|
-
|
|
33
|
-
|
|
49
|
+
// handy escape hatch
|
|
50
|
+
define('__turboDom', { configurable: true, writable: true, value: env });
|
|
51
|
+
|
|
52
|
+
const teardown = () => {
|
|
53
|
+
for (const name of installed) {
|
|
54
|
+
delete target[name];
|
|
55
|
+
const prior = originals.get(name);
|
|
56
|
+
if (prior) Object.defineProperty(target, name, prior);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return { env, window, teardown };
|
|
34
61
|
}
|
package/src/environment/jest.cjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// or point directly at this file:
|
|
7
7
|
//
|
|
8
|
-
// testEnvironment: './node_modules/@miaskiewicz/turbo-dom/
|
|
8
|
+
// testEnvironment: './node_modules/@miaskiewicz/turbo-dom/src/environment/jest.cjs'
|
|
9
9
|
//
|
|
10
10
|
// Per-file / project options:
|
|
11
11
|
// testEnvironmentOptions: { html: '<!doctype html>...', url: 'http://localhost/' }
|
|
@@ -1,31 +1,36 @@
|
|
|
1
|
-
// Vitest environment adapter. Use in vitest config:
|
|
1
|
+
// Vitest environment adapter (vitest 1–4). Use in vitest config:
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// export default defineConfig({ test: { environment:
|
|
3
|
+
// import TurboDom from '@miaskiewicz/turbo-dom/environment/vitest';
|
|
4
|
+
// export default defineConfig({ test: { environment: TurboDom } });
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// or point directly at this file:
|
|
6
|
+
// or point at the file path:
|
|
8
7
|
//
|
|
9
|
-
// test: { environment: './node_modules/@miaskiewicz/turbo-dom/
|
|
8
|
+
// test: { environment: './node_modules/@miaskiewicz/turbo-dom/src/environment/vitest.mjs' }
|
|
9
|
+
//
|
|
10
|
+
// (vitest's bare-name `environment: 'name'` only works for a package literally
|
|
11
|
+
// named `vitest-environment-<name>`; use the object or path form for a scoped pkg.)
|
|
10
12
|
//
|
|
11
13
|
// Per-file options via environmentOptions:
|
|
12
14
|
// test: { environmentOptions: { turboDom: { html: '<!doctype html>...', url: 'http://localhost/' } } }
|
|
13
15
|
|
|
14
16
|
import { installGlobals } from './install.mjs';
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
const environment = {
|
|
17
19
|
name: 'turbo-dom',
|
|
18
|
-
//
|
|
20
|
+
// vitest 3/4 read `viteEnvironment`; older versions read `transformMode`.
|
|
21
|
+
viteEnvironment: 'client',
|
|
19
22
|
transformMode: 'web',
|
|
20
23
|
|
|
21
24
|
setup(global, options) {
|
|
22
25
|
const opts = (options && options.turboDom) || {};
|
|
23
|
-
const env = installGlobals(global, opts);
|
|
26
|
+
const { env, teardown } = installGlobals(global, opts);
|
|
24
27
|
return {
|
|
25
|
-
teardown() {
|
|
26
|
-
|
|
28
|
+
teardown(g) {
|
|
29
|
+
teardown();
|
|
27
30
|
env.reset();
|
|
28
31
|
},
|
|
29
32
|
};
|
|
30
33
|
},
|
|
31
34
|
};
|
|
35
|
+
|
|
36
|
+
export default environment;
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -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()) {
|
|
@@ -150,6 +193,18 @@ export class Node extends EventTarget {
|
|
|
150
193
|
get __owner() { return null; }
|
|
151
194
|
}
|
|
152
195
|
|
|
196
|
+
// nodeType + document-position constants on the prototype, so `node.TEXT_NODE`
|
|
197
|
+
// works on instances (dom-accessibility-api and others read them off the node).
|
|
198
|
+
Object.assign(Node.prototype, {
|
|
199
|
+
ELEMENT_NODE: 1, ATTRIBUTE_NODE: 2, TEXT_NODE: 3, CDATA_SECTION_NODE: 4,
|
|
200
|
+
ENTITY_REFERENCE_NODE: 5, ENTITY_NODE: 6, PROCESSING_INSTRUCTION_NODE: 7,
|
|
201
|
+
COMMENT_NODE: 8, DOCUMENT_NODE: 9, DOCUMENT_TYPE_NODE: 10,
|
|
202
|
+
DOCUMENT_FRAGMENT_NODE: 11, NOTATION_NODE: 12,
|
|
203
|
+
DOCUMENT_POSITION_DISCONNECTED: 1, DOCUMENT_POSITION_PRECEDING: 2,
|
|
204
|
+
DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINS: 8,
|
|
205
|
+
DOCUMENT_POSITION_CONTAINED_BY: 16, DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32,
|
|
206
|
+
});
|
|
207
|
+
|
|
153
208
|
// ----------------------------------------------------- CharacterData ----
|
|
154
209
|
class CharacterData extends Node {
|
|
155
210
|
constructor(ownerDocument, data) { super(ownerDocument); this._data = data ?? ''; }
|
|
@@ -160,11 +215,33 @@ class CharacterData extends Node {
|
|
|
160
215
|
get length() { return this._data.length; }
|
|
161
216
|
get textContent() { return this._data; }
|
|
162
217
|
set textContent(v) { this.data = v; }
|
|
218
|
+
substringData(offset, count) { return this._data.slice(offset, offset + count); }
|
|
219
|
+
appendData(s) { this.data = this._data + s; }
|
|
220
|
+
insertData(offset, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset); }
|
|
221
|
+
deleteData(offset, count) { this.data = this._data.slice(0, offset) + this._data.slice(offset + count); }
|
|
222
|
+
replaceData(offset, count, s) { this.data = this._data.slice(0, offset) + s + this._data.slice(offset + count); }
|
|
223
|
+
before(...nodes) { const p = this.parentNode; if (p) for (const n of nodes) p.insertBefore(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n, this); }
|
|
224
|
+
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); }
|
|
225
|
+
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
226
|
}
|
|
164
227
|
|
|
165
228
|
export class Text extends CharacterData {
|
|
166
229
|
get nodeType() { return TEXT_NODE; }
|
|
167
230
|
get nodeName() { return '#text'; }
|
|
231
|
+
get wholeText() {
|
|
232
|
+
let s = this._data, n = this.previousSibling;
|
|
233
|
+
while (n && n.nodeType === TEXT_NODE) { s = n.data + s; n = n.previousSibling; }
|
|
234
|
+
n = this.nextSibling;
|
|
235
|
+
while (n && n.nodeType === TEXT_NODE) { s += n.data; n = n.nextSibling; }
|
|
236
|
+
return s;
|
|
237
|
+
}
|
|
238
|
+
splitText(offset) {
|
|
239
|
+
const rest = this._data.slice(offset);
|
|
240
|
+
this.data = this._data.slice(0, offset);
|
|
241
|
+
const node = new Text(this.ownerDocument, rest);
|
|
242
|
+
if (this.parentNode) this.parentNode.insertBefore(node, this.nextSibling);
|
|
243
|
+
return node;
|
|
244
|
+
}
|
|
168
245
|
cloneNode() { return new Text(this.ownerDocument, this._data); }
|
|
169
246
|
}
|
|
170
247
|
|
|
@@ -462,6 +539,54 @@ export class Element extends Node {
|
|
|
462
539
|
getBoundingClientRect() { return zeroRect(); }
|
|
463
540
|
getClientRects() { return []; }
|
|
464
541
|
scrollIntoView() {}
|
|
542
|
+
scroll() {} scrollTo() {} scrollBy() {}
|
|
543
|
+
// honest zero geometry (no layout)
|
|
544
|
+
get offsetWidth() { return 0; } get offsetHeight() { return 0; }
|
|
545
|
+
get offsetTop() { return 0; } get offsetLeft() { return 0; }
|
|
546
|
+
get offsetParent() { return null; }
|
|
547
|
+
get clientWidth() { return 0; } get clientHeight() { return 0; }
|
|
548
|
+
get clientTop() { return 0; } get clientLeft() { return 0; }
|
|
549
|
+
get scrollWidth() { return 0; } get scrollHeight() { return 0; }
|
|
550
|
+
get scrollTop() { return 0; } set scrollTop(_v) {} get scrollLeft() { return 0; } set scrollLeft(_v) {}
|
|
551
|
+
|
|
552
|
+
// namespaced attributes
|
|
553
|
+
getAttributeNS(_ns, name) { return this.getAttribute(name); }
|
|
554
|
+
setAttributeNS(_ns, name, value) { this.setAttribute(name, value); }
|
|
555
|
+
hasAttributeNS(_ns, name) { return this.hasAttribute(name); }
|
|
556
|
+
removeAttributeNS(_ns, name) { this.removeAttribute(name); }
|
|
557
|
+
getAttributeNode(name) { const a = this.__attrs.find((x) => x.name === name); return a ? { name: a.name, value: a.value, ownerElement: this } : null; }
|
|
558
|
+
|
|
559
|
+
// adjacency
|
|
560
|
+
insertAdjacentElement(position, el) {
|
|
561
|
+
const p = this.parentNode;
|
|
562
|
+
switch (position) {
|
|
563
|
+
case 'beforebegin': if (p) p.insertBefore(el, this); break;
|
|
564
|
+
case 'afterbegin': this.insertBefore(el, this.firstChild); break;
|
|
565
|
+
case 'beforeend': this.appendChild(el); break;
|
|
566
|
+
case 'afterend': if (p) p.insertBefore(el, this.nextSibling); break;
|
|
567
|
+
}
|
|
568
|
+
return el;
|
|
569
|
+
}
|
|
570
|
+
insertAdjacentText(position, text) { this.insertAdjacentElement(position, this.ownerDocument.createTextNode(text)); }
|
|
571
|
+
|
|
572
|
+
// commonly-reflected properties
|
|
573
|
+
get tabIndex() { return this.hasAttribute('tabindex') ? parseInt(this.getAttribute('tabindex'), 10) || 0 : (/^(a|button|input|select|textarea)$/.test(this.localName) ? 0 : -1); }
|
|
574
|
+
set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
|
|
575
|
+
get title() { return this.getAttribute('title') ?? ''; } set title(v) { this.setAttribute('title', v); }
|
|
576
|
+
get lang() { return this.getAttribute('lang') ?? ''; } set lang(v) { this.setAttribute('lang', v); }
|
|
577
|
+
get dir() { return this.getAttribute('dir') ?? ''; } set dir(v) { this.setAttribute('dir', v); }
|
|
578
|
+
get hidden() { return this.hasAttribute('hidden'); } set hidden(v) { if (v) this.setAttribute('hidden', ''); else this.removeAttribute('hidden'); }
|
|
579
|
+
get role() { return this.getAttribute('role'); } set role(v) { this.setAttribute('role', v); }
|
|
580
|
+
get contentEditable() { return this.getAttribute('contenteditable') ?? 'inherit'; } set contentEditable(v) { this.setAttribute('contenteditable', v); }
|
|
581
|
+
get isContentEditable() { return this.getAttribute('contenteditable') === 'true' || this.getAttribute('contenteditable') === ''; }
|
|
582
|
+
get hreflang() { return this.getAttribute('hreflang') ?? ''; }
|
|
583
|
+
get target() { return this.getAttribute('target') ?? ''; } set target(v) { this.setAttribute('target', v); }
|
|
584
|
+
|
|
585
|
+
// pointer capture + animations (no-op honest stubs)
|
|
586
|
+
setPointerCapture() {} releasePointerCapture() {} hasPointerCapture() { return false; }
|
|
587
|
+
animate() { return { play() {}, pause() {}, cancel() {}, finish() {}, finished: Promise.resolve(), onfinish: null, cancel_: null }; }
|
|
588
|
+
getAnimations() { return []; }
|
|
589
|
+
requestFullscreen() { return Promise.resolve(); }
|
|
465
590
|
|
|
466
591
|
// canvas (no raster backend — honest no-op context)
|
|
467
592
|
getContext(type) { return this.localName === 'canvas' ? (this.__ctx ||= makeCanvasStub()) : null; }
|
|
@@ -586,6 +711,54 @@ function makeSelection() {
|
|
|
586
711
|
};
|
|
587
712
|
}
|
|
588
713
|
|
|
714
|
+
// TreeWalker / NodeIterator over the DOM (doubles for both — common subset).
|
|
715
|
+
class TreeWalker {
|
|
716
|
+
constructor(root, whatToShow, filter) {
|
|
717
|
+
this.root = root; this.whatToShow = whatToShow >>> 0; this.filter = filter; this.currentNode = root;
|
|
718
|
+
this.referenceNode = root; this.pointerBeforeReferenceNode = true;
|
|
719
|
+
}
|
|
720
|
+
__show(node) {
|
|
721
|
+
const bit = node.nodeType === ELEMENT_NODE ? 1 : node.nodeType === TEXT_NODE ? 4 : node.nodeType === COMMENT_NODE ? 128 : 0xffffffff;
|
|
722
|
+
if (!(this.whatToShow & bit) && this.whatToShow !== 0xffffffff) return 3; // FILTER_SKIP
|
|
723
|
+
if (this.filter) {
|
|
724
|
+
const fn = typeof this.filter === 'function' ? this.filter : this.filter.acceptNode;
|
|
725
|
+
return fn.call(this.filter, node);
|
|
726
|
+
}
|
|
727
|
+
return 1; // FILTER_ACCEPT
|
|
728
|
+
}
|
|
729
|
+
__flat() {
|
|
730
|
+
const out = [];
|
|
731
|
+
(function walk(n) { for (const c of (n.__children ? n.__children() : [])) { out.push(c); walk(c); } })(this.root);
|
|
732
|
+
return out.filter((n) => this.__show(n) === 1);
|
|
733
|
+
}
|
|
734
|
+
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; }
|
|
735
|
+
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; }
|
|
736
|
+
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; }
|
|
737
|
+
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; }
|
|
738
|
+
parentNode() { const p = this.currentNode.parentNode; if (p && p !== this.root.parentNode) { this.currentNode = p; return p; } return null; }
|
|
739
|
+
nextSibling() { let n = this.currentNode.nextSibling; while (n && this.__show(n) !== 1) n = n.nextSibling; if (n) this.currentNode = n; return n ?? null; }
|
|
740
|
+
previousSibling() { let n = this.currentNode.previousSibling; while (n && this.__show(n) !== 1) n = n.previousSibling; if (n) this.currentNode = n; return n ?? null; }
|
|
741
|
+
detach() {}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// DOMParser — parses a string into a real Document via the native parser.
|
|
745
|
+
export class DOMParser {
|
|
746
|
+
parseFromString(str, type = 'text/html') {
|
|
747
|
+
if (type === 'text/html') return parseDocument(String(str));
|
|
748
|
+
// XML-ish: parse as a fragment wrapped in a document
|
|
749
|
+
const doc = parseDocument(`<!doctype html><html><body>${str}</body></html>`);
|
|
750
|
+
return doc;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// XMLSerializer — serializes a node back to markup.
|
|
755
|
+
export class XMLSerializer {
|
|
756
|
+
serializeToString(node) {
|
|
757
|
+
if (node.nodeType === DOCUMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE) return serializeInner(node);
|
|
758
|
+
return serializeOuter(node);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
589
762
|
// minimal inline-style CSSOM (honest: only inline + explicitly set props)
|
|
590
763
|
function makeStyle(el) {
|
|
591
764
|
const parse = () => {
|
|
@@ -817,10 +990,44 @@ export class Document extends Node {
|
|
|
817
990
|
createDocumentFragment() { return new DocumentFragment(this); }
|
|
818
991
|
createEvent() { return new Event(''); }
|
|
819
992
|
createRange() { return new Range(this); }
|
|
993
|
+
createAttribute(name) { return { name, value: '', ownerElement: null }; }
|
|
994
|
+
createComment(data) { return new Comment(this, String(data)); }
|
|
820
995
|
getSelection() { if (!this.__selection) this.__selection = makeSelection(); return this.__selection; }
|
|
821
996
|
importNode(node, deep) { return node.cloneNode(deep); }
|
|
822
997
|
adoptNode(node) { if (node.parentNode) node.parentNode.removeChild(node); node.ownerDocument = this; return node; }
|
|
823
998
|
|
|
999
|
+
// TreeWalker / NodeIterator (whatToShow: 1 elements, 4 text, 0xFFFFFFFF all)
|
|
1000
|
+
createTreeWalker(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
|
|
1001
|
+
createNodeIterator(root, whatToShow = 0xffffffff, filter = null) { return new TreeWalker(root, whatToShow, filter); }
|
|
1002
|
+
|
|
1003
|
+
getElementsByName(name) {
|
|
1004
|
+
const self = this; return liveHTMLCollection(() => collectByTag(self, '*').filter((e) => e.getAttribute('name') === name));
|
|
1005
|
+
}
|
|
1006
|
+
elementFromPoint() { return null; }
|
|
1007
|
+
elementsFromPoint() { return []; }
|
|
1008
|
+
execCommand() { return false; }
|
|
1009
|
+
queryCommandSupported() { return false; }
|
|
1010
|
+
queryCommandEnabled() { return false; }
|
|
1011
|
+
hasFocus() { return true; }
|
|
1012
|
+
write() {} writeln() {} open() { return this; } close() {}
|
|
1013
|
+
|
|
1014
|
+
get title() { const t = this.querySelector('title'); return t ? t.textContent : (this.__title || ''); }
|
|
1015
|
+
set title(v) { const t = this.querySelector('title'); if (t) t.textContent = v; else this.__title = String(v); }
|
|
1016
|
+
get location() { return this.defaultView ? this.defaultView.location : null; }
|
|
1017
|
+
get baseURI() { return (this.defaultView && this.defaultView.location && this.defaultView.location.href) || 'about:blank'; }
|
|
1018
|
+
get URL() { return this.baseURI; }
|
|
1019
|
+
get documentURI() { return this.baseURI; }
|
|
1020
|
+
get scrollingElement() { return this.documentElement; }
|
|
1021
|
+
get fullscreenElement() { return null; }
|
|
1022
|
+
get implementation() {
|
|
1023
|
+
const self = this;
|
|
1024
|
+
return {
|
|
1025
|
+
createHTMLDocument(title) { const d = parseDocument(`<!doctype html><html><head><title>${title || ''}</title></head><body></body></html>`); return d; },
|
|
1026
|
+
createDocumentType: (name, pub, sys) => new DocumentType(self, name, pub, sys),
|
|
1027
|
+
hasFeature: () => true,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
824
1031
|
// ---- queries ----
|
|
825
1032
|
getElementById(id) {
|
|
826
1033
|
let found = null;
|
package/src/runtime/window.mjs
CHANGED
|
@@ -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: () => ({
|
|
108
|
-
|
|
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
|
+
}
|
|
Binary file
|