brustjs 0.1.63-alpha → 0.1.65-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.
@@ -12,22 +12,25 @@ export type Instance = Record<string, unknown>
12
12
  * for side-effects on signal change (sync localStorage, the DOM
13
13
  * outside the component, timers). Returns the disposer too.
14
14
  * - `onCleanup(fn)` — register a one-shot teardown for unmount (e.g. removeEventListener). */
15
- export interface BehaviorCtx {
16
- el: HTMLElement
15
+ export interface BehaviorCtx<Host extends Element = HTMLElement> {
16
+ el: Host
17
17
  props: unknown
18
18
  // biome-ignore lint/suspicious/noConfusingVoidType: React useEffect return shape (`void | Destructor`) — see store `effect`.
19
19
  effect: (fn: () => void | (() => void)) => () => void
20
20
  onCleanup: (fn: () => void) => void
21
21
  }
22
- export type Behavior = (ctx: BehaviorCtx) => Instance
22
+ export type Behavior<Host extends Element = HTMLElement> = (ctx: BehaviorCtx<Host>) => Instance
23
23
 
24
24
  interface Mounted {
25
25
  disposers: Array<() => void>
26
26
  }
27
27
 
28
- const registry = new Map<string, Behavior>()
29
- const mounted = new WeakMap<HTMLElement, Mounted>()
28
+ type RegisteredBehavior = Behavior<Element>
29
+
30
+ const registry = new Map<string, RegisteredBehavior>()
31
+ const mounted = new WeakMap<Element, Mounted>()
30
32
  const loading = new Map<string, Promise<unknown>>()
33
+ const pending = new Map<string, Set<Element>>()
31
34
  let started = false
32
35
 
33
36
  /** Per-component behavior chunk URL. Each native interactive component is built to
@@ -39,8 +42,15 @@ const CHUNK_BASE = '/_brust/islands/'
39
42
  /** Register a component behavior under `name`. Called by `<name>.directive.js` chunks
40
43
  * via the global handle below (they do NOT import this module — keeps each chunk to
41
44
  * just its behavior, with the runtime shared as the single `_directives.js` copy). */
42
- export function register(name: string, behavior: Behavior): void {
43
- registry.set(name, behavior)
45
+ export function register<Host extends Element = HTMLElement>(
46
+ name: string,
47
+ behavior: Behavior<Host>,
48
+ ): void {
49
+ registry.set(name, behavior as unknown as RegisteredBehavior)
50
+ const hosts = pending.get(name)
51
+ if (!hosts) return
52
+ pending.delete(name)
53
+ for (const el of hosts) mountElement(el)
44
54
  }
45
55
  // Expose `register` on a global so dynamically-imported behavior chunks self-register
46
56
  // into THIS runtime's registry without importing/duplicating the runtime. Symbol.for
@@ -72,8 +82,8 @@ export function start(root?: ParentNode): void {
72
82
  }
73
83
 
74
84
  function scanAndMount(scope: ParentNode): void {
75
- if (scope instanceof HTMLElement && scope.hasAttribute('x-data')) mountElement(scope)
76
- for (const el of Array.from(scope.querySelectorAll<HTMLElement>('[x-data]'))) {
85
+ if (scope instanceof Element && scope.hasAttribute('x-data')) mountElement(scope)
86
+ for (const el of Array.from(scope.querySelectorAll<Element>('[x-data]'))) {
77
87
  mountElement(el)
78
88
  }
79
89
  // R10 — OPEN shadow roots host their own component trees: scan each the same
@@ -81,8 +91,8 @@ function scanAndMount(scope: ParentNode): void {
81
91
  // per root, since neither the body observer nor an outer root's observer sees
82
92
  // mutations inside an inner shadow tree. Closed roots expose `shadowRoot ===
83
93
  // null` and are unreachable by design. The walk-all is per added subtree only.
84
- if (scope instanceof HTMLElement && scope.shadowRoot) scanShadowRoot(scope.shadowRoot)
85
- for (const el of Array.from(scope.querySelectorAll<HTMLElement>('*'))) {
94
+ if (scope instanceof Element && scope.shadowRoot) scanShadowRoot(scope.shadowRoot)
95
+ for (const el of Array.from(scope.querySelectorAll<Element>('*'))) {
86
96
  if (el.shadowRoot) scanShadowRoot(el.shadowRoot)
87
97
  }
88
98
  }
@@ -92,7 +102,7 @@ function scanShadowRoot(root: ShadowRoot): void {
92
102
  observeRoot(root)
93
103
  }
94
104
 
95
- function mountElement(el: HTMLElement): void {
105
+ function mountElement(el: Element): void {
96
106
  if (mounted.has(el)) return
97
107
  // Removed mid-scan (e.g. an initial-falsy x-if subtree pruned while scanAndMount's
98
108
  // snapshot loop was still iterating): mounting a detached element would leak its
@@ -101,6 +111,12 @@ function mountElement(el: HTMLElement): void {
101
111
  const name = el.getAttribute('x-data') ?? ''
102
112
  const behavior = registry.get(name)
103
113
  if (!behavior) {
114
+ let hosts = pending.get(name)
115
+ if (!hosts) {
116
+ hosts = new Set()
117
+ pending.set(name, hosts)
118
+ }
119
+ hosts.add(el)
104
120
  // Behavior chunk not loaded yet → fetch it on demand, then mount this name.
105
121
  loadBehavior(name)
106
122
  return
@@ -124,12 +140,12 @@ function mountElement(el: HTMLElement): void {
124
140
  mounted.set(el, m)
125
141
  // ctxEffect/onCleanup typed via BehaviorCtx so the `void | Destructor` shape is
126
142
  // declared in one place (the interface) — no inline void-union to suppress here.
127
- const ctxEffect: BehaviorCtx['effect'] = (fn) => {
143
+ const ctxEffect: BehaviorCtx<Element>['effect'] = (fn) => {
128
144
  const dispose = effect(fn)
129
145
  m.disposers.push(dispose)
130
146
  return dispose
131
147
  }
132
- const onCleanup: BehaviorCtx['onCleanup'] = (fn) => {
148
+ const onCleanup: BehaviorCtx<Element>['onCleanup'] = (fn) => {
133
149
  m.disposers.push(fn)
134
150
  }
135
151
  const instance = behavior({ el, props, effect: ctxEffect, onCleanup })
@@ -150,6 +166,7 @@ function mountElement(el: HTMLElement): void {
150
166
  function loadBehavior(name: string): void {
151
167
  if (registry.has(name) || loading.has(name)) return
152
168
  if (!/^[A-Za-z0-9_-]+$/.test(name)) {
169
+ pending.delete(name)
153
170
  console.warn(`[brust] unsafe x-data component name "${name}" — not loaded`)
154
171
  return
155
172
  }
@@ -159,17 +176,16 @@ function loadBehavior(name: string): void {
159
176
  .then(() => import(/* @vite-ignore */ `${CHUNK_BASE}${name}.directive.js`))
160
177
  .then(() => {
161
178
  if (!registry.has(name)) {
179
+ loading.delete(name)
180
+ pending.delete(name)
162
181
  console.warn(`[brust] "${name}.directive.js" loaded but did not register "${name}"`)
163
- return
164
- }
165
- // Mount every element waiting on this name (initial + swapped-in).
166
- if (typeof document !== 'undefined') {
167
- for (const el of Array.from(document.querySelectorAll<HTMLElement>(`[x-data="${name}"]`))) {
168
- mountElement(el)
169
- }
170
182
  }
171
183
  })
172
- .catch((e) => console.error(`[brust] failed to load directive component "${name}":`, e))
184
+ .catch((e) => {
185
+ loading.delete(name)
186
+ pending.delete(name)
187
+ console.error(`[brust] failed to load directive component "${name}":`, e)
188
+ })
173
189
  loading.set(name, p)
174
190
  }
175
191
 
@@ -180,7 +196,7 @@ function loadBehavior(name: string): void {
180
196
  // independently via scanAndMount's shadow-root scan (R10), never inheriting the
181
197
  // enclosing instance's scope. (The `el.children` walk below naturally excludes
182
198
  // shadow content; this is by design, not an accident.)
183
- function bindTree(el: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
199
+ function bindTree(el: Element, instance: Instance, disposers: Array<() => void>): void {
184
200
  // Coexistence check MUST precede the x-for early-exit, else x-for preempts and the
185
201
  // warn never fires. Strip x-if so x-for's template clones don't carry it either.
186
202
  if (el.hasAttribute('x-if') && el.hasAttribute('x-for')) {
@@ -197,7 +213,7 @@ function bindTree(el: HTMLElement, instance: Instance, disposers: Array<() => vo
197
213
  }
198
214
  bindAttrs(el, instance, disposers)
199
215
  for (const child of Array.from(el.children)) {
200
- if (!(child instanceof HTMLElement)) continue
216
+ if (!(child instanceof Element)) continue
201
217
  if (child.hasAttribute('x-data')) continue
202
218
  bindTree(child, instance, disposers)
203
219
  }
@@ -243,7 +259,7 @@ export function parseFor(raw: string): ForExpr | null {
243
259
  }
244
260
 
245
261
  interface ForEntry {
246
- node: HTMLElement
262
+ node: Element
247
263
  itemSig: Signal<unknown>
248
264
  idxSig?: Signal<number>
249
265
  disposers: Array<() => void>
@@ -259,7 +275,7 @@ const forMountGuard = new WeakMap<Node, Set<string>>()
259
275
  // (legacy v1, now with optional plain index). With `by <keypath>...` it is an opt-in
260
276
  // keyed reconcile that reuses DOM nodes (focus/scroll survive) and is reactive
261
277
  // per-item via a per-clone `signal(item)` resolved through `read`'s unwrap-each-hop.
262
- function bindFor(tplEl: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
278
+ function bindFor(tplEl: Element, instance: Instance, disposers: Array<() => void>): void {
263
279
  const expr = parseFor(tplEl.getAttribute('x-for') ?? '')
264
280
  if (!expr) {
265
281
  console.warn(`[brust] malformed x-for expression: "${tplEl.getAttribute('x-for')}"`)
@@ -292,12 +308,12 @@ function bindFor(tplEl: HTMLElement, instance: Instance, disposers: Array<() =>
292
308
  const anchor = tplEl.ownerDocument.createComment(`x-for:${itemName}`)
293
309
  parent.insertBefore(anchor, tplEl)
294
310
  tplEl.removeAttribute('x-for')
295
- const template = tplEl.cloneNode(true) as HTMLElement
311
+ const template = tplEl.cloneNode(true) as Element
296
312
  tplEl.remove()
297
313
 
298
314
  // ---- legacy (no `by`) — full re-render, with optional plain index ----
299
315
  if (!keyPaths) {
300
- const rendered: HTMLElement[] = []
316
+ const rendered: Element[] = []
301
317
  const childDisposers: Array<() => void> = []
302
318
  const clear = () => {
303
319
  for (const d of childDisposers.splice(0)) {
@@ -315,7 +331,7 @@ function bindFor(tplEl: HTMLElement, instance: Instance, disposers: Array<() =>
315
331
  const list = read(instance, listPath)
316
332
  if (!Array.isArray(list)) return
317
333
  for (let i = 0; i < list.length; i++) {
318
- const clone = template.cloneNode(true) as HTMLElement
334
+ const clone = template.cloneNode(true) as Element
319
335
  const childScope: Instance = Object.create(instance)
320
336
  childScope[itemName] = list[i]
321
337
  if (indexName) childScope[indexName] = i
@@ -340,7 +356,7 @@ function installKeyedReconcile(
340
356
  instance: Instance,
341
357
  parent: Node,
342
358
  expr: ForExpr,
343
- template: HTMLElement,
359
+ template: Element,
344
360
  anchor: Comment,
345
361
  initialMap: Map<string, ForEntry>,
346
362
  disposers: Array<() => void>,
@@ -382,7 +398,7 @@ function installKeyedReconcile(
382
398
  live.delete(key)
383
399
  next.set(key, existing)
384
400
  } else {
385
- const clone = template.cloneNode(true) as HTMLElement
401
+ const clone = template.cloneNode(true) as Element
386
402
  const itemSig = signal(item)
387
403
  const idxSig = indexName ? signal(i) : undefined
388
404
  const childScope: Instance = Object.create(instance)
@@ -409,11 +425,11 @@ function installKeyedReconcile(
409
425
  * `data-x-key-0`). Only direct children — the key attr lives on the for-item
410
426
  * root, never a descendant. The x-for match prevents a sibling x-for list under
411
427
  * the same parent from having its seeds consumed by this one. */
412
- function collectSeeds(parent: Node, keyPaths: string[], xforRaw: string): HTMLElement[] {
428
+ function collectSeeds(parent: Node, keyPaths: string[], xforRaw: string): Element[] {
413
429
  const sel = keyPaths.length > 1 ? '[data-x-key-0]' : '[data-x-key]'
414
- const out: HTMLElement[] = []
430
+ const out: Element[] = []
415
431
  for (const c of Array.from((parent as Element).children ?? [])) {
416
- if (c instanceof HTMLElement && c.matches(sel) && c.getAttribute('x-for') === xforRaw) {
432
+ if (c instanceof Element && c.matches(sel) && c.getAttribute('x-for') === xforRaw) {
417
433
  out.push(c)
418
434
  }
419
435
  }
@@ -422,14 +438,14 @@ function collectSeeds(parent: Node, keyPaths: string[], xforRaw: string): HTMLEl
422
438
 
423
439
  /** The seed's key from markup — must match the reconcile's computed key (single
424
440
  * `data-x-key`, OR `data-x-key-*` joined with `\x00` IN JS — NUL never in HTML). */
425
- function seedKey(node: HTMLElement, keyPaths: string[]): string {
441
+ function seedKey(node: Element, keyPaths: string[]): string {
426
442
  if (keyPaths.length > 1) {
427
443
  return keyPaths.map((_, i) => node.getAttribute(`data-x-key-${i}`) ?? '').join('\x00')
428
444
  }
429
445
  return node.getAttribute('data-x-key') ?? ''
430
446
  }
431
447
 
432
- function stripKeyAttrs(el: HTMLElement, keyPaths: string[]): void {
448
+ function stripKeyAttrs(el: Element, keyPaths: string[]): void {
433
449
  if (keyPaths.length > 1) {
434
450
  for (let i = 0; i < keyPaths.length; i++) el.removeAttribute(`data-x-key-${i}`)
435
451
  } else {
@@ -440,10 +456,10 @@ function stripKeyAttrs(el: HTMLElement, keyPaths: string[]): void {
440
456
  /** Bind an adopted seed node as a plain subtree. The node KEEPS its x-for attr so
441
457
  * the parent bindTree loop's later re-visit routes back to bindFor → mount guard
442
458
  * no-ops; do NOT route the node itself through bindFor again here. */
443
- function bindAdoptedNode(node: HTMLElement, scope: Instance, disposers: Array<() => void>): void {
459
+ function bindAdoptedNode(node: Element, scope: Instance, disposers: Array<() => void>): void {
444
460
  bindAttrs(node, scope, disposers)
445
461
  for (const child of Array.from(node.children)) {
446
- if (!(child instanceof HTMLElement)) continue
462
+ if (!(child instanceof Element)) continue
447
463
  if (child.hasAttribute('x-data')) continue
448
464
  bindTree(child, scope, disposers)
449
465
  }
@@ -453,7 +469,7 @@ function bindAdoptedNode(node: HTMLElement, scope: Instance, disposers: Array<()
453
469
  * each item-signal from the matching client item by key, wire reactivity, then
454
470
  * hand the pre-populated map to the shared reconcile (first run = all reused). */
455
471
  function bindForAdopt(
456
- seeds: HTMLElement[],
472
+ seeds: Element[],
457
473
  instance: Instance,
458
474
  parent: Node,
459
475
  expr: ForExpr,
@@ -469,7 +485,7 @@ function bindForAdopt(
469
485
  }
470
486
  const keys = keyPaths as string[]
471
487
  // template for future creates: stripped clone of the first seed.
472
- const template = seeds[0].cloneNode(true) as HTMLElement
488
+ const template = seeds[0].cloneNode(true) as Element
473
489
  template.removeAttribute('x-for')
474
490
  stripKeyAttrs(template, keys)
475
491
  // anchor AFTER the last seed so future inserts keep document order.
@@ -489,7 +505,7 @@ function bindForAdopt(
489
505
  // adopt each seed in place.
490
506
  const map = new Map<string, ForEntry>()
491
507
  for (let si = 0; si < seeds.length; si++) {
492
- const node = seeds[si] as HTMLElement
508
+ const node = seeds[si] as Element
493
509
  let key = seedKey(node, keys)
494
510
  if (map.has(key)) {
495
511
  // mirror the reconcile's dup-key handling: suffix so the entry is tracked
@@ -518,15 +534,15 @@ function bindForAdopt(
518
534
  * the node and runs those disposers. The per-clone disposers cover only non-x-data
519
535
  * teardown (bindTree skips nested x-data); nested x-data dispose/mount is delegated
520
536
  * to the MutationObserver on removal/insert — single-owner discipline. */
521
- function bindIf(el: HTMLElement, instance: Instance, disposers: Array<() => void>): void {
537
+ function bindIf(el: Element, instance: Instance, disposers: Array<() => void>): void {
522
538
  const path = el.getAttribute('x-if') ?? ''
523
539
  const parent = el.parentNode
524
540
  if (!parent) return
525
541
  const anchor = el.ownerDocument.createComment('x-if')
526
542
  parent.insertBefore(anchor, el)
527
543
  el.removeAttribute('x-if')
528
- const template = el.cloneNode(true) as HTMLElement // capture FIRST (before initial effect)
529
- let current: HTMLElement | null = el // the original, adopted if initially truthy
544
+ const template = el.cloneNode(true) as Element // capture FIRST (before initial effect)
545
+ let current: Element | null = el // the original, adopted if initially truthy
530
546
  let bound = false // original starts unbound; clones are bound at creation
531
547
  const currentDisposers: Array<() => void> = []
532
548
  const teardown = () => {
@@ -557,7 +573,7 @@ function bindIf(el: HTMLElement, instance: Instance, disposers: Array<() => void
557
573
  }
558
574
  return
559
575
  }
560
- const clone = template.cloneNode(true) as HTMLElement
576
+ const clone = template.cloneNode(true) as Element
561
577
  bindTree(clone, instance, currentDisposers) // bind BEFORE insert (observer mounts nested x-data after)
562
578
  anchor.parentNode?.insertBefore(clone, anchor.nextSibling)
563
579
  current = clone
@@ -657,13 +673,14 @@ function bindModel(
657
673
  )
658
674
  }
659
675
 
660
- function bindAttrs(el: HTMLElement, scope: Instance, disposers: Array<() => void>): void {
676
+ function bindAttrs(el: Element, scope: Instance, disposers: Array<() => void>): void {
661
677
  for (const attr of Array.from(el.attributes)) {
662
678
  const name = attr.name
663
679
  const value = attr.value
664
680
  if (name === 'x-data' || name === 'x-props') continue
665
681
  if (name === 'x-model') {
666
- bindModel(el, scope, value, disposers)
682
+ if (el instanceof HTMLElement) bindModel(el, scope, value, disposers)
683
+ else console.warn('[brust] x-model is only supported on HTML elements — binding skipped')
667
684
  continue
668
685
  }
669
686
  if (name === 'x-text') {
@@ -678,7 +695,11 @@ function bindAttrs(el: HTMLElement, scope: Instance, disposers: Array<() => void
678
695
  if (name === 'x-show') {
679
696
  disposers.push(
680
697
  effect(() => {
681
- el.style.display = read(scope, value) ? '' : 'none'
698
+ if ('style' in el) {
699
+ ;(el as Element & { style: CSSStyleDeclaration }).style.display = read(scope, value)
700
+ ? ''
701
+ : 'none'
702
+ }
682
703
  }),
683
704
  )
684
705
  continue
@@ -718,40 +739,40 @@ function observeRoot(root: Node): void {
718
739
  const obs = new MutationObserver((records) => {
719
740
  for (const rec of records) {
720
741
  for (const node of Array.from(rec.removedNodes)) {
721
- if (node instanceof HTMLElement) disposeTree(node)
742
+ if (node instanceof Element) disposeTree(node)
722
743
  }
723
744
  for (const node of Array.from(rec.addedNodes)) {
724
- if (node instanceof HTMLElement) scanAndMount(node)
745
+ if (node instanceof Element) scanAndMount(node)
725
746
  }
726
747
  }
727
748
  })
728
749
  obs.observe(root, { childList: true, subtree: true })
729
750
  }
730
751
 
731
- function disposeTree(node: HTMLElement): void {
752
+ function disposeTree(node: Element): void {
732
753
  if (mounted.has(node)) disposeElement(node)
733
- for (const el of Array.from(node.querySelectorAll<HTMLElement>('[x-data]'))) {
754
+ for (const el of Array.from(node.querySelectorAll<Element>('[x-data]'))) {
734
755
  disposeElement(el)
735
756
  }
736
757
  // R10 — a removed HOST's shadow contents never reach any observer: the host's
737
758
  // removal fires on the light tree's observer, and the shadow root's own
738
759
  // observer only sees mutations INSIDE the root. Walk shadow roots explicitly.
739
760
  if (node.shadowRoot) disposeShadowContents(node.shadowRoot)
740
- for (const el of Array.from(node.querySelectorAll<HTMLElement>('*'))) {
761
+ for (const el of Array.from(node.querySelectorAll<Element>('*'))) {
741
762
  if (el.shadowRoot) disposeShadowContents(el.shadowRoot)
742
763
  }
743
764
  }
744
765
 
745
766
  function disposeShadowContents(root: ShadowRoot): void {
746
- for (const el of Array.from(root.querySelectorAll<HTMLElement>('[x-data]'))) {
767
+ for (const el of Array.from(root.querySelectorAll<Element>('[x-data]'))) {
747
768
  disposeElement(el)
748
769
  }
749
- for (const el of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
770
+ for (const el of Array.from(root.querySelectorAll<Element>('*'))) {
750
771
  if (el.shadowRoot) disposeShadowContents(el.shadowRoot)
751
772
  }
752
773
  }
753
774
 
754
- function disposeElement(el: HTMLElement): void {
775
+ function disposeElement(el: Element): void {
755
776
  const m = mounted.get(el)
756
777
  if (!m) return
757
778
  for (const d of m.disposers.splice(0)) {
@@ -766,15 +787,19 @@ function disposeElement(el: HTMLElement): void {
766
787
 
767
788
  const BOOL_PROPS = new Set(['disabled', 'checked', 'hidden', 'readonly', 'required', 'selected'])
768
789
 
769
- /** Apply a bound value to a DOM attr/property. class → className; boolean props →
770
- * property (when present) + attribute presence; value property; else attribute. */
771
- export function setBound(el: HTMLElement, attr: string, value: unknown): void {
790
+ /** Apply a bound value to a DOM attr/property. class → HTML className or a
791
+ * namespace-safe attribute; boolean/value properties are used only when present. */
792
+ export function setBound(el: Element, attr: string, value: unknown): void {
772
793
  if (attr === 'class') {
773
- el.className = value == null ? '' : String(value)
794
+ if (el instanceof HTMLElement) el.className = value == null ? '' : String(value)
795
+ else if (value == null) el.removeAttribute('class')
796
+ else el.setAttribute('class', String(value))
774
797
  return
775
798
  }
776
799
  if (attr === 'value') {
777
- ;(el as unknown as { value: unknown }).value = value == null ? '' : value
800
+ if ('value' in el) (el as unknown as { value: unknown }).value = value == null ? '' : value
801
+ else if (value == null) el.removeAttribute(attr)
802
+ else el.setAttribute(attr, String(value))
778
803
  return
779
804
  }
780
805
  if (BOOL_PROPS.has(attr)) {
@@ -0,0 +1,38 @@
1
+ const ENC = new TextEncoder()
2
+
3
+ /** Splice `snippet` into `body` immediately before the first `</head>`
4
+ * (case-insensitive on the four ASCII letters only). Returns the original body
5
+ * untouched if `snippet` is null/empty or if `</head>` is absent.
6
+ *
7
+ * AI pages are document-only: fragment templates skip this injection entirely. */
8
+ export function injectAiClient(body: Uint8Array, snippet: string | null): Uint8Array {
9
+ if (!snippet) return body
10
+ const pos = findHeadCloseTag(body)
11
+ if (pos < 0) return body
12
+ const tagBytes = ENC.encode(snippet)
13
+ const out = new Uint8Array(body.length + tagBytes.length)
14
+ out.set(body.subarray(0, pos), 0)
15
+ out.set(tagBytes, pos)
16
+ out.set(body.subarray(pos), pos + tagBytes.length)
17
+ return out
18
+ }
19
+
20
+ function findHeadCloseTag(body: Uint8Array): number {
21
+ const LT = 0x3c,
22
+ SL = 0x2f,
23
+ GT = 0x3e
24
+ for (let i = 0, max = body.length - 6; i < max; i++) {
25
+ if (body[i] !== LT || body[i + 1] !== SL) continue
26
+ if (!isLetter(body[i + 2], 0x48)) continue
27
+ if (!isLetter(body[i + 3], 0x45)) continue
28
+ if (!isLetter(body[i + 4], 0x41)) continue
29
+ if (!isLetter(body[i + 5], 0x44)) continue
30
+ if (body[i + 6] !== GT) continue
31
+ return i
32
+ }
33
+ return -1
34
+ }
35
+
36
+ function isLetter(b: number, u: number): boolean {
37
+ return b === u || b === (u | 0x20)
38
+ }
@@ -7,6 +7,7 @@ import { Writable } from 'node:stream'
7
7
  import { IslandUsedContext, createIslandUsedBox } from '../islands/island.tsx'
8
8
  import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
9
  import { injectCssLink } from './inject-css-link.ts'
10
+ import { injectAiClient } from './inject-ai-client.ts'
10
11
  import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
11
12
  import { injectDevClient } from './inject-dev-client.ts'
12
13
  import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
@@ -14,6 +15,7 @@ import { injectBrustStore, buildStoreScripts } from './inject-store.ts'
14
15
  import { getDevClientSnippet } from '../dev/inject.ts'
15
16
  import { getGeneratorMeta, injectGeneratorMeta } from './inject-generator.ts'
16
17
  import { injectShellMeta, shellMetaTag } from './inject-shell-meta.ts'
18
+ import { aiScriptTag } from '../generator.ts'
17
19
 
18
20
  export interface RenderBranchStreamingArgs {
19
21
  element: ReactNode
@@ -184,6 +186,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
184
186
  body = injectGeneratorMeta(body, getGeneratorMeta())
185
187
  body = injectShellMeta(body, args.shellId ?? '')
186
188
  body = injectDevClient(body, getDevClientSnippet())
189
+ body = injectAiClient(body, process.env.BRUST_AI === '1' ? aiScriptTag() : null)
187
190
  body = injectActionPrefix(body, getActionPrefixSnippet())
188
191
  body = injectBrustStore(body, args.storeSnapshot ?? null)
189
192
  const meta = makeMeta({
@@ -246,6 +249,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
246
249
  .map((h) => `<link rel="stylesheet" href="${h}">`)
247
250
  .join('')
248
251
  const devTag = getDevClientSnippet() ?? ''
252
+ const aiTag = process.env.BRUST_AI === '1' ? aiScriptTag() : ''
249
253
  const prefixTag = getActionPrefixSnippet() ?? ''
250
254
  const storeTag = buildStoreScripts(args.storeSnapshot ?? null)
251
255
  const genTag = getGeneratorMeta() ?? ''
@@ -253,6 +257,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
253
257
  if (
254
258
  linkTagsStr.length > 0 ||
255
259
  devTag.length > 0 ||
260
+ aiTag.length > 0 ||
256
261
  prefixTag.length > 0 ||
257
262
  storeTag.length > 0 ||
258
263
  genTag.length > 0 ||
@@ -261,9 +266,10 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
261
266
  const prepend = encoder.encode(
262
267
  genTag + shellTag + linkTagsStr + prefixTag + devTag + storeTag,
263
268
  )
264
- const out = new Uint8Array(flushed.length + prepend.length)
265
- out.set(flushed, 0)
266
- out.set(prepend, flushed.length)
269
+ const out = new Uint8Array(prepend.length + aiTag.length + flushed.length)
270
+ out.set(prepend, 0)
271
+ out.set(encoder.encode(aiTag), prepend.length)
272
+ out.set(flushed, prepend.length + aiTag.length)
267
273
  flushed = out
268
274
  }
269
275
  const meta = makeMeta({ status: successStatus, streaming: true, headers: extraHeaders })
@@ -0,0 +1,17 @@
1
+ import type { FlatRoute } from '../routes.ts';
2
+ export interface AiPageEntry {
3
+ path: string;
4
+ params: string[];
5
+ catchAll: boolean;
6
+ kind: 'react' | 'native' | 'md';
7
+ shellId: string;
8
+ title?: string;
9
+ description?: string;
10
+ }
11
+ export interface AiManifest {
12
+ version: 1;
13
+ pages: AiPageEntry[];
14
+ }
15
+ export declare function extractAiManifest(routes: FlatRoute[]): AiManifest;
16
+ export declare function writeManifest(cwd: string, manifest: AiManifest): Promise<void>;
17
+ export declare function readManifest(cwd: string): Promise<AiManifest | null>;
@@ -71,7 +71,11 @@ export declare function countMainTags(template: string): number;
71
71
  *
72
72
  * Exported for the md emit step (runtime/md/emit.ts), which bakes the same tag
73
73
  * under its `withDevClient` option — md pages render Rust-side too, so without
74
- * it they never auto-reload in dev. */
74
+ * it they never auto-reload in dev.
75
+ *
76
+ * The AI runtime script is injected here as well when BRUST_AI=1. The tag is
77
+ * document-only: the compiler emits a head anchor for full documents, while
78
+ * fragment templates (no head) are left unchanged. */
75
79
  export declare function injectDevClientIntoTemplate(template: string): string;
76
80
  /** Bake the directive runtime loader into a native template iff it uses any
77
81
  * x-data directive. Idempotent. Wrapped in {% raw %} for symmetry with the islands
package/types/config.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface BrustConfig {
10
10
  cacheMaxEntries?: number;
11
11
  /** L2 page-cache capacity (entries). Undefined → Rust default of 1000. */
12
12
  cachePageMaxEntries?: number;
13
+ /** AI runtime toggle from BRUST_AI. Dev mode enables it separately. */
14
+ ai?: boolean;
13
15
  /** R9 cross-process cache invalidation: redis/dragonfly URL. Absent →
14
16
  * feature disabled (current single-process behavior). */
15
17
  cacheSyncUrl?: string;
@@ -4,6 +4,11 @@ export interface GeneratorStrings {
4
4
  /** X-Powered-By value, e.g. `brust/0.1.48-alpha` */
5
5
  header: string;
6
6
  }
7
+ /** Full browser entry tag for the AI runtime chunk. */
8
+ export declare function aiScriptTag(): string;
9
+ /** Insert the AI runtime tag immediately before the first `</head>`.
10
+ * Document-only: fragment templates with no head are left unchanged. */
11
+ export declare function injectAiScriptIntoTemplate(template: string): string;
7
12
  /** Build the resolved strings. Version comes from the brustjs package.json
8
13
  * (readVersion never throws — "unknown" degrades to name-only, never a crash).
9
14
  * The version is sanitized to attr/header-safe bytes; semver chars only. */
package/types/index.d.ts CHANGED
@@ -183,6 +183,8 @@ export declare const brust: {
183
183
  /** Optional global CORS policy — see {@link CorsOptions}. Threaded to
184
184
  * serve() like `actionPrefix`. */
185
185
  cors?: CorsOptions;
186
+ /** AI runtime toggle. Dev mode enables it automatically. */
187
+ ai?: boolean;
186
188
  /** Overrides merged into the underlying `serve()` call (main thread). */
187
189
  serve?: Partial<Omit<ServeOptions, "entry" | "actions" | "mcp">>;
188
190
  /** Per-worker SAB size in bytes. Default 256 KB. */
@@ -18,6 +18,14 @@ export interface BuildIslandsOptions {
18
18
  * on the output filename (X.module.css + X.tsx → both X.js). */
19
19
  plugins?: BunPlugin[];
20
20
  }
21
+ export interface BuildAiRuntimeOptions {
22
+ /** Override the output directory. Default: `<cwd>/.brust/islands`. */
23
+ outDir?: string;
24
+ /** Override the browser entry file. Default: `runtime/ai/index.ts`. */
25
+ entryFile?: string;
26
+ /** Build plugins passed straight to `Bun.build` for the AI runtime chunk. */
27
+ plugins?: BunPlugin[];
28
+ }
21
29
  /** Scan a routes entry file for `<Island component={X} />` usage and derive the
22
30
  * island chunk list (componentName → absolute source path). Replaces the old
23
31
  * static config-file lookup — the chunk set is derived from source.
@@ -46,4 +54,5 @@ export declare function scanIslandChunks(routesEntryFile: string, extraIslands?:
46
54
  /** Build the runtime chunks + all island chunks + bootstrap. Returns the
47
55
  * absolute output directory; caller passes it to `brust.configureIslandsDir`. */
48
56
  export declare function buildIslands(islands: Map<string, string>, options?: BuildIslandsOptions): Promise<IslandsBuildResult>;
57
+ export declare function buildAiRuntime(options?: BuildAiRuntimeOptions): Promise<string | null>;
49
58
  export declare function buildOne(entrypoints: string[], outdir: string, naming: string, external: string[], plugins?: BunPlugin[]): Promise<void>;
@@ -27,6 +27,9 @@ export interface MdEmitOpts {
27
27
  * BRUST_DEV injection — md pages render Rust-side and never pass through the
28
28
  * React renderer's dev-client injection). */
29
29
  withDevClient?: boolean;
30
+ /** Bake the AI runtime tag into document-style md templates. Fragments skip
31
+ * the injection entirely. */
32
+ aiEnabled?: boolean;
30
33
  /** What to do when a route's md file no longer exists on disk (deleted after
31
34
  * the route table was built). emitMdTemplates serves BOTH `brust build` and
32
35
  * the dev re-emit, and the two must diverge here:
@@ -6,17 +6,17 @@ export type Instance = Record<string, unknown>;
6
6
  * for side-effects on signal change (sync localStorage, the DOM
7
7
  * outside the component, timers). Returns the disposer too.
8
8
  * - `onCleanup(fn)` — register a one-shot teardown for unmount (e.g. removeEventListener). */
9
- export interface BehaviorCtx {
10
- el: HTMLElement;
9
+ export interface BehaviorCtx<Host extends Element = HTMLElement> {
10
+ el: Host;
11
11
  props: unknown;
12
12
  effect: (fn: () => void | (() => void)) => () => void;
13
13
  onCleanup: (fn: () => void) => void;
14
14
  }
15
- export type Behavior = (ctx: BehaviorCtx) => Instance;
15
+ export type Behavior<Host extends Element = HTMLElement> = (ctx: BehaviorCtx<Host>) => Instance;
16
16
  /** Register a component behavior under `name`. Called by `<name>.directive.js` chunks
17
17
  * via the global handle below (they do NOT import this module — keeps each chunk to
18
18
  * just its behavior, with the runtime shared as the single `_directives.js` copy). */
19
- export declare function register(name: string, behavior: Behavior): void;
19
+ export declare function register<Host extends Element = HTMLElement>(name: string, behavior: Behavior<Host>): void;
20
20
  /** Scan `root` (default: document) for [x-data], mount each, and (once) attach a
21
21
  * MutationObserver for dynamic mount/dispose. Idempotent. NOTE: `root` scopes the
22
22
  * INITIAL scan only; the observer always watches the global `document.body` (one
@@ -39,9 +39,9 @@ export declare function parseFor(raw: string): ForExpr | null;
39
39
  * for multi-hop paths). The LEAF is never called: `isSignal(leaf)` → `.set(value)`,
40
40
  * else warn once and skip. */
41
41
  export declare function writePath(scope: Instance, path: string, value: unknown): void;
42
- /** Apply a bound value to a DOM attr/property. class → className; boolean props →
43
- * property (when present) + attribute presence; value property; else attribute. */
44
- export declare function setBound(el: HTMLElement, attr: string, value: unknown): void;
42
+ /** Apply a bound value to a DOM attr/property. class → HTML className or a
43
+ * namespace-safe attribute; boolean/value properties are used only when present. */
44
+ export declare function setBound(el: Element, attr: string, value: unknown): void;
45
45
  /** Walk a dotted member path against `scope`, unwrapping a signal/computed at EVERY
46
46
  * hop (so an intermediate item-signal is tracked by `effect`); at the LEAF also call
47
47
  * a plain function to obtain its value (this read is what `effect` tracks). */
@@ -0,0 +1,6 @@
1
+ /** Splice `snippet` into `body` immediately before the first `</head>`
2
+ * (case-insensitive on the four ASCII letters only). Returns the original body
3
+ * untouched if `snippet` is null/empty or if `</head>` is absent.
4
+ *
5
+ * AI pages are document-only: fragment templates skip this injection entirely. */
6
+ export declare function injectAiClient(body: Uint8Array, snippet: string | null): Uint8Array;