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