@kudzujs/core 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -17
- package/framework/README.md +5 -2
- package/framework/binding-runtime.js +85 -0
- package/framework/build.mjs +190 -16
- package/framework/core.d.ts +14 -0
- package/framework/core.mjs +77 -4
- package/framework/native-runtime.js +3 -14
- package/framework/runtime.js +6 -8
- package/framework/serialization.js +12 -0
- package/framework/shared-runtime.js +47 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ Kudzu keeps the familiar function-component, props, children, event-handler, and
|
|
|
12
12
|
|
|
13
13
|
> Experimental `0.2.x`: the compiler API and supported TSX surface may change.
|
|
14
14
|
|
|
15
|
+
Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
|
|
16
|
+
|
|
15
17
|
## Install
|
|
16
18
|
|
|
17
19
|
Create a new project:
|
|
@@ -96,6 +98,18 @@ function increaseTwice() {
|
|
|
96
98
|
|
|
97
99
|
The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
|
|
98
100
|
|
|
101
|
+
## Reactive Attributes
|
|
102
|
+
|
|
103
|
+
`className`, `disabled`, and controlled `value` accept normal state-dependent TSX expressions:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
<div className={active ? "active" : "idle"} />
|
|
107
|
+
<button disabled={loading}>Save</button>
|
|
108
|
+
<input value={name} onInput={event => setName(event.currentTarget.value)} />
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property.
|
|
112
|
+
|
|
99
113
|
## Normal JavaScript
|
|
100
114
|
|
|
101
115
|
Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
|
|
@@ -149,10 +163,10 @@ Supported:
|
|
|
149
163
|
- Synchronous and async event handlers
|
|
150
164
|
- Serializable component-local captures
|
|
151
165
|
- Direct text DOM patches
|
|
166
|
+
- Reactive `className`, `disabled`, and controlled `value` patches
|
|
152
167
|
|
|
153
168
|
Not implemented yet:
|
|
154
169
|
|
|
155
|
-
- Reactive attributes, classes, and controlled inputs
|
|
156
170
|
- Conditional DOM patches and keyed lists
|
|
157
171
|
- Server actions and request-time SSR
|
|
158
172
|
- Imported client helpers and React package islands
|
|
@@ -160,7 +174,7 @@ Not implemented yet:
|
|
|
160
174
|
|
|
161
175
|
## Benchmarks
|
|
162
176
|
|
|
163
|
-
Measurements below were produced on the same machine from production builds.
|
|
177
|
+
Measurements below were produced on the same machine from production builds. Each framework received one warm-up followed by seven clean builds in rotating order; the table reports the median. Initial JavaScript includes inline scripts, root script references, and their static import graph, compressed file-by-file with gzip level 9. Total output is the raw size of every deploy artifact.
|
|
164
178
|
|
|
165
179
|
### Interactive Counter
|
|
166
180
|
|
|
@@ -168,30 +182,31 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
168
182
|
|
|
169
183
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
170
184
|
|---|---:|---:|---:|---:|
|
|
171
|
-
| Kudzu | Yes |
|
|
172
|
-
| Astro | Yes | 158 B | **365 B** |
|
|
185
|
+
| Kudzu | Yes | 563 B | 1.7 KB | **384 ms** |
|
|
186
|
+
| Astro | Yes | **158 B** | **365 B** | 911 ms |
|
|
173
187
|
| Svelte CSR | No | 10.5 KB | 26.9 KB | 910 ms |
|
|
174
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
175
|
-
|
|
|
176
|
-
|
|
|
188
|
+
| Qwik CSR | No | 20.6 KB | 57.8 KB | 627 ms |
|
|
189
|
+
| Vue CSR | No | 24.0 KB | 60.3 KB | 814 ms |
|
|
190
|
+
| React CSR | No | 59.2 KB | 189.0 KB | 1052 ms |
|
|
191
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 2985 ms |
|
|
177
192
|
|
|
178
193
|
Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
|
|
179
194
|
|
|
180
195
|
### Static Journal Page
|
|
181
196
|
|
|
182
|
-
Same content and CSS across every fixture
|
|
197
|
+
Same content and CSS across every fixture:
|
|
183
198
|
|
|
184
199
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
185
200
|
|---|---:|---:|---:|---:|
|
|
186
|
-
| Kudzu | Yes | **0 B** | 3.
|
|
187
|
-
| Astro | Yes | **0 B** | **3.0 KB** |
|
|
188
|
-
| Svelte CSR | No | 10.2 KB | 27.2 KB |
|
|
189
|
-
| Qwik CSR | No | 20.2 KB | 59.6 KB |
|
|
190
|
-
| Vue CSR | No | 24.2 KB | 62.3 KB |
|
|
191
|
-
| React CSR | No | 59.8 KB | 192.3 KB |
|
|
192
|
-
| Next.js | Yes | 182.6 KB | 663.6 KB |
|
|
193
|
-
|
|
194
|
-
Benchmark snapshot collected on July
|
|
201
|
+
| Kudzu | Yes | **0 B** | 3.2 KB | **478 ms** |
|
|
202
|
+
| Astro | Yes | **0 B** | **3.0 KB** | 1253 ms |
|
|
203
|
+
| Svelte CSR | No | 10.2 KB | 27.2 KB | 1018 ms |
|
|
204
|
+
| Qwik CSR | No | 20.2 KB | 59.6 KB | 784 ms |
|
|
205
|
+
| Vue CSR | No | 24.2 KB | 62.3 KB | 960 ms |
|
|
206
|
+
| React CSR | No | 59.8 KB | 192.3 KB | 1253 ms |
|
|
207
|
+
| Next.js | Yes | 182.6 KB | 663.6 KB | 3880 ms |
|
|
208
|
+
|
|
209
|
+
Benchmark snapshot collected on July 21, 2026 with Node 24.14.0 on an Intel i5-9500. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
|
|
195
210
|
|
|
196
211
|
## Development
|
|
197
212
|
|
package/framework/README.md
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
- `build.mjs`: TSX compilation, file routes, behavior extraction, static HTML output, and the development server.
|
|
4
4
|
- `core.mjs`: server-side JSX rendering, state slots, behavior metadata, and serializable capture validation.
|
|
5
5
|
- `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
|
|
6
|
-
- `runtime.js`: command-only
|
|
6
|
+
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
|
+
- `shared-runtime.js`: command runtime with binding commit hooks, emitted only when needed.
|
|
8
|
+
- `binding-runtime.js`: optional reactive `className`, `disabled`, and `value` patches.
|
|
9
|
+
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
7
10
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
8
11
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
9
12
|
|
|
10
|
-
Static routes
|
|
13
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes add `binding-runtime.js`; native handlers add `native-runtime.js`. Generated evaluators live under `dist/assets/handlers/`.
|
|
11
14
|
|
|
12
15
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { browserState, registerCommitter } from "./shared-runtime.js"
|
|
2
|
+
import { deserialize } from "./serialization.js"
|
|
3
|
+
|
|
4
|
+
const bindingTargets = new Map()
|
|
5
|
+
|
|
6
|
+
export function patchBinding(node, target, value) {
|
|
7
|
+
if (target === "disabled") {
|
|
8
|
+
node.toggleAttribute("disabled", Boolean(value))
|
|
9
|
+
} else if (target === "value") {
|
|
10
|
+
const next = value == null ? "" : String(value)
|
|
11
|
+
if (node.value !== next) node.value = next
|
|
12
|
+
} else if (value == null || value === false) {
|
|
13
|
+
node.removeAttribute("class")
|
|
14
|
+
} else {
|
|
15
|
+
node.setAttribute("class", String(value))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function commitBindings(id) {
|
|
20
|
+
for (const binding of bindingTargets.get(id) ?? []) patchBinding(binding.node, binding.target, binding.read())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
registerCommitter(commitBindings)
|
|
24
|
+
|
|
25
|
+
if (typeof document !== "undefined") {
|
|
26
|
+
const imports = new Map()
|
|
27
|
+
const registrations = []
|
|
28
|
+
for (const target of ["class", "disabled", "value"]) {
|
|
29
|
+
for (const node of document.querySelectorAll(`[data-k-bind-${target}]`)) {
|
|
30
|
+
const descriptor = JSON.parse(node.dataset[`kBind${capitalize(target)}`])
|
|
31
|
+
if (descriptor.state) {
|
|
32
|
+
registerBinding(descriptor.state, { node, target, read: () => browserState.get(descriptor.state) })
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
let modulePromise = imports.get(descriptor.module)
|
|
36
|
+
if (!modulePromise) {
|
|
37
|
+
modulePromise = import(descriptor.module)
|
|
38
|
+
imports.set(descriptor.module, modulePromise)
|
|
39
|
+
}
|
|
40
|
+
registrations.push(modulePromise.then(async module => {
|
|
41
|
+
const context = await createBindingContext(descriptor, imports)
|
|
42
|
+
const binding = { node, target, read: () => module[descriptor.handler](context) }
|
|
43
|
+
for (const id of bindingStateIds(descriptor)) registerBinding(id, binding)
|
|
44
|
+
patchBinding(node, target, binding.read())
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
Promise.all(registrations).catch(error => console.error(error))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function registerBinding(id, binding) {
|
|
52
|
+
const bindings = bindingTargets.get(id) ?? []
|
|
53
|
+
bindings.push(binding)
|
|
54
|
+
bindingTargets.set(id, bindings)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function createBindingContext(descriptor, imports) {
|
|
58
|
+
const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value)]))
|
|
59
|
+
const nested = {}
|
|
60
|
+
await Promise.all(Object.entries(descriptor.scopeBindings).map(async ([name, binding]) => {
|
|
61
|
+
let modulePromise = imports.get(binding.module)
|
|
62
|
+
if (!modulePromise) {
|
|
63
|
+
modulePromise = import(binding.module)
|
|
64
|
+
imports.set(binding.module, modulePromise)
|
|
65
|
+
}
|
|
66
|
+
const [module, context] = await Promise.all([modulePromise, createBindingContext(binding, imports)])
|
|
67
|
+
nested[name] = () => module[binding.handler](context)
|
|
68
|
+
}))
|
|
69
|
+
return {
|
|
70
|
+
get: name => browserState.get(descriptor.states[name]),
|
|
71
|
+
scope: name => name in descriptor.scopeStates ? browserState.get(descriptor.scopeStates[name]) : name in nested ? nested[name]() : scope[name]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bindingStateIds(descriptor) {
|
|
76
|
+
return new Set([
|
|
77
|
+
...Object.values(descriptor.states),
|
|
78
|
+
...Object.values(descriptor.scopeStates),
|
|
79
|
+
...Object.values(descriptor.scopeBindings).flatMap(binding => [...bindingStateIds(binding)])
|
|
80
|
+
])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function capitalize(value) {
|
|
84
|
+
return value[0].toUpperCase() + value.slice(1)
|
|
85
|
+
}
|
package/framework/build.mjs
CHANGED
|
@@ -30,6 +30,8 @@ export async function build({ quiet = false } = {}) {
|
|
|
30
30
|
if (!pageFiles.length) throw new Error("No pages found in src/pages/")
|
|
31
31
|
|
|
32
32
|
let behaviorCount = 0
|
|
33
|
+
let bindingCount = 0
|
|
34
|
+
let stateSeedCount = 0
|
|
33
35
|
const plans = []
|
|
34
36
|
const hasStyles = await exists(join(sourceDirectory, "style.css"))
|
|
35
37
|
|
|
@@ -48,14 +50,32 @@ export async function build({ quiet = false } = {}) {
|
|
|
48
50
|
await writeFile(join(routeDirectory, "index.html"), result.html)
|
|
49
51
|
plans.push({ route: `/${route}`, ...result.plan })
|
|
50
52
|
if (result.hasBehaviors) behaviorCount++
|
|
53
|
+
if (result.hasBindings) bindingCount++
|
|
54
|
+
if (result.hasStateSeed) stateSeedCount++
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const assetsDirectory = join(outputDirectory, "assets")
|
|
54
58
|
await mkdir(assetsDirectory, { recursive: true })
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
60
|
+
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
61
|
+
if (behaviorCount) {
|
|
62
|
+
const runtimeFile = bindingCount ? "./shared-runtime.js" : "./runtime.js"
|
|
63
|
+
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
64
|
+
await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
|
|
65
|
+
}
|
|
66
|
+
const hasNativeHandlers = handlerModules.some(module => module.hasNativeHandlers)
|
|
67
|
+
if (bindingCount || hasNativeHandlers) await cp(new URL("./serialization.js", import.meta.url), join(assetsDirectory, "kudzu-serialization.js"))
|
|
68
|
+
if (bindingCount) {
|
|
69
|
+
const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
70
|
+
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
71
|
+
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
72
|
+
await writeFile(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime)
|
|
73
|
+
}
|
|
74
|
+
if (hasNativeHandlers) {
|
|
75
|
+
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
76
|
+
.replace('"./runtime.js"', '"./kudzu.js"')
|
|
77
|
+
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
78
|
+
await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeEvents(nativeRuntime, nativeEvents))
|
|
59
79
|
}
|
|
60
80
|
for (const handlerModule of handlerModules) {
|
|
61
81
|
const output = join(assetsDirectory, handlerModule.path)
|
|
@@ -69,6 +89,18 @@ export async function build({ quiet = false } = {}) {
|
|
|
69
89
|
if (!quiet) console.log(`Built ${pageFiles.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
function specializeEvents(source, events) {
|
|
93
|
+
return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function specializeRuntime(source, events, hasStateSeed) {
|
|
97
|
+
const specialized = specializeEvents(source, events)
|
|
98
|
+
if (hasStateSeed) return specialized
|
|
99
|
+
return specialized
|
|
100
|
+
.replace(" const initialState = document.body.dataset.kState\n", "")
|
|
101
|
+
.replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
|
|
102
|
+
}
|
|
103
|
+
|
|
72
104
|
export async function dev() {
|
|
73
105
|
await build()
|
|
74
106
|
|
|
@@ -110,6 +142,7 @@ export async function dev() {
|
|
|
110
142
|
async function compile(file) {
|
|
111
143
|
const source = await readFile(file, "utf8")
|
|
112
144
|
const nativeHandlers = []
|
|
145
|
+
const reactiveBindings = []
|
|
113
146
|
const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
114
147
|
const result = ts.transpileModule(source, {
|
|
115
148
|
fileName: file,
|
|
@@ -119,7 +152,7 @@ async function compile(file) {
|
|
|
119
152
|
jsx: ts.JsxEmit.ReactJSX,
|
|
120
153
|
jsxImportSource: "@kudzujs/core"
|
|
121
154
|
},
|
|
122
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, `/assets/${handlerPath}`)] },
|
|
155
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, `/assets/${handlerPath}`)] },
|
|
123
156
|
reportDiagnostics: true
|
|
124
157
|
})
|
|
125
158
|
|
|
@@ -132,21 +165,24 @@ async function compile(file) {
|
|
|
132
165
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
133
166
|
await writeFile(output, result.outputText)
|
|
134
167
|
|
|
135
|
-
if (!nativeHandlers.length) return undefined
|
|
136
|
-
const moduleSource =
|
|
168
|
+
if (!nativeHandlers.length && !reactiveBindings.length) return undefined
|
|
169
|
+
const moduleSource = [
|
|
170
|
+
...nativeHandlers.map(handler => printNativeHandler(handler)),
|
|
171
|
+
...reactiveBindings.map(entry => printReactiveBinding(entry))
|
|
172
|
+
].join("\n")
|
|
137
173
|
const moduleResult = ts.transpileModule(moduleSource, {
|
|
138
174
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
|
|
139
175
|
reportDiagnostics: true
|
|
140
176
|
})
|
|
141
177
|
const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
142
178
|
if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
143
|
-
return { path: handlerPath, code: moduleResult.outputText }
|
|
179
|
+
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
|
|
144
180
|
}
|
|
145
181
|
|
|
146
|
-
function createKudzuTransformer(nativeHandlers, handlerUrl) {
|
|
182
|
+
function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
147
183
|
return context => sourceFile => {
|
|
148
184
|
const factory = context.factory
|
|
149
|
-
const
|
|
185
|
+
const settersByFunction = new Map()
|
|
150
186
|
const functions = new Map()
|
|
151
187
|
let usesBehavior = false
|
|
152
188
|
|
|
@@ -155,7 +191,12 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
|
|
|
155
191
|
const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
|
|
156
192
|
const [stateElement, setterElement] = node.name.elements
|
|
157
193
|
if (callName === "useState" && stateElement && setterElement && ts.isBindingElement(stateElement) && ts.isBindingElement(setterElement) && ts.isIdentifier(stateElement.name) && ts.isIdentifier(setterElement.name)) {
|
|
158
|
-
|
|
194
|
+
const owner = nearestFunction(node)
|
|
195
|
+
if (owner) {
|
|
196
|
+
const setters = settersByFunction.get(owner) ?? new Map()
|
|
197
|
+
setters.set(setterElement.name.text, stateElement.name.text)
|
|
198
|
+
settersByFunction.set(owner, setters)
|
|
199
|
+
}
|
|
159
200
|
}
|
|
160
201
|
}
|
|
161
202
|
if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node)
|
|
@@ -185,7 +226,20 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
|
|
|
185
226
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
|
|
186
227
|
}
|
|
187
228
|
|
|
229
|
+
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["className", "disabled", "value"].includes(node.name.getText())) {
|
|
230
|
+
const expression = node.initializer.expression
|
|
231
|
+
const setters = settersForNode(node, settersByFunction)
|
|
232
|
+
const usedStates = referencedStateNames(expression, setters)
|
|
233
|
+
const captures = captureNames(expression, expression, setters)
|
|
234
|
+
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
|
|
235
|
+
usesBehavior = true
|
|
236
|
+
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
|
|
237
|
+
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
188
241
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
|
|
242
|
+
const setters = settersForNode(node, settersByFunction)
|
|
189
243
|
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
|
|
190
244
|
if (event) {
|
|
191
245
|
usesBehavior = true
|
|
@@ -203,6 +257,8 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
|
|
|
203
257
|
|
|
204
258
|
const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
|
|
205
259
|
if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
|
|
260
|
+
if (reactiveBindings.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
|
|
261
|
+
if (reactiveBindings.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
206
262
|
const behaviorImport = factory.createImportDeclaration(
|
|
207
263
|
undefined,
|
|
208
264
|
factory.createImportClause(false, undefined, factory.createNamedImports(behaviorImports)),
|
|
@@ -212,6 +268,44 @@ function createKudzuTransformer(nativeHandlers, handlerUrl) {
|
|
|
212
268
|
}
|
|
213
269
|
}
|
|
214
270
|
|
|
271
|
+
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
272
|
+
const usedStates = referencedStateNames(expression, setters)
|
|
273
|
+
const captures = captureNames(expression, expression, setters)
|
|
274
|
+
const exportName = `binding${reactiveBindings.length}`
|
|
275
|
+
reactiveBindings.push({ exportName, expression, captures, states: usedStates })
|
|
276
|
+
const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
277
|
+
factory.createStringLiteral(name),
|
|
278
|
+
factory.createIdentifier(name)
|
|
279
|
+
]))
|
|
280
|
+
const scope = [...captures].map(name => factory.createArrayLiteralExpression([
|
|
281
|
+
factory.createStringLiteral(name),
|
|
282
|
+
factory.createIdentifier(name)
|
|
283
|
+
]))
|
|
284
|
+
const stateNames = new Set(usedStates)
|
|
285
|
+
const rewriteInitial = node => {
|
|
286
|
+
if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text)) {
|
|
287
|
+
return factory.createPropertyAssignment(node.name, factory.createPropertyAccessExpression(node.name, "value"))
|
|
288
|
+
}
|
|
289
|
+
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
|
|
290
|
+
return factory.createPropertyAccessExpression(node, "value")
|
|
291
|
+
}
|
|
292
|
+
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
|
|
293
|
+
return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createIdentifier("__kBindingValue"), undefined, [node.name]))
|
|
294
|
+
}
|
|
295
|
+
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
|
|
296
|
+
return factory.createCallExpression(factory.createIdentifier("__kBindingValue"), undefined, [node])
|
|
297
|
+
}
|
|
298
|
+
return ts.visitEachChild(node, rewriteInitial, context)
|
|
299
|
+
}
|
|
300
|
+
return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, [
|
|
301
|
+
ts.visitNode(expression, rewriteInitial),
|
|
302
|
+
factory.createStringLiteral(handlerUrl),
|
|
303
|
+
factory.createStringLiteral(exportName),
|
|
304
|
+
factory.createArrayLiteralExpression(states),
|
|
305
|
+
factory.createArrayLiteralExpression(scope)
|
|
306
|
+
])
|
|
307
|
+
}
|
|
308
|
+
|
|
215
309
|
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
|
|
216
310
|
if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
|
|
217
311
|
if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
|
|
@@ -240,14 +334,18 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
240
334
|
}
|
|
241
335
|
|
|
242
336
|
function nativeStateNames(expression, setters) {
|
|
337
|
+
return referencedStateNames(expression.body, setters, expression)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function referencedStateNames(root, setters, scopeRoot = root) {
|
|
243
341
|
const stateNames = new Set(setters.values())
|
|
244
342
|
const used = new Set()
|
|
245
343
|
const visit = node => {
|
|
246
344
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) used.add(setters.get(node.expression.text))
|
|
247
|
-
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node)) used.add(node.text)
|
|
345
|
+
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
|
|
248
346
|
ts.forEachChild(node, visit)
|
|
249
347
|
}
|
|
250
|
-
visit(
|
|
348
|
+
visit(root)
|
|
251
349
|
return used
|
|
252
350
|
}
|
|
253
351
|
|
|
@@ -267,6 +365,10 @@ const nativeGlobals = new Set([
|
|
|
267
365
|
])
|
|
268
366
|
|
|
269
367
|
function nativeCaptureNames(expression, setters) {
|
|
368
|
+
return captureNames(expression, expression.body, setters)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function captureNames(declarationRoot, referenceRoot, setters) {
|
|
270
372
|
const local = new Set()
|
|
271
373
|
const collectDeclarations = node => {
|
|
272
374
|
if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
|
|
@@ -274,7 +376,7 @@ function nativeCaptureNames(expression, setters) {
|
|
|
274
376
|
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
|
|
275
377
|
ts.forEachChild(node, collectDeclarations)
|
|
276
378
|
}
|
|
277
|
-
collectDeclarations(
|
|
379
|
+
collectDeclarations(declarationRoot)
|
|
278
380
|
|
|
279
381
|
const stateNames = new Set(setters.values())
|
|
280
382
|
const captures = new Set()
|
|
@@ -285,7 +387,7 @@ function nativeCaptureNames(expression, setters) {
|
|
|
285
387
|
}
|
|
286
388
|
ts.forEachChild(node, visit)
|
|
287
389
|
}
|
|
288
|
-
visit(
|
|
390
|
+
visit(referenceRoot)
|
|
289
391
|
return captures
|
|
290
392
|
}
|
|
291
393
|
|
|
@@ -308,6 +410,30 @@ function isReferenceIdentifier(node) {
|
|
|
308
410
|
return true
|
|
309
411
|
}
|
|
310
412
|
|
|
413
|
+
function nearestFunction(node) {
|
|
414
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
415
|
+
if (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) return current
|
|
416
|
+
}
|
|
417
|
+
return undefined
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isShadowedByParameter(node, scopeRoot) {
|
|
421
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
422
|
+
if ((ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
|
|
423
|
+
if (current === scopeRoot) break
|
|
424
|
+
}
|
|
425
|
+
return false
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function settersForNode(node, settersByFunction) {
|
|
429
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
430
|
+
if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
|
|
431
|
+
const setters = settersByFunction.get(current)
|
|
432
|
+
if (setters) return setters
|
|
433
|
+
}
|
|
434
|
+
return new Map()
|
|
435
|
+
}
|
|
436
|
+
|
|
311
437
|
function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
312
438
|
const factory = ts.factory
|
|
313
439
|
const stateNames = new Set(setters.values())
|
|
@@ -320,7 +446,7 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
320
446
|
[factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
|
|
321
447
|
)
|
|
322
448
|
}
|
|
323
|
-
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node)) {
|
|
449
|
+
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
|
|
324
450
|
return factory.createCallExpression(
|
|
325
451
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
|
|
326
452
|
undefined,
|
|
@@ -359,6 +485,54 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
359
485
|
}
|
|
360
486
|
}
|
|
361
487
|
|
|
488
|
+
function printReactiveBinding({ exportName, expression, captures, states }) {
|
|
489
|
+
const factory = ts.factory
|
|
490
|
+
const transformer = context => root => {
|
|
491
|
+
const visitor = node => {
|
|
492
|
+
if (ts.isShorthandPropertyAssignment(node) && states.has(node.name.text)) {
|
|
493
|
+
return factory.createPropertyAssignment(
|
|
494
|
+
node.name,
|
|
495
|
+
factory.createCallExpression(
|
|
496
|
+
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
|
|
497
|
+
undefined,
|
|
498
|
+
[factory.createStringLiteral(node.name.text)]
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
if (ts.isIdentifier(node) && states.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
|
|
503
|
+
return factory.createCallExpression(
|
|
504
|
+
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
|
|
505
|
+
undefined,
|
|
506
|
+
[factory.createStringLiteral(node.text)]
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
|
|
510
|
+
return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
|
|
511
|
+
}
|
|
512
|
+
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
|
|
513
|
+
return scopeRead(factory, node.text)
|
|
514
|
+
}
|
|
515
|
+
return ts.visitEachChild(node, visitor, context)
|
|
516
|
+
}
|
|
517
|
+
return ts.visitNode(root, visitor)
|
|
518
|
+
}
|
|
519
|
+
const transformed = ts.transform(expression, [transformer])
|
|
520
|
+
try {
|
|
521
|
+
const declaration = factory.createFunctionDeclaration(
|
|
522
|
+
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
|
|
523
|
+
undefined,
|
|
524
|
+
exportName,
|
|
525
|
+
undefined,
|
|
526
|
+
[factory.createParameterDeclaration(undefined, undefined, "__k")],
|
|
527
|
+
undefined,
|
|
528
|
+
factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
|
|
529
|
+
)
|
|
530
|
+
return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
|
|
531
|
+
} finally {
|
|
532
|
+
transformed.dispose()
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
362
536
|
function scopeRead(factory, name) {
|
|
363
537
|
return factory.createCallExpression(
|
|
364
538
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
|
package/framework/core.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export function useState<T>(initialValue: T): [T, StateSetter<T>]
|
|
|
4
4
|
|
|
5
5
|
export function behavior(commands: Array<["add" | "set" | "log", unknown, unknown]>): unknown
|
|
6
6
|
export function nativeBehavior(module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
|
7
|
+
export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
|
8
|
+
export function bindingValue(value: unknown): unknown
|
|
7
9
|
|
|
8
10
|
export function renderPage(
|
|
9
11
|
component: (props: Record<string, never>) => unknown | Promise<unknown>,
|
|
@@ -28,6 +30,8 @@ export function renderPage(
|
|
|
28
30
|
): Promise<{
|
|
29
31
|
html: string
|
|
30
32
|
hasBehaviors: boolean
|
|
33
|
+
hasBindings: boolean
|
|
34
|
+
hasStateSeed: boolean
|
|
31
35
|
plan: {
|
|
32
36
|
states: Array<{ id: string; name: string; initialValue: unknown }>
|
|
33
37
|
events: Array<{
|
|
@@ -35,5 +39,15 @@ export function renderPage(
|
|
|
35
39
|
commands?: Array<[string, string, unknown]>
|
|
36
40
|
native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
|
|
37
41
|
}>
|
|
42
|
+
bindings: Array<{
|
|
43
|
+
target: "class" | "disabled" | "value"
|
|
44
|
+
state?: string
|
|
45
|
+
module?: string
|
|
46
|
+
handler?: string
|
|
47
|
+
states?: Record<string, string>
|
|
48
|
+
scope?: Record<string, unknown>
|
|
49
|
+
scopeStates?: Record<string, string>
|
|
50
|
+
scopeBindings?: Record<string, unknown>
|
|
51
|
+
}>
|
|
38
52
|
}
|
|
39
53
|
}>
|
package/framework/core.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const signalMarker = Symbol("kudzu.signal")
|
|
2
2
|
const behaviorMarker = Symbol("kudzu.behavior")
|
|
3
3
|
const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
|
|
4
|
+
const bindingMarker = Symbol("kudzu.binding")
|
|
4
5
|
|
|
5
6
|
let renderContext
|
|
6
7
|
|
|
@@ -51,6 +52,38 @@ export function nativeBehavior(module, handler, states, scope) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
export function binding(value, module, handler, states, scope) {
|
|
56
|
+
const scopeStates = {}
|
|
57
|
+
const serializedScope = {}
|
|
58
|
+
const scopeBindings = {}
|
|
59
|
+
for (const [name, entry] of scope) {
|
|
60
|
+
if (entry?.[signalMarker]) scopeStates[name] = entry.id
|
|
61
|
+
else if (entry?.[bindingMarker]) scopeBindings[name] = bindingDescriptor(entry)
|
|
62
|
+
else serializedScope[name] = serializeCapture(name, entry, new Set())
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
[bindingMarker]: true,
|
|
66
|
+
value,
|
|
67
|
+
module,
|
|
68
|
+
handler,
|
|
69
|
+
states: Object.fromEntries(states.map(([name, signal]) => {
|
|
70
|
+
if (!signal?.[signalMarker]) throw new Error("A reactive binding must target framework state")
|
|
71
|
+
return [name, signal.id]
|
|
72
|
+
})),
|
|
73
|
+
scope: serializedScope,
|
|
74
|
+
scopeStates,
|
|
75
|
+
scopeBindings
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function bindingValue(value) {
|
|
80
|
+
return value?.[signalMarker] || value?.[bindingMarker] ? value.value : value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bindingDescriptor(value) {
|
|
84
|
+
return { module: value.module, handler: value.handler, states: value.states, scope: value.scope, scopeStates: value.scopeStates, scopeBindings: value.scopeBindings }
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
function serializeCapture(name, value, seen) {
|
|
55
88
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
56
89
|
if (typeof value === "number") {
|
|
@@ -83,7 +116,7 @@ function serializeCapture(name, value, seen) {
|
|
|
83
116
|
}
|
|
84
117
|
|
|
85
118
|
export async function renderPage(component, metadata = {}) {
|
|
86
|
-
renderContext = { nextState: 0, states: {}, events: [], hasBehaviors: false, hasNativeBehaviors: false }
|
|
119
|
+
renderContext = { nextState: 0, states: {}, textStates: new Set(), events: [], bindings: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false }
|
|
87
120
|
|
|
88
121
|
try {
|
|
89
122
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -98,13 +131,25 @@ export async function renderPage(component, metadata = {}) {
|
|
|
98
131
|
const nativeRuntime = renderContext.hasNativeBehaviors
|
|
99
132
|
? '<script type="module" src="/assets/kudzu-native.js"></script>'
|
|
100
133
|
: ""
|
|
134
|
+
const bindingRuntime = renderContext.hasBindings
|
|
135
|
+
? '<script type="module" src="/assets/kudzu-binding.js"></script>'
|
|
136
|
+
: ""
|
|
137
|
+
const initialState = renderContext.hasBehaviors
|
|
138
|
+
? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
|
|
139
|
+
: []
|
|
140
|
+
const state = initialState.length
|
|
141
|
+
? ` data-k-state="${escapeAttribute(JSON.stringify(initialState))}"`
|
|
142
|
+
: ""
|
|
101
143
|
|
|
102
144
|
return {
|
|
103
|
-
html: `<!doctype html>\n<html lang="${escapeAttribute(metadata.lang ?? "en")}">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${title}</title>\n${head}${styles}\n</head>\n<body>\n${body}\n${runtime}\n${nativeRuntime}\n</body>\n</html>\n`,
|
|
145
|
+
html: `<!doctype html>\n<html lang="${escapeAttribute(metadata.lang ?? "en")}">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${title}</title>\n${head}${styles}\n</head>\n<body${state}>\n${body}\n${runtime}\n${bindingRuntime}\n${nativeRuntime}\n</body>\n</html>\n`,
|
|
104
146
|
hasBehaviors: renderContext.hasBehaviors,
|
|
147
|
+
hasBindings: renderContext.hasBindings,
|
|
148
|
+
hasStateSeed: initialState.length > 0,
|
|
105
149
|
plan: {
|
|
106
150
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
107
|
-
events: renderContext.events
|
|
151
|
+
events: renderContext.events,
|
|
152
|
+
bindings: renderContext.bindings
|
|
108
153
|
}
|
|
109
154
|
}
|
|
110
155
|
} finally {
|
|
@@ -153,6 +198,7 @@ async function renderNode(node) {
|
|
|
153
198
|
return html
|
|
154
199
|
}
|
|
155
200
|
if (node?.[signalMarker]) {
|
|
201
|
+
renderContext.textStates.add(node.id)
|
|
156
202
|
return `<span data-k-text="${node.id}" data-k-value="${escapeAttribute(JSON.stringify(node.value))}">${escapeHtml(node.value)}</span>`
|
|
157
203
|
}
|
|
158
204
|
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
|
|
@@ -171,7 +217,7 @@ async function renderNode(node) {
|
|
|
171
217
|
let attributes = ""
|
|
172
218
|
|
|
173
219
|
for (const [rawName, value] of Object.entries(props)) {
|
|
174
|
-
if (rawName === "children" || rawName === "key"
|
|
220
|
+
if (rawName === "children" || rawName === "key") continue
|
|
175
221
|
|
|
176
222
|
if (/^on[A-Z]/.test(rawName)) {
|
|
177
223
|
const event = rawName.slice(2).toLowerCase()
|
|
@@ -192,6 +238,26 @@ async function renderNode(node) {
|
|
|
192
238
|
}
|
|
193
239
|
|
|
194
240
|
const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
|
|
241
|
+
const target = name === "class" || name === "disabled" || name === "value" ? name : undefined
|
|
242
|
+
if (target && (value?.[signalMarker] || value?.[bindingMarker])) {
|
|
243
|
+
const initialValue = value[signalMarker] ? value.value : value.value
|
|
244
|
+
const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
|
|
245
|
+
if (!reactive) {
|
|
246
|
+
attributes += renderAttribute(name, initialValue)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
const descriptor = value[signalMarker]
|
|
250
|
+
? { state: value.id }
|
|
251
|
+
: bindingDescriptor(value)
|
|
252
|
+
attributes += renderAttribute(name, initialValue)
|
|
253
|
+
attributes += ` data-k-bind-${target}="${escapeAttribute(JSON.stringify(descriptor))}"`
|
|
254
|
+
renderContext.bindings.push({ target, ...descriptor })
|
|
255
|
+
renderContext.hasBehaviors = true
|
|
256
|
+
renderContext.hasBindings = true
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (value == null || value === false) continue
|
|
195
261
|
if (value === true) {
|
|
196
262
|
attributes += ` ${name}`
|
|
197
263
|
} else if (name === "style" && typeof value === "object") {
|
|
@@ -207,6 +273,13 @@ async function renderNode(node) {
|
|
|
207
273
|
return `<${tag}${attributes}>${await renderNode(props.children)}</${tag}>`
|
|
208
274
|
}
|
|
209
275
|
|
|
276
|
+
function renderAttribute(name, value) {
|
|
277
|
+
if (name === "disabled") return value ? " disabled" : ""
|
|
278
|
+
if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
|
|
279
|
+
if (value == null || value === false) return ""
|
|
280
|
+
return ` ${name}="${escapeAttribute(value)}"`
|
|
281
|
+
}
|
|
282
|
+
|
|
210
283
|
function escapeHtml(value) {
|
|
211
284
|
return String(value)
|
|
212
285
|
.replaceAll("&", "&")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { browserState, commitDom } from "./runtime.js"
|
|
2
|
+
import { deserialize } from "./serialization.js"
|
|
2
3
|
|
|
3
4
|
export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
|
|
4
5
|
const changed = new Set()
|
|
@@ -35,7 +36,8 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
|
|
|
35
36
|
if (typeof document !== "undefined") {
|
|
36
37
|
const modules = new Map()
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
|
|
40
|
+
for (const eventName of eventNames) {
|
|
39
41
|
document.addEventListener(eventName, event => {
|
|
40
42
|
const target = event.target.closest(`[data-k-native-${eventName}]`)
|
|
41
43
|
if (!target) return
|
|
@@ -53,19 +55,6 @@ if (typeof document !== "undefined") {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function deserialize(value) {
|
|
57
|
-
if (!value || typeof value !== "object") return value
|
|
58
|
-
if (value.type === "undefined") return undefined
|
|
59
|
-
if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
|
|
60
|
-
if (value.type === "array") return value.value.map(deserialize)
|
|
61
|
-
if (value.type === "object") {
|
|
62
|
-
const object = value.nullPrototype ? Object.create(null) : {}
|
|
63
|
-
for (const [key, entry] of value.value) Object.defineProperty(object, key, { value: deserialize(entry), enumerable: true, writable: true, configurable: true })
|
|
64
|
-
return object
|
|
65
|
-
}
|
|
66
|
-
return value
|
|
67
|
-
}
|
|
68
|
-
|
|
69
58
|
function delegatedEvent(event, currentTarget) {
|
|
70
59
|
return new Proxy(event, {
|
|
71
60
|
get(source, property) {
|
package/framework/runtime.js
CHANGED
|
@@ -17,18 +17,16 @@ export function applyCommands(state, commands, commit, log = console.log) {
|
|
|
17
17
|
export const browserState = new Map()
|
|
18
18
|
|
|
19
19
|
export function commitDom(id, value) {
|
|
20
|
-
for (const node of document.querySelectorAll(`[data-k-text="${id}"]`))
|
|
21
|
-
node.textContent = value
|
|
22
|
-
node.dataset.kValue = JSON.stringify(value)
|
|
23
|
-
}
|
|
20
|
+
for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
if (typeof document !== "undefined") {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const initialState = document.body.dataset.kState
|
|
25
|
+
if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
|
|
26
|
+
for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
const eventNames = ["click", "input", "change"]
|
|
29
|
+
for (const eventName of eventNames) {
|
|
32
30
|
document.addEventListener(eventName, event => {
|
|
33
31
|
const target = event.target.closest(`[data-k-on-${eventName}]`)
|
|
34
32
|
if (!target) return
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function deserialize(value) {
|
|
2
|
+
if (!value || typeof value !== "object") return value
|
|
3
|
+
if (value.type === "undefined") return undefined
|
|
4
|
+
if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
|
|
5
|
+
if (value.type === "array") return value.value.map(deserialize)
|
|
6
|
+
if (value.type === "object") {
|
|
7
|
+
const object = value.nullPrototype ? Object.create(null) : {}
|
|
8
|
+
for (const [key, entry] of value.value) Object.defineProperty(object, key, { value: deserialize(entry), enumerable: true, writable: true, configurable: true })
|
|
9
|
+
return object
|
|
10
|
+
}
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function applyCommands(state, commands, commit, log = console.log) {
|
|
2
|
+
const changed = new Set()
|
|
3
|
+
|
|
4
|
+
for (const [operation, id, operand] of commands) {
|
|
5
|
+
const current = state.get(id)
|
|
6
|
+
if (operation === "log") {
|
|
7
|
+
log(operand, current)
|
|
8
|
+
continue
|
|
9
|
+
}
|
|
10
|
+
state.set(id, operation === "add" ? current + operand : operand)
|
|
11
|
+
changed.add(id)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const id of changed) commit(id, state.get(id))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const browserState = new Map()
|
|
18
|
+
const committers = []
|
|
19
|
+
|
|
20
|
+
export function registerCommitter(commit) {
|
|
21
|
+
committers.push(commit)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commitDom(id, value) {
|
|
25
|
+
for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
|
|
26
|
+
for (const commit of committers) commit(id)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof document !== "undefined") {
|
|
30
|
+
const initialState = document.body.dataset.kState
|
|
31
|
+
if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
|
|
32
|
+
for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
|
|
33
|
+
|
|
34
|
+
const eventNames = ["click", "input", "change"]
|
|
35
|
+
for (const eventName of eventNames) {
|
|
36
|
+
document.addEventListener(eventName, event => {
|
|
37
|
+
const target = event.target.closest(`[data-k-on-${eventName}]`)
|
|
38
|
+
if (!target) return
|
|
39
|
+
const commands = target.dataset[`kOn${capitalize(eventName)}`]
|
|
40
|
+
applyCommands(browserState, JSON.parse(commands), commitDom)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function capitalize(value) {
|
|
46
|
+
return value[0].toUpperCase() + value.slice(1)
|
|
47
|
+
}
|