@kudzujs/core 0.2.0 → 0.3.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,11 @@
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.3.x`: the compiler API and supported TSX surface may change.
12
+
13
+ Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
14
14
 
15
15
  ## Install
16
16
 
@@ -54,6 +54,8 @@ Configure TypeScript:
54
54
  }
55
55
  ```
56
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
+
57
59
  Create `src/pages/index.tsx`:
58
60
 
59
61
  ```tsx
@@ -96,6 +98,39 @@ function increaseTwice() {
96
98
 
97
99
  The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
98
100
 
101
+ ## Reactive Attributes
102
+
103
+ `className`, `disabled`, and controlled `value` accept normal state-dependent TSX expressions:
104
+
105
+ ```tsx
106
+ <div className={active ? "active" : "idle"} />
107
+ <button disabled={loading}>Save</button>
108
+ <input value={name} onInput={event => setName(event.currentTarget.value)} />
109
+ ```
110
+
111
+ Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property.
112
+
113
+ ## Conditional DOM
114
+
115
+ Inline child `&&` and ternary expressions insert and remove bounded DOM ranges directly. A menu bar needs only state setters:
116
+
117
+ ```tsx
118
+ function MenuBar() {
119
+ return <nav><a href="/docs">Docs</a></nav>
120
+ }
121
+
122
+ const [open, setOpen] = useState(false)
123
+
124
+ {open
125
+ ? <button onClick={() => setOpen(false)}>Close menu</button>
126
+ : <button onClick={() => setOpen(true)}>Open menu</button>}
127
+ {open && <MenuBar />}
128
+ ```
129
+
130
+ 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.
131
+
132
+ Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
133
+
99
134
  ## Normal JavaScript
100
135
 
101
136
  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.
@@ -122,7 +157,8 @@ Primitive values, arrays, plain objects, and destructured props can be captured
122
157
  TSX
123
158
  ├─ static component → HTML
124
159
  ├─ ordered state setter → behavior command
125
- └─ normal JS handlerroute handler ESM
160
+ ├─ conditional childbounded DOM range
161
+ └─ normal JS handler → external ESM
126
162
  ```
127
163
 
128
164
  - Static pages ship no client JavaScript.
@@ -149,18 +185,19 @@ Supported:
149
185
  - Synchronous and async event handlers
150
186
  - Serializable component-local captures
151
187
  - Direct text DOM patches
188
+ - Reactive `className`, `disabled`, and controlled `value` patches
189
+ - Conditional child `&&` and ternary DOM patches
152
190
 
153
191
  Not implemented yet:
154
192
 
155
- - Reactive attributes, classes, and controlled inputs
156
- - Conditional DOM patches and keyed lists
193
+ - Keyed lists and generalized JSX-valued locals
157
194
  - Server actions and request-time SSR
158
195
  - Imported client helpers and React package islands
159
196
  - HMR and framework DevTools
160
197
 
161
198
  ## Benchmarks
162
199
 
163
- Measurements below were produced on the same machine from production builds. They compare build artifacts, not ecosystem maturity or full application performance.
200
+ Measurements below were produced on the same machine from production builds. Each framework received one warm-up followed by seven clean builds in rotating order; the table reports the median. Initial JavaScript includes inline scripts, root script references, and their static import graph, compressed file-by-file with gzip level 9. Total output is the raw size of every deploy artifact.
164
201
 
165
202
  ### Interactive Counter
166
203
 
@@ -168,30 +205,31 @@ Same counter with initial value `7` and increment/decrement buttons:
168
205
 
169
206
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
170
207
  |---|---:|---:|---:|---:|
171
- | Kudzu | Yes | **581 B** | 1.7 KB | **371 ms** |
172
- | Astro | Yes | 158 B | **365 B** | 953 ms |
173
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 910 ms |
174
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 633 ms |
175
- | React CSR | No | 59.2 KB | 189.0 KB | 1010 ms |
208
+ | Kudzu | Yes | 487 B | 1.4 KB | **385 ms** |
209
+ | Astro | Yes | **158 B** | **365 B** | 948 ms |
210
+ | Svelte CSR | No | 10.5 KB | 26.9 KB | 885 ms |
211
+ | Qwik CSR | No | 20.6 KB | 57.8 KB | 622 ms |
212
+ | Vue CSR | No | 24.0 KB | 60.3 KB | 822 ms |
213
+ | React CSR | No | 59.2 KB | 189.0 KB | 1084 ms |
176
214
  | Next.js | Yes | 182.1 KB | 652.2 KB | 3074 ms |
177
215
 
178
216
  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.
179
217
 
180
218
  ### Static Journal Page
181
219
 
182
- Same content and CSS across every fixture; build is the median of three clean runs:
220
+ Same content and CSS across every fixture:
183
221
 
184
222
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
185
223
  |---|---:|---:|---:|---:|
186
- | Kudzu | Yes | **0 B** | 3.1 KB | **383 ms** |
187
- | Astro | Yes | **0 B** | **3.0 KB** | 1022 ms |
188
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 907 ms |
189
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 619 ms |
190
- | Vue CSR | No | 24.2 KB | 62.3 KB | 719 ms |
191
- | React CSR | No | 59.8 KB | 192.3 KB | 1053 ms |
192
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3054 ms |
193
-
194
- Benchmark snapshot collected on July 20, 2026 from equivalent production fixtures on the same machine. Qwik used a client entry and therefore did not exercise its SSR resumability advantage. Build times vary by hardware and filesystem cache.
224
+ | Kudzu | Yes | **0 B** | 3.2 KB | **478 ms** |
225
+ | Astro | Yes | **0 B** | **3.0 KB** | 1253 ms |
226
+ | Svelte CSR | No | 10.2 KB | 27.2 KB | 1018 ms |
227
+ | Qwik CSR | No | 20.2 KB | 59.6 KB | 784 ms |
228
+ | Vue CSR | No | 24.2 KB | 62.3 KB | 960 ms |
229
+ | React CSR | No | 59.8 KB | 192.3 KB | 1253 ms |
230
+ | Next.js | Yes | 182.6 KB | 663.6 KB | 3880 ms |
231
+
232
+ Benchmark snapshot collected on July 21, 2026 with Node 24.14.0 on an Intel i5-9500. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
195
233
 
196
234
  ## Development
197
235
 
@@ -3,10 +3,13 @@
3
3
  - `build.mjs`: TSX compilation, file routes, behavior extraction, static HTML output, and the development server.
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
- - `runtime.js`: command-only browser runtime for direct state-to-text patches.
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 attributes and conditional DOM range patches.
9
+ - `serialization.js`: capture deserialization shared by binding and native handlers.
7
10
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
8
11
  - `*.d.ts`: public TypeScript and JSX declarations.
9
12
 
10
- Static routes do not receive either browser runtime. Command-only routes receive `runtime.js`; native handlers add `native-runtime.js` and generated modules under `dist/assets/handlers/`.
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/`.
11
14
 
12
15
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -0,0 +1,209 @@
1
+ import { browserState, mountText, registerCommitter } from "./shared-runtime.js"
2
+ import { deserialize } from "./serialization.js"
3
+
4
+ const imports = new Map()
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
+
12
+ export function patchBinding(node, target, value) {
13
+ if (target === "disabled") {
14
+ node.toggleAttribute("disabled", Boolean(value))
15
+ } else if (target === "value") {
16
+ const next = value == null ? "" : String(value)
17
+ if (node.value !== next) node.value = next
18
+ } else if (value == null || value === false) {
19
+ node.removeAttribute("class")
20
+ } else {
21
+ node.setAttribute("class", String(value))
22
+ }
23
+ }
24
+
25
+ function commitBindings(id) {
26
+ const bindings = bindingTargets.get(id)
27
+ if (!bindings) return
28
+ for (const binding of bindings) {
29
+ if (!binding.node.isConnected) bindings.delete(binding)
30
+ else patchBinding(binding.node, binding.target, binding.read())
31
+ }
32
+ }
33
+
34
+ function commitConditions(id) {
35
+ const conditions = conditionTargets.get(id)
36
+ if (!conditions) return
37
+ for (const condition of conditions) {
38
+ if (!condition.start.isConnected) conditions.delete(condition)
39
+ else updateCondition(condition)
40
+ }
41
+ }
42
+
43
+ registerCommitter(commitBindings)
44
+ registerCommitter(commitConditions)
45
+
46
+ if (typeof document !== "undefined") mountDom(document)
47
+
48
+ function mountDom(root) {
49
+ mountText(root)
50
+ mountBindings(root)
51
+ mountConditions(root)
52
+ }
53
+
54
+ function mountBindings(root) {
55
+ for (const target of ["class", "disabled", "value"]) {
56
+ for (const node of matching(root, `[data-k-bind-${target}]`)) {
57
+ if (mountedBindings.has(node)) continue
58
+ mountedBindings.add(node)
59
+ const descriptor = JSON.parse(node.dataset[`kBind${capitalize(target)}`])
60
+ if (descriptor.state) {
61
+ const binding = { node, target, read: () => browserState.get(descriptor.state) }
62
+ register(bindingTargets, descriptor.state, binding)
63
+ bindingRegistrations.set(node, [[descriptor.state, binding]])
64
+ patchBinding(node, target, binding.read())
65
+ continue
66
+ }
67
+ loadEvaluator(descriptor).then(evaluator => {
68
+ if (!node.isConnected) return
69
+ const binding = { node, target, read: evaluator.read }
70
+ const registrations = []
71
+ for (const id of evaluator.stateIds) {
72
+ register(bindingTargets, id, binding)
73
+ registrations.push([id, binding])
74
+ }
75
+ bindingRegistrations.set(node, registrations)
76
+ patchBinding(node, target, binding.read())
77
+ }).catch(error => console.error(error))
78
+ }
79
+ }
80
+ }
81
+
82
+ function mountConditions(root) {
83
+ for (const start of matching(root, "template[data-k-if]")) {
84
+ if (mountedConditions.has(start)) continue
85
+ mountedConditions.add(start)
86
+ const descriptor = JSON.parse(start.dataset.kIf)
87
+ const end = findEnd(start, descriptor.id)
88
+ const truthy = start.content.querySelector("template[data-k-true]")
89
+ const falsy = start.content.querySelector("template[data-k-false]")
90
+ if (!end || !truthy || !falsy) continue
91
+ const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial) }
92
+ loadEvaluator(descriptor).then(evaluator => {
93
+ if (!start.isConnected) return
94
+ condition.read = evaluator.read
95
+ const registrations = []
96
+ for (const id of evaluator.stateIds) {
97
+ register(conditionTargets, id, condition)
98
+ registrations.push([id, condition])
99
+ }
100
+ conditionRegistrations.set(start, { condition, registrations })
101
+ updateCondition(condition)
102
+ }).catch(error => console.error(error))
103
+ }
104
+ }
105
+
106
+ function updateCondition(condition) {
107
+ const value = condition.read()
108
+ const next = conditionKey(condition.kind, value)
109
+ if (next === condition.current) return
110
+ removeConditionRange(condition.start, condition.end)
111
+ const truthy = Boolean(value)
112
+ const falseText = condition.kind === "and" && !truthy ? renderFalsy(value) : ""
113
+ const fragment = falseText
114
+ ? textFragment(condition.end.ownerDocument, falseText)
115
+ : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
116
+ const nodes = [...fragment.childNodes]
117
+ condition.end.parentNode.insertBefore(fragment, condition.end)
118
+ condition.current = next
119
+ for (const node of nodes) mountDom(node)
120
+ }
121
+
122
+ function unmountDom(root) {
123
+ for (const node of matching(root, "[data-k-bind-class],[data-k-bind-disabled],[data-k-bind-value]")) {
124
+ for (const [id, binding] of bindingRegistrations.get(node) ?? []) bindingTargets.get(id)?.delete(binding)
125
+ bindingRegistrations.delete(node)
126
+ mountedBindings.delete(node)
127
+ }
128
+ for (const start of matching(root, "template[data-k-if]")) {
129
+ const registration = conditionRegistrations.get(start)
130
+ for (const [id, condition] of registration?.registrations ?? []) conditionTargets.get(id)?.delete(condition)
131
+ conditionRegistrations.delete(start)
132
+ mountedConditions.delete(start)
133
+ }
134
+ }
135
+
136
+ function removeConditionRange(start, end) {
137
+ const range = start.ownerDocument.createRange()
138
+ range.setStartAfter(start)
139
+ range.setEndBefore(end)
140
+ const root = range.commonAncestorContainer
141
+ for (const node of matching(root, "[data-k-bind-class],[data-k-bind-disabled],[data-k-bind-value],template[data-k-if]")) {
142
+ if (range.comparePoint(node, 0) === 0) unmountDom(node)
143
+ }
144
+ range.deleteContents()
145
+ }
146
+
147
+ function conditionKey(kind, value) {
148
+ return value ? "true" : kind === "and" ? `false:${renderFalsy(value)}` : "false"
149
+ }
150
+
151
+ function renderFalsy(value) {
152
+ return value === false || value == null || value === true ? "" : String(value)
153
+ }
154
+
155
+ function textFragment(document, value) {
156
+ const fragment = document.createDocumentFragment()
157
+ fragment.append(document.createTextNode(value))
158
+ return fragment
159
+ }
160
+
161
+ function findEnd(start, id) {
162
+ return [...start.ownerDocument.querySelectorAll("template[data-k-if-end]")]
163
+ .find(node => node.dataset.kIfEnd === id)
164
+ }
165
+
166
+ function register(targets, id, entry) {
167
+ const entries = targets.get(id) ?? new Set()
168
+ entries.add(entry)
169
+ targets.set(id, entries)
170
+ }
171
+
172
+ async function loadEvaluator(descriptor) {
173
+ let modulePromise = imports.get(descriptor.module)
174
+ if (!modulePromise) {
175
+ modulePromise = import(descriptor.module)
176
+ imports.set(descriptor.module, modulePromise)
177
+ }
178
+ const [module, context] = await Promise.all([modulePromise, createBindingContext(descriptor)])
179
+ return { read: () => module[descriptor.handler](context), stateIds: bindingStateIds(descriptor) }
180
+ }
181
+
182
+ async function createBindingContext(descriptor) {
183
+ const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value)]))
184
+ const nested = {}
185
+ await Promise.all(Object.entries(descriptor.scopeBindings).map(async ([name, binding]) => {
186
+ const evaluator = await loadEvaluator(binding)
187
+ nested[name] = evaluator.read
188
+ }))
189
+ return {
190
+ get: name => browserState.get(descriptor.states[name]),
191
+ scope: name => name in descriptor.scopeStates ? browserState.get(descriptor.scopeStates[name]) : name in nested ? nested[name]() : scope[name]
192
+ }
193
+ }
194
+
195
+ function bindingStateIds(descriptor) {
196
+ return new Set([
197
+ ...Object.values(descriptor.states),
198
+ ...Object.values(descriptor.scopeStates),
199
+ ...Object.values(descriptor.scopeBindings).flatMap(binding => [...bindingStateIds(binding)])
200
+ ])
201
+ }
202
+
203
+ function matching(root, selector) {
204
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
205
+ }
206
+
207
+ function capitalize(value) {
208
+ return value[0].toUpperCase() + value.slice(1)
209
+ }
@@ -30,6 +30,8 @@ export async function build({ quiet = false } = {}) {
30
30
  if (!pageFiles.length) throw new Error("No pages found in src/pages/")
31
31
 
32
32
  let behaviorCount = 0
33
+ let bindingCount = 0
34
+ let stateSeedCount = 0
33
35
  const plans = []
34
36
  const hasStyles = await exists(join(sourceDirectory, "style.css"))
35
37
 
@@ -48,14 +50,32 @@ export async function build({ quiet = false } = {}) {
48
50
  await writeFile(join(routeDirectory, "index.html"), result.html)
49
51
  plans.push({ route: `/${route}`, ...result.plan })
50
52
  if (result.hasBehaviors) behaviorCount++
53
+ if (result.hasBindings) bindingCount++
54
+ if (result.hasStateSeed) stateSeedCount++
51
55
  }
52
56
 
53
57
  const assetsDirectory = join(outputDirectory, "assets")
54
58
  await mkdir(assetsDirectory, { recursive: true })
55
- if (behaviorCount) await cp(new URL("./runtime.js", import.meta.url), join(assetsDirectory, "kudzu.js"))
56
- if (handlerModules.length) {
57
- const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8")).replace('"./runtime.js"', '"./kudzu.js"')
58
- await writeFile(join(assetsDirectory, "kudzu-native.js"), nativeRuntime)
59
+ const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
60
+ const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
61
+ if (behaviorCount) {
62
+ const runtimeFile = bindingCount ? "./shared-runtime.js" : "./runtime.js"
63
+ const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
64
+ await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
65
+ }
66
+ const hasNativeHandlers = handlerModules.some(module => module.hasNativeHandlers)
67
+ if (bindingCount || hasNativeHandlers) await cp(new URL("./serialization.js", import.meta.url), join(assetsDirectory, "kudzu-serialization.js"))
68
+ if (bindingCount) {
69
+ const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
70
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
71
+ .replace('"./serialization.js"', '"./kudzu-serialization.js"')
72
+ await writeFile(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime)
73
+ }
74
+ if (hasNativeHandlers) {
75
+ const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
76
+ .replace('"./runtime.js"', '"./kudzu.js"')
77
+ .replace('"./serialization.js"', '"./kudzu-serialization.js"')
78
+ await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeEvents(nativeRuntime, nativeEvents))
59
79
  }
60
80
  for (const handlerModule of handlerModules) {
61
81
  const output = join(assetsDirectory, handlerModule.path)
@@ -69,6 +89,18 @@ export async function build({ quiet = false } = {}) {
69
89
  if (!quiet) console.log(`Built ${pageFiles.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
70
90
  }
71
91
 
92
+ function specializeEvents(source, events) {
93
+ return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
94
+ }
95
+
96
+ function specializeRuntime(source, events, hasStateSeed) {
97
+ const specialized = specializeEvents(source, events)
98
+ if (hasStateSeed) return specialized
99
+ return specialized
100
+ .replace(" const initialState = document.body.dataset.kState\n", "")
101
+ .replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
102
+ }
103
+
72
104
  export async function dev() {
73
105
  await build()
74
106
 
@@ -110,6 +142,7 @@ export async function dev() {
110
142
  async function compile(file) {
111
143
  const source = await readFile(file, "utf8")
112
144
  const nativeHandlers = []
145
+ const reactiveBindings = []
113
146
  const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
114
147
  const result = ts.transpileModule(source, {
115
148
  fileName: file,
@@ -119,7 +152,7 @@ async function compile(file) {
119
152
  jsx: ts.JsxEmit.ReactJSX,
120
153
  jsxImportSource: "@kudzujs/core"
121
154
  },
122
- transformers: { before: [createKudzuTransformer(nativeHandlers, `/assets/${handlerPath}`)] },
155
+ transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, `/assets/${handlerPath}`)] },
123
156
  reportDiagnostics: true
124
157
  })
125
158
 
@@ -132,30 +165,40 @@ async function compile(file) {
132
165
  await mkdir(resolve(output, ".."), { recursive: true })
133
166
  await writeFile(output, result.outputText)
134
167
 
135
- if (!nativeHandlers.length) return undefined
136
- const moduleSource = nativeHandlers.map(handler => printNativeHandler(handler)).join("\n")
168
+ if (!nativeHandlers.length && !reactiveBindings.length) return undefined
169
+ const moduleSource = [
170
+ ...nativeHandlers.map(handler => printNativeHandler(handler)),
171
+ ...reactiveBindings.map(entry => printReactiveBinding(entry))
172
+ ].join("\n")
137
173
  const moduleResult = ts.transpileModule(moduleSource, {
138
174
  compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
139
175
  reportDiagnostics: true
140
176
  })
141
177
  const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
142
178
  if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
143
- return { path: handlerPath, code: moduleResult.outputText }
179
+ return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
144
180
  }
145
181
 
146
- function createKudzuTransformer(nativeHandlers, handlerUrl) {
182
+ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
147
183
  return context => sourceFile => {
148
184
  const factory = context.factory
149
- const setters = new Map()
185
+ const settersByFunction = new Map()
150
186
  const functions = new Map()
151
187
  let usesBehavior = false
188
+ let usesBinding = false
189
+ let usesConditional = false
152
190
 
153
191
  const collect = node => {
154
192
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
155
193
  const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
156
194
  const [stateElement, setterElement] = node.name.elements
157
195
  if (callName === "useState" && stateElement && setterElement && ts.isBindingElement(stateElement) && ts.isBindingElement(setterElement) && ts.isIdentifier(stateElement.name) && ts.isIdentifier(setterElement.name)) {
158
- setters.set(setterElement.name.text, stateElement.name.text)
196
+ const owner = nearestFunction(node)
197
+ if (owner) {
198
+ const setters = settersByFunction.get(owner) ?? new Map()
199
+ setters.set(setterElement.name.text, stateElement.name.text)
200
+ settersByFunction.set(owner, setters)
201
+ }
159
202
  }
160
203
  }
161
204
  if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node)
@@ -185,7 +228,38 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
185
228
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
186
229
  }
187
230
 
231
+ if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
232
+ const parts = conditionalParts(node.expression)
233
+ if (parts) {
234
+ const setters = settersForNode(node, settersByFunction)
235
+ const usedStates = referencedStateNames(parts.condition, setters)
236
+ const captures = captureNames(parts.condition, parts.condition, setters)
237
+ if (usedStates.size || captures.size) {
238
+ usesBehavior = true
239
+ usesConditional = true
240
+ const truthy = ts.visitNode(parts.truthy, visitor)
241
+ const falsy = ts.visitNode(parts.falsy, visitor)
242
+ const compiled = compileConditional(parts.kind, parts.condition, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl)
243
+ return factory.updateJsxExpression(node, compiled)
244
+ }
245
+ }
246
+ }
247
+
248
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["className", "disabled", "value"].includes(node.name.getText())) {
249
+ const expression = node.initializer.expression
250
+ const setters = settersForNode(node, settersByFunction)
251
+ const usedStates = referencedStateNames(expression, setters)
252
+ const captures = captureNames(expression, expression, setters)
253
+ if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
254
+ usesBehavior = true
255
+ usesBinding = true
256
+ const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
257
+ return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
258
+ }
259
+ }
260
+
188
261
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
262
+ const setters = settersForNode(node, settersByFunction)
189
263
  const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
190
264
  if (event) {
191
265
  usesBehavior = true
@@ -203,6 +277,9 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
203
277
 
204
278
  const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
205
279
  if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
280
+ if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
281
+ if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
282
+ if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
206
283
  const behaviorImport = factory.createImportDeclaration(
207
284
  undefined,
208
285
  factory.createImportClause(false, undefined, factory.createNamedImports(behaviorImports)),
@@ -212,6 +289,70 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
212
289
  }
213
290
  }
214
291
 
292
+ function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
293
+ return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
294
+ }
295
+
296
+ function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
297
+ const [initial, ...descriptor] = compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl)
298
+ const thunk = branch => factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), branch)
299
+ return factory.createCallExpression(factory.createIdentifier("__kConditional"), undefined, [factory.createStringLiteral(kind), initial, thunk(truthy), thunk(falsy), ...descriptor])
300
+ }
301
+
302
+ function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
303
+ const usedStates = referencedStateNames(expression, setters)
304
+ const captures = captureNames(expression, expression, setters)
305
+ const exportName = `binding${reactiveBindings.length}`
306
+ reactiveBindings.push({ exportName, expression, captures, states: usedStates })
307
+ const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
308
+ factory.createStringLiteral(name),
309
+ factory.createIdentifier(name)
310
+ ]))
311
+ const scope = [...captures].map(name => factory.createArrayLiteralExpression([
312
+ factory.createStringLiteral(name),
313
+ factory.createIdentifier(name)
314
+ ]))
315
+ const stateNames = new Set(usedStates)
316
+ const rewriteInitial = node => {
317
+ if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text)) {
318
+ return factory.createPropertyAssignment(node.name, factory.createPropertyAccessExpression(node.name, "value"))
319
+ }
320
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
321
+ return factory.createPropertyAccessExpression(node, "value")
322
+ }
323
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
324
+ return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createIdentifier("__kBindingValue"), undefined, [node.name]))
325
+ }
326
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
327
+ return factory.createCallExpression(factory.createIdentifier("__kBindingValue"), undefined, [node])
328
+ }
329
+ return ts.visitEachChild(node, rewriteInitial, context)
330
+ }
331
+ return [
332
+ ts.visitNode(expression, rewriteInitial),
333
+ factory.createStringLiteral(handlerUrl),
334
+ factory.createStringLiteral(exportName),
335
+ factory.createArrayLiteralExpression(states),
336
+ factory.createArrayLiteralExpression(scope)
337
+ ]
338
+ }
339
+
340
+ function conditionalParts(expression) {
341
+ const unwrap = node => ts.isParenthesizedExpression(node) ? unwrap(node.expression) : node
342
+ const value = unwrap(expression)
343
+ if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
344
+ return { kind: "and", condition: value.left, truthy: unwrap(value.right), falsy: factoryNull() }
345
+ }
346
+ if (ts.isConditionalExpression(value)) {
347
+ return { kind: "ternary", condition: value.condition, truthy: unwrap(value.whenTrue), falsy: unwrap(value.whenFalse) }
348
+ }
349
+ return undefined
350
+ }
351
+
352
+ function factoryNull() {
353
+ return ts.factory.createNull()
354
+ }
355
+
215
356
  function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
216
357
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
217
358
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
@@ -240,14 +381,18 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
240
381
  }
241
382
 
242
383
  function nativeStateNames(expression, setters) {
384
+ return referencedStateNames(expression.body, setters, expression)
385
+ }
386
+
387
+ function referencedStateNames(root, setters, scopeRoot = root) {
243
388
  const stateNames = new Set(setters.values())
244
389
  const used = new Set()
245
390
  const visit = node => {
246
391
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) used.add(setters.get(node.expression.text))
247
- if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node)) used.add(node.text)
392
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
248
393
  ts.forEachChild(node, visit)
249
394
  }
250
- visit(expression.body)
395
+ visit(root)
251
396
  return used
252
397
  }
253
398
 
@@ -267,6 +412,10 @@ const nativeGlobals = new Set([
267
412
  ])
268
413
 
269
414
  function nativeCaptureNames(expression, setters) {
415
+ return captureNames(expression, expression.body, setters)
416
+ }
417
+
418
+ function captureNames(declarationRoot, referenceRoot, setters) {
270
419
  const local = new Set()
271
420
  const collectDeclarations = node => {
272
421
  if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
@@ -274,7 +423,7 @@ function nativeCaptureNames(expression, setters) {
274
423
  if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
275
424
  ts.forEachChild(node, collectDeclarations)
276
425
  }
277
- collectDeclarations(expression)
426
+ collectDeclarations(declarationRoot)
278
427
 
279
428
  const stateNames = new Set(setters.values())
280
429
  const captures = new Set()
@@ -285,7 +434,7 @@ function nativeCaptureNames(expression, setters) {
285
434
  }
286
435
  ts.forEachChild(node, visit)
287
436
  }
288
- visit(expression.body)
437
+ visit(referenceRoot)
289
438
  return captures
290
439
  }
291
440
 
@@ -308,6 +457,30 @@ function isReferenceIdentifier(node) {
308
457
  return true
309
458
  }
310
459
 
460
+ function nearestFunction(node) {
461
+ for (let current = node.parent; current; current = current.parent) {
462
+ if (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) return current
463
+ }
464
+ return undefined
465
+ }
466
+
467
+ function isShadowedByParameter(node, scopeRoot) {
468
+ for (let current = node.parent; current; current = current.parent) {
469
+ if ((ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
470
+ if (current === scopeRoot) break
471
+ }
472
+ return false
473
+ }
474
+
475
+ function settersForNode(node, settersByFunction) {
476
+ for (let current = node.parent; current; current = current.parent) {
477
+ if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
478
+ const setters = settersByFunction.get(current)
479
+ if (setters) return setters
480
+ }
481
+ return new Map()
482
+ }
483
+
311
484
  function printNativeHandler({ exportName, expression, captures, setters }) {
312
485
  const factory = ts.factory
313
486
  const stateNames = new Set(setters.values())
@@ -320,7 +493,7 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
320
493
  [factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
321
494
  )
322
495
  }
323
- if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node)) {
496
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
324
497
  return factory.createCallExpression(
325
498
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
326
499
  undefined,
@@ -359,6 +532,54 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
359
532
  }
360
533
  }
361
534
 
535
+ function printReactiveBinding({ exportName, expression, captures, states }) {
536
+ const factory = ts.factory
537
+ const transformer = context => root => {
538
+ const visitor = node => {
539
+ if (ts.isShorthandPropertyAssignment(node) && states.has(node.name.text)) {
540
+ return factory.createPropertyAssignment(
541
+ node.name,
542
+ factory.createCallExpression(
543
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
544
+ undefined,
545
+ [factory.createStringLiteral(node.name.text)]
546
+ )
547
+ )
548
+ }
549
+ if (ts.isIdentifier(node) && states.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
550
+ return factory.createCallExpression(
551
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
552
+ undefined,
553
+ [factory.createStringLiteral(node.text)]
554
+ )
555
+ }
556
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
557
+ return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
558
+ }
559
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
560
+ return scopeRead(factory, node.text)
561
+ }
562
+ return ts.visitEachChild(node, visitor, context)
563
+ }
564
+ return ts.visitNode(root, visitor)
565
+ }
566
+ const transformed = ts.transform(expression, [transformer])
567
+ try {
568
+ const declaration = factory.createFunctionDeclaration(
569
+ [factory.createModifier(ts.SyntaxKind.ExportKeyword)],
570
+ undefined,
571
+ exportName,
572
+ undefined,
573
+ [factory.createParameterDeclaration(undefined, undefined, "__k")],
574
+ undefined,
575
+ factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
576
+ )
577
+ return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
578
+ } finally {
579
+ transformed.dispose()
580
+ }
581
+ }
582
+
362
583
  function scopeRead(factory, name) {
363
584
  return factory.createCallExpression(
364
585
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
@@ -4,6 +4,9 @@ export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
4
 
5
5
  export function behavior(commands: Array<["add" | "set" | "log", unknown, unknown]>): unknown
6
6
  export function nativeBehavior(module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
7
+ export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
8
+ export function bindingValue(value: unknown): unknown
9
+ export function conditional(kind: "and" | "ternary", value: unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
7
10
 
8
11
  export function renderPage(
9
12
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -28,6 +31,8 @@ export function renderPage(
28
31
  ): Promise<{
29
32
  html: string
30
33
  hasBehaviors: boolean
34
+ hasBindings: boolean
35
+ hasStateSeed: boolean
31
36
  plan: {
32
37
  states: Array<{ id: string; name: string; initialValue: unknown }>
33
38
  events: Array<{
@@ -35,5 +40,16 @@ export function renderPage(
35
40
  commands?: Array<[string, string, unknown]>
36
41
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
37
42
  }>
43
+ bindings: Array<{
44
+ target: "class" | "disabled" | "value"
45
+ state?: string
46
+ module?: string
47
+ handler?: string
48
+ states?: Record<string, string>
49
+ scope?: Record<string, unknown>
50
+ scopeStates?: Record<string, string>
51
+ scopeBindings?: Record<string, unknown>
52
+ }>
53
+ conditions: Array<Record<string, unknown>>
38
54
  }
39
55
  }>
@@ -1,6 +1,8 @@
1
1
  const signalMarker = Symbol("kudzu.signal")
2
2
  const behaviorMarker = Symbol("kudzu.behavior")
3
3
  const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
4
+ const bindingMarker = Symbol("kudzu.binding")
5
+ const conditionalMarker = Symbol("kudzu.conditional")
4
6
 
5
7
  let renderContext
6
8
 
@@ -51,6 +53,44 @@ export function nativeBehavior(module, handler, states, scope) {
51
53
  }
52
54
  }
53
55
 
56
+ export function binding(value, module, handler, states, scope) {
57
+ return { [bindingMarker]: true, value, ...reactiveDescriptor(module, handler, states, scope) }
58
+ }
59
+
60
+ export function conditional(kind, value, truthy, falsy, module, handler, states, scope) {
61
+ return { [conditionalMarker]: true, kind, value, truthy, falsy, ...reactiveDescriptor(module, handler, states, scope) }
62
+ }
63
+
64
+ function reactiveDescriptor(module, handler, states, scope) {
65
+ const scopeStates = {}
66
+ const serializedScope = {}
67
+ const scopeBindings = {}
68
+ for (const [name, entry] of scope) {
69
+ if (entry?.[signalMarker]) scopeStates[name] = entry.id
70
+ else if (entry?.[bindingMarker]) scopeBindings[name] = bindingDescriptor(entry)
71
+ else serializedScope[name] = serializeCapture(name, entry, new Set())
72
+ }
73
+ return {
74
+ module,
75
+ handler,
76
+ states: Object.fromEntries(states.map(([name, signal]) => {
77
+ if (!signal?.[signalMarker]) throw new Error("A reactive binding must target framework state")
78
+ return [name, signal.id]
79
+ })),
80
+ scope: serializedScope,
81
+ scopeStates,
82
+ scopeBindings
83
+ }
84
+ }
85
+
86
+ export function bindingValue(value) {
87
+ return value?.[signalMarker] || value?.[bindingMarker] ? value.value : value
88
+ }
89
+
90
+ function bindingDescriptor(value) {
91
+ return { module: value.module, handler: value.handler, states: value.states, scope: value.scope, scopeStates: value.scopeStates, scopeBindings: value.scopeBindings }
92
+ }
93
+
54
94
  function serializeCapture(name, value, seen) {
55
95
  if (value === null || typeof value === "string" || typeof value === "boolean") return value
56
96
  if (typeof value === "number") {
@@ -83,7 +123,7 @@ function serializeCapture(name, value, seen) {
83
123
  }
84
124
 
85
125
  export async function renderPage(component, metadata = {}) {
86
- renderContext = { nextState: 0, states: {}, events: [], hasBehaviors: false, hasNativeBehaviors: false }
126
+ renderContext = { nextState: 0, nextCondition: 0, conditionDepth: 0, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false }
87
127
 
88
128
  try {
89
129
  const body = await renderNode({ type: component, props: {} })
@@ -98,13 +138,26 @@ export async function renderPage(component, metadata = {}) {
98
138
  const nativeRuntime = renderContext.hasNativeBehaviors
99
139
  ? '<script type="module" src="/assets/kudzu-native.js"></script>'
100
140
  : ""
141
+ const bindingRuntime = renderContext.hasBindings
142
+ ? '<script type="module" src="/assets/kudzu-binding.js"></script>'
143
+ : ""
144
+ const initialState = renderContext.hasBehaviors
145
+ ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
146
+ : []
147
+ const state = initialState.length
148
+ ? ` data-k-state='${escapeJsonAttribute(initialState)}'`
149
+ : ""
101
150
 
102
151
  return {
103
- html: `<!doctype html>\n<html lang="${escapeAttribute(metadata.lang ?? "en")}">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${title}</title>\n${head}${styles}\n</head>\n<body>\n${body}\n${runtime}\n${nativeRuntime}\n</body>\n</html>\n`,
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>`,
104
153
  hasBehaviors: renderContext.hasBehaviors,
154
+ hasBindings: renderContext.hasBindings,
155
+ hasStateSeed: initialState.length > 0,
105
156
  plan: {
106
157
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
107
- events: renderContext.events
158
+ events: renderContext.events,
159
+ bindings: renderContext.bindings,
160
+ conditions: renderContext.conditions
108
161
  }
109
162
  }
110
163
  } finally {
@@ -127,7 +180,7 @@ function renderMetadata(metadata) {
127
180
 
128
181
  meta("og:title", metadata.title, true)
129
182
  meta("og:description", metadata.description, true)
130
- meta("og:type", metadata.type ?? "website", true)
183
+ if (metadata.type || metadata.title || metadata.description || metadata.url || metadata.image || metadata.siteName || metadata.locale) meta("og:type", metadata.type ?? "website", true)
131
184
  meta("og:url", metadata.url, true)
132
185
  meta("og:image", metadata.image, true)
133
186
  if (metadata.image) {
@@ -142,46 +195,71 @@ function renderMetadata(metadata) {
142
195
  meta("twitter:description", metadata.description)
143
196
  meta("twitter:image", metadata.twitterImage ?? metadata.image)
144
197
 
145
- return tags.length ? `${tags.join("\n")}\n` : ""
198
+ return tags.join("")
146
199
  }
147
200
 
148
- async function renderNode(node) {
201
+ async function renderNode(node, namespace) {
149
202
  if (node == null || node === false || node === true) return ""
150
203
  if (Array.isArray(node)) {
151
204
  let html = ""
152
- for (const child of node) html += await renderNode(child)
205
+ for (const child of node) html += await renderNode(child, namespace)
153
206
  return html
154
207
  }
155
208
  if (node?.[signalMarker]) {
156
- return `<span data-k-text="${node.id}" data-k-value="${escapeAttribute(JSON.stringify(node.value))}">${escapeHtml(node.value)}</span>`
209
+ renderContext.textStates.add(node.id)
210
+ if (renderContext.conditionDepth) renderContext.conditionStates.add(node.id)
211
+ return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value)}</span>`
157
212
  }
158
213
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
159
214
  return escapeHtml(node)
160
215
  }
161
- if (node instanceof Promise) return renderNode(await node)
216
+ if (node instanceof Promise) return renderNode(await node, namespace)
217
+ if (node?.[conditionalMarker]) {
218
+ const descriptor = bindingDescriptor(node)
219
+ const stateIds = reactiveStateIds(descriptor)
220
+ if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace)
221
+ if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
222
+
223
+ const id = `c${renderContext.nextCondition++}`
224
+ renderContext.conditionDepth++
225
+ const truthy = await renderNode(node.truthy())
226
+ const falsy = await renderNode(node.falsy())
227
+ renderContext.conditionDepth--
228
+ const metadata = { id, kind: node.kind, initial: node.value, ...descriptor }
229
+ for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
230
+ renderContext.conditions.push(metadata)
231
+ renderContext.hasBehaviors = true
232
+ renderContext.hasBindings = true
233
+ const encoded = escapeJsonAttribute(metadata)
234
+ const current = node.value ? truthy : node.kind === "and" ? await renderNode(node.value) : falsy
235
+ 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
+ }
162
237
  if (!node || typeof node !== "object" || !("type" in node)) {
163
238
  throw new Error(`Cannot render ${String(node)}`)
164
239
  }
165
240
 
166
- if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children)
167
- if (typeof node.type === "function") return renderNode(await node.type(node.props))
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)
168
243
 
169
244
  const tag = node.type
170
245
  const props = node.props ?? {}
246
+ const childNamespace = tag === "svg" || tag === "math"
247
+ ? tag
248
+ : namespace === "svg" && tag === "foreignObject" ? undefined : namespace
171
249
  let attributes = ""
172
250
 
173
251
  for (const [rawName, value] of Object.entries(props)) {
174
- if (rawName === "children" || rawName === "key" || value == null || value === false) continue
252
+ if (rawName === "children" || rawName === "key") continue
175
253
 
176
254
  if (/^on[A-Z]/.test(rawName)) {
177
255
  const event = rawName.slice(2).toLowerCase()
178
256
  if (value?.[behaviorMarker]) {
179
257
  const commands = JSON.stringify(value.commands)
180
- attributes += ` data-k-on-${event}="${escapeAttribute(commands)}"`
258
+ attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
181
259
  renderContext.events.push({ event, commands: value.commands })
182
260
  } else if (value?.[nativeBehaviorMarker]) {
183
261
  const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
184
- attributes += ` data-k-native-${event}="${escapeAttribute(JSON.stringify(native))}"`
262
+ attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
185
263
  renderContext.events.push({ event, native })
186
264
  renderContext.hasNativeBehaviors = true
187
265
  } else {
@@ -192,6 +270,27 @@ async function renderNode(node) {
192
270
  }
193
271
 
194
272
  const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
273
+ const target = name === "class" || name === "disabled" || name === "value" ? name : undefined
274
+ if (target && (value?.[signalMarker] || value?.[bindingMarker])) {
275
+ const initialValue = value[signalMarker] ? value.value : value.value
276
+ const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
277
+ if (!reactive) {
278
+ attributes += renderAttribute(name, initialValue)
279
+ continue
280
+ }
281
+ const descriptor = value[signalMarker]
282
+ ? { state: value.id }
283
+ : bindingDescriptor(value)
284
+ attributes += renderAttribute(name, initialValue)
285
+ attributes += ` data-k-bind-${target}='${escapeJsonAttribute(descriptor)}'`
286
+ renderContext.bindings.push({ target, ...descriptor })
287
+ if (renderContext.conditionDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
288
+ renderContext.hasBehaviors = true
289
+ renderContext.hasBindings = true
290
+ continue
291
+ }
292
+
293
+ if (value == null || value === false) continue
195
294
  if (value === true) {
196
295
  attributes += ` ${name}`
197
296
  } else if (name === "style" && typeof value === "object") {
@@ -204,7 +303,23 @@ async function renderNode(node) {
204
303
 
205
304
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
206
305
  if (voidElements.has(tag)) return `<${tag}${attributes}>`
207
- return `<${tag}${attributes}>${await renderNode(props.children)}</${tag}>`
306
+ return `<${tag}${attributes}>${await renderNode(props.children, childNamespace)}</${tag}>`
307
+ }
308
+
309
+ function reactiveStateIds(descriptor) {
310
+ if (descriptor.state) return new Set([descriptor.state])
311
+ return new Set([
312
+ ...Object.values(descriptor.states),
313
+ ...Object.values(descriptor.scopeStates),
314
+ ...Object.values(descriptor.scopeBindings).flatMap(entry => [...reactiveStateIds(entry)])
315
+ ])
316
+ }
317
+
318
+ function renderAttribute(name, value) {
319
+ if (name === "disabled") return value ? " disabled" : ""
320
+ if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
321
+ if (value == null || value === false) return ""
322
+ return ` ${name}="${escapeAttribute(value)}"`
208
323
  }
209
324
 
210
325
  function escapeHtml(value) {
@@ -219,6 +334,10 @@ function escapeAttribute(value) {
219
334
  return escapeHtml(value).replaceAll("'", "&#39;")
220
335
  }
221
336
 
337
+ function escapeJsonAttribute(value) {
338
+ return JSON.stringify(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll("'", "&#39;")
339
+ }
340
+
222
341
  function toKebabCase(value) {
223
342
  return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
224
343
  }
@@ -1,4 +1,5 @@
1
1
  import { browserState, commitDom } from "./runtime.js"
2
+ import { deserialize } from "./serialization.js"
2
3
 
3
4
  export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
4
5
  const changed = new Set()
@@ -35,7 +36,8 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
35
36
  if (typeof document !== "undefined") {
36
37
  const modules = new Map()
37
38
 
38
- for (const eventName of ["click", "input", "change", "submit", "keydown", "keyup"]) {
39
+ const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
40
+ for (const eventName of eventNames) {
39
41
  document.addEventListener(eventName, event => {
40
42
  const target = event.target.closest(`[data-k-native-${eventName}]`)
41
43
  if (!target) return
@@ -53,19 +55,6 @@ if (typeof document !== "undefined") {
53
55
  }
54
56
  }
55
57
 
56
- function deserialize(value) {
57
- if (!value || typeof value !== "object") return value
58
- if (value.type === "undefined") return undefined
59
- if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
60
- if (value.type === "array") return value.value.map(deserialize)
61
- if (value.type === "object") {
62
- const object = value.nullPrototype ? Object.create(null) : {}
63
- for (const [key, entry] of value.value) Object.defineProperty(object, key, { value: deserialize(entry), enumerable: true, writable: true, configurable: true })
64
- return object
65
- }
66
- return value
67
- }
68
-
69
58
  function delegatedEvent(event, currentTarget) {
70
59
  return new Proxy(event, {
71
60
  get(source, property) {
@@ -1,43 +1,30 @@
1
1
  export function applyCommands(state, commands, commit, log = console.log) {
2
- const changed = new Set()
3
-
4
- for (const [operation, id, operand] of commands) {
5
- const current = state.get(id)
6
- if (operation === "log") {
7
- log(operand, current)
8
- continue
2
+ const changed=new Set
3
+ for(const [operation,id,value] of commands){
4
+ const current=state.get(id)
5
+ if(operation==="log")log(value,current)
6
+ else {
7
+ state.set(id,operation==="add"?current+value:value)
8
+ changed.add(id)
9
9
  }
10
- state.set(id, operation === "add" ? current + operand : operand)
11
- changed.add(id)
12
10
  }
13
-
14
- for (const id of changed) commit(id, state.get(id))
11
+ for(const id of changed)commit(id,state.get(id))
15
12
  }
16
13
 
17
- export const browserState = new Map()
14
+ export const browserState=new Map
18
15
 
19
16
  export function commitDom(id, value) {
20
- for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) {
21
- node.textContent = value
22
- node.dataset.kValue = JSON.stringify(value)
23
- }
17
+ for(const node of document.querySelectorAll(`[data-k-text="${id}"]`))node.textContent = value
24
18
  }
25
19
 
26
- if (typeof document !== "undefined") {
27
- for (const node of document.querySelectorAll("[data-k-text]")) {
28
- browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
29
- }
20
+ if(typeof document!=="undefined"){
21
+ const initialState = document.body.dataset.kState
22
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
23
+ for(const node of document.querySelectorAll("[data-k-text]"))browserState.set(node.dataset.kText,JSON.parse(node.dataset.kValue))
30
24
 
31
- for (const eventName of ["click", "input", "change"]) {
32
- document.addEventListener(eventName, event => {
33
- const target = event.target.closest(`[data-k-on-${eventName}]`)
34
- if (!target) return
35
- const commands = target.dataset[`kOn${capitalize(eventName)}`]
36
- applyCommands(browserState, JSON.parse(commands), commitDom)
25
+ const eventNames = ["click", "input", "change"]
26
+ for(const eventName of eventNames)document.addEventListener(eventName,event=>{
27
+ const target=event.target.closest(`[data-k-on-${eventName}]`)
28
+ if(target)applyCommands(browserState,JSON.parse(target.getAttribute(`data-k-on-${eventName}`)),commitDom)
37
29
  })
38
- }
39
- }
40
-
41
- function capitalize(value) {
42
- return value[0].toUpperCase() + value.slice(1)
43
30
  }
@@ -0,0 +1,12 @@
1
+ export function deserialize(value) {
2
+ if (!value || typeof value !== "object") return value
3
+ if (value.type === "undefined") return undefined
4
+ if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
5
+ if (value.type === "array") return value.value.map(deserialize)
6
+ if (value.type === "object") {
7
+ const object = value.nullPrototype ? Object.create(null) : {}
8
+ for (const [key, entry] of value.value) Object.defineProperty(object, key, { value: deserialize(entry), enumerable: true, writable: true, configurable: true })
9
+ return object
10
+ }
11
+ return value
12
+ }
@@ -0,0 +1,59 @@
1
+ export function applyCommands(state, commands, commit, log = console.log) {
2
+ const changed = new Set()
3
+
4
+ for (const [operation, id, operand] of commands) {
5
+ const current = state.get(id)
6
+ if (operation === "log") {
7
+ log(operand, current)
8
+ continue
9
+ }
10
+ state.set(id, operation === "add" ? current + operand : operand)
11
+ changed.add(id)
12
+ }
13
+
14
+ for (const id of changed) commit(id, state.get(id))
15
+ }
16
+
17
+ export const browserState = new Map()
18
+ const committers = []
19
+
20
+ export function registerCommitter(commit) {
21
+ committers.push(commit)
22
+ }
23
+
24
+ export function commitDom(id, value) {
25
+ for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
26
+ for (const commit of committers) commit(id)
27
+ }
28
+
29
+ export function mountText(root) {
30
+ for (const node of matching(root, "[data-k-text]")) {
31
+ const id = node.dataset.kText
32
+ if (browserState.has(id)) node.textContent = browserState.get(id)
33
+ else browserState.set(id, JSON.parse(node.dataset.kValue))
34
+ }
35
+ }
36
+
37
+ if (typeof document !== "undefined") {
38
+ const initialState = document.body.dataset.kState
39
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
40
+ mountText(document)
41
+
42
+ const eventNames = ["click", "input", "change"]
43
+ for (const eventName of eventNames) {
44
+ document.addEventListener(eventName, event => {
45
+ const target = event.target.closest(`[data-k-on-${eventName}]`)
46
+ if (!target) return
47
+ const commands = target.dataset[`kOn${capitalize(eventName)}`]
48
+ applyCommands(browserState, JSON.parse(commands), commitDom)
49
+ })
50
+ }
51
+ }
52
+
53
+ function matching(root, selector) {
54
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
55
+ }
56
+
57
+ function capitalize(value) {
58
+ return value[0].toUpperCase() + value.slice(1)
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "scripts": {
47
47
  "build": "node ./bin/kudzu.mjs build",
48
48
  "dev": "node ./bin/kudzu.mjs dev",
49
- "check": "tsc --noEmit && node ./bin/kudzu.mjs build",
49
+ "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json --noEmit && node ./bin/kudzu.mjs build",
50
50
  "test": "node --test",
51
51
  "prepublishOnly": "npm run check && npm test",
52
52
  "deploy": "wrangler deploy",