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.
- package/README.md +6 -0
- package/package.json +7 -7
- package/runtime/ai/actions.ts +271 -0
- package/runtime/ai/index.ts +90 -0
- package/runtime/ai/manifest.ts +68 -0
- package/runtime/ai/navigate.ts +157 -0
- package/runtime/ai/pages.ts +49 -0
- package/runtime/ai/refs.ts +87 -0
- package/runtime/ai/struct.ts +168 -0
- package/runtime/cli/build.ts +43 -4
- package/runtime/cli/dev.ts +6 -1
- package/runtime/cli/help.ts +8 -0
- package/runtime/cli/native-routes-emit.ts +33 -9
- package/runtime/config.ts +3 -0
- package/runtime/generator.ts +14 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +88 -3
- package/runtime/islands/build.ts +42 -1
- package/runtime/md/emit.ts +5 -1
- package/runtime/native/runtime.ts +86 -61
- package/runtime/render/inject-ai-client.ts +38 -0
- package/runtime/render/stream.ts +9 -3
- package/types/ai/manifest.d.ts +17 -0
- package/types/cli/native-routes-emit.d.ts +5 -1
- package/types/config.d.ts +2 -0
- package/types/generator.d.ts +5 -0
- package/types/index.d.ts +2 -0
- package/types/islands/build.d.ts +9 -0
- package/types/md/emit.d.ts +3 -0
- package/types/native/runtime.d.ts +7 -7
- package/types/render/inject-ai-client.d.ts +6 -0
|
@@ -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:
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
43
|
-
|
|
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
|
|
76
|
-
for (const el of Array.from(scope.querySelectorAll<
|
|
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
|
|
85
|
-
for (const el of Array.from(scope.querySelectorAll<
|
|
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:
|
|
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) =>
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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):
|
|
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:
|
|
430
|
+
const out: Element[] = []
|
|
415
431
|
for (const c of Array.from((parent as Element).children ?? [])) {
|
|
416
|
-
if (c instanceof
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
529
|
-
let current:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
742
|
+
if (node instanceof Element) disposeTree(node)
|
|
722
743
|
}
|
|
723
744
|
for (const node of Array.from(rec.addedNodes)) {
|
|
724
|
-
if (node instanceof
|
|
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:
|
|
752
|
+
function disposeTree(node: Element): void {
|
|
732
753
|
if (mounted.has(node)) disposeElement(node)
|
|
733
|
-
for (const el of Array.from(node.querySelectorAll<
|
|
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<
|
|
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<
|
|
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<
|
|
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:
|
|
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
|
|
770
|
-
*
|
|
771
|
-
export function setBound(el:
|
|
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
|
-
|
|
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
|
+
}
|
package/runtime/render/stream.ts
CHANGED
|
@@ -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(
|
|
265
|
-
out.set(
|
|
266
|
-
out.set(
|
|
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;
|
package/types/generator.d.ts
CHANGED
|
@@ -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. */
|
package/types/islands/build.d.ts
CHANGED
|
@@ -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>;
|
package/types/md/emit.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
43
|
-
*
|
|
44
|
-
export declare function setBound(el:
|
|
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;
|