@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 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.3.x`: the compiler API and supported TSX surface may change.
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 `value` accept normal state-dependent TSX expressions:
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
- Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property.
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 `className`, `disabled`, and controlled `value` patches
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
- - Keyed lists and generalized JSX-valued locals
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 | **385 ms** |
209
- | Astro | Yes | **158 B** | **365 B** | 948 ms |
210
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 885 ms |
211
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 622 ms |
212
- | Vue CSR | No | 24.0 KB | 60.3 KB | 822 ms |
213
- | React CSR | No | 59.2 KB | 189.0 KB | 1084 ms |
214
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3074 ms |
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
 
@@ -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 binding commit hooks, emitted only when needed.
8
- - `binding-runtime.js`: optional reactive attributes and conditional DOM range patches.
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, mountText, registerCommitter } from "./shared-runtime.js"
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 target of ["class", "disabled", "value"]) {
56
- for (const node of matching(root, `[data-k-bind-${target}]`)) {
57
- if (mountedBindings.has(node)) continue
58
- mountedBindings.add(node)
59
- const descriptor = JSON.parse(node.dataset[`kBind${capitalize(target)}`])
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
- bindingRegistrations.set(node, [[descriptor.state, binding]])
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 unmountDom(root) {
123
- for (const node of matching(root, "[data-k-bind-class],[data-k-bind-disabled],[data-k-bind-value]")) {
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
- for (const node of matching(root, "[data-k-bind-class],[data-k-bind-disabled],[data-k-bind-value],template[data-k-if]")) {
142
- if (range.comparePoint(node, 0) === 0) unmountDom(node)
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
+ }