@kudzujs/core 0.5.6 → 0.5.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 CHANGED
@@ -97,6 +97,27 @@ export default function Post({ title }: { title: string }) {
97
97
 
98
98
  This emits `/posts/oak` and `/posts/pine`. Parameter values must be safe single path segments; missing, unsafe, and duplicate routes fail the build.
99
99
 
100
+ When a bracket value exists only in the request URL, opt into one static fallback document and read it with `useParams()`:
101
+
102
+ ```tsx
103
+ // src/pages/items/[id].tsx
104
+ import { useEffect, useParams } from "@kudzujs/core"
105
+
106
+ export const runtimeParams = true
107
+
108
+ export default function ItemPage() {
109
+ const { id } = useParams<{ id: string }>()
110
+
111
+ useEffect(() => {
112
+ fetch(`/api/items/${encodeURIComponent(id)}`)
113
+ }, [])
114
+
115
+ return <h1>Item {id}</h1>
116
+ }
117
+ ```
118
+
119
+ This emits `dist/items/[id]/index.html` and a route-specific pathname matcher. `getStaticPaths()` and `runtimeParams` are mutually exclusive. Runtime parameters occupy complete path segments, decode once, and reject empty, malformed, separator, control, and traversal-like values. The development server resolves deep links automatically. Production static hosts must try exact files first, then internally rewrite matching paths to the fallback file while preserving the browser URL; `.kudzu/kudzu-plan.json` and `afterBuild()` expose ordered `rewrites` for host adapters. Navigation remains ordinary `<a>` document navigation, not an SPA router.
120
+
100
121
  Static trusted HTML can be rendered without a transform layer:
101
122
 
102
123
  ```tsx
@@ -110,8 +131,8 @@ Every CSS file under `src` is copied to the same relative path under `dist/asset
110
131
  ```js
111
132
  export default {
112
133
  base: "/newsletter",
113
- async afterBuild({ outDir, routes, plans, base }) {
114
- // Write RSS, sitemap, search indexes, or other static artifacts.
134
+ async afterBuild({ outDir, routes, plans, rewrites, base }) {
135
+ // Write host rewrites, RSS, sitemap, or other static artifacts.
115
136
  }
116
137
  }
117
138
  ```
@@ -325,10 +346,23 @@ useEffect(async () => {
325
346
 
326
347
  Kudzu does not execute the effect during static rendering and does not ship the component. It emits one route-specific effect entry that invokes the compiled callback against existing logical state and direct DOM commit capabilities. Effects may update reactive text, attributes, conditions, and keyed lists. Multiple effects start independently in source order, and one synchronous or asynchronous failure is reported without suppressing later effects.
327
348
 
328
- Only inline block-bodied callbacks with a literal empty dependency array are supported. Dependencies, cleanup or other return values, callback parameters, and non-serializable captures are rejected at build time. Pages without effects receive no effect entry and retain their existing output.
349
+ An effect may directly return an inline cleanup function:
350
+
351
+ ```tsx
352
+ useEffect(() => {
353
+ const onResize = () => console.log(window.innerWidth)
354
+ window.addEventListener("resize", onResize)
355
+
356
+ return () => window.removeEventListener("resize", onResize)
357
+ }, [])
358
+ ```
359
+
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.
329
361
 
330
362
  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.
331
363
 
364
+ 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
+
332
366
  ## Normal JavaScript
333
367
 
334
368
  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.
@@ -395,6 +429,7 @@ Supported:
395
429
  - File-based static routes
396
430
  - Build-time async components
397
431
  - Dynamic static routes with build-time props
432
+ - Runtime bracket parameters with static fallback documents and host rewrite metadata
398
433
  - Static trusted `dangerouslySetInnerHTML`
399
434
  - Base-path deployments, multiple CSS files, and `afterBuild`
400
435
  - Primitive `useState` bindings
@@ -1,6 +1,6 @@
1
1
  # Framework Internals
2
2
 
3
- - `build.mjs`: TSX compilation, static and `getStaticPaths` routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
3
+ - `build.mjs`: TSX compilation, static, `getStaticPaths`, and runtime-fallback routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
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.
@@ -13,6 +13,6 @@
13
13
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
14
14
  - `*.d.ts`: public TypeScript and JSX declarations.
15
15
 
16
- Static routes receive no browser runtime. Command routes receive `runtime.js`; 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. 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. 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.
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
17
 
18
18
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -47,6 +47,8 @@ export async function build({ quiet = false, minify = true } = {}) {
47
47
  let stateSeedCount = 0
48
48
  const plans = []
49
49
  const effectEntries = []
50
+ const paramEntries = []
51
+ const rewrites = []
50
52
  const emittedRoutes = new Set()
51
53
  const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
52
54
 
@@ -55,24 +57,40 @@ export async function build({ quiet = false, minify = true } = {}) {
55
57
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
56
58
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
57
59
 
58
- const entries = await staticPathEntries(module, pageFile)
60
+ const runtimeSchema = runtimeRouteSchema(module, pageFile)
61
+ if (runtimeSchema) {
62
+ const conflicting = rewrites.find(rewrite => sameRuntimePrecedence(rewrite, runtimeSchema))
63
+ if (conflicting) throw new Error(`Ambiguous runtime routes: ${conflicting.route} and ${runtimeSchema.route}`)
64
+ rewrites.push({
65
+ route: runtimeSchema.route,
66
+ pattern: withBase(base, `/${runtimeSchema.route}`),
67
+ file: `${runtimeSchema.route}/index.html`,
68
+ params: runtimeSchema.params,
69
+ segments: runtimeSchema.segments
70
+ })
71
+ }
72
+ const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
59
73
  for (const { params, props } of entries) {
60
- const route = routeFromPage(pageFile, params)
74
+ const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
61
75
  const routePath = withBase(base, `/${route}`)
62
76
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
77
+ const paramPath = `params/${route}/index.js`
63
78
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
64
79
  emittedRoutes.add(routePath)
65
80
  const result = await renderPage(module.default, {
66
81
  ...(module.metadata ?? {}),
67
82
  styles: styleUrls.length ? styleUrls : false,
68
83
  base,
69
- effectAsset: assetPath(base, `assets/${effectPath}`)
84
+ effectAsset: assetPath(base, `assets/${effectPath}`),
85
+ paramAsset: assetPath(base, `assets/${paramPath}`),
86
+ runtimeParams: runtimeSchema?.params
70
87
  }, props)
71
88
  const routeDirectory = join(outputDirectory, route)
72
89
  await mkdir(routeDirectory, { recursive: true })
73
90
  await writeFile(join(routeDirectory, "index.html"), result.html)
74
91
  plans.push({ route: routePath, ...result.plan })
75
- if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects })
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 })
76
94
  if (result.hasBehaviors) behaviorCount++
77
95
  if (result.hasBindings) bindingCount++
78
96
  if (result.hasLists) listCount++
@@ -165,10 +183,15 @@ export async function build({ quiet = false, minify = true } = {}) {
165
183
  await mkdir(resolve(output, ".."), { recursive: true })
166
184
  await writeJavaScript(output, handlerModule.code, minify)
167
185
  }
186
+ for (const entry of paramEntries) {
187
+ const output = join(assetsDirectory, entry.path)
188
+ await mkdir(dirname(output), { recursive: true })
189
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
190
+ }
168
191
  for (const entry of effectEntries) {
169
192
  const output = join(assetsDirectory, entry.path)
170
193
  await mkdir(dirname(output), { recursive: true })
171
- await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base), minify)
194
+ await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
172
195
  }
173
196
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
174
197
  for (const file of clientModules) {
@@ -194,7 +217,8 @@ export async function build({ quiet = false, minify = true } = {}) {
194
217
  })
195
218
  await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
196
219
  }
197
- await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
220
+ const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
221
+ await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
198
222
  for (const file of cssFiles) {
199
223
  const output = join(assetsDirectory, relative(sourceDirectory, file))
200
224
  await mkdir(dirname(output), { recursive: true })
@@ -203,7 +227,7 @@ export async function build({ quiet = false, minify = true } = {}) {
203
227
  if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
204
228
  if (config.afterBuild !== undefined) {
205
229
  if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
206
- await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans })
230
+ await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
207
231
  }
208
232
 
209
233
  if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
@@ -219,7 +243,8 @@ function specializeNativeRuntime(source, events, modules) {
219
243
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
220
244
  }
221
245
 
222
- function printEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
246
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
247
+ const hasCleanup = effects.some(effect => effect.cleanup)
223
248
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
224
249
  const modules = moduleUrls.map(url => {
225
250
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -227,12 +252,15 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
227
252
  return module
228
253
  })
229
254
  const imports = [
230
- `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
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")))}`,
231
258
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
259
+ ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
232
260
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
233
261
  ]
234
262
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
235
- return `${imports.join("\n")}
263
+ if (!hasCleanup) return `${imports.join("\n")}
236
264
  const effects = ${inlineJson(effects)}
237
265
  const modules = new Map([${entries}])
238
266
  for (const effect of effects) {
@@ -242,6 +270,75 @@ for (const effect of effects) {
242
270
  } catch (error) {
243
271
  console.error(error)
244
272
  }
273
+ }`
274
+ return `${imports.join("\n")}
275
+ const effects = ${inlineJson(effects)}
276
+ const modules = new Map([${entries}])
277
+ const cleanups = []
278
+ for (const effect of effects) {
279
+ try {
280
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
281
+ if (effect.cleanup && typeof result === "function") cleanups.push(result)
282
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
283
+ } catch (error) {
284
+ console.error(error)
285
+ }
286
+ }
287
+ let cleaned = false
288
+ const dispose = root => {
289
+ if (root !== document || cleaned) return
290
+ cleaned = true
291
+ for (const cleanup of cleanups) {
292
+ try {
293
+ const result = cleanup()
294
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
295
+ } catch (error) {
296
+ console.error(error)
297
+ }
298
+ }
299
+ cleanups.length = 0
300
+ }
301
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
302
+ addEventListener("pagehide", event => {
303
+ if (event.persisted) return
304
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
305
+ else dispose(document)
306
+ })`
307
+ }
308
+
309
+ function printParamEntry(schema, params, output, assetsDirectory, base) {
310
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
311
+ const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
312
+ const schema = ${inlineJson(schema.segments)}
313
+ const params = ${inlineJson(params)}
314
+ let path = location.pathname
315
+ if (base.length) {
316
+ const pathSegments = path.slice(1).split("/")
317
+ if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
318
+ path = "/" + pathSegments.slice(base.length).join("/")
319
+ }
320
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
321
+ const segments = path.slice(1).split("/")
322
+ if (segments.length !== schema.length) throw new Error("Runtime route does not match its fallback pattern")
323
+ const values = Object.create(null)
324
+ for (let index = 0; index < schema.length; index++) {
325
+ const segment = schema[index]
326
+ const value = decodeSegment(segments[index], Boolean(segment.param))
327
+ if (segment.literal !== undefined && value !== segment.literal) throw new Error("Runtime route literal does not match")
328
+ if (segment.param) values[segment.param] = value
329
+ }
330
+ for (const param of params) {
331
+ const value = values[param.name]
332
+ browserState.set(param.id, value)
333
+ commitDom(param.id, value)
334
+ }
335
+ function decodeSegment(raw, param) {
336
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Runtime route parameter contains an encoded separator")
337
+ let value
338
+ try { value = decodeURIComponent(raw) } catch { throw new Error("Runtime route parameter has malformed encoding") }
339
+ const decodedDots = value.replace(/%2e/gi, ".")
340
+ if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Runtime route parameter is invalid")
341
+ return value
245
342
  }`
246
343
  }
247
344
 
@@ -316,7 +413,8 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
316
413
  const server = createServer(async (request, response) => {
317
414
  try {
318
415
  const url = new URL(request.url, "http://localhost")
319
- const pathname = decodeURIComponent(url.pathname)
416
+ const rawPathname = url.pathname
417
+ const pathname = decodeURIComponent(rawPathname)
320
418
  if (pathname === "/__kudzu_reload") {
321
419
  response.writeHead(200, {
322
420
  "content-type": "text/event-stream; charset=utf-8",
@@ -336,15 +434,24 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
336
434
  return
337
435
  }
338
436
 
339
- const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
437
+ const relativePath = stripBaseStrict(pathname, decodeURIComponent(base)).replace(/^\/+/, "")
340
438
  let file = resolve(outputDirectory, relativePath)
341
439
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
342
440
 
343
441
  if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
344
442
  if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
443
+ let matchedRoute
444
+ if (!(await exists(file)) && !buildError) {
445
+ const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
446
+ const rewrite = plan.rewrites?.find(entry => runtimePathValues(rawPathname, entry, browserPath(base)))
447
+ if (rewrite) {
448
+ file = resolve(outputDirectory, rewrite.file)
449
+ matchedRoute = rewrite.pattern
450
+ }
451
+ }
345
452
  const isHtml = extname(file) === ".html"
346
453
  const content = isHtml
347
- ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
454
+ ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(withBase(base, stripBaseStrict(pathname, decodeURIComponent(base))), matchedRoute))
348
455
  : await readFile(file)
349
456
  response.writeHead(200, {
350
457
  "content-type": contentType(file),
@@ -397,22 +504,58 @@ function injectDevClient(html, session, revision, schema) {
397
504
  return `${html}${devClient(session, revision, schema)}`
398
505
  }
399
506
 
400
- function stripBase(path, base) {
507
+ function stripBaseStrict(path, base) {
401
508
  if (!base) return path
402
509
  if (path === base) return "/"
403
- return path.startsWith(`${base}/`) ? path.slice(base.length) : path
510
+ if (path.startsWith(`${base}/`)) return path.slice(base.length)
511
+ throw new Error("Path is outside the configured base")
404
512
  }
405
513
 
406
- async function devSchema(pathname) {
514
+ async function devSchema(pathname, matchedRoute) {
407
515
  try {
408
516
  const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
409
- const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
517
+ const route = matchedRoute ?? (pathname.replace(/\/(?:index\.html)?$/, "") || "/")
410
518
  return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
411
519
  } catch {
412
520
  return []
413
521
  }
414
522
  }
415
523
 
524
+ function runtimePathValues(pathname, rewrite, base) {
525
+ try {
526
+ let path = stripBrowserBase(pathname, base)
527
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
528
+ const rawSegments = path.slice(1).split("/")
529
+ if (rawSegments.length !== rewrite.segments.length) return undefined
530
+ const values = Object.create(null)
531
+ for (let index = 0; index < rewrite.segments.length; index++) {
532
+ const segment = rewrite.segments[index]
533
+ const value = decodeRuntimeSegment(rawSegments[index], Boolean(segment.param))
534
+ if (segment.literal !== undefined && value !== segment.literal) return undefined
535
+ if (segment.param) values[segment.param] = value
536
+ }
537
+ return values
538
+ } catch {
539
+ return undefined
540
+ }
541
+ }
542
+
543
+ function stripBrowserBase(path, base) {
544
+ if (!base) return path
545
+ const pathSegments = path.slice(1).split("/")
546
+ const baseSegments = base.slice(1).split("/").map(segment => decodeURIComponent(segment))
547
+ if (pathSegments.length < baseSegments.length || baseSegments.some((segment, index) => decodeRuntimeSegment(pathSegments[index], false) !== segment)) throw new Error("Path is outside the configured base")
548
+ return `/${pathSegments.slice(baseSegments.length).join("/")}`
549
+ }
550
+
551
+ function decodeRuntimeSegment(raw, param) {
552
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Encoded separator")
553
+ const value = decodeURIComponent(raw)
554
+ const decodedDots = value.replace(/%2e/gi, ".")
555
+ if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Invalid runtime parameter")
556
+ return value
557
+ }
558
+
416
559
  function inlineJson(value) {
417
560
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
418
561
  }
@@ -699,14 +842,18 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
699
842
  const [callback, dependencies] = node.arguments
700
843
  if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
701
844
  if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
845
+ if (callback.asteriskToken) fail(callback, "useEffect() callback cannot be a generator")
702
846
  if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
703
847
  if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
704
848
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
705
- if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
706
849
  if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
707
- if (returnsEffectValue(callback)) fail(callback, "useEffect() return values are not supported")
850
+ const returns = effectReturns(callback)
851
+ if (returns.invalid) fail(returns.invalid, "useEffect() return values must be inline cleanup functions")
852
+ const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
853
+ if (invalidCleanup) fail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
854
+ if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) fail(callback, "useEffect() async callbacks cannot return cleanup functions")
708
855
  const setters = settersForNode(node, settersByFunction)
709
- const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
856
+ const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true, returns.cleanup)
710
857
  usesBehavior = true
711
858
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
712
859
  callback,
@@ -715,7 +862,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
715
862
  factory.createStringLiteral(descriptor.exportName),
716
863
  descriptor.states,
717
864
  descriptor.scope,
718
- factory.createStringLiteral(sourceLocation(node, sourceFile))
865
+ factory.createStringLiteral(sourceLocation(node, sourceFile)),
866
+ returns.cleanup ? factory.createTrue() : factory.createFalse()
719
867
  ])
720
868
  }
721
869
 
@@ -1133,7 +1281,7 @@ function isJsxSyntaxIdentifier(node) {
1133
1281
  }
1134
1282
 
1135
1283
  function isFunctionLike(node) {
1136
- return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
1284
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)
1137
1285
  }
1138
1286
 
1139
1287
  function isDestructuredParameter(identifier, fn) {
@@ -1373,14 +1521,14 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
1373
1521
  ])
1374
1522
  }
1375
1523
 
1376
- function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
1524
+ function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false, snapshotNested = false) {
1377
1525
  const allCaptures = nativeCaptureNames(expression, setters)
1378
1526
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
1379
1527
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
1380
1528
  for (const entry of imports) clientImports.add(entry.target)
1381
1529
  const usedStates = nativeStateNames(expression, setters)
1382
1530
  const exportName = `${prefix}${entries.length}`
1383
- entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1531
+ entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), snapshotNested })
1384
1532
  const value = name => deferValues
1385
1533
  ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
1386
1534
  : factory.createIdentifier(name)
@@ -1479,6 +1627,7 @@ function isReferenceIdentifier(node) {
1479
1627
  if ((ts.isPropertyAccessExpression(parent) && parent.name === node) ||
1480
1628
  (ts.isPropertyAssignment(parent) && parent.name === node) ||
1481
1629
  (ts.isMethodDeclaration(parent) && parent.name === node) ||
1630
+ ((ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent)) && parent.name === node) ||
1482
1631
  (ts.isVariableDeclaration(parent) && parent.name === node) ||
1483
1632
  (ts.isParameter(parent) && parent.name === node) ||
1484
1633
  (ts.isFunctionDeclaration(parent) && parent.name === node) ||
@@ -1496,7 +1645,7 @@ function nearestFunction(node) {
1496
1645
 
1497
1646
  function isShadowedByParameter(node, scopeRoot) {
1498
1647
  for (let current = node.parent; current; current = current.parent) {
1499
- if ((ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
1648
+ if (isFunctionLike(current) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
1500
1649
  if (current === scopeRoot) break
1501
1650
  }
1502
1651
  return false
@@ -1626,33 +1775,24 @@ function sourceLocation(node, fallbackSource) {
1626
1775
  return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
1627
1776
  }
1628
1777
 
1629
- function returnsCleanup(callback) {
1630
- if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) {
1631
- const body = unwrapExpression(callback.body)
1632
- return ts.isArrowFunction(body) || ts.isFunctionExpression(body)
1633
- }
1634
- let found = false
1778
+ function effectReturns(callback) {
1779
+ let cleanup = false
1780
+ let invalid
1781
+ const cleanups = []
1635
1782
  const visit = node => {
1636
- if (found || node !== callback.body && isFunctionLike(node)) return
1783
+ if (invalid || node !== callback.body && isFunctionLike(node)) return
1637
1784
  if (ts.isReturnStatement(node) && node.expression) {
1638
1785
  const expression = unwrapExpression(node.expression)
1639
- if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) found = true
1786
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
1787
+ cleanup = true
1788
+ cleanups.push(expression)
1789
+ }
1790
+ else invalid = node
1640
1791
  }
1641
- if (!found) ts.forEachChild(node, visit)
1642
- }
1643
- visit(callback.body)
1644
- return found
1645
- }
1646
-
1647
- function returnsEffectValue(callback) {
1648
- let found = false
1649
- const visit = node => {
1650
- if (found || node !== callback.body && isFunctionLike(node)) return
1651
- if (ts.isReturnStatement(node) && node.expression) found = true
1652
- if (!found) ts.forEachChild(node, visit)
1792
+ if (!invalid) ts.forEachChild(node, visit)
1653
1793
  }
1654
1794
  visit(callback.body)
1655
- return found
1795
+ return { cleanup, cleanups, invalid }
1656
1796
  }
1657
1797
 
1658
1798
  function printClientImports(entries, handlerPath) {
@@ -1767,9 +1907,11 @@ function relativeModulePath(from, to) {
1767
1907
  return path.startsWith(".") ? path : `./${path}`
1768
1908
  }
1769
1909
 
1770
- function printNativeHandler({ exportName, expression, captures, setters }) {
1910
+ function printNativeHandler({ exportName, expression, captures, setters, snapshotNested }) {
1771
1911
  const factory = ts.factory
1772
1912
  const stateNames = new Set(setters.values())
1913
+ const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
1914
+ const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
1773
1915
  const transformer = context => root => {
1774
1916
  const visitor = node => {
1775
1917
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
@@ -1786,9 +1928,11 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1786
1928
  return setterReference(factory, setters.get(node.text))
1787
1929
  }
1788
1930
  if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1931
+ if (snapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, snapshots.get(node.name.text))
1789
1932
  return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
1790
1933
  }
1791
1934
  if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1935
+ if (snapshots.has(node.text) && insideNestedFunction(node, expression)) return snapshots.get(node.text)
1792
1936
  return factory.createCallExpression(
1793
1937
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
1794
1938
  undefined,
@@ -1807,9 +1951,13 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1807
1951
  }
1808
1952
  const transformed = ts.transform(expression.body, [transformer])
1809
1953
  try {
1810
- const body = ts.isBlock(expression.body)
1954
+ let body = ts.isBlock(expression.body)
1811
1955
  ? transformed.transformed[0]
1812
1956
  : 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)),
1959
+ ...body.statements
1960
+ ])
1813
1961
  const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
1814
1962
  if (expression.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) modifiers.push(factory.createModifier(ts.SyntaxKind.AsyncKeyword))
1815
1963
  const declaration = factory.createFunctionDeclaration(
@@ -1827,6 +1975,24 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1827
1975
  }
1828
1976
  }
1829
1977
 
1978
+ function nestedStateNames(expression, setters) {
1979
+ const states = new Set(setters.values())
1980
+ const names = new Set()
1981
+ const visit = node => {
1982
+ if (ts.isIdentifier(node) && states.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
1983
+ ts.forEachChild(node, visit)
1984
+ }
1985
+ visit(expression.body)
1986
+ return names
1987
+ }
1988
+
1989
+ function insideNestedFunction(node, root) {
1990
+ for (let current = node.parent; current && current !== root; current = current.parent) {
1991
+ if (isFunctionLike(current)) return true
1992
+ }
1993
+ return false
1994
+ }
1995
+
1830
1996
  function setterReference(factory, stateName) {
1831
1997
  return factory.createArrowFunction(
1832
1998
  undefined,
@@ -1960,10 +2126,17 @@ async function loadConfig() {
1960
2126
 
1961
2127
  function normalizeBase(value) {
1962
2128
  if (value == null || value === "" || value === "/") return ""
1963
- if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
2129
+ 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")
2130
+ let decoded
2131
+ try { decoded = decodeURIComponent(value) } catch { throw new Error("kudzu.config base must be a root-relative path") }
2132
+ if (/[\\?#\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw new Error("kudzu.config base must be a root-relative path")
1964
2133
  return value.replace(/\/+$/, "")
1965
2134
  }
1966
2135
 
2136
+ function browserPath(path) {
2137
+ return path ? new URL(path, "http://kudzu.local").pathname : ""
2138
+ }
2139
+
1967
2140
  function assetPath(base, path) {
1968
2141
  return `${base}/${path}`
1969
2142
  }
@@ -1986,6 +2159,43 @@ async function staticPathEntries(module, file) {
1986
2159
  })
1987
2160
  }
1988
2161
 
2162
+ function runtimeRouteSchema(module, file) {
2163
+ if (!Object.hasOwn(module, "runtimeParams")) return undefined
2164
+ if (module.runtimeParams !== true) throw new Error(`${relative(root, file)} runtimeParams must be exactly true`)
2165
+ if (typeof module.getStaticPaths === "function") throw new Error(`${relative(root, file)} runtimeParams cannot be combined with getStaticPaths()`)
2166
+ const route = pageRoutePattern(file)
2167
+ if (route.includes("[...")) throw new Error(`Catch-all routes are not supported: ${route}`)
2168
+ const names = new Set()
2169
+ const segments = route.split("/").map(segment => {
2170
+ const match = segment.match(/^\[([^\]]+)\]$/)
2171
+ if (!match) {
2172
+ if (/[\[\]]/.test(segment)) throw new Error(`${relative(root, file)} runtime parameters must occupy a complete path segment`)
2173
+ return { literal: segment }
2174
+ }
2175
+ const name = match[1]
2176
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || ["__proto__", "constructor", "prototype"].includes(name)) throw new Error(`${relative(root, file)} invalid runtime parameter name ${JSON.stringify(name)}`)
2177
+ if (names.has(name)) throw new Error(`${relative(root, file)} duplicate runtime parameter ${JSON.stringify(name)}`)
2178
+ names.add(name)
2179
+ return { param: name }
2180
+ })
2181
+ if (!names.size) throw new Error(`${relative(root, file)} runtimeParams requires a bracket page`)
2182
+ return { route, segments, params: [...names] }
2183
+ }
2184
+
2185
+ function pageRoutePattern(file) {
2186
+ const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
2187
+ return page === "index" ? "" : page.replace(/\/index$/, "")
2188
+ }
2189
+
2190
+ function runtimeSpecificity(schema) {
2191
+ return schema.segments.filter(segment => segment.literal !== undefined).length
2192
+ }
2193
+
2194
+ function sameRuntimePrecedence(left, right) {
2195
+ if (left.segments.length !== right.segments.length || runtimeSpecificity(left) !== runtimeSpecificity(right)) return false
2196
+ return left.segments.every((segment, index) => segment.literal === undefined || right.segments[index].literal === undefined || segment.literal === right.segments[index].literal)
2197
+ }
2198
+
1989
2199
  function routeFromPage(file, params = {}) {
1990
2200
  const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
1991
2201
  if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
@@ -1,7 +1,9 @@
1
1
  export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
+ export type EffectCleanup = () => void | Promise<void>
2
3
 
3
4
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
- export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
5
+ export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly []): void
6
+ export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
5
7
 
6
8
  export interface RefObject<T> {
7
9
  readonly current: T | null
@@ -48,24 +50,28 @@ export function renderPage<Props = Record<string, never>>(
48
50
  styles?: boolean | string[]
49
51
  base?: string
50
52
  effectAsset?: string
53
+ paramAsset?: string
54
+ runtimeParams?: string[]
51
55
  },
52
56
  props?: Props
53
57
  ): Promise<{
54
58
  html: string
55
59
  hasBehaviors: boolean
56
60
  hasEffects: boolean
61
+ hasParams: boolean
57
62
  hasBindings: boolean
58
63
  hasLists: boolean
59
64
  hasListStyles: boolean
60
65
  hasStateSeed: boolean
61
66
  plan: {
62
67
  states: Array<{ id: string; name: string; initialValue: unknown }>
68
+ params: Array<{ name: string; id: string }>
63
69
  events: Array<{
64
70
  event: string
65
71
  commands?: Array<[string, string, unknown]>
66
72
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
67
73
  }>
68
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }>
74
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; cleanup?: true }>
69
75
  bindings: Array<{
70
76
  target: string
71
77
  state?: string
@@ -24,10 +24,37 @@ export function useState(initialValue, name) {
24
24
  }
25
25
 
26
26
  const id = `s${renderContext.nextState++}`
27
- const signal = {
27
+ const signal = createSignal(id, initialValue)
28
+
29
+ const setter = () => {
30
+ throw new Error("State setters are compiled into ordered browser behaviors")
31
+ }
32
+ Object.defineProperty(setter, setterMarker, { value: id })
33
+ renderContext.states[id] = { name: name ?? id, initialValue }
34
+ return [signal, setter]
35
+ }
36
+
37
+ export function useParams() {
38
+ if (!renderContext?.runtimeParamNames?.length) throw new Error("useParams() requires export const runtimeParams = true on a bracket page")
39
+ if (!renderContext.params) {
40
+ const params = Object.create(null)
41
+ renderContext.paramEntries = renderContext.runtimeParamNames.map((name, index) => {
42
+ const id = `p${index}`
43
+ params[name] = createSignal(id, "")
44
+ return { name, id }
45
+ })
46
+ renderContext.params = Object.freeze(params)
47
+ renderContext.hasBehaviors = true
48
+ renderContext.hasParams = true
49
+ }
50
+ return renderContext.params
51
+ }
52
+
53
+ function createSignal(id, value) {
54
+ return {
28
55
  [signalMarker]: true,
29
56
  id,
30
- value: initialValue,
57
+ value,
31
58
  valueOf() {
32
59
  return this.value
33
60
  },
@@ -35,21 +62,14 @@ export function useState(initialValue, name) {
35
62
  return String(this.value)
36
63
  }
37
64
  }
38
-
39
- const setter = () => {
40
- throw new Error("State setters are compiled into ordered browser behaviors")
41
- }
42
- Object.defineProperty(setter, setterMarker, { value: id })
43
- renderContext.states[id] = { name: name ?? id, initialValue }
44
- return [signal, setter]
45
65
  }
46
66
 
47
- export function useEffect(callback, dependencies, module, handler, states, scope, source) {
67
+ export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
48
68
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
49
69
  if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
50
70
  throw new Error("useEffect() must be compiled with a literal empty dependency array")
51
71
  }
52
- renderContext.effects.push({ module, handler, states, scope, source })
72
+ renderContext.effects.push({ module, handler, states, scope, source, ...(cleanup ? { cleanup: true } : {}) })
53
73
  renderContext.hasBehaviors = true
54
74
  renderContext.hasEffects = true
55
75
  }
@@ -242,7 +262,7 @@ function serializeCapture(name, value, seen) {
242
262
  }
243
263
 
244
264
  export async function renderPage(component, metadata = {}, props = {}) {
245
- renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasBindings: false, hasLists: false, hasListStyles: false }
265
+ renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
246
266
 
247
267
  try {
248
268
  const body = await renderNode({ type: component, props })
@@ -251,6 +271,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
251
271
  return {
252
272
  module: effect.module,
253
273
  handler: effect.handler,
274
+ ...(effect.cleanup ? { cleanup: true } : {}),
254
275
  ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
255
276
  }
256
277
  } catch (error) {
@@ -268,6 +289,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
268
289
  const nativeRuntime = renderContext.hasNativeBehaviors
269
290
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
270
291
  : ""
292
+ const paramRuntime = renderContext.hasParams
293
+ ? `<script type="module" src="${escapeAttribute(metadata.paramAsset)}"></script>`
294
+ : ""
271
295
  const bindingRuntime = renderContext.hasBindings
272
296
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
273
297
  : ""
@@ -293,15 +317,17 @@ export async function renderPage(component, metadata = {}, props = {}) {
293
317
  : ""
294
318
 
295
319
  return {
296
- 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}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
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>`,
297
321
  hasBehaviors: renderContext.hasBehaviors,
298
322
  hasEffects: renderContext.hasEffects,
323
+ hasParams: renderContext.hasParams,
299
324
  hasBindings: renderContext.hasBindings,
300
325
  hasLists: renderContext.hasLists,
301
326
  hasListStyles: renderContext.hasListStyles,
302
327
  hasStateSeed: initialState.length > 0,
303
328
  plan: {
304
329
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
330
+ params: renderContext.paramEntries,
305
331
  events: renderContext.events,
306
332
  effects: renderContext.effects,
307
333
  bindings: renderContext.bindings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "build": "node ./bin/kudzu.mjs build",
48
48
  "dev": "node ./bin/kudzu.mjs dev",
49
49
  "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json --noEmit && node ./bin/kudzu.mjs build",
50
- "test": "node --test",
50
+ "test": "node --test test/*.test.mjs",
51
51
  "prepublishOnly": "npm run check && npm test",
52
52
  "deploy": "wrangler deploy",
53
53
  "preview": "wrangler dev"