@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/Cargo.lock +588 -0
- package/Cargo.toml +34 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/build.rs +4 -0
- package/index.d.ts +62 -0
- package/index.js +318 -0
- package/package.json +121 -0
- package/src/core.rs +492 -0
- package/src/environment/install.mjs +34 -0
- package/src/environment/jest.cjs +40 -0
- package/src/environment/vitest.mjs +31 -0
- package/src/lib.rs +161 -0
- package/src/runtime/buffer.mjs +35 -0
- package/src/runtime/collections.mjs +50 -0
- package/src/runtime/dom.mjs +863 -0
- package/src/runtime/events.mjs +213 -0
- package/src/runtime/html-serialize.mjs +72 -0
- package/src/runtime/index.mjs +46 -0
- package/src/runtime/selectors.mjs +239 -0
- package/src/runtime/stubs.mjs +148 -0
- package/src/runtime/window.mjs +168 -0
- package/turbo-dom-parser.darwin-arm64.node +0 -0
- package/turbo-dom-parser.linux-arm64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-musl.node +0 -0
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Event system — implemented fully (it's small and load-bearing; laziness saves
|
|
2
|
+
// nothing here). Capture + target + bubble phases, composedPath, stop/prevent.
|
|
3
|
+
|
|
4
|
+
const PHASE_NONE = 0;
|
|
5
|
+
const PHASE_CAPTURING = 1;
|
|
6
|
+
const PHASE_AT_TARGET = 2;
|
|
7
|
+
const PHASE_BUBBLING = 3;
|
|
8
|
+
|
|
9
|
+
export class Event {
|
|
10
|
+
static NONE = PHASE_NONE;
|
|
11
|
+
static CAPTURING_PHASE = PHASE_CAPTURING;
|
|
12
|
+
static AT_TARGET = PHASE_AT_TARGET;
|
|
13
|
+
static BUBBLING_PHASE = PHASE_BUBBLING;
|
|
14
|
+
|
|
15
|
+
constructor(type, init = {}) {
|
|
16
|
+
this.type = type;
|
|
17
|
+
this.bubbles = !!init.bubbles;
|
|
18
|
+
this.cancelable = !!init.cancelable;
|
|
19
|
+
this.composed = !!init.composed;
|
|
20
|
+
this.target = null;
|
|
21
|
+
this.currentTarget = null;
|
|
22
|
+
this.eventPhase = PHASE_NONE;
|
|
23
|
+
this.defaultPrevented = false;
|
|
24
|
+
this.isTrusted = false;
|
|
25
|
+
this.timeStamp = 0;
|
|
26
|
+
this._stopPropagation = false;
|
|
27
|
+
this._stopImmediate = false;
|
|
28
|
+
this._path = [];
|
|
29
|
+
this._passiveListener = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get NONE() { return PHASE_NONE; }
|
|
33
|
+
get CAPTURING_PHASE() { return PHASE_CAPTURING; }
|
|
34
|
+
get AT_TARGET() { return PHASE_AT_TARGET; }
|
|
35
|
+
get BUBBLING_PHASE() { return PHASE_BUBBLING; }
|
|
36
|
+
|
|
37
|
+
stopPropagation() { this._stopPropagation = true; }
|
|
38
|
+
stopImmediatePropagation() { this._stopPropagation = true; this._stopImmediate = true; }
|
|
39
|
+
preventDefault() { if (this.cancelable && !this._passiveListener) this.defaultPrevented = true; }
|
|
40
|
+
get returnValue() { return !this.defaultPrevented; }
|
|
41
|
+
set returnValue(v) { if (v === false) this.preventDefault(); }
|
|
42
|
+
|
|
43
|
+
composedPath() { return this._path.slice(); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class CustomEvent extends Event {
|
|
47
|
+
constructor(type, init = {}) {
|
|
48
|
+
super(type, init);
|
|
49
|
+
this.detail = init.detail ?? null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Typed events. Real libraries (RTL/user-event) construct these by name; we copy
|
|
54
|
+
// the init dict onto the instance so common props (button, key, relatedTarget…) read back.
|
|
55
|
+
const TYPED_DEFAULTS = {
|
|
56
|
+
UIEvent: { detail: 0, view: null },
|
|
57
|
+
MouseEvent: { button: 0, buttons: 0, clientX: 0, clientY: 0, screenX: 0, screenY: 0, relatedTarget: null, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false },
|
|
58
|
+
PointerEvent: { pointerId: 0, pointerType: '', button: 0, buttons: 0, clientX: 0, clientY: 0 },
|
|
59
|
+
KeyboardEvent: { key: '', code: '', keyCode: 0, which: 0, ctrlKey: false, shiftKey: false, altKey: false, metaKey: false, repeat: false },
|
|
60
|
+
InputEvent: { data: null, inputType: '', isComposing: false },
|
|
61
|
+
FocusEvent: { relatedTarget: null },
|
|
62
|
+
CompositionEvent: { data: '' },
|
|
63
|
+
WheelEvent: { deltaX: 0, deltaY: 0, deltaZ: 0, deltaMode: 0 },
|
|
64
|
+
TouchEvent: { touches: [], targetTouches: [], changedTouches: [] },
|
|
65
|
+
DragEvent: { dataTransfer: null },
|
|
66
|
+
ProgressEvent: { lengthComputable: false, loaded: 0, total: 0 },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function makeTyped(name) {
|
|
70
|
+
const defaults = TYPED_DEFAULTS[name];
|
|
71
|
+
return class extends Event {
|
|
72
|
+
constructor(type, init = {}) {
|
|
73
|
+
super(type, init);
|
|
74
|
+
Object.assign(this, defaults, init);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const UIEvent = makeTyped('UIEvent');
|
|
80
|
+
export const MouseEvent = makeTyped('MouseEvent');
|
|
81
|
+
export const PointerEvent = makeTyped('PointerEvent');
|
|
82
|
+
export const KeyboardEvent = makeTyped('KeyboardEvent');
|
|
83
|
+
export const InputEvent = makeTyped('InputEvent');
|
|
84
|
+
export const FocusEvent = makeTyped('FocusEvent');
|
|
85
|
+
export const CompositionEvent = makeTyped('CompositionEvent');
|
|
86
|
+
export const WheelEvent = makeTyped('WheelEvent');
|
|
87
|
+
export const TouchEvent = makeTyped('TouchEvent');
|
|
88
|
+
export const DragEvent = makeTyped('DragEvent');
|
|
89
|
+
export const ProgressEvent = makeTyped('ProgressEvent');
|
|
90
|
+
|
|
91
|
+
function normalizeOptions(options) {
|
|
92
|
+
if (typeof options === 'boolean') return { capture: options, once: false, passive: false };
|
|
93
|
+
options = options || {};
|
|
94
|
+
return { capture: !!options.capture, once: !!options.once, passive: !!options.passive };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class EventTarget {
|
|
98
|
+
constructor() {
|
|
99
|
+
// type -> array of { callback, capture, once, passive }
|
|
100
|
+
this.__listeners = new Map();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
addEventListener(type, callback, options) {
|
|
104
|
+
if (callback == null) return;
|
|
105
|
+
const o = normalizeOptions(options);
|
|
106
|
+
let list = this.__listeners.get(type);
|
|
107
|
+
if (!list) { list = []; this.__listeners.set(type, list); }
|
|
108
|
+
// dedupe on (callback, capture) per spec
|
|
109
|
+
if (list.some((l) => l.callback === callback && l.capture === o.capture)) return;
|
|
110
|
+
list.push({ callback, capture: o.capture, once: o.once, passive: o.passive });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
removeEventListener(type, callback, options) {
|
|
114
|
+
const o = normalizeOptions(options);
|
|
115
|
+
const list = this.__listeners.get(type);
|
|
116
|
+
if (!list) return;
|
|
117
|
+
const i = list.findIndex((l) => l.callback === callback && l.capture === o.capture);
|
|
118
|
+
if (i !== -1) list.splice(i, 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build the event path: target up through ancestors to the root.
|
|
122
|
+
__eventPath() {
|
|
123
|
+
const path = [];
|
|
124
|
+
let node = this;
|
|
125
|
+
while (node) {
|
|
126
|
+
path.push(node);
|
|
127
|
+
node = node.parentNode || node.__owner || null; // element->parent, then document->window
|
|
128
|
+
}
|
|
129
|
+
return path;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
dispatchEvent(event) {
|
|
133
|
+
if (!(event instanceof Event)) throw new TypeError('dispatchEvent requires an Event');
|
|
134
|
+
event.target = this;
|
|
135
|
+
event._path = this.__eventPath();
|
|
136
|
+
const path = event._path;
|
|
137
|
+
|
|
138
|
+
const invoke = (node, phase) => {
|
|
139
|
+
const list = node.__listeners && node.__listeners.get(event.type);
|
|
140
|
+
if (!list || list.length === 0) return;
|
|
141
|
+
event.currentTarget = node;
|
|
142
|
+
event.eventPhase = phase;
|
|
143
|
+
// snapshot — listeners added during dispatch don't fire this round
|
|
144
|
+
for (const l of list.slice()) {
|
|
145
|
+
if (phase === PHASE_CAPTURING && !l.capture) continue;
|
|
146
|
+
if (phase === PHASE_BUBBLING && l.capture) continue;
|
|
147
|
+
if (l.once) {
|
|
148
|
+
const cur = node.__listeners.get(event.type);
|
|
149
|
+
const idx = cur ? cur.indexOf(l) : -1;
|
|
150
|
+
if (idx !== -1) cur.splice(idx, 1);
|
|
151
|
+
}
|
|
152
|
+
event._passiveListener = l.passive;
|
|
153
|
+
const handler = typeof l.callback === 'function' ? l.callback : l.callback.handleEvent;
|
|
154
|
+
try {
|
|
155
|
+
handler.call(node, event);
|
|
156
|
+
} finally {
|
|
157
|
+
event._passiveListener = false;
|
|
158
|
+
}
|
|
159
|
+
if (event._stopImmediate) return;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// capturing: root -> just before target
|
|
164
|
+
for (let i = path.length - 1; i >= 1; i--) {
|
|
165
|
+
if (event._stopPropagation) break;
|
|
166
|
+
invoke(path[i], PHASE_CAPTURING);
|
|
167
|
+
}
|
|
168
|
+
// at target
|
|
169
|
+
if (!event._stopPropagation) invoke(path[0], PHASE_AT_TARGET);
|
|
170
|
+
// bubbling: target's parent -> root
|
|
171
|
+
if (event.bubbles) {
|
|
172
|
+
for (let i = 1; i < path.length; i++) {
|
|
173
|
+
if (event._stopPropagation) break;
|
|
174
|
+
invoke(path[i], PHASE_BUBBLING);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
event.eventPhase = PHASE_NONE;
|
|
179
|
+
event.currentTarget = null;
|
|
180
|
+
|
|
181
|
+
// default actions (checkbox toggle, label→control, etc.) unless prevented
|
|
182
|
+
if (!event.defaultPrevented && typeof this.__runDefaultAction === 'function') {
|
|
183
|
+
this.__runDefaultAction(event);
|
|
184
|
+
}
|
|
185
|
+
return !event.defaultPrevented;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// on<event> handler properties (onclick, oninput, …). Defining these makes
|
|
190
|
+
// `'oninput' in document` true, so React skips its legacy attachEvent polyfill,
|
|
191
|
+
// and lets libraries assign el.onX = fn directly.
|
|
192
|
+
const ON_EVENTS = [
|
|
193
|
+
'click', 'dblclick', 'input', 'change', 'focus', 'blur', 'focusin', 'focusout',
|
|
194
|
+
'keydown', 'keyup', 'keypress', 'mousedown', 'mouseup', 'mousemove', 'mouseover',
|
|
195
|
+
'mouseout', 'mouseenter', 'mouseleave', 'submit', 'reset', 'load', 'error',
|
|
196
|
+
'scroll', 'wheel', 'contextmenu', 'pointerdown', 'pointerup', 'pointermove',
|
|
197
|
+
'pointerenter', 'pointerleave', 'pointercancel', 'touchstart', 'touchend',
|
|
198
|
+
'touchmove', 'animationstart', 'animationend', 'transitionend', 'paste', 'copy',
|
|
199
|
+
'cut', 'drop', 'dragstart', 'dragover', 'dragend', 'select', 'invalid', 'beforeinput',
|
|
200
|
+
];
|
|
201
|
+
for (const type of ON_EVENTS) {
|
|
202
|
+
const slot = '__on_' + type;
|
|
203
|
+
Object.defineProperty(EventTarget.prototype, 'on' + type, {
|
|
204
|
+
configurable: true,
|
|
205
|
+
get() { return this[slot] || null; },
|
|
206
|
+
set(fn) {
|
|
207
|
+
const prev = this[slot];
|
|
208
|
+
if (prev) this.removeEventListener(type, prev);
|
|
209
|
+
this[slot] = (typeof fn === 'function') ? fn : null;
|
|
210
|
+
if (this[slot]) this.addEventListener(type, this[slot]);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// HTML serialization for innerHTML / outerHTML (WHATWG-ish fragment serializer).
|
|
2
|
+
|
|
3
|
+
const VOID = new Set([
|
|
4
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
5
|
+
'meta', 'param', 'source', 'track', 'wbr',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
// Elements whose content is raw text (not escaped).
|
|
9
|
+
const RAW_TEXT = new Set(['style', 'script', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext']);
|
|
10
|
+
|
|
11
|
+
function escapeText(s) {
|
|
12
|
+
return s.replace(/&/g, '&').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>');
|
|
13
|
+
}
|
|
14
|
+
function escapeAttr(s) {
|
|
15
|
+
return s.replace(/&/g, '&').replace(/ /g, ' ').replace(/"/g, '"');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function serializeNode(node, out) {
|
|
19
|
+
switch (node.nodeType) {
|
|
20
|
+
case 1: { // element
|
|
21
|
+
const tag = node.localName;
|
|
22
|
+
out.push('<' + tag);
|
|
23
|
+
for (const a of node.__attrs) {
|
|
24
|
+
const name = a.prefix ? `${a.prefix}:${a.name}` : a.name;
|
|
25
|
+
out.push(` ${name}="${escapeAttr(a.value)}"`);
|
|
26
|
+
}
|
|
27
|
+
out.push('>');
|
|
28
|
+
if (VOID.has(tag)) break;
|
|
29
|
+
if (tag === 'template' && node.content) {
|
|
30
|
+
serializeChildren(node.content, out);
|
|
31
|
+
} else {
|
|
32
|
+
serializeChildren(node, out);
|
|
33
|
+
}
|
|
34
|
+
out.push(`</${tag}>`);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case 3: { // text
|
|
38
|
+
const parentTag = node.parentNode && node.parentNode.localName;
|
|
39
|
+
out.push(RAW_TEXT.has(parentTag) ? node.data : escapeText(node.data));
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 8: // comment
|
|
43
|
+
out.push(`<!--${node.data}-->`);
|
|
44
|
+
break;
|
|
45
|
+
case 10: // doctype
|
|
46
|
+
out.push(`<!DOCTYPE ${node.name}>`);
|
|
47
|
+
break;
|
|
48
|
+
case 11: // fragment
|
|
49
|
+
serializeChildren(node, out);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serializeChildren(node, out) {
|
|
55
|
+
for (const c of node.childNodes) serializeNode(c, out);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function serializeInner(node) {
|
|
59
|
+
const out = [];
|
|
60
|
+
if (node.nodeType === 1 && node.localName === 'template' && node.content) {
|
|
61
|
+
serializeChildren(node.content, out);
|
|
62
|
+
} else {
|
|
63
|
+
serializeChildren(node, out);
|
|
64
|
+
}
|
|
65
|
+
return out.join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function serializeOuter(node) {
|
|
69
|
+
const out = [];
|
|
70
|
+
serializeNode(node, out);
|
|
71
|
+
return out.join('');
|
|
72
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// turbo-dom test runtime — assembles Layers 1–5 into a jsdom-like environment.
|
|
2
|
+
//
|
|
3
|
+
// import { createEnvironment } from './src/runtime/index.mjs';
|
|
4
|
+
// const env = createEnvironment('<!doctype html><body><div id=app></div></body>');
|
|
5
|
+
// env.window.document.querySelector('#app');
|
|
6
|
+
// env.reset(); // Layer 5: cheap per-file reset
|
|
7
|
+
// env.reset('<body>next</body>');
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { Document } from './dom.mjs';
|
|
11
|
+
import { createWindow } from './window.mjs';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const native = require('../../index.js');
|
|
15
|
+
|
|
16
|
+
export { Document } from './dom.mjs';
|
|
17
|
+
export * from './dom.mjs';
|
|
18
|
+
|
|
19
|
+
export function createEnvironment(html = '<!doctype html><html><head></head><body></body></html>', options = {}) {
|
|
20
|
+
// Layer 1: native parse → immutable SoA buffer (typed arrays, one boundary copy).
|
|
21
|
+
let soa = native.parseBuffer(String(html));
|
|
22
|
+
|
|
23
|
+
// Layer 2: Document over the buffer (nodes inflate lazily from the arrays).
|
|
24
|
+
const document = new Document();
|
|
25
|
+
document.__load(soa);
|
|
26
|
+
|
|
27
|
+
// Layer 3: lazy window.
|
|
28
|
+
const win = createWindow(document, options);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
window: win.window,
|
|
32
|
+
document,
|
|
33
|
+
touched: win.touched,
|
|
34
|
+
globalKeys: win.globalKeys,
|
|
35
|
+
|
|
36
|
+
// Layer 5: arena-style reset. Re-point at the (re)parsed buffer, drop the
|
|
37
|
+
// owned overlay + node cache + materialized globals. Class machinery stays warm.
|
|
38
|
+
reset(nextHtml) {
|
|
39
|
+
if (nextHtml !== undefined) soa = native.parseBuffer(String(nextHtml));
|
|
40
|
+
document.__load(soa); // drops __cache + __kids overlay, keeps the buffer if reused
|
|
41
|
+
win.resetGlobals();
|
|
42
|
+
document.__active = null;
|
|
43
|
+
document.__cookie = '';
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Compact CSS selector engine. Correctness-first, right-to-left matching.
|
|
2
|
+
// Supports: type, *, #id, .class, [attr], [attr op val] (= ^= $= *= ~= |=),
|
|
3
|
+
// combinators (descendant ' ', child '>', adjacent '+', sibling '~'),
|
|
4
|
+
// comma selector lists, and :not(), :first-child, :last-child, :only-child,
|
|
5
|
+
// :empty, :root. Enough for React Testing Library usage.
|
|
6
|
+
|
|
7
|
+
const ATTR_RE = /\[\s*([^\]=~^$*|\s]+)\s*(?:([~^$*|]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\]]*?))\s*)?\]/y;
|
|
8
|
+
|
|
9
|
+
function parseCompound(src, i) {
|
|
10
|
+
const compound = { tag: null, id: null, classes: [], attrs: [], pseudos: [] };
|
|
11
|
+
let matchedAny = false;
|
|
12
|
+
while (i < src.length) {
|
|
13
|
+
const c = src[i];
|
|
14
|
+
if (c === '*') { compound.tag = '*'; i++; matchedAny = true; continue; }
|
|
15
|
+
if (/[a-zA-Z]/.test(c) && compound.tag === null && compound.id === null && !compound.classes.length && !compound.attrs.length && !compound.pseudos.length) {
|
|
16
|
+
let j = i;
|
|
17
|
+
while (j < src.length && /[a-zA-Z0-9_-]/.test(src[j])) j++;
|
|
18
|
+
compound.tag = src.slice(i, j).toLowerCase();
|
|
19
|
+
i = j; matchedAny = true; continue;
|
|
20
|
+
}
|
|
21
|
+
if (c === '#') {
|
|
22
|
+
let j = i + 1;
|
|
23
|
+
while (j < src.length && /[a-zA-Z0-9_-]/.test(src[j])) j++;
|
|
24
|
+
compound.id = src.slice(i + 1, j); i = j; matchedAny = true; continue;
|
|
25
|
+
}
|
|
26
|
+
if (c === '.') {
|
|
27
|
+
let j = i + 1;
|
|
28
|
+
while (j < src.length && /[a-zA-Z0-9_-]/.test(src[j])) j++;
|
|
29
|
+
compound.classes.push(src.slice(i + 1, j)); i = j; matchedAny = true; continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === '[') {
|
|
32
|
+
ATTR_RE.lastIndex = i;
|
|
33
|
+
const m = ATTR_RE.exec(src);
|
|
34
|
+
if (!m) throw new SyntaxError(`bad attribute selector at ${src.slice(i)}`);
|
|
35
|
+
compound.attrs.push({ name: m[1], op: m[2] || null, value: m[3] ?? m[4] ?? m[5] ?? null });
|
|
36
|
+
i = ATTR_RE.lastIndex; matchedAny = true; continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === ':') {
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
while (j < src.length && /[a-zA-Z-]/.test(src[j])) j++;
|
|
41
|
+
const name = src.slice(i + 1, j);
|
|
42
|
+
let arg = null;
|
|
43
|
+
if (src[j] === '(') {
|
|
44
|
+
let depth = 1, k = j + 1;
|
|
45
|
+
while (k < src.length && depth > 0) { if (src[k] === '(') depth++; else if (src[k] === ')') depth--; k++; }
|
|
46
|
+
arg = src.slice(j + 1, k - 1);
|
|
47
|
+
j = k;
|
|
48
|
+
}
|
|
49
|
+
compound.pseudos.push({ name, arg });
|
|
50
|
+
i = j; matchedAny = true; continue;
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (!matchedAny) throw new SyntaxError(`empty compound at ${src.slice(i)}`);
|
|
55
|
+
return { compound, i };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse one complex selector into compounds[] + combinators[] (combinator[k]
|
|
59
|
+
// relates compounds[k] to compounds[k+1], left to right).
|
|
60
|
+
function parseComplex(src) {
|
|
61
|
+
let i = 0;
|
|
62
|
+
const compounds = [];
|
|
63
|
+
const combinators = [];
|
|
64
|
+
src = src.trim();
|
|
65
|
+
while (i < src.length) {
|
|
66
|
+
while (src[i] === ' ') i++;
|
|
67
|
+
const r = parseCompound(src, i);
|
|
68
|
+
compounds.push(r.compound);
|
|
69
|
+
i = r.i;
|
|
70
|
+
// read optional combinator
|
|
71
|
+
let sawSpace = false;
|
|
72
|
+
while (src[i] === ' ') { sawSpace = true; i++; }
|
|
73
|
+
if (i >= src.length) break;
|
|
74
|
+
if (src[i] === '>' || src[i] === '+' || src[i] === '~') {
|
|
75
|
+
combinators.push(src[i]); i++;
|
|
76
|
+
while (src[i] === ' ') i++;
|
|
77
|
+
} else if (sawSpace) {
|
|
78
|
+
combinators.push(' ');
|
|
79
|
+
} else {
|
|
80
|
+
throw new SyntaxError(`unexpected '${src[i]}' in selector`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { compounds, combinators };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseSelectorList(selector) {
|
|
87
|
+
return splitTopLevel(selector, ',').map((s) => parseComplex(s.trim()));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function splitTopLevel(s, sep) {
|
|
91
|
+
const out = [];
|
|
92
|
+
let depth = 0, last = 0;
|
|
93
|
+
for (let i = 0; i < s.length; i++) {
|
|
94
|
+
const c = s[i];
|
|
95
|
+
if (c === '(' || c === '[') depth++;
|
|
96
|
+
else if (c === ')' || c === ']') depth--;
|
|
97
|
+
else if (c === sep && depth === 0) { out.push(s.slice(last, i)); last = i + 1; }
|
|
98
|
+
}
|
|
99
|
+
out.push(s.slice(last));
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function elementChildren(node) {
|
|
104
|
+
const kids = typeof node.__children === 'function' ? node.__children() : Array.from(node.childNodes || []);
|
|
105
|
+
return kids.filter((n) => n.nodeType === 1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function matchAttr(el, a) {
|
|
109
|
+
if (!el.hasAttribute(a.name)) return false;
|
|
110
|
+
if (a.op === null) return true;
|
|
111
|
+
const v = el.getAttribute(a.name) ?? '';
|
|
112
|
+
const t = a.value ?? '';
|
|
113
|
+
switch (a.op) {
|
|
114
|
+
case '=': return v === t;
|
|
115
|
+
case '^=': return t !== '' && v.startsWith(t);
|
|
116
|
+
case '$=': return t !== '' && v.endsWith(t);
|
|
117
|
+
case '*=': return t !== '' && v.includes(t);
|
|
118
|
+
case '~=': return v.split(/\s+/).includes(t);
|
|
119
|
+
case '|=': return v === t || v.startsWith(t + '-');
|
|
120
|
+
default: return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function matchPseudo(el, p) {
|
|
125
|
+
switch (p.name) {
|
|
126
|
+
case 'not':
|
|
127
|
+
return !parseSelectorList(p.arg).some((cx) => matchComplex(el, cx));
|
|
128
|
+
case 'first-child':
|
|
129
|
+
return previousElement(el) === null;
|
|
130
|
+
case 'last-child':
|
|
131
|
+
return nextElement(el) === null;
|
|
132
|
+
case 'only-child':
|
|
133
|
+
return previousElement(el) === null && nextElement(el) === null;
|
|
134
|
+
case 'empty':
|
|
135
|
+
return el.childNodes.length === 0;
|
|
136
|
+
case 'root':
|
|
137
|
+
return el.parentNode == null || el.parentNode.nodeType === 9;
|
|
138
|
+
default:
|
|
139
|
+
return false; // unknown pseudo: never matches (honest, not a silent true)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function previousElement(el) {
|
|
144
|
+
let n = el.previousSibling;
|
|
145
|
+
while (n && n.nodeType !== 1) n = n.previousSibling;
|
|
146
|
+
return n || null;
|
|
147
|
+
}
|
|
148
|
+
function nextElement(el) {
|
|
149
|
+
let n = el.nextSibling;
|
|
150
|
+
while (n && n.nodeType !== 1) n = n.nextSibling;
|
|
151
|
+
return n || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function matchCompound(el, compound) {
|
|
155
|
+
if (!el || el.nodeType !== 1) return false;
|
|
156
|
+
if (compound.tag && compound.tag !== '*' && el.localName !== compound.tag) return false;
|
|
157
|
+
if (compound.id !== null && el.getAttribute('id') !== compound.id) return false;
|
|
158
|
+
for (const cls of compound.classes) if (!el.classList.contains(cls)) return false;
|
|
159
|
+
for (const a of compound.attrs) if (!matchAttr(el, a)) return false;
|
|
160
|
+
for (const p of compound.pseudos) if (!matchPseudo(el, p)) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Match a parsed complex selector against `el` (the rightmost compound applies to el).
|
|
165
|
+
function matchComplex(el, cx) {
|
|
166
|
+
const { compounds, combinators } = cx;
|
|
167
|
+
const last = compounds.length - 1;
|
|
168
|
+
if (!matchCompound(el, compounds[last])) return false;
|
|
169
|
+
|
|
170
|
+
// walk leftward
|
|
171
|
+
let idx = last - 1;
|
|
172
|
+
let current = el;
|
|
173
|
+
while (idx >= 0) {
|
|
174
|
+
const comb = combinators[idx]; // relation between compounds[idx] and compounds[idx+1]
|
|
175
|
+
const target = compounds[idx];
|
|
176
|
+
if (comb === ' ') {
|
|
177
|
+
let anc = current.parentNode;
|
|
178
|
+
let matched = false;
|
|
179
|
+
while (anc && anc.nodeType === 1) {
|
|
180
|
+
if (matchCompound(anc, target)) { current = anc; matched = true; break; }
|
|
181
|
+
anc = anc.parentNode;
|
|
182
|
+
}
|
|
183
|
+
if (!matched) return false;
|
|
184
|
+
} else if (comb === '>') {
|
|
185
|
+
const parent = current.parentNode;
|
|
186
|
+
if (!parent || parent.nodeType !== 1 || !matchCompound(parent, target)) return false;
|
|
187
|
+
current = parent;
|
|
188
|
+
} else if (comb === '+') {
|
|
189
|
+
const prev = previousElement(current);
|
|
190
|
+
if (!prev || !matchCompound(prev, target)) return false;
|
|
191
|
+
current = prev;
|
|
192
|
+
} else if (comb === '~') {
|
|
193
|
+
let prev = previousElement(current);
|
|
194
|
+
let matched = false;
|
|
195
|
+
while (prev) {
|
|
196
|
+
if (matchCompound(prev, target)) { current = prev; matched = true; break; }
|
|
197
|
+
prev = previousElement(prev);
|
|
198
|
+
}
|
|
199
|
+
if (!matched) return false;
|
|
200
|
+
}
|
|
201
|
+
idx--;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function matchesSelector(el, selector) {
|
|
207
|
+
const list = parseSelectorList(selector);
|
|
208
|
+
return list.some((cx) => matchComplex(el, cx));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function querySelectorAll(root, selector) {
|
|
212
|
+
const list = parseSelectorList(selector);
|
|
213
|
+
const out = [];
|
|
214
|
+
const visit = (node) => {
|
|
215
|
+
for (const child of elementChildren(node)) {
|
|
216
|
+
if (list.some((cx) => matchComplex(child, cx))) out.push(child);
|
|
217
|
+
visit(child);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
visit(root);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function querySelector(root, selector) {
|
|
225
|
+
const list = parseSelectorList(selector);
|
|
226
|
+
let found = null;
|
|
227
|
+
const visit = (node) => {
|
|
228
|
+
for (const child of elementChildren(node)) {
|
|
229
|
+
if (found) return;
|
|
230
|
+
if (list.some((cx) => matchComplex(child, cx))) { found = child; return; }
|
|
231
|
+
visit(child);
|
|
232
|
+
if (found) return;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
visit(root);
|
|
236
|
+
return found;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const _internal = { parseComplex, parseSelectorList, matchCompound, matchComplex };
|