@kudzujs/core 0.4.7 → 0.4.9
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 -26
- package/framework/README.md +1 -1
- package/framework/build.mjs +31 -15
- package/framework/core.d.ts +7 -0
- package/framework/core.mjs +18 -1
- package/framework/list-runtime.js +1 -0
- package/framework/serialization.js +1 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -122,7 +122,22 @@ Regular attributes use the same expressions without an allowlist:
|
|
|
122
122
|
/>
|
|
123
123
|
```
|
|
124
124
|
|
|
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 `
|
|
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.
|
|
126
141
|
|
|
127
142
|
## Conditional DOM
|
|
128
143
|
|
|
@@ -160,6 +175,7 @@ const [items, setItems] = useState([
|
|
|
160
175
|
key={item.id}
|
|
161
176
|
className={item.done ? "done" : "active"}
|
|
162
177
|
aria-label={`${item.name} item`}
|
|
178
|
+
style={{ opacity: item.done ? 0.5 : 1 }}
|
|
163
179
|
>
|
|
164
180
|
{item.name.toUpperCase()}
|
|
165
181
|
<button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
|
|
@@ -167,9 +183,9 @@ const [items, setItems] = useState([
|
|
|
167
183
|
)}</ul>
|
|
168
184
|
```
|
|
169
185
|
|
|
170
|
-
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.
|
|
186
|
+
Kudzu emits initial items as static HTML, then adds, removes, updates, styles, 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.
|
|
171
187
|
|
|
172
|
-
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,
|
|
188
|
+
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, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
173
189
|
|
|
174
190
|
## Normal JavaScript
|
|
175
191
|
|
|
@@ -206,6 +222,7 @@ TSX
|
|
|
206
222
|
|
|
207
223
|
- Static pages ship no client JavaScript.
|
|
208
224
|
- Interactive pages receive only the runtime capabilities they use.
|
|
225
|
+
- Production JavaScript is minified; development output stays readable.
|
|
209
226
|
- Components are authoring units; no component tree is retained in the browser.
|
|
210
227
|
- There is no VDOM, hydration pass, router, or client application runtime.
|
|
211
228
|
|
|
@@ -230,6 +247,7 @@ Supported:
|
|
|
230
247
|
- Direct text DOM patches
|
|
231
248
|
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
232
249
|
- Reactive object `style` attributes
|
|
250
|
+
- Object DOM refs in native event handlers
|
|
233
251
|
- Controlled `value` and `checked` form properties
|
|
234
252
|
- Conditional child `&&` and ternary DOM patches
|
|
235
253
|
- Direct keyed local-state lists
|
|
@@ -251,13 +269,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
251
269
|
|
|
252
270
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
253
271
|
|---|---:|---:|---:|---:|
|
|
254
|
-
| Kudzu | Yes |
|
|
255
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
256
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
257
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
258
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
259
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
260
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
272
|
+
| Kudzu | Yes | 393 B | 1.1 KB | **431 ms** |
|
|
273
|
+
| Astro | Yes | **158 B** | **365 B** | 974 ms |
|
|
274
|
+
| Svelte CSR | No | 10.5 KB | 26.9 KB | 961 ms |
|
|
275
|
+
| Qwik CSR | No | 20.6 KB | 57.8 KB | 660 ms |
|
|
276
|
+
| Vue CSR | No | 24.0 KB | 60.3 KB | 859 ms |
|
|
277
|
+
| React CSR | No | 59.2 KB | 189.0 KB | 1133 ms |
|
|
278
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 3269 ms |
|
|
261
279
|
|
|
262
280
|
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.
|
|
263
281
|
|
|
@@ -267,13 +285,13 @@ Same content and CSS across every fixture:
|
|
|
267
285
|
|
|
268
286
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
269
287
|
|---|---:|---:|---:|---:|
|
|
270
|
-
| Kudzu | Yes | **0 B** | 3.2 KB | **
|
|
271
|
-
| Astro | Yes | **0 B** | **3.0 KB** |
|
|
272
|
-
| Svelte CSR | No | 10.2 KB | 27.2 KB |
|
|
273
|
-
| Qwik CSR | No | 20.2 KB | 59.6 KB |
|
|
274
|
-
| Vue CSR | No | 24.2 KB | 62.3 KB |
|
|
275
|
-
| React CSR | No | 59.8 KB | 192.3 KB |
|
|
276
|
-
| Next.js | Yes | 182.6 KB | 663.6 KB |
|
|
288
|
+
| Kudzu | Yes | **0 B** | 3.2 KB | **385 ms** |
|
|
289
|
+
| Astro | Yes | **0 B** | **3.0 KB** | 970 ms |
|
|
290
|
+
| Svelte CSR | No | 10.2 KB | 27.2 KB | 829 ms |
|
|
291
|
+
| Qwik CSR | No | 20.2 KB | 59.6 KB | 634 ms |
|
|
292
|
+
| Vue CSR | No | 24.2 KB | 62.3 KB | 767 ms |
|
|
293
|
+
| React CSR | No | 59.8 KB | 192.3 KB | 1098 ms |
|
|
294
|
+
| Next.js | Yes | 182.6 KB | 663.6 KB | 3217 ms |
|
|
277
295
|
|
|
278
296
|
### 1,000-item Keyed List
|
|
279
297
|
|
|
@@ -281,15 +299,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
281
299
|
|
|
282
300
|
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
283
301
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
284
|
-
| Astro | Yes | **324 B** | **43.6 KB** |
|
|
285
|
-
| Kudzu | Yes |
|
|
286
|
-
|
|
|
287
|
-
|
|
|
288
|
-
|
|
|
289
|
-
| Qwik CSR | No | 22.2 KB | 64.1 KB |
|
|
290
|
-
| Svelte CSR | No | 12.9 KB | 33.1 KB |
|
|
291
|
-
|
|
292
|
-
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
|
|
302
|
+
| Astro | Yes | **324 B** | **43.6 KB** | 889 ms | **3.9 ms** | 27.6 ms | 7.9 ms | **18.1 ms** | **57.5 ms** |
|
|
303
|
+
| Kudzu | Yes | 5.0 KB | 60.3 KB | **447 ms** | 6.3 ms | 31.0 ms | **7.3 ms** | 20.7 ms | 65.3 ms |
|
|
304
|
+
| Vue CSR | No | 24.3 KB | 61.3 KB | 805 ms | 10.9 ms | 36.4 ms | 12.2 ms | 21.0 ms | 80.5 ms |
|
|
305
|
+
| Next.js | Yes | 182.2 KB | 695.2 KB | 3081 ms | 7.9 ms | 40.2 ms | 9.4 ms | 23.5 ms | 81.0 ms |
|
|
306
|
+
| React CSR | No | 59.3 KB | 189.4 KB | 1075 ms | 10.8 ms | 40.6 ms | 11.0 ms | 21.0 ms | 83.4 ms |
|
|
307
|
+
| Qwik CSR | No | 22.2 KB | 64.1 KB | 655 ms | 11.9 ms | **25.1 ms** | 40.7 ms | 22.6 ms | 100.3 ms |
|
|
308
|
+
| Svelte CSR | No | 12.9 KB | 33.1 KB | 895 ms | 6.1 ms | 69.9 ms | 11.2 ms | 20.8 ms | 108.0 ms |
|
|
309
|
+
|
|
310
|
+
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 65.3 ms, 7.8 ms behind Astro and 18.1 ms ahead of React across all four operations.
|
|
293
311
|
|
|
294
312
|
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.
|
|
295
313
|
|
package/framework/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
7
|
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
8
|
- `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
|
|
9
|
-
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic item-handler scopes, moves, and cleanup.
|
|
9
|
+
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic styles and item-handler scopes, moves, and cleanup.
|
|
10
10
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
11
11
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
12
12
|
- `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
|
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 })
|
|
@@ -36,6 +37,7 @@ export async function build({ quiet = false } = {}) {
|
|
|
36
37
|
let behaviorCount = 0
|
|
37
38
|
let bindingCount = 0
|
|
38
39
|
let listCount = 0
|
|
40
|
+
let listStyleCount = 0
|
|
39
41
|
let stateSeedCount = 0
|
|
40
42
|
const plans = []
|
|
41
43
|
const hasStyles = await exists(join(sourceDirectory, "style.css"))
|
|
@@ -57,6 +59,7 @@ export async function build({ quiet = false } = {}) {
|
|
|
57
59
|
if (result.hasBehaviors) behaviorCount++
|
|
58
60
|
if (result.hasBindings) bindingCount++
|
|
59
61
|
if (result.hasLists) listCount++
|
|
62
|
+
if (result.hasListStyles) listStyleCount++
|
|
60
63
|
if (result.hasStateSeed) stateSeedCount++
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -69,32 +72,40 @@ export async function build({ quiet = false } = {}) {
|
|
|
69
72
|
if (behaviorCount) {
|
|
70
73
|
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
71
74
|
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
72
|
-
await
|
|
75
|
+
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
73
76
|
}
|
|
74
|
-
if (bindingCount || hasNativeHandlers) await
|
|
77
|
+
if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify)
|
|
78
|
+
if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
|
|
75
79
|
if (bindingCount) {
|
|
76
|
-
await cp(new URL("./style.js", import.meta.url), join(assetsDirectory, "kudzu-style.js"))
|
|
77
80
|
const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
78
81
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
79
82
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
80
83
|
.replace('"./style.js"', '"./kudzu-style.js"')
|
|
81
|
-
await
|
|
84
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify)
|
|
82
85
|
}
|
|
83
86
|
if (listCount) {
|
|
84
|
-
|
|
87
|
+
let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
|
|
85
88
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
86
|
-
|
|
89
|
+
const stylePatch = ` if (target === "style") {
|
|
90
|
+
const style = serializeStyle(value)
|
|
91
|
+
if (style) node.setAttribute("style", style)
|
|
92
|
+
else node.removeAttribute("style")
|
|
93
|
+
return
|
|
94
|
+
}`
|
|
95
|
+
listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
|
|
96
|
+
if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
|
|
97
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify)
|
|
87
98
|
}
|
|
88
99
|
if (hasNativeHandlers) {
|
|
89
100
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
90
101
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
91
102
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
92
|
-
await
|
|
103
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify)
|
|
93
104
|
}
|
|
94
105
|
for (const handlerModule of handlerModules) {
|
|
95
106
|
const output = join(assetsDirectory, handlerModule.path)
|
|
96
107
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
97
|
-
await
|
|
108
|
+
await writeJavaScript(output, handlerModule.code, minify)
|
|
98
109
|
}
|
|
99
110
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
|
|
100
111
|
if (hasStyles) await cp(join(sourceDirectory, "style.css"), join(assetsDirectory, "style.css"))
|
|
@@ -113,12 +124,17 @@ function specializeNativeRuntime(source, events, modules) {
|
|
|
113
124
|
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
function specializeRuntime(source, events, hasStateSeed) {
|
|
127
|
+
export function specializeRuntime(source, events, hasStateSeed) {
|
|
117
128
|
const specialized = specializeEvents(source, events)
|
|
118
129
|
if (hasStateSeed) return specialized
|
|
119
130
|
return specialized
|
|
120
131
|
.replace(" const initialState = document.body.dataset.kState\n", "")
|
|
121
|
-
.replace(
|
|
132
|
+
.replace(/^ if \(initialState\).*\n/m, "")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function writeJavaScript(file, source, minify) {
|
|
136
|
+
const code = minify ? (await transform(source, { format: "esm", legalComments: "none", minify: true, target: "es2022" })).code : source
|
|
137
|
+
await writeFile(file, code)
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
export function parseDevPort(value) {
|
|
@@ -136,7 +152,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
136
152
|
let revision = 0
|
|
137
153
|
const session = randomUUID()
|
|
138
154
|
try {
|
|
139
|
-
await build()
|
|
155
|
+
await build({ minify: false })
|
|
140
156
|
revision++
|
|
141
157
|
} catch (error) {
|
|
142
158
|
buildError = errorText(error)
|
|
@@ -204,7 +220,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
204
220
|
do {
|
|
205
221
|
pending = false
|
|
206
222
|
try {
|
|
207
|
-
await build({ quiet: true })
|
|
223
|
+
await build({ quiet: true, minify: false })
|
|
208
224
|
buildError = undefined
|
|
209
225
|
revision++
|
|
210
226
|
console.log(`Rebuilt after ${changedFile ?? "source change"}`)
|
|
@@ -482,7 +498,7 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
482
498
|
const field = directProperty(expression, parts.item)
|
|
483
499
|
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
|
|
484
500
|
if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
|
|
485
|
-
if (field && ts.isJsxAttribute(node.parent) && ["
|
|
501
|
+
if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
486
502
|
if (isRootKey) return
|
|
487
503
|
if (field) {
|
|
488
504
|
listValues.set(node.expression, { field })
|
|
@@ -490,7 +506,7 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
490
506
|
}
|
|
491
507
|
if (referencesIdentifier(expression, parts.item)) {
|
|
492
508
|
validateListExpression(expression, parts.item, node, fail)
|
|
493
|
-
if (ts.isJsxAttribute(node.parent) && ["
|
|
509
|
+
if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
494
510
|
listValues.set(node.expression, { item: parts.item })
|
|
495
511
|
return
|
|
496
512
|
}
|
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
|
|
@@ -37,6 +43,7 @@ export function renderPage(
|
|
|
37
43
|
hasBehaviors: boolean
|
|
38
44
|
hasBindings: boolean
|
|
39
45
|
hasLists: boolean
|
|
46
|
+
hasListStyles: boolean
|
|
40
47
|
hasStateSeed: boolean
|
|
41
48
|
plan: {
|
|
42
49
|
states: Array<{ id: string; name: string; initialValue: unknown }>
|
package/framework/core.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const listMarker = Symbol("kudzu.list")
|
|
|
9
9
|
const listFieldMarker = Symbol("kudzu.listField")
|
|
10
10
|
const listExpressionMarker = Symbol("kudzu.listExpression")
|
|
11
11
|
const listItemMarker = Symbol("kudzu.listItem")
|
|
12
|
+
const refMarker = Symbol("kudzu.ref")
|
|
12
13
|
const noSelectValue = Symbol("kudzu.no-select-value")
|
|
13
14
|
|
|
14
15
|
let renderContext
|
|
@@ -37,6 +38,12 @@ export function useState(initialValue, name) {
|
|
|
37
38
|
}]
|
|
38
39
|
}
|
|
39
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
|
+
|
|
40
47
|
export function behavior(commands) {
|
|
41
48
|
return {
|
|
42
49
|
[behaviorMarker]: true,
|
|
@@ -158,6 +165,7 @@ function bindingDescriptor(value) {
|
|
|
158
165
|
|
|
159
166
|
function serializeCapture(name, value, seen) {
|
|
160
167
|
if (value?.[listItemMarker]) return { type: "list-item" }
|
|
168
|
+
if (value?.[refMarker]) return { type: "ref", id: value.id }
|
|
161
169
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
162
170
|
if (typeof value === "number") {
|
|
163
171
|
return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
|
|
@@ -189,7 +197,7 @@ function serializeCapture(name, value, seen) {
|
|
|
189
197
|
}
|
|
190
198
|
|
|
191
199
|
export async function renderPage(component, metadata = {}) {
|
|
192
|
-
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, hasListStyles: false }
|
|
193
201
|
|
|
194
202
|
try {
|
|
195
203
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -227,6 +235,7 @@ export async function renderPage(component, metadata = {}) {
|
|
|
227
235
|
hasBehaviors: renderContext.hasBehaviors,
|
|
228
236
|
hasBindings: renderContext.hasBindings,
|
|
229
237
|
hasLists: renderContext.hasLists,
|
|
238
|
+
hasListStyles: renderContext.hasListStyles,
|
|
230
239
|
hasStateSeed: initialState.length > 0,
|
|
231
240
|
plan: {
|
|
232
241
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
@@ -351,6 +360,12 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
351
360
|
|
|
352
361
|
for (const [rawName, value] of Object.entries(props)) {
|
|
353
362
|
if (rawName === "children" || rawName === "key") continue
|
|
363
|
+
if (rawName === "ref") {
|
|
364
|
+
if (!value?.[refMarker]) throw new Error("ref must be created by useRef(null)")
|
|
365
|
+
if (renderContext.listDepth) throw new Error("Refs are not supported in keyed lists")
|
|
366
|
+
attributes += ` data-k-ref="${value.id}"`
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
354
369
|
if (rawName === "selected" && selectValue !== noSelectValue) continue
|
|
355
370
|
if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
|
|
356
371
|
if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
|
|
@@ -383,11 +398,13 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
383
398
|
if (value?.[listFieldMarker]) {
|
|
384
399
|
attributes += renderAttribute(name, value.value)
|
|
385
400
|
listAttributes.push([name, value.field])
|
|
401
|
+
if (name === "style") renderContext.hasListStyles = true
|
|
386
402
|
continue
|
|
387
403
|
}
|
|
388
404
|
if (value?.[listExpressionMarker]) {
|
|
389
405
|
attributes += renderAttribute(name, value.value)
|
|
390
406
|
listExpressionAttributes.push([name, value.module, value.handler])
|
|
407
|
+
if (name === "style") renderContext.hasListStyles = true
|
|
391
408
|
continue
|
|
392
409
|
}
|
|
393
410
|
if (value?.[signalMarker] || value?.[bindingMarker]) {
|
|
@@ -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) : {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kudzujs/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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": {
|