@kudzujs/core 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kudzujs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/kudzujs/kudzu/main/public/icon-128.png" width="96" alt="Kudzu logo">
3
+ </p>
4
+
5
+ # Kudzu
6
+
7
+ HTML-first TSX framework with synchronous state semantics and no virtual DOM.
8
+
9
+ Brand assets, favicons, manifest icons, and the 1200×630 social preview live in [`public/`](./public).
10
+
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
+
13
+ > Experimental `0.1.x`: the compiler API and supported TSX surface may change.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @kudzujs/core
19
+ ```
20
+
21
+ Add scripts:
22
+
23
+ ```json
24
+ {
25
+ "scripts": {
26
+ "dev": "kudzu dev",
27
+ "build": "kudzu build"
28
+ }
29
+ }
30
+ ```
31
+
32
+ Configure TypeScript:
33
+
34
+ ```json
35
+ {
36
+ "compilerOptions": {
37
+ "target": "ES2022",
38
+ "module": "ESNext",
39
+ "moduleResolution": "Bundler",
40
+ "jsx": "react-jsx",
41
+ "jsxImportSource": "@kudzujs/core",
42
+ "strict": true
43
+ }
44
+ }
45
+ ```
46
+
47
+ Create `src/pages/index.tsx`:
48
+
49
+ ```tsx
50
+ import { useState } from "@kudzujs/core"
51
+
52
+ export default function HomePage() {
53
+ const [count, setCount] = useState(0)
54
+
55
+ function increaseTwice() {
56
+ setCount(count + 1)
57
+ setCount(count + 1)
58
+ }
59
+
60
+ return <button onClick={increaseTwice}>Count: {count}</button>
61
+ }
62
+ ```
63
+
64
+ ```bash
65
+ npm run dev
66
+ ```
67
+
68
+ Pages live in `src/pages`; `index.tsx` maps to `/`. Production output is written to `dist/`.
69
+
70
+ ## State Semantics
71
+
72
+ Kudzu intentionally differs from React's state snapshot behavior:
73
+
74
+ ```tsx
75
+ function increaseTwice() {
76
+ setCount(count + 1)
77
+ setCount(count + 1)
78
+ }
79
+ ```
80
+
81
+ - A setter updates logical state immediately.
82
+ - The next statement reads the latest logical state.
83
+ - Setters execute in source order.
84
+ - DOM writes batch at synchronous-turn boundaries.
85
+ - The same input produces the same execution plan.
86
+
87
+ The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
88
+
89
+ ## Normal JavaScript
90
+
91
+ 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.
92
+
93
+ ```tsx
94
+ async function load() {
95
+ setStatus("loading")
96
+
97
+ try {
98
+ const response = await fetch("/api/status")
99
+ const result = await response.json()
100
+ setStatus(result.status)
101
+ } catch {
102
+ setStatus("failed")
103
+ }
104
+ }
105
+ ```
106
+
107
+ Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
108
+
109
+ ## Rendering
110
+
111
+ ```text
112
+ TSX
113
+ ├─ static component → HTML
114
+ ├─ ordered state setter → behavior command
115
+ └─ normal JS handler → route handler ESM
116
+ ```
117
+
118
+ - Static pages ship no client JavaScript.
119
+ - Interactive pages receive only the runtime capabilities they use.
120
+ - Components are authoring units; no component tree is retained in the browser.
121
+ - There is no VDOM, hydration pass, router, or client application runtime.
122
+
123
+ Example Nginx configuration:
124
+
125
+ ```nginx
126
+ location / {
127
+ try_files $uri $uri/ $uri/index.html =404;
128
+ }
129
+ ```
130
+
131
+ ## Current Scope
132
+
133
+ Supported:
134
+
135
+ - Function components, props, children, fragments, and TSX
136
+ - File-based static routes
137
+ - Build-time async components
138
+ - Primitive `useState` bindings
139
+ - Synchronous and async event handlers
140
+ - Serializable component-local captures
141
+ - Direct text DOM patches
142
+
143
+ Not implemented yet:
144
+
145
+ - Reactive attributes, classes, and controlled inputs
146
+ - Conditional DOM patches and keyed lists
147
+ - Server actions and request-time SSR
148
+ - Imported client helpers and React package islands
149
+ - HMR and framework DevTools
150
+
151
+ ## Benchmarks
152
+
153
+ Measurements below were produced on the same machine from production builds. They compare build artifacts, not ecosystem maturity or full application performance.
154
+
155
+ ### Interactive Counter
156
+
157
+ Same counter with initial value `7` and increment/decrement buttons:
158
+
159
+ | Framework | Initial content | Initial JS gzip | Total output | Clean build |
160
+ |---|---:|---:|---:|---:|
161
+ | Kudzu | Yes | **581 B** | 1.7 KB | **371 ms** |
162
+ | Astro | Yes | 158 B | **365 B** | 953 ms |
163
+ | 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 |
167
+
168
+ 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
+
170
+ ### Static Journal Page
171
+
172
+ Same content and CSS across every fixture; build is the median of three clean runs:
173
+
174
+ | Framework | Initial content | Initial JS gzip | Total output | Clean build |
175
+ |---|---:|---:|---:|---:|
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.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ npm install
190
+ npm run check
191
+ npm test
192
+ ```
193
+
194
+ License: MIT
package/bin/kudzu.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { build, dev } from "../framework/build.mjs"
4
+
5
+ const command = process.argv[2] ?? "dev"
6
+
7
+ if (command === "build") {
8
+ await build()
9
+ } else if (command === "dev") {
10
+ await dev()
11
+ } else {
12
+ console.error(`Unknown command: ${command}\nUse: kudzu <build|dev>`)
13
+ process.exitCode = 1
14
+ }
@@ -0,0 +1,12 @@
1
+ # Framework Internals
2
+
3
+ - `build.mjs`: TSX compilation, file routes, behavior extraction, static HTML output, and the development server.
4
+ - `core.mjs`: server-side JSX rendering, state slots, behavior metadata, and serializable capture validation.
5
+ - `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
6
+ - `runtime.js`: command-only browser runtime for direct state-to-text patches.
7
+ - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
8
+ - `*.d.ts`: public TypeScript and JSX declarations.
9
+
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/`.
11
+
12
+ Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -0,0 +1,446 @@
1
+ import { createServer } from "node:http"
2
+ import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
3
+ import { extname, join, relative, resolve, sep } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import ts from "typescript"
6
+ import { renderPage } from "./core.mjs"
7
+
8
+ const root = process.cwd()
9
+ const sourceDirectory = join(root, "src")
10
+ const pagesDirectory = join(sourceDirectory, "pages")
11
+ const workDirectory = join(root, ".kudzu")
12
+ const outputDirectory = join(root, "dist")
13
+
14
+ export async function build({ quiet = false } = {}) {
15
+ await rm(workDirectory, { recursive: true, force: true })
16
+ await rm(outputDirectory, { recursive: true, force: true })
17
+ await mkdir(workDirectory, { recursive: true })
18
+ await mkdir(outputDirectory, { recursive: true })
19
+
20
+ const sourceFiles = (await walk(sourceDirectory)).filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
21
+ if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
22
+
23
+ const handlerModules = []
24
+ for (const file of sourceFiles) {
25
+ const handlerModule = await compile(file)
26
+ if (handlerModule) handlerModules.push(handlerModule)
27
+ }
28
+
29
+ const pageFiles = sourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
30
+ if (!pageFiles.length) throw new Error("No pages found in src/pages/")
31
+
32
+ let behaviorCount = 0
33
+ const plans = []
34
+ const hasStyles = await exists(join(sourceDirectory, "style.css"))
35
+
36
+ for (const pageFile of pageFiles) {
37
+ const compiledFile = compiledPath(pageFile)
38
+ const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
39
+ if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
40
+
41
+ const result = await renderPage(module.default, {
42
+ ...(module.metadata ?? {}),
43
+ styles: hasStyles
44
+ })
45
+ const route = routeFromPage(pageFile)
46
+ const routeDirectory = join(outputDirectory, route)
47
+ await mkdir(routeDirectory, { recursive: true })
48
+ await writeFile(join(routeDirectory, "index.html"), result.html)
49
+ plans.push({ route: `/${route}`, ...result.plan })
50
+ if (result.hasBehaviors) behaviorCount++
51
+ }
52
+
53
+ const assetsDirectory = join(outputDirectory, "assets")
54
+ 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
+ }
60
+ for (const handlerModule of handlerModules) {
61
+ const output = join(assetsDirectory, handlerModule.path)
62
+ await mkdir(resolve(output, ".."), { recursive: true })
63
+ await writeFile(output, handlerModule.code)
64
+ }
65
+ await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
66
+ if (hasStyles) await cp(join(sourceDirectory, "style.css"), join(assetsDirectory, "style.css"))
67
+ if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
68
+
69
+ if (!quiet) console.log(`Built ${pageFiles.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
70
+ }
71
+
72
+ export async function dev() {
73
+ await build()
74
+
75
+ const server = createServer(async (request, response) => {
76
+ try {
77
+ const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname)
78
+ const relativePath = pathname.replace(/^\/+/, "")
79
+ let file = resolve(outputDirectory, relativePath)
80
+ if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
81
+
82
+ if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
83
+ if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
84
+ const content = await readFile(file)
85
+ response.writeHead(200, { "content-type": contentType(file) })
86
+ response.end(content)
87
+ } catch {
88
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
89
+ response.end("Not found")
90
+ }
91
+ })
92
+
93
+ server.listen(3000, () => console.log("Kudzu dev server: http://localhost:3000"))
94
+
95
+ let timer
96
+ const watcher = watch(sourceDirectory, { recursive: true })
97
+ for await (const event of watcher) {
98
+ clearTimeout(timer)
99
+ timer = setTimeout(async () => {
100
+ try {
101
+ await build({ quiet: true })
102
+ console.log(`Rebuilt after ${event.filename ?? "source change"}`)
103
+ } catch (error) {
104
+ console.error(error)
105
+ }
106
+ }, 80)
107
+ }
108
+ }
109
+
110
+ async function compile(file) {
111
+ const source = await readFile(file, "utf8")
112
+ const nativeHandlers = []
113
+ const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
114
+ const result = ts.transpileModule(source, {
115
+ fileName: file,
116
+ compilerOptions: {
117
+ target: ts.ScriptTarget.ES2022,
118
+ module: ts.ModuleKind.ESNext,
119
+ jsx: ts.JsxEmit.ReactJSX,
120
+ jsxImportSource: "@kudzujs/core"
121
+ },
122
+ transformers: { before: [createKudzuTransformer(nativeHandlers, `/assets/${handlerPath}`)] },
123
+ reportDiagnostics: true
124
+ })
125
+
126
+ const errors = result.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
127
+ if (errors.length) {
128
+ throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
129
+ }
130
+
131
+ const output = compiledPath(file)
132
+ await mkdir(resolve(output, ".."), { recursive: true })
133
+ await writeFile(output, result.outputText)
134
+
135
+ if (!nativeHandlers.length) return undefined
136
+ const moduleSource = nativeHandlers.map(handler => printNativeHandler(handler)).join("\n")
137
+ const moduleResult = ts.transpileModule(moduleSource, {
138
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
139
+ reportDiagnostics: true
140
+ })
141
+ const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
142
+ if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
143
+ return { path: handlerPath, code: moduleResult.outputText }
144
+ }
145
+
146
+ function createKudzuTransformer(nativeHandlers, handlerUrl) {
147
+ return context => sourceFile => {
148
+ const factory = context.factory
149
+ const setters = new Map()
150
+ const functions = new Map()
151
+ let usesBehavior = false
152
+
153
+ const collect = node => {
154
+ if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
155
+ const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
156
+ const [stateElement, setterElement] = node.name.elements
157
+ 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)
159
+ }
160
+ }
161
+ if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node)
162
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
163
+ functions.set(node.name.text, node.initializer)
164
+ }
165
+ ts.forEachChild(node, collect)
166
+ }
167
+ collect(sourceFile)
168
+
169
+ const visitor = node => {
170
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
171
+ return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
172
+ }
173
+
174
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
175
+ return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(modulePath(node.moduleSpecifier.text)), node.attributes)
176
+ }
177
+
178
+ if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1) {
179
+ const stateElement = node.name.elements[0]
180
+ if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
181
+ const initializer = factory.updateCallExpression(node.initializer, node.initializer.expression, node.initializer.typeArguments, [
182
+ ...node.initializer.arguments,
183
+ factory.createStringLiteral(stateElement.name.text)
184
+ ])
185
+ return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
186
+ }
187
+
188
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
189
+ const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
190
+ if (event) {
191
+ usesBehavior = true
192
+ return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
193
+ }
194
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
195
+ throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.getText()} must reference a function`)
196
+ }
197
+
198
+ return ts.visitEachChild(node, visitor, context)
199
+ }
200
+
201
+ const transformed = ts.visitNode(sourceFile, visitor)
202
+ if (!usesBehavior) return transformed
203
+
204
+ const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
205
+ if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
206
+ const behaviorImport = factory.createImportDeclaration(
207
+ undefined,
208
+ factory.createImportClause(false, undefined, factory.createNamedImports(behaviorImports)),
209
+ factory.createStringLiteral("@kudzujs/core")
210
+ )
211
+ return factory.updateSourceFile(transformed, [behaviorImport, ...transformed.statements])
212
+ }
213
+ }
214
+
215
+ function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
216
+ if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
217
+ if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
218
+
219
+ const optimized = compileOptimizedEvent(expression, setters, factory)
220
+ if (optimized) return optimized
221
+
222
+ const captures = nativeCaptureNames(expression, setters)
223
+ const usedStates = nativeStateNames(expression, setters)
224
+ const exportName = `handler${nativeHandlers.length}`
225
+ nativeHandlers.push({ exportName, expression, captures, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
226
+ const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
227
+ factory.createStringLiteral(name),
228
+ factory.createIdentifier(name)
229
+ ]))
230
+ const scope = [...captures].map(name => factory.createArrayLiteralExpression([
231
+ factory.createStringLiteral(name),
232
+ factory.createIdentifier(name)
233
+ ]))
234
+ return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
235
+ factory.createStringLiteral(handlerUrl),
236
+ factory.createStringLiteral(exportName),
237
+ factory.createArrayLiteralExpression(states),
238
+ factory.createArrayLiteralExpression(scope)
239
+ ])
240
+ }
241
+
242
+ function nativeStateNames(expression, setters) {
243
+ const stateNames = new Set(setters.values())
244
+ const used = new Set()
245
+ const visit = node => {
246
+ 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)
248
+ ts.forEachChild(node, visit)
249
+ }
250
+ visit(expression.body)
251
+ return used
252
+ }
253
+
254
+ function compileOptimizedEvent(expression, setters, factory) {
255
+ const statements = ts.isBlock(expression.body) ? expression.body.statements : [factory.createExpressionStatement(expression.body)]
256
+ const commands = statements.map(statement => {
257
+ if (!ts.isExpressionStatement(statement)) return undefined
258
+ return compileEventCommand(statement.expression, setters, factory)
259
+ })
260
+ if (!commands.length || commands.some(command => !command)) return undefined
261
+
262
+ return factory.createCallExpression(factory.createIdentifier("__kBehavior"), undefined, [factory.createArrayLiteralExpression(commands)])
263
+ }
264
+
265
+ const nativeGlobals = new Set([
266
+ "Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
267
+ ])
268
+
269
+ function nativeCaptureNames(expression, setters) {
270
+ const local = new Set()
271
+ const collectDeclarations = node => {
272
+ if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
273
+ if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
274
+ if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
275
+ ts.forEachChild(node, collectDeclarations)
276
+ }
277
+ collectDeclarations(expression)
278
+
279
+ const stateNames = new Set(setters.values())
280
+ const captures = new Set()
281
+ const visit = node => {
282
+ if (ts.isTypeNode(node)) return
283
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !local.has(node.text) && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) {
284
+ captures.add(node.text)
285
+ }
286
+ ts.forEachChild(node, visit)
287
+ }
288
+ visit(expression.body)
289
+ return captures
290
+ }
291
+
292
+ function bindingNames(name) {
293
+ if (ts.isIdentifier(name)) return [name.text]
294
+ return name.elements.flatMap(element => ts.isBindingElement(element) ? bindingNames(element.name) : [])
295
+ }
296
+
297
+ function isReferenceIdentifier(node) {
298
+ const parent = node.parent
299
+ if (!parent) return true
300
+ if ((ts.isPropertyAccessExpression(parent) && parent.name === node) ||
301
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
302
+ (ts.isMethodDeclaration(parent) && parent.name === node) ||
303
+ (ts.isVariableDeclaration(parent) && parent.name === node) ||
304
+ (ts.isParameter(parent) && parent.name === node) ||
305
+ (ts.isFunctionDeclaration(parent) && parent.name === node) ||
306
+ (ts.isBindingElement(parent) && parent.name === node) ||
307
+ ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
308
+ return true
309
+ }
310
+
311
+ function printNativeHandler({ exportName, expression, captures, setters }) {
312
+ const factory = ts.factory
313
+ const stateNames = new Set(setters.values())
314
+ const transformer = context => root => {
315
+ const visitor = node => {
316
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) {
317
+ return factory.createCallExpression(
318
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"),
319
+ undefined,
320
+ [factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
321
+ )
322
+ }
323
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node)) {
324
+ return factory.createCallExpression(
325
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
326
+ undefined,
327
+ [factory.createStringLiteral(node.text)]
328
+ )
329
+ }
330
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
331
+ return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
332
+ }
333
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
334
+ return scopeRead(factory, node.text)
335
+ }
336
+ return ts.visitEachChild(node, visitor, context)
337
+ }
338
+ return ts.visitNode(root, visitor)
339
+ }
340
+ const transformed = ts.transform(expression.body, [transformer])
341
+ try {
342
+ const body = ts.isBlock(expression.body)
343
+ ? transformed.transformed[0]
344
+ : factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
345
+ const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
346
+ if (expression.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) modifiers.push(factory.createModifier(ts.SyntaxKind.AsyncKeyword))
347
+ const declaration = factory.createFunctionDeclaration(
348
+ modifiers,
349
+ expression.asteriskToken,
350
+ exportName,
351
+ undefined,
352
+ [factory.createParameterDeclaration(undefined, undefined, "__k"), ...expression.parameters],
353
+ undefined,
354
+ body
355
+ )
356
+ return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
357
+ } finally {
358
+ transformed.dispose()
359
+ }
360
+ }
361
+
362
+ function scopeRead(factory, name) {
363
+ return factory.createCallExpression(
364
+ factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
365
+ undefined,
366
+ [factory.createStringLiteral(name)]
367
+ )
368
+ }
369
+
370
+ function compileEventCommand(expression, setters, factory) {
371
+ if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && ts.isIdentifier(expression.expression.expression) && expression.expression.expression.text === "console" && expression.expression.name.text === "log" && expression.arguments.length === 2 && ts.isStringLiteral(expression.arguments[0]) && ts.isIdentifier(expression.arguments[1]) && [...setters.values()].includes(expression.arguments[1].text)) {
372
+ return command(factory, "log", expression.arguments[1], expression.arguments[0])
373
+ }
374
+
375
+ if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression) || expression.arguments.length !== 1) return undefined
376
+ const stateName = setters.get(expression.expression.text)
377
+ if (!stateName) return undefined
378
+
379
+ const state = factory.createIdentifier(stateName)
380
+ const value = expression.arguments[0]
381
+ if (ts.isBinaryExpression(value) && ts.isIdentifier(value.left) && value.left.text === stateName && ts.isNumericLiteral(value.right)) {
382
+ if (value.operatorToken.kind !== ts.SyntaxKind.PlusToken && value.operatorToken.kind !== ts.SyntaxKind.MinusToken) return undefined
383
+ return command(factory, "add", state, numericExpression(factory, Number(value.right.text), value.operatorToken.kind === ts.SyntaxKind.MinusToken))
384
+ }
385
+ if (ts.isArrowFunction(value) && value.parameters.length === 1 && ts.isIdentifier(value.parameters[0].name) && ts.isBinaryExpression(value.body) && ts.isIdentifier(value.body.left) && value.body.left.text === value.parameters[0].name.text && ts.isNumericLiteral(value.body.right)) {
386
+ if (value.body.operatorToken.kind !== ts.SyntaxKind.PlusToken && value.body.operatorToken.kind !== ts.SyntaxKind.MinusToken) return undefined
387
+ return command(factory, "add", state, numericExpression(factory, Number(value.body.right.text), value.body.operatorToken.kind === ts.SyntaxKind.MinusToken))
388
+ }
389
+ if (isPrimitiveLiteral(value)) return command(factory, "set", state, value)
390
+ return undefined
391
+ }
392
+
393
+ function command(factory, operation, state, value) {
394
+ return factory.createArrayLiteralExpression([factory.createStringLiteral(operation), state, value])
395
+ }
396
+
397
+ function isPrimitiveLiteral(node) {
398
+ return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword
399
+ }
400
+
401
+ function numericExpression(factory, value, negative) {
402
+ const literal = factory.createNumericLiteral(value)
403
+ return negative ? factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, literal) : literal
404
+ }
405
+
406
+ function modulePath(value) {
407
+ if (/\.(?:ts|tsx|js|jsx)$/.test(value)) return value.replace(/\.(?:ts|tsx|js|jsx)$/, ".mjs")
408
+ return `${value}.mjs`
409
+ }
410
+
411
+ function compiledPath(file) {
412
+ return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
413
+ }
414
+
415
+ function routeFromPage(file) {
416
+ const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
417
+ return page === "index" ? "" : page.replace(/\/index$/, "")
418
+ }
419
+
420
+ async function walk(directory) {
421
+ const entries = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))
422
+ const files = await Promise.all(entries.map(entry => {
423
+ const path = join(directory, entry.name)
424
+ return entry.isDirectory() ? walk(path) : path
425
+ }))
426
+ return files.flat()
427
+ }
428
+
429
+ async function exists(path) {
430
+ try {
431
+ await stat(path)
432
+ return true
433
+ } catch {
434
+ return false
435
+ }
436
+ }
437
+
438
+ function contentType(file) {
439
+ return {
440
+ ".html": "text/html; charset=utf-8",
441
+ ".css": "text/css; charset=utf-8",
442
+ ".js": "text/javascript; charset=utf-8",
443
+ ".json": "application/json; charset=utf-8",
444
+ ".svg": "image/svg+xml"
445
+ }[extname(file)] ?? "application/octet-stream"
446
+ }
@@ -0,0 +1,39 @@
1
+ export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
+
3
+ export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
+
5
+ export function behavior(commands: Array<["add" | "set" | "log", unknown, unknown]>): unknown
6
+ export function nativeBehavior(module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
7
+
8
+ export function renderPage(
9
+ component: (props: Record<string, never>) => unknown | Promise<unknown>,
10
+ metadata?: {
11
+ title?: string
12
+ description?: string
13
+ lang?: string
14
+ locale?: string
15
+ siteName?: string
16
+ type?: string
17
+ url?: string
18
+ image?: string
19
+ imageAlt?: string
20
+ twitterCard?: string
21
+ twitterImage?: string
22
+ themeColor?: string
23
+ icon?: string
24
+ appleTouchIcon?: string
25
+ manifest?: string
26
+ styles?: boolean
27
+ }
28
+ ): Promise<{
29
+ html: string
30
+ hasBehaviors: boolean
31
+ plan: {
32
+ states: Array<{ id: string; name: string; initialValue: unknown }>
33
+ events: Array<{
34
+ event: string
35
+ commands?: Array<[string, string, unknown]>
36
+ native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
37
+ }>
38
+ }
39
+ }>
@@ -0,0 +1,224 @@
1
+ const signalMarker = Symbol("kudzu.signal")
2
+ const behaviorMarker = Symbol("kudzu.behavior")
3
+ const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
4
+
5
+ let renderContext
6
+
7
+ export function useState(initialValue, name) {
8
+ if (!renderContext) {
9
+ throw new Error("useState() can only run while rendering a Kudzu component")
10
+ }
11
+
12
+ const id = `s${renderContext.nextState++}`
13
+ const signal = {
14
+ [signalMarker]: true,
15
+ id,
16
+ value: initialValue,
17
+ valueOf() {
18
+ return this.value
19
+ },
20
+ toString() {
21
+ return String(this.value)
22
+ }
23
+ }
24
+
25
+ renderContext.states[id] = { name: name ?? id, initialValue }
26
+ return [signal, () => {
27
+ throw new Error("State setters are compiled into ordered browser behaviors")
28
+ }]
29
+ }
30
+
31
+ export function behavior(commands) {
32
+ return {
33
+ [behaviorMarker]: true,
34
+ commands: commands.map(([operation, signal, value]) => {
35
+ if (!signal?.[signalMarker]) throw new Error("A compiled behavior must target framework state")
36
+ return [operation, signal.id, value]
37
+ })
38
+ }
39
+ }
40
+
41
+ export function nativeBehavior(module, handler, states, scope) {
42
+ return {
43
+ [nativeBehaviorMarker]: true,
44
+ module,
45
+ handler,
46
+ states: Object.fromEntries(states.map(([name, signal]) => {
47
+ if (!signal?.[signalMarker]) throw new Error("A native behavior must target framework state")
48
+ return [name, signal.id]
49
+ })),
50
+ scope: Object.fromEntries(scope.map(([name, value]) => [name, serializeCapture(name, value, new Set())]))
51
+ }
52
+ }
53
+
54
+ function serializeCapture(name, value, seen) {
55
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value
56
+ if (typeof value === "number") {
57
+ return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
58
+ }
59
+ if (value === undefined) return { type: "undefined" }
60
+ if (typeof value !== "object") throw new Error(`Native capture "${name}" is not serializable: ${typeof value}`)
61
+ if (seen.has(value)) throw new Error(`Native capture "${name}" is not serializable: cycle`)
62
+
63
+ seen.add(value)
64
+ try {
65
+ if (Array.isArray(value)) {
66
+ return { type: "array", value: Array.from(value, entry => serializeCapture(name, entry, seen)) }
67
+ }
68
+ const prototype = Object.getPrototypeOf(value)
69
+ if (prototype !== Object.prototype && prototype !== null) {
70
+ throw new Error(`Native capture "${name}" is not serializable: ${value.constructor?.name ?? "non-plain object"}`)
71
+ }
72
+ if (Object.getOwnPropertySymbols(value).length) throw new Error(`Native capture "${name}" is not serializable: symbol`)
73
+ const entries = []
74
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
75
+ if (!descriptor.enumerable) continue
76
+ if (!("value" in descriptor)) throw new Error(`Native capture "${name}" is not serializable: accessor`)
77
+ entries.push([key, serializeCapture(name, descriptor.value, seen)])
78
+ }
79
+ return { type: "object", nullPrototype: prototype === null, value: entries }
80
+ } finally {
81
+ seen.delete(value)
82
+ }
83
+ }
84
+
85
+ export async function renderPage(component, metadata = {}) {
86
+ renderContext = { nextState: 0, states: {}, events: [], hasBehaviors: false, hasNativeBehaviors: false }
87
+
88
+ try {
89
+ const body = await renderNode({ type: component, props: {} })
90
+ const title = escapeHtml(metadata.title ?? "Kudzu")
91
+ const head = renderMetadata(metadata)
92
+ const styles = metadata.styles === false
93
+ ? ""
94
+ : '<link rel="stylesheet" href="/assets/style.css">'
95
+ const runtime = renderContext.hasBehaviors
96
+ ? '<script type="module" src="/assets/kudzu.js"></script>'
97
+ : ""
98
+ const nativeRuntime = renderContext.hasNativeBehaviors
99
+ ? '<script type="module" src="/assets/kudzu-native.js"></script>'
100
+ : ""
101
+
102
+ 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`,
104
+ hasBehaviors: renderContext.hasBehaviors,
105
+ plan: {
106
+ states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
107
+ events: renderContext.events
108
+ }
109
+ }
110
+ } finally {
111
+ renderContext = undefined
112
+ }
113
+ }
114
+
115
+ function renderMetadata(metadata) {
116
+ const tags = []
117
+ const meta = (name, content, property = false) => {
118
+ if (content) tags.push(`<meta ${property ? "property" : "name"}="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`)
119
+ }
120
+
121
+ if (metadata.description) meta("description", metadata.description)
122
+ if (metadata.themeColor) meta("theme-color", metadata.themeColor)
123
+ if (metadata.url) tags.push(`<link rel="canonical" href="${escapeAttribute(metadata.url)}">`)
124
+ if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(metadata.icon)}">`)
125
+ if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(metadata.appleTouchIcon)}">`)
126
+ if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(metadata.manifest)}">`)
127
+
128
+ meta("og:title", metadata.title, true)
129
+ meta("og:description", metadata.description, true)
130
+ meta("og:type", metadata.type ?? "website", true)
131
+ meta("og:url", metadata.url, true)
132
+ meta("og:image", metadata.image, true)
133
+ if (metadata.image) {
134
+ meta("og:image:width", "1200", true)
135
+ meta("og:image:height", "630", true)
136
+ meta("og:image:alt", metadata.imageAlt ?? metadata.title, true)
137
+ }
138
+ meta("og:site_name", metadata.siteName, true)
139
+ meta("og:locale", metadata.locale, true)
140
+ meta("twitter:card", metadata.twitterCard ?? (metadata.image ? "summary_large_image" : undefined))
141
+ meta("twitter:title", metadata.title)
142
+ meta("twitter:description", metadata.description)
143
+ meta("twitter:image", metadata.twitterImage ?? metadata.image)
144
+
145
+ return tags.length ? `${tags.join("\n")}\n` : ""
146
+ }
147
+
148
+ async function renderNode(node) {
149
+ if (node == null || node === false || node === true) return ""
150
+ if (Array.isArray(node)) {
151
+ let html = ""
152
+ for (const child of node) html += await renderNode(child)
153
+ return html
154
+ }
155
+ if (node?.[signalMarker]) {
156
+ return `<span data-k-text="${node.id}" data-k-value="${escapeAttribute(JSON.stringify(node.value))}">${escapeHtml(node.value)}</span>`
157
+ }
158
+ if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
159
+ return escapeHtml(node)
160
+ }
161
+ if (node instanceof Promise) return renderNode(await node)
162
+ if (!node || typeof node !== "object" || !("type" in node)) {
163
+ throw new Error(`Cannot render ${String(node)}`)
164
+ }
165
+
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))
168
+
169
+ const tag = node.type
170
+ const props = node.props ?? {}
171
+ let attributes = ""
172
+
173
+ for (const [rawName, value] of Object.entries(props)) {
174
+ if (rawName === "children" || rawName === "key" || value == null || value === false) continue
175
+
176
+ if (/^on[A-Z]/.test(rawName)) {
177
+ const event = rawName.slice(2).toLowerCase()
178
+ if (value?.[behaviorMarker]) {
179
+ const commands = JSON.stringify(value.commands)
180
+ attributes += ` data-k-on-${event}="${escapeAttribute(commands)}"`
181
+ renderContext.events.push({ event, commands: value.commands })
182
+ } else if (value?.[nativeBehaviorMarker]) {
183
+ const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
184
+ attributes += ` data-k-native-${event}="${escapeAttribute(JSON.stringify(native))}"`
185
+ renderContext.events.push({ event, native })
186
+ renderContext.hasNativeBehaviors = true
187
+ } else {
188
+ throw new Error(`${rawName} must reference a compilable event handler`)
189
+ }
190
+ renderContext.hasBehaviors = true
191
+ continue
192
+ }
193
+
194
+ const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
195
+ if (value === true) {
196
+ attributes += ` ${name}`
197
+ } else if (name === "style" && typeof value === "object") {
198
+ const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
199
+ attributes += ` style="${escapeAttribute(style)}"`
200
+ } else {
201
+ attributes += ` ${name}="${escapeAttribute(value)}"`
202
+ }
203
+ }
204
+
205
+ const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
206
+ if (voidElements.has(tag)) return `<${tag}${attributes}>`
207
+ return `<${tag}${attributes}>${await renderNode(props.children)}</${tag}>`
208
+ }
209
+
210
+ function escapeHtml(value) {
211
+ return String(value)
212
+ .replaceAll("&", "&amp;")
213
+ .replaceAll("<", "&lt;")
214
+ .replaceAll(">", "&gt;")
215
+ .replaceAll('"', "&quot;")
216
+ }
217
+
218
+ function escapeAttribute(value) {
219
+ return escapeHtml(value).replaceAll("'", "&#39;")
220
+ }
221
+
222
+ function toKebabCase(value) {
223
+ return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
224
+ }
@@ -0,0 +1,17 @@
1
+ export namespace JSX {
2
+ type Element = unknown
3
+ type Children = unknown
4
+
5
+ interface IntrinsicAttributes {
6
+ key?: string | number
7
+ }
8
+
9
+ interface IntrinsicElements {
10
+ [elementName: string]: Record<string, unknown>
11
+ }
12
+ }
13
+
14
+ export const Fragment: unique symbol
15
+ export function jsx(type: unknown, props: unknown, key?: string): JSX.Element
16
+ export const jsxs: typeof jsx
17
+ export const jsxDEV: typeof jsx
@@ -0,0 +1,8 @@
1
+ export const Fragment = Symbol.for("kudzu.fragment")
2
+
3
+ export function jsx(type, props, key) {
4
+ return { type, props: key == null ? props : { ...props, key } }
5
+ }
6
+
7
+ export const jsxs = jsx
8
+ export const jsxDEV = jsx
@@ -0,0 +1,81 @@
1
+ import { browserState, commitDom } from "./runtime.js"
2
+
3
+ export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
4
+ const changed = new Set()
5
+ let scheduled = false
6
+ const scope = Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value)]))
7
+
8
+ const flush = () => {
9
+ scheduled = false
10
+ const ids = [...changed]
11
+ changed.clear()
12
+ for (const id of ids) commit(id, state.get(id))
13
+ }
14
+
15
+ return {
16
+ get(name) {
17
+ return state.get(stateIds[name])
18
+ },
19
+ scope(name) {
20
+ return scope[name]
21
+ },
22
+ set(name, value) {
23
+ const id = stateIds[name]
24
+ const current = state.get(id)
25
+ state.set(id, typeof value === "function" ? value(current) : value)
26
+ changed.add(id)
27
+ if (!scheduled) {
28
+ scheduled = true
29
+ queueMicrotask(flush)
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ if (typeof document !== "undefined") {
36
+ const modules = new Map()
37
+
38
+ for (const eventName of ["click", "input", "change", "submit", "keydown", "keyup"]) {
39
+ document.addEventListener(eventName, event => {
40
+ const target = event.target.closest(`[data-k-native-${eventName}]`)
41
+ if (!target) return
42
+
43
+ const native = JSON.parse(target.dataset[`kNative${capitalize(eventName)}`])
44
+ let modulePromise = modules.get(native.module)
45
+ if (!modulePromise) {
46
+ modulePromise = import(native.module)
47
+ modules.set(native.module, modulePromise)
48
+ }
49
+ modulePromise
50
+ .then(module => module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target)))
51
+ .catch(error => console.error(error))
52
+ }, true)
53
+ }
54
+ }
55
+
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
+ function delegatedEvent(event, currentTarget) {
70
+ return new Proxy(event, {
71
+ get(source, property) {
72
+ if (property === "currentTarget") return currentTarget
73
+ const value = Reflect.get(source, property, source)
74
+ return typeof value === "function" ? value.bind(source) : value
75
+ }
76
+ })
77
+ }
78
+
79
+ function capitalize(value) {
80
+ return value[0].toUpperCase() + value.slice(1)
81
+ }
@@ -0,0 +1,43 @@
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
+
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
+ }
24
+ }
25
+
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
+ }
30
+
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)
37
+ })
38
+ }
39
+ }
40
+
41
+ function capitalize(value) {
42
+ return value[0].toUpperCase() + value.slice(1)
43
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@kudzujs/core",
3
+ "version": "0.1.0",
4
+ "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kudzujs/kudzu.git"
10
+ },
11
+ "homepage": "https://github.com/kudzujs/kudzu#readme",
12
+ "bugs": "https://github.com/kudzujs/kudzu/issues",
13
+ "keywords": [
14
+ "tsx",
15
+ "framework",
16
+ "html-first",
17
+ "compiler",
18
+ "ssg",
19
+ "no-vdom"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "framework/",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "bin": {
34
+ "kudzu": "./bin/kudzu.mjs"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./framework/core.d.ts",
39
+ "default": "./framework/core.mjs"
40
+ },
41
+ "./jsx-runtime": {
42
+ "types": "./framework/jsx-runtime.d.ts",
43
+ "default": "./framework/jsx-runtime.mjs"
44
+ }
45
+ },
46
+ "scripts": {
47
+ "build": "node ./bin/kudzu.mjs build",
48
+ "dev": "node ./bin/kudzu.mjs dev",
49
+ "check": "tsc --noEmit && node ./bin/kudzu.mjs build",
50
+ "test": "node --test",
51
+ "prepublishOnly": "npm run check && npm test"
52
+ },
53
+ "dependencies": {
54
+ "typescript": "^5.9.2"
55
+ }
56
+ }