@kudzujs/core 0.4.5 → 0.4.7

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
@@ -108,6 +108,7 @@ The handler above increments by two and patches its bound DOM once. Inspect the
108
108
  <input value={name} onInput={event => setName(event.currentTarget.value)} />
109
109
  <input type="checkbox" checked={subscribed} onChange={event => setSubscribed(event.currentTarget.checked)} />
110
110
  <select value={theme} onChange={event => setTheme(event.currentTarget.value)} />
111
+ <div style={{ opacity: open ? 1 : 0, width: open ? 240 : 0 }} />
111
112
  ```
112
113
 
113
114
  Regular attributes use the same expressions without an allowlist:
@@ -121,7 +122,7 @@ Regular attributes use the same expressions without an allowlist:
121
122
  />
122
123
  ```
123
124
 
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.
125
+ 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. Object `style` values use React-shaped camelCase properties, add `px` to nonzero dimensional numbers, and preserve unitless properties and CSS custom properties. Reactive `ref` and `dangerouslySetInnerHTML` remain unsupported.
125
126
 
126
127
  ## Conditional DOM
127
128
 
@@ -228,6 +229,7 @@ Supported:
228
229
  - Serializable component-local captures
229
230
  - Direct text DOM patches
230
231
  - Reactive standard, `aria-*`, and `data-*` attributes
232
+ - Reactive object `style` attributes
231
233
  - Controlled `value` and `checked` form properties
232
234
  - Conditional child `&&` and ternary DOM patches
233
235
  - Direct keyed local-state lists
@@ -279,15 +281,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
279
281
 
280
282
  | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
281
283
  |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
282
- | Astro | Yes | **324 B** | **43.6 KB** | 867 ms | **3.5 ms** | **25.4 ms** | 10.7 ms | **18.0 ms** | **57.6 ms** |
283
- | Kudzu | Yes | 5.7 KB | 180.8 KB | **417 ms** | 8.3 ms | 28.5 ms | **10.2 ms** | 20.5 ms | **67.5 ms** |
284
- | Vue CSR | No | 24.3 KB | 61.3 KB | 771 ms | 9.7 ms | 31.3 ms | 9.4 ms | 18.1 ms | 68.5 ms |
285
- | React CSR | No | 59.3 KB | 189.4 KB | 1024 ms | 8.7 ms | 32.3 ms | 12.4 ms | 18.1 ms | 71.5 ms |
286
- | Next.js | Yes | 182.2 KB | 695.2 KB | 3049 ms | 6.7 ms | 35.0 ms | 16.3 ms | 19.4 ms | 77.4 ms |
287
- | Qwik CSR | No | 22.2 KB | 64.1 KB | 602 ms | 14.5 ms | 19.9 ms | 31.3 ms | 18.9 ms | 84.6 ms |
288
- | Svelte CSR | No | 12.9 KB | 33.1 KB | 859 ms | 5.2 ms | 65.4 ms | 10.7 ms | 19.9 ms | 101.2 ms |
289
-
290
- Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 67.5 ms, ahead of the React fixture's 71.5 ms and within 9.9 ms of Astro across all four operations.
284
+ | Astro | Yes | **324 B** | **43.6 KB** | 852 ms | **3.5 ms** | 25.8 ms | 15.8 ms | **17.9 ms** | **63.0 ms** |
285
+ | Kudzu | Yes | 6.3 KB | 69.3 KB | **406 ms** | 6.0 ms | 28.5 ms | 14.7 ms | 19.1 ms | **68.3 ms** |
286
+ | Next.js | Yes | 182.2 KB | 695.2 KB | 2950 ms | 6.8 ms | 34.1 ms | **9.0 ms** | 19.3 ms | 69.2 ms |
287
+ | React CSR | No | 59.3 KB | 189.4 KB | 1028 ms | 9.5 ms | 35.3 ms | 10.1 ms | **17.8 ms** | 72.7 ms |
288
+ | Vue CSR | No | 24.3 KB | 61.3 KB | 759 ms | 10.2 ms | 33.9 ms | 9.3 ms | 19.7 ms | 73.1 ms |
289
+ | Qwik CSR | No | 22.2 KB | 64.1 KB | 604 ms | 15.6 ms | **20.0 ms** | 30.4 ms | 18.1 ms | 84.1 ms |
290
+ | Svelte CSR | No | 12.9 KB | 33.1 KB | 845 ms | 5.4 ms | 64.4 ms | 11.2 ms | 19.1 ms | 100.1 ms |
291
+
292
+ Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 68.3 ms, ahead of the React fixture's 72.7 ms and within 5.3 ms of Astro across all four operations.
291
293
 
292
294
  Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
293
295
 
@@ -1,5 +1,6 @@
1
1
  import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
2
2
  import { deserialize } from "./serialization.js"
3
+ import { serializeStyle } from "./style.js"
3
4
 
4
5
  const imports = new Map()
5
6
  const bindingTargets = new Map()
@@ -8,7 +9,7 @@ const mountedBindings = new WeakSet()
8
9
  const mountedConditions = new WeakSet()
9
10
  const bindingRegistrations = new WeakMap()
10
11
  const conditionRegistrations = new WeakMap()
11
- const bindingTypes = ["class", "disabled", "value", "checked"]
12
+ const bindingTypes = ["class", "disabled", "value", "checked", "style"]
12
13
  const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
13
14
 
14
15
  export function patchBinding(node, target, value) {
@@ -19,6 +20,10 @@ export function patchBinding(node, target, value) {
19
20
  } else if (target === "value") {
20
21
  const next = value == null ? "" : String(value)
21
22
  if (node.value !== next) node.value = next
23
+ } else if (target === "style") {
24
+ const style = serializeStyle(value)
25
+ if (style) node.setAttribute("style", style)
26
+ else node.removeAttribute("style")
22
27
  } else if (target === "class" && (value == null || value === false)) {
23
28
  node.removeAttribute("class")
24
29
  } else if (target === "class") {
@@ -73,9 +73,11 @@ export async function build({ quiet = false } = {}) {
73
73
  }
74
74
  if (bindingCount || hasNativeHandlers) await cp(new URL("./serialization.js", import.meta.url), join(assetsDirectory, "kudzu-serialization.js"))
75
75
  if (bindingCount) {
76
+ await cp(new URL("./style.js", import.meta.url), join(assetsDirectory, "kudzu-style.js"))
76
77
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
77
78
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
78
79
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
80
+ .replace('"./style.js"', '"./kudzu-style.js"')
79
81
  await writeFile(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime)
80
82
  }
81
83
  if (listCount) {
@@ -116,7 +118,7 @@ function specializeRuntime(source, events, hasStateSeed) {
116
118
  if (hasStateSeed) return specialized
117
119
  return specialized
118
120
  .replace(" const initialState = document.body.dataset.kState\n", "")
119
- .replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
121
+ .replace(" if (initialState) for (const [id, value, compact] of JSON.parse(initialState)) browserState.set(id, compact ? value[1].map(row => Object.fromEntries(value[0].map((field, index) => [field, row[index]]))) : value)\n", "")
120
122
  }
121
123
 
122
124
  export function parseDevPort(value) {
@@ -388,7 +390,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
388
390
  }
389
391
  }
390
392
 
391
- 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())) {
393
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
392
394
  const expression = node.initializer.expression
393
395
  const setters = settersForNode(node, settersByFunction)
394
396
  const usedStates = referencedStateNames(expression, setters)
@@ -1,3 +1,5 @@
1
+ import { serializeStyle } from "./style.js"
2
+
1
3
  const signalMarker = Symbol("kudzu.signal")
2
4
  const behaviorMarker = Symbol("kudzu.behavior")
3
5
  const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
@@ -187,7 +189,7 @@ function serializeCapture(name, value, seen) {
187
189
  }
188
190
 
189
191
  export async function renderPage(component, metadata = {}) {
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 }
192
+ renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listFields: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
191
193
 
192
194
  try {
193
195
  const body = await renderNode({ type: component, props: {} })
@@ -208,8 +210,13 @@ export async function renderPage(component, metadata = {}) {
208
210
  const listRuntime = renderContext.hasLists
209
211
  ? '<script type="module" src="/assets/kudzu-list.js"></script>'
210
212
  : ""
213
+ const listStates = new Set(renderContext.lists.map(list => list.state))
214
+ const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
211
215
  const initialState = renderContext.hasBehaviors
212
- ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
216
+ ? Object.entries(renderContext.states).filter(([id]) => (!renderContext.textStates.has(id) || renderContext.conditionStates.has(id)) && !seededListStates.has(id)).map(([id, entry]) => {
217
+ const compact = listStates.has(id) && compactListState(entry.initialValue)
218
+ return compact ? [id, compact, 1] : [id, entry.initialValue]
219
+ })
213
220
  : []
214
221
  const state = initialState.length
215
222
  ? ` data-k-state='${escapeJsonAttribute(initialState)}'`
@@ -305,11 +312,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
305
312
  }
306
313
  if (node?.[listMarker]) return renderList(node, namespace, selectValue)
307
314
  if (node?.[listFieldMarker]) {
308
- return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
315
+ if (renderContext.listTemplate) renderContext.listFields?.add(node.field)
316
+ const marker = renderContext.listTemplate ? ` data-k-list-text="${escapeAttribute(node.field)}"` : ""
317
+ return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
309
318
  }
310
319
  if (node?.[listExpressionMarker]) {
311
320
  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>`
321
+ const marker = renderContext.listTemplate ? ` data-k-list-expression='${escapeJsonAttribute(descriptor)}'` : ""
322
+ return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
313
323
  }
314
324
  if (!node || typeof node !== "object" || !("type" in node)) {
315
325
  throw new Error(`Cannot render ${String(node)}`)
@@ -336,9 +346,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
336
346
  if (renderContext.listRoot) {
337
347
  const root = renderContext.listRoot
338
348
  renderContext.listRoot = undefined
339
- attributes += root.template
340
- ? ` data-k-list-root="${root.id}"`
341
- : ` data-k-list-item='${escapeJsonAttribute([root.id, root.key])}'`
349
+ if (root.template) attributes += ` data-k-list-root="${root.id}"`
342
350
  }
343
351
 
344
352
  for (const [rawName, value] of Object.entries(props)) {
@@ -346,7 +354,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
346
354
  if (rawName === "selected" && selectValue !== noSelectValue) continue
347
355
  if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
348
356
  if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
349
- if (["style", "ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
357
+ if (["ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
350
358
  throw new Error(`Reactive ${rawName} is not supported`)
351
359
  }
352
360
 
@@ -371,7 +379,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
371
379
  }
372
380
 
373
381
  const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
374
- const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked"
382
+ const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked" || name === "style"
375
383
  if (value?.[listFieldMarker]) {
376
384
  attributes += renderAttribute(name, value.value)
377
385
  listAttributes.push([name, value.field])
@@ -403,19 +411,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
403
411
  }
404
412
 
405
413
  if (tag === "select" && name === "value") continue
406
- if (name === "style" && value && typeof value === "object") {
407
- const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
408
- attributes += ` style="${escapeAttribute(style)}"`
409
- } else {
410
- attributes += renderAttribute(name, value)
411
- }
414
+ attributes += renderAttribute(name, value)
412
415
  }
413
416
 
414
417
  if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
415
- if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
416
- if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
417
- if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
418
- if (directListText) attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
418
+ if (renderContext.listTemplate && listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
419
+ if (renderContext.listTemplate && listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
420
+ if (renderContext.listTemplate && listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
421
+ if (renderContext.listTemplate && directListText) {
422
+ renderContext.listFields?.add(directListText.field)
423
+ attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
424
+ }
419
425
 
420
426
  if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
421
427
 
@@ -428,12 +434,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
428
434
  async function renderList(node, namespace, selectValue) {
429
435
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
430
436
  const id = `l${renderContext.nextList++}`
431
- const descriptor = { id, state: node.items.id, key: node.keyField }
437
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]) }
432
438
  renderContext.listDepth++
439
+ const previousListFields = renderContext.listFields
433
440
  try {
434
441
  renderContext.listTemplate = true
442
+ renderContext.listFields = new Set([node.keyField])
435
443
  renderContext.listRoot = { id, template: true }
436
444
  const template = await renderNode(node.render({}), namespace, selectValue)
445
+ if (template.includes("data-k-native-")) descriptor.mount = true
446
+ const seed = listSeed(node.items.value, renderContext.listFields)
447
+ if (seed) descriptor.seed = seed
437
448
  let current = ""
438
449
  renderContext.listTemplate = false
439
450
  for (const item of node.items.value) {
@@ -447,6 +458,7 @@ async function renderList(node, namespace, selectValue) {
447
458
  } finally {
448
459
  renderContext.listRoot = undefined
449
460
  renderContext.listTemplate = false
461
+ renderContext.listFields = previousListFields
450
462
  renderContext.listDepth--
451
463
  }
452
464
  }
@@ -466,6 +478,10 @@ function reactiveStateIds(descriptor) {
466
478
  }
467
479
 
468
480
  function renderAttribute(name, value) {
481
+ if (name === "style") {
482
+ const style = serializeStyle(value)
483
+ return style ? ` style="${escapeAttribute(style)}"` : ""
484
+ }
469
485
  if (name === "disabled" || name === "checked") return value ? ` ${name}` : ""
470
486
  if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
471
487
  if (value == null || (value === false && !isStringBooleanAttribute(name))) return ""
@@ -493,6 +509,20 @@ function escapeJsonAttribute(value) {
493
509
  return JSON.stringify(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll("'", "&#39;")
494
510
  }
495
511
 
496
- function toKebabCase(value) {
497
- return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
512
+ function compactListState(value) {
513
+ if (!Array.isArray(value) || !value.length) return undefined
514
+ const fields = Object.keys(value[0])
515
+ if (!fields.length || !value.every(item => Object.keys(item).length === fields.length && fields.every(field => Object.hasOwn(item, field)))) return undefined
516
+ return [fields, value.map(item => fields.map(field => item[field]))]
517
+ }
518
+
519
+ function listSeed(items, fields) {
520
+ if (!items.length || !items.every(item => Object.keys(item).length === fields.size && Object.keys(item).every(field => fields.has(field)))) return undefined
521
+ const seed = {}
522
+ for (const field of fields) {
523
+ const types = new Set(items.map(item => item[field] === null ? "null" : typeof item[field]))
524
+ if (types.size !== 1 || !["string", "number", "boolean", "null"].includes([...types][0])) return undefined
525
+ seed[field] = [...types][0]
526
+ }
527
+ return seed
498
528
  }
@@ -28,16 +28,22 @@ function mountLists(root) {
28
28
  if (mountedLists.has(start)) continue
29
29
  mountedLists.add(start)
30
30
  const descriptor = JSON.parse(start.dataset.kList)
31
- const roots = [...start.ownerDocument.querySelectorAll("[data-k-list-item]")].filter(node => JSON.parse(node.dataset.kListItem)[0] === descriptor.id)
31
+ const end = findEnd(start, descriptor.id)
32
+ const roots = listRoots(start, end)
33
+ const templateRoot = start.content.firstElementChild
34
+ const parts = listItemPartPlan(templateRoot)
35
+ for (const root of roots) mapListItemParts(parts, root)
36
+ if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
32
37
  const items = browserState.get(descriptor.state)
33
- for (const root of roots) listItemParts(root)
34
38
  const list = {
35
39
  start,
36
40
  descriptor,
37
- roots: new Map(roots.map(node => [keyToken(JSON.parse(node.dataset.kListItem)[1]), node])),
38
- values: new Map(items.map(item => [keyToken(item[descriptor.key]), JSON.stringify(item)])),
41
+ parts,
42
+ seedFields: descriptor.seed && Object.keys(descriptor.seed),
43
+ roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
44
+ values: new Map(),
39
45
  container: roots[0]?.parentNode,
40
- boundary: roots.length ? roots.at(-1).nextSibling : findEnd(start, descriptor.id)
46
+ boundary: end
41
47
  }
42
48
  register(listTargets, descriptor.state, list)
43
49
  listRegistrations.set(start, { state: descriptor.state, list })
@@ -70,11 +76,12 @@ function updateList(list) {
70
76
  const key = item?.[list.descriptor.key]
71
77
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
72
78
  assertListItem(item)
73
- assertListValue(item, seen, true)
79
+ const seededValue = list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed)
80
+ if (seededValue === undefined) assertListValue(item, seen, true)
74
81
  const token = keyToken(key)
75
82
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
76
83
  keys.add(token)
77
- entries.push({ item, key, token, value: JSON.stringify(item) })
84
+ entries.push({ item, key, token, value: seededValue ?? JSON.stringify(item) })
78
85
  }
79
86
  const next = []
80
87
  const values = new Map()
@@ -88,7 +95,7 @@ function updateList(list) {
88
95
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
89
96
  if (!node) throw new Error("Keyed list template has no root element")
90
97
  node.removeAttribute("data-k-list-root")
91
- node.dataset.kListItem = JSON.stringify([list.descriptor.id, key])
98
+ mapListItemParts(list.parts, node)
92
99
  fillListItem(node, item)
93
100
  additions.append(node)
94
101
  added = true
@@ -98,13 +105,18 @@ function updateList(list) {
98
105
  next.push([token, node])
99
106
  values.set(token, value)
100
107
  }
108
+ const removals = parent.ownerDocument.createDocumentFragment()
101
109
  for (const [token, node] of list.roots) {
102
110
  if (keys.has(token)) continue
103
- unmountDom(node)
104
- node.remove()
111
+ if (list.descriptor.mount) {
112
+ unmountDom(node)
113
+ node.remove()
114
+ } else {
115
+ removals.append(node)
116
+ }
105
117
  }
106
118
  if (added) {
107
- mountDom(additions)
119
+ if (list.descriptor.mount) mountDom(additions)
108
120
  parent.insertBefore(additions, list.boundary)
109
121
  list.container ??= parent
110
122
  }
@@ -135,8 +147,8 @@ function fillListItem(root, item) {
135
147
  const revision = (revisions.get(root) ?? 0) + 1
136
148
  revisions.set(root, revision)
137
149
  const parts = listItemParts(root)
138
- for (const node of parts.directTexts) {
139
- const text = item?.[node.dataset.kListText]
150
+ for (const [node, field] of parts.directTexts) {
151
+ const text = item?.[field]
140
152
  const value = text == null ? "" : String(text)
141
153
  const current = node.firstChild
142
154
  if (current?.nodeType === Node.TEXT_NODE && !current.nextSibling) {
@@ -145,14 +157,14 @@ function fillListItem(root, item) {
145
157
  node.textContent = value
146
158
  }
147
159
  }
148
- for (const marker of parts.texts) {
149
- patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
160
+ for (const [marker, field] of parts.texts) {
161
+ patchListText(marker, "template[data-k-list-text-end]", item?.[field])
150
162
  }
151
163
  for (const [node, attributes] of parts.attributes) {
152
164
  for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
153
165
  }
154
- for (const node of parts.events) {
155
- for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
166
+ for (const [node, events] of parts.events) {
167
+ for (const [event, native] of JSON.parse(events)) {
156
168
  native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
157
169
  node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
158
170
  }
@@ -176,9 +188,9 @@ function listItemParts(root) {
176
188
  if (parts) return parts
177
189
  parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [] }
178
190
  for (const node of matching(root, itemPartsSelector)) {
179
- if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push(node)
191
+ if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
180
192
  if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
181
- if (node.hasAttribute("data-k-list-events")) parts.events.push(node)
193
+ if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
182
194
  if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
183
195
  if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
184
196
  }
@@ -186,6 +198,61 @@ function listItemParts(root) {
186
198
  return parts
187
199
  }
188
200
 
201
+ function listItemPartPlan(template) {
202
+ const source = [template, ...template.querySelectorAll("*")]
203
+ const indexes = new Map(source.map((node, index) => [node, index]))
204
+ const parts = listItemParts(template)
205
+ return {
206
+ directTexts: parts.directTexts.map(([node, field]) => [indexes.get(node), field]),
207
+ texts: parts.texts.map(([node, field]) => [indexes.get(node), field]),
208
+ attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
209
+ events: parts.events.map(([node, events]) => [indexes.get(node), events]),
210
+ expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
211
+ expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes])
212
+ }
213
+ }
214
+
215
+ function mapListItemParts(parts, root) {
216
+ const target = [root, ...root.querySelectorAll("*")]
217
+ itemParts.set(root, {
218
+ directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
219
+ texts: parts.texts.map(([index, field]) => [target[index], field]),
220
+ attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
221
+ events: parts.events.map(([index, events]) => [target[index], events]),
222
+ expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
223
+ expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes])
224
+ })
225
+ }
226
+
227
+ function listRoots(start, end) {
228
+ const roots = []
229
+ for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) {
230
+ if (node.nodeType === Node.ELEMENT_NODE) roots.push(node)
231
+ }
232
+ return roots
233
+ }
234
+
235
+ function seedListItem(root, descriptor, index) {
236
+ const item = { [descriptor.key]: descriptor.keys[index] }
237
+ const parts = itemParts.get(root)
238
+ for (const [node, field] of parts.directTexts) item[field] = seedValue(node.textContent, descriptor.seed[field])
239
+ for (const [marker, field] of parts.texts) item[field] = seedValue(rangeText(marker, "template[data-k-list-text-end]"), descriptor.seed[field])
240
+ return item
241
+ }
242
+
243
+ function seedValue(value, type) {
244
+ if (type === "number") return Number(value)
245
+ if (type === "boolean") return value === "true"
246
+ if (type === "null") return null
247
+ return value
248
+ }
249
+
250
+ function rangeText(marker, endSelector) {
251
+ let value = ""
252
+ for (let node = marker.nextSibling; node && !(node.nodeType === Node.ELEMENT_NODE && node.matches(endSelector)); node = node.nextSibling) value += node.textContent
253
+ return value
254
+ }
255
+
189
256
  function patchListText(marker, endSelector, value) {
190
257
  let end = marker.nextSibling
191
258
  while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
@@ -254,6 +321,22 @@ function assertListItem(item) {
254
321
  if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
255
322
  }
256
323
 
324
+ function seededListValue(item, fields, seed) {
325
+ const keys = Reflect.ownKeys(item)
326
+ if (keys.length !== fields.length || keys.some(key => typeof key !== "string" || !fields.includes(key))) return undefined
327
+ const values = []
328
+ for (const field of fields) {
329
+ const descriptor = Object.getOwnPropertyDescriptor(item, field)
330
+ if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
331
+ if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
332
+ const value = descriptor.value
333
+ const type = value === null ? "null" : typeof value
334
+ if (type !== seed[field] || type === "number" && (!Number.isFinite(value) || Object.is(value, -0))) return undefined
335
+ values.push(value)
336
+ }
337
+ return JSON.stringify(values)
338
+ }
339
+
257
340
  function assertListValue(value, seen, root = false) {
258
341
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
259
342
  if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
@@ -55,7 +55,7 @@ export function unmountDom(root) {
55
55
 
56
56
  if (typeof document !== "undefined") {
57
57
  const initialState = document.body.dataset.kState
58
- if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
58
+ if (initialState) for (const [id, value, compact] of JSON.parse(initialState)) browserState.set(id, compact ? value[1].map(row => Object.fromEntries(value[0].map((field, index) => [field, row[index]]))) : value)
59
59
  mountText(document)
60
60
 
61
61
  const eventNames = ["click", "input", "change"]
@@ -0,0 +1,23 @@
1
+ const unitless = new Set([
2
+ "animationIterationCount", "aspectRatio", "borderImageOutset", "borderImageSlice", "borderImageWidth",
3
+ "columnCount", "columns", "flex", "flexGrow", "flexShrink", "fontWeight", "gridArea", "gridColumn",
4
+ "gridColumnEnd", "gridColumnSpan", "gridColumnStart", "gridRow", "gridRowEnd", "gridRowSpan", "gridRowStart",
5
+ "lineClamp", "lineHeight", "opacity", "order", "orphans", "scale", "tabSize", "widows", "zIndex", "zoom",
6
+ "fillOpacity", "floodOpacity", "stopOpacity", "strokeDasharray", "strokeDashoffset", "strokeMiterlimit",
7
+ "strokeOpacity", "strokeWidth"
8
+ ])
9
+
10
+ export function serializeStyle(value) {
11
+ if (value == null || value === false) return ""
12
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("style must be an object")
13
+ return Object.entries(value).flatMap(([property, entry]) => {
14
+ if (entry == null || typeof entry === "boolean") return []
15
+ if (typeof entry === "number" && !Number.isFinite(entry)) throw new Error(`style.${property} must be finite`)
16
+ const suffix = typeof entry === "number" && entry !== 0 && !property.startsWith("--") && !unitless.has(property) ? "px" : ""
17
+ return `${toKebabCase(property)}:${entry}${suffix}`
18
+ }).join(";")
19
+ }
20
+
21
+ function toKebabCase(value) {
22
+ return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`).replace(/^ms-/, "-ms-")
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",