@kudzujs/core 0.1.0 → 0.2.3

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
@@ -10,10 +10,22 @@ Brand assets, favicons, manifest icons, and the 1200×630 social preview live in
10
10
 
11
11
  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
12
 
13
- > Experimental `0.1.x`: the compiler API and supported TSX surface may change.
13
+ > Experimental `0.2.x`: the compiler API and supported TSX surface may change.
14
+
15
+ Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
14
16
 
15
17
  ## Install
16
18
 
19
+ Create a new project:
20
+
21
+ ```bash
22
+ npm create kudzu@latest my-app
23
+ cd my-app
24
+ npm run dev
25
+ ```
26
+
27
+ Or add Kudzu to an existing project:
28
+
17
29
  ```bash
18
30
  npm install @kudzujs/core
19
31
  ```
@@ -86,6 +98,18 @@ function increaseTwice() {
86
98
 
87
99
  The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
88
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
+
89
113
  ## Normal JavaScript
90
114
 
91
115
  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.
@@ -139,10 +163,10 @@ Supported:
139
163
  - Synchronous and async event handlers
140
164
  - Serializable component-local captures
141
165
  - Direct text DOM patches
166
+ - Reactive `className`, `disabled`, and controlled `value` patches
142
167
 
143
168
  Not implemented yet:
144
169
 
145
- - Reactive attributes, classes, and controlled inputs
146
170
  - Conditional DOM patches and keyed lists
147
171
  - Server actions and request-time SSR
148
172
  - Imported client helpers and React package islands
@@ -150,7 +174,7 @@ Not implemented yet:
150
174
 
151
175
  ## Benchmarks
152
176
 
153
- Measurements below were produced on the same machine from production builds. They compare build artifacts, not ecosystem maturity or full application performance.
177
+ 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.
154
178
 
155
179
  ### Interactive Counter
156
180
 
@@ -158,30 +182,31 @@ Same counter with initial value `7` and increment/decrement buttons:
158
182
 
159
183
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
160
184
  |---|---:|---:|---:|---:|
161
- | Kudzu | Yes | **581 B** | 1.7 KB | **371 ms** |
162
- | Astro | Yes | 158 B | **365 B** | 953 ms |
185
+ | Kudzu | Yes | 563 B | 1.7 KB | **384 ms** |
186
+ | Astro | Yes | **158 B** | **365 B** | 911 ms |
163
187
  | Svelte CSR | No | 10.5 KB | 26.9 KB | 910 ms |
164
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 633 ms |
165
- | React CSR | No | 59.2 KB | 189.0 KB | 1010 ms |
166
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3074 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 |
167
192
 
168
193
  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.
169
194
 
170
195
  ### Static Journal Page
171
196
 
172
- Same content and CSS across every fixture; build is the median of three clean runs:
197
+ Same content and CSS across every fixture:
173
198
 
174
199
  | Framework | Initial content | Initial JS gzip | Total output | Clean build |
175
200
  |---|---:|---:|---:|---:|
176
- | Kudzu | Yes | **0 B** | 3.1 KB | **383 ms** |
177
- | Astro | Yes | **0 B** | **3.0 KB** | 1022 ms |
178
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 907 ms |
179
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 619 ms |
180
- | Vue CSR | No | 24.2 KB | 62.3 KB | 719 ms |
181
- | React CSR | No | 59.8 KB | 192.3 KB | 1053 ms |
182
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3054 ms |
183
-
184
- 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.
201
+ | Kudzu | Yes | **0 B** | 3.2 KB | **478 ms** |
202
+ | Astro | Yes | **0 B** | **3.0 KB** | 1253 ms |
203
+ | Svelte CSR | No | 10.2 KB | 27.2 KB | 1018 ms |
204
+ | Qwik CSR | No | 20.2 KB | 59.6 KB | 784 ms |
205
+ | Vue CSR | No | 24.2 KB | 62.3 KB | 960 ms |
206
+ | React CSR | No | 59.8 KB | 192.3 KB | 1253 ms |
207
+ | Next.js | Yes | 182.6 KB | 663.6 KB | 3880 ms |
208
+
209
+ 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.
185
210
 
186
211
  ## Development
187
212
 
@@ -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 `className`, `disabled`, and `value` 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,85 @@
1
+ import { browserState, registerCommitter } from "./shared-runtime.js"
2
+ import { deserialize } from "./serialization.js"
3
+
4
+ const bindingTargets = new Map()
5
+
6
+ export function patchBinding(node, target, value) {
7
+ if (target === "disabled") {
8
+ node.toggleAttribute("disabled", Boolean(value))
9
+ } else if (target === "value") {
10
+ const next = value == null ? "" : String(value)
11
+ if (node.value !== next) node.value = next
12
+ } else if (value == null || value === false) {
13
+ node.removeAttribute("class")
14
+ } else {
15
+ node.setAttribute("class", String(value))
16
+ }
17
+ }
18
+
19
+ function commitBindings(id) {
20
+ for (const binding of bindingTargets.get(id) ?? []) patchBinding(binding.node, binding.target, binding.read())
21
+ }
22
+
23
+ registerCommitter(commitBindings)
24
+
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)}`])
31
+ if (descriptor.state) {
32
+ registerBinding(descriptor.state, { node, target, read: () => browserState.get(descriptor.state) })
33
+ continue
34
+ }
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)
44
+ patchBinding(node, target, binding.read())
45
+ }))
46
+ }
47
+ }
48
+ Promise.all(registrations).catch(error => console.error(error))
49
+ }
50
+
51
+ function registerBinding(id, binding) {
52
+ const bindings = bindingTargets.get(id) ?? []
53
+ bindings.push(binding)
54
+ bindingTargets.set(id, bindings)
55
+ }
56
+
57
+ async function createBindingContext(descriptor, imports) {
58
+ const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value)]))
59
+ const nested = {}
60
+ 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)
68
+ }))
69
+ return {
70
+ get: name => browserState.get(descriptor.states[name]),
71
+ scope: name => name in descriptor.scopeStates ? browserState.get(descriptor.scopeStates[name]) : name in nested ? nested[name]() : scope[name]
72
+ }
73
+ }
74
+
75
+ function bindingStateIds(descriptor) {
76
+ return new Set([
77
+ ...Object.values(descriptor.states),
78
+ ...Object.values(descriptor.scopeStates),
79
+ ...Object.values(descriptor.scopeBindings).flatMap(binding => [...bindingStateIds(binding)])
80
+ ])
81
+ }
82
+
83
+ function capitalize(value) {
84
+ return value[0].toUpperCase() + value.slice(1)
85
+ }
@@ -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,21 +165,24 @@ 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
152
188
 
@@ -155,7 +191,12 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
155
191
  const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
156
192
  const [stateElement, setterElement] = node.name.elements
157
193
  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)
194
+ const owner = nearestFunction(node)
195
+ if (owner) {
196
+ const setters = settersByFunction.get(owner) ?? new Map()
197
+ setters.set(setterElement.name.text, stateElement.name.text)
198
+ settersByFunction.set(owner, setters)
199
+ }
159
200
  }
160
201
  }
161
202
  if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node)
@@ -185,7 +226,20 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
185
226
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
186
227
  }
187
228
 
229
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["className", "disabled", "value"].includes(node.name.getText())) {
230
+ const expression = node.initializer.expression
231
+ const setters = settersForNode(node, settersByFunction)
232
+ const usedStates = referencedStateNames(expression, setters)
233
+ const captures = captureNames(expression, expression, setters)
234
+ if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
235
+ usesBehavior = true
236
+ const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
237
+ return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
238
+ }
239
+ }
240
+
188
241
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
242
+ const setters = settersForNode(node, settersByFunction)
189
243
  const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
190
244
  if (event) {
191
245
  usesBehavior = true
@@ -203,6 +257,8 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
203
257
 
204
258
  const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
205
259
  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")))
206
262
  const behaviorImport = factory.createImportDeclaration(
207
263
  undefined,
208
264
  factory.createImportClause(false, undefined, factory.createNamedImports(behaviorImports)),
@@ -212,6 +268,44 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
212
268
  }
213
269
  }
214
270
 
271
+ function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
272
+ const usedStates = referencedStateNames(expression, setters)
273
+ const captures = captureNames(expression, expression, setters)
274
+ const exportName = `binding${reactiveBindings.length}`
275
+ reactiveBindings.push({ exportName, expression, captures, states: usedStates })
276
+ const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
277
+ factory.createStringLiteral(name),
278
+ factory.createIdentifier(name)
279
+ ]))
280
+ const scope = [...captures].map(name => factory.createArrayLiteralExpression([
281
+ factory.createStringLiteral(name),
282
+ factory.createIdentifier(name)
283
+ ]))
284
+ const stateNames = new Set(usedStates)
285
+ const rewriteInitial = node => {
286
+ if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text)) {
287
+ return factory.createPropertyAssignment(node.name, factory.createPropertyAccessExpression(node.name, "value"))
288
+ }
289
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
290
+ return factory.createPropertyAccessExpression(node, "value")
291
+ }
292
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
293
+ return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createIdentifier("__kBindingValue"), undefined, [node.name]))
294
+ }
295
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
296
+ return factory.createCallExpression(factory.createIdentifier("__kBindingValue"), undefined, [node])
297
+ }
298
+ return ts.visitEachChild(node, rewriteInitial, context)
299
+ }
300
+ return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, [
301
+ ts.visitNode(expression, rewriteInitial),
302
+ factory.createStringLiteral(handlerUrl),
303
+ factory.createStringLiteral(exportName),
304
+ factory.createArrayLiteralExpression(states),
305
+ factory.createArrayLiteralExpression(scope)
306
+ ])
307
+ }
308
+
215
309
  function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
216
310
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
217
311
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
@@ -240,14 +334,18 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
240
334
  }
241
335
 
242
336
  function nativeStateNames(expression, setters) {
337
+ return referencedStateNames(expression.body, setters, expression)
338
+ }
339
+
340
+ function referencedStateNames(root, setters, scopeRoot = root) {
243
341
  const stateNames = new Set(setters.values())
244
342
  const used = new Set()
245
343
  const visit = node => {
246
344
  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)
345
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
248
346
  ts.forEachChild(node, visit)
249
347
  }
250
- visit(expression.body)
348
+ visit(root)
251
349
  return used
252
350
  }
253
351
 
@@ -267,6 +365,10 @@ const nativeGlobals = new Set([
267
365
  ])
268
366
 
269
367
  function nativeCaptureNames(expression, setters) {
368
+ return captureNames(expression, expression.body, setters)
369
+ }
370
+
371
+ function captureNames(declarationRoot, referenceRoot, setters) {
270
372
  const local = new Set()
271
373
  const collectDeclarations = node => {
272
374
  if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
@@ -274,7 +376,7 @@ function nativeCaptureNames(expression, setters) {
274
376
  if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
275
377
  ts.forEachChild(node, collectDeclarations)
276
378
  }
277
- collectDeclarations(expression)
379
+ collectDeclarations(declarationRoot)
278
380
 
279
381
  const stateNames = new Set(setters.values())
280
382
  const captures = new Set()
@@ -285,7 +387,7 @@ function nativeCaptureNames(expression, setters) {
285
387
  }
286
388
  ts.forEachChild(node, visit)
287
389
  }
288
- visit(expression.body)
390
+ visit(referenceRoot)
289
391
  return captures
290
392
  }
291
393
 
@@ -308,6 +410,30 @@ function isReferenceIdentifier(node) {
308
410
  return true
309
411
  }
310
412
 
413
+ function nearestFunction(node) {
414
+ for (let current = node.parent; current; current = current.parent) {
415
+ if (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) return current
416
+ }
417
+ return undefined
418
+ }
419
+
420
+ function isShadowedByParameter(node, scopeRoot) {
421
+ for (let current = node.parent; current; current = current.parent) {
422
+ if ((ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
423
+ if (current === scopeRoot) break
424
+ }
425
+ return false
426
+ }
427
+
428
+ function settersForNode(node, settersByFunction) {
429
+ for (let current = node.parent; current; current = current.parent) {
430
+ if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
431
+ const setters = settersByFunction.get(current)
432
+ if (setters) return setters
433
+ }
434
+ return new Map()
435
+ }
436
+
311
437
  function printNativeHandler({ exportName, expression, captures, setters }) {
312
438
  const factory = ts.factory
313
439
  const stateNames = new Set(setters.values())
@@ -320,7 +446,7 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
320
446
  [factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
321
447
  )
322
448
  }
323
- if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node)) {
449
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
324
450
  return factory.createCallExpression(
325
451
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
326
452
  undefined,
@@ -359,6 +485,54 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
359
485
  }
360
486
  }
361
487
 
488
+ function printReactiveBinding({ exportName, expression, captures, states }) {
489
+ const factory = ts.factory
490
+ const transformer = context => root => {
491
+ const visitor = node => {
492
+ if (ts.isShorthandPropertyAssignment(node) && states.has(node.name.text)) {
493
+ return factory.createPropertyAssignment(
494
+ node.name,
495
+ factory.createCallExpression(
496
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
497
+ undefined,
498
+ [factory.createStringLiteral(node.name.text)]
499
+ )
500
+ )
501
+ }
502
+ if (ts.isIdentifier(node) && states.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
503
+ return factory.createCallExpression(
504
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
505
+ undefined,
506
+ [factory.createStringLiteral(node.text)]
507
+ )
508
+ }
509
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
510
+ return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
511
+ }
512
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
513
+ return scopeRead(factory, node.text)
514
+ }
515
+ return ts.visitEachChild(node, visitor, context)
516
+ }
517
+ return ts.visitNode(root, visitor)
518
+ }
519
+ const transformed = ts.transform(expression, [transformer])
520
+ try {
521
+ const declaration = factory.createFunctionDeclaration(
522
+ [factory.createModifier(ts.SyntaxKind.ExportKeyword)],
523
+ undefined,
524
+ exportName,
525
+ undefined,
526
+ [factory.createParameterDeclaration(undefined, undefined, "__k")],
527
+ undefined,
528
+ factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
529
+ )
530
+ return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
531
+ } finally {
532
+ transformed.dispose()
533
+ }
534
+ }
535
+
362
536
  function scopeRead(factory, name) {
363
537
  return factory.createCallExpression(
364
538
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
@@ -4,6 +4,8 @@ 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
7
9
 
8
10
  export function renderPage(
9
11
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -28,6 +30,8 @@ export function renderPage(
28
30
  ): Promise<{
29
31
  html: string
30
32
  hasBehaviors: boolean
33
+ hasBindings: boolean
34
+ hasStateSeed: boolean
31
35
  plan: {
32
36
  states: Array<{ id: string; name: string; initialValue: unknown }>
33
37
  events: Array<{
@@ -35,5 +39,15 @@ export function renderPage(
35
39
  commands?: Array<[string, string, unknown]>
36
40
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
37
41
  }>
42
+ bindings: Array<{
43
+ target: "class" | "disabled" | "value"
44
+ state?: string
45
+ module?: string
46
+ handler?: string
47
+ states?: Record<string, string>
48
+ scope?: Record<string, unknown>
49
+ scopeStates?: Record<string, string>
50
+ scopeBindings?: Record<string, unknown>
51
+ }>
38
52
  }
39
53
  }>
@@ -1,6 +1,7 @@
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")
4
5
 
5
6
  let renderContext
6
7
 
@@ -51,6 +52,38 @@ export function nativeBehavior(module, handler, states, scope) {
51
52
  }
52
53
  }
53
54
 
55
+ export function binding(value, module, handler, states, scope) {
56
+ const scopeStates = {}
57
+ const serializedScope = {}
58
+ const scopeBindings = {}
59
+ for (const [name, entry] of scope) {
60
+ if (entry?.[signalMarker]) scopeStates[name] = entry.id
61
+ else if (entry?.[bindingMarker]) scopeBindings[name] = bindingDescriptor(entry)
62
+ else serializedScope[name] = serializeCapture(name, entry, new Set())
63
+ }
64
+ return {
65
+ [bindingMarker]: true,
66
+ value,
67
+ module,
68
+ handler,
69
+ states: Object.fromEntries(states.map(([name, signal]) => {
70
+ if (!signal?.[signalMarker]) throw new Error("A reactive binding must target framework state")
71
+ return [name, signal.id]
72
+ })),
73
+ scope: serializedScope,
74
+ scopeStates,
75
+ scopeBindings
76
+ }
77
+ }
78
+
79
+ export function bindingValue(value) {
80
+ return value?.[signalMarker] || value?.[bindingMarker] ? value.value : value
81
+ }
82
+
83
+ function bindingDescriptor(value) {
84
+ return { module: value.module, handler: value.handler, states: value.states, scope: value.scope, scopeStates: value.scopeStates, scopeBindings: value.scopeBindings }
85
+ }
86
+
54
87
  function serializeCapture(name, value, seen) {
55
88
  if (value === null || typeof value === "string" || typeof value === "boolean") return value
56
89
  if (typeof value === "number") {
@@ -83,7 +116,7 @@ function serializeCapture(name, value, seen) {
83
116
  }
84
117
 
85
118
  export async function renderPage(component, metadata = {}) {
86
- renderContext = { nextState: 0, states: {}, events: [], hasBehaviors: false, hasNativeBehaviors: false }
119
+ renderContext = { nextState: 0, states: {}, textStates: new Set(), events: [], bindings: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false }
87
120
 
88
121
  try {
89
122
  const body = await renderNode({ type: component, props: {} })
@@ -98,13 +131,25 @@ export async function renderPage(component, metadata = {}) {
98
131
  const nativeRuntime = renderContext.hasNativeBehaviors
99
132
  ? '<script type="module" src="/assets/kudzu-native.js"></script>'
100
133
  : ""
134
+ const bindingRuntime = renderContext.hasBindings
135
+ ? '<script type="module" src="/assets/kudzu-binding.js"></script>'
136
+ : ""
137
+ const initialState = renderContext.hasBehaviors
138
+ ? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
139
+ : []
140
+ const state = initialState.length
141
+ ? ` data-k-state="${escapeAttribute(JSON.stringify(initialState))}"`
142
+ : ""
101
143
 
102
144
  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`,
145
+ 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${state}>\n${body}\n${runtime}\n${bindingRuntime}\n${nativeRuntime}\n</body>\n</html>\n`,
104
146
  hasBehaviors: renderContext.hasBehaviors,
147
+ hasBindings: renderContext.hasBindings,
148
+ hasStateSeed: initialState.length > 0,
105
149
  plan: {
106
150
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
107
- events: renderContext.events
151
+ events: renderContext.events,
152
+ bindings: renderContext.bindings
108
153
  }
109
154
  }
110
155
  } finally {
@@ -153,6 +198,7 @@ async function renderNode(node) {
153
198
  return html
154
199
  }
155
200
  if (node?.[signalMarker]) {
201
+ renderContext.textStates.add(node.id)
156
202
  return `<span data-k-text="${node.id}" data-k-value="${escapeAttribute(JSON.stringify(node.value))}">${escapeHtml(node.value)}</span>`
157
203
  }
158
204
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
@@ -171,7 +217,7 @@ async function renderNode(node) {
171
217
  let attributes = ""
172
218
 
173
219
  for (const [rawName, value] of Object.entries(props)) {
174
- if (rawName === "children" || rawName === "key" || value == null || value === false) continue
220
+ if (rawName === "children" || rawName === "key") continue
175
221
 
176
222
  if (/^on[A-Z]/.test(rawName)) {
177
223
  const event = rawName.slice(2).toLowerCase()
@@ -192,6 +238,26 @@ async function renderNode(node) {
192
238
  }
193
239
 
194
240
  const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
241
+ const target = name === "class" || name === "disabled" || name === "value" ? name : undefined
242
+ if (target && (value?.[signalMarker] || value?.[bindingMarker])) {
243
+ const initialValue = value[signalMarker] ? value.value : value.value
244
+ const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
245
+ if (!reactive) {
246
+ attributes += renderAttribute(name, initialValue)
247
+ continue
248
+ }
249
+ const descriptor = value[signalMarker]
250
+ ? { state: value.id }
251
+ : bindingDescriptor(value)
252
+ attributes += renderAttribute(name, initialValue)
253
+ attributes += ` data-k-bind-${target}="${escapeAttribute(JSON.stringify(descriptor))}"`
254
+ renderContext.bindings.push({ target, ...descriptor })
255
+ renderContext.hasBehaviors = true
256
+ renderContext.hasBindings = true
257
+ continue
258
+ }
259
+
260
+ if (value == null || value === false) continue
195
261
  if (value === true) {
196
262
  attributes += ` ${name}`
197
263
  } else if (name === "style" && typeof value === "object") {
@@ -207,6 +273,13 @@ async function renderNode(node) {
207
273
  return `<${tag}${attributes}>${await renderNode(props.children)}</${tag}>`
208
274
  }
209
275
 
276
+ function renderAttribute(name, value) {
277
+ if (name === "disabled") return value ? " disabled" : ""
278
+ if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
279
+ if (value == null || value === false) return ""
280
+ return ` ${name}="${escapeAttribute(value)}"`
281
+ }
282
+
210
283
  function escapeHtml(value) {
211
284
  return String(value)
212
285
  .replaceAll("&", "&amp;")
@@ -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) {
@@ -17,18 +17,16 @@ export function applyCommands(state, commands, commit, log = console.log) {
17
17
  export const browserState = new Map()
18
18
 
19
19
  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
- }
20
+ for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
24
21
  }
25
22
 
26
23
  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
- }
24
+ const initialState = document.body.dataset.kState
25
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
26
+ for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
30
27
 
31
- for (const eventName of ["click", "input", "change"]) {
28
+ const eventNames = ["click", "input", "change"]
29
+ for (const eventName of eventNames) {
32
30
  document.addEventListener(eventName, event => {
33
31
  const target = event.target.closest(`[data-k-on-${eventName}]`)
34
32
  if (!target) return
@@ -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,47 @@
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
+ if (typeof document !== "undefined") {
30
+ const initialState = document.body.dataset.kState
31
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
32
+ for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
33
+
34
+ const eventNames = ["click", "input", "change"]
35
+ for (const eventName of eventNames) {
36
+ document.addEventListener(eventName, event => {
37
+ const target = event.target.closest(`[data-k-on-${eventName}]`)
38
+ if (!target) return
39
+ const commands = target.dataset[`kOn${capitalize(eventName)}`]
40
+ applyCommands(browserState, JSON.parse(commands), commitDom)
41
+ })
42
+ }
43
+ }
44
+
45
+ function capitalize(value) {
46
+ return value[0].toUpperCase() + value.slice(1)
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.3",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "bin": {
34
- "kudzu": "./bin/kudzu.mjs"
34
+ "kudzu": "bin/kudzu.mjs"
35
35
  },
36
36
  "exports": {
37
37
  ".": {
@@ -48,9 +48,14 @@
48
48
  "dev": "node ./bin/kudzu.mjs dev",
49
49
  "check": "tsc --noEmit && node ./bin/kudzu.mjs build",
50
50
  "test": "node --test",
51
- "prepublishOnly": "npm run check && npm test"
51
+ "prepublishOnly": "npm run check && npm test",
52
+ "deploy": "wrangler deploy",
53
+ "preview": "wrangler dev"
52
54
  },
53
55
  "dependencies": {
54
56
  "typescript": "^5.9.2"
57
+ },
58
+ "devDependencies": {
59
+ "wrangler": "^4.112.0"
55
60
  }
56
61
  }