@devchitchat/rdbljs 0.0.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.
Files changed (4) hide show
  1. package/README.md +550 -0
  2. package/index.js +1 -0
  3. package/package.json +54 -0
  4. package/src/rdbl.js +877 -0
package/src/rdbl.js ADDED
@@ -0,0 +1,877 @@
1
+ /**
2
+ * rdbljs v1 — single-file reference implementation
3
+ *
4
+ * Philosophy:
5
+ * - DOM-first (HTML is structure)
6
+ * - plain attributes (text/show/class/attr/model/each/key + native on*)
7
+ * - NO expressions in bindings
8
+ * - on* attributes are action-path only (Model A)
9
+ * - signals + computed + effects
10
+ * - keyed list diffing
11
+ * - DOM-scoped Context (no prop drilling)
12
+ * - clean disposal
13
+ *
14
+ * Evolutions included:
15
+ * 1) MutationObserver auto-bind (opt-in)
16
+ * 2) Microtask batching for effects
17
+ * 3) Dev warnings (missing paths/actions, invalid usage)
18
+ * 4) Scoped sub-bind: bind(el, subScope) and createScope(parent, patch)
19
+ */
20
+
21
+ // ─────────────────────────────────────────────────────────────
22
+ // Reactive core: signal / computed / effect / batch
23
+ // ─────────────────────────────────────────────────────────────
24
+ let CURRENT_EFFECT = null
25
+ let BATCH_DEPTH = 0
26
+ let FLUSH_SCHEDULED = false
27
+ const QUEUE = new Set()
28
+
29
+ function schedule(runner) {
30
+ QUEUE.add(runner)
31
+ if (BATCH_DEPTH > 0) return
32
+ if (FLUSH_SCHEDULED) return
33
+ FLUSH_SCHEDULED = true
34
+ queueMicrotask(flush)
35
+ }
36
+
37
+ function flush() {
38
+ FLUSH_SCHEDULED = false
39
+ while (QUEUE.size) {
40
+ const toRun = Array.from(QUEUE)
41
+ QUEUE.clear()
42
+ for (const fn of toRun) fn()
43
+ }
44
+ }
45
+
46
+ export function batch(fn) {
47
+ BATCH_DEPTH++
48
+ try { return fn() }
49
+ finally {
50
+ BATCH_DEPTH--
51
+ if (BATCH_DEPTH === 0) flush()
52
+ }
53
+ }
54
+
55
+ export function signal(initial) {
56
+ let value = initial
57
+ const subs = new Set()
58
+
59
+ function read() {
60
+ if (CURRENT_EFFECT) {
61
+ subs.add(CURRENT_EFFECT)
62
+ CURRENT_EFFECT._touched.add(subs)
63
+ }
64
+ return value
65
+ }
66
+
67
+ read.set = (next) => {
68
+ if (Object.is(value, next)) return
69
+ value = next
70
+ subs.forEach(schedule)
71
+ }
72
+
73
+ read.peek = () => value
74
+ return read
75
+ }
76
+
77
+ export function effect(fn) {
78
+ let cleanup
79
+ const runner = () => {
80
+ // unsubscribe from prior dependencies
81
+ runner._touched.forEach(s => s.delete(runner))
82
+ runner._touched.clear()
83
+ if (typeof cleanup === 'function') {
84
+ cleanup()
85
+ cleanup = undefined
86
+ }
87
+
88
+ const prev = CURRENT_EFFECT
89
+ CURRENT_EFFECT = runner
90
+ try {
91
+ const nextCleanup = fn()
92
+ cleanup = typeof nextCleanup === 'function' ? nextCleanup : undefined
93
+ } finally { CURRENT_EFFECT = prev }
94
+ }
95
+ runner._touched = new Set()
96
+ runner()
97
+ return () => {
98
+ runner._touched.forEach(s => s.delete(runner))
99
+ runner._touched.clear()
100
+ if (typeof cleanup === 'function') {
101
+ cleanup()
102
+ cleanup = undefined
103
+ }
104
+ }
105
+ }
106
+
107
+ export function computed(fn) {
108
+ let cached
109
+ let dirty = true
110
+ const subs = new Set()
111
+
112
+ const invalidator = () => {
113
+ dirty = true
114
+ subs.forEach(schedule)
115
+ }
116
+ invalidator._touched = new Set()
117
+
118
+ function read() {
119
+ if (CURRENT_EFFECT) {
120
+ subs.add(CURRENT_EFFECT)
121
+ CURRENT_EFFECT._touched.add(subs)
122
+ }
123
+ if (!dirty) return cached
124
+
125
+ // unsubscribe invalidator from old deps
126
+ invalidator._touched.forEach(s => s.delete(invalidator))
127
+ invalidator._touched.clear()
128
+
129
+ const prev = CURRENT_EFFECT
130
+ CURRENT_EFFECT = invalidator
131
+ try { cached = fn() }
132
+ finally { CURRENT_EFFECT = prev }
133
+
134
+ dirty = false
135
+ return cached
136
+ }
137
+
138
+ read.peek = () => (dirty ? fn() : cached)
139
+ return read
140
+ }
141
+
142
+ // ─────────────────────────────────────────────────────────────
143
+ // Context (DOM-scoped) — no prop drilling
144
+ // ─────────────────────────────────────────────────────────────
145
+ export class Context {
146
+ static #map = new WeakMap()
147
+
148
+ static provide(rootEl, ctx) {
149
+ if (!(rootEl instanceof Element)) throw new TypeError('Context.provide: rootEl must be an Element')
150
+ this.#map.set(rootEl, ctx)
151
+ return ctx
152
+ }
153
+
154
+ static read(fromEl) {
155
+ for (let cur = fromEl; cur; cur = cur.parentElement) {
156
+ const ctx = this.#map.get(cur)
157
+ if (ctx) return ctx
158
+ }
159
+ return null
160
+ }
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────
164
+ // Dev helpers
165
+ // ─────────────────────────────────────────────────────────────
166
+ function warn(dev, ...args) { if (dev) console.warn('[rdbljs]', ...args) }
167
+
168
+ function truncate(text, max = 180) {
169
+ const value = String(text ?? '')
170
+ return value.length <= max ? value : `${value.slice(0, max)}...`
171
+ }
172
+
173
+ function compactHtmlSnippet(el) {
174
+ if (!(el instanceof Element)) return ''
175
+ const source = el.outerHTML || `<${el.tagName.toLowerCase()}>`
176
+ return truncate(source.replace(/\s+/g, ' ').trim(), 180)
177
+ }
178
+
179
+ function safeJson(value) {
180
+ try {
181
+ const seen = new WeakSet()
182
+ return JSON.stringify(value, (key, current) => {
183
+ if (typeof current === 'function') return '[Function]'
184
+ if (current && typeof current === 'object') {
185
+ if (seen.has(current)) return '[Circular]'
186
+ seen.add(current)
187
+ }
188
+ return current
189
+ })
190
+ } catch {
191
+ return '[Unserializable]'
192
+ }
193
+ }
194
+
195
+ function bindingDebugContext(el) {
196
+ if (!(el instanceof Element)) return ''
197
+ const itemCtx = getItemContext(el)
198
+ const parts = []
199
+ const island = el.closest?.('[island]')?.getAttribute?.('island')
200
+ || itemCtx?.host?.closest?.('[island]')?.getAttribute?.('island')
201
+ || itemCtx?.host?.getAttribute?.('island')
202
+ if (island) parts.push(`island="${island}"`)
203
+ const node = compactHtmlSnippet(el)
204
+ if (node) parts.push(`node=${node}`)
205
+ if (itemCtx && Object.prototype.hasOwnProperty.call(itemCtx, 'item')) {
206
+ parts.push(`item=${safeJson(itemCtx.item)}`)
207
+ }
208
+ return parts.length ? ` (${parts.join(' ')})` : ''
209
+ }
210
+
211
+ function isSignal(v) {
212
+ return typeof v === 'function' && typeof v.set === 'function'
213
+ }
214
+
215
+ function isReadable(v) {
216
+ return typeof v === 'function' && (typeof v.set === 'function' || typeof v.peek === 'function')
217
+ }
218
+
219
+ function resolve(obj, path) {
220
+ const parts = String(path).split('.').map(s => s.trim()).filter(Boolean)
221
+ let cur = obj
222
+ for (const p of parts) cur = cur?.[p]
223
+ return cur
224
+ }
225
+
226
+ function readValue(scope, path) {
227
+ const v = resolve(scope, path)
228
+ return isReadable(v) ? v() : v
229
+ }
230
+
231
+ function readBoundValue(scope, path, el) {
232
+ return readValue(getBoundScope(el) ?? scope, path)
233
+ }
234
+
235
+ function setModelValue(el, v) {
236
+ if (el instanceof HTMLInputElement) {
237
+ if (el.type === 'checkbox') el.checked = !!v
238
+ else el.value = v ?? ''
239
+ return
240
+ }
241
+ if (el instanceof HTMLTextAreaElement) { el.value = v ?? ''; return }
242
+ if (el instanceof HTMLSelectElement) { el.value = v ?? ''; return }
243
+ }
244
+
245
+ function getModelValue(el) {
246
+ if (el instanceof HTMLInputElement) {
247
+ if (el.type === 'checkbox') return el.checked
248
+ return el.value
249
+ }
250
+ if (el instanceof HTMLTextAreaElement) return el.value
251
+ if (el instanceof HTMLSelectElement) return el.value
252
+ return undefined
253
+ }
254
+
255
+ // Item context (list rows) — uses WeakMap to avoid expando fields if desired
256
+ const ITEM_CTX = new WeakMap()
257
+ function setItemContext(node, ctx) { ITEM_CTX.set(node, ctx) }
258
+ export function getItemContext(fromEl) {
259
+ for (let cur = fromEl; cur; cur = cur.parentElement) {
260
+ const ctx = ITEM_CTX.get(cur)
261
+ if (ctx) return ctx
262
+ }
263
+ return null
264
+ }
265
+
266
+ const BOUND_SCOPE = new WeakMap()
267
+ function setBoundScope(node, scope) { BOUND_SCOPE.set(node, scope) }
268
+ function getBoundScope(fromEl) {
269
+ for (let cur = fromEl; cur; cur = cur.parentElement) {
270
+ const scope = BOUND_SCOPE.get(cur)
271
+ if (scope) return scope
272
+ }
273
+ return null
274
+ }
275
+
276
+ // Scoped sub-bind helper: create a scope that falls back to parent
277
+ export function createScope(parent, patch) {
278
+ return new Proxy(patch, {
279
+ get(target, prop) {
280
+ if (prop in target) return target[prop]
281
+ return parent[prop]
282
+ },
283
+ has(target, prop) {
284
+ return (prop in target) || (prop in parent)
285
+ }
286
+ })
287
+ }
288
+
289
+ // ─────────────────────────────────────────────────────────────
290
+ // Binder internals
291
+ // ─────────────────────────────────────────────────────────────
292
+ const DEFAULTS = {
293
+ dev: true,
294
+ autoBind: false, // MutationObserver auto-bind
295
+ observeRoot: false, // if true, observes root itself too (rarely needed)
296
+ ignoreSelector: '[data-no-bind]', // subtree opt-out marker
297
+ }
298
+
299
+ function shouldIgnore(el, ignoreSelector) {
300
+ if (!(el instanceof Element)) return true
301
+ if (el.closest && el.closest('template')) return true
302
+ return !!(ignoreSelector && el.closest && el.closest(ignoreSelector))
303
+ }
304
+
305
+ function isManagedByEach(el) {
306
+ const eachHost = el?.closest?.('[each]')
307
+ return !!(eachHost && !el.hasAttribute('each'))
308
+ }
309
+
310
+ function collectRootsForAutoBind(node, ignoreSelector) {
311
+ const roots = []
312
+ if (!(node instanceof Element)) return roots
313
+ if (!shouldIgnore(node, ignoreSelector)) roots.push(node)
314
+ node.querySelectorAll?.(':scope *')?.forEach?.(() => {}) // noop; avoid older engines? (safe)
315
+ return roots
316
+ }
317
+
318
+ // Return list of "binding elements" under root, including root
319
+ function allElements(root) {
320
+ const els = []
321
+ function walk(node) {
322
+ if (!(node instanceof Element)) return
323
+ els.push(node)
324
+ // enable nested islands.
325
+ if (node !== root && node.hasAttribute('island')) return
326
+ for (const child of Array.from(node.children || [])) {
327
+ walk(child)
328
+ }
329
+ }
330
+ walk(root)
331
+ return els
332
+ }
333
+
334
+ function parseAttrSpec(spec) {
335
+ // "name:path; name2:path2"
336
+ return String(spec)
337
+ .split(';')
338
+ .map(s => s.trim())
339
+ .filter(Boolean)
340
+ .map(pair => pair.split(':').map(x => x.trim()))
341
+ .filter(([a,b]) => a && b)
342
+ }
343
+
344
+ function isDialogElement(el) {
345
+ return (typeof HTMLDialogElement !== 'undefined' && el instanceof HTMLDialogElement)
346
+ || el?.tagName === 'DIALOG'
347
+ }
348
+
349
+ function setDialogShown(el, shown) {
350
+ if (shown) {
351
+ el.hidden = false
352
+ if (!el.hasAttribute('open')) {
353
+ if (typeof el.showModal === 'function') el.showModal()
354
+ else el.setAttribute('open', '')
355
+ }
356
+ return
357
+ }
358
+
359
+ if (el.hasAttribute('open')) {
360
+ if (typeof el.close === 'function') el.close()
361
+ else el.removeAttribute('open')
362
+ }
363
+ el.hidden = true
364
+ }
365
+
366
+ // ─────────────────────────────────────────────────────────────
367
+ // bind(root, scope, options)
368
+ // ─────────────────────────────────────────────────────────────
369
+ export function bind(root, scope, options = {}) {
370
+ if (!root || root.nodeType !== 1) throw new TypeError('bind: root must be an Element')
371
+ const opt = { ...DEFAULTS, ...options }
372
+ const disposers = []
373
+ const boundRoots = new WeakSet() // prevent double-binding in auto-bind mode
374
+
375
+ const getCtx = (el) => Context.read(el)
376
+
377
+ function bindRootOnce(r) {
378
+ if (!(r instanceof Element)) return
379
+ if (boundRoots.has(r)) return
380
+ if (shouldIgnore(r, opt.ignoreSelector)) return
381
+
382
+ boundRoots.add(r)
383
+
384
+ // Order matters slightly: lists create subtrees, but we keep it simple:
385
+ // - bindEach first so it renders children, then bind the rest for that root
386
+ disposers.push(bindEach(r, scope, opt, { getCtx, bindSubtree }))
387
+ disposers.push(bindText(r, scope, opt))
388
+ disposers.push(bindHtml(r, scope, opt))
389
+ disposers.push(bindShow(r, scope, opt))
390
+ disposers.push(bindClass(r, scope, opt))
391
+ disposers.push(bindAttr(r, scope, opt))
392
+ disposers.push(bindModel(r, scope, opt))
393
+ disposers.push(bindEvents(r, scope, opt, { getCtx }))
394
+ }
395
+
396
+ function bindSubtree(subRoot, subScope = scope) {
397
+ // Scoped sub-bind: same binder but with different scope
398
+ // Note: not wired into autoBind automatically unless you call it
399
+ return bind(subRoot, subScope, { ...opt, autoBind: false })
400
+ }
401
+
402
+ // Initial bind
403
+ bindRootOnce(root)
404
+
405
+ // MutationObserver auto-bind (opt-in)
406
+ let observerDispose = null
407
+ if (opt.autoBind) {
408
+ const obsRoot = opt.observeRoot ? root : root
409
+ const mo = new MutationObserver((mutations) => {
410
+ for (const m of mutations) {
411
+ for (const n of m.addedNodes) {
412
+ if (!(n instanceof Element)) continue
413
+ if (shouldIgnore(n, opt.ignoreSelector)) continue
414
+ // Bind the added subtree root once (binder scans its descendants)
415
+ bindRootOnce(n)
416
+ }
417
+ }
418
+ })
419
+ mo.observe(obsRoot, { childList: true, subtree: true })
420
+ observerDispose = () => mo.disconnect()
421
+ }
422
+
423
+ return {
424
+ bindSubtree,
425
+ dispose() {
426
+ if (observerDispose) observerDispose()
427
+ // dispose in reverse (best-effort)
428
+ for (let i = disposers.length - 1; i >= 0; i--) {
429
+ try { disposers[i]?.dispose?.() } catch {}
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ // ─────────────────────────────────────────────────────────────
436
+ // Individual directive binders
437
+ // ─────────────────────────────────────────────────────────────
438
+ function bindText(root, scope, opt) {
439
+ const stops = []
440
+ for (const el of allElements(root)) {
441
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
442
+ if (isManagedByEach(el)) continue
443
+ if (!el.hasAttribute('text')) continue
444
+ const path = el.getAttribute('text')
445
+
446
+ const stop = effect(() => {
447
+ const v = readValue(scope, path)
448
+ const value = readBoundValue(scope, path, el)
449
+ if (value === undefined) warn(opt.dev, `text="${path}" resolved to undefined${bindingDebugContext(el)}`)
450
+ el.textContent = value ?? ''
451
+ })
452
+ stops.push(stop)
453
+ }
454
+ return { dispose: () => stops.forEach(s => s()) }
455
+ }
456
+
457
+ function bindHtml(root, scope, opt) {
458
+ const stops = []
459
+ for (const el of allElements(root)) {
460
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
461
+ if (isManagedByEach(el)) continue
462
+ if (!el.hasAttribute('html')) continue
463
+ const path = el.getAttribute('html')
464
+
465
+ const stop = effect(() => {
466
+ const v = readBoundValue(scope, path, el)
467
+ el.innerHTML = v ?? ''
468
+ })
469
+ stops.push(stop)
470
+ }
471
+ return { dispose: () => stops.forEach(s => s()) }
472
+ }
473
+
474
+ function bindShow(root, scope, opt) {
475
+ const stops = []
476
+ for (const el of allElements(root)) {
477
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
478
+ if (isManagedByEach(el)) continue
479
+ if (!el.hasAttribute('show')) continue
480
+ const path = el.getAttribute('show')
481
+
482
+ const stop = effect(() => {
483
+ const v = !!readBoundValue(scope, path, el)
484
+ if (isDialogElement(el)) {
485
+ setDialogShown(el, v)
486
+ return
487
+ }
488
+ el.hidden = !v
489
+ })
490
+ stops.push(stop)
491
+ }
492
+ return { dispose: () => stops.forEach(s => s()) }
493
+ }
494
+
495
+ function bindClass(root, scope, opt) {
496
+ const stops = []
497
+ for (const el of allElements(root)) {
498
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
499
+ if (isManagedByEach(el)) continue
500
+ if (!el.hasAttribute('class')) continue
501
+
502
+ // NOTE: this collides with native class attr.
503
+ // If you want to keep native class= for static classnames,
504
+ // prefer `cls="path"` instead. For v1, we support `cls`.
505
+ // We'll honor `cls` and ignore `class` unless it's `cls`.
506
+ }
507
+ // Prefer `cls`:
508
+ for (const el of allElements(root)) {
509
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
510
+ if (isManagedByEach(el)) continue
511
+ if (!el.hasAttribute('cls')) continue
512
+ const path = el.getAttribute('cls')
513
+ const stop = effect(() => {
514
+ const v = readBoundValue(scope, path, el)
515
+ if (typeof v === 'string') {
516
+ el.className = v
517
+ } else if (v && typeof v === 'object') {
518
+ for (const [cls, on] of Object.entries(v)) el.classList.toggle(cls, !!on)
519
+ } else if (v == null) {
520
+ // no-op
521
+ } else {
522
+ warn(opt.dev, `cls="${path}" should be string or object`, el)
523
+ }
524
+ })
525
+ stops.push(stop)
526
+ }
527
+ return { dispose: () => stops.forEach(s => s()) }
528
+ }
529
+
530
+ function bindAttr(root, scope, opt) {
531
+ const stops = []
532
+ for (const el of allElements(root)) {
533
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
534
+ if (isManagedByEach(el)) continue
535
+ if (!el.hasAttribute('attr')) continue
536
+ const spec = el.getAttribute('attr')
537
+ const pairs = parseAttrSpec(spec)
538
+ const stop = effect(() => {
539
+ for (const [name, path] of pairs) {
540
+ const v = readBoundValue(scope, path, el)
541
+ if (v === false || v == null) el.removeAttribute(name)
542
+ else el.setAttribute(name, String(v))
543
+ }
544
+ })
545
+ stops.push(stop)
546
+ }
547
+ return { dispose: () => stops.forEach(s => s()) }
548
+ }
549
+
550
+ function bindModel(root, scope, opt) {
551
+ const stops = []
552
+ const offs = []
553
+
554
+ for (const el of allElements(root)) {
555
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
556
+ if (isManagedByEach(el)) continue
557
+ if (!el.hasAttribute('model')) continue
558
+
559
+ const path = el.getAttribute('model')
560
+ const sig = resolve(scope, path)
561
+
562
+ if (!isSignal(sig)) {
563
+ warn(opt.dev, `model="${path}" must resolve to a signal`, el)
564
+ continue
565
+ }
566
+
567
+ // UI -> state
568
+ const evt =
569
+ (el instanceof HTMLSelectElement) ? 'change'
570
+ : (el instanceof HTMLInputElement && el.type === 'checkbox') ? 'change'
571
+ : 'input'
572
+
573
+ const handler = () => sig.set(getModelValue(el))
574
+ el.addEventListener(evt, handler)
575
+ offs.push(() => el.removeEventListener(evt, handler))
576
+
577
+ // state -> UI
578
+ const stop = effect(() => {
579
+ const v = sig()
580
+ setModelValue(el, v)
581
+ })
582
+ stops.push(stop)
583
+ }
584
+
585
+ return {
586
+ dispose() {
587
+ stops.forEach(s => s())
588
+ offs.forEach(off => off())
589
+ }
590
+ }
591
+ }
592
+
593
+ function bindEvents(root, scope, opt, { getCtx }) {
594
+ const offs = []
595
+
596
+ // Walk all elements (including root). For each attribute starting with "on"
597
+ for (const el of allElements(root)) {
598
+ if (shouldIgnore(el, opt.ignoreSelector)) continue
599
+ if (isManagedByEach(el)) continue
600
+
601
+ for (const attr of Array.from(el.attributes)) {
602
+ if (!attr.name.startsWith('on')) continue
603
+
604
+ const eventName = attr.name.slice(2) // onclick -> click
605
+ const actionPath = attr.value.trim()
606
+
607
+ // Disable native inline handler evaluation
608
+ try { el[attr.name] = null } catch {}
609
+ el.removeAttribute(attr.name)
610
+
611
+ const handler = (event) => {
612
+ const fn = resolve(scope, actionPath)
613
+ if (typeof fn !== 'function') {
614
+ warn(opt.dev, `on${eventName}="${actionPath}" did not resolve to a function`, el)
615
+ return
616
+ }
617
+ const ctx = getCtx(el)
618
+ const res = fn.call(scope, event, el, ctx)
619
+ if (res === false) {
620
+ event.preventDefault()
621
+ event.stopPropagation()
622
+ }
623
+ }
624
+
625
+ el.addEventListener(eventName, handler)
626
+ offs.push(() => el.removeEventListener(eventName, handler))
627
+ }
628
+ }
629
+
630
+ return { dispose: () => offs.forEach(off => off()) }
631
+ }
632
+
633
+ // ─────────────────────────────────────────────────────────────
634
+ // each + key (keyed list diffing)
635
+ // Notes:
636
+ // - each="path" must resolve to array (signal or plain)
637
+ // - key="idPath" resolved on item (path-only, default: "id")
638
+ // - template required
639
+ // - item scope provides item props + $item/$index (via proxy)
640
+ // - bindSubtree is used to bind item content without rebinding the whole document
641
+ // ─────────────────────────────────────────────────────────────
642
+ function bindEach(root, scope, opt, { getCtx, bindSubtree }) {
643
+ const stops = []
644
+ const listDisposers = []
645
+
646
+ for (const host of allElements(root)) {
647
+ if (shouldIgnore(host, opt.ignoreSelector)) continue
648
+ if (!host.hasAttribute('each')) continue
649
+
650
+ const listPath = host.getAttribute('each')
651
+ const keyPath = host.getAttribute('key') || 'id'
652
+ const tpl = host.querySelector('template')
653
+ if (!tpl) {
654
+ warn(opt.dev, `each="${listPath}" requires a <template> child`, host)
655
+ continue
656
+ }
657
+
658
+ // Prepare host
659
+ const marker = document.createComment(`each:${listPath}`)
660
+ host.innerHTML = ''
661
+ host.append(marker, tpl)
662
+
663
+ let live = new Map() // key -> entry
664
+
665
+ function itemKey(item) {
666
+ const k = resolve(item, keyPath)
667
+ return k
668
+ }
669
+
670
+ function createEntry(item, index) {
671
+ let currentItem = item
672
+ const frag = tpl.content.cloneNode(true)
673
+
674
+ // Bind into a temporary container so bind() can query within it
675
+ const tmp = document.createElement('div')
676
+ tmp.appendChild(frag)
677
+
678
+ // Item scope: item props first, then parent scope.
679
+ // Also expose $item/$index for handlers that want it without expressions in HTML.
680
+ const itemScope = createScope(scope, {
681
+ $item: item,
682
+ $index: index,
683
+ })
684
+
685
+ // Provide direct property access to item fields:
686
+ const proxyScope = new Proxy(itemScope, {
687
+ get(target, prop) {
688
+ if (prop in target) return target[prop]
689
+ if (currentItem && typeof currentItem === 'object' && prop in currentItem) return currentItem[prop]
690
+ return scope[prop]
691
+ },
692
+ has(target, prop) {
693
+ return (prop in target) || (currentItem && typeof currentItem === 'object' && prop in currentItem) || (prop in scope)
694
+ }
695
+ })
696
+
697
+ // Collect nodes
698
+ const nodes = Array.from(tmp.childNodes)
699
+
700
+ function attachItemContextTree(node, ctx) {
701
+ if (!(node instanceof Element)) return
702
+ setItemContext(node, ctx)
703
+ for (const child of Array.from(node.children || [])) {
704
+ attachItemContextTree(child, ctx)
705
+ }
706
+ }
707
+
708
+ function attachBoundScopeTree(node, scopeForNode) {
709
+ if (!(node instanceof Element)) return
710
+ setBoundScope(node, scopeForNode)
711
+ for (const child of Array.from(node.children || [])) {
712
+ attachBoundScopeTree(child, scopeForNode)
713
+ }
714
+ }
715
+
716
+ // Attach item context before binding so first-run effects can report row context.
717
+ const initialCtx = { item, index, key: itemKey(item), host }
718
+ for (const n of nodes) {
719
+ attachItemContextTree(n, initialCtx)
720
+ attachBoundScopeTree(n, proxyScope)
721
+ }
722
+
723
+ // Bind the item subtree (no autoBind here)
724
+ const binding = bindSubtree(tmp, proxyScope)
725
+
726
+ return {
727
+ nodes,
728
+ binding,
729
+ item,
730
+ index,
731
+ setItem(nextItem, nextIndex, key) {
732
+ currentItem = nextItem
733
+ this.item = nextItem
734
+ this.index = nextIndex
735
+ for (const n of this.nodes) {
736
+ if (n instanceof Element) setItemContext(n, { item: nextItem, index: nextIndex, key, host })
737
+ }
738
+ }
739
+ }
740
+ }
741
+
742
+ const stop = effect(() => {
743
+ const items = readValue(scope, listPath) || []
744
+ if (!Array.isArray(items)) {
745
+ warn(opt.dev, `each="${listPath}" did not resolve to an array`, host)
746
+ return
747
+ }
748
+
749
+ const next = new Map()
750
+
751
+ // Create/reuse
752
+ for (let i = 0; i < items.length; i++) {
753
+ const item = items[i]
754
+ const k = itemKey(item)
755
+ if (k == null) {
756
+ warn(opt.dev, `each="${listPath}" item missing key "${keyPath}"`, item, host)
757
+ continue
758
+ }
759
+
760
+ let entry = live.get(k)
761
+ if (!entry) {
762
+ entry = createEntry(item, i)
763
+ } else {
764
+ // If keyed item identity changed, recreate subtree bindings so
765
+ // non-reactive plain item fields are read from the new object.
766
+ if (entry.item !== item) {
767
+ try { entry.binding?.dispose?.() } catch {}
768
+ for (const n of entry.nodes) n.remove()
769
+ entry = createEntry(item, i)
770
+ } else {
771
+ entry.setItem(item, i, k)
772
+ }
773
+ }
774
+ next.set(k, entry)
775
+ }
776
+
777
+ // Reorder DOM
778
+ const frag = document.createDocumentFragment()
779
+ for (const entry of next.values()) {
780
+ for (const n of entry.nodes) frag.appendChild(n)
781
+ }
782
+
783
+ // Clear rendered nodes between marker and template
784
+ while (marker.nextSibling && marker.nextSibling !== tpl) marker.nextSibling.remove()
785
+
786
+ host.insertBefore(frag, tpl)
787
+
788
+ // Dispose removed
789
+ for (const [k, entry] of live.entries()) {
790
+ if (!next.has(k)) {
791
+ try { entry.binding?.dispose?.() } catch {}
792
+ for (const n of entry.nodes) n.remove()
793
+ }
794
+ }
795
+
796
+ live = next
797
+ })
798
+
799
+ stops.push(stop)
800
+ listDisposers.push(() => {
801
+ stop()
802
+ for (const entry of live.values()) {
803
+ try { entry.binding?.dispose?.() } catch {}
804
+ for (const n of entry.nodes) n.remove()
805
+ }
806
+ live.clear()
807
+ })
808
+ }
809
+
810
+ return {
811
+ dispose() {
812
+ stops.forEach(s => s())
813
+ listDisposers.forEach(d => d())
814
+ }
815
+ }
816
+ }
817
+
818
+ async function init(window, roots) {
819
+ if (!roots) {
820
+ roots = [...document.querySelectorAll('[island]')]
821
+ }
822
+
823
+ const instances = {}
824
+ let i = 0
825
+
826
+ for await (const root of roots) {
827
+ const key = root.getAttribute('island')
828
+ try {
829
+ const scopeFactory = (await import(key)).default
830
+ const scope = scopeFactory(root, window)
831
+ instances[`${key}:${i++}`] = bind(root, scope, { dev: true })
832
+ } catch (err) {
833
+ console.error(`Failed to load island "${key}":`, err)
834
+ }
835
+ }
836
+ return instances
837
+ }
838
+
839
+ /* ─────────────────────────────────────────────────────────────
840
+ Usage sketch:
841
+
842
+ import { bind, signal, computed, Context, getItemContext } from './rdbl.js'
843
+
844
+ const root = document.querySelector('#app')
845
+
846
+ Context.provide(root, {
847
+ router: { go: (path) => (location.href = path) },
848
+ log: console.log
849
+ })
850
+
851
+ const state = {
852
+ count: signal(0),
853
+ double: null,
854
+ todos: signal([{ id: 1, text: 'write it', done: false }]),
855
+ newTodo: signal(''),
856
+
857
+ inc(e, el, ctx) { this.count.set(this.count() + 1) },
858
+ addTodo(e, el, ctx) {
859
+ const text = this.newTodo().trim()
860
+ if (!text) return
861
+ this.todos.set([...this.todos(), { id: Date.now(), text, done: false }])
862
+ this.newTodo.set('')
863
+ },
864
+ toggleTodo(e, el, ctx) {
865
+ const info = getItemContext(el)
866
+ if (!info) return
867
+ const id = info.item.id
868
+ this.todos.set(this.todos().map(t => t.id === id ? { ...t, done: !t.done } : t))
869
+ }
870
+ }
871
+
872
+ state.double = computed(() => state.count() * 2)
873
+
874
+ const app = bind(root, state, { dev: true, autoBind: true })
875
+
876
+ // later: app.dispose()
877
+ ──────────────────────────────────────────────────────────── */