@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/README.md
CHANGED
|
@@ -6,11 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
HTML-first TSX framework with synchronous state semantics and no virtual DOM.
|
|
8
8
|
|
|
9
|
-
Brand assets, favicons, manifest icons, and the 1200×630 social preview live in [`public/`](./public).
|
|
10
|
-
|
|
11
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.
|
|
12
10
|
|
|
13
|
-
> Experimental `0.
|
|
11
|
+
> Experimental `0.4.x`: the compiler API and supported TSX surface may change.
|
|
14
12
|
|
|
15
13
|
Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
|
|
16
14
|
|
|
@@ -56,6 +54,8 @@ Configure TypeScript:
|
|
|
56
54
|
}
|
|
57
55
|
```
|
|
58
56
|
|
|
57
|
+
Make sure application TSX files are included by this `tsconfig.json`. Files outside its `include` may fall into an editor-inferred React project and incorrectly report a missing `react/jsx-runtime` or React event-type errors.
|
|
58
|
+
|
|
59
59
|
Create `src/pages/index.tsx`:
|
|
60
60
|
|
|
61
61
|
```tsx
|
|
@@ -100,15 +100,66 @@ 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)} />
|
|
111
|
+
```
|
|
112
|
+
|
|
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.
|
|
125
|
+
|
|
126
|
+
## Conditional DOM
|
|
127
|
+
|
|
128
|
+
Inline child `&&` and ternary expressions insert and remove bounded DOM ranges directly. A menu bar needs only state setters:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
function MenuBar() {
|
|
132
|
+
return <nav><a href="/docs">Docs</a></nav>
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const [open, setOpen] = useState(false)
|
|
136
|
+
|
|
137
|
+
{open
|
|
138
|
+
? <button onClick={() => setOpen(false)}>Close menu</button>
|
|
139
|
+
: <button onClick={() => setOpen(true)}>Open menu</button>}
|
|
140
|
+
{open && <MenuBar />}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Logical state persists across branch switches, while uncontrolled DOM state resets on remount. Both branches are materialized in inert templates at build time, so conditional rendering is not an authorization boundary and dormant branches must not contain secrets.
|
|
144
|
+
|
|
145
|
+
Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
|
|
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>
|
|
109
158
|
```
|
|
110
159
|
|
|
111
|
-
Kudzu
|
|
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>`.
|
|
112
163
|
|
|
113
164
|
## Normal JavaScript
|
|
114
165
|
|
|
@@ -136,7 +187,9 @@ Primitive values, arrays, plain objects, and destructured props can be captured
|
|
|
136
187
|
TSX
|
|
137
188
|
├─ static component → HTML
|
|
138
189
|
├─ ordered state setter → behavior command
|
|
139
|
-
|
|
190
|
+
├─ conditional child → bounded DOM range
|
|
191
|
+
├─ keyed state map → keyed DOM moves
|
|
192
|
+
└─ normal JS handler → external ESM
|
|
140
193
|
```
|
|
141
194
|
|
|
142
195
|
- Static pages ship no client JavaScript.
|
|
@@ -163,11 +216,14 @@ Supported:
|
|
|
163
216
|
- Synchronous and async event handlers
|
|
164
217
|
- Serializable component-local captures
|
|
165
218
|
- Direct text DOM patches
|
|
166
|
-
- Reactive
|
|
219
|
+
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
220
|
+
- Controlled `value` and `checked` form properties
|
|
221
|
+
- Conditional child `&&` and ternary DOM patches
|
|
222
|
+
- Direct keyed local-state lists
|
|
167
223
|
|
|
168
224
|
Not implemented yet:
|
|
169
225
|
|
|
170
|
-
-
|
|
226
|
+
- Generalized JSX-valued locals and non-direct list item expressions
|
|
171
227
|
- Server actions and request-time SSR
|
|
172
228
|
- Imported client helpers and React package islands
|
|
173
229
|
- HMR and framework DevTools
|
|
@@ -182,13 +238,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
182
238
|
|
|
183
239
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
184
240
|
|---|---:|---:|---:|---:|
|
|
185
|
-
| Kudzu | Yes |
|
|
186
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
187
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
188
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
189
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
190
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
191
|
-
| 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 |
|
|
192
248
|
|
|
193
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.
|
|
194
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,70 +1,205 @@
|
|
|
1
|
-
import { browserState, registerCommitter } from "./shared-runtime.js"
|
|
1
|
+
import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
3
|
|
|
4
|
+
const imports = new Map()
|
|
4
5
|
const bindingTargets = new Map()
|
|
6
|
+
const conditionTargets = new Map()
|
|
7
|
+
const mountedBindings = new WeakSet()
|
|
8
|
+
const mountedConditions = new WeakSet()
|
|
9
|
+
const bindingRegistrations = new WeakMap()
|
|
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(",")
|
|
5
13
|
|
|
6
14
|
export function patchBinding(node, target, value) {
|
|
7
15
|
if (target === "disabled") {
|
|
8
16
|
node.toggleAttribute("disabled", Boolean(value))
|
|
17
|
+
} else if (target === "checked") {
|
|
18
|
+
node.checked = Boolean(value)
|
|
9
19
|
} else if (target === "value") {
|
|
10
20
|
const next = value == null ? "" : String(value)
|
|
11
21
|
if (node.value !== next) node.value = next
|
|
12
|
-
} else if (value == null || value === false) {
|
|
22
|
+
} else if (target === "class" && (value == null || value === false)) {
|
|
13
23
|
node.removeAttribute("class")
|
|
14
|
-
} else {
|
|
24
|
+
} else if (target === "class") {
|
|
15
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))
|
|
16
30
|
}
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
function commitBindings(id) {
|
|
20
|
-
|
|
34
|
+
const bindings = bindingTargets.get(id)
|
|
35
|
+
if (!bindings) return
|
|
36
|
+
for (const binding of bindings) {
|
|
37
|
+
if (!binding.node.isConnected) bindings.delete(binding)
|
|
38
|
+
else patchBinding(binding.node, binding.target, binding.read())
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function commitConditions(id) {
|
|
43
|
+
const conditions = conditionTargets.get(id)
|
|
44
|
+
if (!conditions) return
|
|
45
|
+
for (const condition of conditions) {
|
|
46
|
+
if (!condition.start.isConnected) conditions.delete(condition)
|
|
47
|
+
else updateCondition(condition)
|
|
48
|
+
}
|
|
21
49
|
}
|
|
22
50
|
|
|
23
51
|
registerCommitter(commitBindings)
|
|
52
|
+
registerCommitter(commitConditions)
|
|
53
|
+
registerMountHook(mountBindings)
|
|
54
|
+
registerMountHook(mountConditions)
|
|
55
|
+
registerUnmountHook(unmountBindings)
|
|
56
|
+
registerUnmountHook(unmountConditions)
|
|
57
|
+
|
|
58
|
+
if (typeof document !== "undefined") mountDom(document)
|
|
24
59
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
60
|
+
function mountBindings(root) {
|
|
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) {
|
|
31
71
|
if (descriptor.state) {
|
|
32
|
-
|
|
72
|
+
const binding = { node, target, read: () => browserState.get(descriptor.state) }
|
|
73
|
+
register(bindingTargets, descriptor.state, binding)
|
|
74
|
+
registrations.push([descriptor.state, binding])
|
|
75
|
+
patchBinding(node, target, binding.read())
|
|
33
76
|
continue
|
|
34
77
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const binding = { node, target, read: () => module[descriptor.handler](context) }
|
|
43
|
-
for (const id of bindingStateIds(descriptor)) registerBinding(id, binding)
|
|
78
|
+
loadEvaluator(descriptor).then(evaluator => {
|
|
79
|
+
if (!node.isConnected) return
|
|
80
|
+
const binding = { node, target, read: evaluator.read }
|
|
81
|
+
for (const id of evaluator.stateIds) {
|
|
82
|
+
register(bindingTargets, id, binding)
|
|
83
|
+
registrations.push([id, binding])
|
|
84
|
+
}
|
|
44
85
|
patchBinding(node, target, binding.read())
|
|
45
|
-
}))
|
|
86
|
+
}).catch(error => console.error(error))
|
|
46
87
|
}
|
|
47
88
|
}
|
|
48
|
-
Promise.all(registrations).catch(error => console.error(error))
|
|
49
89
|
}
|
|
50
90
|
|
|
51
|
-
function
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
function mountConditions(root) {
|
|
92
|
+
for (const start of matching(root, "template[data-k-if]")) {
|
|
93
|
+
if (mountedConditions.has(start)) continue
|
|
94
|
+
mountedConditions.add(start)
|
|
95
|
+
const descriptor = JSON.parse(start.dataset.kIf)
|
|
96
|
+
const end = findEnd(start, descriptor.id)
|
|
97
|
+
const truthy = start.content.querySelector("template[data-k-true]")
|
|
98
|
+
const falsy = start.content.querySelector("template[data-k-false]")
|
|
99
|
+
if (!end || !truthy || !falsy) continue
|
|
100
|
+
const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial) }
|
|
101
|
+
loadEvaluator(descriptor).then(evaluator => {
|
|
102
|
+
if (!start.isConnected) return
|
|
103
|
+
condition.read = evaluator.read
|
|
104
|
+
const registrations = []
|
|
105
|
+
for (const id of evaluator.stateIds) {
|
|
106
|
+
register(conditionTargets, id, condition)
|
|
107
|
+
registrations.push([id, condition])
|
|
108
|
+
}
|
|
109
|
+
conditionRegistrations.set(start, { condition, registrations })
|
|
110
|
+
updateCondition(condition)
|
|
111
|
+
}).catch(error => console.error(error))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateCondition(condition) {
|
|
116
|
+
const value = condition.read()
|
|
117
|
+
const next = conditionKey(condition.kind, value)
|
|
118
|
+
if (next === condition.current) return
|
|
119
|
+
removeConditionRange(condition.start, condition.end)
|
|
120
|
+
const truthy = Boolean(value)
|
|
121
|
+
const falseText = condition.kind === "and" && !truthy ? renderFalsy(value) : ""
|
|
122
|
+
const fragment = falseText
|
|
123
|
+
? textFragment(condition.end.ownerDocument, falseText)
|
|
124
|
+
: (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
|
|
125
|
+
const nodes = [...fragment.childNodes]
|
|
126
|
+
condition.end.parentNode.insertBefore(fragment, condition.end)
|
|
127
|
+
condition.current = next
|
|
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
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unmountBindings(root) {
|
|
136
|
+
for (const node of matching(root, bindingSelector)) {
|
|
137
|
+
for (const [id, binding] of bindingRegistrations.get(node) ?? []) bindingTargets.get(id)?.delete(binding)
|
|
138
|
+
bindingRegistrations.delete(node)
|
|
139
|
+
mountedBindings.delete(node)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unmountConditions(root) {
|
|
144
|
+
for (const start of matching(root, "template[data-k-if]")) {
|
|
145
|
+
const registration = conditionRegistrations.get(start)
|
|
146
|
+
for (const [id, condition] of registration?.registrations ?? []) conditionTargets.get(id)?.delete(condition)
|
|
147
|
+
conditionRegistrations.delete(start)
|
|
148
|
+
mountedConditions.delete(start)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function removeConditionRange(start, end) {
|
|
153
|
+
const range = start.ownerDocument.createRange()
|
|
154
|
+
range.setStartAfter(start)
|
|
155
|
+
range.setEndBefore(end)
|
|
156
|
+
const root = range.commonAncestorContainer
|
|
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)
|
|
159
|
+
range.deleteContents()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function conditionKey(kind, value) {
|
|
163
|
+
return value ? "true" : kind === "and" ? `false:${renderFalsy(value)}` : "false"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderFalsy(value) {
|
|
167
|
+
return value === false || value == null || value === true ? "" : String(value)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function textFragment(document, value) {
|
|
171
|
+
const fragment = document.createDocumentFragment()
|
|
172
|
+
fragment.append(document.createTextNode(value))
|
|
173
|
+
return fragment
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function findEnd(start, id) {
|
|
177
|
+
return [...start.ownerDocument.querySelectorAll("template[data-k-if-end]")]
|
|
178
|
+
.find(node => node.dataset.kIfEnd === id)
|
|
55
179
|
}
|
|
56
180
|
|
|
57
|
-
|
|
181
|
+
function register(targets, id, entry) {
|
|
182
|
+
const entries = targets.get(id) ?? new Set()
|
|
183
|
+
entries.add(entry)
|
|
184
|
+
targets.set(id, entries)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadEvaluator(descriptor) {
|
|
188
|
+
let modulePromise = imports.get(descriptor.module)
|
|
189
|
+
if (!modulePromise) {
|
|
190
|
+
modulePromise = import(descriptor.module)
|
|
191
|
+
imports.set(descriptor.module, modulePromise)
|
|
192
|
+
}
|
|
193
|
+
const [module, context] = await Promise.all([modulePromise, createBindingContext(descriptor)])
|
|
194
|
+
return { read: () => module[descriptor.handler](context), stateIds: bindingStateIds(descriptor) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function createBindingContext(descriptor) {
|
|
58
198
|
const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value)]))
|
|
59
199
|
const nested = {}
|
|
60
200
|
await Promise.all(Object.entries(descriptor.scopeBindings).map(async ([name, binding]) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
modulePromise = import(binding.module)
|
|
64
|
-
imports.set(binding.module, modulePromise)
|
|
65
|
-
}
|
|
66
|
-
const [module, context] = await Promise.all([modulePromise, createBindingContext(binding, imports)])
|
|
67
|
-
nested[name] = () => module[binding.handler](context)
|
|
201
|
+
const evaluator = await loadEvaluator(binding)
|
|
202
|
+
nested[name] = evaluator.read
|
|
68
203
|
}))
|
|
69
204
|
return {
|
|
70
205
|
get: name => browserState.get(descriptor.states[name]),
|
|
@@ -80,6 +215,14 @@ function bindingStateIds(descriptor) {
|
|
|
80
215
|
])
|
|
81
216
|
}
|
|
82
217
|
|
|
218
|
+
function matching(root, selector) {
|
|
219
|
+
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
220
|
+
}
|
|
221
|
+
|
|
83
222
|
function capitalize(value) {
|
|
84
223
|
return value[0].toUpperCase() + value.slice(1)
|
|
85
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,7 +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
|
|
196
|
+
let usesBinding = false
|
|
197
|
+
let usesConditional = false
|
|
198
|
+
let usesList = false
|
|
188
199
|
|
|
189
200
|
const collect = node => {
|
|
190
201
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
@@ -226,13 +237,45 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
226
237
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
|
|
227
238
|
}
|
|
228
239
|
|
|
229
|
-
if (ts.
|
|
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
|
+
}
|
|
254
|
+
const parts = conditionalParts(node.expression)
|
|
255
|
+
if (parts) {
|
|
256
|
+
const setters = settersForNode(node, settersByFunction)
|
|
257
|
+
const usedStates = referencedStateNames(parts.condition, setters)
|
|
258
|
+
const captures = captureNames(parts.condition, parts.condition, setters)
|
|
259
|
+
if (usedStates.size || captures.size) {
|
|
260
|
+
usesBehavior = true
|
|
261
|
+
usesConditional = true
|
|
262
|
+
const truthy = ts.visitNode(parts.truthy, visitor)
|
|
263
|
+
const falsy = ts.visitNode(parts.falsy, visitor)
|
|
264
|
+
const compiled = compileConditional(parts.kind, parts.condition, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl)
|
|
265
|
+
return factory.updateJsxExpression(node, compiled)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
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())) {
|
|
230
271
|
const expression = node.initializer.expression
|
|
272
|
+
if (listFieldExpressions.has(expression)) return node
|
|
231
273
|
const setters = settersForNode(node, settersByFunction)
|
|
232
274
|
const usedStates = referencedStateNames(expression, setters)
|
|
233
275
|
const captures = captureNames(expression, expression, setters)
|
|
234
276
|
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
|
|
235
277
|
usesBehavior = true
|
|
278
|
+
usesBinding = true
|
|
236
279
|
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
|
|
237
280
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
|
|
238
281
|
}
|
|
@@ -257,8 +300,10 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
257
300
|
|
|
258
301
|
const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
|
|
259
302
|
if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
|
|
260
|
-
if (
|
|
261
|
-
if (
|
|
303
|
+
if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
|
|
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")))
|
|
306
|
+
if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
262
307
|
const behaviorImport = factory.createImportDeclaration(
|
|
263
308
|
undefined,
|
|
264
309
|
factory.createImportClause(false, undefined, factory.createNamedImports(behaviorImports)),
|
|
@@ -268,7 +313,94 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
268
313
|
}
|
|
269
314
|
}
|
|
270
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
|
+
|
|
271
393
|
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
394
|
+
return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
398
|
+
const [initial, ...descriptor] = compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl)
|
|
399
|
+
const thunk = branch => factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), branch)
|
|
400
|
+
return factory.createCallExpression(factory.createIdentifier("__kConditional"), undefined, [factory.createStringLiteral(kind), initial, thunk(truthy), thunk(falsy), ...descriptor])
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
272
404
|
const usedStates = referencedStateNames(expression, setters)
|
|
273
405
|
const captures = captureNames(expression, expression, setters)
|
|
274
406
|
const exportName = `binding${reactiveBindings.length}`
|
|
@@ -297,13 +429,29 @@ function compileReactiveBinding(expression, setters, factory, context, reactiveB
|
|
|
297
429
|
}
|
|
298
430
|
return ts.visitEachChild(node, rewriteInitial, context)
|
|
299
431
|
}
|
|
300
|
-
return
|
|
432
|
+
return [
|
|
301
433
|
ts.visitNode(expression, rewriteInitial),
|
|
302
434
|
factory.createStringLiteral(handlerUrl),
|
|
303
435
|
factory.createStringLiteral(exportName),
|
|
304
436
|
factory.createArrayLiteralExpression(states),
|
|
305
437
|
factory.createArrayLiteralExpression(scope)
|
|
306
|
-
]
|
|
438
|
+
]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function conditionalParts(expression) {
|
|
442
|
+
const unwrap = node => ts.isParenthesizedExpression(node) ? unwrap(node.expression) : node
|
|
443
|
+
const value = unwrap(expression)
|
|
444
|
+
if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
445
|
+
return { kind: "and", condition: value.left, truthy: unwrap(value.right), falsy: factoryNull() }
|
|
446
|
+
}
|
|
447
|
+
if (ts.isConditionalExpression(value)) {
|
|
448
|
+
return { kind: "ternary", condition: value.condition, truthy: unwrap(value.whenTrue), falsy: unwrap(value.whenFalse) }
|
|
449
|
+
}
|
|
450
|
+
return undefined
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function factoryNull() {
|
|
454
|
+
return ts.factory.createNull()
|
|
307
455
|
}
|
|
308
456
|
|
|
309
457
|
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
|