@kudzujs/core 0.4.6 → 0.4.8
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 +44 -25
- package/framework/binding-runtime.js +6 -1
- package/framework/build.mjs +20 -12
- package/framework/core.d.ts +6 -0
- package/framework/core.mjs +24 -13
- package/framework/serialization.js +1 -0
- package/framework/style.js +23 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ The handler above increments by two and patches its bound DOM once. Inspect the
|
|
|
108
108
|
<input value={name} onInput={event => setName(event.currentTarget.value)} />
|
|
109
109
|
<input type="checkbox" checked={subscribed} onChange={event => setSubscribed(event.currentTarget.checked)} />
|
|
110
110
|
<select value={theme} onChange={event => setTheme(event.currentTarget.value)} />
|
|
111
|
+
<div style={{ opacity: open ? 1 : 0, width: open ? 240 : 0 }} />
|
|
111
112
|
```
|
|
112
113
|
|
|
113
114
|
Regular attributes use the same expressions without an allowlist:
|
|
@@ -121,7 +122,22 @@ Regular attributes use the same expressions without an allowlist:
|
|
|
121
122
|
/>
|
|
122
123
|
```
|
|
123
124
|
|
|
124
|
-
Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property. `aria-*` and `data-*` boolean values serialize as `"true"` or `"false"`; ordinary false values remove the attribute.
|
|
125
|
+
Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property. `aria-*` and `data-*` boolean values serialize as `"true"` or `"false"`; ordinary false values remove the attribute. Object `style` values use React-shaped camelCase properties, add `px` to nonzero dimensional numbers, and preserve unitless properties and CSS custom properties. Reactive `dangerouslySetInnerHTML` remains unsupported.
|
|
126
|
+
|
|
127
|
+
## DOM Refs
|
|
128
|
+
|
|
129
|
+
Use an object ref to access an element from a normal event handler:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
133
|
+
|
|
134
|
+
return <>
|
|
135
|
+
<input ref={inputRef} />
|
|
136
|
+
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
|
137
|
+
</>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Kudzu resolves `current` when the handler reads it, so removed conditional elements return `null` without a component runtime. Refs must initialize with `null`; callback refs, mutable value refs, and refs inside keyed lists are not supported.
|
|
125
141
|
|
|
126
142
|
## Conditional DOM
|
|
127
143
|
|
|
@@ -168,7 +184,7 @@ const [items, setItems] = useState([
|
|
|
168
184
|
|
|
169
185
|
Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
|
|
170
186
|
|
|
171
|
-
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags,
|
|
187
|
+
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, dynamic styles, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
172
188
|
|
|
173
189
|
## Normal JavaScript
|
|
174
190
|
|
|
@@ -205,6 +221,7 @@ TSX
|
|
|
205
221
|
|
|
206
222
|
- Static pages ship no client JavaScript.
|
|
207
223
|
- Interactive pages receive only the runtime capabilities they use.
|
|
224
|
+
- Production JavaScript is minified; development output stays readable.
|
|
208
225
|
- Components are authoring units; no component tree is retained in the browser.
|
|
209
226
|
- There is no VDOM, hydration pass, router, or client application runtime.
|
|
210
227
|
|
|
@@ -228,6 +245,8 @@ Supported:
|
|
|
228
245
|
- Serializable component-local captures
|
|
229
246
|
- Direct text DOM patches
|
|
230
247
|
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
248
|
+
- Reactive object `style` attributes
|
|
249
|
+
- Object DOM refs in native event handlers
|
|
231
250
|
- Controlled `value` and `checked` form properties
|
|
232
251
|
- Conditional child `&&` and ternary DOM patches
|
|
233
252
|
- Direct keyed local-state lists
|
|
@@ -249,13 +268,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
249
268
|
|
|
250
269
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
251
270
|
|---|---:|---:|---:|---:|
|
|
252
|
-
| Kudzu | Yes |
|
|
253
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
254
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
255
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
256
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
257
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
258
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
271
|
+
| Kudzu | Yes | 393 B | 1.1 KB | **431 ms** |
|
|
272
|
+
| Astro | Yes | **158 B** | **365 B** | 974 ms |
|
|
273
|
+
| Svelte CSR | No | 10.5 KB | 26.9 KB | 961 ms |
|
|
274
|
+
| Qwik CSR | No | 20.6 KB | 57.8 KB | 660 ms |
|
|
275
|
+
| Vue CSR | No | 24.0 KB | 60.3 KB | 859 ms |
|
|
276
|
+
| React CSR | No | 59.2 KB | 189.0 KB | 1133 ms |
|
|
277
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 3269 ms |
|
|
259
278
|
|
|
260
279
|
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.
|
|
261
280
|
|
|
@@ -265,13 +284,13 @@ Same content and CSS across every fixture:
|
|
|
265
284
|
|
|
266
285
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
267
286
|
|---|---:|---:|---:|---:|
|
|
268
|
-
| Kudzu | Yes | **0 B** | 3.2 KB | **
|
|
269
|
-
| Astro | Yes | **0 B** | **3.0 KB** |
|
|
270
|
-
| Svelte CSR | No | 10.2 KB | 27.2 KB |
|
|
271
|
-
| Qwik CSR | No | 20.2 KB | 59.6 KB |
|
|
272
|
-
| Vue CSR | No | 24.2 KB | 62.3 KB |
|
|
273
|
-
| React CSR | No | 59.8 KB | 192.3 KB |
|
|
274
|
-
| Next.js | Yes | 182.6 KB | 663.6 KB |
|
|
287
|
+
| Kudzu | Yes | **0 B** | 3.2 KB | **385 ms** |
|
|
288
|
+
| Astro | Yes | **0 B** | **3.0 KB** | 970 ms |
|
|
289
|
+
| Svelte CSR | No | 10.2 KB | 27.2 KB | 829 ms |
|
|
290
|
+
| Qwik CSR | No | 20.2 KB | 59.6 KB | 634 ms |
|
|
291
|
+
| Vue CSR | No | 24.2 KB | 62.3 KB | 767 ms |
|
|
292
|
+
| React CSR | No | 59.8 KB | 192.3 KB | 1098 ms |
|
|
293
|
+
| Next.js | Yes | 182.6 KB | 663.6 KB | 3217 ms |
|
|
275
294
|
|
|
276
295
|
### 1,000-item Keyed List
|
|
277
296
|
|
|
@@ -279,15 +298,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
279
298
|
|
|
280
299
|
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
281
300
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
282
|
-
| Astro | Yes | **324 B** | **43.6 KB** |
|
|
283
|
-
| Kudzu | Yes |
|
|
284
|
-
|
|
|
285
|
-
|
|
|
286
|
-
|
|
|
287
|
-
| Qwik CSR | No | 22.2 KB | 64.1 KB |
|
|
288
|
-
| Svelte CSR | No | 12.9 KB | 33.1 KB |
|
|
289
|
-
|
|
290
|
-
Astro is the hand-authored native DOM baseline in the interactive fixtures. 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. Kudzu's keyed-list operations total
|
|
301
|
+
| Astro | Yes | **324 B** | **43.6 KB** | 869 ms | **3.9 ms** | 29.4 ms | 13.9 ms | **18.3 ms** | **65.5 ms** |
|
|
302
|
+
| Kudzu | Yes | 5.0 KB | 60.3 KB | **437 ms** | 7.0 ms | 31.7 ms | **6.7 ms** | 22.0 ms | 67.4 ms |
|
|
303
|
+
| Vue CSR | No | 24.3 KB | 61.3 KB | 813 ms | 11.3 ms | 37.5 ms | 10.1 ms | 21.4 ms | 80.3 ms |
|
|
304
|
+
| Next.js | Yes | 182.2 KB | 695.2 KB | 3069 ms | 7.8 ms | 40.6 ms | 9.8 ms | 22.2 ms | 80.4 ms |
|
|
305
|
+
| React CSR | No | 59.3 KB | 189.4 KB | 1073 ms | 10.5 ms | 40.2 ms | 9.9 ms | 21.4 ms | 82.0 ms |
|
|
306
|
+
| Qwik CSR | No | 22.2 KB | 64.1 KB | 625 ms | 13.3 ms | **25.4 ms** | 37.1 ms | 24.4 ms | 100.2 ms |
|
|
307
|
+
| Svelte CSR | No | 12.9 KB | 33.1 KB | 905 ms | 6.5 ms | 72.3 ms | 9.2 ms | 22.3 ms | 110.3 ms |
|
|
308
|
+
|
|
309
|
+
Astro is the hand-authored native DOM baseline in the interactive fixtures. 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. In this run Kudzu's keyed-list operations total 67.4 ms, 1.9 ms behind Astro and 14.6 ms ahead of React across all four operations.
|
|
291
310
|
|
|
292
311
|
Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
|
|
293
312
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { browserState, mountDom, registerCommitter, registerMountHook, registerUnmountHook, unmountDom } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
|
+
import { serializeStyle } from "./style.js"
|
|
3
4
|
|
|
4
5
|
const imports = new Map()
|
|
5
6
|
const bindingTargets = new Map()
|
|
@@ -8,7 +9,7 @@ const mountedBindings = new WeakSet()
|
|
|
8
9
|
const mountedConditions = new WeakSet()
|
|
9
10
|
const bindingRegistrations = new WeakMap()
|
|
10
11
|
const conditionRegistrations = new WeakMap()
|
|
11
|
-
const bindingTypes = ["class", "disabled", "value", "checked"]
|
|
12
|
+
const bindingTypes = ["class", "disabled", "value", "checked", "style"]
|
|
12
13
|
const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
|
|
13
14
|
|
|
14
15
|
export function patchBinding(node, target, value) {
|
|
@@ -19,6 +20,10 @@ export function patchBinding(node, target, value) {
|
|
|
19
20
|
} else if (target === "value") {
|
|
20
21
|
const next = value == null ? "" : String(value)
|
|
21
22
|
if (node.value !== next) node.value = next
|
|
23
|
+
} else if (target === "style") {
|
|
24
|
+
const style = serializeStyle(value)
|
|
25
|
+
if (style) node.setAttribute("style", style)
|
|
26
|
+
else node.removeAttribute("style")
|
|
22
27
|
} else if (target === "class" && (value == null || value === false)) {
|
|
23
28
|
node.removeAttribute("class")
|
|
24
29
|
} else if (target === "class") {
|
package/framework/build.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"
|
|
|
3
3
|
import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
|
|
4
4
|
import { extname, join, relative, resolve, sep } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
6
|
+
import { transform } from "esbuild"
|
|
6
7
|
import ts from "typescript"
|
|
7
8
|
import { renderPage } from "./core.mjs"
|
|
8
9
|
import { stateSchema } from "./dev-state.js"
|
|
@@ -15,7 +16,7 @@ const outputDirectory = join(root, "dist")
|
|
|
15
16
|
|
|
16
17
|
const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\\/assets\\/kudzu(?:-(?:binding|list|native))?\\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
|
|
17
18
|
|
|
18
|
-
export async function build({ quiet = false } = {}) {
|
|
19
|
+
export async function build({ quiet = false, minify = true } = {}) {
|
|
19
20
|
await rm(workDirectory, { recursive: true, force: true })
|
|
20
21
|
await rm(outputDirectory, { recursive: true, force: true })
|
|
21
22
|
await mkdir(workDirectory, { recursive: true })
|
|
@@ -69,30 +70,32 @@ export async function build({ quiet = false } = {}) {
|
|
|
69
70
|
if (behaviorCount) {
|
|
70
71
|
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
71
72
|
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
72
|
-
await
|
|
73
|
+
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
73
74
|
}
|
|
74
|
-
if (bindingCount || hasNativeHandlers) await
|
|
75
|
+
if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify)
|
|
75
76
|
if (bindingCount) {
|
|
77
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
|
|
76
78
|
const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
77
79
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
78
80
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
79
|
-
|
|
81
|
+
.replace('"./style.js"', '"./kudzu-style.js"')
|
|
82
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify)
|
|
80
83
|
}
|
|
81
84
|
if (listCount) {
|
|
82
85
|
const listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
|
|
83
86
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
84
|
-
await
|
|
87
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify)
|
|
85
88
|
}
|
|
86
89
|
if (hasNativeHandlers) {
|
|
87
90
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
88
91
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
89
92
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
90
|
-
await
|
|
93
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify)
|
|
91
94
|
}
|
|
92
95
|
for (const handlerModule of handlerModules) {
|
|
93
96
|
const output = join(assetsDirectory, handlerModule.path)
|
|
94
97
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
95
|
-
await
|
|
98
|
+
await writeJavaScript(output, handlerModule.code, minify)
|
|
96
99
|
}
|
|
97
100
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
|
|
98
101
|
if (hasStyles) await cp(join(sourceDirectory, "style.css"), join(assetsDirectory, "style.css"))
|
|
@@ -111,12 +114,17 @@ function specializeNativeRuntime(source, events, modules) {
|
|
|
111
114
|
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
function specializeRuntime(source, events, hasStateSeed) {
|
|
117
|
+
export function specializeRuntime(source, events, hasStateSeed) {
|
|
115
118
|
const specialized = specializeEvents(source, events)
|
|
116
119
|
if (hasStateSeed) return specialized
|
|
117
120
|
return specialized
|
|
118
121
|
.replace(" const initialState = document.body.dataset.kState\n", "")
|
|
119
|
-
.replace(
|
|
122
|
+
.replace(/^ if \(initialState\).*\n/m, "")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function writeJavaScript(file, source, minify) {
|
|
126
|
+
const code = minify ? (await transform(source, { format: "esm", legalComments: "none", minify: true, target: "es2022" })).code : source
|
|
127
|
+
await writeFile(file, code)
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
export function parseDevPort(value) {
|
|
@@ -134,7 +142,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
134
142
|
let revision = 0
|
|
135
143
|
const session = randomUUID()
|
|
136
144
|
try {
|
|
137
|
-
await build()
|
|
145
|
+
await build({ minify: false })
|
|
138
146
|
revision++
|
|
139
147
|
} catch (error) {
|
|
140
148
|
buildError = errorText(error)
|
|
@@ -202,7 +210,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
202
210
|
do {
|
|
203
211
|
pending = false
|
|
204
212
|
try {
|
|
205
|
-
await build({ quiet: true })
|
|
213
|
+
await build({ quiet: true, minify: false })
|
|
206
214
|
buildError = undefined
|
|
207
215
|
revision++
|
|
208
216
|
console.log(`Rebuilt after ${changedFile ?? "source change"}`)
|
|
@@ -388,7 +396,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
388
396
|
}
|
|
389
397
|
}
|
|
390
398
|
|
|
391
|
-
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["
|
|
399
|
+
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
|
|
392
400
|
const expression = node.initializer.expression
|
|
393
401
|
const setters = settersForNode(node, settersByFunction)
|
|
394
402
|
const usedStates = referencedStateNames(expression, setters)
|
package/framework/core.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
|
|
|
2
2
|
|
|
3
3
|
export function useState<T>(initialValue: T): [T, StateSetter<T>]
|
|
4
4
|
|
|
5
|
+
export interface RefObject<T> {
|
|
6
|
+
readonly current: T | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useRef<T>(initialValue: null): RefObject<T>
|
|
10
|
+
|
|
5
11
|
export function behavior(commands: Array<["add" | "set" | "log", unknown, unknown]>): unknown
|
|
6
12
|
export function nativeBehavior(module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
|
7
13
|
export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
|
package/framework/core.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { serializeStyle } from "./style.js"
|
|
2
|
+
|
|
1
3
|
const signalMarker = Symbol("kudzu.signal")
|
|
2
4
|
const behaviorMarker = Symbol("kudzu.behavior")
|
|
3
5
|
const nativeBehaviorMarker = Symbol("kudzu.nativeBehavior")
|
|
@@ -7,6 +9,7 @@ const listMarker = Symbol("kudzu.list")
|
|
|
7
9
|
const listFieldMarker = Symbol("kudzu.listField")
|
|
8
10
|
const listExpressionMarker = Symbol("kudzu.listExpression")
|
|
9
11
|
const listItemMarker = Symbol("kudzu.listItem")
|
|
12
|
+
const refMarker = Symbol("kudzu.ref")
|
|
10
13
|
const noSelectValue = Symbol("kudzu.no-select-value")
|
|
11
14
|
|
|
12
15
|
let renderContext
|
|
@@ -35,6 +38,12 @@ export function useState(initialValue, name) {
|
|
|
35
38
|
}]
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
export function useRef(initialValue) {
|
|
42
|
+
if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
|
|
43
|
+
if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
|
|
44
|
+
return { [refMarker]: true, id: `r${renderContext.nextRef++}`, current: null }
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
export function behavior(commands) {
|
|
39
48
|
return {
|
|
40
49
|
[behaviorMarker]: true,
|
|
@@ -156,6 +165,7 @@ function bindingDescriptor(value) {
|
|
|
156
165
|
|
|
157
166
|
function serializeCapture(name, value, seen) {
|
|
158
167
|
if (value?.[listItemMarker]) return { type: "list-item" }
|
|
168
|
+
if (value?.[refMarker]) return { type: "ref", id: value.id }
|
|
159
169
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
160
170
|
if (typeof value === "number") {
|
|
161
171
|
return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
|
|
@@ -187,7 +197,7 @@ function serializeCapture(name, value, seen) {
|
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
export async function renderPage(component, metadata = {}) {
|
|
190
|
-
renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listFields: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
|
|
200
|
+
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listFields: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
|
|
191
201
|
|
|
192
202
|
try {
|
|
193
203
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -349,10 +359,16 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
349
359
|
|
|
350
360
|
for (const [rawName, value] of Object.entries(props)) {
|
|
351
361
|
if (rawName === "children" || rawName === "key") continue
|
|
362
|
+
if (rawName === "ref") {
|
|
363
|
+
if (!value?.[refMarker]) throw new Error("ref must be created by useRef(null)")
|
|
364
|
+
if (renderContext.listDepth) throw new Error("Refs are not supported in keyed lists")
|
|
365
|
+
attributes += ` data-k-ref="${value.id}"`
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
352
368
|
if (rawName === "selected" && selectValue !== noSelectValue) continue
|
|
353
369
|
if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
|
|
354
370
|
if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
|
|
355
|
-
if (["
|
|
371
|
+
if (["ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
|
|
356
372
|
throw new Error(`Reactive ${rawName} is not supported`)
|
|
357
373
|
}
|
|
358
374
|
|
|
@@ -377,7 +393,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
377
393
|
}
|
|
378
394
|
|
|
379
395
|
const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
|
|
380
|
-
const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked"
|
|
396
|
+
const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked" || name === "style"
|
|
381
397
|
if (value?.[listFieldMarker]) {
|
|
382
398
|
attributes += renderAttribute(name, value.value)
|
|
383
399
|
listAttributes.push([name, value.field])
|
|
@@ -409,12 +425,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
409
425
|
}
|
|
410
426
|
|
|
411
427
|
if (tag === "select" && name === "value") continue
|
|
412
|
-
|
|
413
|
-
const style = Object.entries(value).map(([property, entry]) => `${toKebabCase(property)}:${entry}`).join(";")
|
|
414
|
-
attributes += ` style="${escapeAttribute(style)}"`
|
|
415
|
-
} else {
|
|
416
|
-
attributes += renderAttribute(name, value)
|
|
417
|
-
}
|
|
428
|
+
attributes += renderAttribute(name, value)
|
|
418
429
|
}
|
|
419
430
|
|
|
420
431
|
if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
|
|
@@ -481,6 +492,10 @@ function reactiveStateIds(descriptor) {
|
|
|
481
492
|
}
|
|
482
493
|
|
|
483
494
|
function renderAttribute(name, value) {
|
|
495
|
+
if (name === "style") {
|
|
496
|
+
const style = serializeStyle(value)
|
|
497
|
+
return style ? ` style="${escapeAttribute(style)}"` : ""
|
|
498
|
+
}
|
|
484
499
|
if (name === "disabled" || name === "checked") return value ? ` ${name}` : ""
|
|
485
500
|
if (name === "value") return value == null ? "" : ` value="${escapeAttribute(value)}"`
|
|
486
501
|
if (value == null || (value === false && !isStringBooleanAttribute(name))) return ""
|
|
@@ -525,7 +540,3 @@ function listSeed(items, fields) {
|
|
|
525
540
|
}
|
|
526
541
|
return seed
|
|
527
542
|
}
|
|
528
|
-
|
|
529
|
-
function toKebabCase(value) {
|
|
530
|
-
return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
|
|
531
|
-
}
|
|
@@ -2,6 +2,7 @@ export function deserialize(value) {
|
|
|
2
2
|
if (!value || typeof value !== "object") return value
|
|
3
3
|
if (value.type === "undefined") return undefined
|
|
4
4
|
if (value.type === "number") return value.value === "NaN" ? NaN : value.value === "Infinity" ? Infinity : value.value === "-Infinity" ? -Infinity : -0
|
|
5
|
+
if (value.type === "ref") return { get current() { return typeof document === "undefined" ? null : document.querySelector(`[data-k-ref="${value.id}"]`) } }
|
|
5
6
|
if (value.type === "array") return value.value.map(deserialize)
|
|
6
7
|
if (value.type === "object") {
|
|
7
8
|
const object = value.nullPrototype ? Object.create(null) : {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const unitless = new Set([
|
|
2
|
+
"animationIterationCount", "aspectRatio", "borderImageOutset", "borderImageSlice", "borderImageWidth",
|
|
3
|
+
"columnCount", "columns", "flex", "flexGrow", "flexShrink", "fontWeight", "gridArea", "gridColumn",
|
|
4
|
+
"gridColumnEnd", "gridColumnSpan", "gridColumnStart", "gridRow", "gridRowEnd", "gridRowSpan", "gridRowStart",
|
|
5
|
+
"lineClamp", "lineHeight", "opacity", "order", "orphans", "scale", "tabSize", "widows", "zIndex", "zoom",
|
|
6
|
+
"fillOpacity", "floodOpacity", "stopOpacity", "strokeDasharray", "strokeDashoffset", "strokeMiterlimit",
|
|
7
|
+
"strokeOpacity", "strokeWidth"
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
export function serializeStyle(value) {
|
|
11
|
+
if (value == null || value === false) return ""
|
|
12
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new Error("style must be an object")
|
|
13
|
+
return Object.entries(value).flatMap(([property, entry]) => {
|
|
14
|
+
if (entry == null || typeof entry === "boolean") return []
|
|
15
|
+
if (typeof entry === "number" && !Number.isFinite(entry)) throw new Error(`style.${property} must be finite`)
|
|
16
|
+
const suffix = typeof entry === "number" && entry !== 0 && !property.startsWith("--") && !unitless.has(property) ? "px" : ""
|
|
17
|
+
return `${toKebabCase(property)}:${entry}${suffix}`
|
|
18
|
+
}).join(";")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toKebabCase(value) {
|
|
22
|
+
return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`).replace(/^ms-/, "-ms-")
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kudzujs/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"preview": "wrangler dev"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
+
"esbuild": "^0.28.1",
|
|
56
57
|
"typescript": "^5.9.2"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|