@kudzujs/core 0.3.0 → 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 +45 -12
- package/framework/README.md +4 -3
- package/framework/binding-runtime.js +41 -22
- package/framework/build.mjs +103 -2
- package/framework/core.d.ts +4 -1
- package/framework/core.mjs +148 -28
- package/framework/list-runtime.js +185 -0
- package/framework/shared-runtime.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ HTML-first TSX framework with synchronous state semantics and no virtual DOM.
|
|
|
8
8
|
|
|
9
9
|
Kudzu keeps the familiar function-component, props, children, event-handler, and `useState` shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers compile to external ESM.
|
|
10
10
|
|
|
11
|
-
> Experimental `0.
|
|
11
|
+
> Experimental `0.4.x`: the compiler API and supported TSX surface may change.
|
|
12
12
|
|
|
13
13
|
Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
|
|
14
14
|
|
|
@@ -100,15 +100,28 @@ The handler above increments by two and patches its bound DOM once. Inspect the
|
|
|
100
100
|
|
|
101
101
|
## Reactive Attributes
|
|
102
102
|
|
|
103
|
-
`className`, `disabled`, and controlled `
|
|
103
|
+
`className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
|
|
104
104
|
|
|
105
105
|
```tsx
|
|
106
106
|
<div className={active ? "active" : "idle"} />
|
|
107
107
|
<button disabled={loading}>Save</button>
|
|
108
108
|
<input value={name} onInput={event => setName(event.currentTarget.value)} />
|
|
109
|
+
<input type="checkbox" checked={subscribed} onChange={event => setSubscribed(event.currentTarget.checked)} />
|
|
110
|
+
<select value={theme} onChange={event => setTheme(event.currentTarget.value)} />
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
Regular attributes use the same expressions without an allowlist:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<button
|
|
117
|
+
aria-expanded={open}
|
|
118
|
+
data-state={open ? "open" : "closed"}
|
|
119
|
+
hidden={!visible}
|
|
120
|
+
title={open ? "Close menu" : "Open menu"}
|
|
121
|
+
/>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property. `aria-*` and `data-*` boolean values serialize as `"true"` or `"false"`; ordinary false values remove the attribute. Reactive `style`, `ref`, and `dangerouslySetInnerHTML` remain unsupported.
|
|
112
125
|
|
|
113
126
|
## Conditional DOM
|
|
114
127
|
|
|
@@ -131,6 +144,23 @@ Logical state persists across branch switches, while uncontrolled DOM state rese
|
|
|
131
144
|
|
|
132
145
|
Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
|
|
133
146
|
|
|
147
|
+
## Keyed Lists
|
|
148
|
+
|
|
149
|
+
Map local array state directly to one keyed JSX element per item:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
const [items, setItems] = useState([
|
|
153
|
+
{ id: 1, name: "Oak" },
|
|
154
|
+
{ id: 2, name: "Pine" }
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
<ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Each item must be a plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, plain objects, and primitive values.
|
|
161
|
+
|
|
162
|
+
The MVP requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Item data may appear only as direct `item.<field>` text or attributes. Item-derived expressions, item-local handlers, nested conditions or lists, component tags, and fragments are rejected at build time. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
163
|
+
|
|
134
164
|
## Normal JavaScript
|
|
135
165
|
|
|
136
166
|
Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
|
|
@@ -158,6 +188,7 @@ TSX
|
|
|
158
188
|
├─ static component → HTML
|
|
159
189
|
├─ ordered state setter → behavior command
|
|
160
190
|
├─ conditional child → bounded DOM range
|
|
191
|
+
├─ keyed state map → keyed DOM moves
|
|
161
192
|
└─ normal JS handler → external ESM
|
|
162
193
|
```
|
|
163
194
|
|
|
@@ -185,12 +216,14 @@ Supported:
|
|
|
185
216
|
- Synchronous and async event handlers
|
|
186
217
|
- Serializable component-local captures
|
|
187
218
|
- Direct text DOM patches
|
|
188
|
-
- Reactive
|
|
219
|
+
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
220
|
+
- Controlled `value` and `checked` form properties
|
|
189
221
|
- Conditional child `&&` and ternary DOM patches
|
|
222
|
+
- Direct keyed local-state lists
|
|
190
223
|
|
|
191
224
|
Not implemented yet:
|
|
192
225
|
|
|
193
|
-
-
|
|
226
|
+
- Generalized JSX-valued locals and non-direct list item expressions
|
|
194
227
|
- Server actions and request-time SSR
|
|
195
228
|
- Imported client helpers and React package islands
|
|
196
229
|
- HMR and framework DevTools
|
|
@@ -205,13 +238,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
205
238
|
|
|
206
239
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
207
240
|
|---|---:|---:|---:|---:|
|
|
208
|
-
| Kudzu | Yes | 487 B | 1.4 KB | **
|
|
209
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
210
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
211
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
212
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
213
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
214
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
241
|
+
| Kudzu | Yes | 487 B | 1.4 KB | **373 ms** |
|
|
242
|
+
| Astro | Yes | **158 B** | **365 B** | 877 ms |
|
|
243
|
+
| Svelte CSR | No | 10.5 KB | 26.9 KB | 837 ms |
|
|
244
|
+
| Qwik CSR | No | 20.6 KB | 57.8 KB | 626 ms |
|
|
245
|
+
| Vue CSR | No | 24.0 KB | 60.3 KB | 778 ms |
|
|
246
|
+
| React CSR | No | 59.2 KB | 189.0 KB | 1049 ms |
|
|
247
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 3099 ms |
|
|
215
248
|
|
|
216
249
|
Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
|
|
217
250
|
|
package/framework/README.md
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
- `core.mjs`: server-side JSX rendering, state slots, behavior metadata, and serializable capture validation.
|
|
5
5
|
- `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
|
-
- `shared-runtime.js`: command runtime with
|
|
8
|
-
- `binding-runtime.js`: optional
|
|
7
|
+
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
|
+
- `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
|
|
9
|
+
- `list-runtime.js`: optional keyed list validation, updates, moves, and cleanup.
|
|
9
10
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
10
11
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
11
12
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
12
13
|
|
|
13
|
-
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes add `binding-runtime.js`; native handlers add `native-runtime.js`. Generated evaluators live under `dist/assets/handlers/`.
|
|
14
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers/`.
|
|
14
15
|
|
|
15
16
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserState,
|
|
1
|
+
import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
3
|
|
|
4
4
|
const imports = new Map()
|
|
@@ -8,17 +8,25 @@ const mountedBindings = new WeakSet()
|
|
|
8
8
|
const mountedConditions = new WeakSet()
|
|
9
9
|
const bindingRegistrations = new WeakMap()
|
|
10
10
|
const conditionRegistrations = new WeakMap()
|
|
11
|
+
const bindingTypes = ["class", "disabled", "value", "checked"]
|
|
12
|
+
const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
|
|
11
13
|
|
|
12
14
|
export function patchBinding(node, target, value) {
|
|
13
15
|
if (target === "disabled") {
|
|
14
16
|
node.toggleAttribute("disabled", Boolean(value))
|
|
17
|
+
} else if (target === "checked") {
|
|
18
|
+
node.checked = Boolean(value)
|
|
15
19
|
} else if (target === "value") {
|
|
16
20
|
const next = value == null ? "" : String(value)
|
|
17
21
|
if (node.value !== next) node.value = next
|
|
18
|
-
} else if (value == null || value === false) {
|
|
22
|
+
} else if (target === "class" && (value == null || value === false)) {
|
|
19
23
|
node.removeAttribute("class")
|
|
20
|
-
} else {
|
|
24
|
+
} else if (target === "class") {
|
|
21
25
|
node.setAttribute("class", String(value))
|
|
26
|
+
} else if (value == null || (value === false && !isStringBooleanAttribute(target))) {
|
|
27
|
+
node.removeAttribute(target)
|
|
28
|
+
} else {
|
|
29
|
+
node.setAttribute(target, value === true && !isStringBooleanAttribute(target) ? "" : String(value))
|
|
22
30
|
}
|
|
23
31
|
}
|
|
24
32
|
|
|
@@ -42,37 +50,38 @@ function commitConditions(id) {
|
|
|
42
50
|
|
|
43
51
|
registerCommitter(commitBindings)
|
|
44
52
|
registerCommitter(commitConditions)
|
|
53
|
+
registerMountHook(mountBindings)
|
|
54
|
+
registerMountHook(mountConditions)
|
|
55
|
+
registerUnmountHook(unmountBindings)
|
|
56
|
+
registerUnmountHook(unmountConditions)
|
|
45
57
|
|
|
46
58
|
if (typeof document !== "undefined") mountDom(document)
|
|
47
59
|
|
|
48
|
-
function mountDom(root) {
|
|
49
|
-
mountText(root)
|
|
50
|
-
mountBindings(root)
|
|
51
|
-
mountConditions(root)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
60
|
function mountBindings(root) {
|
|
55
|
-
for (const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
for (const node of matching(root, bindingSelector)) {
|
|
62
|
+
if (mountedBindings.has(node)) continue
|
|
63
|
+
mountedBindings.add(node)
|
|
64
|
+
const registrations = []
|
|
65
|
+
bindingRegistrations.set(node, registrations)
|
|
66
|
+
const descriptors = bindingTypes.flatMap(target => node.hasAttribute(`data-k-bind-${target}`)
|
|
67
|
+
? [[target, JSON.parse(node.dataset[`kBind${capitalize(target)}`])]]
|
|
68
|
+
: [])
|
|
69
|
+
if (node.dataset.kBindAttrs) descriptors.push(...JSON.parse(node.dataset.kBindAttrs).map(({ target, ...descriptor }) => [target, descriptor]))
|
|
70
|
+
for (const [target, descriptor] of descriptors) {
|
|
60
71
|
if (descriptor.state) {
|
|
61
72
|
const binding = { node, target, read: () => browserState.get(descriptor.state) }
|
|
62
73
|
register(bindingTargets, descriptor.state, binding)
|
|
63
|
-
|
|
74
|
+
registrations.push([descriptor.state, binding])
|
|
64
75
|
patchBinding(node, target, binding.read())
|
|
65
76
|
continue
|
|
66
77
|
}
|
|
67
78
|
loadEvaluator(descriptor).then(evaluator => {
|
|
68
79
|
if (!node.isConnected) return
|
|
69
80
|
const binding = { node, target, read: evaluator.read }
|
|
70
|
-
const registrations = []
|
|
71
81
|
for (const id of evaluator.stateIds) {
|
|
72
82
|
register(bindingTargets, id, binding)
|
|
73
83
|
registrations.push([id, binding])
|
|
74
84
|
}
|
|
75
|
-
bindingRegistrations.set(node, registrations)
|
|
76
85
|
patchBinding(node, target, binding.read())
|
|
77
86
|
}).catch(error => console.error(error))
|
|
78
87
|
}
|
|
@@ -117,14 +126,21 @@ function updateCondition(condition) {
|
|
|
117
126
|
condition.end.parentNode.insertBefore(fragment, condition.end)
|
|
118
127
|
condition.current = next
|
|
119
128
|
for (const node of nodes) mountDom(node)
|
|
129
|
+
const select = condition.start.closest("select[data-k-bind-value]")
|
|
130
|
+
for (const binding of new Set((bindingRegistrations.get(select) ?? []).map(([, entry]) => entry))) {
|
|
131
|
+
patchBinding(binding.node, binding.target, binding.read())
|
|
132
|
+
}
|
|
120
133
|
}
|
|
121
134
|
|
|
122
|
-
function
|
|
123
|
-
for (const node of matching(root,
|
|
135
|
+
function unmountBindings(root) {
|
|
136
|
+
for (const node of matching(root, bindingSelector)) {
|
|
124
137
|
for (const [id, binding] of bindingRegistrations.get(node) ?? []) bindingTargets.get(id)?.delete(binding)
|
|
125
138
|
bindingRegistrations.delete(node)
|
|
126
139
|
mountedBindings.delete(node)
|
|
127
140
|
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unmountConditions(root) {
|
|
128
144
|
for (const start of matching(root, "template[data-k-if]")) {
|
|
129
145
|
const registration = conditionRegistrations.get(start)
|
|
130
146
|
for (const [id, condition] of registration?.registrations ?? []) conditionTargets.get(id)?.delete(condition)
|
|
@@ -138,9 +154,8 @@ function removeConditionRange(start, end) {
|
|
|
138
154
|
range.setStartAfter(start)
|
|
139
155
|
range.setEndBefore(end)
|
|
140
156
|
const root = range.commonAncestorContainer
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
157
|
+
const nodes = matching(root, "*").filter(node => range.comparePoint(node, 0) === 0)
|
|
158
|
+
for (const node of nodes) if (!nodes.some(parent => parent !== node && parent.contains(node))) unmountDom(node)
|
|
144
159
|
range.deleteContents()
|
|
145
160
|
}
|
|
146
161
|
|
|
@@ -207,3 +222,7 @@ function matching(root, selector) {
|
|
|
207
222
|
function capitalize(value) {
|
|
208
223
|
return value[0].toUpperCase() + value.slice(1)
|
|
209
224
|
}
|
|
225
|
+
|
|
226
|
+
function isStringBooleanAttribute(name) {
|
|
227
|
+
return name.startsWith("aria-") || name.startsWith("data-")
|
|
228
|
+
}
|
package/framework/build.mjs
CHANGED
|
@@ -31,6 +31,7 @@ export async function build({ quiet = false } = {}) {
|
|
|
31
31
|
|
|
32
32
|
let behaviorCount = 0
|
|
33
33
|
let bindingCount = 0
|
|
34
|
+
let listCount = 0
|
|
34
35
|
let stateSeedCount = 0
|
|
35
36
|
const plans = []
|
|
36
37
|
const hasStyles = await exists(join(sourceDirectory, "style.css"))
|
|
@@ -51,6 +52,7 @@ export async function build({ quiet = false } = {}) {
|
|
|
51
52
|
plans.push({ route: `/${route}`, ...result.plan })
|
|
52
53
|
if (result.hasBehaviors) behaviorCount++
|
|
53
54
|
if (result.hasBindings) bindingCount++
|
|
55
|
+
if (result.hasLists) listCount++
|
|
54
56
|
if (result.hasStateSeed) stateSeedCount++
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -59,7 +61,7 @@ export async function build({ quiet = false } = {}) {
|
|
|
59
61
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
60
62
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
61
63
|
if (behaviorCount) {
|
|
62
|
-
const runtimeFile = bindingCount ? "./shared-runtime.js" : "./runtime.js"
|
|
64
|
+
const runtimeFile = bindingCount || listCount ? "./shared-runtime.js" : "./runtime.js"
|
|
63
65
|
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
64
66
|
await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
|
|
65
67
|
}
|
|
@@ -71,6 +73,11 @@ export async function build({ quiet = false } = {}) {
|
|
|
71
73
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
72
74
|
await writeFile(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime)
|
|
73
75
|
}
|
|
76
|
+
if (listCount) {
|
|
77
|
+
const listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
|
|
78
|
+
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
79
|
+
await writeFile(join(assetsDirectory, "kudzu-list.js"), listRuntime)
|
|
80
|
+
}
|
|
74
81
|
if (hasNativeHandlers) {
|
|
75
82
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
76
83
|
.replace('"./runtime.js"', '"./kudzu.js"')
|
|
@@ -184,9 +191,11 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
184
191
|
const factory = context.factory
|
|
185
192
|
const settersByFunction = new Map()
|
|
186
193
|
const functions = new Map()
|
|
194
|
+
const listFieldExpressions = new WeakSet()
|
|
187
195
|
let usesBehavior = false
|
|
188
196
|
let usesBinding = false
|
|
189
197
|
let usesConditional = false
|
|
198
|
+
let usesList = false
|
|
190
199
|
|
|
191
200
|
const collect = node => {
|
|
192
201
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
@@ -229,6 +238,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
229
238
|
}
|
|
230
239
|
|
|
231
240
|
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
241
|
+
const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
242
|
+
if (listParts) {
|
|
243
|
+
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
244
|
+
validateKeyedList(listParts, sourceFile, listFieldExpressions)
|
|
245
|
+
usesBehavior = true
|
|
246
|
+
usesBinding = true
|
|
247
|
+
usesList = true
|
|
248
|
+
return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
|
|
249
|
+
listParts.state,
|
|
250
|
+
factory.createStringLiteral(listParts.keyField),
|
|
251
|
+
ts.visitNode(listParts.callback, visitor)
|
|
252
|
+
]))
|
|
253
|
+
}
|
|
232
254
|
const parts = conditionalParts(node.expression)
|
|
233
255
|
if (parts) {
|
|
234
256
|
const setters = settersForNode(node, settersByFunction)
|
|
@@ -245,8 +267,9 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
245
267
|
}
|
|
246
268
|
}
|
|
247
269
|
|
|
248
|
-
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["
|
|
270
|
+
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["style", "key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
|
|
249
271
|
const expression = node.initializer.expression
|
|
272
|
+
if (listFieldExpressions.has(expression)) return node
|
|
250
273
|
const setters = settersForNode(node, settersByFunction)
|
|
251
274
|
const usedStates = referencedStateNames(expression, setters)
|
|
252
275
|
const captures = captureNames(expression, expression, setters)
|
|
@@ -279,6 +302,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
279
302
|
if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
|
|
280
303
|
if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
|
|
281
304
|
if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
|
|
305
|
+
if (usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
|
|
282
306
|
if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
283
307
|
const behaviorImport = factory.createImportDeclaration(
|
|
284
308
|
undefined,
|
|
@@ -289,6 +313,83 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
289
313
|
}
|
|
290
314
|
}
|
|
291
315
|
|
|
316
|
+
function keyedListParts(expression, setters) {
|
|
317
|
+
const value = unwrapExpression(expression)
|
|
318
|
+
if (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map" || !ts.isIdentifier(value.expression.expression)) return undefined
|
|
319
|
+
const state = value.expression.expression
|
|
320
|
+
if (![...setters.values()].includes(state.text)) return undefined
|
|
321
|
+
const callback = value.arguments[0]
|
|
322
|
+
if (!ts.isArrowFunction(callback) || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name)) {
|
|
323
|
+
throw new Error("Keyed list map callback must be an arrow function with one identifier parameter")
|
|
324
|
+
}
|
|
325
|
+
const root = unwrapExpression(callback.body)
|
|
326
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) throw new Error("Keyed list map callback must return one JSX element")
|
|
327
|
+
const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
|
|
328
|
+
const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText() === "key")
|
|
329
|
+
const field = key && ts.isJsxAttribute(key) && key.initializer && ts.isJsxExpression(key.initializer) && key.initializer.expression && directProperty(key.initializer.expression, callback.parameters[0].name.text)
|
|
330
|
+
if (!field) throw new Error(`Keyed list root must have key={${callback.parameters[0].name.text}.<field>}`)
|
|
331
|
+
return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validateKeyedList(parts, sourceFile, listFieldExpressions) {
|
|
335
|
+
const fail = (node, message) => {
|
|
336
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
337
|
+
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
338
|
+
}
|
|
339
|
+
const validateElement = node => {
|
|
340
|
+
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
|
|
341
|
+
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
|
|
342
|
+
}
|
|
343
|
+
const visit = node => {
|
|
344
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
|
|
345
|
+
if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map") fail(node, "Nested keyed lists are not supported")
|
|
346
|
+
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
|
|
347
|
+
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) fail(node, "Item-local handlers are not supported in keyed lists")
|
|
348
|
+
if (ts.isJsxExpression(node) && node.expression) {
|
|
349
|
+
const expression = unwrapExpression(node.expression)
|
|
350
|
+
const field = directProperty(expression, parts.item)
|
|
351
|
+
const isRootKey = node.parent?.parent === parts.root && ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
|
|
352
|
+
if (field && ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
353
|
+
if (isRootKey || field) {
|
|
354
|
+
if (field) listFieldExpressions.add(node.expression)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (conditionalParts(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
|
|
358
|
+
if (referencesIdentifier(expression, parts.item)) fail(node, `Keyed list item expressions must be direct ${parts.item}.<field> reads`)
|
|
359
|
+
}
|
|
360
|
+
ts.forEachChild(node, visit)
|
|
361
|
+
}
|
|
362
|
+
visit(parts.root)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function directProperty(expression, objectName) {
|
|
366
|
+
const value = unwrapExpression(expression)
|
|
367
|
+
if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) return undefined
|
|
368
|
+
if (objectName !== undefined && value.expression.text !== objectName) return undefined
|
|
369
|
+
return value.name.text
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function keyedListParentTag(node) {
|
|
373
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
374
|
+
if (ts.isJsxElement(current)) return current.openingElement.tagName.getText().toLowerCase()
|
|
375
|
+
}
|
|
376
|
+
return undefined
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function referencesIdentifier(root, name) {
|
|
380
|
+
let found = false
|
|
381
|
+
const visit = node => {
|
|
382
|
+
if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node)) found = true
|
|
383
|
+
if (!found) ts.forEachChild(node, visit)
|
|
384
|
+
}
|
|
385
|
+
visit(root)
|
|
386
|
+
return found
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function unwrapExpression(node) {
|
|
390
|
+
return ts.isParenthesizedExpression(node) ? unwrapExpression(node.expression) : node
|
|
391
|
+
}
|
|
392
|
+
|
|
292
393
|
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
293
394
|
return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
|
|
294
395
|
}
|
package/framework/core.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export function nativeBehavior(module: string, handler: string, states: Array<[s
|
|
|
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
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
|
|
10
11
|
|
|
11
12
|
export function renderPage(
|
|
12
13
|
component: (props: Record<string, never>) => unknown | Promise<unknown>,
|
|
@@ -32,6 +33,7 @@ export function renderPage(
|
|
|
32
33
|
html: string
|
|
33
34
|
hasBehaviors: boolean
|
|
34
35
|
hasBindings: boolean
|
|
36
|
+
hasLists: boolean
|
|
35
37
|
hasStateSeed: boolean
|
|
36
38
|
plan: {
|
|
37
39
|
states: Array<{ id: string; name: string; initialValue: unknown }>
|
|
@@ -41,7 +43,7 @@ export function renderPage(
|
|
|
41
43
|
native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
|
|
42
44
|
}>
|
|
43
45
|
bindings: Array<{
|
|
44
|
-
target:
|
|
46
|
+
target: string
|
|
45
47
|
state?: string
|
|
46
48
|
module?: string
|
|
47
49
|
handler?: string
|
|
@@ -51,5 +53,6 @@ export function renderPage(
|
|
|
51
53
|
scopeBindings?: Record<string, unknown>
|
|
52
54
|
}>
|
|
53
55
|
conditions: Array<Record<string, unknown>>
|
|
56
|
+
lists: Array<Record<string, unknown>>
|
|
54
57
|
}
|
|
55
58
|
}>
|
package/framework/core.mjs
CHANGED
|
@@ -3,6 +3,9 @@ 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 noSelectValue = Symbol("kudzu.no-select-value")
|
|
6
9
|
|
|
7
10
|
let renderContext
|
|
8
11
|
|
|
@@ -61,6 +64,50 @@ export function conditional(kind, value, truthy, falsy, module, handler, states,
|
|
|
61
64
|
return { [conditionalMarker]: true, kind, value, truthy, falsy, ...reactiveDescriptor(module, handler, states, scope) }
|
|
62
65
|
}
|
|
63
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
|
+
|
|
64
111
|
function reactiveDescriptor(module, handler, states, scope) {
|
|
65
112
|
const scopeStates = {}
|
|
66
113
|
const serializedScope = {}
|
|
@@ -123,7 +170,7 @@ function serializeCapture(name, value, seen) {
|
|
|
123
170
|
}
|
|
124
171
|
|
|
125
172
|
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 }
|
|
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 }
|
|
127
174
|
|
|
128
175
|
try {
|
|
129
176
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -141,6 +188,9 @@ export async function renderPage(component, metadata = {}) {
|
|
|
141
188
|
const bindingRuntime = renderContext.hasBindings
|
|
142
189
|
? '<script type="module" src="/assets/kudzu-binding.js"></script>'
|
|
143
190
|
: ""
|
|
191
|
+
const listRuntime = renderContext.hasLists
|
|
192
|
+
? '<script type="module" src="/assets/kudzu-list.js"></script>'
|
|
193
|
+
: ""
|
|
144
194
|
const initialState = renderContext.hasBehaviors
|
|
145
195
|
? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
|
|
146
196
|
: []
|
|
@@ -149,15 +199,17 @@ export async function renderPage(component, metadata = {}) {
|
|
|
149
199
|
: ""
|
|
150
200
|
|
|
151
201
|
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>`,
|
|
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>`,
|
|
153
203
|
hasBehaviors: renderContext.hasBehaviors,
|
|
154
204
|
hasBindings: renderContext.hasBindings,
|
|
205
|
+
hasLists: renderContext.hasLists,
|
|
155
206
|
hasStateSeed: initialState.length > 0,
|
|
156
207
|
plan: {
|
|
157
208
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
158
209
|
events: renderContext.events,
|
|
159
210
|
bindings: renderContext.bindings,
|
|
160
|
-
conditions: renderContext.conditions
|
|
211
|
+
conditions: renderContext.conditions,
|
|
212
|
+
lists: renderContext.lists
|
|
161
213
|
}
|
|
162
214
|
}
|
|
163
215
|
} finally {
|
|
@@ -198,32 +250,32 @@ function renderMetadata(metadata) {
|
|
|
198
250
|
return tags.join("")
|
|
199
251
|
}
|
|
200
252
|
|
|
201
|
-
async function renderNode(node, namespace) {
|
|
253
|
+
async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
202
254
|
if (node == null || node === false || node === true) return ""
|
|
203
255
|
if (Array.isArray(node)) {
|
|
204
256
|
let html = ""
|
|
205
|
-
for (const child of node) html += await renderNode(child, namespace)
|
|
257
|
+
for (const child of node) html += await renderNode(child, namespace, selectValue)
|
|
206
258
|
return html
|
|
207
259
|
}
|
|
208
260
|
if (node?.[signalMarker]) {
|
|
209
261
|
renderContext.textStates.add(node.id)
|
|
210
|
-
if (renderContext.conditionDepth) renderContext.conditionStates.add(node.id)
|
|
262
|
+
if (renderContext.conditionDepth || renderContext.listDepth) renderContext.conditionStates.add(node.id)
|
|
211
263
|
return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value)}</span>`
|
|
212
264
|
}
|
|
213
265
|
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
|
|
214
266
|
return escapeHtml(node)
|
|
215
267
|
}
|
|
216
|
-
if (node instanceof Promise) return renderNode(await node, namespace)
|
|
268
|
+
if (node instanceof Promise) return renderNode(await node, namespace, selectValue)
|
|
217
269
|
if (node?.[conditionalMarker]) {
|
|
218
270
|
const descriptor = bindingDescriptor(node)
|
|
219
271
|
const stateIds = reactiveStateIds(descriptor)
|
|
220
|
-
if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace)
|
|
272
|
+
if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
|
|
221
273
|
if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
|
|
222
274
|
|
|
223
275
|
const id = `c${renderContext.nextCondition++}`
|
|
224
276
|
renderContext.conditionDepth++
|
|
225
|
-
const truthy = await renderNode(node.truthy())
|
|
226
|
-
const falsy = await renderNode(node.falsy())
|
|
277
|
+
const truthy = await renderNode(node.truthy(), namespace, selectValue)
|
|
278
|
+
const falsy = await renderNode(node.falsy(), namespace, selectValue)
|
|
227
279
|
renderContext.conditionDepth--
|
|
228
280
|
const metadata = { id, kind: node.kind, initial: node.value, ...descriptor }
|
|
229
281
|
for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
|
|
@@ -231,25 +283,48 @@ async function renderNode(node, namespace) {
|
|
|
231
283
|
renderContext.hasBehaviors = true
|
|
232
284
|
renderContext.hasBindings = true
|
|
233
285
|
const encoded = escapeJsonAttribute(metadata)
|
|
234
|
-
const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value) : falsy
|
|
286
|
+
const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy
|
|
235
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>`
|
|
236
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
|
+
}
|
|
237
293
|
if (!node || typeof node !== "object" || !("type" in node)) {
|
|
238
294
|
throw new Error(`Cannot render ${String(node)}`)
|
|
239
295
|
}
|
|
240
296
|
|
|
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)
|
|
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)
|
|
243
299
|
|
|
244
300
|
const tag = node.type
|
|
245
301
|
const props = node.props ?? {}
|
|
302
|
+
const childSelectValue = tag === "select"
|
|
303
|
+
? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
|
|
304
|
+
: selectValue
|
|
246
305
|
const childNamespace = tag === "svg" || tag === "math"
|
|
247
306
|
? tag
|
|
248
307
|
: namespace === "svg" && tag === "foreignObject" ? undefined : namespace
|
|
249
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
|
+
}
|
|
250
319
|
|
|
251
320
|
for (const [rawName, value] of Object.entries(props)) {
|
|
252
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
|
+
}
|
|
253
328
|
|
|
254
329
|
if (/^on[A-Z]/.test(rawName)) {
|
|
255
330
|
const event = rawName.slice(2).toLowerCase()
|
|
@@ -270,40 +345,80 @@ async function renderNode(node, namespace) {
|
|
|
270
345
|
}
|
|
271
346
|
|
|
272
347
|
const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
|
|
273
|
-
const
|
|
274
|
-
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]) {
|
|
275
355
|
const initialValue = value[signalMarker] ? value.value : value.value
|
|
276
356
|
const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
|
|
277
357
|
if (!reactive) {
|
|
278
|
-
attributes += renderAttribute(name, initialValue)
|
|
358
|
+
if (tag !== "select" || name !== "value") attributes += renderAttribute(name, initialValue)
|
|
279
359
|
continue
|
|
280
360
|
}
|
|
281
361
|
const descriptor = value[signalMarker]
|
|
282
362
|
? { state: value.id }
|
|
283
363
|
: bindingDescriptor(value)
|
|
284
|
-
attributes += renderAttribute(name, initialValue)
|
|
285
|
-
attributes += ` data-k-bind-${
|
|
286
|
-
|
|
287
|
-
|
|
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)
|
|
288
369
|
renderContext.hasBehaviors = true
|
|
289
370
|
renderContext.hasBindings = true
|
|
290
371
|
continue
|
|
291
372
|
}
|
|
292
373
|
|
|
293
|
-
if (
|
|
294
|
-
if (value ===
|
|
295
|
-
attributes += ` ${name}`
|
|
296
|
-
} else if (name === "style" && typeof value === "object") {
|
|
374
|
+
if (tag === "select" && name === "value") continue
|
|
375
|
+
if (name === "style" && value && typeof value === "object") {
|
|
297
376
|
const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
|
|
298
377
|
attributes += ` style="${escapeAttribute(style)}"`
|
|
299
378
|
} else {
|
|
300
|
-
attributes +=
|
|
379
|
+
attributes += renderAttribute(name, value)
|
|
301
380
|
}
|
|
302
381
|
}
|
|
303
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
|
+
|
|
304
388
|
const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
|
|
305
389
|
if (voidElements.has(tag)) return `<${tag}${attributes}>`
|
|
306
|
-
return `<${tag}${attributes}>${await renderNode(props.children, childNamespace)}</${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 ?? ""
|
|
307
422
|
}
|
|
308
423
|
|
|
309
424
|
function reactiveStateIds(descriptor) {
|
|
@@ -316,12 +431,17 @@ function reactiveStateIds(descriptor) {
|
|
|
316
431
|
}
|
|
317
432
|
|
|
318
433
|
function renderAttribute(name, value) {
|
|
319
|
-
if (name === "disabled") return value ?
|
|
434
|
+
if (name === "disabled" || name === "checked") return value ? ` ${name}` : ""
|
|
320
435
|
if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
|
|
321
|
-
if (value == null || value === false) return ""
|
|
436
|
+
if (value == null || (value === false && !isStringBooleanAttribute(name))) return ""
|
|
437
|
+
if (value === true && !isStringBooleanAttribute(name)) return ` ${name}`
|
|
322
438
|
return ` ${name}="${escapeAttribute(value)}"`
|
|
323
439
|
}
|
|
324
440
|
|
|
441
|
+
function isStringBooleanAttribute(name) {
|
|
442
|
+
return name.startsWith("aria-") || name.startsWith("data-")
|
|
443
|
+
}
|
|
444
|
+
|
|
325
445
|
function escapeHtml(value) {
|
|
326
446
|
return String(value)
|
|
327
447
|
.replaceAll("&", "&")
|
|
@@ -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
|
+
}
|
|
@@ -16,11 +16,21 @@ export function applyCommands(state, commands, commit, log = console.log) {
|
|
|
16
16
|
|
|
17
17
|
export const browserState = new Map()
|
|
18
18
|
const committers = []
|
|
19
|
+
const mountHooks = []
|
|
20
|
+
const unmountHooks = []
|
|
19
21
|
|
|
20
22
|
export function registerCommitter(commit) {
|
|
21
23
|
committers.push(commit)
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
export function registerMountHook(mount) {
|
|
27
|
+
mountHooks.push(mount)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerUnmountHook(unmount) {
|
|
31
|
+
unmountHooks.push(unmount)
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
export function commitDom(id, value) {
|
|
25
35
|
for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
|
|
26
36
|
for (const commit of committers) commit(id)
|
|
@@ -34,6 +44,15 @@ export function mountText(root) {
|
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
export function mountDom(root) {
|
|
48
|
+
mountText(root)
|
|
49
|
+
for (const mount of mountHooks) mount(root)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function unmountDom(root) {
|
|
53
|
+
for (const unmount of unmountHooks) unmount(root)
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
if (typeof document !== "undefined") {
|
|
38
57
|
const initialState = document.body.dataset.kState
|
|
39
58
|
if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
|