@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 +12 -10
- package/framework/binding-runtime.js +6 -1
- package/framework/build.mjs +4 -2
- package/framework/core.mjs +52 -22
- package/framework/list-runtime.js +102 -19
- package/framework/shared-runtime.js +1 -1
- package/framework/style.js +23 -0
- package/package.json +1 -1
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.
|
|
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** |
|
|
283
|
-
| Kudzu | Yes |
|
|
284
|
-
|
|
|
285
|
-
| React CSR | No | 59.3 KB | 189.4 KB |
|
|
286
|
-
|
|
|
287
|
-
| Qwik CSR | No | 22.2 KB | 64.1 KB |
|
|
288
|
-
| Svelte CSR | No | 12.9 KB | 33.1 KB |
|
|
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
|
|
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") {
|
package/framework/build.mjs
CHANGED
|
@@ -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()) && !["
|
|
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)
|
package/framework/core.mjs
CHANGED
|
@@ -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]) =>
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 (["
|
|
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
|
-
|
|
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)
|
|
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("&", "&").replaceAll("<", "<").replaceAll("'", "'")
|
|
494
510
|
}
|
|
495
511
|
|
|
496
|
-
function
|
|
497
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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?.[
|
|
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?.[
|
|
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(
|
|
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
|
+
}
|