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