@kudzujs/core 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  HTML-first TSX framework with synchronous state semantics and no virtual DOM.
8
8
 
9
- Kudzu keeps the familiar function-component, props, children, event-handler, and `useState` shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers compile to external ESM.
9
+ Kudzu keeps the familiar function-component, props, children, event-handler, `useState`, and mount-effect shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers and mount effects compile to external ESM.
10
10
 
11
11
  > Experimental `0.4.x`: the compiler API and supported TSX surface may change.
12
12
 
@@ -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
  ```
@@ -308,6 +329,27 @@ The original component remains reusable across multiple lists and ordinary JSX.
308
329
 
309
330
  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>`.
310
331
 
332
+ ## Mount Effects
333
+
334
+ Browser-only initial work uses the familiar empty-dependency effect shape:
335
+
336
+ ```tsx
337
+ import { useEffect, useState } from "@kudzujs/core"
338
+
339
+ const [items, setItems] = useState([])
340
+
341
+ useEffect(async () => {
342
+ const response = await fetch("/api/items")
343
+ setItems(await response.json())
344
+ }, [])
345
+ ```
346
+
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.
348
+
349
+ 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.
350
+
351
+ 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.
352
+
311
353
  ## Normal JavaScript
312
354
 
313
355
  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.
@@ -374,9 +416,11 @@ Supported:
374
416
  - File-based static routes
375
417
  - Build-time async components
376
418
  - Dynamic static routes with build-time props
419
+ - Runtime bracket parameters with static fallback documents and host rewrite metadata
377
420
  - Static trusted `dangerouslySetInnerHTML`
378
421
  - Base-path deployments, multiple CSS files, and `afterBuild`
379
422
  - Primitive `useState` bindings
423
+ - Mount-only `useEffect(fn, [])` compiled to route-specific ESM
380
424
  - Synchronous and async event handlers
381
425
  - Relative imported helpers in native handlers
382
426
  - Serializable component-local captures
@@ -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.
@@ -8,10 +8,11 @@
8
8
  - `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
9
9
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
+ - `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
11
12
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
13
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
13
14
  - `*.d.ts`: public TypeScript and JSX declarations.
14
15
 
15
- 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`. 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. 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.
16
17
 
17
18
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -46,6 +46,9 @@ export async function build({ quiet = false, minify = true } = {}) {
46
46
  let listStyleCount = 0
47
47
  let stateSeedCount = 0
48
48
  const plans = []
49
+ const effectEntries = []
50
+ const paramEntries = []
51
+ const rewrites = []
49
52
  const emittedRoutes = new Set()
50
53
  const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
51
54
 
@@ -54,21 +57,40 @@ export async function build({ quiet = false, minify = true } = {}) {
54
57
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
55
58
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
56
59
 
57
- 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)
58
73
  for (const { params, props } of entries) {
59
- const route = routeFromPage(pageFile, params)
74
+ const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
60
75
  const routePath = withBase(base, `/${route}`)
76
+ const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
77
+ const paramPath = `params/${route}/index.js`
61
78
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
62
79
  emittedRoutes.add(routePath)
63
80
  const result = await renderPage(module.default, {
64
81
  ...(module.metadata ?? {}),
65
82
  styles: styleUrls.length ? styleUrls : false,
66
- base
83
+ base,
84
+ effectAsset: assetPath(base, `assets/${effectPath}`),
85
+ paramAsset: assetPath(base, `assets/${paramPath}`),
86
+ runtimeParams: runtimeSchema?.params
67
87
  }, props)
68
88
  const routeDirectory = join(outputDirectory, route)
69
89
  await mkdir(routeDirectory, { recursive: true })
70
90
  await writeFile(join(routeDirectory, "index.html"), result.html)
71
91
  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 })
72
94
  if (result.hasBehaviors) behaviorCount++
73
95
  if (result.hasBindings) bindingCount++
74
96
  if (result.hasLists) listCount++
@@ -83,19 +105,37 @@ export async function build({ quiet = false, minify = true } = {}) {
83
105
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
84
106
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
85
107
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
108
+ const hasListTextRanges = plans.some(plan => plan.lists.some(list => list.textRanges))
109
+ const hasListAttributes = plans.some(plan => plan.lists.some(list => list.attributes))
110
+ const hasListEvents = plans.some(plan => plan.lists.some(list => list.events))
111
+ const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
112
+ const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
113
+ const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
114
+ const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
115
+ const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
86
116
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
87
117
  const hasSetterCaptures = hasCaptureType(plans, "setter")
118
+ const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
88
119
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
89
120
  const hasNativeHandlers = nativeModules.length > 0
121
+ const hasEffects = effectEntries.length > 0
90
122
  if (behaviorCount) {
91
123
  const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
92
124
  const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
93
125
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
94
126
  }
95
- if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
127
+ if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
96
128
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
97
129
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
98
130
  })
131
+ if (hasEffects) {
132
+ let effectRuntime = await readFile(new URL("./effect-runtime.js", import.meta.url), "utf8")
133
+ effectRuntime = hasEffectCaptures ? effectRuntime.replace('"./serialization.js"', '"./kudzu-serialization.js"') : effectRuntime.replace(/^import[^\n]+\n/, "")
134
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-effect.js"), effectRuntime, minify, {
135
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures),
136
+ "globalThis.__KUDZU_EFFECT_CAPTURES__": String(hasEffectCaptures)
137
+ })
138
+ }
99
139
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
100
140
  if (bindingCount) {
101
141
  const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
@@ -118,7 +158,17 @@ export async function build({ quiet = false, minify = true } = {}) {
118
158
  }`
119
159
  listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
120
160
  if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
121
- await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, { __KUDZU_LIST_CONDITIONS__: String(hasListConditions) })
161
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, {
162
+ __KUDZU_LIST_CONDITIONS__: String(hasListConditions),
163
+ __KUDZU_LIST_TEXT_RANGES__: String(hasListTextRanges),
164
+ __KUDZU_LIST_ATTRIBUTES__: String(hasListAttributes),
165
+ __KUDZU_LIST_EVENTS__: String(hasListEvents),
166
+ __KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
167
+ __KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
168
+ __KUDZU_LIST_SEEDS__: String(hasListSeeds),
169
+ __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
170
+ __KUDZU_LIST_MOUNTS__: String(hasListMounts)
171
+ })
122
172
  }
123
173
  if (hasNativeHandlers) {
124
174
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
@@ -133,6 +183,16 @@ export async function build({ quiet = false, minify = true } = {}) {
133
183
  await mkdir(resolve(output, ".."), { recursive: true })
134
184
  await writeJavaScript(output, handlerModule.code, minify)
135
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
+ }
191
+ for (const entry of effectEntries) {
192
+ const output = join(assetsDirectory, entry.path)
193
+ await mkdir(dirname(output), { recursive: true })
194
+ await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
195
+ }
136
196
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
137
197
  for (const file of clientModules) {
138
198
  const output = join(assetsDirectory, clientModulePath(file))
@@ -157,7 +217,8 @@ export async function build({ quiet = false, minify = true } = {}) {
157
217
  })
158
218
  await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
159
219
  }
160
- 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))
161
222
  for (const file of cssFiles) {
162
223
  const output = join(assetsDirectory, relative(sourceDirectory, file))
163
224
  await mkdir(dirname(output), { recursive: true })
@@ -166,7 +227,7 @@ export async function build({ quiet = false, minify = true } = {}) {
166
227
  if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
167
228
  if (config.afterBuild !== undefined) {
168
229
  if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
169
- 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 })
170
231
  }
171
232
 
172
233
  if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
@@ -182,6 +243,69 @@ function specializeNativeRuntime(source, events, modules) {
182
243
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
183
244
  }
184
245
 
246
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
247
+ const moduleUrls = [...new Set(effects.map(effect => effect.module))]
248
+ const modules = moduleUrls.map(url => {
249
+ const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
250
+ if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
251
+ return module
252
+ })
253
+ const imports = [
254
+ `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
255
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
256
+ ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
257
+ ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
258
+ ]
259
+ const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
260
+ return `${imports.join("\n")}
261
+ const effects = ${inlineJson(effects)}
262
+ const modules = new Map([${entries}])
263
+ for (const effect of effects) {
264
+ try {
265
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
266
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
267
+ } catch (error) {
268
+ console.error(error)
269
+ }
270
+ }`
271
+ }
272
+
273
+ function printParamEntry(schema, params, output, assetsDirectory, base) {
274
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
275
+ const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
276
+ const schema = ${inlineJson(schema.segments)}
277
+ const params = ${inlineJson(params)}
278
+ let path = location.pathname
279
+ if (base.length) {
280
+ const pathSegments = path.slice(1).split("/")
281
+ if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
282
+ path = "/" + pathSegments.slice(base.length).join("/")
283
+ }
284
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
285
+ const segments = path.slice(1).split("/")
286
+ if (segments.length !== schema.length) throw new Error("Runtime route does not match its fallback pattern")
287
+ const values = Object.create(null)
288
+ for (let index = 0; index < schema.length; index++) {
289
+ const segment = schema[index]
290
+ const value = decodeSegment(segments[index], Boolean(segment.param))
291
+ if (segment.literal !== undefined && value !== segment.literal) throw new Error("Runtime route literal does not match")
292
+ if (segment.param) values[segment.param] = value
293
+ }
294
+ for (const param of params) {
295
+ const value = values[param.name]
296
+ browserState.set(param.id, value)
297
+ commitDom(param.id, value)
298
+ }
299
+ function decodeSegment(raw, param) {
300
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Runtime route parameter contains an encoded separator")
301
+ let value
302
+ try { value = decodeURIComponent(raw) } catch { throw new Error("Runtime route parameter has malformed encoding") }
303
+ const decodedDots = value.replace(/%2e/gi, ".")
304
+ 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")
305
+ return value
306
+ }`
307
+ }
308
+
185
309
  function hasCaptureType(value, type) {
186
310
  if (!value || typeof value !== "object") return false
187
311
  if (value.type === type) return true
@@ -253,7 +377,8 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
253
377
  const server = createServer(async (request, response) => {
254
378
  try {
255
379
  const url = new URL(request.url, "http://localhost")
256
- const pathname = decodeURIComponent(url.pathname)
380
+ const rawPathname = url.pathname
381
+ const pathname = decodeURIComponent(rawPathname)
257
382
  if (pathname === "/__kudzu_reload") {
258
383
  response.writeHead(200, {
259
384
  "content-type": "text/event-stream; charset=utf-8",
@@ -273,15 +398,24 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
273
398
  return
274
399
  }
275
400
 
276
- const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
401
+ const relativePath = stripBaseStrict(pathname, decodeURIComponent(base)).replace(/^\/+/, "")
277
402
  let file = resolve(outputDirectory, relativePath)
278
403
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
279
404
 
280
405
  if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
281
406
  if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
407
+ let matchedRoute
408
+ if (!(await exists(file)) && !buildError) {
409
+ const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
410
+ const rewrite = plan.rewrites?.find(entry => runtimePathValues(rawPathname, entry, browserPath(base)))
411
+ if (rewrite) {
412
+ file = resolve(outputDirectory, rewrite.file)
413
+ matchedRoute = rewrite.pattern
414
+ }
415
+ }
282
416
  const isHtml = extname(file) === ".html"
283
417
  const content = isHtml
284
- ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
418
+ ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(withBase(base, stripBaseStrict(pathname, decodeURIComponent(base))), matchedRoute))
285
419
  : await readFile(file)
286
420
  response.writeHead(200, {
287
421
  "content-type": contentType(file),
@@ -334,22 +468,58 @@ function injectDevClient(html, session, revision, schema) {
334
468
  return `${html}${devClient(session, revision, schema)}`
335
469
  }
336
470
 
337
- function stripBase(path, base) {
471
+ function stripBaseStrict(path, base) {
338
472
  if (!base) return path
339
473
  if (path === base) return "/"
340
- return path.startsWith(`${base}/`) ? path.slice(base.length) : path
474
+ if (path.startsWith(`${base}/`)) return path.slice(base.length)
475
+ throw new Error("Path is outside the configured base")
341
476
  }
342
477
 
343
- async function devSchema(pathname) {
478
+ async function devSchema(pathname, matchedRoute) {
344
479
  try {
345
480
  const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
346
- const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
481
+ const route = matchedRoute ?? (pathname.replace(/\/(?:index\.html)?$/, "") || "/")
347
482
  return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
348
483
  } catch {
349
484
  return []
350
485
  }
351
486
  }
352
487
 
488
+ function runtimePathValues(pathname, rewrite, base) {
489
+ try {
490
+ let path = stripBrowserBase(pathname, base)
491
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
492
+ const rawSegments = path.slice(1).split("/")
493
+ if (rawSegments.length !== rewrite.segments.length) return undefined
494
+ const values = Object.create(null)
495
+ for (let index = 0; index < rewrite.segments.length; index++) {
496
+ const segment = rewrite.segments[index]
497
+ const value = decodeRuntimeSegment(rawSegments[index], Boolean(segment.param))
498
+ if (segment.literal !== undefined && value !== segment.literal) return undefined
499
+ if (segment.param) values[segment.param] = value
500
+ }
501
+ return values
502
+ } catch {
503
+ return undefined
504
+ }
505
+ }
506
+
507
+ function stripBrowserBase(path, base) {
508
+ if (!base) return path
509
+ const pathSegments = path.slice(1).split("/")
510
+ const baseSegments = base.slice(1).split("/").map(segment => decodeURIComponent(segment))
511
+ if (pathSegments.length < baseSegments.length || baseSegments.some((segment, index) => decodeRuntimeSegment(pathSegments[index], false) !== segment)) throw new Error("Path is outside the configured base")
512
+ return `/${pathSegments.slice(baseSegments.length).join("/")}`
513
+ }
514
+
515
+ function decodeRuntimeSegment(raw, param) {
516
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Encoded separator")
517
+ const value = decodeURIComponent(raw)
518
+ const decodedDots = value.replace(/%2e/gi, ".")
519
+ 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")
520
+ return value
521
+ }
522
+
353
523
  function inlineJson(value) {
354
524
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
355
525
  }
@@ -373,6 +543,7 @@ function escapeHtml(value) {
373
543
  async function compile(file, sourceFiles, sourceIndex, base) {
374
544
  const source = sourceIndex.get(file)
375
545
  const nativeHandlers = []
546
+ const effectHandlers = []
376
547
  const reactiveBindings = []
377
548
  const listExpressions = []
378
549
  const clientImports = new Set()
@@ -385,7 +556,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
385
556
  jsx: ts.JsxEmit.ReactJSX,
386
557
  jsxImportSource: "@kudzujs/core"
387
558
  },
388
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
559
+ transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
389
560
  reportDiagnostics: true
390
561
  })
391
562
 
@@ -398,10 +569,11 @@ async function compile(file, sourceFiles, sourceIndex, base) {
398
569
  await mkdir(resolve(output, ".."), { recursive: true })
399
570
  await writeFile(output, result.outputText)
400
571
 
401
- if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
572
+ if (!nativeHandlers.length && !effectHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
573
+ const callbacks = [...nativeHandlers, ...effectHandlers]
402
574
  const moduleSource = [
403
- printClientImports(nativeHandlers.flatMap(handler => handler.imports), handlerPath),
404
- ...nativeHandlers.map(handler => printNativeHandler(handler)),
575
+ printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
576
+ ...callbacks.map(handler => printNativeHandler(handler)),
405
577
  ...reactiveBindings.map(entry => printReactiveBinding(entry)),
406
578
  ...listExpressions.map(entry => printListExpression(entry))
407
579
  ].join("\n")
@@ -411,15 +583,16 @@ async function compile(file, sourceFiles, sourceIndex, base) {
411
583
  })
412
584
  const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
413
585
  if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
414
- return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
586
+ return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
415
587
  }
416
588
 
417
- function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
589
+ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
418
590
  return context => sourceFile => {
419
591
  const factory = context.factory
420
592
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
421
593
  ts.setParentRecursive(sourceFile, false)
422
594
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
595
+ const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === "@kudzujs/core" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
423
596
  const importedSources = new Map()
424
597
  const importedSource = target => {
425
598
  let imported = importedSources.get(target)
@@ -628,6 +801,31 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
628
801
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
629
802
  }
630
803
 
804
+ if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
805
+ if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
806
+ const [callback, dependencies] = node.arguments
807
+ if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
808
+ if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
809
+ if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
810
+ if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
811
+ if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
812
+ if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
813
+ if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
814
+ if (returnsEffectValue(callback)) fail(callback, "useEffect() return values are not supported")
815
+ const setters = settersForNode(node, settersByFunction)
816
+ const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
817
+ usesBehavior = true
818
+ return factory.updateCallExpression(node, node.expression, node.typeArguments, [
819
+ callback,
820
+ dependencies,
821
+ factory.createStringLiteral(handlerUrl),
822
+ factory.createStringLiteral(descriptor.exportName),
823
+ descriptor.states,
824
+ descriptor.scope,
825
+ factory.createStringLiteral(sourceLocation(node, sourceFile))
826
+ ])
827
+ }
828
+
631
829
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1) {
632
830
  const stateElement = node.name.elements[0]
633
831
  if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
@@ -1273,26 +1471,37 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
1273
1471
  const optimized = compileOptimizedEvent(expression, setters, factory)
1274
1472
  if (optimized) return optimized
1275
1473
 
1474
+ const descriptor = compileNativeCallback(expression, setters, factory, nativeHandlers, importBindings, clientImports, "handler", listItem)
1475
+ return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
1476
+ factory.createStringLiteral(handlerUrl),
1477
+ factory.createStringLiteral(descriptor.exportName),
1478
+ descriptor.states,
1479
+ descriptor.scope
1480
+ ])
1481
+ }
1482
+
1483
+ function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
1276
1484
  const allCaptures = nativeCaptureNames(expression, setters)
1277
1485
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
1278
1486
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
1279
1487
  for (const entry of imports) clientImports.add(entry.target)
1280
1488
  const usedStates = nativeStateNames(expression, setters)
1281
- const exportName = `handler${nativeHandlers.length}`
1282
- nativeHandlers.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1283
- const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
1284
- factory.createStringLiteral(name),
1285
- factory.createIdentifier(name)
1286
- ]))
1287
- return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
1288
- factory.createStringLiteral(handlerUrl),
1289
- factory.createStringLiteral(exportName),
1290
- factory.createArrayLiteralExpression(states),
1291
- factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
1489
+ const exportName = `${prefix}${entries.length}`
1490
+ entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
1491
+ const value = name => deferValues
1492
+ ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
1493
+ : factory.createIdentifier(name)
1494
+ return {
1495
+ exportName,
1496
+ states: factory.createArrayLiteralExpression([...usedStates].map(name => factory.createArrayLiteralExpression([
1292
1497
  factory.createStringLiteral(name),
1293
- name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : factory.createIdentifier(name)
1498
+ value(name)
1499
+ ]))),
1500
+ scope: factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
1501
+ factory.createStringLiteral(name),
1502
+ name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : value(name)
1294
1503
  ])))
1295
- ])
1504
+ }
1296
1505
  }
1297
1506
 
1298
1507
  function nativeStateNames(expression, setters) {
@@ -1303,7 +1512,8 @@ function referencedStateNames(root, setters, scopeRoot = root) {
1303
1512
  const stateNames = new Set(setters.values())
1304
1513
  const used = new Set()
1305
1514
  const visit = node => {
1306
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) used.add(setters.get(node.expression.text))
1515
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, scopeRoot)) used.add(setters.get(node.expression.text))
1516
+ if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, scopeRoot)) used.add(setters.get(node.text))
1307
1517
  if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
1308
1518
  ts.forEachChild(node, visit)
1309
1519
  }
@@ -1342,20 +1552,22 @@ function referencedImportedBindings(expression, imports) {
1342
1552
 
1343
1553
  function captureNames(declarationRoot, referenceRoot, setters) {
1344
1554
  const local = new Set()
1345
- const collectDeclarations = node => {
1346
- if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
1347
- if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
1348
- if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
1349
- ts.forEachChild(node, collectDeclarations)
1555
+ if (!isFunctionLike(declarationRoot)) {
1556
+ const collectDeclarations = node => {
1557
+ if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
1558
+ if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
1559
+ if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
1560
+ ts.forEachChild(node, collectDeclarations)
1561
+ }
1562
+ collectDeclarations(declarationRoot)
1350
1563
  }
1351
- collectDeclarations(declarationRoot)
1352
-
1353
1564
  const stateNames = new Set(setters.values())
1354
1565
  const captures = new Set()
1355
1566
  const visit = node => {
1356
1567
  if (ts.isTypeNode(node)) return
1357
- if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !local.has(node.text) && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) {
1358
- captures.add(node.text)
1568
+ if (ts.isIdentifier(node)) {
1569
+ const declared = isFunctionLike(declarationRoot) ? isShadowedIdentifier(node, declarationRoot) : local.has(node.text)
1570
+ if (isReferenceIdentifier(node) && !declared && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) captures.add(node.text)
1359
1571
  }
1360
1572
  ts.forEachChild(node, visit)
1361
1573
  }
@@ -1377,7 +1589,7 @@ function isReferenceIdentifier(node) {
1377
1589
  (ts.isVariableDeclaration(parent) && parent.name === node) ||
1378
1590
  (ts.isParameter(parent) && parent.name === node) ||
1379
1591
  (ts.isFunctionDeclaration(parent) && parent.name === node) ||
1380
- (ts.isBindingElement(parent) && parent.name === node) ||
1592
+ (ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
1381
1593
  ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
1382
1594
  return true
1383
1595
  }
@@ -1397,6 +1609,43 @@ function isShadowedByParameter(node, scopeRoot) {
1397
1609
  return false
1398
1610
  }
1399
1611
 
1612
+ function isShadowedIdentifier(node, scopeRoot) {
1613
+ if (isShadowedByParameter(node, scopeRoot)) return true
1614
+ if (node === scopeRoot) return false
1615
+ if (isFunctionLike(scopeRoot) && scopeRoot.name?.text === node.text) return true
1616
+ if (isFunctionLike(scopeRoot) && functionVarDeclaresName(scopeRoot, node.text)) return true
1617
+ for (let current = node.parent; current; current = current.parent) {
1618
+ if (current === scopeRoot) break
1619
+ if (ts.isBlock(current) && current.statements.some(statement => statementDeclaresName(statement, node.text))) return true
1620
+ if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statementDeclaresName(statement, node.text)))) return true
1621
+ if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
1622
+ if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
1623
+ if (isFunctionLike(current) && functionVarDeclaresName(current, node.text)) return true
1624
+ }
1625
+ return false
1626
+ }
1627
+
1628
+ function statementDeclaresName(statement, name) {
1629
+ if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
1630
+ return (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name
1631
+ }
1632
+
1633
+ function loopDeclaresName(loop, name) {
1634
+ const declaration = ts.isForStatement(loop) ? loop.initializer : loop.initializer
1635
+ return declaration && ts.isVariableDeclarationList(declaration) && declaration.declarations.some(entry => bindingNames(entry.name).includes(name))
1636
+ }
1637
+
1638
+ function functionVarDeclaresName(fn, name) {
1639
+ let found = false
1640
+ const visit = node => {
1641
+ if (found || node !== fn.body && isFunctionLike(node)) return
1642
+ if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0 && node.declarations.some(entry => bindingNames(entry.name).includes(name))) found = true
1643
+ if (!found) ts.forEachChild(node, visit)
1644
+ }
1645
+ if (fn.body) visit(fn.body)
1646
+ return found
1647
+ }
1648
+
1400
1649
  function settersForNode(node, settersByFunction) {
1401
1650
  for (let current = node.parent; current; current = current.parent) {
1402
1651
  if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
@@ -1477,6 +1726,42 @@ function sourceNodeError(node, fallbackSource, message) {
1477
1726
  return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
1478
1727
  }
1479
1728
 
1729
+ function sourceLocation(node, fallbackSource) {
1730
+ const original = ts.getOriginalNode(node)
1731
+ const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
1732
+ const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
1733
+ return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
1734
+ }
1735
+
1736
+ function returnsCleanup(callback) {
1737
+ if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) {
1738
+ const body = unwrapExpression(callback.body)
1739
+ return ts.isArrowFunction(body) || ts.isFunctionExpression(body)
1740
+ }
1741
+ let found = false
1742
+ const visit = node => {
1743
+ if (found || node !== callback.body && isFunctionLike(node)) return
1744
+ if (ts.isReturnStatement(node) && node.expression) {
1745
+ const expression = unwrapExpression(node.expression)
1746
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) found = true
1747
+ }
1748
+ if (!found) ts.forEachChild(node, visit)
1749
+ }
1750
+ visit(callback.body)
1751
+ return found
1752
+ }
1753
+
1754
+ function returnsEffectValue(callback) {
1755
+ let found = false
1756
+ const visit = node => {
1757
+ if (found || node !== callback.body && isFunctionLike(node)) return
1758
+ if (ts.isReturnStatement(node) && node.expression) found = true
1759
+ if (!found) ts.forEachChild(node, visit)
1760
+ }
1761
+ visit(callback.body)
1762
+ return found
1763
+ }
1764
+
1480
1765
  function printClientImports(entries, handlerPath) {
1481
1766
  const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
1482
1767
  const groups = Map.groupBy(unique.values(), entry => entry.target)
@@ -1594,24 +1879,33 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1594
1879
  const stateNames = new Set(setters.values())
1595
1880
  const transformer = context => root => {
1596
1881
  const visitor = node => {
1597
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) {
1882
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
1598
1883
  return factory.createCallExpression(
1599
1884
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"),
1600
1885
  undefined,
1601
1886
  [factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
1602
1887
  )
1603
1888
  }
1604
- if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression)) {
1889
+ if (ts.isShorthandPropertyAssignment(node) && setters.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1890
+ return factory.createPropertyAssignment(node.name, setterReference(factory, setters.get(node.name.text)))
1891
+ }
1892
+ if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1893
+ return setterReference(factory, setters.get(node.text))
1894
+ }
1895
+ if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1896
+ return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
1897
+ }
1898
+ if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1605
1899
  return factory.createCallExpression(
1606
1900
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
1607
1901
  undefined,
1608
1902
  [factory.createStringLiteral(node.text)]
1609
1903
  )
1610
1904
  }
1611
- if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
1905
+ if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
1612
1906
  return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
1613
1907
  }
1614
- if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
1908
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
1615
1909
  return scopeRead(factory, node.text)
1616
1910
  }
1617
1911
  return ts.visitEachChild(node, visitor, context)
@@ -1640,6 +1934,17 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
1640
1934
  }
1641
1935
  }
1642
1936
 
1937
+ function setterReference(factory, stateName) {
1938
+ return factory.createArrowFunction(
1939
+ undefined,
1940
+ undefined,
1941
+ [factory.createParameterDeclaration(undefined, undefined, "value")],
1942
+ undefined,
1943
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
1944
+ factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(stateName), factory.createIdentifier("value")])
1945
+ )
1946
+ }
1947
+
1643
1948
  function printReactiveBinding({ exportName, expression, captures, states }) {
1644
1949
  const factory = ts.factory
1645
1950
  const transformer = context => root => {
@@ -1762,10 +2067,17 @@ async function loadConfig() {
1762
2067
 
1763
2068
  function normalizeBase(value) {
1764
2069
  if (value == null || value === "" || value === "/") return ""
1765
- if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
2070
+ 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")
2071
+ let decoded
2072
+ try { decoded = decodeURIComponent(value) } catch { throw new Error("kudzu.config base must be a root-relative path") }
2073
+ 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")
1766
2074
  return value.replace(/\/+$/, "")
1767
2075
  }
1768
2076
 
2077
+ function browserPath(path) {
2078
+ return path ? new URL(path, "http://kudzu.local").pathname : ""
2079
+ }
2080
+
1769
2081
  function assetPath(base, path) {
1770
2082
  return `${base}/${path}`
1771
2083
  }
@@ -1788,6 +2100,43 @@ async function staticPathEntries(module, file) {
1788
2100
  })
1789
2101
  }
1790
2102
 
2103
+ function runtimeRouteSchema(module, file) {
2104
+ if (!Object.hasOwn(module, "runtimeParams")) return undefined
2105
+ if (module.runtimeParams !== true) throw new Error(`${relative(root, file)} runtimeParams must be exactly true`)
2106
+ if (typeof module.getStaticPaths === "function") throw new Error(`${relative(root, file)} runtimeParams cannot be combined with getStaticPaths()`)
2107
+ const route = pageRoutePattern(file)
2108
+ if (route.includes("[...")) throw new Error(`Catch-all routes are not supported: ${route}`)
2109
+ const names = new Set()
2110
+ const segments = route.split("/").map(segment => {
2111
+ const match = segment.match(/^\[([^\]]+)\]$/)
2112
+ if (!match) {
2113
+ if (/[\[\]]/.test(segment)) throw new Error(`${relative(root, file)} runtime parameters must occupy a complete path segment`)
2114
+ return { literal: segment }
2115
+ }
2116
+ const name = match[1]
2117
+ 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)}`)
2118
+ if (names.has(name)) throw new Error(`${relative(root, file)} duplicate runtime parameter ${JSON.stringify(name)}`)
2119
+ names.add(name)
2120
+ return { param: name }
2121
+ })
2122
+ if (!names.size) throw new Error(`${relative(root, file)} runtimeParams requires a bracket page`)
2123
+ return { route, segments, params: [...names] }
2124
+ }
2125
+
2126
+ function pageRoutePattern(file) {
2127
+ const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
2128
+ return page === "index" ? "" : page.replace(/\/index$/, "")
2129
+ }
2130
+
2131
+ function runtimeSpecificity(schema) {
2132
+ return schema.segments.filter(segment => segment.literal !== undefined).length
2133
+ }
2134
+
2135
+ function sameRuntimePrecedence(left, right) {
2136
+ if (left.segments.length !== right.segments.length || runtimeSpecificity(left) !== runtimeSpecificity(right)) return false
2137
+ return left.segments.every((segment, index) => segment.literal === undefined || right.segments[index].literal === undefined || segment.literal === right.segments[index].literal)
2138
+ }
2139
+
1791
2140
  function routeFromPage(file, params = {}) {
1792
2141
  const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
1793
2142
  if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
@@ -1,6 +1,8 @@
1
1
  export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
2
 
3
3
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
+ export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
5
+ export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
4
6
 
5
7
  export interface RefObject<T> {
6
8
  readonly current: T | null
@@ -46,22 +48,29 @@ export function renderPage<Props = Record<string, never>>(
46
48
  manifest?: string
47
49
  styles?: boolean | string[]
48
50
  base?: string
51
+ effectAsset?: string
52
+ paramAsset?: string
53
+ runtimeParams?: string[]
49
54
  },
50
55
  props?: Props
51
56
  ): Promise<{
52
57
  html: string
53
58
  hasBehaviors: boolean
59
+ hasEffects: boolean
60
+ hasParams: boolean
54
61
  hasBindings: boolean
55
62
  hasLists: boolean
56
63
  hasListStyles: boolean
57
64
  hasStateSeed: boolean
58
65
  plan: {
59
66
  states: Array<{ id: string; name: string; initialValue: unknown }>
67
+ params: Array<{ name: string; id: string }>
60
68
  events: Array<{
61
69
  event: string
62
70
  commands?: Array<[string, string, unknown]>
63
71
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
64
72
  }>
73
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }>
65
74
  bindings: Array<{
66
75
  target: string
67
76
  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,13 +62,16 @@ export function useState(initialValue, name) {
35
62
  return String(this.value)
36
63
  }
37
64
  }
65
+ }
38
66
 
39
- const setter = () => {
40
- throw new Error("State setters are compiled into ordered browser behaviors")
67
+ export function useEffect(callback, dependencies, module, handler, states, scope, source) {
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")
41
71
  }
42
- Object.defineProperty(setter, setterMarker, { value: id })
43
- renderContext.states[id] = { name: name ?? id, initialValue }
44
- return [signal, setter]
72
+ renderContext.effects.push({ module, handler, states, scope, source })
73
+ renderContext.hasBehaviors = true
74
+ renderContext.hasEffects = true
45
75
  }
46
76
 
47
77
  export function useRef(initialValue) {
@@ -82,8 +112,14 @@ export function nativeBehavior(module, handler, states, scope) {
82
112
  [nativeBehaviorMarker]: true,
83
113
  module,
84
114
  handler,
115
+ ...nativeDescriptor(states, scope)
116
+ }
117
+ }
118
+
119
+ function nativeDescriptor(states, scope) {
120
+ return {
85
121
  states: Object.fromEntries(states.map(([name, signal]) => {
86
- if (!signal?.[signalMarker]) throw new Error("A native behavior must target framework state")
122
+ if (!signal?.[signalMarker]) throw new Error("A native callback must target framework state")
87
123
  return [name, signal.id]
88
124
  })),
89
125
  scope: Object.fromEntries(scope.map(([name, value]) => [name, value?.[signalMarker] ? { type: "state", id: value.id } : serializeCapture(name, value, new Set())]))
@@ -226,10 +262,21 @@ function serializeCapture(name, value, seen) {
226
262
  }
227
263
 
228
264
  export async function renderPage(component, metadata = {}, props = {}) {
229
- 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: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: 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 }
230
266
 
231
267
  try {
232
268
  const body = await renderNode({ type: component, props })
269
+ renderContext.effects = renderContext.effects.map(effect => {
270
+ try {
271
+ return {
272
+ module: effect.module,
273
+ handler: effect.handler,
274
+ ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
275
+ }
276
+ } catch (error) {
277
+ throw new Error(`${effect.source} ${error.message}`)
278
+ }
279
+ })
233
280
  const title = escapeHtml(metadata.title ?? "Kudzu")
234
281
  const head = renderMetadata(metadata)
235
282
  const styles = metadata.styles === false
@@ -241,12 +288,18 @@ export async function renderPage(component, metadata = {}, props = {}) {
241
288
  const nativeRuntime = renderContext.hasNativeBehaviors
242
289
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
243
290
  : ""
291
+ const paramRuntime = renderContext.hasParams
292
+ ? `<script type="module" src="${escapeAttribute(metadata.paramAsset)}"></script>`
293
+ : ""
244
294
  const bindingRuntime = renderContext.hasBindings
245
295
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
246
296
  : ""
247
297
  const listRuntime = renderContext.hasLists
248
298
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
249
299
  : ""
300
+ const effectRuntime = renderContext.hasEffects
301
+ ? `<script type="module" src="${escapeAttribute(metadata.effectAsset)}"></script>`
302
+ : ""
250
303
  const listStates = new Set(renderContext.lists.map(list => list.state))
251
304
  const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
252
305
  const initialState = renderContext.hasBehaviors
@@ -263,15 +316,19 @@ export async function renderPage(component, metadata = {}, props = {}) {
263
316
  : ""
264
317
 
265
318
  return {
266
- 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}</body></html>`,
319
+ 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>`,
267
320
  hasBehaviors: renderContext.hasBehaviors,
321
+ hasEffects: renderContext.hasEffects,
322
+ hasParams: renderContext.hasParams,
268
323
  hasBindings: renderContext.hasBindings,
269
324
  hasLists: renderContext.hasLists,
270
325
  hasListStyles: renderContext.hasListStyles,
271
326
  hasStateSeed: initialState.length > 0,
272
327
  plan: {
273
328
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
329
+ params: renderContext.paramEntries,
274
330
  events: renderContext.events,
331
+ effects: renderContext.effects,
275
332
  bindings: renderContext.bindings,
276
333
  conditions: renderContext.conditions,
277
334
  lists: renderContext.lists
@@ -552,6 +609,11 @@ async function renderList(node, namespace, selectValue) {
552
609
  const template = await renderNode(node.render({}), namespace, selectValue)
553
610
  if (template.includes("data-k-native-")) descriptor.mount = true
554
611
  if (template.includes("data-k-list-condition")) descriptor.conditions = true
612
+ if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
613
+ if (template.includes("data-k-list-attrs")) descriptor.attributes = true
614
+ if (template.includes("data-k-list-events")) descriptor.events = true
615
+ if (template.includes("data-k-list-expression=")) descriptor.expressions = true
616
+ if (template.includes("data-k-list-expression-attrs")) descriptor.expressionAttributes = true
555
617
  const seed = listSeed(node.items.value, renderContext.listFields)
556
618
  if (seed) descriptor.seed = seed
557
619
  let current = ""
@@ -0,0 +1,39 @@
1
+ import { deserialize } from "./serialization.js"
2
+
3
+ export function createEffectContext(state, stateIds, commit, serializedScope = {}) {
4
+ const changed = new Set()
5
+ let scheduled = false
6
+
7
+ const flush = () => {
8
+ scheduled = false
9
+ const ids = [...changed]
10
+ changed.clear()
11
+ for (const id of ids) commit(id, state.get(id))
12
+ }
13
+
14
+ const setId = (id, value) => {
15
+ const current = state.get(id)
16
+ state.set(id, typeof value === "function" ? value(current) : value)
17
+ changed.add(id)
18
+ if (!scheduled) {
19
+ scheduled = true
20
+ queueMicrotask(flush)
21
+ }
22
+ }
23
+
24
+ const scope = globalThis.__KUDZU_EFFECT_CAPTURES__
25
+ ? Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined)]))
26
+ : undefined
27
+
28
+ return {
29
+ get(name) {
30
+ return state.get(stateIds[name])
31
+ },
32
+ scope(name) {
33
+ return globalThis.__KUDZU_EFFECT_CAPTURES__ ? serializedScope[name]?.type === "state" ? state.get(serializedScope[name].id) : scope[name] : undefined
34
+ },
35
+ set(name, value) {
36
+ setId(stateIds[name], value)
37
+ }
38
+ }
39
+ }
@@ -3,11 +3,11 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
3
3
  const listTargets = new Map()
4
4
  const listRegistrations = new WeakMap()
5
5
  const mountedLists = new WeakSet()
6
- const imports = new Map()
7
- const revisions = new WeakMap()
6
+ const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
7
+ const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
8
8
  const itemParts = new WeakMap()
9
- const conditionOwners = new WeakMap()
10
- const itemPartsSelector = `[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
9
+ const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
10
+ const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
11
11
 
12
12
  function commitLists(id) {
13
13
  const lists = listTargets.get(id)
@@ -34,13 +34,13 @@ function mountLists(root) {
34
34
  const templateRoot = start.content.firstElementChild
35
35
  const parts = listItemPartPlan(templateRoot)
36
36
  for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
37
- if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
37
+ if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
38
38
  const items = browserState.get(descriptor.state)
39
39
  const list = {
40
40
  start,
41
41
  descriptor,
42
42
  parts,
43
- seedFields: descriptor.seed && Object.keys(descriptor.seed),
43
+ seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
44
44
  roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
45
45
  values: new Map(),
46
46
  container: roots[0]?.parentNode,
@@ -77,7 +77,7 @@ function updateList(list) {
77
77
  const key = item?.[list.descriptor.key]
78
78
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
79
79
  assertListItem(item)
80
- const seededValue = list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed)
80
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
81
81
  if (seededValue === undefined) assertListValue(item, seen, true)
82
82
  const token = keyToken(key)
83
83
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
@@ -108,13 +108,13 @@ function updateList(list) {
108
108
  }
109
109
  for (const [token, node] of list.roots) {
110
110
  if (keys.has(token)) continue
111
- if (list.descriptor.mount) {
111
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
112
112
  unmountDom(node)
113
113
  node.remove()
114
114
  } else node.remove()
115
115
  }
116
116
  if (added) {
117
- if (list.descriptor.mount) mountDom(additions)
117
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(additions)
118
118
  parent.insertBefore(additions, list.boundary)
119
119
  list.container ??= parent
120
120
  }
@@ -142,8 +142,8 @@ function updateList(list) {
142
142
  }
143
143
 
144
144
  function fillListItem(root, item) {
145
- const revision = (revisions.get(root) ?? 0) + 1
146
- revisions.set(root, revision)
145
+ const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
146
+ if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
147
147
  const parts = listItemParts(root)
148
148
  fillListParts(root, parts, item, revision)
149
149
  }
@@ -159,30 +159,38 @@ function fillListParts(root, parts, item, revision) {
159
159
  node.textContent = value
160
160
  }
161
161
  }
162
- for (const [marker, field] of parts.texts) {
163
- patchListText(marker, "template[data-k-list-text-end]", item?.[field])
162
+ if (__KUDZU_LIST_TEXT_RANGES__) {
163
+ for (const [marker, field] of parts.texts) patchListText(marker, "template[data-k-list-text-end]", item?.[field])
164
164
  }
165
- for (const [node, attributes] of parts.attributes) {
166
- for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
167
- }
168
- for (const [node, events] of parts.events) {
169
- for (const [event, native] of JSON.parse(events)) {
170
- native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
171
- node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
165
+ if (__KUDZU_LIST_ATTRIBUTES__) {
166
+ for (const [node, attributes] of parts.attributes) {
167
+ for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
172
168
  }
173
169
  }
174
- for (const [marker, descriptor] of parts.expressions) {
175
- evaluate(descriptor, item).then(value => {
176
- if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
177
- }).catch(error => console.error(error))
170
+ if (__KUDZU_LIST_EVENTS__) {
171
+ for (const [node, events] of parts.events) {
172
+ for (const [event, native] of JSON.parse(events)) {
173
+ native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
174
+ node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
175
+ }
176
+ }
178
177
  }
179
- for (const [node, attributes] of parts.expressionAttributes) {
180
- for (const [target, module, handler] of attributes) {
181
- evaluate({ module, handler }, item).then(value => {
182
- if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
178
+ if (__KUDZU_LIST_EXPRESSIONS__) {
179
+ for (const [marker, descriptor] of parts.expressions) {
180
+ evaluate(descriptor, item).then(value => {
181
+ if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
183
182
  }).catch(error => console.error(error))
184
183
  }
185
184
  }
185
+ if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__) {
186
+ for (const [node, attributes] of parts.expressionAttributes) {
187
+ for (const [target, module, handler] of attributes) {
188
+ evaluate({ module, handler }, item).then(value => {
189
+ if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
190
+ }).catch(error => console.error(error))
191
+ }
192
+ }
193
+ }
186
194
  if (__KUDZU_LIST_CONDITIONS__) {
187
195
  for (const [marker, descriptor] of parts.conditions) {
188
196
  evaluate(descriptor, item).then(value => {
@@ -198,10 +206,10 @@ function listItemParts(root) {
198
206
  parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
199
207
  for (const node of matching(root, itemPartsSelector)) {
200
208
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
201
- if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
202
- if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
203
- if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
204
- if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
209
+ if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
210
+ if (__KUDZU_LIST_EVENTS__ && node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
211
+ if (__KUDZU_LIST_EXPRESSIONS__ && node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
212
+ if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ && node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
205
213
  if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
206
214
  parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
207
215
  conditionOwners.set(node, root)
@@ -217,11 +225,11 @@ function listItemPartPlan(template) {
217
225
  const parts = listItemParts(template)
218
226
  return {
219
227
  directTexts: parts.directTexts.map(([node, field]) => [indexes.get(node), field]),
220
- texts: parts.texts.map(([node, field]) => [indexes.get(node), field]),
221
- attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
222
- events: parts.events.map(([node, events]) => [indexes.get(node), events]),
223
- expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
224
- expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
228
+ texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([node, field]) => [indexes.get(node), field]) : [],
229
+ attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
230
+ events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([node, events]) => [indexes.get(node), events]) : [],
231
+ expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
232
+ expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
225
233
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
226
234
  }
227
235
  }
@@ -230,11 +238,11 @@ function mapListItemParts(parts, root) {
230
238
  const target = [root, ...root.querySelectorAll("*")]
231
239
  itemParts.set(root, {
232
240
  directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
233
- texts: parts.texts.map(([index, field]) => [target[index], field]),
234
- attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
235
- events: parts.events.map(([index, events]) => [target[index], events]),
236
- expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
237
- expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
241
+ texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([index, field]) => [target[index], field]) : [],
242
+ attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([index, attributes]) => [target[index], attributes]) : [],
243
+ events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([index, events]) => [target[index], events]) : [],
244
+ expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([index, descriptor]) => [target[index], descriptor]) : [],
245
+ expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]) : [],
238
246
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
239
247
  conditionOwners.set(target[index], root)
240
248
  return [target[index], descriptor]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
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"