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