@atolljs/islands 0.1.0

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.
@@ -0,0 +1,107 @@
1
+ import {
2
+ allocId,
3
+ bumpOpsVersion,
4
+ getActiveInstance,
5
+ getLastActiveInstance,
6
+ getInstanceSize,
7
+ instances,
8
+ markInstanceActive,
9
+ pushOp,
10
+ registerHandler,
11
+ unregisterHandler,
12
+ } from '../instance';
13
+ import type { ElementInstance, HostInstance, TextInstance } from '../instance';
14
+ import type { EventPayload, Op } from '../../ops';
15
+ import { parseDocument, ElementType } from 'htmlparser2';
16
+
17
+ import { ProxyNode, ProxyText, ProxyComment, hyphenate } from './node';
18
+ import { ProxyElement } from './element';
19
+ import type { InternalDocument } from './document';
20
+
21
+ /* ── HTML: parse (htmlparser2) + serialize (shadow tree) ───────────────── */
22
+
23
+ /**
24
+ * Parse an HTML fragment into fresh proxy nodes — one createElement/
25
+ * createTextNode/appendChild chain per parsed node, so setting innerHTML or
26
+ * calling insertAdjacentHTML emits the same op stream hand-built code
27
+ * would. htmlparser2 is pure JS — no DOM, worker-safe.
28
+ */
29
+ /** A node in htmlparser2's parse tree — typed off the public API so no
30
+ * transitive domhandler import is needed. */
31
+ type ParsedNode = ReturnType<typeof parseDocument>['children'][number];
32
+
33
+ export function parseChildren(doc: InternalDocument, html: string): ProxyNode[] {
34
+ const parsed = parseDocument(String(html));
35
+ const build = (nodes: readonly ParsedNode[]): ProxyNode[] => {
36
+ const out: ProxyNode[] = [];
37
+ for (const node of nodes) {
38
+ switch (node.type) {
39
+ case ElementType.Tag:
40
+ case ElementType.Script:
41
+ case ElementType.Style: {
42
+ const el = doc.createElement(node.name);
43
+ for (const [name, value] of Object.entries(node.attribs)) el.setAttribute(name, value);
44
+ for (const child of build(node.children)) el.appendChild(child);
45
+ out.push(el);
46
+ break;
47
+ }
48
+ case ElementType.Text: {
49
+ if (node.data !== '') out.push(doc.createTextNode(node.data));
50
+ break;
51
+ }
52
+ case ElementType.Comment: {
53
+ // Comments materialize as ProxyComment — framework anchors baked
54
+ // into template HTML (Svelte's `<!>`/`<!--x-->` block boundaries,
55
+ // Vue's v-if anchors) survive the parse. Directives and doctype
56
+ // stay skipped.
57
+ out.push(doc.createComment(node.data));
58
+ break;
59
+ }
60
+ case ElementType.CDATA: {
61
+ // CDATA carries its text as children — flatten like the DOM does.
62
+ for (const child of build(node.children)) out.push(child);
63
+ break;
64
+ }
65
+ default:
66
+ break; // directives/doctype: skipped.
67
+ }
68
+ }
69
+ return out;
70
+ };
71
+ return build(parsed.children);
72
+ }
73
+
74
+ /** Elements the HTML serializer emits without an end tag. */
75
+ const VOID_ELEMENTS = new Set([
76
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
77
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
78
+ ]);
79
+
80
+ const escapeText = (s: string): string =>
81
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
82
+ const escapeAttr = (s: string): string =>
83
+ s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
84
+
85
+ /** Serialize a shadow node — elements with attrs (+ inline style from the
86
+ * style proxy), text escaped, comments round-tripping as `<!--data-->`.
87
+ * Reads the shadow tree only; nothing the proxy didn't write appears here. */
88
+ export function serializeNode(node: ProxyNode): string {
89
+ // ProxyComment extends ProxyText — check it FIRST so comment data doesn't
90
+ // serialize as literal text.
91
+ if (node instanceof ProxyComment) return `<!--${node.data}-->`;
92
+ if (node instanceof ProxyText) return escapeText(node.textContent);
93
+ if (!(node instanceof ProxyElement)) return '';
94
+ const tag = node.instance.type;
95
+ const attrs = new Map(node._attrs);
96
+ const styleText = Object.entries(node._styleProps)
97
+ .map(([k, v]) => `${hyphenate(k)}: ${v};`)
98
+ .join(' ');
99
+ if (styleText !== '') attrs.set('style', styleText);
100
+ let out = `<${tag}`;
101
+ for (const [name, value] of attrs) out += ` ${name}="${escapeAttr(value)}"`;
102
+ if (VOID_ELEMENTS.has(tag)) return `${out}>`;
103
+ out += '>';
104
+ for (const child of node._children) out += serializeNode(child);
105
+ return `${out}</${tag}>`;
106
+ }
107
+
@@ -0,0 +1,481 @@
1
+ import {
2
+ allocId,
3
+ bumpOpsVersion,
4
+ getActiveInstance,
5
+ getLastActiveInstance,
6
+ getInstanceSize,
7
+ instances,
8
+ markInstanceActive,
9
+ pushOp,
10
+ registerHandler,
11
+ unregisterHandler,
12
+ } from '../instance';
13
+ import type { ElementInstance, HostInstance, TextInstance } from '../instance';
14
+ import type { EventPayload, Op } from '../../ops';
15
+
16
+ import type { InternalDocument, ProxyDocument } from './document';
17
+
18
+ /** Handler signature for proxy addEventListener — the wire payload plus a
19
+ * synthesized `target` (the proxy node for `targetId`, when known).
20
+ * `this` is bound to the node/document/facade the listener was attached
21
+ * to, like the real DOM's currentTarget. */
22
+ export type ProxyEventHandler = (this: unknown, payload: EventPayload) => void;
23
+
24
+ /** The subset of AddEventListenerOptions the `listen`/`unlisten` ops carry. */
25
+ export interface WireListenerOpts {
26
+ once?: boolean;
27
+ passive?: boolean;
28
+ capture?: boolean;
29
+ }
30
+
31
+ /** Normalize the DOM's `options | captureFlag` argument into concrete flags. */
32
+ export const normalizeListenerOptions = (
33
+ options?: boolean | AddEventListenerOptions | EventListenerOptions | null,
34
+ ): { once: boolean; passive: boolean; capture: boolean } => {
35
+ const o = (typeof options === 'boolean' ? { capture: options } : (options ?? {})) as AddEventListenerOptions;
36
+ return { once: o.once === true, passive: o.passive === true, capture: o.capture === true };
37
+ };
38
+
39
+ /** The `opts` field for a listen/unlisten op — undefined when every flag is
40
+ * off so plain listeners emit the same op shape as before. */
41
+ export const listenerOptsForWire = (
42
+ options?: boolean | AddEventListenerOptions | EventListenerOptions | null,
43
+ ): WireListenerOpts | undefined => {
44
+ const o = normalizeListenerOptions(options);
45
+ return o.once || o.passive || o.capture
46
+ ? { once: o.once, passive: o.passive, capture: o.capture }
47
+ : undefined;
48
+ };
49
+
50
+ export const hyphenate = (k: string): string => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
51
+ export const camelize = (k: string): string => k.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
52
+
53
+ /**
54
+ * Phantom instance ids — negative, so they can never collide with allocId's
55
+ * positive ids and are never registered in `instances`. A phantom is the
56
+ * synthetic text child an element's `textContent` setter leaves in the
57
+ * shadow tree (the real DOM gets the same effect from `utext` on the
58
+ * parent, which is why phantom mutations retarget the parent — see below).
59
+ */
60
+ let nextPhantomId = -1;
61
+ export const allocPhantomId = (): number => nextPhantomId--;
62
+
63
+ /**
64
+ * A node that isn't a ProxyNode can only arrive via a foreign `document`
65
+ * (the dispatcher's real-DOM fallback or a poisoned override) — typically a
66
+ * library's deferred callback running after its instance was torn down, or
67
+ * globals restored while worker-side timers still fire. Adopting it would
68
+ * crash later on `child.instance`; reject it here with the actual cause.
69
+ */
70
+ export const rejectForeignChild = (child: unknown): never => {
71
+ throw new Error(
72
+ 'proxyDom.insertBefore: child is not a proxy node — the ambient `document`/' +
73
+ '`createElement` resolved a foreign DOM (a stale override or the real one), ' +
74
+ 'so this node was created outside the instance that owns the parent',
75
+ );
76
+ };
77
+
78
+ /**
79
+ * Detach `child` from its recorded parent, tolerating a stale/foreign
80
+ * `_parent` link (undefined, or a non-proxy object) instead of crashing on
81
+ * `_detach` — a corrupted link is cleared, a valid one is honored.
82
+ */
83
+ export const detachFromParent = (child: ProxyNode): void => {
84
+ const p = child._parent;
85
+ if (p instanceof ProxyNode) p._detach(child);
86
+ else child._parent = null;
87
+ };
88
+
89
+ /** Strings become text nodes — the ChildNode/ParentNode convenience arg. */
90
+ const asChild = (doc: InternalDocument, n: ProxyNode | string): ProxyNode =>
91
+ typeof n === 'string' ? doc.createTextNode(n) : n;
92
+
93
+ /* ── ProxyNode ─────────────────────────────────────────────────────────── */
94
+
95
+ export class ProxyNode {
96
+ /** The shared host-instance record — same objects `instances` holds. */
97
+ readonly instance: HostInstance;
98
+ /** The document that created/wrapped this node (internal fields below). */
99
+ readonly doc: InternalDocument;
100
+ _parent: ProxyNode | null = null;
101
+ _children: ProxyNode[] = [];
102
+
103
+ constructor(doc: InternalDocument, instance: HostInstance) {
104
+ this.doc = doc;
105
+ this.instance = instance;
106
+ }
107
+
108
+ get nodeType(): number {
109
+ return 0;
110
+ }
111
+
112
+ /**
113
+ * '#text'/'#comment'/'#document-fragment' for character/fragment nodes;
114
+ * the tag name for elements — uppercase in the HTML namespace, verbatim
115
+ * for namespaced elements (svg 'foreignObject' keeps its case, matching
116
+ * the real DOM).
117
+ */
118
+ get nodeName(): string {
119
+ switch (this.nodeType) {
120
+ case 1: {
121
+ const inst = this.instance as ElementInstance;
122
+ return inst.ns === undefined ? inst.type.toUpperCase() : inst.type;
123
+ }
124
+ case 3:
125
+ return '#text';
126
+ case 8:
127
+ return '#comment';
128
+ case 11:
129
+ return '#document-fragment';
130
+ default:
131
+ return '#node';
132
+ }
133
+ }
134
+
135
+ /** The proxy document that owns this node — what real DOM code reads as
136
+ * `el.ownerDocument`. */
137
+ get ownerDocument(): ProxyDocument {
138
+ return this.doc;
139
+ }
140
+
141
+ get parentNode(): ProxyNode | null {
142
+ return this._parent;
143
+ }
144
+ get parentElement(): ProxyNode | null {
145
+ // structural Element check — importing ProxyElement here would create a
146
+ // module cycle because ProxyElement extends ProxyNode.
147
+ return this._parent !== null && this._parent.nodeType === 1 ? this._parent : null;
148
+ }
149
+ /** A copy of the shadow child list — structural changes go through
150
+ * appendChild/insertBefore/removeChild so ops stay in sync. */
151
+ get childNodes(): ProxyNode[] {
152
+ return this._children.slice();
153
+ }
154
+ get firstChild(): ProxyNode | null {
155
+ return this._children[0] ?? null;
156
+ }
157
+ get lastChild(): ProxyNode | null {
158
+ return this._children[this._children.length - 1] ?? null;
159
+ }
160
+ get nextSibling(): ProxyNode | null {
161
+ const p = this._parent;
162
+ if (p === null) return null;
163
+ return p._children[p._children.indexOf(this) + 1] ?? null;
164
+ }
165
+ get previousSibling(): ProxyNode | null {
166
+ const p = this._parent;
167
+ if (p === null) return null;
168
+ const i = p._children.indexOf(this);
169
+ return i > 0 ? p._children[i - 1] : null;
170
+ }
171
+ /** Attached to the document's root (driver-side id 0 container)? */
172
+ get isConnected(): boolean {
173
+ let n: ProxyNode | null = this;
174
+ while (n !== null) {
175
+ if (n === this.doc._root) return true;
176
+ n = n._parent;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /**
182
+ * The topmost shadow-tree ancestor — the root container element when the
183
+ * node is connected. (The facade has no separate Document node object;
184
+ * `doc.body`/`documentElement` ARE that root.)
185
+ */
186
+ getRootNode(): ProxyNode {
187
+ let n: ProxyNode = this;
188
+ while (n._parent !== null) n = n._parent;
189
+ return n;
190
+ }
191
+
192
+ /** Concatenated descendant text — walked from the shadow tree. Comments
193
+ * are excluded: the DOM's descendant text content is Text-node data only. */
194
+ get textContent(): string {
195
+ let out = '';
196
+ for (const child of this._children) {
197
+ if (child.nodeType !== 8) out += child.textContent;
198
+ }
199
+ return out;
200
+ }
201
+ set textContent(value: string) {
202
+ this.doc._assertAlive();
203
+ const text = String(value);
204
+ for (const child of this._children) child._parent = null;
205
+ this._children = text === '' ? [] : [this.doc._phantomText(this, text)];
206
+ this._op({ t: 'utext', id: this.instance.id, text });
207
+ }
208
+
209
+ contains(other: ProxyNode): boolean {
210
+ let n: ProxyNode | null = other;
211
+ while (n !== null) {
212
+ if (n === this) return true;
213
+ n = n._parent;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ appendChild<T extends ProxyNode>(child: T): T {
219
+ return this.insertBefore(child, null);
220
+ }
221
+
222
+ insertBefore<T extends ProxyNode>(child: T, ref: ProxyNode | null): T {
223
+ this.doc._assertAlive();
224
+ // Character-data nodes can't hold children — the driver replays every
225
+ // emitted append as a real insertBefore, which would throw
226
+ // HierarchyRequestError on the page; fail here first, like the DOM.
227
+ if (this.nodeType === 3 || this.nodeType === 8) {
228
+ throw new Error(
229
+ `proxyDom.insertBefore: ${this.nodeName} nodes cannot have children`,
230
+ );
231
+ }
232
+ if (!(child instanceof ProxyNode)) rejectForeignChild(child);
233
+ // A fragment is a phantom PARENT — the real DOM splices its children
234
+ // in at the insertion point and empties it; do the same (per-child ops).
235
+ if (child instanceof ProxyFragment) {
236
+ for (const c of child._children.slice()) this.insertBefore(c, ref);
237
+ return child;
238
+ }
239
+ let beforeId: number | undefined;
240
+ if (ref === null) {
241
+ detachFromParent(child);
242
+ child._parent = this;
243
+ this._children.push(child);
244
+ } else {
245
+ const index = this._children.indexOf(ref);
246
+ if (index === -1) {
247
+ throw new Error('proxyDom.insertBefore: reference node is not a child of this node');
248
+ }
249
+ detachFromParent(child);
250
+ child._parent = this;
251
+ this._children.splice(index, 0, child);
252
+ if (ref.instance.id > 0) beforeId = ref.instance.id;
253
+ }
254
+ // Phantom nodes (fragments, shadow-only texts) have NEGATIVE ids and no
255
+ // driver-side counterpart — skip ops when either endpoint is phantom.
256
+ // Id 0 is NOT phantom: it's the island's real root container.
257
+ if (this.instance.id >= 0 && child.instance.id > 0) {
258
+ this._op({ t: 'append', parent: this.instance.id, child: child.instance.id, before: beforeId });
259
+ }
260
+ return child;
261
+ }
262
+
263
+ removeChild<T extends ProxyNode>(child: T): T {
264
+ this.doc._assertAlive();
265
+ const index = this._children.indexOf(child);
266
+ if (index === -1) {
267
+ throw new Error('proxyDom.removeChild: node is not a child of this node');
268
+ }
269
+ this._children.splice(index, 1);
270
+ child._parent = null;
271
+ if (this.instance.id >= 0 && child.instance.id > 0) {
272
+ this._op({ t: 'remove', child: child.instance.id });
273
+ }
274
+ return child;
275
+ }
276
+
277
+ /** Detach from the parent — no-op when already orphaned (like the DOM). */
278
+ remove(): void {
279
+ this._parent?.removeChild(this);
280
+ }
281
+
282
+ /* ── ChildNode / ParentNode conveniences ───────────────────────────────
283
+ * Strings become text nodes like the DOM. before/after/replaceWith on a
284
+ * parentless node are no-ops (per spec). append/prepend/replaceChildren
285
+ * live on the base so elements AND fragments share them — real DOM also
286
+ * exposes them on DocumentFragment. */
287
+
288
+ before(...nodes: Array<ProxyNode | string>): void {
289
+ const p = this._parent;
290
+ if (p === null) return;
291
+ for (const n of nodes) p.insertBefore(asChild(this.doc, n), this);
292
+ }
293
+
294
+ after(...nodes: Array<ProxyNode | string>): void {
295
+ const p = this._parent;
296
+ if (p === null) return;
297
+ const ref = this.nextSibling;
298
+ for (const n of nodes) p.insertBefore(asChild(this.doc, n), ref);
299
+ }
300
+
301
+ replaceWith(...nodes: Array<ProxyNode | string>): void {
302
+ const p = this._parent;
303
+ if (p === null) return;
304
+ for (const n of nodes) p.insertBefore(asChild(this.doc, n), this);
305
+ p.removeChild(this);
306
+ }
307
+
308
+ /** append(...nodes) — strings become text nodes, like the DOM. */
309
+ append(...nodes: Array<ProxyNode | string>): void {
310
+ this.doc._assertAlive();
311
+ for (const n of nodes) this.appendChild(asChild(this.doc, n));
312
+ }
313
+
314
+ /** prepend(...nodes) — inserts at the front, in argument order. */
315
+ prepend(...nodes: Array<ProxyNode | string>): void {
316
+ this.doc._assertAlive();
317
+ const ref = this.firstChild;
318
+ for (const n of nodes) this.insertBefore(asChild(this.doc, n), ref);
319
+ }
320
+
321
+ /** replaceChildren(...nodes) — remove every child, then append the set. */
322
+ replaceChildren(...nodes: Array<ProxyNode | string>): void {
323
+ this.doc._assertAlive();
324
+ for (const child of this._children.slice()) this.removeChild(child);
325
+ this.append(...nodes);
326
+ }
327
+
328
+ /**
329
+ * Structural clone — each concrete kind implements it. The copy is a
330
+ * FRESH instance (own id, own create op), not a shared record; listeners
331
+ * never cross, matching the DOM.
332
+ */
333
+ cloneNode(_deep?: boolean): ProxyNode {
334
+ throw new Error('proxyDom.cloneNode: unsupported node kind');
335
+ }
336
+
337
+ /**
338
+ * Queue an op on this node's instance — the same routing hostConfig uses for
339
+ * instance-bound ops. Outside a instance task the op still lands on the right
340
+ * queue, and the doorbell is bumped so push-mode islands pick it up.
341
+ */
342
+ _op(op: Op): void {
343
+ pushOp(this.instance.instance, op);
344
+ if (getActiveInstance() !== this.instance.instance) bumpOpsVersion();
345
+ }
346
+
347
+ /** Detach a child from the shadow list WITHOUT emitting an op — used when
348
+ * a following append op will move the real node on the main thread. */
349
+ _detach(child: ProxyNode): void {
350
+ const i = this._children.indexOf(child);
351
+ if (i !== -1) this._children.splice(i, 1);
352
+ child._parent = null;
353
+ }
354
+ }
355
+
356
+ /* ── ProxyText ─────────────────────────────────────────────────────────── */
357
+
358
+ export class ProxyText extends ProxyNode {
359
+ declare readonly instance: TextInstance;
360
+
361
+ constructor(doc: InternalDocument, instance: TextInstance) {
362
+ super(doc, instance);
363
+ }
364
+
365
+ override get nodeType(): number {
366
+ return 3;
367
+ }
368
+
369
+ /**
370
+ * The driver-side id a text write targets. Real text instances write
371
+ * their own id; phantoms (created by an element's `textContent` setter)
372
+ * have no driver-side node, so writes retarget the parent — whose real
373
+ * `textContent` IS this node's whole text.
374
+ */
375
+ get _utextTarget(): number {
376
+ return this.instance.id > 0
377
+ ? this.instance.id
378
+ : (this._parent?.instance.id ?? this.instance.id);
379
+ }
380
+
381
+ get data(): string {
382
+ return this.instance.text;
383
+ }
384
+ set data(v: string) {
385
+ this._write(v);
386
+ }
387
+ get nodeValue(): string {
388
+ return this.instance.text;
389
+ }
390
+ set nodeValue(v: string) {
391
+ this._write(v);
392
+ }
393
+ override get textContent(): string {
394
+ return this.instance.text;
395
+ }
396
+ override set textContent(v: string) {
397
+ this._write(v);
398
+ }
399
+
400
+ private _write(v: string): void {
401
+ this.doc._assertAlive();
402
+ const text = String(v);
403
+ this.instance.text = text;
404
+ this._op({ t: 'utext', id: this._utextTarget, text });
405
+ }
406
+
407
+ /** Fresh text node — the clone is a new `text` op, not a shared record. */
408
+ override cloneNode(_deep?: boolean): ProxyText {
409
+ return this.doc.createTextNode(this.instance.text);
410
+ }
411
+ }
412
+
413
+ /* ── ProxyComment ────────────────────────────────────────────────────── */
414
+
415
+ /**
416
+ * Comment — nodeType 8, backed by a REAL empty text node driver-side: the
417
+ * wire has no comment op, so `createComment` emits a `text` op with ''
418
+ * giving the node a `before:`-addressable position for anchors (Svelte's
419
+ * `<!>` block boundaries, Vue/Angular comment anchors).
420
+ *
421
+ * `data`/`nodeValue`/`textContent` writes update the shadow record ONLY —
422
+ * emitting `utext` would render the comment's data as visible page text.
423
+ */
424
+ export class ProxyComment extends ProxyText {
425
+ override get nodeType(): number {
426
+ return 8;
427
+ }
428
+
429
+ override get data(): string {
430
+ return this.instance.text;
431
+ }
432
+ override set data(v: string) {
433
+ this._shadowWrite(v);
434
+ }
435
+ override get nodeValue(): string {
436
+ return this.instance.text;
437
+ }
438
+ override set nodeValue(v: string) {
439
+ this._shadowWrite(v);
440
+ }
441
+ override get textContent(): string {
442
+ return this.instance.text;
443
+ }
444
+ override set textContent(v: string) {
445
+ this._shadowWrite(v);
446
+ }
447
+
448
+ private _shadowWrite(v: string): void {
449
+ this.doc._assertAlive();
450
+ this.instance.text = String(v);
451
+ }
452
+
453
+ override cloneNode(_deep?: boolean): ProxyComment {
454
+ return this.doc.createComment(this.data);
455
+ }
456
+ }
457
+
458
+ /* ── ProxyFragment ─────────────────────────────────────────────────────── */
459
+
460
+ /**
461
+ * DocumentFragment — a PHANTOM parent: it never exists driver-side, so its
462
+ * `instance.id` is negative (no ops ever target it). Appending it to a real
463
+ * parent splices its children out like the DOM does; libraries use it to
464
+ * batch insertions (Leaflet's GridLayer builds each zoom level in one).
465
+ */
466
+ export class ProxyFragment extends ProxyNode {
467
+ override get nodeType(): number {
468
+ return 11;
469
+ }
470
+
471
+ /** Fresh fragment; `deep` clones children polymorphically so comment
472
+ * anchors keep nodeType 8. */
473
+ cloneNode(deep?: boolean): ProxyFragment {
474
+ const copy = this.doc.createDocumentFragment();
475
+ if (deep === true) {
476
+ for (const child of this._children) copy.appendChild(child.cloneNode(true));
477
+ }
478
+ return copy;
479
+ }
480
+ }
481
+
@@ -0,0 +1,108 @@
1
+ import { ProxyElement } from './element';
2
+ import type { ProxyNode } from './node';
3
+
4
+ /* ── Selectors (compound + descendant combinator only) ─────────────────── */
5
+
6
+ interface CompoundSelector {
7
+ tag?: string;
8
+ id?: string;
9
+ classes: string[];
10
+ attrs: Array<{ name: string; value?: string }>;
11
+ }
12
+
13
+ const unsupportedSelector = (sel: string): never => {
14
+ throw new Error(
15
+ `proxyDom.querySelector: unsupported selector "${sel}" — only tag, .class, #id, [attr], [attr="v"] and descendant combinators are supported`,
16
+ );
17
+ };
18
+
19
+ export function parseCompound(part: string, sel: string): CompoundSelector {
20
+ const out: CompoundSelector = { classes: [], attrs: [] };
21
+ let i = 0;
22
+ const tag = /^[a-zA-Z][\w-]*/.exec(part);
23
+ if (tag !== null) {
24
+ out.tag = tag[0].toLowerCase();
25
+ i = tag[0].length;
26
+ }
27
+ while (i < part.length) {
28
+ const ch = part[i];
29
+ if (ch === '#') {
30
+ const m = /^[\w-]+/.exec(part.slice(i + 1));
31
+ if (m === null) unsupportedSelector(sel);
32
+ out.id = m![0];
33
+ i += 1 + m![0].length;
34
+ } else if (ch === '.') {
35
+ const m = /^[\w-]+/.exec(part.slice(i + 1));
36
+ if (m === null) unsupportedSelector(sel);
37
+ out.classes.push(m![0]);
38
+ i += 1 + m![0].length;
39
+ } else if (ch === '[') {
40
+ const end = part.indexOf(']', i);
41
+ if (end === -1) unsupportedSelector(sel);
42
+ const body = part.slice(i + 1, end).trim();
43
+ const m = /^([\w-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^'"\s]+)))?$/.exec(body);
44
+ if (m === null) unsupportedSelector(sel);
45
+ out.attrs.push({ name: m![1], value: m![2] ?? m![3] ?? m![4] });
46
+ i = end + 1;
47
+ } else {
48
+ unsupportedSelector(sel);
49
+ }
50
+ }
51
+ if (
52
+ out.tag === undefined &&
53
+ out.id === undefined &&
54
+ out.classes.length === 0 &&
55
+ out.attrs.length === 0
56
+ ) {
57
+ unsupportedSelector(sel);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export function parseSelector(selector: string): CompoundSelector[] {
63
+ const sel = selector.trim();
64
+ if (
65
+ sel === '' ||
66
+ sel.includes(',') ||
67
+ sel.includes('>') ||
68
+ sel.includes('+') ||
69
+ sel.includes('~') ||
70
+ sel.includes(':')
71
+ ) {
72
+ unsupportedSelector(selector);
73
+ }
74
+ return sel.split(/\s+/).map((part) => parseCompound(part, selector));
75
+ }
76
+
77
+ export function matchCompound(el: ProxyElement, c: CompoundSelector): boolean {
78
+ if (c.tag !== undefined && el.tagName.toLowerCase() !== c.tag) return false;
79
+ if (c.id !== undefined && el._attrs.get('id') !== c.id) return false;
80
+ for (const cls of c.classes) if (!el._classes.has(cls)) return false;
81
+ for (const a of c.attrs) {
82
+ const v = el._attrs.get(a.name);
83
+ if (v === undefined) return false;
84
+ if (a.value !== undefined && v !== a.value) return false;
85
+ }
86
+ return true;
87
+ }
88
+
89
+ /** `el` matches the last compound; each earlier compound must match some
90
+ * ancestor above the previous match — the descendant combinator. */
91
+ export function matchesChain(el: ProxyElement, chain: CompoundSelector[]): boolean {
92
+ if (!matchCompound(el, chain[chain.length - 1])) return false;
93
+ let cur: ProxyNode | null = el._parent;
94
+ for (let i = chain.length - 2; i >= 0; i--) {
95
+ let found = false;
96
+ while (cur !== null) {
97
+ if (cur instanceof ProxyElement && matchCompound(cur, chain[i])) {
98
+ found = true;
99
+ cur = cur._parent;
100
+ break;
101
+ }
102
+ cur = cur._parent;
103
+ }
104
+ if (!found) return false;
105
+ }
106
+ return true;
107
+ }
108
+