@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 +21 -0
- package/README.md +194 -0
- package/bin/kudzu.mjs +14 -0
- package/framework/README.md +12 -0
- package/framework/build.mjs +446 -0
- package/framework/core.d.ts +39 -0
- package/framework/core.mjs +224 -0
- package/framework/jsx-runtime.d.ts +17 -0
- package/framework/jsx-runtime.mjs +8 -0
- package/framework/native-runtime.js +81 -0
- package/framework/runtime.js +43 -0
- package/package.json +56 -0
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("&", "&")
|
|
213
|
+
.replaceAll("<", "<")
|
|
214
|
+
.replaceAll(">", ">")
|
|
215
|
+
.replaceAll('"', """)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function escapeAttribute(value) {
|
|
219
|
+
return escapeHtml(value).replaceAll("'", "'")
|
|
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,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
|
+
}
|