@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 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.2.x`: the compiler API and supported TSX surface may change.
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 `value` accept normal state-dependent TSX expressions:
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 compiles derived expressions to external ESM and patches only the bound DOM attribute or property.
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
- └─ normal JS handlerroute handler ESM
190
+ ├─ conditional childbounded 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 `className`, `disabled`, and controlled `value` patches
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
- - Conditional DOM patches and keyed lists
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 | 563 B | 1.7 KB | **384 ms** |
186
- | Astro | Yes | **158 B** | **365 B** | 911 ms |
187
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 910 ms |
188
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 627 ms |
189
- | Vue CSR | No | 24.0 KB | 60.3 KB | 814 ms |
190
- | React CSR | No | 59.2 KB | 189.0 KB | 1052 ms |
191
- | Next.js | Yes | 182.1 KB | 652.2 KB | 2985 ms |
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
 
@@ -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 binding commit hooks, emitted only when needed.
8
- - `binding-runtime.js`: optional reactive `className`, `disabled`, and `value` patches.
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
- for (const binding of bindingTargets.get(id) ?? []) patchBinding(binding.node, binding.target, binding.read())
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
- if (typeof document !== "undefined") {
26
- const imports = new Map()
27
- const registrations = []
28
- for (const target of ["class", "disabled", "value"]) {
29
- for (const node of document.querySelectorAll(`[data-k-bind-${target}]`)) {
30
- const descriptor = JSON.parse(node.dataset[`kBind${capitalize(target)}`])
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
- registerBinding(descriptor.state, { node, target, read: () => browserState.get(descriptor.state) })
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
- let modulePromise = imports.get(descriptor.module)
36
- if (!modulePromise) {
37
- modulePromise = import(descriptor.module)
38
- imports.set(descriptor.module, modulePromise)
39
- }
40
- registrations.push(modulePromise.then(async module => {
41
- const context = await createBindingContext(descriptor, imports)
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 registerBinding(id, binding) {
52
- const bindings = bindingTargets.get(id) ?? []
53
- bindings.push(binding)
54
- bindingTargets.set(id, bindings)
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
- async function createBindingContext(descriptor, imports) {
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
- let modulePromise = imports.get(binding.module)
62
- if (!modulePromise) {
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
+ }
@@ -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.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["className", "disabled", "value"].includes(node.name.getText())) {
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 (reactiveBindings.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
261
- if (reactiveBindings.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
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 factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, [
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) {