@kudzujs/core 0.5.8 → 0.5.10

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
@@ -126,17 +126,20 @@ Static trusted HTML can be rendered without a transform layer:
126
126
 
127
127
  The HTML is intentionally not sanitized. Use only trusted or previously sanitized build-time content. Reactive raw HTML, children on the same element, void elements, and keyed-list raw HTML are rejected.
128
128
 
129
- Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
129
+ Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Stylesheets produced by another build step can be declared globally so Kudzu still emits them in every document `<head>`. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
130
130
 
131
131
  ```js
132
132
  export default {
133
133
  base: "/newsletter",
134
+ styles: ["/assets/generated.css"],
134
135
  async afterBuild({ outDir, routes, plans, rewrites, base }) {
135
- // Write host rewrites, RSS, sitemap, or other static artifacts.
136
+ // Write generated.css, host rewrites, RSS, sitemap, or other static artifacts.
136
137
  }
137
138
  }
138
139
  ```
139
140
 
141
+ Do not render `<link rel="stylesheet">` from page or component JSX. Kudzu rejects direct static body stylesheets with a source location and catches computed JSX stylesheet output during rendering. Trusted `dangerouslySetInnerHTML` remains unparsed and is responsible for its own resource tags.
142
+
140
143
  ## State Semantics
141
144
 
142
145
  Kudzu intentionally differs from React's state snapshot behavior:
@@ -329,7 +332,7 @@ The original component remains reusable across multiple lists and ordinary JSX.
329
332
 
330
333
  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 local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props and top-level single-`const` calculations before one intrinsic return. A list alias may only be rendered once and cannot be read by other JavaScript. 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, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
331
334
 
332
- ## Mount Effects
335
+ ## Effects
333
336
 
334
337
  Browser-only initial work uses the familiar empty-dependency effect shape:
335
338
 
@@ -357,12 +360,30 @@ useEffect(() => {
357
360
  }, [])
358
361
  ```
359
362
 
360
- Cleanup runs once when the document leaves outside the browser back-forward cache. Effect-local resources and component state read by nested cleanup closures retain their mount-time values. Cleanup failures are isolated so later cleanups still run. Only inline block-bodied callbacks with a literal empty dependency array are supported. Dependencies, named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected at build time. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry, and effects without cleanup retain their smaller runtime output.
363
+ Cleanup runs once when the document leaves outside the browser back-forward cache. Effect-local resources and component state read by nested cleanup closures retain their setup-time values. Cleanup failures are isolated so later cleanups still run.
364
+
365
+ Literal arrays of direct primitive `useState` or `useParams` signal identifiers rerun after committed dependency changes:
366
+
367
+ ```tsx
368
+ const [event, setEvent] = useState("resize")
369
+
370
+ useEffect(() => {
371
+ const listener = () => console.log(event)
372
+ window.addEventListener(event, listener)
373
+ return () => window.removeEventListener(event, listener)
374
+ }, [event])
375
+ ```
376
+
377
+ Dependency values are limited to JSON-safe strings, finite numbers, booleans, and `null`; direct signal aliases are accepted, while expressions, property reads, ordinary props or locals, objects, spreads, and dynamic arrays fail the build. Kudzu compares dependencies with `Object.is`, coalesces multiple commits in one turn, invokes every affected previous cleanup in declaration order, awaits asynchronous cleanup, and then runs the affected setups in declaration order. The component itself is not rerun.
378
+
379
+ Effect callbacks must be inline and block-bodied. Named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry. Empty-dependency effects retain their smaller output, and dependency-only capability code is isolated to the routes that use `kudzu-deps.js` unless another capability already requires the shared runtime.
361
380
 
362
381
  A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
363
382
 
364
383
  A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
365
384
 
385
+ In the matched dependency-rerun fixture, Kudzu shipped 1.5 KB JavaScript gzip and built in 429 ms. Svelte shipped 9.7 KB in 995 ms, Vue 23.8 KB in 943 ms, React 59.2 KB in 1,172 ms, and the hand-written Astro baseline 196 B in 969 ms. Kudzu and Astro emitted initial HTML; the CSR fixtures did not.
386
+
366
387
  ## Normal JavaScript
367
388
 
368
389
  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.
@@ -409,6 +430,7 @@ TSX
409
430
 
410
431
  - Static pages ship no client JavaScript.
411
432
  - Interactive pages receive only the runtime capabilities they use.
433
+ - Interactive route modules are discovered in the document head and retain deferred execution after HTML parsing, overlapping cold downloads with document transfer.
412
434
  - Production JavaScript is minified; development output stays readable.
413
435
  - Components are authoring units; no component tree is retained in the browser.
414
436
  - There is no VDOM, hydration pass, router, or client application runtime.
@@ -4,6 +4,7 @@
4
4
  - `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
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
+ - `dependency-runtime.js`: command runtime with commit observation for dependency effects.
7
8
  - `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
8
9
  - `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
9
10
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
@@ -13,6 +14,6 @@
13
14
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
14
15
  - `*.d.ts`: public TypeScript and JSX declarations.
15
16
 
16
- Static routes receive no browser runtime. Command routes receive `runtime.js`; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; mount effects add `effect-runtime.js` and one route-specific entry. Effect cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`; unrelated routes and effects retain their existing runtime. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
17
+ Static routes receive no browser runtime. Command routes receive `runtime.js`; dependency effects use route-specific `kudzu-deps.js` unless that route already requires shared commit hooks; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; effects add `effect-runtime.js` and one route-specific entry. Generated module scripts live in the document head, so cold downloads overlap HTML transfer while standard module deferral preserves execution after parsing. A single effect with one dependency compiles to a direct runner; generic maps, sets, and ordering are reserved for larger effect graphs. Dependency commits coalesce in a microtask; affected cleanups are awaited in declaration order before replacement setups run. Document cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
17
18
 
18
- Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
19
+ Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
@@ -19,6 +19,7 @@ const devClient = (session, revision, schema) => `<script>(()=>{const show=event
19
19
  export async function build({ quiet = false, minify = true } = {}) {
20
20
  const config = await loadConfig()
21
21
  const base = normalizeBase(config.base)
22
+ const configuredStyles = normalizeStyles(config.styles, base)
22
23
  await rm(workDirectory, { recursive: true, force: true })
23
24
  await rm(outputDirectory, { recursive: true, force: true })
24
25
  await mkdir(workDirectory, { recursive: true })
@@ -41,16 +42,23 @@ export async function build({ quiet = false, minify = true } = {}) {
41
42
  if (!pageFiles.length) throw new Error("No pages found in src/pages/")
42
43
 
43
44
  let behaviorCount = 0
45
+ let regularBehaviorCount = 0
44
46
  let bindingCount = 0
45
47
  let listCount = 0
46
48
  let listStyleCount = 0
47
- let stateSeedCount = 0
49
+ let regularStateSeedCount = 0
50
+ let dependencyStateSeedCount = 0
48
51
  const plans = []
52
+ const pageEntries = []
49
53
  const effectEntries = []
50
54
  const paramEntries = []
51
55
  const rewrites = []
52
56
  const emittedRoutes = new Set()
53
- const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
57
+ const styleUrls = [...new Set([
58
+ ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
59
+ ...configuredStyles
60
+ ])]
61
+ const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
54
62
 
55
63
  for (const pageFile of pageFiles) {
56
64
  const compiledFile = compiledPath(pageFile)
@@ -81,21 +89,28 @@ export async function build({ quiet = false, minify = true } = {}) {
81
89
  ...(module.metadata ?? {}),
82
90
  styles: styleUrls.length ? styleUrls : false,
83
91
  base,
92
+ runtimeAsset: runtimePlaceholder,
84
93
  effectAsset: assetPath(base, `assets/${effectPath}`),
85
94
  paramAsset: assetPath(base, `assets/${paramPath}`),
86
95
  runtimeParams: runtimeSchema?.params
87
96
  }, props)
88
- const routeDirectory = join(outputDirectory, route)
89
- await mkdir(routeDirectory, { recursive: true })
90
- await writeFile(join(routeDirectory, "index.html"), result.html)
97
+ const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
98
+ const usesDependencyRuntime = hasDependencies && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
99
+ pageEntries.push({ route, html: result.html, usesDependencyRuntime })
91
100
  plans.push({ route: routePath, ...result.plan })
92
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params })
93
- if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined })
94
- if (result.hasBehaviors) behaviorCount++
101
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
102
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime })
103
+ if (result.hasBehaviors) {
104
+ behaviorCount++
105
+ if (!usesDependencyRuntime) regularBehaviorCount++
106
+ }
95
107
  if (result.hasBindings) bindingCount++
96
108
  if (result.hasLists) listCount++
97
109
  if (result.hasListStyles) listStyleCount++
98
- if (result.hasStateSeed) stateSeedCount++
110
+ if (result.hasStateSeed) {
111
+ if (usesDependencyRuntime) dependencyStateSeedCount++
112
+ else regularStateSeedCount++
113
+ }
99
114
  }
100
115
  }
101
116
 
@@ -119,11 +134,24 @@ export async function build({ quiet = false, minify = true } = {}) {
119
134
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
120
135
  const hasNativeHandlers = nativeModules.length > 0
121
136
  const hasEffects = effectEntries.length > 0
122
- if (behaviorCount) {
123
- const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
124
- const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
137
+ const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers
138
+ const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
139
+ const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
140
+ for (const entry of pageEntries) {
141
+ const routeDirectory = join(outputDirectory, entry.route)
142
+ await mkdir(routeDirectory, { recursive: true })
143
+ const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
144
+ await writeFile(join(routeDirectory, "index.html"), html)
145
+ }
146
+ if (behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
147
+ const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
148
+ const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
125
149
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
126
150
  }
151
+ if (hasDependencyRuntime) {
152
+ const runtime = specializeRuntime(await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8"), commandEvents, dependencyStateSeedCount > 0)
153
+ await writeJavaScript(join(assetsDirectory, "kudzu-deps.js"), runtime, minify)
154
+ }
127
155
  if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
128
156
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
129
157
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
@@ -186,12 +214,12 @@ export async function build({ quiet = false, minify = true } = {}) {
186
214
  for (const entry of paramEntries) {
187
215
  const output = join(assetsDirectory, entry.path)
188
216
  await mkdir(dirname(output), { recursive: true })
189
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
217
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
190
218
  }
191
219
  for (const entry of effectEntries) {
192
220
  const output = join(assetsDirectory, entry.path)
193
221
  await mkdir(dirname(output), { recursive: true })
194
- await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
222
+ await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
195
223
  }
196
224
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
197
225
  for (const file of clientModules) {
@@ -243,8 +271,9 @@ function specializeNativeRuntime(source, events, modules) {
243
271
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
244
272
  }
245
273
 
246
- function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
274
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
247
275
  const hasCleanup = effects.some(effect => effect.cleanup)
276
+ const hasDependencies = effects.some(effect => effect.dependencies?.length)
248
277
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
249
278
  const modules = moduleUrls.map(url => {
250
279
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -252,14 +281,117 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
252
281
  return module
253
282
  })
254
283
  const imports = [
255
- hasCleanup
256
- ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}\nconst { browserState, commitDom } = __kRuntime`
257
- : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
284
+ hasCleanup || hasDependencies
285
+ ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
286
+ : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
258
287
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
259
288
  ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
260
289
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
261
290
  ]
262
291
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
292
+ if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
293
+ const disposal = hasCleanup ? `
294
+ let disposed = false
295
+ const dispose = root => {
296
+ if (root !== document || disposed) return
297
+ disposed = true
298
+ active = false
299
+ pending.clear()
300
+ for (const record of records) invokeCleanup(record)
301
+ }
302
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
303
+ addEventListener("pagehide", event => {
304
+ if (event.persisted) return
305
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
306
+ else dispose(document)
307
+ })` : ""
308
+ if (hasDependencies) return `${imports.join("\n")}
309
+ const effects = ${inlineJson(effects)}
310
+ const modules = new Map([${entries}])
311
+ const records = effects.map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined }))
312
+ const dependencies = new Map()
313
+ const pending = new Set()
314
+ let scheduled = false
315
+ let flushing = false
316
+ let active = true
317
+ for (const record of records) {
318
+ for (const id of record.effect.dependencies ?? []) {
319
+ const subscribers = dependencies.get(id) ?? new Set()
320
+ subscribers.add(record)
321
+ dependencies.set(id, subscribers)
322
+ }
323
+ }
324
+ __kRuntime.registerCommitter(id => {
325
+ if (!active) return
326
+ for (const record of dependencies.get(id) ?? []) pending.add(record)
327
+ schedule()
328
+ })
329
+ for (const record of records) {
330
+ try {
331
+ record.values = readDependencies(record)
332
+ invoke(record)
333
+ } catch (error) {
334
+ console.error(error)
335
+ }
336
+ }
337
+ function schedule() {
338
+ if (!pending.size || scheduled || flushing) return
339
+ scheduled = true
340
+ queueMicrotask(flush)
341
+ }
342
+ async function flush() {
343
+ scheduled = false
344
+ if (!active) return pending.clear()
345
+ flushing = true
346
+ try {
347
+ const selected = [...pending].sort((left, right) => left.index - right.index)
348
+ pending.clear()
349
+ const changed = []
350
+ for (const record of selected) {
351
+ try {
352
+ const values = readDependencies(record)
353
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
354
+ record.values = values
355
+ changed.push(record)
356
+ }
357
+ } catch (error) {
358
+ console.error(error)
359
+ }
360
+ }
361
+ for (const record of changed) await invokeCleanup(record)
362
+ if (active) for (const record of changed) invoke(record)
363
+ } finally {
364
+ flushing = false
365
+ if (active) schedule()
366
+ }
367
+ }
368
+ function readDependencies(record) {
369
+ return (record.effect.dependencies ?? []).map(id => {
370
+ const value = browserState.get(id)
371
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
372
+ return value
373
+ })
374
+ }
375
+ function invoke(record) {
376
+ try {
377
+ const effect = record.effect
378
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
379
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
380
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
381
+ } catch (error) {
382
+ console.error(error)
383
+ }
384
+ }
385
+ async function invokeCleanup(record) {
386
+ const cleanup = record.cleanup
387
+ record.cleanup = undefined
388
+ if (!cleanup) return
389
+ try {
390
+ await cleanup()
391
+ } catch (error) {
392
+ console.error(error)
393
+ }
394
+ }${disposal}`
263
395
  if (!hasCleanup) return `${imports.join("\n")}
264
396
  const effects = ${inlineJson(effects)}
265
397
  const modules = new Map([${entries}])
@@ -306,8 +438,96 @@ addEventListener("pagehide", event => {
306
438
  })`
307
439
  }
308
440
 
309
- function printParamEntry(schema, params, output, assetsDirectory, base) {
310
- return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
441
+ function printSingleDependencyEffect(imports, effect, hasCleanup) {
442
+ const disposal = hasCleanup ? `
443
+ const dispose = root => {
444
+ if (root !== document || !active) return
445
+ active = false
446
+ pending = false
447
+ invokeCleanup()
448
+ }
449
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
450
+ addEventListener("pagehide", event => {
451
+ if (event.persisted) return
452
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
453
+ else dispose(document)
454
+ })` : ""
455
+ return `${imports.join("\n")}
456
+ const effect = ${inlineJson(effect)}
457
+ const dependency = effect.dependencies[0]
458
+ let value
459
+ let cleanup
460
+ let active = true
461
+ let pending = false
462
+ let scheduled = false
463
+ let running = false
464
+ __kRuntime.registerCommitter(id => {
465
+ if (active && id === dependency) {
466
+ pending = true
467
+ schedule()
468
+ }
469
+ })
470
+ try {
471
+ value = readDependency()
472
+ invoke()
473
+ } catch (error) {
474
+ console.error(error)
475
+ }
476
+ function schedule() {
477
+ if (!pending || scheduled || running) return
478
+ scheduled = true
479
+ queueMicrotask(flush)
480
+ }
481
+ async function flush() {
482
+ scheduled = false
483
+ if (!active) return
484
+ let next
485
+ try {
486
+ next = readDependency()
487
+ } catch (error) {
488
+ console.error(error)
489
+ return
490
+ }
491
+ pending = false
492
+ if (Object.is(next, value)) return
493
+ value = next
494
+ running = true
495
+ try {
496
+ await invokeCleanup()
497
+ if (active) invoke()
498
+ } finally {
499
+ running = false
500
+ if (active) schedule()
501
+ }
502
+ }
503
+ function readDependency() {
504
+ const next = browserState.get(dependency)
505
+ if (next !== null && typeof next !== "string" && typeof next !== "boolean" && !(typeof next === "number" && Number.isFinite(next) && !Object.is(next, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
506
+ return next
507
+ }
508
+ function invoke() {
509
+ try {
510
+ const result = __kEffectModule0[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
511
+ if (effect.cleanup && typeof result === "function") cleanup = result
512
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
513
+ } catch (error) {
514
+ console.error(error)
515
+ }
516
+ }
517
+ async function invokeCleanup() {
518
+ const current = cleanup
519
+ cleanup = undefined
520
+ if (!current) return
521
+ try {
522
+ await current()
523
+ } catch (error) {
524
+ console.error(error)
525
+ }
526
+ }${disposal}`
527
+ }
528
+
529
+ function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
530
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
311
531
  const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
312
532
  const schema = ${inlineJson(schema.segments)}
313
533
  const params = ${inlineJson(params)}
@@ -501,7 +721,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
501
721
  }
502
722
 
503
723
  function injectDevClient(html, session, revision, schema) {
504
- return `${html}${devClient(session, revision, schema)}`
724
+ return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
505
725
  }
506
726
 
507
727
  function stripBaseStrict(path, base) {
@@ -576,6 +796,10 @@ function escapeHtml(value) {
576
796
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
577
797
  }
578
798
 
799
+ function escapeAttribute(value) {
800
+ return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;")
801
+ }
802
+
579
803
  async function compile(file, sourceFiles, sourceIndex, base) {
580
804
  const source = sourceIndex.get(file)
581
805
  const nativeHandlers = []
@@ -625,6 +849,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
625
849
  function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
626
850
  return context => sourceFile => {
627
851
  const factory = context.factory
852
+ const hasLinkElements = /<link/i.test(sourceFile.text)
628
853
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
629
854
  ts.setParentRecursive(sourceFile, false)
630
855
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
@@ -827,6 +1052,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
827
1052
  if (specializedDeclarations.has(node)) return node
828
1053
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
829
1054
 
1055
+ if (hasLinkElements && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && isStylesheetLink(node)) {
1056
+ fail(node, "Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
1057
+ }
1058
+
830
1059
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
831
1060
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
832
1061
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
@@ -838,13 +1067,15 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
838
1067
  }
839
1068
 
840
1069
  if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
841
- if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
1070
+ if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal dependency array")
842
1071
  const [callback, dependencies] = node.arguments
843
1072
  if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
844
1073
  if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
845
1074
  if (callback.asteriskToken) fail(callback, "useEffect() callback cannot be a generator")
846
1075
  if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
847
- if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
1076
+ if (!ts.isArrayLiteralExpression(dependencies)) fail(dependencies, "useEffect() dependencies must be a literal array")
1077
+ const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
1078
+ if (invalidDependency) fail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
848
1079
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
849
1080
  if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
850
1081
  const returns = effectReturns(callback)
@@ -1268,6 +1499,19 @@ function jsxTagName(node) {
1268
1499
  return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
1269
1500
  }
1270
1501
 
1502
+ function isStylesheetLink(node) {
1503
+ const element = ts.isJsxElement(node) ? node.openingElement : node
1504
+ if (!ts.isIdentifier(element.tagName) || element.tagName.text.toLowerCase() !== "link") return false
1505
+ const attribute = element.attributes.properties.find(property => ts.isJsxAttribute(property) && property.name.getText().toLowerCase() === "rel")
1506
+ if (!attribute?.initializer) return false
1507
+ const value = ts.isStringLiteral(attribute.initializer)
1508
+ ? attribute.initializer.text
1509
+ : ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression && (ts.isStringLiteral(attribute.initializer.expression) || ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))
1510
+ ? attribute.initializer.expression.text
1511
+ : undefined
1512
+ return value?.toLowerCase().split(/\s+/).includes("stylesheet") ?? false
1513
+ }
1514
+
1271
1515
  function isContextProviderValue(node, contexts) {
1272
1516
  if (node.name.getText() !== "value") return false
1273
1517
  const element = node.parent?.parent
@@ -1912,6 +2156,8 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1912
2156
  const stateNames = new Set(setters.values())
1913
2157
  const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
1914
2158
  const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
2159
+ const captureSnapshotNames = snapshotNested ? nestedCaptureNames(expression, captures) : new Set()
2160
+ const captureSnapshots = new Map([...captureSnapshotNames].map(name => [name, factory.createUniqueName("__kEffectCapture")]))
1915
2161
  const transformer = context => root => {
1916
2162
  const visitor = node => {
1917
2163
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
@@ -1940,9 +2186,11 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1940
2186
  )
1941
2187
  }
1942
2188
  if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
2189
+ if (captureSnapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, captureSnapshots.get(node.name.text))
1943
2190
  return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
1944
2191
  }
1945
2192
  if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
2193
+ if (captureSnapshots.has(node.text) && insideNestedFunction(node, expression)) return captureSnapshots.get(node.text)
1946
2194
  return scopeRead(factory, node.text)
1947
2195
  }
1948
2196
  return ts.visitEachChild(node, visitor, context)
@@ -1954,8 +2202,12 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1954
2202
  let body = ts.isBlock(expression.body)
1955
2203
  ? transformed.transformed[0]
1956
2204
  : factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
1957
- if (snapshots.size) body = factory.updateBlock(body, [
1958
- factory.createVariableStatement(undefined, factory.createVariableDeclarationList([...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))), ts.NodeFlags.Const)),
2205
+ const snapshotDeclarations = [
2206
+ ...[...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))),
2207
+ ...[...captureSnapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"), undefined, [factory.createStringLiteral(name)])))
2208
+ ]
2209
+ if (snapshotDeclarations.length) body = factory.updateBlock(body, [
2210
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList(snapshotDeclarations, ts.NodeFlags.Const)),
1959
2211
  ...body.statements
1960
2212
  ])
1961
2213
  const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
@@ -1975,6 +2227,16 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1975
2227
  }
1976
2228
  }
1977
2229
 
2230
+ function nestedCaptureNames(expression, captures) {
2231
+ const names = new Set()
2232
+ const visit = node => {
2233
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
2234
+ ts.forEachChild(node, visit)
2235
+ }
2236
+ visit(expression.body)
2237
+ return names
2238
+ }
2239
+
1978
2240
  function nestedStateNames(expression, setters) {
1979
2241
  const states = new Set(setters.values())
1980
2242
  const names = new Set()
@@ -2124,6 +2386,19 @@ async function loadConfig() {
2124
2386
  return {}
2125
2387
  }
2126
2388
 
2389
+ function normalizeStyles(value, base) {
2390
+ if (value === undefined) return []
2391
+ if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array of URLs")
2392
+ return value.map((style, index) => {
2393
+ if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
2394
+ if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
2395
+ if (style.startsWith("/")) return withBase(base, style)
2396
+ if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
2397
+ try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
2398
+ return style
2399
+ })
2400
+ }
2401
+
2127
2402
  function normalizeBase(value) {
2128
2403
  if (value == null || value === "" || value === "/") return ""
2129
2404
  if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || /%(?:2f|5c)/i.test(value)) throw new Error("kudzu.config base must be a root-relative path")
@@ -1,8 +1,9 @@
1
1
  export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
2
  export type EffectCleanup = () => void | Promise<void>
3
+ export type EffectDependency = string | number | boolean | null
3
4
 
4
5
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
5
- export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly []): void
6
+ export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
6
7
  export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
7
8
 
8
9
  export interface RefObject<T> {
@@ -49,6 +50,7 @@ export function renderPage<Props = Record<string, never>>(
49
50
  manifest?: string
50
51
  styles?: boolean | string[]
51
52
  base?: string
53
+ runtimeAsset?: string
52
54
  effectAsset?: string
53
55
  paramAsset?: string
54
56
  runtimeParams?: string[]
@@ -71,7 +73,7 @@ export function renderPage<Props = Record<string, never>>(
71
73
  commands?: Array<[string, string, unknown]>
72
74
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
73
75
  }>
74
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; cleanup?: true }>
76
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; dependencies?: string[]; cleanup?: true }>
75
77
  bindings: Array<{
76
78
  target: string
77
79
  state?: string
@@ -66,14 +66,20 @@ function createSignal(id, value) {
66
66
 
67
67
  export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
68
68
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
69
- if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
70
- throw new Error("useEffect() must be compiled with a literal empty dependency array")
71
- }
72
- renderContext.effects.push({ module, handler, states, scope, source, ...(cleanup ? { cleanup: true } : {}) })
69
+ if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
70
+ const dependencyIds = dependencies.map(dependency => {
71
+ if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
72
+ return dependency.id
73
+ })
74
+ renderContext.effects.push({ module, handler, states, scope, source, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}) })
73
75
  renderContext.hasBehaviors = true
74
76
  renderContext.hasEffects = true
75
77
  }
76
78
 
79
+ function validEffectDependency(value) {
80
+ return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)
81
+ }
82
+
77
83
  export function useRef(initialValue) {
78
84
  if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
79
85
  if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
@@ -271,6 +277,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
271
277
  return {
272
278
  module: effect.module,
273
279
  handler: effect.handler,
280
+ ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
274
281
  ...(effect.cleanup ? { cleanup: true } : {}),
275
282
  ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
276
283
  }
@@ -284,7 +291,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
284
291
  ? ""
285
292
  : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
286
293
  const runtime = renderContext.hasBehaviors
287
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu.js")}"></script>`
294
+ ? `<script type="module" src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
288
295
  : ""
289
296
  const nativeRuntime = renderContext.hasNativeBehaviors
290
297
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
@@ -317,7 +324,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
317
324
  : ""
318
325
 
319
326
  return {
320
- 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}${textBindings}>${body}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
327
+ 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}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</head><body${state}${textBindings}>${body}</body></html>`,
321
328
  hasBehaviors: renderContext.hasBehaviors,
322
329
  hasEffects: renderContext.hasEffects,
323
330
  hasParams: renderContext.hasParams,
@@ -476,6 +483,13 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
476
483
 
477
484
  const tag = node.type
478
485
  const props = node.props ?? {}
486
+ if (typeof tag === "string" && tag.toLowerCase() === "link") {
487
+ const rel = Object.entries(props).find(([name]) => name.toLowerCase() === "rel")?.[1]
488
+ const value = rel?.[signalMarker] || rel?.[bindingMarker] ? rel.value : rel
489
+ if (typeof value === "string" && value.toLowerCase().split(/\s+/).includes("stylesheet")) {
490
+ throw new Error("Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
491
+ }
492
+ }
479
493
  const directListText = props.children?.[listFieldMarker] ? props.children : undefined
480
494
  const childSelectValue = tag === "select"
481
495
  ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
@@ -0,0 +1,36 @@
1
+ export function applyCommands(state, commands, commit, log = console.log) {
2
+ const changed = new Set()
3
+ for (const [operation, id, operand] of commands) {
4
+ const current = state.get(id)
5
+ if (operation === "log") log(operand, current)
6
+ else {
7
+ state.set(id, operation === "add" ? current + operand : operand)
8
+ changed.add(id)
9
+ }
10
+ }
11
+ for (const id of changed) commit(id, state.get(id))
12
+ }
13
+
14
+ export const browserState = new Map()
15
+ const committers = []
16
+
17
+ export function registerCommitter(commit) {
18
+ committers.push(commit)
19
+ }
20
+
21
+ export function commitDom(id, value) {
22
+ for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
23
+ for (const commit of committers) commit(id)
24
+ }
25
+
26
+ if (typeof document !== "undefined") {
27
+ const initialState = document.body.dataset.kState
28
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
29
+ for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
30
+
31
+ const eventNames = ["click", "input", "change"]
32
+ for (const eventName of eventNames) document.addEventListener(eventName, event => {
33
+ const target = event.target.closest(`[data-k-on-${eventName}]`)
34
+ if (target) applyCommands(browserState, JSON.parse(target.getAttribute(`data-k-on-${eventName}`)), commitDom)
35
+ })
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",