@kudzujs/core 0.2.3 → 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 +35 -12
- package/framework/README.md +1 -1
- package/framework/binding-runtime.js +154 -30
- package/framework/build.mjs +51 -4
- package/framework/core.d.ts +2 -0
- package/framework/core.mjs +65 -19
- package/framework/runtime.js +15 -26
- package/framework/shared-runtime.js +13 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,11 +6,9 @@
|
|
|
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.
|
|
14
12
|
|
|
15
13
|
Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
|
|
16
14
|
|
|
@@ -56,6 +54,8 @@ Configure TypeScript:
|
|
|
56
54
|
}
|
|
57
55
|
```
|
|
58
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
|
+
|
|
59
59
|
Create `src/pages/index.tsx`:
|
|
60
60
|
|
|
61
61
|
```tsx
|
|
@@ -110,6 +110,27 @@ The handler above increments by two and patches its bound DOM once. Inspect the
|
|
|
110
110
|
|
|
111
111
|
Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property.
|
|
112
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
|
+
|
|
113
134
|
## Normal JavaScript
|
|
114
135
|
|
|
115
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.
|
|
@@ -136,7 +157,8 @@ Primitive values, arrays, plain objects, and destructured props can be captured
|
|
|
136
157
|
TSX
|
|
137
158
|
├─ static component → HTML
|
|
138
159
|
├─ ordered state setter → behavior command
|
|
139
|
-
|
|
160
|
+
├─ conditional child → bounded DOM range
|
|
161
|
+
└─ normal JS handler → external ESM
|
|
140
162
|
```
|
|
141
163
|
|
|
142
164
|
- Static pages ship no client JavaScript.
|
|
@@ -164,10 +186,11 @@ Supported:
|
|
|
164
186
|
- Serializable component-local captures
|
|
165
187
|
- Direct text DOM patches
|
|
166
188
|
- Reactive `className`, `disabled`, and controlled `value` patches
|
|
189
|
+
- Conditional child `&&` and ternary DOM patches
|
|
167
190
|
|
|
168
191
|
Not implemented yet:
|
|
169
192
|
|
|
170
|
-
-
|
|
193
|
+
- Keyed lists and generalized JSX-valued locals
|
|
171
194
|
- Server actions and request-time SSR
|
|
172
195
|
- Imported client helpers and React package islands
|
|
173
196
|
- HMR and framework DevTools
|
|
@@ -182,13 +205,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
182
205
|
|
|
183
206
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
184
207
|
|---|---:|---:|---:|---:|
|
|
185
|
-
| Kudzu | Yes |
|
|
186
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
187
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
188
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
189
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
190
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
191
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
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 |
|
|
214
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 3074 ms |
|
|
192
215
|
|
|
193
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.
|
|
194
217
|
|
package/framework/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
- `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
7
|
- `shared-runtime.js`: command runtime with binding commit hooks, emitted only when needed.
|
|
8
|
-
- `binding-runtime.js`: optional reactive
|
|
8
|
+
- `binding-runtime.js`: optional reactive attributes and conditional DOM range patches.
|
|
9
9
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
10
10
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
11
11
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { browserState, registerCommitter } from "./shared-runtime.js"
|
|
1
|
+
import { browserState, mountText, registerCommitter } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
3
|
|
|
4
|
+
const imports = new Map()
|
|
4
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()
|
|
5
11
|
|
|
6
12
|
export function patchBinding(node, target, value) {
|
|
7
13
|
if (target === "disabled") {
|
|
@@ -17,54 +23,168 @@ export function patchBinding(node, target, value) {
|
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
function commitBindings(id) {
|
|
20
|
-
|
|
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
|
+
}
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
registerCommitter(commitBindings)
|
|
44
|
+
registerCommitter(commitConditions)
|
|
45
|
+
|
|
46
|
+
if (typeof document !== "undefined") mountDom(document)
|
|
24
47
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
function mountDom(root) {
|
|
49
|
+
mountText(root)
|
|
50
|
+
mountBindings(root)
|
|
51
|
+
mountConditions(root)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mountBindings(root) {
|
|
28
55
|
for (const target of ["class", "disabled", "value"]) {
|
|
29
|
-
for (const node of
|
|
56
|
+
for (const node of matching(root, `[data-k-bind-${target}]`)) {
|
|
57
|
+
if (mountedBindings.has(node)) continue
|
|
58
|
+
mountedBindings.add(node)
|
|
30
59
|
const descriptor = JSON.parse(node.dataset[`kBind${capitalize(target)}`])
|
|
31
60
|
if (descriptor.state) {
|
|
32
|
-
|
|
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())
|
|
33
65
|
continue
|
|
34
66
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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)
|
|
44
76
|
patchBinding(node, target, binding.read())
|
|
45
|
-
}))
|
|
77
|
+
}).catch(error => console.error(error))
|
|
46
78
|
}
|
|
47
79
|
}
|
|
48
|
-
Promise.all(registrations).catch(error => console.error(error))
|
|
49
80
|
}
|
|
50
81
|
|
|
51
|
-
function
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
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)
|
|
55
170
|
}
|
|
56
171
|
|
|
57
|
-
async function
|
|
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) {
|
|
58
183
|
const scope = Object.fromEntries(Object.entries(descriptor.scope).map(([name, value]) => [name, deserialize(value)]))
|
|
59
184
|
const nested = {}
|
|
60
185
|
await Promise.all(Object.entries(descriptor.scopeBindings).map(async ([name, binding]) => {
|
|
61
|
-
|
|
62
|
-
|
|
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)
|
|
186
|
+
const evaluator = await loadEvaluator(binding)
|
|
187
|
+
nested[name] = evaluator.read
|
|
68
188
|
}))
|
|
69
189
|
return {
|
|
70
190
|
get: name => browserState.get(descriptor.states[name]),
|
|
@@ -80,6 +200,10 @@ function bindingStateIds(descriptor) {
|
|
|
80
200
|
])
|
|
81
201
|
}
|
|
82
202
|
|
|
203
|
+
function matching(root, selector) {
|
|
204
|
+
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
205
|
+
}
|
|
206
|
+
|
|
83
207
|
function capitalize(value) {
|
|
84
208
|
return value[0].toUpperCase() + value.slice(1)
|
|
85
209
|
}
|
package/framework/build.mjs
CHANGED
|
@@ -185,6 +185,8 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
185
185
|
const settersByFunction = new Map()
|
|
186
186
|
const functions = new Map()
|
|
187
187
|
let usesBehavior = false
|
|
188
|
+
let usesBinding = false
|
|
189
|
+
let usesConditional = false
|
|
188
190
|
|
|
189
191
|
const collect = node => {
|
|
190
192
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
@@ -226,6 +228,23 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
226
228
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
|
|
227
229
|
}
|
|
228
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
|
+
|
|
229
248
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && ["className", "disabled", "value"].includes(node.name.getText())) {
|
|
230
249
|
const expression = node.initializer.expression
|
|
231
250
|
const setters = settersForNode(node, settersByFunction)
|
|
@@ -233,6 +252,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
233
252
|
const captures = captureNames(expression, expression, setters)
|
|
234
253
|
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
|
|
235
254
|
usesBehavior = true
|
|
255
|
+
usesBinding = true
|
|
236
256
|
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
|
|
237
257
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
|
|
238
258
|
}
|
|
@@ -257,8 +277,9 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
257
277
|
|
|
258
278
|
const behaviorImports = [factory.createImportSpecifier(false, factory.createIdentifier("behavior"), factory.createIdentifier("__kBehavior"))]
|
|
259
279
|
if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
|
|
260
|
-
if (
|
|
261
|
-
if (
|
|
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")))
|
|
262
283
|
const behaviorImport = factory.createImportDeclaration(
|
|
263
284
|
undefined,
|
|
264
285
|
factory.createImportClause(false, undefined, factory.createNamedImports(behaviorImports)),
|
|
@@ -269,6 +290,16 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
|
|
|
269
290
|
}
|
|
270
291
|
|
|
271
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) {
|
|
272
303
|
const usedStates = referencedStateNames(expression, setters)
|
|
273
304
|
const captures = captureNames(expression, expression, setters)
|
|
274
305
|
const exportName = `binding${reactiveBindings.length}`
|
|
@@ -297,13 +328,29 @@ function compileReactiveBinding(expression, setters, factory, context, reactiveB
|
|
|
297
328
|
}
|
|
298
329
|
return ts.visitEachChild(node, rewriteInitial, context)
|
|
299
330
|
}
|
|
300
|
-
return
|
|
331
|
+
return [
|
|
301
332
|
ts.visitNode(expression, rewriteInitial),
|
|
302
333
|
factory.createStringLiteral(handlerUrl),
|
|
303
334
|
factory.createStringLiteral(exportName),
|
|
304
335
|
factory.createArrayLiteralExpression(states),
|
|
305
336
|
factory.createArrayLiteralExpression(scope)
|
|
306
|
-
]
|
|
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()
|
|
307
354
|
}
|
|
308
355
|
|
|
309
356
|
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
|
package/framework/core.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export function behavior(commands: Array<["add" | "set" | "log", unknown, unknow
|
|
|
6
6
|
export function nativeBehavior(module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
|
7
7
|
export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
|
8
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
|
|
9
10
|
|
|
10
11
|
export function renderPage(
|
|
11
12
|
component: (props: Record<string, never>) => unknown | Promise<unknown>,
|
|
@@ -49,5 +50,6 @@ export function renderPage(
|
|
|
49
50
|
scopeStates?: Record<string, string>
|
|
50
51
|
scopeBindings?: Record<string, unknown>
|
|
51
52
|
}>
|
|
53
|
+
conditions: Array<Record<string, unknown>>
|
|
52
54
|
}
|
|
53
55
|
}>
|
package/framework/core.mjs
CHANGED
|
@@ -2,6 +2,7 @@ const signalMarker = Symbol("kudzu.signal")
|
|
|
2
2
|
const behaviorMarker = Symbol("kudzu.behavior")
|
|
3
3
|
const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
|
|
4
4
|
const bindingMarker = Symbol("kudzu.binding")
|
|
5
|
+
const conditionalMarker = Symbol("kudzu.conditional")
|
|
5
6
|
|
|
6
7
|
let renderContext
|
|
7
8
|
|
|
@@ -53,6 +54,14 @@ export function nativeBehavior(module, handler, states, scope) {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
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) {
|
|
56
65
|
const scopeStates = {}
|
|
57
66
|
const serializedScope = {}
|
|
58
67
|
const scopeBindings = {}
|
|
@@ -62,8 +71,6 @@ export function binding(value, module, handler, states, scope) {
|
|
|
62
71
|
else serializedScope[name] = serializeCapture(name, entry, new Set())
|
|
63
72
|
}
|
|
64
73
|
return {
|
|
65
|
-
[bindingMarker]: true,
|
|
66
|
-
value,
|
|
67
74
|
module,
|
|
68
75
|
handler,
|
|
69
76
|
states: Object.fromEntries(states.map(([name, signal]) => {
|
|
@@ -116,7 +123,7 @@ function serializeCapture(name, value, seen) {
|
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
export async function renderPage(component, metadata = {}) {
|
|
119
|
-
renderContext = { nextState: 0, states: {}, textStates: new Set(), events: [], bindings: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: 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 }
|
|
120
127
|
|
|
121
128
|
try {
|
|
122
129
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -135,21 +142,22 @@ export async function renderPage(component, metadata = {}) {
|
|
|
135
142
|
? '<script type="module" src="/assets/kudzu-binding.js"></script>'
|
|
136
143
|
: ""
|
|
137
144
|
const initialState = renderContext.hasBehaviors
|
|
138
|
-
? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
|
|
145
|
+
? Object.entries(renderContext.states).filter(([id]) => !renderContext.textStates.has(id) || renderContext.conditionStates.has(id)).map(([id, entry]) => [id, entry.initialValue])
|
|
139
146
|
: []
|
|
140
147
|
const state = initialState.length
|
|
141
|
-
? ` data-k-state=
|
|
148
|
+
? ` data-k-state='${escapeJsonAttribute(initialState)}'`
|
|
142
149
|
: ""
|
|
143
150
|
|
|
144
151
|
return {
|
|
145
|
-
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>`,
|
|
146
153
|
hasBehaviors: renderContext.hasBehaviors,
|
|
147
154
|
hasBindings: renderContext.hasBindings,
|
|
148
155
|
hasStateSeed: initialState.length > 0,
|
|
149
156
|
plan: {
|
|
150
157
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
151
158
|
events: renderContext.events,
|
|
152
|
-
bindings: renderContext.bindings
|
|
159
|
+
bindings: renderContext.bindings,
|
|
160
|
+
conditions: renderContext.conditions
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
} finally {
|
|
@@ -172,7 +180,7 @@ function renderMetadata(metadata) {
|
|
|
172
180
|
|
|
173
181
|
meta("og:title", metadata.title, true)
|
|
174
182
|
meta("og:description", metadata.description, true)
|
|
175
|
-
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)
|
|
176
184
|
meta("og:url", metadata.url, true)
|
|
177
185
|
meta("og:image", metadata.image, true)
|
|
178
186
|
if (metadata.image) {
|
|
@@ -187,33 +195,57 @@ function renderMetadata(metadata) {
|
|
|
187
195
|
meta("twitter:description", metadata.description)
|
|
188
196
|
meta("twitter:image", metadata.twitterImage ?? metadata.image)
|
|
189
197
|
|
|
190
|
-
return tags.
|
|
198
|
+
return tags.join("")
|
|
191
199
|
}
|
|
192
200
|
|
|
193
|
-
async function renderNode(node) {
|
|
201
|
+
async function renderNode(node, namespace) {
|
|
194
202
|
if (node == null || node === false || node === true) return ""
|
|
195
203
|
if (Array.isArray(node)) {
|
|
196
204
|
let html = ""
|
|
197
|
-
for (const child of node) html += await renderNode(child)
|
|
205
|
+
for (const child of node) html += await renderNode(child, namespace)
|
|
198
206
|
return html
|
|
199
207
|
}
|
|
200
208
|
if (node?.[signalMarker]) {
|
|
201
209
|
renderContext.textStates.add(node.id)
|
|
202
|
-
|
|
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>`
|
|
203
212
|
}
|
|
204
213
|
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
|
|
205
214
|
return escapeHtml(node)
|
|
206
215
|
}
|
|
207
|
-
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
|
+
}
|
|
208
237
|
if (!node || typeof node !== "object" || !("type" in node)) {
|
|
209
238
|
throw new Error(`Cannot render ${String(node)}`)
|
|
210
239
|
}
|
|
211
240
|
|
|
212
|
-
if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children)
|
|
213
|
-
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)
|
|
214
243
|
|
|
215
244
|
const tag = node.type
|
|
216
245
|
const props = node.props ?? {}
|
|
246
|
+
const childNamespace = tag === "svg" || tag === "math"
|
|
247
|
+
? tag
|
|
248
|
+
: namespace === "svg" && tag === "foreignObject" ? undefined : namespace
|
|
217
249
|
let attributes = ""
|
|
218
250
|
|
|
219
251
|
for (const [rawName, value] of Object.entries(props)) {
|
|
@@ -223,11 +255,11 @@ async function renderNode(node) {
|
|
|
223
255
|
const event = rawName.slice(2).toLowerCase()
|
|
224
256
|
if (value?.[behaviorMarker]) {
|
|
225
257
|
const commands = JSON.stringify(value.commands)
|
|
226
|
-
attributes += ` data-k-on-${event}=
|
|
258
|
+
attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
|
|
227
259
|
renderContext.events.push({ event, commands: value.commands })
|
|
228
260
|
} else if (value?.[nativeBehaviorMarker]) {
|
|
229
261
|
const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
|
|
230
|
-
attributes += ` data-k-native-${event}=
|
|
262
|
+
attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
|
|
231
263
|
renderContext.events.push({ event, native })
|
|
232
264
|
renderContext.hasNativeBehaviors = true
|
|
233
265
|
} else {
|
|
@@ -250,8 +282,9 @@ async function renderNode(node) {
|
|
|
250
282
|
? { state: value.id }
|
|
251
283
|
: bindingDescriptor(value)
|
|
252
284
|
attributes += renderAttribute(name, initialValue)
|
|
253
|
-
attributes += ` data-k-bind-${target}=
|
|
285
|
+
attributes += ` data-k-bind-${target}='${escapeJsonAttribute(descriptor)}'`
|
|
254
286
|
renderContext.bindings.push({ target, ...descriptor })
|
|
287
|
+
if (renderContext.conditionDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
|
|
255
288
|
renderContext.hasBehaviors = true
|
|
256
289
|
renderContext.hasBindings = true
|
|
257
290
|
continue
|
|
@@ -270,7 +303,16 @@ async function renderNode(node) {
|
|
|
270
303
|
|
|
271
304
|
const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
|
|
272
305
|
if (voidElements.has(tag)) return `<${tag}${attributes}>`
|
|
273
|
-
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
|
+
])
|
|
274
316
|
}
|
|
275
317
|
|
|
276
318
|
function renderAttribute(name, value) {
|
|
@@ -292,6 +334,10 @@ function escapeAttribute(value) {
|
|
|
292
334
|
return escapeHtml(value).replaceAll("'", "'")
|
|
293
335
|
}
|
|
294
336
|
|
|
337
|
+
function escapeJsonAttribute(value) {
|
|
338
|
+
return JSON.stringify(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll("'", "'")
|
|
339
|
+
}
|
|
340
|
+
|
|
295
341
|
function toKebabCase(value) {
|
|
296
342
|
return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
|
|
297
343
|
}
|
package/framework/runtime.js
CHANGED
|
@@ -1,41 +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
|
|
17
|
+
for(const node of document.querySelectorAll(`[data-k-text="${id}"]`))node.textContent = value
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
if
|
|
20
|
+
if(typeof document!=="undefined"){
|
|
24
21
|
const initialState = document.body.dataset.kState
|
|
25
22
|
if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
|
|
26
|
-
for
|
|
23
|
+
for(const node of document.querySelectorAll("[data-k-text]"))browserState.set(node.dataset.kText,JSON.parse(node.dataset.kValue))
|
|
27
24
|
|
|
28
25
|
const eventNames = ["click", "input", "change"]
|
|
29
|
-
for
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!target) return
|
|
33
|
-
const commands = target.dataset[`kOn${capitalize(eventName)}`]
|
|
34
|
-
applyCommands(browserState, JSON.parse(commands), commitDom)
|
|
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)
|
|
35
29
|
})
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function capitalize(value) {
|
|
40
|
-
return value[0].toUpperCase() + value.slice(1)
|
|
41
30
|
}
|
|
@@ -26,10 +26,18 @@ export function commitDom(id, value) {
|
|
|
26
26
|
for (const commit of committers) commit(id)
|
|
27
27
|
}
|
|
28
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
|
+
|
|
29
37
|
if (typeof document !== "undefined") {
|
|
30
38
|
const initialState = document.body.dataset.kState
|
|
31
39
|
if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
|
|
32
|
-
|
|
40
|
+
mountText(document)
|
|
33
41
|
|
|
34
42
|
const eventNames = ["click", "input", "change"]
|
|
35
43
|
for (const eventName of eventNames) {
|
|
@@ -42,6 +50,10 @@ if (typeof document !== "undefined") {
|
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
function matching(root, selector) {
|
|
54
|
+
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
function capitalize(value) {
|
|
46
58
|
return value[0].toUpperCase() + value.slice(1)
|
|
47
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",
|