@miaskiewicz/turbo-dom 0.1.17 → 0.1.19
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 +12 -9
- package/package.json +4 -3
- package/src/runtime/dom.mjs +69 -17
- package/src/runtime/selectors.mjs +88 -16
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm install -D @miaskiewicz/turbo-dom
|
|
|
14
14
|
|
|
15
15
|
- ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
|
|
16
16
|
Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
|
|
17
|
-
- ⚡ **Faster than jsdom** — ~
|
|
17
|
+
- ⚡ **Faster than jsdom** — ~23× lower per-file setup, ~6× on query-heavy DOM work, 18–37× faster HTML parsing.
|
|
18
18
|
- 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and
|
|
19
19
|
`getComputedStyle` reflects only what you set. Geometry tests belong in a real browser.
|
|
20
20
|
|
|
@@ -102,16 +102,19 @@ adopted yet.
|
|
|
102
102
|
|
|
103
103
|
Measured on darwin-arm64, Node 24 (`npm run bench:all`):
|
|
104
104
|
|
|
105
|
-
| benchmark | turbo-dom | happy-dom | jsdom |
|
|
106
|
-
|
|
107
|
-
| per-file setup + 1 query (ops/s) | **
|
|
108
|
-
| full suite, 200 files (ms/file) | **0.13** | 1.
|
|
109
|
-
|
|
|
110
|
-
| parse
|
|
105
|
+
| benchmark | turbo-dom | happy-dom | jsdom | vs jsdom |
|
|
106
|
+
|---|---:|---:|---:|---:|
|
|
107
|
+
| per-file setup + 1 query (ops/s) | **5,950** | 611 | 260 | **22.9×** |
|
|
108
|
+
| full suite, 200 files (ms/file) | **0.13** | 1.50 | 3.38 | **23.6×** |
|
|
109
|
+
| query-heavy DOM work (iters/s) | **18,125** | — | 3,089 | **5.9×** |
|
|
110
|
+
| parse 56 KB SSR (ops/s) | **478** | 43 | 26 | **18×** |
|
|
111
|
+
| parse 20 KB real page (ops/s) | **4,203** | 190 | 114 | **37×** |
|
|
111
112
|
|
|
112
113
|
Why it's fast: parsing is native; the JS DOM doesn't allocate node objects for parts of the
|
|
113
|
-
tree a test never reads;
|
|
114
|
-
matchMedia…) a render-only test never touches
|
|
114
|
+
tree a test never reads; `window` doesn't build the ~12 globals (storage, observers,
|
|
115
|
+
matchMedia…) a render-only test never touches; and the selector/match engine is allocation-free
|
|
116
|
+
on the hot paths (no per-element `classList`/`split`/regex), so `querySelectorAll` and the
|
|
117
|
+
`getElementsBy*` collections that RTL leans on stay cheap.
|
|
115
118
|
|
|
116
119
|
## How it works
|
|
117
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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",
|
|
@@ -64,10 +64,11 @@
|
|
|
64
64
|
"bench:construct": "node bench/construct.mjs",
|
|
65
65
|
"bench:suite": "node bench/suite.mjs",
|
|
66
66
|
"bench:wasm": "node bench/wasm.mjs",
|
|
67
|
-
"bench:all": "node bench/parse.mjs && node bench/construct.mjs && node bench/suite.mjs && node bench/wasm.mjs",
|
|
67
|
+
"bench:all": "node bench/parse.mjs && node bench/construct.mjs && node bench/suite.mjs && node bench/query.mjs && node bench/wasm.mjs",
|
|
68
68
|
"prepublishOnly": "napi build --platform --release",
|
|
69
69
|
"test:vitest": "vitest run -c test-vitest/vitest.config.mjs",
|
|
70
|
-
"test:jest": "jest -c test-jest/jest.config.cjs"
|
|
70
|
+
"test:jest": "jest -c test-jest/jest.config.cjs",
|
|
71
|
+
"bench:query": "node bench/query.mjs"
|
|
71
72
|
},
|
|
72
73
|
"keywords": [
|
|
73
74
|
"dom",
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export class Node extends EventTarget {
|
|
|
158
158
|
else if (kids[i].nodeType === ELEMENT_NODE) kids[i].normalize();
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
|
|
161
|
+
replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(typeof n === 'string' ? this.ownerDocument.createTextNode(n) : n); }
|
|
162
162
|
// bitmask: 1 DISCONNECTED, 2 PRECEDING, 4 FOLLOWING, 8 CONTAINS, 16 CONTAINED_BY
|
|
163
163
|
compareDocumentPosition(other) {
|
|
164
164
|
if (other === this) return 0;
|
|
@@ -189,9 +189,15 @@ export class Node extends EventTarget {
|
|
|
189
189
|
}
|
|
190
190
|
set textContent(value) {
|
|
191
191
|
this.__kids = [];
|
|
192
|
+
this.__touch();
|
|
192
193
|
if (value !== '') this.appendChild(this.ownerDocument.createTextNode(String(value)));
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
// bump the document version so getElementsBy* caches invalidate after direct
|
|
197
|
+
// __kids reassignments (innerHTML/textContent/replaceChildren) that bypass
|
|
198
|
+
// insertBefore/removeChild.
|
|
199
|
+
__touch() { const d = this.ownerDocument; if (d) d.__version = (d.__version || 0) + 1; }
|
|
200
|
+
|
|
195
201
|
// window/document path for event propagation past the document
|
|
196
202
|
get __owner() { return null; }
|
|
197
203
|
}
|
|
@@ -300,8 +306,8 @@ export class Element extends Node {
|
|
|
300
306
|
get namespaceURI() { return nsUri(this.__ns); }
|
|
301
307
|
|
|
302
308
|
// ---- attributes ----
|
|
303
|
-
getAttribute(name) { const
|
|
304
|
-
hasAttribute(name) {
|
|
309
|
+
getAttribute(name) { const at = this.__attrs; for (let i = 0; i < at.length; i++) if (at[i].name === name) return at[i].value; return null; }
|
|
310
|
+
hasAttribute(name) { const at = this.__attrs; for (let i = 0; i < at.length; i++) if (at[i].name === name) return true; return false; }
|
|
305
311
|
getAttributeNames() { return this.__attrs.map((a) => a.name); }
|
|
306
312
|
setAttribute(name, value) {
|
|
307
313
|
const a = this.__attrs.find((x) => x.name === name);
|
|
@@ -505,7 +511,7 @@ export class Element extends Node {
|
|
|
505
511
|
before(...nodes) { const p = this.parentNode; if (!p) return; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), this); }
|
|
506
512
|
after(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
|
|
507
513
|
replaceWith(...nodes) { const p = this.parentNode; if (!p) return; const ref = this.nextSibling; this.remove(); for (const n of nodes) p.insertBefore(toNode(this.ownerDocument, n), ref); }
|
|
508
|
-
replaceChildren(...nodes) { this.__kids = []; for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
|
|
514
|
+
replaceChildren(...nodes) { this.__kids = []; this.__touch(); for (const n of nodes) this.appendChild(toNode(this.ownerDocument, n)); }
|
|
509
515
|
|
|
510
516
|
// ---- queries ----
|
|
511
517
|
matches(sel) { return matchesSelector(this, sel); }
|
|
@@ -520,6 +526,7 @@ export class Element extends Node {
|
|
|
520
526
|
set innerHTML(html) {
|
|
521
527
|
const frag = native.parseFragment(String(html), this.__ns ? `${this.__ns} ${this.localName}` : this.localName);
|
|
522
528
|
this.__kids = [];
|
|
529
|
+
this.__touch();
|
|
523
530
|
for (const rawChild of frag.children) {
|
|
524
531
|
if (rawChild.nodeType === DOCUMENT_FRAGMENT_NODE && rawChild.name === 'content') continue;
|
|
525
532
|
const child = this.ownerDocument.__inflateNested(rawChild);
|
|
@@ -726,25 +733,43 @@ function toNode(doc, n) { return typeof n === 'string' ? doc.createTextNode(n) :
|
|
|
726
733
|
|
|
727
734
|
function collectByTag(root, tag) {
|
|
728
735
|
const out = [];
|
|
736
|
+
const all = tag === '*';
|
|
729
737
|
const visit = (node) => {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
738
|
+
const kids = node.__children();
|
|
739
|
+
for (let i = 0; i < kids.length; i++) {
|
|
740
|
+
const c = kids[i];
|
|
741
|
+
if (c.nodeType !== ELEMENT_NODE) continue;
|
|
742
|
+
if (all || c.localName === tag) out.push(c);
|
|
743
|
+
visit(c);
|
|
735
744
|
}
|
|
736
745
|
};
|
|
737
746
|
visit(root);
|
|
738
747
|
return out;
|
|
739
748
|
}
|
|
749
|
+
// allocation-free whole-word class membership (no ClassList, no split)
|
|
750
|
+
function elHasClass(el, cls) {
|
|
751
|
+
const cn = el.getAttribute('class');
|
|
752
|
+
if (!cn) return false;
|
|
753
|
+
if (cn === cls) return true;
|
|
754
|
+
const L = cls.length;
|
|
755
|
+
let idx = cn.indexOf(cls);
|
|
756
|
+
while (idx !== -1) {
|
|
757
|
+
if ((idx === 0 || cn.charCodeAt(idx - 1) <= 32) && (idx + L === cn.length || cn.charCodeAt(idx + L) <= 32)) return true;
|
|
758
|
+
idx = cn.indexOf(cls, idx + 1);
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
740
762
|
function collectByClass(root, classes) {
|
|
741
763
|
const out = [];
|
|
742
764
|
const visit = (node) => {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
765
|
+
const kids = node.__children();
|
|
766
|
+
for (let i = 0; i < kids.length; i++) {
|
|
767
|
+
const c = kids[i];
|
|
768
|
+
if (c.nodeType !== ELEMENT_NODE) continue;
|
|
769
|
+
let ok = true;
|
|
770
|
+
for (let j = 0; j < classes.length; j++) if (!elHasClass(c, classes[j])) { ok = false; break; }
|
|
771
|
+
if (ok) out.push(c);
|
|
772
|
+
visit(c);
|
|
748
773
|
}
|
|
749
774
|
};
|
|
750
775
|
visit(root);
|
|
@@ -938,7 +963,11 @@ function isDescendant(node, ancestor) {
|
|
|
938
963
|
}
|
|
939
964
|
function notifyMutation(target, record) {
|
|
940
965
|
const doc = target.ownerDocument;
|
|
941
|
-
if (!doc
|
|
966
|
+
if (!doc) return;
|
|
967
|
+
// bump the DOM version on every structural/attribute mutation — invalidates
|
|
968
|
+
// the document's getElementsBy* caches. Unconditional (independent of observers).
|
|
969
|
+
doc.__version = (doc.__version || 0) + 1;
|
|
970
|
+
if (!doc.__mo || doc.__mo.length === 0) return;
|
|
942
971
|
for (const reg of doc.__mo) {
|
|
943
972
|
const { obs, target: obsTarget, options } = reg;
|
|
944
973
|
const onTarget = record.target === obsTarget;
|
|
@@ -1091,6 +1120,9 @@ export class Document extends Node {
|
|
|
1091
1120
|
this.__active = null;
|
|
1092
1121
|
this.__mo = []; // drop observers
|
|
1093
1122
|
this.__moPending = null;
|
|
1123
|
+
this.__version = (this.__version || 0) + 1; // invalidate getElementsBy* caches
|
|
1124
|
+
this.__tagCache = null;
|
|
1125
|
+
this.__classCache = null;
|
|
1094
1126
|
}
|
|
1095
1127
|
|
|
1096
1128
|
get documentElement() { return this.__children().find((n) => n.nodeType === ELEMENT_NODE && n.localName === 'html') ?? null; }
|
|
@@ -1173,8 +1205,28 @@ export class Document extends Node {
|
|
|
1173
1205
|
}
|
|
1174
1206
|
querySelector(sel) { return qsel(this, sel); }
|
|
1175
1207
|
querySelectorAll(sel) { return qselAll(this, sel); }
|
|
1176
|
-
|
|
1177
|
-
|
|
1208
|
+
// version-keyed cache: getElementsBy* is called repeatedly within a single
|
|
1209
|
+
// query (e.g. RTL getByLabelText calls element.labels per element, each doing
|
|
1210
|
+
// document.getElementsByTagName('label')). Without caching that's O(n²) tree
|
|
1211
|
+
// walks. The cache invalidates whenever the DOM version bumps (any mutation).
|
|
1212
|
+
__byTag(t) {
|
|
1213
|
+
const v = this.__version || 0;
|
|
1214
|
+
const c = this.__tagCache && this.__tagCache.get(t);
|
|
1215
|
+
if (c && c.v === v) return c.arr;
|
|
1216
|
+
const arr = collectByTag(this, t);
|
|
1217
|
+
(this.__tagCache || (this.__tagCache = new Map())).set(t, { v, arr });
|
|
1218
|
+
return arr;
|
|
1219
|
+
}
|
|
1220
|
+
__byClass(key, classes) {
|
|
1221
|
+
const v = this.__version || 0;
|
|
1222
|
+
const c = this.__classCache && this.__classCache.get(key);
|
|
1223
|
+
if (c && c.v === v) return c.arr;
|
|
1224
|
+
const arr = collectByClass(this, classes);
|
|
1225
|
+
(this.__classCache || (this.__classCache = new Map())).set(key, { v, arr });
|
|
1226
|
+
return arr;
|
|
1227
|
+
}
|
|
1228
|
+
getElementsByTagName(tag) { const self = this; const t = tag.toLowerCase(); return liveHTMLCollection(() => self.__byTag(t)); }
|
|
1229
|
+
getElementsByClassName(cls) { const self = this; const classes = cls.split(/\s+/).filter(Boolean); return liveHTMLCollection(() => self.__byClass(cls, classes)); }
|
|
1178
1230
|
contains(node) { return Node.prototype.contains.call(this, node); }
|
|
1179
1231
|
|
|
1180
1232
|
// cookie jar: store name=value, strip attributes (path/Secure/SameSite/…),
|
|
@@ -83,8 +83,17 @@ function parseComplex(src) {
|
|
|
83
83
|
return { compounds, combinators };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// parsed-selector cache: querySelector(All)/matches re-run the same selector
|
|
87
|
+
// strings constantly; parsing once and reusing is a large win on query-heavy
|
|
88
|
+
// suites. Bounded so a pathological generator can't grow it without limit.
|
|
89
|
+
const __selectorCache = new Map();
|
|
86
90
|
export function parseSelectorList(selector) {
|
|
87
|
-
|
|
91
|
+
const hit = __selectorCache.get(selector);
|
|
92
|
+
if (hit !== undefined) return hit;
|
|
93
|
+
const parsed = splitTopLevel(selector, ',').map((s) => parseComplex(s.trim()));
|
|
94
|
+
if (__selectorCache.size > 10000) __selectorCache.clear();
|
|
95
|
+
__selectorCache.set(selector, parsed);
|
|
96
|
+
return parsed;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
function splitTopLevel(s, sep) {
|
|
@@ -106,9 +115,10 @@ function elementChildren(node) {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
function matchAttr(el, a) {
|
|
109
|
-
|
|
118
|
+
const raw = el.getAttribute(a.name); // single lookup (null = absent)
|
|
119
|
+
if (raw === null) return false;
|
|
110
120
|
if (a.op === null) return true;
|
|
111
|
-
const v =
|
|
121
|
+
const v = raw;
|
|
112
122
|
const t = a.value ?? '';
|
|
113
123
|
switch (a.op) {
|
|
114
124
|
case '=': return v === t;
|
|
@@ -211,11 +221,32 @@ function nextElement(el) {
|
|
|
211
221
|
return n || null;
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
// allocation-free "does the class attribute contain this whole-word class"
|
|
225
|
+
function hasClass(cn, cls) {
|
|
226
|
+
if (cn === cls) return true;
|
|
227
|
+
const L = cls.length;
|
|
228
|
+
let idx = cn.indexOf(cls);
|
|
229
|
+
while (idx !== -1) {
|
|
230
|
+
const before = idx === 0 || cn.charCodeAt(idx - 1) <= 32;
|
|
231
|
+
const after = idx + L === cn.length || cn.charCodeAt(idx + L) <= 32;
|
|
232
|
+
if (before && after) return true;
|
|
233
|
+
idx = cn.indexOf(cls, idx + 1);
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
214
238
|
function matchCompound(el, compound) {
|
|
215
239
|
if (!el || el.nodeType !== 1) return false;
|
|
240
|
+
// cheapest checks first; tag/local is a plain property
|
|
216
241
|
if (compound.tag && compound.tag !== '*' && el.localName !== compound.tag) return false;
|
|
217
242
|
if (compound.id !== null && el.getAttribute('id') !== compound.id) return false;
|
|
218
|
-
|
|
243
|
+
// classes: read the class attribute ONCE and test membership without
|
|
244
|
+
// allocating (no ClassList, no split, no padded copy) — dominated matching.
|
|
245
|
+
if (compound.classes.length) {
|
|
246
|
+
const cn = el.getAttribute('class');
|
|
247
|
+
if (!cn) return false;
|
|
248
|
+
for (const cls of compound.classes) if (!hasClass(cn, cls)) return false;
|
|
249
|
+
}
|
|
219
250
|
for (const a of compound.attrs) if (!matchAttr(el, a)) return false;
|
|
220
251
|
for (const p of compound.pseudos) if (!matchPseudo(el, p)) return false;
|
|
221
252
|
return true;
|
|
@@ -268,13 +299,43 @@ export function matchesSelector(el, selector) {
|
|
|
268
299
|
return list.some((cx) => matchComplex(el, cx));
|
|
269
300
|
}
|
|
270
301
|
|
|
302
|
+
// Fast paths for the overwhelmingly common simple selectors, skipping the
|
|
303
|
+
// parse + per-element matchComplex machinery.
|
|
304
|
+
const SIMPLE = /^\s*(#[\w-]+|\.[\w-]+|[a-zA-Z][\w-]*)\s*$/;
|
|
305
|
+
function simpleMatcher(selector) {
|
|
306
|
+
const m = SIMPLE.exec(selector);
|
|
307
|
+
if (!m) return null;
|
|
308
|
+
const s = m[1];
|
|
309
|
+
if (s[0] === '#') { const id = s.slice(1); return (el) => el.getAttribute('id') === id; }
|
|
310
|
+
if (s[0] === '.') { const cls = s.slice(1); return (el) => el.classList.contains(cls); }
|
|
311
|
+
const tag = s.toLowerCase(); return (el) => el.localName === tag;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// child node array without allocating a filtered copy per call
|
|
315
|
+
function rawChildren(node) {
|
|
316
|
+
return typeof node.__children === 'function' ? node.__children() : Array.from(node.childNodes || []);
|
|
317
|
+
}
|
|
318
|
+
|
|
271
319
|
export function querySelectorAll(root, selector) {
|
|
272
|
-
const
|
|
320
|
+
const simple = simpleMatcher(selector);
|
|
273
321
|
const out = [];
|
|
322
|
+
if (simple) {
|
|
323
|
+
const visit = (node) => {
|
|
324
|
+
const kids = rawChildren(node);
|
|
325
|
+
for (let i = 0; i < kids.length; i++) { const c = kids[i]; if (c.nodeType !== 1) continue; if (simple(c)) out.push(c); visit(c); }
|
|
326
|
+
};
|
|
327
|
+
visit(root);
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
const list = parseSelectorList(selector);
|
|
331
|
+
const single = list.length === 1 ? list[0] : null;
|
|
274
332
|
const visit = (node) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
333
|
+
const kids = rawChildren(node);
|
|
334
|
+
for (let i = 0; i < kids.length; i++) {
|
|
335
|
+
const c = kids[i];
|
|
336
|
+
if (c.nodeType !== 1) continue;
|
|
337
|
+
if (single ? matchComplex(c, single) : list.some((cx) => matchComplex(c, cx))) out.push(c);
|
|
338
|
+
visit(c);
|
|
278
339
|
}
|
|
279
340
|
};
|
|
280
341
|
visit(root);
|
|
@@ -282,18 +343,29 @@ export function querySelectorAll(root, selector) {
|
|
|
282
343
|
}
|
|
283
344
|
|
|
284
345
|
export function querySelector(root, selector) {
|
|
346
|
+
const simple = simpleMatcher(selector);
|
|
347
|
+
if (simple) {
|
|
348
|
+
const visit = (node) => {
|
|
349
|
+
const kids = rawChildren(node);
|
|
350
|
+
for (let i = 0; i < kids.length; i++) { const c = kids[i]; if (c.nodeType !== 1) continue; if (simple(c)) return c; const r = visit(c); if (r) return r; }
|
|
351
|
+
return null;
|
|
352
|
+
};
|
|
353
|
+
return visit(root);
|
|
354
|
+
}
|
|
285
355
|
const list = parseSelectorList(selector);
|
|
286
|
-
|
|
356
|
+
const single = list.length === 1 ? list[0] : null;
|
|
287
357
|
const visit = (node) => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
358
|
+
const kids = rawChildren(node);
|
|
359
|
+
for (let i = 0; i < kids.length; i++) {
|
|
360
|
+
const c = kids[i];
|
|
361
|
+
if (c.nodeType !== 1) continue;
|
|
362
|
+
if (single ? matchComplex(c, single) : list.some((cx) => matchComplex(c, cx))) return c;
|
|
363
|
+
const r = visit(c);
|
|
364
|
+
if (r) return r;
|
|
293
365
|
}
|
|
366
|
+
return null;
|
|
294
367
|
};
|
|
295
|
-
visit(root);
|
|
296
|
-
return found;
|
|
368
|
+
return visit(root);
|
|
297
369
|
}
|
|
298
370
|
|
|
299
371
|
export const _internal = { parseComplex, parseSelectorList, matchCompound, matchComplex };
|
|
Binary file
|