@kudzujs/core 0.3.0 → 0.4.0

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,23 @@ 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" },
154
+ { id: 2, name: "Pine" }
155
+ ])
156
+
157
+ <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
158
+ ```
159
+
160
+ 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. Each item must be a plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, plain objects, and primitive values.
161
+
162
+ The MVP requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Item data may appear only as direct `item.<field>` text or attributes. Item-derived expressions, item-local handlers, nested conditions or lists, component tags, and fragments are rejected at build time. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
163
+
134
164
  ## Normal JavaScript
135
165
 
136
166
  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.
@@ -158,6 +188,7 @@ TSX
158
188
  ├─ static component → HTML
159
189
  ├─ ordered state setter → behavior command
160
190
  ├─ conditional child → bounded DOM range
191
+ ├─ keyed state map → keyed DOM moves
161
192
  └─ normal JS handler → external ESM
162
193
  ```
163
194
 
@@ -185,12 +216,14 @@ Supported:
185
216
  - Synchronous and async event handlers
186
217
  - Serializable component-local captures
187
218
  - Direct text DOM patches
188
- - Reactive `className`, `disabled`, and controlled `value` patches
219
+ - Reactive standard, `aria-*`, and `data-*` attributes
220
+ - Controlled `value` and `checked` form properties
189
221
  - Conditional child `&&` and ternary DOM patches
222
+ - Direct keyed local-state lists
190
223
 
191
224
  Not implemented yet:
192
225
 
193
- - Keyed lists and generalized JSX-valued locals
226
+ - Generalized JSX-valued locals and non-direct list item expressions
194
227
  - Server actions and request-time SSR
195
228
  - Imported client helpers and React package islands
196
229
  - HMR and framework DevTools
@@ -205,13 +238,13 @@ Same counter with initial value `7` and increment/decrement buttons:
205
238
 
206
239
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
207
240
  |---|---:|---:|---:|---:|
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 |
241
+ | Kudzu | Yes | 487 B | 1.4 KB | **373 ms** |
242
+ | Astro | Yes | **158 B** | **365 B** | 877 ms |
243
+ | Svelte CSR | No | 10.5 KB | 26.9 KB | 837 ms |
244
+ | Qwik CSR | No | 20.6 KB | 57.8 KB | 626 ms |
245
+ | Vue CSR | No | 24.0 KB | 60.3 KB | 778 ms |
246
+ | React CSR | No | 59.2 KB | 189.0 KB | 1049 ms |
247
+ | Next.js | Yes | 182.1 KB | 652.2 KB | 3099 ms |
215
248
 
216
249
  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
250
 
@@ -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, updates, 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
+ }
@@ -31,6 +31,7 @@ export async function build({ quiet = false } = {}) {
31
31
 
32
32
  let behaviorCount = 0
33
33
  let bindingCount = 0
34
+ let listCount = 0
34
35
  let stateSeedCount = 0
35
36
  const plans = []
36
37
  const hasStyles = await exists(join(sourceDirectory, "style.css"))
@@ -51,6 +52,7 @@ export async function build({ quiet = false } = {}) {
51
52
  plans.push({ route: `/${route}`, ...result.plan })
52
53
  if (result.hasBehaviors) behaviorCount++
53
54
  if (result.hasBindings) bindingCount++
55
+ if (result.hasLists) listCount++
54
56
  if (result.hasStateSeed) stateSeedCount++
55
57
  }
56
58
 
@@ -59,7 +61,7 @@ export async function build({ quiet = false } = {}) {
59
61
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
60
62
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
61
63
  if (behaviorCount) {
62
- const runtimeFile = bindingCount ? "./shared-runtime.js" : "./runtime.js"
64
+ const runtimeFile = bindingCount || listCount ? "./shared-runtime.js" : "./runtime.js"
63
65
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
64
66
  await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
65
67
  }
@@ -71,6 +73,11 @@ export async function build({ quiet = false } = {}) {
71
73
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
72
74
  await writeFile(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime)
73
75
  }
76
+ if (listCount) {
77
+ const listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
78
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
79
+ await writeFile(join(assetsDirectory, "kudzu-list.js"), listRuntime)
80
+ }
74
81
  if (hasNativeHandlers) {
75
82
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
76
83
  .replace('"./runtime.js"', '"./kudzu.js"')
@@ -184,9 +191,11 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
184
191
  const factory = context.factory
185
192
  const settersByFunction = new Map()
186
193
  const functions = new Map()
194
+ const listFieldExpressions = new WeakSet()
187
195
  let usesBehavior = false
188
196
  let usesBinding = false
189
197
  let usesConditional = false
198
+ let usesList = false
190
199
 
191
200
  const collect = node => {
192
201
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -229,6 +238,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
229
238
  }
230
239
 
231
240
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
241
+ const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
242
+ if (listParts) {
243
+ if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
244
+ validateKeyedList(listParts, sourceFile, listFieldExpressions)
245
+ usesBehavior = true
246
+ usesBinding = true
247
+ usesList = true
248
+ return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
249
+ listParts.state,
250
+ factory.createStringLiteral(listParts.keyField),
251
+ ts.visitNode(listParts.callback, visitor)
252
+ ]))
253
+ }
232
254
  const parts = conditionalParts(node.expression)
233
255
  if (parts) {
234
256
  const setters = settersForNode(node, settersByFunction)
@@ -245,8 +267,9 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
245
267
  }
246
268
  }
247
269
 
248
- if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["className", "disabled", "value"].includes(node.name.getText())) {
270
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["style", "key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
249
271
  const expression = node.initializer.expression
272
+ if (listFieldExpressions.has(expression)) return node
250
273
  const setters = settersForNode(node, settersByFunction)
251
274
  const usedStates = referencedStateNames(expression, setters)
252
275
  const captures = captureNames(expression, expression, setters)
@@ -279,6 +302,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
279
302
  if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
280
303
  if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
281
304
  if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
305
+ if (usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
282
306
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
283
307
  const behaviorImport = factory.createImportDeclaration(
284
308
  undefined,
@@ -289,6 +313,83 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
289
313
  }
290
314
  }
291
315
 
316
+ function keyedListParts(expression, setters) {
317
+ const value = unwrapExpression(expression)
318
+ if (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map" || !ts.isIdentifier(value.expression.expression)) return undefined
319
+ const state = value.expression.expression
320
+ if (![...setters.values()].includes(state.text)) return undefined
321
+ const callback = value.arguments[0]
322
+ if (!ts.isArrowFunction(callback) || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name)) {
323
+ throw new Error("Keyed list map callback must be an arrow function with one identifier parameter")
324
+ }
325
+ const root = unwrapExpression(callback.body)
326
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) throw new Error("Keyed list map callback must return one JSX element")
327
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
328
+ const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText() === "key")
329
+ const field = key && ts.isJsxAttribute(key) && key.initializer && ts.isJsxExpression(key.initializer) && key.initializer.expression && directProperty(key.initializer.expression, callback.parameters[0].name.text)
330
+ if (!field) throw new Error(`Keyed list root must have key={${callback.parameters[0].name.text}.<field>}`)
331
+ return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
332
+ }
333
+
334
+ function validateKeyedList(parts, sourceFile, listFieldExpressions) {
335
+ const fail = (node, message) => {
336
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
337
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
338
+ }
339
+ const validateElement = node => {
340
+ const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
341
+ if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
342
+ }
343
+ const visit = node => {
344
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
345
+ if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map") fail(node, "Nested keyed lists are not supported")
346
+ if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
347
+ if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) fail(node, "Item-local handlers are not supported in keyed lists")
348
+ if (ts.isJsxExpression(node) && node.expression) {
349
+ const expression = unwrapExpression(node.expression)
350
+ const field = directProperty(expression, parts.item)
351
+ const isRootKey = node.parent?.parent === parts.root && ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
352
+ if (field && ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
353
+ if (isRootKey || field) {
354
+ if (field) listFieldExpressions.add(node.expression)
355
+ return
356
+ }
357
+ if (conditionalParts(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
358
+ if (referencesIdentifier(expression, parts.item)) fail(node, `Keyed list item expressions must be direct ${parts.item}.<field> reads`)
359
+ }
360
+ ts.forEachChild(node, visit)
361
+ }
362
+ visit(parts.root)
363
+ }
364
+
365
+ function directProperty(expression, objectName) {
366
+ const value = unwrapExpression(expression)
367
+ if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) return undefined
368
+ if (objectName !== undefined && value.expression.text !== objectName) return undefined
369
+ return value.name.text
370
+ }
371
+
372
+ function keyedListParentTag(node) {
373
+ for (let current = node.parent; current; current = current.parent) {
374
+ if (ts.isJsxElement(current)) return current.openingElement.tagName.getText().toLowerCase()
375
+ }
376
+ return undefined
377
+ }
378
+
379
+ function referencesIdentifier(root, name) {
380
+ let found = false
381
+ const visit = node => {
382
+ if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node)) found = true
383
+ if (!found) ts.forEachChild(node, visit)
384
+ }
385
+ visit(root)
386
+ return found
387
+ }
388
+
389
+ function unwrapExpression(node) {
390
+ return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
391
+ }
392
+
292
393
  function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
293
394
  return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
294
395
  }
@@ -7,6 +7,7 @@ export function nativeBehavior(module: string, handler: string, states: Array<[s
7
7
  export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
8
8
  export function bindingValue(value: unknown): unknown
9
9
  export function conditional(kind: "and" | "ternary", value: unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
10
+ export function list(items: unknown, keyField: string, render: (item: unknown) => unknown): unknown
10
11
 
11
12
  export function renderPage(
12
13
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -32,6 +33,7 @@ export function renderPage(
32
33
  html: string
33
34
  hasBehaviors: boolean
34
35
  hasBindings: boolean
36
+ hasLists: boolean
35
37
  hasStateSeed: boolean
36
38
  plan: {
37
39
  states: Array<{ id: string; name: string; initialValue: unknown }>
@@ -41,7 +43,7 @@ export function renderPage(
41
43
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
42
44
  }>
43
45
  bindings: Array<{
44
- target: "class" | "disabled" | "value"
46
+ target: string
45
47
  state?: string
46
48
  module?: string
47
49
  handler?: string
@@ -51,5 +53,6 @@ export function renderPage(
51
53
  scopeBindings?: Record<string, unknown>
52
54
  }>
53
55
  conditions: Array<Record<string, unknown>>
56
+ lists: Array<Record<string, unknown>>
54
57
  }
55
58
  }>
@@ -3,6 +3,9 @@ const behaviorMarker = Symbol("kudzu.behavior")
3
3
  const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
4
4
  const bindingMarker = Symbol("kudzu.binding")
5
5
  const conditionalMarker = Symbol("kudzu.conditional")
6
+ const listMarker = Symbol("kudzu.list")
7
+ const listFieldMarker = Symbol("kudzu.listField")
8
+ const noSelectValue = Symbol("kudzu.no-select-value")
6
9
 
7
10
  let renderContext
8
11
 
@@ -61,6 +64,50 @@ export function conditional(kind, value, truthy, falsy, module, handler, states,
61
64
  return { [conditionalMarker]: true, kind, value, truthy, falsy, ...reactiveDescriptor(module, handler, states, scope) }
62
65
  }
63
66
 
67
+ export function list(items, keyField, render) {
68
+ if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state")
69
+ const keys = new Set()
70
+ for (const item of items.value) {
71
+ const key = item?.[keyField]
72
+ if (!validListKey(key)) throw new Error(`Keyed list key "${keyField}" must be a string or finite number`)
73
+ assertListItem(item)
74
+ assertListValue(item, new Set())
75
+ const token = `${typeof key}:${key}`
76
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
77
+ keys.add(token)
78
+ }
79
+ return { [listMarker]: true, items, keyField, render }
80
+ }
81
+
82
+ function validListKey(key) {
83
+ return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
84
+ }
85
+
86
+ function assertListItem(item) {
87
+ const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
88
+ if (!item || Array.isArray(item) || prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must be plain objects")
89
+ }
90
+
91
+ function assertListValue(value, seen) {
92
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
93
+ if (!value || typeof value !== "object") throw new Error(`Keyed list items must contain only JSON-safe values`)
94
+ if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
95
+ const prototype = Object.getPrototypeOf(value)
96
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must contain only arrays and plain objects")
97
+ if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
98
+ seen.add(value)
99
+ const descriptors = Object.getOwnPropertyDescriptors(value)
100
+ if (Array.isArray(value) && Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
101
+ if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
102
+ for (const [key, descriptor] of Object.entries(descriptors)) {
103
+ if (Array.isArray(value) && key === "length") continue
104
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
105
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
106
+ assertListValue(descriptor.value, seen)
107
+ }
108
+ seen.delete(value)
109
+ }
110
+
64
111
  function reactiveDescriptor(module, handler, states, scope) {
65
112
  const scopeStates = {}
66
113
  const serializedScope = {}
@@ -123,7 +170,7 @@ function serializeCapture(name, value, seen) {
123
170
  }
124
171
 
125
172
  export async function renderPage(component, metadata = {}) {
126
- renderContext = { nextState: 0, nextCondition: 0, conditionDepth: 0, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false }
173
+ renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
127
174
 
128
175
  try {
129
176
  const body = await renderNode({ type: component, props: {} })
@@ -141,6 +188,9 @@ export async function renderPage(component, metadata = {}) {
141
188
  const bindingRuntime = renderContext.hasBindings
142
189
  ? '<script type="module" src="/assets/kudzu-binding.js"></script>'
143
190
  : ""
191
+ const listRuntime = renderContext.hasLists
192
+ ? '<script type="module" src="/assets/kudzu-list.js"></script>'
193
+ : ""
144
194
  const initialState = renderContext.hasBehaviors
145
195
  ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
146
196
  : []
@@ -149,15 +199,17 @@ export async function renderPage(component, metadata = {}) {
149
199
  : ""
150
200
 
151
201
  return {
152
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}>${body}${runtime}${bindingRuntime}${nativeRuntime}</body></html>`,
202
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
153
203
  hasBehaviors: renderContext.hasBehaviors,
154
204
  hasBindings: renderContext.hasBindings,
205
+ hasLists: renderContext.hasLists,
155
206
  hasStateSeed: initialState.length > 0,
156
207
  plan: {
157
208
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
158
209
  events: renderContext.events,
159
210
  bindings: renderContext.bindings,
160
- conditions: renderContext.conditions
211
+ conditions: renderContext.conditions,
212
+ lists: renderContext.lists
161
213
  }
162
214
  }
163
215
  } finally {
@@ -198,32 +250,32 @@ function renderMetadata(metadata) {
198
250
  return tags.join("")
199
251
  }
200
252
 
201
- async function renderNode(node, namespace) {
253
+ async function renderNode(node, namespace, selectValue = noSelectValue) {
202
254
  if (node == null || node === false || node === true) return ""
203
255
  if (Array.isArray(node)) {
204
256
  let html = ""
205
- for (const child of node) html += await renderNode(child, namespace)
257
+ for (const child of node) html += await renderNode(child, namespace, selectValue)
206
258
  return html
207
259
  }
208
260
  if (node?.[signalMarker]) {
209
261
  renderContext.textStates.add(node.id)
210
- if (renderContext.conditionDepth) renderContext.conditionStates.add(node.id)
262
+ if (renderContext.conditionDepth || renderContext.listDepth) renderContext.conditionStates.add(node.id)
211
263
  return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value)}</span>`
212
264
  }
213
265
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
214
266
  return escapeHtml(node)
215
267
  }
216
- if (node instanceof Promise) return renderNode(await node, namespace)
268
+ if (node instanceof Promise) return renderNode(await node, namespace, selectValue)
217
269
  if (node?.[conditionalMarker]) {
218
270
  const descriptor = bindingDescriptor(node)
219
271
  const stateIds = reactiveStateIds(descriptor)
220
- if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace)
272
+ if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
221
273
  if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
222
274
 
223
275
  const id = `c${renderContext.nextCondition++}`
224
276
  renderContext.conditionDepth++
225
- const truthy = await renderNode(node.truthy())
226
- const falsy = await renderNode(node.falsy())
277
+ const truthy = await renderNode(node.truthy(), namespace, selectValue)
278
+ const falsy = await renderNode(node.falsy(), namespace, selectValue)
227
279
  renderContext.conditionDepth--
228
280
  const metadata = { id, kind: node.kind, initial: node.value, ...descriptor }
229
281
  for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
@@ -231,25 +283,48 @@ async function renderNode(node, namespace) {
231
283
  renderContext.hasBehaviors = true
232
284
  renderContext.hasBindings = true
233
285
  const encoded = escapeJsonAttribute(metadata)
234
- const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value) : falsy
286
+ const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy
235
287
  return `<template data-k-if='${encoded}'><template data-k-true>${truthy}</template><template data-k-false>${falsy}</template></template>${current}<template data-k-if-end="${id}"></template>`
236
288
  }
289
+ if (node?.[listMarker]) return renderList(node, namespace, selectValue)
290
+ if (node?.[listFieldMarker]) {
291
+ return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
292
+ }
237
293
  if (!node || typeof node !== "object" || !("type" in node)) {
238
294
  throw new Error(`Cannot render ${String(node)}`)
239
295
  }
240
296
 
241
- if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children, namespace)
242
- if (typeof node.type === "function") return renderNode(await node.type(node.props), namespace)
297
+ if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children, namespace, selectValue)
298
+ if (typeof node.type === "function") return renderNode(await node.type(node.props), namespace, selectValue)
243
299
 
244
300
  const tag = node.type
245
301
  const props = node.props ?? {}
302
+ const childSelectValue = tag === "select"
303
+ ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
304
+ : selectValue
246
305
  const childNamespace = tag === "svg" || tag === "math"
247
306
  ? tag
248
307
  : namespace === "svg" && tag === "foreignObject" ? undefined : namespace
249
308
  let attributes = ""
309
+ const attributeBindings = []
310
+ const listAttributes = []
311
+
312
+ if (renderContext.listRoot) {
313
+ const root = renderContext.listRoot
314
+ renderContext.listRoot = undefined
315
+ attributes += root.template
316
+ ? ` data-k-list-root="${root.id}"`
317
+ : ` data-k-list-item='${escapeJsonAttribute([root.id, root.key])}'`
318
+ }
250
319
 
251
320
  for (const [rawName, value] of Object.entries(props)) {
252
321
  if (rawName === "children" || rawName === "key") continue
322
+ if (rawName === "selected" && selectValue !== noSelectValue) continue
323
+ if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
324
+ if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
325
+ if (["style", "ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
326
+ throw new Error(`Reactive ${rawName} is not supported`)
327
+ }
253
328
 
254
329
  if (/^on[A-Z]/.test(rawName)) {
255
330
  const event = rawName.slice(2).toLowerCase()
@@ -270,40 +345,80 @@ async function renderNode(node, namespace) {
270
345
  }
271
346
 
272
347
  const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
273
- const target = name === "class" || name === "disabled" || name === "value" ? name : undefined
274
- if (target && (value?.[signalMarker] || value?.[bindingMarker])) {
348
+ const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked"
349
+ if (value?.[listFieldMarker]) {
350
+ attributes += renderAttribute(name, value.value)
351
+ listAttributes.push([name, value.field])
352
+ continue
353
+ }
354
+ if (value?.[signalMarker] || value?.[bindingMarker]) {
275
355
  const initialValue = value[signalMarker] ? value.value : value.value
276
356
  const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
277
357
  if (!reactive) {
278
- attributes += renderAttribute(name, initialValue)
358
+ if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
279
359
  continue
280
360
  }
281
361
  const descriptor = value[signalMarker]
282
362
  ? { state: value.id }
283
363
  : bindingDescriptor(value)
284
- attributes += renderAttribute(name, initialValue)
285
- attributes += ` data-k-bind-${target}='${escapeJsonAttribute(descriptor)}'`
286
- renderContext.bindings.push({ target, ...descriptor })
287
- if (renderContext.conditionDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
364
+ if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
365
+ if (propertyTarget) attributes += ` data-k-bind-${name}='${escapeJsonAttribute(descriptor)}'`
366
+ else attributeBindings.push({ target: name, ...descriptor })
367
+ renderContext.bindings.push({ target: name, ...descriptor })
368
+ if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
288
369
  renderContext.hasBehaviors = true
289
370
  renderContext.hasBindings = true
290
371
  continue
291
372
  }
292
373
 
293
- if (value == null || value === false) continue
294
- if (value === true) {
295
- attributes += ` ${name}`
296
- } else if (name === "style" && typeof value === "object") {
374
+ if (tag === "select" && name === "value") continue
375
+ if (name === "style" && value && typeof value === "object") {
297
376
  const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
298
377
  attributes += ` style="${escapeAttribute(style)}"`
299
378
  } else {
300
- attributes += ` ${name}="${escapeAttribute(value)}"`
379
+ attributes += renderAttribute(name, value)
301
380
  }
302
381
  }
303
382
 
383
+ if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
384
+ if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
385
+
386
+ if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
387
+
304
388
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
305
389
  if (voidElements.has(tag)) return `<${tag}${attributes}>`
306
- return `<${tag}${attributes}>${await renderNode(props.children, childNamespace)}</${tag}>`
390
+ return `<${tag}${attributes}>${await renderNode(props.children, childNamespace, childSelectValue)}</${tag}>`
391
+ }
392
+
393
+ async function renderList(node, namespace, selectValue) {
394
+ if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
395
+ const id = `l${renderContext.nextList++}`
396
+ const descriptor = { id, state: node.items.id, key: node.keyField }
397
+ const itemProxy = value => new Proxy({}, {
398
+ get: (_, field) => ({ [listFieldMarker]: true, field: String(field), value: value?.[field] })
399
+ })
400
+ renderContext.listDepth++
401
+ try {
402
+ renderContext.listRoot = { id, template: true }
403
+ const template = await renderNode(node.render(itemProxy(undefined)), namespace, selectValue)
404
+ let current = ""
405
+ for (const item of node.items.value) {
406
+ renderContext.listRoot = { id, key: item[node.keyField], template: false }
407
+ current += await renderNode(node.render(itemProxy(item)), namespace, selectValue)
408
+ }
409
+ renderContext.lists.push(descriptor)
410
+ renderContext.hasBehaviors = true
411
+ renderContext.hasLists = true
412
+ return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
413
+ } finally {
414
+ renderContext.listRoot = undefined
415
+ renderContext.listDepth--
416
+ }
417
+ }
418
+
419
+ function optionValue(props) {
420
+ if (props.value != null) return bindingValue(props.value)
421
+ return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
307
422
  }
308
423
 
309
424
  function reactiveStateIds(descriptor) {
@@ -316,12 +431,17 @@ function reactiveStateIds(descriptor) {
316
431
  }
317
432
 
318
433
  function renderAttribute(name, value) {
319
- if (name === "disabled") return value ? " disabled" : ""
434
+ if (name === "disabled" || name === "checked") return value ? ` ${name}` : ""
320
435
  if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
321
- if (value == null || value === false) return ""
436
+ if (value == null || (value === false && !isStringBooleanAttribute(name))) return ""
437
+ if (value === true && !isStringBooleanAttribute(name)) return ` ${name}`
322
438
  return ` ${name}="${escapeAttribute(value)}"`
323
439
  }
324
440
 
441
+ function isStringBooleanAttribute(name) {
442
+ return name.startsWith("aria-") || name.startsWith("data-")
443
+ }
444
+
325
445
  function escapeHtml(value) {
326
446
  return String(value)
327
447
  .replaceAll("&", "&amp;")
@@ -0,0 +1,185 @@
1
+ import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
2
+
3
+ const listTargets = new Map()
4
+ const listRegistrations = new WeakMap()
5
+ const mountedLists = new WeakSet()
6
+
7
+ function commitLists(id) {
8
+ const lists = listTargets.get(id)
9
+ if (!lists) return
10
+ for (const list of lists) {
11
+ if (!list.start.isConnected) unregisterList(list.start)
12
+ else updateList(list)
13
+ }
14
+ }
15
+
16
+ registerCommitter(commitLists)
17
+ registerMountHook(mountLists)
18
+ registerUnmountHook(unmountLists)
19
+
20
+ if (typeof document !== "undefined") mountDom(document)
21
+
22
+ function mountLists(root) {
23
+ for (const start of matching(root, "template[data-k-list]")) {
24
+ if (mountedLists.has(start)) continue
25
+ mountedLists.add(start)
26
+ const descriptor = JSON.parse(start.dataset.kList)
27
+ const roots = [...start.ownerDocument.querySelectorAll("[data-k-list-item]")].filter(node => JSON.parse(node.dataset.kListItem)[0] === descriptor.id)
28
+ const list = {
29
+ start,
30
+ descriptor,
31
+ roots: new Map(roots.map(node => [keyToken(JSON.parse(node.dataset.kListItem)[1]), node])),
32
+ container: roots[0]?.parentNode,
33
+ boundary: roots.length ? roots.at(-1).nextSibling : findEnd(start, descriptor.id)
34
+ }
35
+ register(listTargets, descriptor.state, list)
36
+ listRegistrations.set(start, { state: descriptor.state, list })
37
+ updateList(list)
38
+ }
39
+ }
40
+
41
+ function unmountLists(root) {
42
+ for (const start of matching(root, "template[data-k-list]")) unregisterList(start)
43
+ }
44
+
45
+ function unregisterList(start) {
46
+ const registration = listRegistrations.get(start)
47
+ if (registration) {
48
+ const lists = listTargets.get(registration.state)
49
+ lists?.delete(registration.list)
50
+ if (!lists?.size) listTargets.delete(registration.state)
51
+ }
52
+ listRegistrations.delete(start)
53
+ mountedLists.delete(start)
54
+ }
55
+
56
+ function updateList(list) {
57
+ const items = browserState.get(list.descriptor.state)
58
+ if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
59
+ const entries = []
60
+ const keys = new Set()
61
+ for (const item of items) {
62
+ const key = item?.[list.descriptor.key]
63
+ if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
64
+ assertListItem(item)
65
+ assertListValue(item, new Set())
66
+ const token = keyToken(key)
67
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
68
+ keys.add(token)
69
+ entries.push({ item, key, token })
70
+ }
71
+ const next = []
72
+ for (const { item, key, token } of entries) {
73
+ let node = list.roots.get(token)
74
+ if (!node) {
75
+ const fragment = list.start.content.cloneNode(true)
76
+ node = fragment.querySelector(`[data-k-list-root="${list.descriptor.id}"]`)
77
+ if (!node) throw new Error("Keyed list template has no root element")
78
+ node.removeAttribute("data-k-list-root")
79
+ node.dataset.kListItem = JSON.stringify([list.descriptor.id, key])
80
+ fillListItem(node, item)
81
+ ;(list.container ?? list.start.parentNode).insertBefore(fragment, list.boundary)
82
+ list.container ??= node.parentNode
83
+ mountDom(node)
84
+ } else {
85
+ fillListItem(node, item)
86
+ }
87
+ next.push([token, node])
88
+ }
89
+ for (const [token, node] of list.roots) {
90
+ if (keys.has(token)) continue
91
+ unmountDom(node)
92
+ node.remove()
93
+ }
94
+ const parent = list.container ?? list.start.parentNode
95
+ for (const [, node] of next) parent.insertBefore(node, list.boundary)
96
+ list.roots = new Map(next)
97
+ }
98
+
99
+ function fillListItem(root, item) {
100
+ for (const marker of matching(root, "template[data-k-list-text]")) {
101
+ const value = item?.[marker.dataset.kListText]
102
+ let end = marker.nextSibling
103
+ while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches("template[data-k-list-text-end]"))) end = end.nextSibling
104
+ if (!end) throw new Error("Keyed list text marker has no end")
105
+ const range = marker.ownerDocument.createRange()
106
+ range.setStartAfter(marker)
107
+ range.setEndBefore(end)
108
+ range.deleteContents()
109
+ end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
110
+ }
111
+ for (const node of matching(root, "[data-k-list-attrs]")) {
112
+ for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
113
+ }
114
+ }
115
+
116
+ function patchBinding(node, target, value) {
117
+ if (target === "disabled") {
118
+ node.toggleAttribute("disabled", Boolean(value))
119
+ } else if (target === "checked") {
120
+ node.checked = Boolean(value)
121
+ } else if (target === "value") {
122
+ const next = value == null ? "" : String(value)
123
+ if (node.value !== next) node.value = next
124
+ } else if (target === "class" && (value == null || value === false)) {
125
+ node.removeAttribute("class")
126
+ } else if (target === "class") {
127
+ node.setAttribute("class", String(value))
128
+ } else if (value == null || (value === false && !isStringBooleanAttribute(target))) {
129
+ node.removeAttribute(target)
130
+ } else {
131
+ node.setAttribute(target, value === true && !isStringBooleanAttribute(target) ? "" : String(value))
132
+ }
133
+ }
134
+
135
+ function keyToken(key) {
136
+ return `${typeof key}:${key}`
137
+ }
138
+
139
+ function validListKey(key) {
140
+ return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
141
+ }
142
+
143
+ function assertListItem(item) {
144
+ const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
145
+ if (!item || Array.isArray(item) || prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must be plain objects")
146
+ }
147
+
148
+ function assertListValue(value, seen) {
149
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
150
+ if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
151
+ if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
152
+ const prototype = Object.getPrototypeOf(value)
153
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must contain only arrays and plain objects")
154
+ if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
155
+ seen.add(value)
156
+ const descriptors = Object.getOwnPropertyDescriptors(value)
157
+ if (Array.isArray(value) && Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
158
+ if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
159
+ for (const [key, descriptor] of Object.entries(descriptors)) {
160
+ if (Array.isArray(value) && key === "length") continue
161
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
162
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
163
+ assertListValue(descriptor.value, seen)
164
+ }
165
+ seen.delete(value)
166
+ }
167
+
168
+ function findEnd(start, id) {
169
+ return [...start.ownerDocument.querySelectorAll("template[data-k-list-end]")]
170
+ .find(node => node.dataset.kListEnd === id)
171
+ }
172
+
173
+ function register(targets, id, entry) {
174
+ const entries = targets.get(id) ?? new Set()
175
+ entries.add(entry)
176
+ targets.set(id, entries)
177
+ }
178
+
179
+ function matching(root, selector) {
180
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
181
+ }
182
+
183
+ function isStringBooleanAttribute(name) {
184
+ return name.startsWith("aria-") || name.startsWith("data-")
185
+ }
@@ -16,11 +16,21 @@ export function applyCommands(state, commands, commit, log = console.log) {
16
16
 
17
17
  export const browserState = new Map()
18
18
  const committers = []
19
+ const mountHooks = []
20
+ const unmountHooks = []
19
21
 
20
22
  export function registerCommitter(commit) {
21
23
  committers.push(commit)
22
24
  }
23
25
 
26
+ export function registerMountHook(mount) {
27
+ mountHooks.push(mount)
28
+ }
29
+
30
+ export function registerUnmountHook(unmount) {
31
+ unmountHooks.push(unmount)
32
+ }
33
+
24
34
  export function commitDom(id, value) {
25
35
  for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
26
36
  for (const commit of committers) commit(id)
@@ -34,6 +44,15 @@ export function mountText(root) {
34
44
  }
35
45
  }
36
46
 
47
+ export function mountDom(root) {
48
+ mountText(root)
49
+ for (const mount of mountHooks) mount(root)
50
+ }
51
+
52
+ export function unmountDom(root) {
53
+ for (const unmount of unmountHooks) unmount(root)
54
+ }
55
+
37
56
  if (typeof document !== "undefined") {
38
57
  const initialState = document.body.dataset.kState
39
58
  if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",