@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.
@@ -3,6 +3,11 @@ 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 listExpressionMarker = Symbol("kudzu.listExpression")
9
+ const listItemMarker = Symbol("kudzu.listItem")
10
+ const noSelectValue = Symbol("kudzu.no-select-value")
6
11
 
7
12
  let renderContext
8
13
 
@@ -61,6 +66,64 @@ export function conditional(kind, value, truthy, falsy, module, handler, states,
61
66
  return { [conditionalMarker]: true, kind, value, truthy, falsy, ...reactiveDescriptor(module, handler, states, scope) }
62
67
  }
63
68
 
69
+ export function list(items, keyField, render) {
70
+ if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state")
71
+ const keys = new Set()
72
+ for (const item of items.value) {
73
+ const key = item?.[keyField]
74
+ if (!validListKey(key)) throw new Error(`Keyed list key "${keyField}" must be a string or finite number`)
75
+ assertListItem(item)
76
+ assertListValue(item, new Set())
77
+ const token = `${typeof key}:${key}`
78
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
79
+ keys.add(token)
80
+ }
81
+ return { [listMarker]: true, items, keyField, render }
82
+ }
83
+
84
+ export function listField(read, field) {
85
+ return { [listFieldMarker]: true, field, value: renderContext?.listTemplate ? undefined : read() }
86
+ }
87
+
88
+ export function listExpression(read, module, handler) {
89
+ const value = renderContext?.listTemplate ? undefined : read()
90
+ if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
91
+ return { [listExpressionMarker]: true, module, handler, value }
92
+ }
93
+
94
+ export function listItem() {
95
+ return { [listItemMarker]: true }
96
+ }
97
+
98
+ function validListKey(key) {
99
+ return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
100
+ }
101
+
102
+ function assertListItem(item) {
103
+ const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
104
+ if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
105
+ }
106
+
107
+ function assertListValue(value, seen) {
108
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
109
+ if (!value || typeof value !== "object") throw new Error(`Keyed list items must contain only JSON-safe values`)
110
+ if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
111
+ const prototype = Object.getPrototypeOf(value)
112
+ if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
113
+ if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
114
+ seen.add(value)
115
+ const descriptors = Object.getOwnPropertyDescriptors(value)
116
+ 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")
117
+ if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
118
+ for (const [key, descriptor] of Object.entries(descriptors)) {
119
+ if (Array.isArray(value) && key === "length") continue
120
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
121
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
122
+ assertListValue(descriptor.value, seen)
123
+ }
124
+ seen.delete(value)
125
+ }
126
+
64
127
  function reactiveDescriptor(module, handler, states, scope) {
65
128
  const scopeStates = {}
66
129
  const serializedScope = {}
@@ -92,6 +155,7 @@ function bindingDescriptor(value) {
92
155
  }
93
156
 
94
157
  function serializeCapture(name, value, seen) {
158
+ if (value?.[listItemMarker]) return { type: "list-item" }
95
159
  if (value === null || typeof value === "string" || typeof value === "boolean") return value
96
160
  if (typeof value === "number") {
97
161
  return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
@@ -123,7 +187,7 @@ function serializeCapture(name, value, seen) {
123
187
  }
124
188
 
125
189
  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 }
190
+ renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
127
191
 
128
192
  try {
129
193
  const body = await renderNode({ type: component, props: {} })
@@ -141,6 +205,9 @@ export async function renderPage(component, metadata = {}) {
141
205
  const bindingRuntime = renderContext.hasBindings
142
206
  ? '<script type="module" src="/assets/kudzu-binding.js"></script>'
143
207
  : ""
208
+ const listRuntime = renderContext.hasLists
209
+ ? '<script type="module" src="/assets/kudzu-list.js"></script>'
210
+ : ""
144
211
  const initialState = renderContext.hasBehaviors
145
212
  ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
146
213
  : []
@@ -149,15 +216,17 @@ export async function renderPage(component, metadata = {}) {
149
216
  : ""
150
217
 
151
218
  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>`,
219
+ 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
220
  hasBehaviors: renderContext.hasBehaviors,
154
221
  hasBindings: renderContext.hasBindings,
222
+ hasLists: renderContext.hasLists,
155
223
  hasStateSeed: initialState.length > 0,
156
224
  plan: {
157
225
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
158
226
  events: renderContext.events,
159
227
  bindings: renderContext.bindings,
160
- conditions: renderContext.conditions
228
+ conditions: renderContext.conditions,
229
+ lists: renderContext.lists
161
230
  }
162
231
  }
163
232
  } finally {
@@ -198,32 +267,32 @@ function renderMetadata(metadata) {
198
267
  return tags.join("")
199
268
  }
200
269
 
201
- async function renderNode(node, namespace) {
270
+ async function renderNode(node, namespace, selectValue = noSelectValue) {
202
271
  if (node == null || node === false || node === true) return ""
203
272
  if (Array.isArray(node)) {
204
273
  let html = ""
205
- for (const child of node) html += await renderNode(child, namespace)
274
+ for (const child of node) html += await renderNode(child, namespace, selectValue)
206
275
  return html
207
276
  }
208
277
  if (node?.[signalMarker]) {
209
278
  renderContext.textStates.add(node.id)
210
- if (renderContext.conditionDepth) renderContext.conditionStates.add(node.id)
279
+ if (renderContext.conditionDepth || renderContext.listDepth) renderContext.conditionStates.add(node.id)
211
280
  return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value)}</span>`
212
281
  }
213
282
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
214
283
  return escapeHtml(node)
215
284
  }
216
- if (node instanceof Promise) return renderNode(await node, namespace)
285
+ if (node instanceof Promise) return renderNode(await node, namespace, selectValue)
217
286
  if (node?.[conditionalMarker]) {
218
287
  const descriptor = bindingDescriptor(node)
219
288
  const stateIds = reactiveStateIds(descriptor)
220
- if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace)
289
+ if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
221
290
  if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
222
291
 
223
292
  const id = `c${renderContext.nextCondition++}`
224
293
  renderContext.conditionDepth++
225
- const truthy = await renderNode(node.truthy())
226
- const falsy = await renderNode(node.falsy())
294
+ const truthy = await renderNode(node.truthy(), namespace, selectValue)
295
+ const falsy = await renderNode(node.falsy(), namespace, selectValue)
227
296
  renderContext.conditionDepth--
228
297
  const metadata = { id, kind: node.kind, initial: node.value, ...descriptor }
229
298
  for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
@@ -231,36 +300,67 @@ async function renderNode(node, namespace) {
231
300
  renderContext.hasBehaviors = true
232
301
  renderContext.hasBindings = true
233
302
  const encoded = escapeJsonAttribute(metadata)
234
- const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value) : falsy
303
+ const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy
235
304
  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
305
  }
306
+ if (node?.[listMarker]) return renderList(node, namespace, selectValue)
307
+ if (node?.[listFieldMarker]) {
308
+ return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
309
+ }
310
+ if (node?.[listExpressionMarker]) {
311
+ const descriptor = { module: node.module, handler: node.handler }
312
+ return `<template data-k-list-expression='${escapeJsonAttribute(descriptor)}'></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
313
+ }
237
314
  if (!node || typeof node !== "object" || !("type" in node)) {
238
315
  throw new Error(`Cannot render ${String(node)}`)
239
316
  }
240
317
 
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)
318
+ if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children, namespace, selectValue)
319
+ if (typeof node.type === "function") return renderNode(await node.type(node.props), namespace, selectValue)
243
320
 
244
321
  const tag = node.type
245
322
  const props = node.props ?? {}
323
+ const childSelectValue = tag === "select"
324
+ ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
325
+ : selectValue
246
326
  const childNamespace = tag === "svg" || tag === "math"
247
327
  ? tag
248
328
  : namespace === "svg" && tag === "foreignObject" ? undefined : namespace
249
329
  let attributes = ""
330
+ const attributeBindings = []
331
+ const listAttributes = []
332
+ const listExpressionAttributes = []
333
+ const listEvents = []
334
+
335
+ if (renderContext.listRoot) {
336
+ const root = renderContext.listRoot
337
+ renderContext.listRoot = undefined
338
+ attributes += root.template
339
+ ? ` data-k-list-root="${root.id}"`
340
+ : ` data-k-list-item='${escapeJsonAttribute([root.id, root.key])}'`
341
+ }
250
342
 
251
343
  for (const [rawName, value] of Object.entries(props)) {
252
344
  if (rawName === "children" || rawName === "key") continue
345
+ if (rawName === "selected" && selectValue !== noSelectValue) continue
346
+ if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
347
+ if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
348
+ if (["style", "ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
349
+ throw new Error(`Reactive ${rawName} is not supported`)
350
+ }
253
351
 
254
352
  if (/^on[A-Z]/.test(rawName)) {
255
- const event = rawName.slice(2).toLowerCase()
353
+ const event = rawName.slice(2).toLowerCase()
256
354
  if (value?.[behaviorMarker]) {
257
355
  const commands = JSON.stringify(value.commands)
258
356
  attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
259
357
  renderContext.events.push({ event, commands: value.commands })
260
358
  } else if (value?.[nativeBehaviorMarker]) {
261
- const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
359
+ const template = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
360
+ const native = template
262
361
  attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
263
362
  renderContext.events.push({ event, native })
363
+ if (renderContext.listDepth && Object.values(template.scope).some(entry => entry?.type === "list-item")) listEvents.push([event, template])
264
364
  renderContext.hasNativeBehaviors = true
265
365
  } else {
266
366
  throw new Error(`${rawName} must reference a compilable event handler`)
@@ -270,40 +370,87 @@ async function renderNode(node, namespace) {
270
370
  }
271
371
 
272
372
  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])) {
373
+ const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked"
374
+ if (value?.[listFieldMarker]) {
375
+ attributes += renderAttribute(name, value.value)
376
+ listAttributes.push([name, value.field])
377
+ continue
378
+ }
379
+ if (value?.[listExpressionMarker]) {
380
+ attributes += renderAttribute(name, value.value)
381
+ listExpressionAttributes.push([name, value.module, value.handler])
382
+ continue
383
+ }
384
+ if (value?.[signalMarker] || value?.[bindingMarker]) {
275
385
  const initialValue = value[signalMarker] ? value.value : value.value
276
386
  const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
277
387
  if (!reactive) {
278
- attributes += renderAttribute(name, initialValue)
388
+ if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
279
389
  continue
280
390
  }
281
391
  const descriptor = value[signalMarker]
282
392
  ? { state: value.id }
283
393
  : 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)
394
+ if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
395
+ if (propertyTarget) attributes += ` data-k-bind-${name}='${escapeJsonAttribute(descriptor)}'`
396
+ else attributeBindings.push({ target: name, ...descriptor })
397
+ renderContext.bindings.push({ target: name, ...descriptor })
398
+ if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
288
399
  renderContext.hasBehaviors = true
289
400
  renderContext.hasBindings = true
290
401
  continue
291
402
  }
292
403
 
293
- if (value == null || value === false) continue
294
- if (value === true) {
295
- attributes += ` ${name}`
296
- } else if (name === "style" && typeof value === "object") {
404
+ if (tag === "select" && name === "value") continue
405
+ if (name === "style" && value && typeof value === "object") {
297
406
  const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
298
407
  attributes += ` style="${escapeAttribute(style)}"`
299
408
  } else {
300
- attributes += ` ${name}="${escapeAttribute(value)}"`
409
+ attributes += renderAttribute(name, value)
301
410
  }
302
411
  }
303
412
 
413
+ if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
414
+ if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
415
+ if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
416
+ if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
417
+
418
+ if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
419
+
304
420
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
305
421
  if (voidElements.has(tag)) return `<${tag}${attributes}>`
306
- return `<${tag}${attributes}>${await renderNode(props.children, childNamespace)}</${tag}>`
422
+ return `<${tag}${attributes}>${await renderNode(props.children, childNamespace, childSelectValue)}</${tag}>`
423
+ }
424
+
425
+ async function renderList(node, namespace, selectValue) {
426
+ if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
427
+ const id = `l${renderContext.nextList++}`
428
+ const descriptor = { id, state: node.items.id, key: node.keyField }
429
+ renderContext.listDepth++
430
+ try {
431
+ renderContext.listTemplate = true
432
+ renderContext.listRoot = { id, template: true }
433
+ const template = await renderNode(node.render({}), namespace, selectValue)
434
+ let current = ""
435
+ renderContext.listTemplate = false
436
+ for (const item of node.items.value) {
437
+ renderContext.listRoot = { id, key: item[node.keyField], template: false }
438
+ current += await renderNode(node.render(item), namespace, selectValue)
439
+ }
440
+ renderContext.lists.push(descriptor)
441
+ renderContext.hasBehaviors = true
442
+ renderContext.hasLists = true
443
+ return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
444
+ } finally {
445
+ renderContext.listRoot = undefined
446
+ renderContext.listTemplate = false
447
+ renderContext.listDepth--
448
+ }
449
+ }
450
+
451
+ function optionValue(props) {
452
+ if (props.value != null) return bindingValue(props.value)
453
+ return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
307
454
  }
308
455
 
309
456
  function reactiveStateIds(descriptor) {
@@ -316,12 +463,17 @@ function reactiveStateIds(descriptor) {
316
463
  }
317
464
 
318
465
  function renderAttribute(name, value) {
319
- if (name === "disabled") return value ? " disabled" : ""
466
+ if (name === "disabled" || name === "checked") return value ? ` ${name}` : ""
320
467
  if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
321
- if (value == null || value === false) return ""
468
+ if (value == null || (value === false && !isStringBooleanAttribute(name))) return ""
469
+ if (value === true && !isStringBooleanAttribute(name)) return ` ${name}`
322
470
  return ` ${name}="${escapeAttribute(value)}"`
323
471
  }
324
472
 
473
+ function isStringBooleanAttribute(name) {
474
+ return name.startsWith("aria-") || name.startsWith("data-")
475
+ }
476
+
325
477
  function escapeHtml(value) {
326
478
  return String(value)
327
479
  .replaceAll("&", "&amp;")
@@ -0,0 +1,233 @@
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
+ const imports = new Map()
7
+ const revisions = new WeakMap()
8
+
9
+ function commitLists(id) {
10
+ const lists = listTargets.get(id)
11
+ if (!lists) return
12
+ for (const list of lists) {
13
+ if (!list.start.isConnected) unregisterList(list.start)
14
+ else updateList(list)
15
+ }
16
+ }
17
+
18
+ registerCommitter(commitLists)
19
+ registerMountHook(mountLists)
20
+ registerUnmountHook(unmountLists)
21
+
22
+ if (typeof document !== "undefined") mountDom(document)
23
+
24
+ function mountLists(root) {
25
+ for (const start of matching(root, "template[data-k-list]")) {
26
+ if (mountedLists.has(start)) continue
27
+ mountedLists.add(start)
28
+ const descriptor = JSON.parse(start.dataset.kList)
29
+ const roots = [...start.ownerDocument.querySelectorAll("[data-k-list-item]")].filter(node => JSON.parse(node.dataset.kListItem)[0] === descriptor.id)
30
+ const list = {
31
+ start,
32
+ descriptor,
33
+ roots: new Map(roots.map(node => [keyToken(JSON.parse(node.dataset.kListItem)[1]), node])),
34
+ container: roots[0]?.parentNode,
35
+ boundary: roots.length ? roots.at(-1).nextSibling : findEnd(start, descriptor.id)
36
+ }
37
+ register(listTargets, descriptor.state, list)
38
+ listRegistrations.set(start, { state: descriptor.state, list })
39
+ updateList(list)
40
+ }
41
+ }
42
+
43
+ function unmountLists(root) {
44
+ for (const start of matching(root, "template[data-k-list]")) unregisterList(start)
45
+ }
46
+
47
+ function unregisterList(start) {
48
+ const registration = listRegistrations.get(start)
49
+ if (registration) {
50
+ const lists = listTargets.get(registration.state)
51
+ lists?.delete(registration.list)
52
+ if (!lists?.size) listTargets.delete(registration.state)
53
+ }
54
+ listRegistrations.delete(start)
55
+ mountedLists.delete(start)
56
+ }
57
+
58
+ function updateList(list) {
59
+ const items = browserState.get(list.descriptor.state)
60
+ if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
61
+ const entries = []
62
+ const keys = new Set()
63
+ for (const item of items) {
64
+ const key = item?.[list.descriptor.key]
65
+ if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
66
+ assertListItem(item)
67
+ assertListValue(item, new Set())
68
+ const token = keyToken(key)
69
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
70
+ keys.add(token)
71
+ entries.push({ item, key, token })
72
+ }
73
+ const next = []
74
+ for (const { item, key, token } of entries) {
75
+ let node = list.roots.get(token)
76
+ if (!node) {
77
+ const fragment = list.start.content.cloneNode(true)
78
+ node = fragment.querySelector(`[data-k-list-root="${list.descriptor.id}"]`)
79
+ if (!node) throw new Error("Keyed list template has no root element")
80
+ node.removeAttribute("data-k-list-root")
81
+ node.dataset.kListItem = JSON.stringify([list.descriptor.id, key])
82
+ fillListItem(node, item)
83
+ ;(list.container ?? list.start.parentNode).insertBefore(fragment, list.boundary)
84
+ list.container ??= node.parentNode
85
+ mountDom(node)
86
+ } else {
87
+ fillListItem(node, item)
88
+ }
89
+ next.push([token, node])
90
+ }
91
+ for (const [token, node] of list.roots) {
92
+ if (keys.has(token)) continue
93
+ unmountDom(node)
94
+ node.remove()
95
+ }
96
+ const parent = list.container ?? list.start.parentNode
97
+ for (const [, node] of next) parent.insertBefore(node, list.boundary)
98
+ list.roots = new Map(next)
99
+ }
100
+
101
+ function fillListItem(root, item) {
102
+ const revision = (revisions.get(root) ?? 0) + 1
103
+ revisions.set(root, revision)
104
+ for (const marker of matching(root, "template[data-k-list-text]")) {
105
+ patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
106
+ }
107
+ for (const node of matching(root, "[data-k-list-attrs]")) {
108
+ for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
109
+ }
110
+ for (const node of matching(root, "[data-k-list-events]")) {
111
+ for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
112
+ native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
113
+ node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
114
+ }
115
+ }
116
+ for (const marker of matching(root, "template[data-k-list-expression]")) {
117
+ evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
118
+ if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
119
+ }).catch(error => console.error(error))
120
+ }
121
+ for (const node of matching(root, "[data-k-list-expression-attrs]")) {
122
+ for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
123
+ evaluate({ module, handler }, item).then(value => {
124
+ if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
125
+ }).catch(error => console.error(error))
126
+ }
127
+ }
128
+ }
129
+
130
+ function patchListText(marker, endSelector, value) {
131
+ let end = marker.nextSibling
132
+ while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
133
+ if (!end) throw new Error("Keyed list text marker has no end")
134
+ const range = marker.ownerDocument.createRange()
135
+ range.setStartAfter(marker)
136
+ range.setEndBefore(end)
137
+ range.deleteContents()
138
+ end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
139
+ }
140
+
141
+ function evaluate(descriptor, item) {
142
+ let module = imports.get(descriptor.module)
143
+ if (!module) {
144
+ module = import(descriptor.module)
145
+ imports.set(descriptor.module, module)
146
+ }
147
+ return module.then(exports => {
148
+ const value = exports[descriptor.handler](item)
149
+ if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
150
+ return value
151
+ })
152
+ }
153
+
154
+ function serializeItem(value) {
155
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return value
156
+ if (Array.isArray(value)) return { type: "array", value: value.map(serializeItem) }
157
+ return { type: "object", nullPrototype: false, value: Object.entries(value).map(([key, entry]) => [key, serializeItem(entry)]) }
158
+ }
159
+
160
+ function patchBinding(node, target, value) {
161
+ if (target === "disabled") {
162
+ node.toggleAttribute("disabled", Boolean(value))
163
+ } else if (target === "checked") {
164
+ node.checked = Boolean(value)
165
+ } else if (target === "value") {
166
+ const next = value == null ? "" : String(value)
167
+ if (node.value !== next) node.value = next
168
+ } else if (target === "class" && (value == null || value === false)) {
169
+ node.removeAttribute("class")
170
+ } else if (target === "class") {
171
+ node.setAttribute("class", String(value))
172
+ } else if (value == null || (value === false && !isStringBooleanAttribute(target))) {
173
+ node.removeAttribute(target)
174
+ } else {
175
+ node.setAttribute(target, value === true && !isStringBooleanAttribute(target) ? "" : String(value))
176
+ }
177
+ }
178
+
179
+ function keyToken(key) {
180
+ return `${typeof key}:${key}`
181
+ }
182
+
183
+ function validListKey(key) {
184
+ return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
185
+ }
186
+
187
+ function assertListItem(item) {
188
+ const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
189
+ if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
190
+ }
191
+
192
+ function assertListValue(value, seen) {
193
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
194
+ if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
195
+ if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
196
+ const prototype = Object.getPrototypeOf(value)
197
+ if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
198
+ if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
199
+ seen.add(value)
200
+ const descriptors = Object.getOwnPropertyDescriptors(value)
201
+ 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")
202
+ if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
203
+ for (const [key, descriptor] of Object.entries(descriptors)) {
204
+ if (Array.isArray(value) && key === "length") continue
205
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
206
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
207
+ assertListValue(descriptor.value, seen)
208
+ }
209
+ seen.delete(value)
210
+ }
211
+
212
+ function findEnd(start, id) {
213
+ return [...start.ownerDocument.querySelectorAll("template[data-k-list-end]")]
214
+ .find(node => node.dataset.kListEnd === id)
215
+ }
216
+
217
+ function register(targets, id, entry) {
218
+ const entries = targets.get(id) ?? new Set()
219
+ entries.add(entry)
220
+ targets.set(id, entries)
221
+ }
222
+
223
+ function matching(root, selector) {
224
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
225
+ }
226
+
227
+ function isStringBooleanAttribute(name) {
228
+ return name.startsWith("aria-") || name.startsWith("data-")
229
+ }
230
+
231
+ function capitalize(value) {
232
+ return value[0].toUpperCase() + value.slice(1)
233
+ }
@@ -39,19 +39,38 @@ if (typeof document !== "undefined") {
39
39
  const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
40
40
  for (const eventName of eventNames) {
41
41
  document.addEventListener(eventName, event => {
42
- const target = event.target.closest(`[data-k-native-${eventName}]`)
43
- if (!target) return
44
-
45
- const native = JSON.parse(target.dataset[`kNative${capitalize(eventName)}`])
46
- let modulePromise = modules.get(native.module)
47
- if (!modulePromise) {
48
- modulePromise = import(native.module)
49
- modules.set(native.module, modulePromise)
42
+ try {
43
+ dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
44
+ } catch (error) {
45
+ console.error(error)
50
46
  }
51
- modulePromise
52
- .then(module => module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target)))
53
- .catch(error => console.error(error))
54
- }, true)
47
+ })
48
+ }
49
+ }
50
+
51
+ function snapshotNativeTargets(event, eventName) {
52
+ const selector = `[data-k-native-${eventName}]`
53
+ const targets = []
54
+ for (let target = event.target.closest(selector); target; target = target.parentElement?.closest(selector)) {
55
+ targets.push({ target, native: JSON.parse(target.dataset[`kNative${capitalize(eventName)}`]) })
56
+ }
57
+ return targets
58
+ }
59
+
60
+ async function dispatchNative(event, targets, modules) {
61
+ for (const { target, native } of targets) {
62
+ let modulePromise = modules.get(native.module)
63
+ if (!modulePromise) {
64
+ modulePromise = import(native.module)
65
+ modules.set(native.module, modulePromise)
66
+ }
67
+ try {
68
+ const module = await modulePromise
69
+ const result = module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target))
70
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
71
+ } catch (error) {
72
+ console.error(error)
73
+ }
55
74
  }
56
75
  }
57
76