@kudzujs/core 0.3.0 → 0.4.1
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 +56 -12
- package/framework/README.md +4 -3
- package/framework/binding-runtime.js +41 -22
- package/framework/build.mjs +254 -13
- package/framework/core.d.ts +7 -1
- package/framework/core.mjs +182 -30
- package/framework/list-runtime.js +233 -0
- package/framework/native-runtime.js +31 -12
- package/framework/shared-runtime.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ HTML-first TSX framework with synchronous state semantics and no virtual DOM.
|
|
|
8
8
|
|
|
9
9
|
Kudzu keeps the familiar function-component, props, children, event-handler, and `useState` shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers compile to external ESM.
|
|
10
10
|
|
|
11
|
-
> Experimental `0.
|
|
11
|
+
> Experimental `0.4.x`: the compiler API and supported TSX surface may change.
|
|
12
12
|
|
|
13
13
|
Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
|
|
14
14
|
|
|
@@ -100,15 +100,28 @@ The handler above increments by two and patches its bound DOM once. Inspect the
|
|
|
100
100
|
|
|
101
101
|
## Reactive Attributes
|
|
102
102
|
|
|
103
|
-
`className`, `disabled`, and controlled `
|
|
103
|
+
`className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
|
|
104
104
|
|
|
105
105
|
```tsx
|
|
106
106
|
<div className={active ? "active" : "idle"} />
|
|
107
107
|
<button disabled={loading}>Save</button>
|
|
108
108
|
<input value={name} onInput={event => setName(event.currentTarget.value)} />
|
|
109
|
+
<input type="checkbox" checked={subscribed} onChange={event => setSubscribed(event.currentTarget.checked)} />
|
|
110
|
+
<select value={theme} onChange={event => setTheme(event.currentTarget.value)} />
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
Regular attributes use the same expressions without an allowlist:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<button
|
|
117
|
+
aria-expanded={open}
|
|
118
|
+
data-state={open ? "open" : "closed"}
|
|
119
|
+
hidden={!visible}
|
|
120
|
+
title={open ? "Close menu" : "Open menu"}
|
|
121
|
+
/>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property. `aria-*` and `data-*` boolean values serialize as `"true"` or `"false"`; ordinary false values remove the attribute. Reactive `style`, `ref`, and `dangerouslySetInnerHTML` remain unsupported.
|
|
112
125
|
|
|
113
126
|
## Conditional DOM
|
|
114
127
|
|
|
@@ -131,6 +144,32 @@ Logical state persists across branch switches, while uncontrolled DOM state rese
|
|
|
131
144
|
|
|
132
145
|
Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
|
|
133
146
|
|
|
147
|
+
## Keyed Lists
|
|
148
|
+
|
|
149
|
+
Map local array state directly to one keyed JSX element per item:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
const [items, setItems] = useState([
|
|
153
|
+
{ id: 1, name: "Oak", done: false },
|
|
154
|
+
{ id: 2, name: "Pine", done: true }
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
<ul>{items.map(item =>
|
|
158
|
+
<li
|
|
159
|
+
key={item.id}
|
|
160
|
+
className={item.done ? "done" : "active"}
|
|
161
|
+
aria-label={`${item.name} item`}
|
|
162
|
+
>
|
|
163
|
+
{item.name.toUpperCase()}
|
|
164
|
+
<button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
|
|
165
|
+
</li>
|
|
166
|
+
)}</ul>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use delegated events and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
|
|
170
|
+
|
|
171
|
+
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, fragments, and reactive `style`, `ref`, or `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
172
|
+
|
|
134
173
|
## Normal JavaScript
|
|
135
174
|
|
|
136
175
|
Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
|
|
@@ -151,6 +190,8 @@ async function load() {
|
|
|
151
190
|
|
|
152
191
|
Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
|
|
153
192
|
|
|
193
|
+
Native handlers are delegated after normal event bubbling and run from the target toward matching Kudzu ancestors in deterministic order. Delegated handlers cannot call or reference `preventDefault`, `stopPropagation`, or `stopImmediatePropagation`; the compiler rejects those methods because external ESM cannot apply them with correct synchronous DOM semantics.
|
|
194
|
+
|
|
154
195
|
## Rendering
|
|
155
196
|
|
|
156
197
|
```text
|
|
@@ -158,6 +199,7 @@ TSX
|
|
|
158
199
|
├─ static component → HTML
|
|
159
200
|
├─ ordered state setter → behavior command
|
|
160
201
|
├─ conditional child → bounded DOM range
|
|
202
|
+
├─ keyed state map → keyed DOM moves
|
|
161
203
|
└─ normal JS handler → external ESM
|
|
162
204
|
```
|
|
163
205
|
|
|
@@ -185,12 +227,14 @@ Supported:
|
|
|
185
227
|
- Synchronous and async event handlers
|
|
186
228
|
- Serializable component-local captures
|
|
187
229
|
- Direct text DOM patches
|
|
188
|
-
- Reactive
|
|
230
|
+
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
231
|
+
- Controlled `value` and `checked` form properties
|
|
189
232
|
- Conditional child `&&` and ternary DOM patches
|
|
233
|
+
- Direct keyed local-state lists
|
|
190
234
|
|
|
191
235
|
Not implemented yet:
|
|
192
236
|
|
|
193
|
-
-
|
|
237
|
+
- Generalized JSX-valued locals and non-direct list item expressions
|
|
194
238
|
- Server actions and request-time SSR
|
|
195
239
|
- Imported client helpers and React package islands
|
|
196
240
|
- HMR and framework DevTools
|
|
@@ -205,13 +249,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
205
249
|
|
|
206
250
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
207
251
|
|---|---:|---:|---:|---:|
|
|
208
|
-
| Kudzu | Yes | 487 B | 1.4 KB | **
|
|
209
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
210
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
211
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
212
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
213
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
214
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
252
|
+
| Kudzu | Yes | 487 B | 1.4 KB | **373 ms** |
|
|
253
|
+
| Astro | Yes | **158 B** | **365 B** | 877 ms |
|
|
254
|
+
| Svelte CSR | No | 10.5 KB | 26.9 KB | 837 ms |
|
|
255
|
+
| Qwik CSR | No | 20.6 KB | 57.8 KB | 626 ms |
|
|
256
|
+
| Vue CSR | No | 24.0 KB | 60.3 KB | 778 ms |
|
|
257
|
+
| React CSR | No | 59.2 KB | 189.0 KB | 1049 ms |
|
|
258
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 3099 ms |
|
|
215
259
|
|
|
216
260
|
Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
|
|
217
261
|
|
package/framework/README.md
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
- `core.mjs`: server-side JSX rendering, state slots, behavior metadata, and serializable capture validation.
|
|
5
5
|
- `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
|
-
- `shared-runtime.js`: command runtime with
|
|
8
|
-
- `binding-runtime.js`: optional
|
|
7
|
+
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
|
+
- `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
|
|
9
|
+
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic item-handler scopes, moves, and cleanup.
|
|
9
10
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
10
11
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
11
12
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
12
13
|
|
|
13
|
-
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes add `binding-runtime.js`; native handlers add `native-runtime.js`. Generated evaluators live under `dist/assets/handlers/`.
|
|
14
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers/`.
|
|
14
15
|
|
|
15
16
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserState,
|
|
1
|
+
import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
3
|
|
|
4
4
|
const imports = new Map()
|
|
@@ -8,17 +8,25 @@ const mountedBindings = new WeakSet()
|
|
|
8
8
|
const mountedConditions = new WeakSet()
|
|
9
9
|
const bindingRegistrations = new WeakMap()
|
|
10
10
|
const conditionRegistrations = new WeakMap()
|
|
11
|
+
const bindingTypes = ["class", "disabled", "value", "checked"]
|
|
12
|
+
const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
|
|
11
13
|
|
|
12
14
|
export function patchBinding(node, target, value) {
|
|
13
15
|
if (target === "disabled") {
|
|
14
16
|
node.toggleAttribute("disabled", Boolean(value))
|
|
17
|
+
} else if (target === "checked") {
|
|
18
|
+
node.checked = Boolean(value)
|
|
15
19
|
} else if (target === "value") {
|
|
16
20
|
const next = value == null ? "" : String(value)
|
|
17
21
|
if (node.value !== next) node.value = next
|
|
18
|
-
} else if (value == null || value === false) {
|
|
22
|
+
} else if (target === "class" && (value == null || value === false)) {
|
|
19
23
|
node.removeAttribute("class")
|
|
20
|
-
} else {
|
|
24
|
+
} else if (target === "class") {
|
|
21
25
|
node.setAttribute("class", String(value))
|
|
26
|
+
} else if (value == null || (value === false && !isStringBooleanAttribute(target))) {
|
|
27
|
+
node.removeAttribute(target)
|
|
28
|
+
} else {
|
|
29
|
+
node.setAttribute(target, value === true && !isStringBooleanAttribute(target) ? "" : String(value))
|
|
22
30
|
}
|
|
23
31
|
}
|
|
24
32
|
|
|
@@ -42,37 +50,38 @@ function commitConditions(id) {
|
|
|
42
50
|
|
|
43
51
|
registerCommitter(commitBindings)
|
|
44
52
|
registerCommitter(commitConditions)
|
|
53
|
+
registerMountHook(mountBindings)
|
|
54
|
+
registerMountHook(mountConditions)
|
|
55
|
+
registerUnmountHook(unmountBindings)
|
|
56
|
+
registerUnmountHook(unmountConditions)
|
|
45
57
|
|
|
46
58
|
if (typeof document !== "undefined") mountDom(document)
|
|
47
59
|
|
|
48
|
-
function mountDom(root) {
|
|
49
|
-
mountText(root)
|
|
50
|
-
mountBindings(root)
|
|
51
|
-
mountConditions(root)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
60
|
function mountBindings(root) {
|
|
55
|
-
for (const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
for (const node of matching(root, bindingSelector)) {
|
|
62
|
+
if (mountedBindings.has(node)) continue
|
|
63
|
+
mountedBindings.add(node)
|
|
64
|
+
const registrations = []
|
|
65
|
+
bindingRegistrations.set(node, registrations)
|
|
66
|
+
const descriptors = bindingTypes.flatMap(target => node.hasAttribute(`data-k-bind-${target}`)
|
|
67
|
+
? [[target, JSON.parse(node.dataset[`kBind${capitalize(target)}`])]]
|
|
68
|
+
: [])
|
|
69
|
+
if (node.dataset.kBindAttrs) descriptors.push(...JSON.parse(node.dataset.kBindAttrs).map(({ target, ...descriptor }) => [target, descriptor]))
|
|
70
|
+
for (const [target, descriptor] of descriptors) {
|
|
60
71
|
if (descriptor.state) {
|
|
61
72
|
const binding = { node, target, read: () => browserState.get(descriptor.state) }
|
|
62
73
|
register(bindingTargets, descriptor.state, binding)
|
|
63
|
-
|
|
74
|
+
registrations.push([descriptor.state, binding])
|
|
64
75
|
patchBinding(node, target, binding.read())
|
|
65
76
|
continue
|
|
66
77
|
}
|
|
67
78
|
loadEvaluator(descriptor).then(evaluator => {
|
|
68
79
|
if (!node.isConnected) return
|
|
69
80
|
const binding = { node, target, read: evaluator.read }
|
|
70
|
-
const registrations = []
|
|
71
81
|
for (const id of evaluator.stateIds) {
|
|
72
82
|
register(bindingTargets, id, binding)
|
|
73
83
|
registrations.push([id, binding])
|
|
74
84
|
}
|
|
75
|
-
bindingRegistrations.set(node, registrations)
|
|
76
85
|
patchBinding(node, target, binding.read())
|
|
77
86
|
}).catch(error => console.error(error))
|
|
78
87
|
}
|
|
@@ -117,14 +126,21 @@ function updateCondition(condition) {
|
|
|
117
126
|
condition.end.parentNode.insertBefore(fragment, condition.end)
|
|
118
127
|
condition.current = next
|
|
119
128
|
for (const node of nodes) mountDom(node)
|
|
129
|
+
const select = condition.start.closest("select[data-k-bind-value]")
|
|
130
|
+
for (const binding of new Set((bindingRegistrations.get(select) ?? []).map(([, entry]) => entry))) {
|
|
131
|
+
patchBinding(binding.node, binding.target, binding.read())
|
|
132
|
+
}
|
|
120
133
|
}
|
|
121
134
|
|
|
122
|
-
function
|
|
123
|
-
for (const node of matching(root,
|
|
135
|
+
function unmountBindings(root) {
|
|
136
|
+
for (const node of matching(root, bindingSelector)) {
|
|
124
137
|
for (const [id, binding] of bindingRegistrations.get(node) ?? []) bindingTargets.get(id)?.delete(binding)
|
|
125
138
|
bindingRegistrations.delete(node)
|
|
126
139
|
mountedBindings.delete(node)
|
|
127
140
|
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unmountConditions(root) {
|
|
128
144
|
for (const start of matching(root, "template[data-k-if]")) {
|
|
129
145
|
const registration = conditionRegistrations.get(start)
|
|
130
146
|
for (const [id, condition] of registration?.registrations ?? []) conditionTargets.get(id)?.delete(condition)
|
|
@@ -138,9 +154,8 @@ function removeConditionRange(start, end) {
|
|
|
138
154
|
range.setStartAfter(start)
|
|
139
155
|
range.setEndBefore(end)
|
|
140
156
|
const root = range.commonAncestorContainer
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
157
|
+
const nodes = matching(root, "*").filter(node => range.comparePoint(node, 0) === 0)
|
|
158
|
+
for (const node of nodes) if (!nodes.some(parent => parent !== node && parent.contains(node))) unmountDom(node)
|
|
144
159
|
range.deleteContents()
|
|
145
160
|
}
|
|
146
161
|
|
|
@@ -207,3 +222,7 @@ function matching(root, selector) {
|
|
|
207
222
|
function capitalize(value) {
|
|
208
223
|
return value[0].toUpperCase() + value.slice(1)
|
|
209
224
|
}
|
|
225
|
+
|
|
226
|
+
function isStringBooleanAttribute(name) {
|
|
227
|
+
return name.startsWith("aria-") || name.startsWith("data-")
|
|
228
|
+
}
|