@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.
- package/README.md +550 -0
- package/index.js +1 -0
- package/package.json +54 -0
- 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
|
+
──────────────────────────────────────────────────────────── */
|