@brust/runtime-dom 0.0.0-stage → 2.0.0-alpha.10

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 CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # @brust/runtime-dom
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React-free browser runtime that binds a compiled client chunk (`defineBehavior`) to server-rendered HTML
4
+ through `x-*` attributes. **This README is the output format the M1c client backend prints against**; every
5
+ behaviour below is pinned by a test in `test/`. Zero dependencies, eval-free, bundle < 18 KB minified
6
+ (`bun scripts/build.ts`).
7
+
8
+ ## Public surface
9
+
10
+ ```ts
11
+ import { signal, computed, effect, batch, untracked, defineBehavior, setChunkLoader, mount, unmount } from '@brust/runtime-dom'
12
+ ```
13
+
14
+ - Signals (`test/signal.test.ts`): writes are `Object.is`-deduped; `computed` is lazy and cached; `effect(fn)` runs now and on
15
+ change, a returned function is a cleanup that runs before each re-run and on dispose; `batch` defers effects to its end;
16
+ `untracked` reads without subscribing.
17
+ - `defineBehavior(name, factory)`: `factory({ el, props, effect, onCleanup, ref })` returns the instance **members**
18
+ (signals, computeds, functions, plain values). `props` is a **signal** of the props object. An `init` member, if a function, runs after the factory.
19
+ - `setChunkLoader(load)`: called once per unknown behavior name; the loader is expected to call `defineBehavior(name, …)`. Default: none (hosts wait).
20
+ - `mount(root = document.body)`: idempotent; mounts every `[x-data]` in document order (parents first) and starts a `MutationObserver`
21
+ that mounts added hosts and disposes removed ones (a removed-then-reinserted host is disposed, then mounted fresh).
22
+ `unmount(root)` disposes instances under `root` and stops the observer. A factory that throws is logged and its instance discarded; other hosts still mount.
23
+
24
+ ## Directive value grammar
25
+
26
+ ```
27
+ value := path (":" bindings)?
28
+ path := ident ("." ident)* // looked up on the instance members; a signal/computed hop is unwrapped
29
+ bindings := ident ("," ident)* // loop-scope names (x-for item/index); the member must be a function and is called with them
30
+ ```
31
+
32
+ Anything else (`x + 1`, `fn()`) is not parsed: a warning is logged once and the DOM is left as rendered. An unknown member behaves the same.
33
+
34
+ ## Attributes
35
+
36
+ Directives come only from compiler output: the compiler never prints an `x-*` attribute written in component JSX (warning `reserved-attr`), and the runtime does not bind inside `dangerouslySetInnerHTML` markup (`x-html` / `x-raw`) or a React island (`<brust-island>`).
37
+
38
+ - **`x-data="name"`**: one instance per host. Nested hosts are bound by their own instance, not the parent's walker.
39
+ - **`x-props='{"json":1}'`**: seeds `ctx.props`. Invalid JSON warns and seeds `{}`.
40
+ - **`x-props-bind="member[:binding]"`**: on a child host, rebinds the child's `props` signal reactively to the nearest ancestor instance's member
41
+ (an object, typically a `computed`; function values pass through). Inside `x-for` the row's scope supplies the bindings. If the parent is not mounted yet
42
+ (chunk still loading) the child links up when the parent mounts. No ancestor: warns, keeps the `x-props` seed.
43
+ - **`x-text="value"`**: sets `textContent`; `null`/`undefined` → `''`.
44
+ - **`x-html="value"`**: sets `innerHTML` (raw markup, unescaped); `null`/`undefined` → `''`. Emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint; the author sanitises the markup (same contract as React). The first-paint check compares parsed markup, so equivalent spellings do not warn.
45
+ - **`x-show="value"`**: `style.display = ''` when truthy, `'none'` otherwise.
46
+ - **`x-if="value"`**: the element is replaced by a `<!--x-if-->` comment anchor; a clone is inserted after it while truthy and removed otherwise.
47
+ Nested `x-data` hosts in the clone mount/dispose with it. Server HTML omits the element when false.
48
+ - **`x-bind-<attr>="value"`**: `class` → `className`; `value` → `.value`; `disabled checked selected readonly required hidden open multiple` → property + attribute (present/absent);
49
+ anything else: `null`/`undefined`/`false` removes the attribute, otherwise `setAttribute`. **Refused** (warn once): `on*` and `srcdoc` attributes, and, in `href src action formaction poster data`, any URL whose parsed scheme is not `http(s):`/`mailto:`/`tel:` (relative URLs are fine; `java\tscript:` is caught because the URL is parsed, not pattern-matched).
50
+ - **`x-on-<event>="member[:binding]"`**: calls the member with the bound scope values first, then the event: `pick(item, index, event)`.
51
+ - **`x-model="member"`**: the member must be a writable signal (else warns). text-like: `input` event ↔ `.value`; checkbox: `change` ↔ boolean `.checked`;
52
+ radio: `change` writes `.value` when checked, signal sets `.checked`; single `select`: `change` ↔ `.value`, re-applied whenever its options change. `select[multiple]` is unsupported (warns).
53
+ - **`x-for="item[, index] in source by keyFn"`**: `source := path (":" bindings)?` is a member path to an array (or signal/computed of one); with bindings the member is a function called with the outer loop names, so a nested list reads the outer row, e.g. `<tr x-for="row in rows by k1"><td x-for="c in cells:row by k2">`; `keyFn` is a member function of the item.
54
+ Server-rendered sibling rows carrying the same `x-for` are **adopted in order** on first run; with zero rows the server emits a `<!--x-for-->` comment followed by one `hidden` template element
55
+ (carrying `x-for`) which is cloned. Rows are reconciled by key (moves keep DOM identity); a reused row whose item/index changed has its binders re-run. Duplicate keys warn once and fall back to index identity.
56
+ Elements with `x-for` are owned by the directive: their other `x-*` attributes are bound on the rows, with scope `{ item, index }`.
57
+ - **`x-ref="name"`**: `ctx.ref('name').current` is the element after mount, `null` after dispose.
58
+
59
+ ## First-paint rule
60
+
61
+ Nothing re-renders on mount: server HTML is the initial state. A binder writes the DOM only when its effect re-runs after a change, **or** when the initial `x-text` / `x-html` value differs from the server text,
62
+ in which case it corrects it and warns once (`first-paint mismatch …`): a mismatch is a compiler bug the runtime must not hide. Elements created after mount (new rows, `x-if` clones) never trip this check.
63
+
64
+ ## Gates
65
+
66
+ `bun test` · `bunx tsc --noEmit -p .` (`bun check` needs Bun ≥ 1.4.3) · `bun scripts/build.ts` (fails if the bundle imports React).
67
+
68
+ ## React islands
69
+
70
+ The server paints a react-tier component as
71
+ `<brust-island data-id="<componentId>" x-props='<json>'>…server HTML…</brust-island>`.
72
+ The island's chunk registers its hydrate function without importing the runtime:
73
+
74
+ ;(globalThis.__brustIslands ||= []).push(['<componentId>', (host, props) => hydrateRoot(host, <Comp {...props}/>)])
75
+ globalThis.__brustIslandReady?.()
76
+
77
+ The runtime hydrates every host once, when the browser is idle (`requestIdleCallback`, else
78
+ `setTimeout(…, 1)`), and sets `data-hydrated="1"` on success. Load order between the runtime
79
+ script and island chunks does not matter. Bad `x-props` JSON hydrates with `{}` and warns once;
80
+ a hydrate that throws is reported on `console.error` and the server HTML stays. This is the only
81
+ strategy in M2 (spec S12).
82
+
83
+ ## Store
84
+ The 0.1.x `brustjs/store` API on the same reactive core the directives use (one graph, even across module copies:
85
+ tracking state lives on `globalThis[Symbol.for('brust.reactive.ctx')]`).
86
+
87
+ - `@brust/runtime-dom/store` (also the runtime entry, and `@brust/core/store`): `signal computed effect batch
88
+ untracked isSignal isComputed defineStore` and the types `Signal Computed Dispose StoreHandle Snapshot`.
89
+ - `defineStore(name, factory)` returns a handle: `store.field`, `store.snapshot()`, `store.serialize()`,
90
+ `store.hydrate(state)`, `store.subscribe(cb)`. First factory wins per name.
91
+ - The browser keeps one instance per name in `globalThis[Symbol.for('brust.stores')]`. The first access to a
92
+ name seeds it from `<script type="application/json" data-brust-store="NAME">` (lazy, once).
93
+ - `@brust/runtime-dom/store/react` (also `@brust/core/store/react`): `useStore(store)` via
94
+ `useSyncExternalStore`. React is an **optional peer**, imported only by this subpath; the runtime bundle stays
95
+ React-free.
96
+ - `@brust/runtime-dom/store/internal`: the server-resolver hook and serialization helpers the Bun worker uses.
97
+ Not part of the public surface.
98
+
99
+ ## Feature modules (L33, F139)
100
+
101
+ The runtime is a core plus feature modules, so a page loads only what its components use and the client JS does not grow as the compiler learns more of React.
102
+
103
+ - `@brust/runtime-dom/core` (`src/core.ts`): signals, `defineBehavior`/`setChunkLoader`, `mount`/`unmount`, the binder registry (`BINDERS`) and the internals features need (`src/core-api.ts`). It knows no directive by name.
104
+ - `@brust/runtime-dom/features/<name>` (`src/features/*.ts`): `basics` (`x-text`, `x-show`, `x-bind-*`, `x-on-*`, `x-ref`), `if`, `for`, `model`, `html`, `island` (React island hydration glue). A feature registers itself with the core when imported and imports the core only through `core-api`; in the browser build `core-api` is the shared core file, so there is one signal graph and one binder registry per page.
105
+ - `@brust/runtime-dom` (`src/index.ts`) is core + every feature, for tests and non-split consumers. The browser build never uses it.
106
+
107
+ **The rule for a new feature.** The micro-directives nearly every interactive page uses ship together in `basics`. Anything else gets its OWN module when it is at least about 1 KB gzip OR used by a minority of pages: `store`, `island`, `if`/`for`/`model`/`html` as measured, and every future feature (context, portal, focus trap, ...). Do not add a feature to `basics` or the core to save a file; add it as a module the compiler requests only for routes that use it. The CI byte budget (core + `basics`) holds the line.
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-alpha.10",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./src/index.ts"
9
+ },
10
+ "./core": {
11
+ "types": "./src/core.ts",
12
+ "default": "./src/core.ts"
13
+ },
14
+ "./features/*": {
15
+ "types": "./src/features/*.ts",
16
+ "default": "./src/features/*.ts"
17
+ },
18
+ "./store": {
19
+ "types": "./src/store/index.ts",
20
+ "default": "./src/store/index.ts"
21
+ },
22
+ "./store/react": {
23
+ "types": "./src/store/react.ts",
24
+ "default": "./src/store/react.ts"
25
+ },
26
+ "./store/internal": {
27
+ "types": "./src/store/internal.ts",
28
+ "default": "./src/store/internal.ts"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "test": "bun test",
33
+ "typecheck": "bun check",
34
+ "build": "bun scripts/build.ts"
35
+ },
36
+ "dependencies": {},
37
+ "peerDependencies": {
38
+ "react": "^19.0.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "react": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "devDependencies": {
46
+ "@happy-dom/global-registrator": "^20.0.0",
47
+ "@types/bun": "^1.4.0",
48
+ "@types/react": "^19.2.0",
49
+ "@types/react-dom": "^19.2.0",
50
+ "happy-dom": "^20.0.0",
51
+ "react": "^19.2.0",
52
+ "react-dom": "^19.2.0",
53
+ "typescript": "^7.0.2"
54
+ },
55
+ "files": [
56
+ "src"
57
+ ],
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "homepage": "https://brust.dev",
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/AssetsArt/brust.git",
65
+ "directory": "packages/runtime-dom"
66
+ },
67
+ "bugs": {
68
+ "url": "https://github.com/AssetsArt/brust/issues"
69
+ }
70
+ }
@@ -0,0 +1,10 @@
1
+ // What a feature module may import from the core, and nothing else. In the browser build the core is one shared
2
+ // ESM file and every feature is a separate file that imports these names from it (bundle.ts maps this module to the
3
+ // core URL), so a feature registers on the page's single binder registry and signal graph.
4
+ export { BINDERS, bindHost, bindElement, walkChildren } from './directives/index'
5
+ export type { Binder } from './directives/index'
6
+ export { parsed, read, member, mismatch, insideRaw, RAW_SEL } from './directives/common'
7
+ export { hooks, islandHooks } from './instance'
8
+ export { warnOnce } from './warn'
9
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
10
+ export type { Signal, Computed, Dispose } from './signal'
package/src/core.ts ADDED
@@ -0,0 +1,8 @@
1
+ // The always-loaded core: signals, the behavior registry, mount/unmount and the binder registry. Features
2
+ // (./features/*) register their binders on import; nothing here knows a directive by name.
3
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from './signal'
4
+ export type { Signal, Computed, Dispose } from './signal'
5
+ export { defineBehavior, setChunkLoader } from './registry'
6
+ export type { BehaviorCtx, BehaviorFactory, BehaviorInstance } from './registry'
7
+ export { mount, unmount } from './mount'
8
+ export * from './core-api'
@@ -0,0 +1,31 @@
1
+ import { BINDERS, parsed, read, warnOnce } from '../core-api'
2
+
3
+ // Attributes that execute or load code from a string; a bound value must never reach them as-is.
4
+ const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'data', 'xlink:href', 'ping'])
5
+ // Scheme allowlist on the parsed URL (relative URLs resolve to http): new URL() applies the browser's tab/newline/control-char stripping.
6
+ const safeUrl = (v: string): boolean => { try { return ['http:', 'https:', 'mailto:', 'tel:'].includes(new URL(v, 'http://x').protocol) } catch { return false } }
7
+ const BOOLEAN = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'hidden', 'open', 'multiple'])
8
+ BINDERS.push({
9
+ match: (a) => a.startsWith('x-bind-'),
10
+ bind: ({ inst, el, attr, raw, scope }) => {
11
+ const name = attr.slice('x-bind-'.length)
12
+ if (name.startsWith('on') || name === 'srcdoc') { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name} refused (use x-on-*)`); return }
13
+ const p = parsed(inst, attr, raw); if (!p) return
14
+ inst.effect(() => {
15
+ const r = read(inst, p, scope, attr); if (!r.ok) return
16
+ const v = r.value
17
+ // setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
18
+ if (name === 'class') { el.setAttribute('class', v == null ? '' : String(v)); return }
19
+ if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
20
+ if (BOOLEAN.has(name)) {
21
+ const on = Boolean(v)
22
+ if (name in el) (el as unknown as Record<string, unknown>)[name] = on
23
+ if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
24
+ return
25
+ }
26
+ if (v == null || v === false) { el.removeAttribute(name); return }
27
+ if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
28
+ el.setAttribute(name, String(v))
29
+ })
30
+ },
31
+ })
@@ -0,0 +1,27 @@
1
+ import { parseValue, resolve, resolveMember, type ParsedValue, type Scope } from '../value'
2
+ import { warnOnce } from '../warn'
3
+ import type { Instance } from '../instance'
4
+
5
+ /** Markup inside a `dangerouslySetInnerHTML` element (`x-html` / `x-raw`, compiler-emitted) or a React island is inert: nothing under it is bound, mounted or hydrated. */
6
+ export const RAW_SEL = '[x-html],[x-raw],brust-island'
7
+ export const insideRaw = (el: Element): boolean => !!el.parentElement?.closest(RAW_SEL)
8
+
9
+ export function parsed(inst: Instance, attr: string, raw: string): ParsedValue | null {
10
+ const p = parseValue(raw)
11
+ if (!p) warnOnce(`p:${inst.name}:${attr}:${raw}`, `${attr}="${raw}" in ${inst.name}: not a member path`)
12
+ return p
13
+ }
14
+ export function read(inst: Instance, p: ParsedValue, scope: Scope, attr: string): { ok: boolean; value: unknown } {
15
+ const r = resolve(inst.members, p, scope)
16
+ if (!r.ok) warnOnce(`m:${inst.name}:${attr}:${p.path.join('.')}`, `${attr}="${p.path.join('.')}" in ${inst.name}: no such member`)
17
+ return r.ok ? r : { ok: false, value: undefined }
18
+ }
19
+ export function member(inst: Instance, p: ParsedValue, attr: string): unknown {
20
+ const m = resolveMember(inst.members, p)
21
+ if (m === undefined) warnOnce(`m:${inst.name}:${attr}:${p.path.join('.')}`, `${attr}="${p.path.join('.')}" in ${inst.name}: no such member`)
22
+ return m
23
+ }
24
+ export function mismatch(inst: Instance, attr: string, _el: Element, server: unknown, client: unknown): void {
25
+ if (inst.booted) return // element created after mount (new x-for row / x-if clone): nothing was server-rendered
26
+ warnOnce(`x:${inst.name}:${attr}`, `first-paint mismatch ${attr} in ${inst.name}: ${JSON.stringify(server)} vs ${JSON.stringify(client)}`)
27
+ }
@@ -0,0 +1,84 @@
1
+ import { BINDERS, bindElement, walkChildren, parsed, read, member, warnOnce, computed, signal, hooks, type Signal } from '../core-api'
2
+
3
+ type RowScope = Record<string, unknown> & { __own: Signal<number> }
4
+
5
+ const SYNTAX = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s+in\s+([A-Za-z_$][\w$.]*(?::[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)?)\s+by\s+([A-Za-z_$][\w$.]*)\s*$/
6
+ const done = new WeakSet<Element>()
7
+
8
+ BINDERS.push({
9
+ match: (a) => a === 'x-for',
10
+ bind: ({ inst, el, attr, raw, scope }) => {
11
+ if (done.has(el)) return
12
+ const m = SYNTAX.exec(raw)
13
+ if (!m) { warnOnce(`for:${inst.name}:${raw}`, `x-for="${raw}" in ${inst.name}: bad syntax`); return }
14
+ const [, itemName, indexName, sourcePath, keyPath] = m as unknown as [string, string, string | undefined, string, string]
15
+ const src = parsed(inst, attr, sourcePath)!; const keyP = parsed(inst, attr, keyPath)!
16
+
17
+ // All server-rendered siblings carrying the same x-for are rows of this list; the first is the template.
18
+ const parent = el.parentElement!
19
+ const siblings = Array.from(parent.children).filter((c) => c.getAttribute('x-for') === raw)
20
+ siblings.forEach((s) => done.add(s))
21
+ const template = el.cloneNode(true) as Element
22
+ template.removeAttribute('x-for'); template.removeAttribute('hidden')
23
+ const anchor = document.createComment('x-for')
24
+ parent.insertBefore(anchor, el)
25
+ // Server-rendered rows (not the hidden zero-row template) are adopted in order on the first run.
26
+ const adopt = siblings.filter((s) => !s.hasAttribute('hidden'))
27
+ if (!adopt.includes(el)) el.remove()
28
+ let firstRun = true
29
+
30
+ let rows = new Map<unknown, Element[]>() // key -> elements (array to tolerate duplicates)
31
+ let order: Element[] = []
32
+
33
+ inst.effect(() => {
34
+ const r = read(inst, src, scope, attr); if (!r.ok) return
35
+ const list = Array.isArray(r.value) ? (r.value as unknown[]) : []
36
+ const keyFn = member(inst, keyP, attr)
37
+ if (typeof keyFn !== 'function') return
38
+ const created: Element[] = []
39
+ const next = new Map<unknown, Element[]>(); const nextOrder: Element[] = []
40
+ const seen = new Set<unknown>()
41
+ list.forEach((item, i) => {
42
+ let key = (keyFn as (x: unknown) => unknown)(item)
43
+ // `undefined` is a row whose element arm is null (`it.link && <li key={it.link.id}/>`): it
44
+ // carries no key in React either, so it is positional and never a duplicate.
45
+ if (key === undefined) key = `__nil_${i}`
46
+ else if (seen.has(key)) { warnOnce(`fd:${inst.name}:${raw}`, `x-for in ${inst.name}: duplicate key ${JSON.stringify(key)}`); key = `__dup_${i}` }
47
+ seen.add(key)
48
+ const pool = rows.get(key)
49
+ let node = pool?.shift()
50
+ if (node) {
51
+ // Reused row: refresh its scope and poke its tick so every binder that read the scope re-runs.
52
+ const rs = (node as unknown as { __scope: RowScope }).__scope
53
+ if (!Object.is(rs[itemName], item) || (indexName && rs[indexName] !== i)) {
54
+ rs[itemName] = item; if (indexName) rs[indexName] = i
55
+ rs.__own.set((n) => n + 1)
56
+ }
57
+ }
58
+ if (!node) {
59
+ const adopted = firstRun ? adopt.shift() : undefined
60
+ node = adopted ?? (template.cloneNode(true) as Element)
61
+ if (!adopted) created.push(node)
62
+ adopted?.removeAttribute('x-for')
63
+ const own = signal(0)
64
+ const outer = scope.__tick as (() => number) | undefined
65
+ const rowScope: RowScope = { ...scope, [itemName]: item, ...(indexName ? { [indexName]: i } : {}), __own: own, __tick: computed(() => (outer ? outer() : 0) + own()) }
66
+ ;(node as unknown as { __scope: RowScope }).__scope = rowScope
67
+ // a row that is itself a nested host is bound by its own instance
68
+ if (!node.hasAttribute('x-data')) { bindElement(inst, node, rowScope); walkChildren(inst, node, rowScope) }
69
+ }
70
+ ;(next.get(key) ?? next.set(key, []).get(key)!).push(node)
71
+ nextOrder.push(node)
72
+ })
73
+ for (const [, pool] of rows) for (const stale of pool) stale.remove()
74
+ for (const left of adopt.splice(0)) left.remove()
75
+ firstRun = false
76
+ // place in order after the anchor
77
+ let ref: Node = anchor
78
+ for (const node of nextOrder) { if (ref.nextSibling !== node) parent.insertBefore(node, ref.nextSibling); ref = node }
79
+ rows = next; order = nextOrder
80
+ for (const n of created) hooks.mountTree(n) // nested hosts in new rows mount now, not on the observer's next tick
81
+ })
82
+ inst.onCleanup(() => { for (const n of order) n.remove() })
83
+ },
84
+ })
@@ -0,0 +1,20 @@
1
+ import { BINDERS, parsed, read, mismatch } from '../core-api'
2
+ /** Both sides through the HTML parser (inert `<template>`), so `<br/>` vs `<br>` or entity spelling is not a mismatch. */
3
+ function sameMarkup(el: Element, next: string): boolean {
4
+ const t = document.createElement('template'); t.innerHTML = next
5
+ return t.innerHTML === el.innerHTML
6
+ }
7
+ // `x-html` is emitted only by the compiler, for a `dangerouslySetInnerHTML` whose value changes after first paint.
8
+ BINDERS.push({
9
+ match: (a) => a === 'x-html',
10
+ bind: ({ inst, el, attr, raw, scope }) => {
11
+ const p = parsed(inst, attr, raw); if (!p) return
12
+ let first = true
13
+ inst.effect(() => {
14
+ const r = read(inst, p, scope, attr); if (!r.ok) return
15
+ const next = r.value == null ? '' : String(r.value)
16
+ if (first) { first = false; if (!sameMarkup(el, next)) { mismatch(inst, attr, el, el.innerHTML, next); el.innerHTML = next }; return }
17
+ el.innerHTML = next
18
+ })
19
+ },
20
+ })
@@ -0,0 +1,25 @@
1
+ import { BINDERS, bindElement, walkChildren, parsed, read, hooks } from '../core-api'
2
+ BINDERS.push({
3
+ match: (a) => a === 'x-if',
4
+ bind: ({ inst, el, attr, raw, scope }) => {
5
+ const p = parsed(inst, attr, raw); if (!p) return
6
+ const anchor = document.createComment('x-if')
7
+ const template = el.cloneNode(true) as Element
8
+ template.removeAttribute('x-if'); template.removeAttribute('hidden')
9
+ el.replaceWith(anchor) // the server-rendered element becomes the first clone below
10
+ let current: Element | null = null
11
+ inst.effect(() => {
12
+ const r = read(inst, p, scope, attr); if (!r.ok) return
13
+ const want = Boolean(r.value)
14
+ if (want && !current) {
15
+ current = template.cloneNode(true) as Element
16
+ anchor.after(current)
17
+ bindElement(inst, current, scope); walkChildren(inst, current, scope)
18
+ hooks.mountTree(current)
19
+ } else if (!want && current) {
20
+ current.remove(); current = null // the observer disposes nested hosts
21
+ }
22
+ })
23
+ inst.onCleanup(() => { current?.remove() })
24
+ },
25
+ })
@@ -0,0 +1,34 @@
1
+ import type { Instance } from '../instance'
2
+ import type { Scope } from '../value'
3
+
4
+ export type Binder = (ctx: { inst: Instance; el: Element; attr: string; raw: string; scope: Scope }) => void
5
+ /** attribute-name prefix → binder. Exact names first, then prefixes (x-bind-, x-on-). */
6
+ export const BINDERS: Array<{ match: (attr: string) => boolean; bind: Binder }> = []
7
+
8
+ const HOST_ATTR = 'x-data'
9
+ /** Install binders for every x-* attribute under `el` (inclusive), not descending into nested hosts. */
10
+ export function bindHost(inst: Instance, el: Element, scope: Scope): void {
11
+ bindElement(inst, el, scope)
12
+ walkChildren(inst, el, scope)
13
+ }
14
+ export function walkChildren(inst: Instance, el: Element, scope: Scope): void {
15
+ if (el.hasAttribute('x-html') || el.hasAttribute('x-raw') || el.localName === 'brust-island') return // raw markup / React island: inert
16
+ for (const child of Array.from(el.children)) {
17
+ if ('__scope' in child) continue // x-for row: already bound with its own scope
18
+ if (child.hasAttribute('x-for')) { bindElement(inst, child, scope); continue } // x-for owns the element (even when it is also a host)
19
+ if (child.hasAttribute(HOST_ATTR)) continue // nested host: its own instance binds it
20
+ bindElement(inst, child, scope)
21
+ if (!child.hasAttribute('x-if')) walkChildren(inst, child, scope) // x-if owns its subtree
22
+ }
23
+ }
24
+ export function bindElement(inst: Instance, el: Element, scope: Scope): void {
25
+ // Structural directives own the element: x-for/x-if re-create it from a template, so the
26
+ // remaining attributes are bound on the clones (with the right scope), never on this original.
27
+ const structural = el.hasAttribute('x-for') ? 'x-for' : el.hasAttribute('x-if') ? 'x-if' : null
28
+ for (const attr of Array.from(el.attributes)) {
29
+ if (structural && attr.name !== structural) continue
30
+ if (!attr.name.startsWith('x-') || attr.name === HOST_ATTR || attr.name === 'x-props' || attr.name === 'x-props-bind') continue
31
+ const b = BINDERS.find((b) => b.match(attr.name))
32
+ if (b) b.bind({ inst, el, attr: attr.name, raw: attr.value, scope })
33
+ }
34
+ }
@@ -0,0 +1,30 @@
1
+ import { BINDERS, parsed, member, isSignal, warnOnce, type Signal } from '../core-api'
2
+ BINDERS.push({
3
+ match: (a) => a === 'x-model',
4
+ bind: ({ inst, el, attr, raw }) => {
5
+ const p = parsed(inst, attr, raw); if (!p) return
6
+ const sig = member(inst, p, attr)
7
+ if (!isSignal(sig) || typeof (sig as Signal<unknown>).set !== 'function') {
8
+ warnOnce(`model:${inst.name}:${raw}`, `x-model="${raw}" in ${inst.name} needs a signal`); return
9
+ }
10
+ const s = sig as Signal<unknown>
11
+ const input = el as HTMLInputElement
12
+ const type = input.type
13
+ if (input instanceof HTMLInputElement && type === 'checkbox') {
14
+ const h = () => s.set(input.checked); el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
15
+ inst.effect(() => { input.checked = Boolean(s()) })
16
+ } else if (input instanceof HTMLInputElement && type === 'radio') {
17
+ const h = () => { if (input.checked) s.set(input.value) }; el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
18
+ inst.effect(() => { input.checked = s() === input.value })
19
+ } else if (el instanceof HTMLSelectElement) {
20
+ if (el.multiple) { warnOnce(`mm:${inst.name}`, `select[multiple] unsupported`); return }
21
+ const h = () => s.set(el.value); el.addEventListener('change', h); inst.onCleanup(() => el.removeEventListener('change', h))
22
+ const apply = () => { const v = s(); if (el.value !== String(v)) el.value = String(v ?? '') }
23
+ inst.effect(apply)
24
+ const mo = new MutationObserver(() => apply()); mo.observe(el, { childList: true }); inst.onCleanup(() => mo.disconnect())
25
+ } else {
26
+ const h = () => s.set(input.value); el.addEventListener('input', h); inst.onCleanup(() => el.removeEventListener('input', h))
27
+ inst.effect(() => { const v = s(); if (input.value !== String(v ?? '')) input.value = String(v ?? '') })
28
+ }
29
+ },
30
+ })
@@ -0,0 +1,16 @@
1
+ import { BINDERS, parsed, member } from '../core-api'
2
+ BINDERS.push({
3
+ match: (a) => a.startsWith('x-on-'),
4
+ bind: ({ inst, el, attr, raw, scope }) => {
5
+ const event = attr.slice('x-on-'.length)
6
+ const p = parsed(inst, attr, raw); if (!p) return
7
+ const handler = (e: Event) => {
8
+ const fn = member(inst, p, attr)
9
+ if (typeof fn !== 'function') return
10
+ const args = p.bindings.map((b) => scope[b])
11
+ ;(fn as (...a: unknown[]) => void)(...args, e)
12
+ }
13
+ el.addEventListener(event, handler)
14
+ inst.onCleanup(() => el.removeEventListener(event, handler))
15
+ },
16
+ })
@@ -0,0 +1,9 @@
1
+ import { BINDERS } from '../core-api'
2
+ BINDERS.push({
3
+ match: (a) => a === 'x-ref',
4
+ bind: ({ inst, el, raw }) => {
5
+ const r = inst.ref(raw.trim())
6
+ r.current = el as HTMLElement
7
+ inst.onCleanup(() => { if (r.current === el) r.current = null })
8
+ },
9
+ })
@@ -0,0 +1,11 @@
1
+ import { BINDERS, parsed, read } from '../core-api'
2
+ BINDERS.push({
3
+ match: (a) => a === 'x-show',
4
+ bind: ({ inst, el, attr, raw, scope }) => {
5
+ const p = parsed(inst, attr, raw); if (!p) return
6
+ inst.effect(() => {
7
+ const r = read(inst, p, scope, attr); if (!r.ok) return
8
+ ;(el as HTMLElement).style.display = r.value ? '' : 'none'
9
+ })
10
+ },
11
+ })
@@ -0,0 +1,14 @@
1
+ import { BINDERS, parsed, read, mismatch } from '../core-api'
2
+ BINDERS.push({
3
+ match: (a) => a === 'x-text',
4
+ bind: ({ inst, el, attr, raw, scope }) => {
5
+ const p = parsed(inst, attr, raw); if (!p) return
6
+ let first = true
7
+ inst.effect(() => {
8
+ const r = read(inst, p, scope, attr); if (!r.ok) return
9
+ const next = r.value == null ? '' : String(r.value)
10
+ if (first) { first = false; if (el.textContent !== next) { mismatch(inst, attr, el, el.textContent, next); el.textContent = next }; return }
11
+ el.textContent = next
12
+ })
13
+ },
14
+ })
@@ -0,0 +1,6 @@
1
+ // The directives nearly every interactive page uses, shipped as ONE module (see README "Feature modules").
2
+ import '../directives/text'
3
+ import '../directives/show'
4
+ import '../directives/bind'
5
+ import '../directives/on'
6
+ import '../directives/ref'
@@ -0,0 +1 @@
1
+ import '../directives/for'
@@ -0,0 +1 @@
1
+ import '../directives/html'
@@ -0,0 +1 @@
1
+ import '../directives/if'
@@ -0,0 +1,8 @@
1
+ // Every feature at once: what `@brust/runtime-dom` (the full entry) loads. The browser build never uses this file;
2
+ // it imports exactly the features a route needs.
3
+ import './basics'
4
+ import './if'
5
+ import './for'
6
+ import './model'
7
+ import './html'
8
+ import './island'
@@ -0,0 +1,9 @@
1
+ // React island hydration glue. Registers on the core's island hooks; hosts already in the document when this module
2
+ // arrives (the core mounts first) are scheduled right away.
3
+ import { islandHooks } from '../core-api'
4
+ import { scheduleIslands, cancelIslands } from '../island'
5
+ islandHooks.schedule = scheduleIslands
6
+ islandHooks.cancel = cancelIslands
7
+ if (typeof document !== 'undefined') scheduleIslands(document.documentElement)
8
+ export { defineIsland } from '../island'
9
+ export type { IslandHydrate } from '../island'
@@ -0,0 +1 @@
1
+ import '../directives/model'
@@ -0,0 +1,6 @@
1
+ // The store module: `defineStore` plus the reactive primitives a store's code imports (`@brust/core/store` aliases here),
2
+ // re-exported from the core so there is one signal graph per page.
3
+ export { signal, computed, effect, batch, untracked, isSignal, isComputed } from '../core-api'
4
+ export type { Signal, Computed, Dispose } from '../core-api'
5
+ export { defineStore } from '../store/define-store'
6
+ export type { StoreHandle, Snapshot } from '../store/define-store'
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // The full runtime: the core plus every feature. Tests and non-split consumers import this; the browser build
2
+ // loads `./core` and only the `./features/*` a route needs.
3
+ export * from './core'
4
+ export { defineStore } from './store/define-store'
5
+ export type { StoreHandle, Snapshot } from './store/define-store'
6
+ export { defineIsland } from './island'
7
+ export type { IslandHydrate } from './island'
8
+ import './features/index'