brustjs 0.1.21-alpha → 0.1.22-alpha

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,324 @@
1
+ // Directive runtime — react-free, dom-only. Scans the DOM for x-* directives and
2
+ // binds them to per-element component instances via brustjs/store's `effect`.
3
+ import { effect, isComputed, isSignal } from '../store/index.ts'
4
+
5
+ export type Instance = Record<string, unknown>
6
+ export type Behavior = (ctx: { el: HTMLElement; props: unknown }) => Instance
7
+
8
+ interface Mounted {
9
+ disposers: Array<() => void>
10
+ }
11
+
12
+ const registry = new Map<string, Behavior>()
13
+ const mounted = new WeakMap<HTMLElement, Mounted>()
14
+ const loading = new Map<string, Promise<unknown>>()
15
+ let started = false
16
+
17
+ /** Per-component behavior chunk URL. Each native interactive component is built to
18
+ * its OWN `<name>.directive.js` chunk (served from the islands static route) and
19
+ * loaded ON DEMAND — only when an `x-data="<name>"` for it actually appears (initial
20
+ * render OR after an SPA-nav swap). The chunk self-registers via the global below. */
21
+ const CHUNK_BASE = '/_brust/islands/'
22
+
23
+ /** Register a component behavior under `name`. Called by `<name>.directive.js` chunks
24
+ * via the global handle below (they do NOT import this module — keeps each chunk to
25
+ * just its behavior, with the runtime shared as the single `_directives.js` copy). */
26
+ export function register(name: string, behavior: Behavior): void {
27
+ registry.set(name, behavior)
28
+ }
29
+ // Expose `register` on a global so dynamically-imported behavior chunks self-register
30
+ // into THIS runtime's registry without importing/duplicating the runtime. Symbol.for
31
+ // → shared across chunks (same rationale as the store's brands/reactive ctx).
32
+ ;(globalThis as { [k: symbol]: unknown })[Symbol.for('brust.directive.register')] = register
33
+
34
+ /** Scan `root` (default: document) for [x-data], mount each, and (once) attach a
35
+ * MutationObserver for dynamic mount/dispose. Idempotent. NOTE: `root` scopes the
36
+ * INITIAL scan only; the observer always watches the global `document.body` (one
37
+ * observer per document handles every later mount/dispose, incl. SPA-nav swaps). */
38
+ export function start(root?: ParentNode): void {
39
+ const scope: ParentNode | undefined =
40
+ root ?? (typeof document !== 'undefined' ? document : undefined)
41
+ if (!scope) return
42
+ const run = () => {
43
+ scanAndMount(scope)
44
+ if (!started) {
45
+ started = true
46
+ observe()
47
+ }
48
+ }
49
+ if (typeof document !== 'undefined' && document.readyState === 'loading') {
50
+ document.addEventListener('DOMContentLoaded', run, { once: true })
51
+ } else {
52
+ run()
53
+ }
54
+ }
55
+
56
+ function scanAndMount(scope: ParentNode): void {
57
+ if (scope instanceof HTMLElement && scope.hasAttribute('x-data')) mountElement(scope)
58
+ for (const el of Array.from(scope.querySelectorAll<HTMLElement>('[x-data]'))) {
59
+ mountElement(el)
60
+ }
61
+ }
62
+
63
+ function mountElement(el: HTMLElement): void {
64
+ if (mounted.has(el)) return
65
+ const name = el.getAttribute('x-data') ?? ''
66
+ const behavior = registry.get(name)
67
+ if (!behavior) {
68
+ // Behavior chunk not loaded yet → fetch it on demand, then mount this name.
69
+ loadBehavior(name)
70
+ return
71
+ }
72
+ let props: unknown = {}
73
+ const rawProps = el.getAttribute('x-props')
74
+ if (rawProps) {
75
+ try {
76
+ props = JSON.parse(rawProps)
77
+ } catch {
78
+ console.warn(`[brust] x-props on "${name}" is not valid JSON`)
79
+ }
80
+ }
81
+ const instance = behavior({ el, props })
82
+ const m: Mounted = { disposers: [] }
83
+ mounted.set(el, m)
84
+ bindTree(el, instance, m.disposers)
85
+ if (typeof instance.init === 'function') {
86
+ try {
87
+ Promise.resolve((instance.init as () => unknown)()).catch((e) =>
88
+ console.error('[brust] x-data init() failed:', e),
89
+ )
90
+ } catch (e) {
91
+ console.error('[brust] x-data init() threw:', e)
92
+ }
93
+ }
94
+ }
95
+
96
+ // Dynamically import a component's behavior chunk (once), then mount every pending
97
+ // element for that name. The chunk self-registers via the global register handle.
98
+ function loadBehavior(name: string): void {
99
+ if (registry.has(name) || loading.has(name)) return
100
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
101
+ console.warn(`[brust] unsafe x-data component name "${name}" — not loaded`)
102
+ return
103
+ }
104
+ // Promise.resolve().then(...) so a synchronous import() throw (e.g. happy-dom in
105
+ // unit tests, or a bad specifier) becomes a rejection the .catch() handles.
106
+ const p = Promise.resolve()
107
+ .then(() => import(/* @vite-ignore */ `${CHUNK_BASE}${name}.directive.js`))
108
+ .then(() => {
109
+ if (!registry.has(name)) {
110
+ console.warn(`[brust] "${name}.directive.js" loaded but did not register "${name}"`)
111
+ return
112
+ }
113
+ // Mount every element waiting on this name (initial + swapped-in).
114
+ if (typeof document !== 'undefined') {
115
+ for (const el of Array.from(document.querySelectorAll<HTMLElement>(`[x-data="${name}"]`))) {
116
+ mountElement(el)
117
+ }
118
+ }
119
+ })
120
+ .catch((e) => console.error(`[brust] failed to load directive component "${name}":`, e))
121
+ loading.set(name, p)
122
+ }
123
+
124
+ // Bind this element's directives, then recurse — but never descend into a nested
125
+ // [x-data] (it owns its own subtree and is mounted independently).
126
+ function bindTree(el: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
127
+ if (el.hasAttribute('x-for')) {
128
+ bindFor(el, instance, disposers)
129
+ return
130
+ }
131
+ bindAttrs(el, instance, disposers)
132
+ for (const child of Array.from(el.children)) {
133
+ if (!(child instanceof HTMLElement)) continue
134
+ if (child.hasAttribute('x-data')) continue
135
+ bindTree(child, instance, disposers)
136
+ }
137
+ }
138
+
139
+ const FOR_RE = /^\s*(\w+)\s+in\s+([\w.]+)\s*$/
140
+
141
+ // `x-for="item in member"` — the element is the template. Replace it with a comment
142
+ // anchor; on each change of `member`, clear previous clones and render one per item,
143
+ // binding each clone with a child scope { [item]: value } prototype-linked to the
144
+ // instance (so instance members + methods stay visible). v1 = full re-render.
145
+ function bindFor(tplEl: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
146
+ const raw = tplEl.getAttribute('x-for') ?? ''
147
+ const m = FOR_RE.exec(raw)
148
+ if (!m) {
149
+ console.warn(`[brust] malformed x-for expression: "${raw}"`)
150
+ return
151
+ }
152
+ const itemName = m[1] as string
153
+ const listPath = m[2] as string
154
+ const parent = tplEl.parentNode
155
+ if (!parent) return
156
+ const anchor = tplEl.ownerDocument.createComment(`x-for:${itemName}`)
157
+ parent.insertBefore(anchor, tplEl)
158
+ tplEl.removeAttribute('x-for')
159
+ const template = tplEl.cloneNode(true) as HTMLElement
160
+ tplEl.remove()
161
+
162
+ const rendered: HTMLElement[] = []
163
+ const childDisposers: Array<() => void> = []
164
+
165
+ const clear = () => {
166
+ for (const d of childDisposers.splice(0)) {
167
+ try {
168
+ d()
169
+ } catch {
170
+ /* keep clearing */
171
+ }
172
+ }
173
+ for (const node of rendered.splice(0)) node.remove()
174
+ }
175
+
176
+ disposers.push(
177
+ effect(() => {
178
+ clear()
179
+ const list = read(instance, listPath)
180
+ if (!Array.isArray(list)) return
181
+ for (const item of list) {
182
+ const clone = template.cloneNode(true) as HTMLElement
183
+ const childScope: Instance = Object.create(instance)
184
+ childScope[itemName] = item
185
+ bindTree(clone, childScope, childDisposers)
186
+ parent.insertBefore(clone, anchor) // before anchor → preserves order
187
+ rendered.push(clone)
188
+ }
189
+ }),
190
+ )
191
+ disposers.push(clear)
192
+ }
193
+
194
+ function bindAttrs(el: HTMLElement, scope: Instance, disposers: Array<() => void>): void {
195
+ for (const attr of Array.from(el.attributes)) {
196
+ const name = attr.name
197
+ const value = attr.value
198
+ if (name === 'x-data' || name === 'x-props') continue
199
+ if (name === 'x-text') {
200
+ disposers.push(
201
+ effect(() => {
202
+ const v = read(scope, value)
203
+ el.textContent = v == null ? '' : String(v)
204
+ }),
205
+ )
206
+ continue
207
+ }
208
+ if (name === 'x-show') {
209
+ disposers.push(
210
+ effect(() => {
211
+ el.style.display = read(scope, value) ? '' : 'none'
212
+ }),
213
+ )
214
+ continue
215
+ }
216
+ if (name.startsWith('x-bind-')) {
217
+ const target = name.slice('x-bind-'.length)
218
+ disposers.push(
219
+ effect(() => {
220
+ setBound(el, target, read(scope, value))
221
+ }),
222
+ )
223
+ continue
224
+ }
225
+ if (name.startsWith('x-on-')) {
226
+ const eventName = name.slice('x-on-'.length)
227
+ const handler = (e: Event) => callMethod(scope, value, e)
228
+ el.addEventListener(eventName, handler)
229
+ disposers.push(() => el.removeEventListener(eventName, handler))
230
+ }
231
+ }
232
+ }
233
+
234
+ function observe(): void {
235
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
236
+ if (!document.body) return // nothing to observe yet (called pre-<body>); start() re-runs on DOMContentLoaded
237
+ const obs = new MutationObserver((records) => {
238
+ for (const rec of records) {
239
+ for (const node of Array.from(rec.removedNodes)) {
240
+ if (node instanceof HTMLElement) disposeTree(node)
241
+ }
242
+ for (const node of Array.from(rec.addedNodes)) {
243
+ if (node instanceof HTMLElement) scanAndMount(node)
244
+ }
245
+ }
246
+ })
247
+ obs.observe(document.body, { childList: true, subtree: true })
248
+ }
249
+
250
+ function disposeTree(node: HTMLElement): void {
251
+ if (mounted.has(node)) disposeElement(node)
252
+ for (const el of Array.from(node.querySelectorAll<HTMLElement>('[x-data]'))) {
253
+ disposeElement(el)
254
+ }
255
+ }
256
+
257
+ function disposeElement(el: HTMLElement): void {
258
+ const m = mounted.get(el)
259
+ if (!m) return
260
+ for (const d of m.disposers.splice(0)) {
261
+ try {
262
+ d()
263
+ } catch {
264
+ // disposer must not break sibling disposal
265
+ }
266
+ }
267
+ mounted.delete(el)
268
+ }
269
+
270
+ const BOOL_PROPS = new Set(['disabled', 'checked', 'hidden', 'readonly', 'required', 'selected'])
271
+
272
+ /** Apply a bound value to a DOM attr/property. class → className; boolean props →
273
+ * property (when present) + attribute presence; value → property; else attribute. */
274
+ export function setBound(el: HTMLElement, attr: string, value: unknown): void {
275
+ if (attr === 'class') {
276
+ el.className = value == null ? '' : String(value)
277
+ return
278
+ }
279
+ if (attr === 'value') {
280
+ ;(el as unknown as { value: unknown }).value = value == null ? '' : value
281
+ return
282
+ }
283
+ if (BOOL_PROPS.has(attr)) {
284
+ const on = Boolean(value)
285
+ if (attr in el) (el as unknown as Record<string, unknown>)[attr] = on
286
+ if (on) el.setAttribute(attr, '')
287
+ else el.removeAttribute(attr)
288
+ return
289
+ }
290
+ if (value == null || value === false) el.removeAttribute(attr)
291
+ else el.setAttribute(attr, String(value))
292
+ }
293
+
294
+ // --- reactive read helpers (used by later tasks) -------------------------------
295
+
296
+ /** Walk a dotted member path against `scope`; at the LEAF, call signals/computeds/
297
+ * functions to obtain the reactive value (this read is what `effect` tracks). */
298
+ export function read(scope: Instance, path: string): unknown {
299
+ let cur: unknown = scope
300
+ for (const part of path.split('.')) {
301
+ if (cur == null) return undefined
302
+ cur = (cur as Record<string, unknown>)[part]
303
+ }
304
+ if (isSignal(cur) || isComputed(cur)) return (cur as () => unknown)()
305
+ if (typeof cur === 'function') return (cur as () => unknown)()
306
+ return cur
307
+ }
308
+
309
+ /** Resolve a dotted path WITHOUT calling the leaf (for x-on handlers). */
310
+ export function resolveRaw(scope: Instance, path: string): unknown {
311
+ let cur: unknown = scope
312
+ for (const part of path.split('.')) {
313
+ if (cur == null) return undefined
314
+ cur = (cur as Record<string, unknown>)[part]
315
+ }
316
+ return cur
317
+ }
318
+
319
+ /** Resolve `path` on `scope` and, if a function, call it with the event. */
320
+ export function callMethod(scope: Instance, path: string, event: Event): void {
321
+ const fn = resolveRaw(scope, path)
322
+ if (typeof fn === 'function') (fn as (e: Event) => unknown)(event)
323
+ else console.warn(`[brust] x-on target "${path}" is not a function`)
324
+ }
@@ -35,38 +35,56 @@ interface Consumer {
35
35
  running: boolean
36
36
  }
37
37
 
38
- let activeConsumer: Consumer | null = null
39
- let batchDepth = 0
40
- const pendingNotify = new Set<Consumer>()
38
+ // The dependency-tracking state (the "currently running consumer", the batch
39
+ // depth, the pending-notify queue) MUST be shared across chunks, for the SAME
40
+ // reason the brands use Symbol.for: every island / the directive runtime is a
41
+ // SEPARATE Bun.build that inlines its own copy of THIS module. If `activeConsumer`
42
+ // were a module-local `let`, each chunk would have its own — so an `effect` in
43
+ // chunk B reading a `signal` created in chunk A would register against chunk A's
44
+ // (always-null) activeConsumer and never subscribe. Concretely: a native directive
45
+ // button's effect (its own chunk) would never re-run when a React island (another
46
+ // chunk) mutated the shared store, even though the store value changed. Holding the
47
+ // context on `globalThis` under a Symbol.for key makes all chunks share ONE tracker.
48
+ interface ReactiveCtx {
49
+ activeConsumer: Consumer | null
50
+ batchDepth: number
51
+ pendingNotify: Set<Consumer>
52
+ }
53
+ const CTX_KEY = Symbol.for('brust.reactive.ctx')
54
+ const ctxHolder = globalThis as { [CTX_KEY]?: ReactiveCtx }
55
+ if (!ctxHolder[CTX_KEY]) {
56
+ ctxHolder[CTX_KEY] = { activeConsumer: null, batchDepth: 0, pendingNotify: new Set<Consumer>() }
57
+ }
58
+ const ctx: ReactiveCtx = ctxHolder[CTX_KEY]
41
59
 
42
60
  function track(subscribers: Set<Consumer>): void {
43
- if (activeConsumer) {
44
- subscribers.add(activeConsumer)
45
- activeConsumer.deps.add(subscribers)
61
+ if (ctx.activeConsumer) {
62
+ subscribers.add(ctx.activeConsumer)
63
+ ctx.activeConsumer.deps.add(subscribers)
46
64
  }
47
65
  }
48
66
 
49
67
  function notify(subscribers: Set<Consumer>): void {
50
68
  // Snapshot — a consumer re-running mutates the set.
51
69
  for (const c of [...subscribers]) {
52
- if (batchDepth > 0) pendingNotify.add(c)
70
+ if (ctx.batchDepth > 0) ctx.pendingNotify.add(c)
53
71
  else c.run()
54
72
  }
55
73
  }
56
74
 
57
75
  function flush(): void {
58
- const queued = [...pendingNotify]
59
- pendingNotify.clear()
76
+ const queued = [...ctx.pendingNotify]
77
+ ctx.pendingNotify.clear()
60
78
  for (const c of queued) c.run()
61
79
  }
62
80
 
63
81
  export function batch(fn: () => void): void {
64
- batchDepth++
82
+ ctx.batchDepth++
65
83
  try {
66
84
  fn()
67
85
  } finally {
68
- batchDepth--
69
- if (batchDepth === 0) flush()
86
+ ctx.batchDepth--
87
+ if (ctx.batchDepth === 0) flush()
70
88
  }
71
89
  }
72
90
 
@@ -114,13 +132,13 @@ export function computed<T>(fn: () => T): Computed<T> {
114
132
  track(subscribers)
115
133
  if (dirty) {
116
134
  clearDeps(self)
117
- const prev = activeConsumer
118
- activeConsumer = self
135
+ const prev = ctx.activeConsumer
136
+ ctx.activeConsumer = self
119
137
  try {
120
138
  cached = fn()
121
139
  dirty = false
122
140
  } finally {
123
- activeConsumer = prev
141
+ ctx.activeConsumer = prev
124
142
  }
125
143
  }
126
144
  return cached
@@ -137,12 +155,12 @@ export function effect(fn: () => void): () => void {
137
155
  if (self.running) return
138
156
  self.running = true
139
157
  clearDeps(self)
140
- const prev = activeConsumer
141
- activeConsumer = self
158
+ const prev = ctx.activeConsumer
159
+ ctx.activeConsumer = self
142
160
  try {
143
161
  fn()
144
162
  } finally {
145
- activeConsumer = prev
163
+ ctx.activeConsumer = prev
146
164
  self.running = false
147
165
  }
148
166
  },