@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 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.2.x`: the compiler API and supported TSX surface may change.
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
- └─ normal JS handlerroute handler ESM
160
+ ├─ conditional childbounded 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
- - Conditional DOM patches and keyed lists
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 | 563 B | 1.7 KB | **384 ms** |
186
- | Astro | Yes | **158 B** | **365 B** | 911 ms |
187
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 910 ms |
188
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 627 ms |
189
- | Vue CSR | No | 24.0 KB | 60.3 KB | 814 ms |
190
- | React CSR | No | 59.2 KB | 189.0 KB | 1052 ms |
191
- | Next.js | Yes | 182.1 KB | 652.2 KB | 2985 ms |
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
 
@@ -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 `className`, `disabled`, and `value` patches.
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
- for (const binding of bindingTargets.get(id) ?? []) patchBinding(binding.node, binding.target, binding.read())
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
- if (typeof document !== "undefined") {
26
- const imports = new Map()
27
- const registrations = []
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 document.querySelectorAll(`[data-k-bind-${target}]`)) {
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
- registerBinding(descriptor.state, { node, target, read: () => browserState.get(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())
33
65
  continue
34
66
  }
35
- let modulePromise = imports.get(descriptor.module)
36
- if (!modulePromise) {
37
- modulePromise = import(descriptor.module)
38
- imports.set(descriptor.module, modulePromise)
39
- }
40
- registrations.push(modulePromise.then(async module => {
41
- const context = await createBindingContext(descriptor, imports)
42
- const binding = { node, target, read: () => module[descriptor.handler](context) }
43
- for (const id of bindingStateIds(descriptor)) registerBinding(id, binding)
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 registerBinding(id, binding) {
52
- const bindings = bindingTargets.get(id) ?? []
53
- bindings.push(binding)
54
- bindingTargets.set(id, bindings)
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 createBindingContext(descriptor, imports) {
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
- let modulePromise = imports.get(binding.module)
62
- if (!modulePromise) {
63
- modulePromise = import(binding.module)
64
- imports.set(binding.module, modulePromise)
65
- }
66
- const [module, context] = await Promise.all([modulePromise, createBindingContext(binding, imports)])
67
- nested[name] = () => module[binding.handler](context)
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
  }
@@ -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 (reactiveBindings.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
261
- if (reactiveBindings.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
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 factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, [
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) {
@@ -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
  }>
@@ -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="${escapeAttribute(JSON.stringify(initialState))}"`
148
+ ? ` data-k-state='${escapeJsonAttribute(initialState)}'`
142
149
  : ""
143
150
 
144
151
  return {
145
- html: `<!doctype html>\n<html lang="${escapeAttribute(metadata.lang ?? "en")}">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${title}</title>\n${head}${styles}\n</head>\n<body${state}>\n${body}\n${runtime}\n${bindingRuntime}\n${nativeRuntime}\n</body>\n</html>\n`,
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.length ? `${tags.join("\n")}\n` : ""
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
- return `<span data-k-text="${node.id}" data-k-value="${escapeAttribute(JSON.stringify(node.value))}">${escapeHtml(node.value)}</span>`
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}="${escapeAttribute(commands)}"`
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}="${escapeAttribute(JSON.stringify(native))}"`
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}="${escapeAttribute(JSON.stringify(descriptor))}"`
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("'", "&#39;")
293
335
  }
294
336
 
337
+ function escapeJsonAttribute(value) {
338
+ return JSON.stringify(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll("'", "&#39;")
339
+ }
340
+
295
341
  function toKebabCase(value) {
296
342
  return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
297
343
  }
@@ -1,41 +1,30 @@
1
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
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 = new Map()
14
+ export const browserState=new Map
18
15
 
19
16
  export function commitDom(id, value) {
20
- for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
17
+ for(const node of document.querySelectorAll(`[data-k-text="${id}"]`))node.textContent = value
21
18
  }
22
19
 
23
- if (typeof document !== "undefined") {
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 (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
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 (const eventName of eventNames) {
30
- document.addEventListener(eventName, event => {
31
- const target = event.target.closest(`[data-k-on-${eventName}]`)
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
- for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
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.2.3",
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",