@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 +106 -2
- package/package.json +68 -4
- package/src/core-api.ts +10 -0
- package/src/core.ts +8 -0
- package/src/directives/bind.ts +31 -0
- package/src/directives/common.ts +27 -0
- package/src/directives/for.ts +84 -0
- package/src/directives/html.ts +20 -0
- package/src/directives/if.ts +25 -0
- package/src/directives/index.ts +34 -0
- package/src/directives/model.ts +30 -0
- package/src/directives/on.ts +16 -0
- package/src/directives/ref.ts +9 -0
- package/src/directives/show.ts +11 -0
- package/src/directives/text.ts +14 -0
- package/src/features/basics.ts +6 -0
- package/src/features/for.ts +1 -0
- package/src/features/html.ts +1 -0
- package/src/features/if.ts +1 -0
- package/src/features/index.ts +8 -0
- package/src/features/island.ts +9 -0
- package/src/features/model.ts +1 -0
- package/src/features/store.ts +6 -0
- package/src/index.ts +8 -0
- package/src/instance.ts +98 -0
- package/src/island.ts +89 -0
- package/src/mount.ts +76 -0
- package/src/props-bind.ts +30 -0
- package/src/registry.ts +38 -0
- package/src/signal.ts +110 -0
- package/src/store/define-store.ts +106 -0
- package/src/store/index.ts +5 -0
- package/src/store/internal.ts +5 -0
- package/src/store/react.ts +21 -0
- package/src/store/registry.ts +103 -0
- package/src/store/serialize.ts +33 -0
- package/src/value.ts +47 -0
- package/src/warn.ts +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brust/runtime-dom
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/core-api.ts
ADDED
|
@@ -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,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 @@
|
|
|
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'
|