@kudzujs/core 0.2.3 → 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.
@@ -6,6 +6,8 @@ export function behavior(commands: Array<["add" | "set" | "log", unknown, unknow
6
6
  export function nativeBehavior(module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
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
+ 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
9
11
 
10
12
  export function renderPage(
11
13
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -31,6 +33,7 @@ export function renderPage(
31
33
  html: string
32
34
  hasBehaviors: boolean
33
35
  hasBindings: boolean
36
+ hasLists: boolean
34
37
  hasStateSeed: boolean
35
38
  plan: {
36
39
  states: Array<{ id: string; name: string; initialValue: unknown }>
@@ -40,7 +43,7 @@ export function renderPage(
40
43
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
41
44
  }>
42
45
  bindings: Array<{
43
- target: "class" | "disabled" | "value"
46
+ target: string
44
47
  state?: string
45
48
  module?: string
46
49
  handler?: string
@@ -49,5 +52,7 @@ export function renderPage(
49
52
  scopeStates?: Record<string, string>
50
53
  scopeBindings?: Record<string, unknown>
51
54
  }>
55
+ conditions: Array<Record<string, unknown>>
56
+ lists: Array<Record<string, unknown>>
52
57
  }
53
58
  }>
@@ -2,6 +2,10 @@ const signalMarker = Symbol("kudzu.signal")
2
2
  const behaviorMarker = Symbol("kudzu.behavior")
3
3
  const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
4
4
  const bindingMarker = Symbol("kudzu.binding")
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")
5
9
 
6
10
  let renderContext
7
11
 
@@ -53,6 +57,58 @@ export function nativeBehavior(module, handler, states, scope) {
53
57
  }
54
58
 
55
59
  export function binding(value, module, handler, states, scope) {
60
+ return { [bindingMarker]: true, value, ...reactiveDescriptor(module, handler, states, scope) }
61
+ }
62
+
63
+ export function conditional(kind, value, truthy, falsy, module, handler, states, scope) {
64
+ return { [conditionalMarker]: true, kind, value, truthy, falsy, ...reactiveDescriptor(module, handler, states, scope) }
65
+ }
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
+
111
+ function reactiveDescriptor(module, handler, states, scope) {
56
112
  const scopeStates = {}
57
113
  const serializedScope = {}
58
114
  const scopeBindings = {}
@@ -62,8 +118,6 @@ export function binding(value, module, handler, states, scope) {
62
118
  else serializedScope[name] = serializeCapture(name, entry, new Set())
63
119
  }
64
120
  return {
65
- [bindingMarker]: true,
66
- value,
67
121
  module,
68
122
  handler,
69
123
  states: Object.fromEntries(states.map(([name, signal]) => {
@@ -116,7 +170,7 @@ function serializeCapture(name, value, seen) {
116
170
  }
117
171
 
118
172
  export async function renderPage(component, metadata = {}) {
119
- renderContext = { nextState: 0, states: {}, textStates: new Set(), events: [], bindings: [], 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 }
120
174
 
121
175
  try {
122
176
  const body = await renderNode({ type: component, props: {} })
@@ -134,22 +188,28 @@ export async function renderPage(component, metadata = {}) {
134
188
  const bindingRuntime = renderContext.hasBindings
135
189
  ? '<script type="module" src="/assets/kudzu-binding.js"></script>'
136
190
  : ""
191
+ const listRuntime = renderContext.hasLists
192
+ ? '<script type="module" src="/assets/kudzu-list.js"></script>'
193
+ : ""
137
194
  const initialState = renderContext.hasBehaviors
138
- ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
195
+ ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
139
196
  : []
140
197
  const state = initialState.length
141
- ? ` data-k-state="${escapeAttribute(JSON.stringify(initialState))}"`
198
+ ? ` data-k-state='${escapeJsonAttribute(initialState)}'`
142
199
  : ""
143
200
 
144
201
  return {
145
- html: `<!doctype html>\n<html lang="${escapeAttribute(metadata.lang ?? "en")}">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${title}</title>\n${head}${styles}\n</head>\n<body${state}>\n${body}\n${runtime}\n${bindingRuntime}\n${nativeRuntime}\n</body>\n</html>\n`,
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>`,
146
203
  hasBehaviors: renderContext.hasBehaviors,
147
204
  hasBindings: renderContext.hasBindings,
205
+ hasLists: renderContext.hasLists,
148
206
  hasStateSeed: initialState.length > 0,
149
207
  plan: {
150
208
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
151
209
  events: renderContext.events,
152
- bindings: renderContext.bindings
210
+ bindings: renderContext.bindings,
211
+ conditions: renderContext.conditions,
212
+ lists: renderContext.lists
153
213
  }
154
214
  }
155
215
  } finally {
@@ -172,7 +232,7 @@ function renderMetadata(metadata) {
172
232
 
173
233
  meta("og:title", metadata.title, true)
174
234
  meta("og:description", metadata.description, true)
175
- meta("og:type", metadata.type ?? "website", true)
235
+ if (metadata.type || metadata.title || metadata.description || metadata.url || metadata.image || metadata.siteName || metadata.locale) meta("og:type", metadata.type ?? "website", true)
176
236
  meta("og:url", metadata.url, true)
177
237
  meta("og:image", metadata.image, true)
178
238
  if (metadata.image) {
@@ -187,47 +247,94 @@ function renderMetadata(metadata) {
187
247
  meta("twitter:description", metadata.description)
188
248
  meta("twitter:image", metadata.twitterImage ?? metadata.image)
189
249
 
190
- return tags.length ? `${tags.join("\n")}\n` : ""
250
+ return tags.join("")
191
251
  }
192
252
 
193
- async function renderNode(node) {
253
+ async function renderNode(node, namespace, selectValue = noSelectValue) {
194
254
  if (node == null || node === false || node === true) return ""
195
255
  if (Array.isArray(node)) {
196
256
  let html = ""
197
- for (const child of node) html += await renderNode(child)
257
+ for (const child of node) html += await renderNode(child, namespace, selectValue)
198
258
  return html
199
259
  }
200
260
  if (node?.[signalMarker]) {
201
261
  renderContext.textStates.add(node.id)
202
- return `<span data-k-text="${node.id}" data-k-value="${escapeAttribute(JSON.stringify(node.value))}">${escapeHtml(node.value)}</span>`
262
+ if (renderContext.conditionDepth || renderContext.listDepth) renderContext.conditionStates.add(node.id)
263
+ return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value)}</span>`
203
264
  }
204
265
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
205
266
  return escapeHtml(node)
206
267
  }
207
- if (node instanceof Promise) return renderNode(await node)
268
+ if (node instanceof Promise) return renderNode(await node, namespace, selectValue)
269
+ if (node?.[conditionalMarker]) {
270
+ const descriptor = bindingDescriptor(node)
271
+ const stateIds = reactiveStateIds(descriptor)
272
+ if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
273
+ if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
274
+
275
+ const id = `c${renderContext.nextCondition++}`
276
+ renderContext.conditionDepth++
277
+ const truthy = await renderNode(node.truthy(), namespace, selectValue)
278
+ const falsy = await renderNode(node.falsy(), namespace, selectValue)
279
+ renderContext.conditionDepth--
280
+ const metadata = { id, kind: node.kind, initial: node.value, ...descriptor }
281
+ for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
282
+ renderContext.conditions.push(metadata)
283
+ renderContext.hasBehaviors = true
284
+ renderContext.hasBindings = true
285
+ const encoded = escapeJsonAttribute(metadata)
286
+ const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy
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>`
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
+ }
208
293
  if (!node || typeof node !== "object" || !("type" in node)) {
209
294
  throw new Error(`Cannot render ${String(node)}`)
210
295
  }
211
296
 
212
- if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children)
213
- if (typeof node.type === "function") return renderNode(await node.type(node.props))
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)
214
299
 
215
300
  const tag = node.type
216
301
  const props = node.props ?? {}
302
+ const childSelectValue = tag === "select"
303
+ ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
304
+ : selectValue
305
+ const childNamespace = tag === "svg" || tag === "math"
306
+ ? tag
307
+ : namespace === "svg" && tag === "foreignObject" ? undefined : namespace
217
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
+ }
218
319
 
219
320
  for (const [rawName, value] of Object.entries(props)) {
220
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
+ }
221
328
 
222
329
  if (/^on[A-Z]/.test(rawName)) {
223
330
  const event = rawName.slice(2).toLowerCase()
224
331
  if (value?.[behaviorMarker]) {
225
332
  const commands = JSON.stringify(value.commands)
226
- attributes += ` data-k-on-${event}="${escapeAttribute(commands)}"`
333
+ attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
227
334
  renderContext.events.push({ event, commands: value.commands })
228
335
  } else if (value?.[nativeBehaviorMarker]) {
229
336
  const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
230
- attributes += ` data-k-native-${event}="${escapeAttribute(JSON.stringify(native))}"`
337
+ attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
231
338
  renderContext.events.push({ event, native })
232
339
  renderContext.hasNativeBehaviors = true
233
340
  } else {
@@ -238,48 +345,103 @@ async function renderNode(node) {
238
345
  }
239
346
 
240
347
  const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
241
- const target = name === "class" || name === "disabled" || name === "value" ? name : undefined
242
- 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]) {
243
355
  const initialValue = value[signalMarker] ? value.value : value.value
244
356
  const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
245
357
  if (!reactive) {
246
- attributes += renderAttribute(name, initialValue)
358
+ if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
247
359
  continue
248
360
  }
249
361
  const descriptor = value[signalMarker]
250
362
  ? { state: value.id }
251
363
  : bindingDescriptor(value)
252
- attributes += renderAttribute(name, initialValue)
253
- attributes += ` data-k-bind-${target}="${escapeAttribute(JSON.stringify(descriptor))}"`
254
- renderContext.bindings.push({ target, ...descriptor })
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)
255
369
  renderContext.hasBehaviors = true
256
370
  renderContext.hasBindings = true
257
371
  continue
258
372
  }
259
373
 
260
- if (value == null || value === false) continue
261
- if (value === true) {
262
- attributes += ` ${name}`
263
- } else if (name === "style" && typeof value === "object") {
374
+ if (tag === "select" && name === "value") continue
375
+ if (name === "style" && value && typeof value === "object") {
264
376
  const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
265
377
  attributes += ` style="${escapeAttribute(style)}"`
266
378
  } else {
267
- attributes += ` ${name}="${escapeAttribute(value)}"`
379
+ attributes += renderAttribute(name, value)
268
380
  }
269
381
  }
270
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
+
271
388
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
272
389
  if (voidElements.has(tag)) return `<${tag}${attributes}>`
273
- return `<${tag}${attributes}>${await renderNode(props.children)}</${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 ?? ""
422
+ }
423
+
424
+ function reactiveStateIds(descriptor) {
425
+ if (descriptor.state) return new Set([descriptor.state])
426
+ return new Set([
427
+ ...Object.values(descriptor.states),
428
+ ...Object.values(descriptor.scopeStates),
429
+ ...Object.values(descriptor.scopeBindings).flatMap(entry => [...reactiveStateIds(entry)])
430
+ ])
274
431
  }
275
432
 
276
433
  function renderAttribute(name, value) {
277
- if (name === "disabled") return value ? " disabled" : ""
434
+ if (name === "disabled" || name === "checked") return value ? ` ${name}` : ""
278
435
  if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
279
- if (value == null || value === false) return ""
436
+ if (value == null || (value === false && !isStringBooleanAttribute(name))) return ""
437
+ if (value === true && !isStringBooleanAttribute(name)) return ` ${name}`
280
438
  return ` ${name}="${escapeAttribute(value)}"`
281
439
  }
282
440
 
441
+ function isStringBooleanAttribute(name) {
442
+ return name.startsWith("aria-") || name.startsWith("data-")
443
+ }
444
+
283
445
  function escapeHtml(value) {
284
446
  return String(value)
285
447
  .replaceAll("&", "&amp;")
@@ -292,6 +454,10 @@ function escapeAttribute(value) {
292
454
  return escapeHtml(value).replaceAll("'", "&#39;")
293
455
  }
294
456
 
457
+ function escapeJsonAttribute(value) {
458
+ return JSON.stringify(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll("'", "&#39;")
459
+ }
460
+
295
461
  function toKebabCase(value) {
296
462
  return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
297
463
  }
@@ -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
+ }
@@ -1,41 +1,30 @@
1
1
  export function applyCommands(state, commands, commit, log = console.log) {
2
- const changed = new Set()
3
-
4
- for (const [operation, id, operand] of commands) {
5
- const current = state.get(id)
6
- if (operation === "log") {
7
- log(operand, current)
8
- continue
2
+ const changed=new Set
3
+ for(const [operation,id,value] of commands){
4
+ const current=state.get(id)
5
+ if(operation==="log")log(value,current)
6
+ else {
7
+ state.set(id,operation==="add"?current+value:value)
8
+ changed.add(id)
9
9
  }
10
- state.set(id, operation === "add" ? current + operand : operand)
11
- changed.add(id)
12
10
  }
13
-
14
- for (const id of changed) commit(id, state.get(id))
11
+ for(const id of changed)commit(id,state.get(id))
15
12
  }
16
13
 
17
- export const browserState = new Map()
14
+ export const browserState=new Map
18
15
 
19
16
  export function commitDom(id, value) {
20
- for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
17
+ for(const node of document.querySelectorAll(`[data-k-text="${id}"]`))node.textContent = value
21
18
  }
22
19
 
23
- if (typeof document !== "undefined") {
20
+ if(typeof document!=="undefined"){
24
21
  const initialState = document.body.dataset.kState
25
22
  if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
26
- for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
23
+ for(const node of document.querySelectorAll("[data-k-text]"))browserState.set(node.dataset.kText,JSON.parse(node.dataset.kValue))
27
24
 
28
25
  const eventNames = ["click", "input", "change"]
29
- for (const eventName of eventNames) {
30
- document.addEventListener(eventName, event => {
31
- const target = event.target.closest(`[data-k-on-${eventName}]`)
32
- if (!target) return
33
- const commands = target.dataset[`kOn${capitalize(eventName)}`]
34
- applyCommands(browserState, JSON.parse(commands), commitDom)
26
+ for(const eventName of eventNames)document.addEventListener(eventName,event=>{
27
+ const target=event.target.closest(`[data-k-on-${eventName}]`)
28
+ if(target)applyCommands(browserState,JSON.parse(target.getAttribute(`data-k-on-${eventName}`)),commitDom)
35
29
  })
36
- }
37
- }
38
-
39
- function capitalize(value) {
40
- return value[0].toUpperCase() + value.slice(1)
41
30
  }