@kudzujs/core 0.5.8 → 0.6.0

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.
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http"
2
- import { randomUUID } from "node:crypto"
2
+ import { createHash, randomUUID } from "node:crypto"
3
3
  import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
4
4
  import { dirname, extname, join, relative, resolve, sep } from "node:path"
5
5
  import { pathToFileURL } from "node:url"
@@ -19,6 +19,15 @@ const devClient = (session, revision, schema) => `<script>(()=>{const show=event
19
19
  export async function build({ quiet = false, minify = true } = {}) {
20
20
  const config = await loadConfig()
21
21
  const base = normalizeBase(config.base)
22
+ const configuredStyles = normalizeStyles(config.styles, base)
23
+ const navigationRoutes = normalizeNavigation(config.navigation)
24
+ const navigationSet = new Set(navigationRoutes)
25
+ const browserNavigationRoutes = navigationRoutes.map(route => withBase(base, route))
26
+ const navigationAsset = assetPath(base, "assets/kudzu-navigation.js")
27
+ const navigationId = navigationRoutes.length ? createHash("sha256").update(JSON.stringify([...navigationRoutes].sort())).digest("hex").slice(0, 16) : undefined
28
+ const applicationId = navigationId ? `a-${navigationId}` : undefined
29
+ const layoutId = navigationId ? `l-${navigationId}` : undefined
30
+ let navigationLayout
22
31
  await rm(workDirectory, { recursive: true, force: true })
23
32
  await rm(outputDirectory, { recursive: true, force: true })
24
33
  await mkdir(workDirectory, { recursive: true })
@@ -41,21 +50,30 @@ export async function build({ quiet = false, minify = true } = {}) {
41
50
  if (!pageFiles.length) throw new Error("No pages found in src/pages/")
42
51
 
43
52
  let behaviorCount = 0
53
+ let regularBehaviorCount = 0
44
54
  let bindingCount = 0
45
55
  let listCount = 0
46
56
  let listStyleCount = 0
47
- let stateSeedCount = 0
57
+ let regularStateSeedCount = 0
58
+ let dependencyStateSeedCount = 0
48
59
  const plans = []
60
+ const pageEntries = []
49
61
  const effectEntries = []
50
62
  const paramEntries = []
51
63
  const rewrites = []
52
64
  const emittedRoutes = new Set()
53
- const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
65
+ const emittedApplicationRoutes = new Set()
66
+ const styleUrls = [...new Set([
67
+ ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
68
+ ...configuredStyles
69
+ ])]
70
+ const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
54
71
 
55
72
  for (const pageFile of pageFiles) {
56
73
  const compiledFile = compiledPath(pageFile)
57
74
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
58
75
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
76
+ if (Object.hasOwn(module, "layout") && typeof module.layout !== "function") throw layoutExportError(pageFile, sourceIndex.get(pageFile))
59
77
 
60
78
  const runtimeSchema = runtimeRouteSchema(module, pageFile)
61
79
  if (runtimeSchema) {
@@ -72,33 +90,52 @@ export async function build({ quiet = false, minify = true } = {}) {
72
90
  const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
73
91
  for (const { params, props } of entries) {
74
92
  const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
93
+ const applicationRoute = `/${route}`
75
94
  const routePath = withBase(base, `/${route}`)
95
+ const navigable = navigationSet.has(applicationRoute)
76
96
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
77
97
  const paramPath = `params/${route}/index.js`
78
98
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
79
99
  emittedRoutes.add(routePath)
100
+ emittedApplicationRoutes.add(applicationRoute)
101
+ if (navigable) {
102
+ if (runtimeSchema) throw new Error(`kudzu.config navigation route ${JSON.stringify(routePath)} must be an exact static route; runtime bracket routes are not supported`)
103
+ if (typeof module.layout !== "function") throw new Error(`kudzu.config navigation route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
104
+ if (navigationLayout && navigationLayout !== module.layout) throw new Error("kudzu.config navigation routes must export the same layout function identity")
105
+ navigationLayout = module.layout
106
+ }
80
107
  const result = await renderPage(module.default, {
81
108
  ...(module.metadata ?? {}),
82
109
  styles: styleUrls.length ? styleUrls : false,
83
110
  base,
111
+ runtimeAsset: runtimePlaceholder,
84
112
  effectAsset: assetPath(base, `assets/${effectPath}`),
85
113
  paramAsset: assetPath(base, `assets/${paramPath}`),
86
- runtimeParams: runtimeSchema?.params
87
- }, props)
88
- const routeDirectory = join(outputDirectory, route)
89
- await mkdir(routeDirectory, { recursive: true })
90
- await writeFile(join(routeDirectory, "index.html"), result.html)
114
+ runtimeParams: runtimeSchema?.params,
115
+ ...(navigable ? { navigationAsset, applicationId, layoutId } : {})
116
+ }, props, module.layout)
117
+ const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
118
+ const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
119
+ pageEntries.push({ route, html: result.html, usesDependencyRuntime })
91
120
  plans.push({ route: routePath, ...result.plan })
92
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params })
93
- if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined })
94
- if (result.hasBehaviors) behaviorCount++
121
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
122
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
123
+ if (result.hasBehaviors) {
124
+ behaviorCount++
125
+ if (!usesDependencyRuntime) regularBehaviorCount++
126
+ }
95
127
  if (result.hasBindings) bindingCount++
96
128
  if (result.hasLists) listCount++
97
129
  if (result.hasListStyles) listStyleCount++
98
- if (result.hasStateSeed) stateSeedCount++
130
+ if (result.hasStateSeed) {
131
+ if (usesDependencyRuntime) dependencyStateSeedCount++
132
+ else regularStateSeedCount++
133
+ }
99
134
  }
100
135
  }
101
136
 
137
+ for (const route of navigationRoutes) if (!emittedApplicationRoutes.has(route)) throw new Error(`kudzu.config navigation route ${JSON.stringify(route)} is not an exact emitted route`)
138
+
102
139
  const assetsDirectory = join(outputDirectory, "assets")
103
140
  await mkdir(assetsDirectory, { recursive: true })
104
141
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
@@ -111,6 +148,7 @@ export async function build({ quiet = false, minify = true } = {}) {
111
148
  const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
112
149
  const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
113
150
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
151
+ const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
114
152
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
115
153
  const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
116
154
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
@@ -119,11 +157,26 @@ export async function build({ quiet = false, minify = true } = {}) {
119
157
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
120
158
  const hasNativeHandlers = nativeModules.length > 0
121
159
  const hasEffects = effectEntries.length > 0
122
- if (behaviorCount) {
123
- const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
124
- const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
160
+ const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
161
+ const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
162
+ const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
163
+ const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
164
+ for (const entry of pageEntries) {
165
+ const routeDirectory = join(outputDirectory, entry.route)
166
+ await mkdir(routeDirectory, { recursive: true })
167
+ const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
168
+ await writeFile(join(routeDirectory, "index.html"), html)
169
+ }
170
+ if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
171
+ const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
172
+ let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
173
+ if (hasNavigableEffects) runtime = runtime.replace("export function registerCommitter(commit) {\n committers.push(commit)\n}", "export function registerCommitter(commit) {\n committers.push(commit)\n return () => {\n const index = committers.indexOf(commit)\n if (index !== -1) committers.splice(index, 1)\n }\n}")
125
174
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
126
175
  }
176
+ if (hasDependencyRuntime) {
177
+ const runtime = specializeRuntime(await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8"), commandEvents, dependencyStateSeedCount > 0)
178
+ await writeJavaScript(join(assetsDirectory, "kudzu-deps.js"), runtime, minify)
179
+ }
127
180
  if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
128
181
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
129
182
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
@@ -138,10 +191,11 @@ export async function build({ quiet = false, minify = true } = {}) {
138
191
  }
139
192
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
140
193
  if (bindingCount) {
141
- const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
194
+ let bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
142
195
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
143
196
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
144
197
  .replace('"./style.js"', '"./kudzu-style.js"')
198
+ if (navigationRoutes.length) bindingRuntime = specializeNavigationTextDescriptors(bindingRuntime)
145
199
  await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
146
200
  "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
147
201
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
@@ -166,6 +220,7 @@ export async function build({ quiet = false, minify = true } = {}) {
166
220
  __KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
167
221
  __KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
168
222
  __KUDZU_LIST_SEEDS__: String(hasListSeeds),
223
+ __KUDZU_LIST_EFFECTS__: String(hasListEffects),
169
224
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
170
225
  __KUDZU_LIST_MOUNTS__: String(hasListMounts)
171
226
  })
@@ -178,6 +233,14 @@ export async function build({ quiet = false, minify = true } = {}) {
178
233
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
179
234
  })
180
235
  }
236
+ if (navigationRoutes.length) {
237
+ const navigationRuntime = (await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8"))
238
+ .replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(browserNavigationRoutes))
239
+ .replace("__KUDZU_APPLICATION_ID__", JSON.stringify(applicationId))
240
+ .replace("__KUDZU_LAYOUT_ID__", JSON.stringify(layoutId))
241
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
242
+ await writeJavaScript(join(assetsDirectory, "kudzu-navigation.js"), specializeNavigationEffects(navigationRuntime, hasNavigableEffects), minify)
243
+ }
181
244
  for (const handlerModule of handlerModules) {
182
245
  const output = join(assetsDirectory, handlerModule.path)
183
246
  await mkdir(resolve(output, ".."), { recursive: true })
@@ -186,12 +249,14 @@ export async function build({ quiet = false, minify = true } = {}) {
186
249
  for (const entry of paramEntries) {
187
250
  const output = join(assetsDirectory, entry.path)
188
251
  await mkdir(dirname(output), { recursive: true })
189
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
252
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
190
253
  }
191
254
  for (const entry of effectEntries) {
192
255
  const output = join(assetsDirectory, entry.path)
193
256
  await mkdir(dirname(output), { recursive: true })
194
- await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
257
+ await writeJavaScript(output, entry.navigable
258
+ ? printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
259
+ : printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
195
260
  }
196
261
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
197
262
  for (const file of clientModules) {
@@ -237,14 +302,53 @@ function specializeEvents(source, events) {
237
302
  return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
238
303
  }
239
304
 
305
+ function specializeNavigationEffects(source, enabled) {
306
+ if (enabled) return source
307
+ return source
308
+ .replace("const noDispose = async () => {}\nlet routeDispose = noDispose\nlet layoutDispose = noDispose\nconst ready = mountInitial()\n", "")
309
+ .replace(`addEventListener("pagehide", event => {
310
+ if (event.persisted) return
311
+ ++revision
312
+ request?.abort()
313
+ void (async () => {
314
+ await routeDispose()
315
+ await layoutDispose()
316
+ })()
317
+ })
318
+ `, "")
319
+ .replace(`
320
+ async function mountInitial() {
321
+ try {
322
+ const effects = await loadCapabilities(validate(document))
323
+ layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
324
+ routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
325
+ } catch (error) {
326
+ console.error(error)
327
+ }
328
+ }
329
+ `, "")
330
+ .replace(" await ready\n", "")
331
+ .replace(" const effects = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
332
+ .replace(" await routeDispose()\n if (current !== revision) return\n", "")
333
+ .replace(" routeDispose = await effects?.mountRouteEffects?.() ?? noDispose\n", "")
334
+ .replace(`
335
+ async function loadCapabilities(parsed) {
336
+ const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
337
+ return modules.find(module => typeof module.mountRouteEffects === "function")
338
+ }
339
+ `, "")
340
+ }
341
+
240
342
  function specializeNativeRuntime(source, events, modules) {
241
343
  const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
242
344
  const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
243
345
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
244
346
  }
245
347
 
246
- function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
348
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
247
349
  const hasCleanup = effects.some(effect => effect.cleanup)
350
+ const hasDependencies = effects.some(effect => effect.dependencies?.length)
351
+ const hasOwners = effects.some(effect => effect.owner)
248
352
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
249
353
  const modules = moduleUrls.map(url => {
250
354
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -252,14 +356,119 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
252
356
  return module
253
357
  })
254
358
  const imports = [
255
- hasCleanup
256
- ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}\nconst { browserState, commitDom } = __kRuntime`
257
- : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
359
+ hasCleanup || hasDependencies || hasOwners
360
+ ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
361
+ : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
258
362
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
259
363
  ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
260
364
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
261
365
  ]
262
366
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
367
+ if (hasOwners) return printOwnedEffectEntry(imports, effects, entries)
368
+ if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
369
+ const disposal = hasCleanup ? `
370
+ let disposed = false
371
+ const dispose = root => {
372
+ if (root !== document || disposed) return
373
+ disposed = true
374
+ active = false
375
+ pending.clear()
376
+ for (const record of records) invokeCleanup(record)
377
+ }
378
+
379
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
380
+ addEventListener("pagehide", event => {
381
+ if (event.persisted) return
382
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
383
+ else dispose(document)
384
+ })` : ""
385
+ if (hasDependencies) return `${imports.join("\n")}
386
+ const effects = ${inlineJson(effects)}
387
+ const modules = new Map([${entries}])
388
+ const records = effects.map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined }))
389
+ const dependencies = new Map()
390
+ const pending = new Set()
391
+ let scheduled = false
392
+ let flushing = false
393
+ let active = true
394
+ for (const record of records) {
395
+ for (const id of record.effect.dependencies ?? []) {
396
+ const subscribers = dependencies.get(id) ?? new Set()
397
+ subscribers.add(record)
398
+ dependencies.set(id, subscribers)
399
+ }
400
+ }
401
+ __kRuntime.registerCommitter(id => {
402
+ if (!active) return
403
+ for (const record of dependencies.get(id) ?? []) pending.add(record)
404
+ schedule()
405
+ })
406
+ for (const record of records) {
407
+ try {
408
+ record.values = readDependencies(record)
409
+ invoke(record)
410
+ } catch (error) {
411
+ console.error(error)
412
+ }
413
+ }
414
+ function schedule() {
415
+ if (!pending.size || scheduled || flushing) return
416
+ scheduled = true
417
+ queueMicrotask(flush)
418
+ }
419
+ async function flush() {
420
+ scheduled = false
421
+ if (!active) return pending.clear()
422
+ flushing = true
423
+ try {
424
+ const selected = [...pending].sort((left, right) => left.index - right.index)
425
+ pending.clear()
426
+ const changed = []
427
+ for (const record of selected) {
428
+ try {
429
+ const values = readDependencies(record)
430
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
431
+ record.values = values
432
+ changed.push(record)
433
+ }
434
+ } catch (error) {
435
+ console.error(error)
436
+ }
437
+ }
438
+ for (const record of changed) await invokeCleanup(record)
439
+ if (active) for (const record of changed) invoke(record)
440
+ } finally {
441
+ flushing = false
442
+ if (active) schedule()
443
+ }
444
+ }
445
+ function readDependencies(record) {
446
+ return (record.effect.dependencies ?? []).map(id => {
447
+ const value = browserState.get(id)
448
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
449
+ return value
450
+ })
451
+ }
452
+ function invoke(record) {
453
+ try {
454
+ const effect = record.effect
455
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
456
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
457
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
458
+ } catch (error) {
459
+ console.error(error)
460
+ }
461
+ }
462
+ async function invokeCleanup(record) {
463
+ const cleanup = record.cleanup
464
+ record.cleanup = undefined
465
+ if (!cleanup) return
466
+ try {
467
+ await cleanup()
468
+ } catch (error) {
469
+ console.error(error)
470
+ }
471
+ }${disposal}`
263
472
  if (!hasCleanup) return `${imports.join("\n")}
264
473
  const effects = ${inlineJson(effects)}
265
474
  const modules = new Map([${entries}])
@@ -306,8 +515,415 @@ addEventListener("pagehide", event => {
306
515
  })`
307
516
  }
308
517
 
309
- function printParamEntry(schema, params, output, assetsDirectory, base) {
310
- return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
518
+ function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
519
+ const moduleUrls = [...new Set(effects.map(effect => effect.module))]
520
+ const modules = moduleUrls.map(url => {
521
+ const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
522
+ if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
523
+ return module
524
+ })
525
+ const imports = [
526
+ `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
527
+ `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
528
+ ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
529
+ ]
530
+ const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
531
+ return `${imports.join("\n")}
532
+ const effects = ${inlineJson(effects)}
533
+ const modules = new Map([${entries}])
534
+ export const mountLayoutEffects = () => mount("layout")
535
+ export const mountRouteEffects = () => mount("route")
536
+ function mount(lifetime) {
537
+ let active = true
538
+ let flushing
539
+ const records = effects.filter(effect => effect.lifetime === lifetime).map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined, token: undefined }))
540
+ const dependencies = new Map()
541
+ const pending = new Set()
542
+ let scheduled = false
543
+ for (const record of records) for (const id of record.effect.dependencies ?? []) {
544
+ const subscribers = dependencies.get(id) ?? new Set()
545
+ subscribers.add(record)
546
+ dependencies.set(id, subscribers)
547
+ }
548
+ const unsubscribe = dependencies.size ? __kRuntime.registerCommitter(id => {
549
+ if (!active) return
550
+ for (const record of dependencies.get(id) ?? []) pending.add(record)
551
+ if (pending.size && !scheduled && !flushing) {
552
+ scheduled = true
553
+ queueMicrotask(flush)
554
+ }
555
+ }) : undefined
556
+ for (const record of records) {
557
+ try {
558
+ record.values = readDependencies(record)
559
+ invoke(record)
560
+ } catch (error) {
561
+ console.error(error)
562
+ }
563
+ }
564
+ async function flush() {
565
+ scheduled = false
566
+ if (!active) return pending.clear()
567
+ const operation = (async () => {
568
+ const selected = [...pending].sort((left, right) => left.index - right.index)
569
+ pending.clear()
570
+ const changed = []
571
+ for (const record of selected) {
572
+ try {
573
+ const values = readDependencies(record)
574
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
575
+ record.values = values
576
+ changed.push(record)
577
+ }
578
+ } catch (error) {
579
+ console.error(error)
580
+ }
581
+ }
582
+ for (const record of changed) await cleanup(record)
583
+ if (active) for (const record of changed) invoke(record)
584
+ })()
585
+ flushing = operation
586
+ try { await operation } finally {
587
+ if (flushing === operation) flushing = undefined
588
+ if (active && pending.size && !scheduled) {
589
+ scheduled = true
590
+ queueMicrotask(flush)
591
+ }
592
+ }
593
+ }
594
+ function readDependencies(record) {
595
+ return (record.effect.dependencies ?? []).map(id => {
596
+ const value = __kRuntime.browserState.get(id)
597
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
598
+ return value
599
+ })
600
+ }
601
+ function invoke(record) {
602
+ const token = { active: true }
603
+ record.token = token
604
+ try {
605
+ const effect = record.effect
606
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(__kRuntime.browserState, effect.states, __kRuntime.commitDom, effect.scope, () => active && token.active && record.token === token))
607
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
608
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
609
+ } catch (error) {
610
+ console.error(error)
611
+ }
612
+ }
613
+ async function cleanup(record) {
614
+ if (record.token) record.token.active = false
615
+ record.token = undefined
616
+ const current = record.cleanup
617
+ record.cleanup = undefined
618
+ if (!current) return
619
+ try { await current() } catch (error) { console.error(error) }
620
+ }
621
+ let disposal
622
+ return async function dispose() {
623
+ if (disposal) return disposal
624
+ disposal = (async () => {
625
+ active = false
626
+ unsubscribe?.()
627
+ pending.clear()
628
+ for (const record of records) if (record.token) record.token.active = false
629
+ if (flushing) await flushing
630
+ for (const record of records) await cleanup(record)
631
+ })()
632
+ return disposal
633
+ }
634
+ }`
635
+ }
636
+
637
+ function runtimeEffects(effects, lifetimes = false) {
638
+ return effects.map(effect => ({
639
+ module: effect.module,
640
+ handler: effect.handler,
641
+ ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
642
+ ...(effect.cleanup ? { cleanup: true } : {}),
643
+ ...(effect.owner ? { owner: effect.owner } : {}),
644
+ ...(effect.list ? { list: true } : {}),
645
+ ...(lifetimes && effect.lifetime ? { lifetime: effect.lifetime } : {}),
646
+ states: effect.states,
647
+ scope: effect.scope
648
+ }))
649
+ }
650
+
651
+ function printOwnedEffectEntry(imports, effects, entries) {
652
+ return `${imports.join("\n")}
653
+ const effects = ${inlineJson(effects)}
654
+ const modules = new Map([${entries}])
655
+ const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
656
+ const listTemplates = new Map(effects.map((effect, index) => effect.list ? [effect.owner, { effect, index }] : undefined).filter(Boolean))
657
+ const owners = new Map(records.filter(record => record.effect.owner).map(record => [record.effect.owner, record]))
658
+ const listRegistrations = new WeakMap()
659
+ const mountedRecords = new Set(records.filter(record => record.mounted))
660
+ const dependencies = new Map()
661
+ const pending = new Set()
662
+ let scheduled = false
663
+ let flushing = false
664
+ let active = true
665
+ for (const record of records) registerDependencies(record)
666
+ function createRecord(effect, index) {
667
+ return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined }
668
+ }
669
+ function registerDependencies(record) {
670
+ for (const id of record.effect.dependencies ?? []) {
671
+ const subscribers = dependencies.get(id) ?? new Set()
672
+ subscribers.add(record)
673
+ dependencies.set(id, subscribers)
674
+ }
675
+ }
676
+ function unregisterDependencies(record) {
677
+ for (const id of record.effect.dependencies ?? []) {
678
+ const subscribers = dependencies.get(id)
679
+ subscribers?.delete(record)
680
+ if (!subscribers?.size) dependencies.delete(id)
681
+ }
682
+ }
683
+ __kRuntime.registerCommitter(id => {
684
+ if (!active) return
685
+ for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
686
+ schedule()
687
+ })
688
+ __kRuntime.registerMountHook(root => {
689
+ if (!active) return
690
+ for (const marker of matching(root)) {
691
+ if (marker.dataset.kEffects) {
692
+ if (listRegistrations.has(marker)) continue
693
+ const rowRecords = JSON.parse(marker.dataset.kEffects).map(owner => {
694
+ const template = listTemplates.get(owner)
695
+ if (!template) throw new Error("Keyed row effect template was not emitted")
696
+ const record = createRecord(template.effect, template.index)
697
+ registerDependencies(record)
698
+ mount(record, marker)
699
+ return record
700
+ })
701
+ listRegistrations.set(marker, rowRecords)
702
+ continue
703
+ }
704
+ const record = owners.get(marker.dataset.kEffect)
705
+ if (!record?.mounted) mount(record, marker)
706
+ }
707
+ })
708
+ __kRuntime.registerUnmountHook(root => {
709
+ if (root === document) {
710
+ if (!active) return
711
+ active = false
712
+ pending.clear()
713
+ for (const record of [...mountedRecords]) unmount(record, record.effect.list)
714
+ return
715
+ }
716
+ for (const marker of matching(root)) {
717
+ const rowRecords = listRegistrations.get(marker)
718
+ if (rowRecords) {
719
+ for (const record of rowRecords) unmount(record, true)
720
+ listRegistrations.delete(marker)
721
+ continue
722
+ }
723
+ const record = owners.get(marker.dataset.kEffect)
724
+ if (record?.marker === marker) unmount(record)
725
+ }
726
+ })
727
+ for (const record of records) if (record.mounted) start(record)
728
+ __kRuntime.mountDom(document)
729
+ addEventListener("pagehide", event => {
730
+ if (!event.persisted) __kRuntime.unmountDom(document)
731
+ })
732
+ function matching(root) {
733
+ const selector = "template[data-k-effect],[data-k-effects]"
734
+ return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
735
+ }
736
+ function mount(record, marker) {
737
+ record.mounted = true
738
+ record.marker = marker
739
+ mountedRecords.add(record)
740
+ const version = ++record.version
741
+ const begin = () => {
742
+ if (!active || !record.mounted || record.version !== version || !marker.isConnected) return
743
+ start(record)
744
+ }
745
+ if (record.disposal) record.disposal.then(begin)
746
+ else begin()
747
+ }
748
+ function unmount(record, dynamic = false) {
749
+ if (!record.mounted) return
750
+ record.mounted = false
751
+ record.marker = undefined
752
+ mountedRecords.delete(record)
753
+ if (dynamic) unregisterDependencies(record)
754
+ record.version++
755
+ pending.delete(record)
756
+ invokeCleanup(record)
757
+ }
758
+ function start(record) {
759
+ try {
760
+ record.values = readDependencies(record)
761
+ invoke(record)
762
+ } catch (error) {
763
+ console.error(error)
764
+ }
765
+ }
766
+ function schedule() {
767
+ if (!pending.size || scheduled || flushing) return
768
+ scheduled = true
769
+ queueMicrotask(flush)
770
+ }
771
+ async function flush() {
772
+ scheduled = false
773
+ if (!active) return pending.clear()
774
+ flushing = true
775
+ try {
776
+ const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index)
777
+ pending.clear()
778
+ const changed = []
779
+ for (const record of selected) {
780
+ try {
781
+ const values = readDependencies(record)
782
+ if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
783
+ record.values = values
784
+ changed.push([record, record.version])
785
+ }
786
+ } catch (error) {
787
+ console.error(error)
788
+ }
789
+ }
790
+ for (const [record] of changed) await invokeCleanup(record)
791
+ if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
792
+ } finally {
793
+ flushing = false
794
+ if (active) schedule()
795
+ }
796
+ }
797
+ function readDependencies(record) {
798
+ return (record.effect.dependencies ?? []).map(id => {
799
+ const value = browserState.get(id)
800
+ if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
801
+ return value
802
+ })
803
+ }
804
+ function invoke(record) {
805
+ try {
806
+ const effect = record.effect
807
+ const scope = effect.list
808
+ ? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
809
+ : effect.scope
810
+ const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope))
811
+ if (effect.cleanup && typeof result === "function") record.cleanup = result
812
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
813
+ } catch (error) {
814
+ console.error(error)
815
+ }
816
+ }
817
+ function invokeCleanup(record) {
818
+ if (record.disposal) return record.disposal
819
+ const cleanup = record.cleanup
820
+ record.cleanup = undefined
821
+ if (!cleanup) return Promise.resolve()
822
+ const disposal = (async () => {
823
+ try {
824
+ await cleanup()
825
+ } catch (error) {
826
+ console.error(error)
827
+ }
828
+ })()
829
+ record.disposal = disposal
830
+ disposal.finally(() => {
831
+ if (record.disposal === disposal) record.disposal = undefined
832
+ })
833
+ return disposal
834
+ }`
835
+ }
836
+
837
+ function printSingleDependencyEffect(imports, effect, hasCleanup) {
838
+ const disposal = hasCleanup ? `
839
+ const dispose = root => {
840
+ if (root !== document || !active) return
841
+ active = false
842
+ pending = false
843
+ invokeCleanup()
844
+ }
845
+ if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
846
+ addEventListener("pagehide", event => {
847
+ if (event.persisted) return
848
+ if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
849
+ else dispose(document)
850
+ })` : ""
851
+ return `${imports.join("\n")}
852
+ const effect = ${inlineJson(effect)}
853
+ const dependency = effect.dependencies[0]
854
+ let value
855
+ let cleanup
856
+ let active = true
857
+ let pending = false
858
+ let scheduled = false
859
+ let running = false
860
+ __kRuntime.registerCommitter(id => {
861
+ if (active && id === dependency) {
862
+ pending = true
863
+ schedule()
864
+ }
865
+ })
866
+ try {
867
+ value = readDependency()
868
+ invoke()
869
+ } catch (error) {
870
+ console.error(error)
871
+ }
872
+ function schedule() {
873
+ if (!pending || scheduled || running) return
874
+ scheduled = true
875
+ queueMicrotask(flush)
876
+ }
877
+ async function flush() {
878
+ scheduled = false
879
+ if (!active) return
880
+ let next
881
+ try {
882
+ next = readDependency()
883
+ } catch (error) {
884
+ console.error(error)
885
+ return
886
+ }
887
+ pending = false
888
+ if (Object.is(next, value)) return
889
+ value = next
890
+ running = true
891
+ try {
892
+ await invokeCleanup()
893
+ if (active) invoke()
894
+ } finally {
895
+ running = false
896
+ if (active) schedule()
897
+ }
898
+ }
899
+ function readDependency() {
900
+ const next = browserState.get(dependency)
901
+ if (next !== null && typeof next !== "string" && typeof next !== "boolean" && !(typeof next === "number" && Number.isFinite(next) && !Object.is(next, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
902
+ return next
903
+ }
904
+ function invoke() {
905
+ try {
906
+ const result = __kEffectModule0[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
907
+ if (effect.cleanup && typeof result === "function") cleanup = result
908
+ else if (result && typeof result.then === "function") result.catch(error => console.error(error))
909
+ } catch (error) {
910
+ console.error(error)
911
+ }
912
+ }
913
+ async function invokeCleanup() {
914
+ const current = cleanup
915
+ cleanup = undefined
916
+ if (!current) return
917
+ try {
918
+ await current()
919
+ } catch (error) {
920
+ console.error(error)
921
+ }
922
+ }${disposal}`
923
+ }
924
+
925
+ function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
926
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
311
927
  const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
312
928
  const schema = ${inlineJson(schema.segments)}
313
929
  const params = ${inlineJson(params)}
@@ -501,7 +1117,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
501
1117
  }
502
1118
 
503
1119
  function injectDevClient(html, session, revision, schema) {
504
- return `${html}${devClient(session, revision, schema)}`
1120
+ return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
505
1121
  }
506
1122
 
507
1123
  function stripBaseStrict(path, base) {
@@ -576,6 +1192,10 @@ function escapeHtml(value) {
576
1192
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
577
1193
  }
578
1194
 
1195
+ function escapeAttribute(value) {
1196
+ return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;")
1197
+ }
1198
+
579
1199
  async function compile(file, sourceFiles, sourceIndex, base) {
580
1200
  const source = sourceIndex.get(file)
581
1201
  const nativeHandlers = []
@@ -625,6 +1245,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
625
1245
  function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
626
1246
  return context => sourceFile => {
627
1247
  const factory = context.factory
1248
+ const hasLinkElements = /<link/i.test(sourceFile.text)
628
1249
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
629
1250
  ts.setParentRecursive(sourceFile, false)
630
1251
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
@@ -650,10 +1271,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
650
1271
  const listValues = new WeakMap()
651
1272
  const listEventItems = new WeakMap()
652
1273
  const listConditions = new WeakMap()
1274
+ const listEffectEntries = new WeakMap()
653
1275
  let usesBehavior = false
654
1276
  let usesBinding = false
655
1277
  let usesConditional = false
656
1278
  let usesList = false
1279
+ let usesListEffects = false
657
1280
 
658
1281
  const collect = node => {
659
1282
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -758,6 +1381,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
758
1381
  }))
759
1382
  const componentSpecializations = new WeakMap()
760
1383
  const specializedDeclarations = new WeakSet()
1384
+ const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
761
1385
  for (const name of listComponentNames) {
762
1386
  let component = components.get(name)
763
1387
  const local = Boolean(component)
@@ -770,7 +1394,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
770
1394
  if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
771
1395
  const calls = jsxTagUses(sourceFile, name)
772
1396
  if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
773
- for (const call of calls) componentSpecializations.set(call, specializeComponentCall(call, component.function, sourceFile, factory, context, fail))
1397
+ for (const call of calls) {
1398
+ const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
1399
+ if (specialization.effects.length && !keyedComponentCalls.has(call)) fail(call, "Effectful keyed row components may only be used directly as keyed map rows")
1400
+ componentSpecializations.set(call, specialization)
1401
+ }
774
1402
  if (local) specializedDeclarations.add(component.declaration)
775
1403
  }
776
1404
  const renderedLists = new WeakMap()
@@ -778,7 +1406,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
778
1406
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
779
1407
  const specialization = componentSpecializations.get(originalParts.root)
780
1408
  const root = specialization?.root ?? originalParts.root
781
- const callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
1409
+ let callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
782
1410
  originalParts.callback,
783
1411
  originalParts.callback.modifiers,
784
1412
  originalParts.callback.typeParameters,
@@ -798,6 +1426,20 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
798
1426
  validateListExpression(calculation, parts.item, originalParts.root, fail)
799
1427
  }
800
1428
  validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions)
1429
+ if (specialization?.effects.length) {
1430
+ usesListEffects = true
1431
+ const statements = specialization.effects.map(entry => {
1432
+ const call = factory.updateCallExpression(entry.call, factory.createIdentifier("__kListUseEffect"), entry.call.typeArguments, entry.call.arguments)
1433
+ synthesizeTree(call)
1434
+ const effectSource = entry.source.getSourceFile()
1435
+ listEffectEntries.set(call, { item: parts.item, source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
1436
+ return factory.createExpressionStatement(call)
1437
+ })
1438
+ callback = factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, callback.parameters, callback.type, callback.equalsGreaterThanToken, factory.createBlock([...statements, factory.createReturnStatement(root)], true))
1439
+ ts.setParentRecursive(callback, false)
1440
+ callback.parent = originalParts.callback.parent
1441
+ parts.callback = callback
1442
+ }
801
1443
  renderedLists.set(node, parts)
802
1444
  }
803
1445
 
@@ -827,6 +1469,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
827
1469
  if (specializedDeclarations.has(node)) return node
828
1470
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
829
1471
 
1472
+ if (hasLinkElements && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && isStylesheetLink(node)) {
1473
+ fail(node, "Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
1474
+ }
1475
+
830
1476
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
831
1477
  const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
832
1478
  return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
@@ -837,23 +1483,33 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
837
1483
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
838
1484
  }
839
1485
 
840
- if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
841
- if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
1486
+ const listEffect = ts.isCallExpression(node) ? listEffectEntries.get(node) : undefined
1487
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || listEffect)) {
1488
+ const effectFail = (target, message) => {
1489
+ if (listEffect) throw sourceNodeError(listEffect.source, listEffect.sourceFile, message)
1490
+ fail(target, message)
1491
+ }
1492
+ if (node.arguments.length !== 2) effectFail(node, "useEffect() requires exactly a callback and literal dependency array")
842
1493
  const [callback, dependencies] = node.arguments
843
- if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
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")
846
- if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
847
- if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
1494
+ if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) effectFail(callback, "useEffect() callback must be an inline function")
1495
+ if (ts.isFunctionExpression(callback) && callback.name) effectFail(callback, "useEffect() callback function must be anonymous")
1496
+ if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
1497
+ if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
1498
+ if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
1499
+ if (listEffect && dependencies.elements.some(dependency => referencesIdentifier(dependency, listEffect.item))) {
1500
+ effectFail(dependencies, "useEffect() item-property dependencies are not supported in keyed lists; use [] or primitive Kudzu state identifiers")
1501
+ }
1502
+ const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
1503
+ if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
848
1504
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
849
- if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
1505
+ if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
850
1506
  const returns = effectReturns(callback)
851
- if (returns.invalid) fail(returns.invalid, "useEffect() return values must be inline cleanup functions")
1507
+ if (returns.invalid) effectFail(returns.invalid, "useEffect() return values must be inline cleanup functions")
852
1508
  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")
1509
+ if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1510
+ if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
855
1511
  const setters = settersForNode(node, settersByFunction)
856
- const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true, returns.cleanup)
1512
+ const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", listEffect?.item, true, returns.cleanup)
857
1513
  usesBehavior = true
858
1514
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
859
1515
  callback,
@@ -862,7 +1518,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
862
1518
  factory.createStringLiteral(descriptor.exportName),
863
1519
  descriptor.states,
864
1520
  descriptor.scope,
865
- factory.createStringLiteral(sourceLocation(node, sourceFile)),
1521
+ factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
866
1522
  returns.cleanup ? factory.createTrue() : factory.createFalse()
867
1523
  ])
868
1524
  }
@@ -976,6 +1632,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
976
1632
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
977
1633
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
978
1634
  }
1635
+ if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
979
1636
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
980
1637
  const behaviorImport = factory.createImportDeclaration(
981
1638
  undefined,
@@ -1198,6 +1855,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
1198
1855
 
1199
1856
  let returned
1200
1857
  const calculations = []
1858
+ const effectCalls = []
1201
1859
  if (!ts.isBlock(component.body)) {
1202
1860
  returned = component.body
1203
1861
  } else {
@@ -1205,6 +1863,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
1205
1863
  const last = statements.pop()
1206
1864
  if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, "Keyed list component must end with one JSX return")
1207
1865
  for (const statement of statements) {
1866
+ if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect") {
1867
+ effectCalls.push(statement.expression)
1868
+ continue
1869
+ }
1208
1870
  if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, "Keyed list component locals must be single const declarations")
1209
1871
  const declaration = statement.declarationList.declarations[0]
1210
1872
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Keyed list component locals must be initialized identifiers")
@@ -1223,7 +1885,8 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
1223
1885
  if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
1224
1886
  ts.setParentRecursive(root, false)
1225
1887
  root.parent = call.parent
1226
- return { root, calculations }
1888
+ const effects = effectCalls.map(source => ({ source, call: substituteClone(source, substitutions, factory, context) }))
1889
+ return { root, calculations, effects }
1227
1890
  }
1228
1891
 
1229
1892
  function substituteClone(root, substitutions, factory, context) {
@@ -1256,6 +1919,16 @@ function cloneAst(root, factory, context) {
1256
1919
  return visit(root)
1257
1920
  }
1258
1921
 
1922
+ function synthesizeTree(root) {
1923
+ const visit = node => {
1924
+ ts.setTextRange(node, { pos: -1, end: -1 })
1925
+ ts.setOriginalNode(node, undefined)
1926
+ ts.forEachChild(node, visit)
1927
+ }
1928
+ visit(root)
1929
+ return root
1930
+ }
1931
+
1259
1932
  function addJsxAttribute(root, attribute, factory) {
1260
1933
  if (ts.isJsxSelfClosingElement(root)) {
1261
1934
  return factory.updateJsxSelfClosingElement(root, root.tagName, root.typeArguments, factory.updateJsxAttributes(root.attributes, [attribute, ...root.attributes.properties]))
@@ -1268,6 +1941,19 @@ function jsxTagName(node) {
1268
1941
  return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
1269
1942
  }
1270
1943
 
1944
+ function isStylesheetLink(node) {
1945
+ const element = ts.isJsxElement(node) ? node.openingElement : node
1946
+ if (!ts.isIdentifier(element.tagName) || element.tagName.text.toLowerCase() !== "link") return false
1947
+ const attribute = element.attributes.properties.find(property => ts.isJsxAttribute(property) && property.name.getText().toLowerCase() === "rel")
1948
+ if (!attribute?.initializer) return false
1949
+ const value = ts.isStringLiteral(attribute.initializer)
1950
+ ? attribute.initializer.text
1951
+ : ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression && (ts.isStringLiteral(attribute.initializer.expression) || ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))
1952
+ ? attribute.initializer.expression.text
1953
+ : undefined
1954
+ return value?.toLowerCase().split(/\s+/).includes("stylesheet") ?? false
1955
+ }
1956
+
1271
1957
  function isContextProviderValue(node, contexts) {
1272
1958
  if (node.name.getText() !== "value") return false
1273
1959
  const element = node.parent?.parent
@@ -1898,6 +2584,22 @@ function parseSourceFile(file, source) {
1898
2584
  return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
1899
2585
  }
1900
2586
 
2587
+ function layoutExportError(file, source) {
2588
+ const sourceFile = parseSourceFile(file, source)
2589
+ for (const statement of sourceFile.statements) {
2590
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
2591
+ const specifier = statement.exportClause.elements.find(entry => entry.name.text === "layout")
2592
+ if (specifier) return sourceNodeError(specifier, sourceFile, "layout export must be a function")
2593
+ }
2594
+ if (ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
2595
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === "layout")
2596
+ if (declaration) return sourceNodeError(declaration, sourceFile, "layout export must be a function")
2597
+ }
2598
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === "layout") return sourceNodeError(statement, sourceFile, "layout export must be a function")
2599
+ }
2600
+ return new Error(`${relative(root, file)} layout export must be a function`)
2601
+ }
2602
+
1901
2603
  function clientModulePath(file) {
1902
2604
  return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
1903
2605
  }
@@ -1912,6 +2614,8 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1912
2614
  const stateNames = new Set(setters.values())
1913
2615
  const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
1914
2616
  const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
2617
+ const captureSnapshotNames = snapshotNested ? nestedCaptureNames(expression, captures) : new Set()
2618
+ const captureSnapshots = new Map([...captureSnapshotNames].map(name => [name, factory.createUniqueName("__kEffectCapture")]))
1915
2619
  const transformer = context => root => {
1916
2620
  const visitor = node => {
1917
2621
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
@@ -1940,9 +2644,11 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1940
2644
  )
1941
2645
  }
1942
2646
  if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
2647
+ if (captureSnapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, captureSnapshots.get(node.name.text))
1943
2648
  return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
1944
2649
  }
1945
2650
  if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
2651
+ if (captureSnapshots.has(node.text) && insideNestedFunction(node, expression)) return captureSnapshots.get(node.text)
1946
2652
  return scopeRead(factory, node.text)
1947
2653
  }
1948
2654
  return ts.visitEachChild(node, visitor, context)
@@ -1954,8 +2660,12 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1954
2660
  let body = ts.isBlock(expression.body)
1955
2661
  ? transformed.transformed[0]
1956
2662
  : 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)),
2663
+ const snapshotDeclarations = [
2664
+ ...[...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))),
2665
+ ...[...captureSnapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"), undefined, [factory.createStringLiteral(name)])))
2666
+ ]
2667
+ if (snapshotDeclarations.length) body = factory.updateBlock(body, [
2668
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList(snapshotDeclarations, ts.NodeFlags.Const)),
1959
2669
  ...body.statements
1960
2670
  ])
1961
2671
  const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
@@ -1975,6 +2685,16 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
1975
2685
  }
1976
2686
  }
1977
2687
 
2688
+ function nestedCaptureNames(expression, captures) {
2689
+ const names = new Set()
2690
+ const visit = node => {
2691
+ if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
2692
+ ts.forEachChild(node, visit)
2693
+ }
2694
+ visit(expression.body)
2695
+ return names
2696
+ }
2697
+
1978
2698
  function nestedStateNames(expression, setters) {
1979
2699
  const states = new Set(setters.values())
1980
2700
  const names = new Set()
@@ -2124,6 +2844,43 @@ async function loadConfig() {
2124
2844
  return {}
2125
2845
  }
2126
2846
 
2847
+ function normalizeStyles(value, base) {
2848
+ if (value === undefined) return []
2849
+ if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array of URLs")
2850
+ return value.map((style, index) => {
2851
+ if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
2852
+ if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
2853
+ if (style.startsWith("/")) return withBase(base, style)
2854
+ if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
2855
+ try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
2856
+ return style
2857
+ })
2858
+ }
2859
+
2860
+ export function normalizeNavigation(value) {
2861
+ if (value === undefined) return []
2862
+ if (!isPlainRecord(value)) throw new Error("kudzu.config navigation must be a plain object")
2863
+ if (Object.keys(value).some(key => key !== "routes")) throw new Error("kudzu.config navigation only supports routes")
2864
+ if (!Array.isArray(value.routes) || !value.routes.length) throw new Error("kudzu.config navigation.routes must be a nonempty array")
2865
+ const routes = value.routes.map((route, index) => {
2866
+ if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[?#\\\0]/.test(route) || /%(?:2f|5c)/i.test(route)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
2867
+ let decoded
2868
+ try { decoded = decodeURIComponent(route) } catch { throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`) }
2869
+ if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
2870
+ return route
2871
+ })
2872
+ if (new Set(routes).size !== routes.length) throw new Error("kudzu.config navigation.routes must contain unique paths")
2873
+ return routes
2874
+ }
2875
+
2876
+ function specializeNavigationTextDescriptors(source) {
2877
+ const dynamic = source
2878
+ .replace("const textDescriptors = globalThis.__KUDZU_TEXT_BINDINGS__ && typeof document !== \"undefined\" ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []", "const textDescriptors = () => globalThis.__KUDZU_TEXT_BINDINGS__ ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []")
2879
+ .replace("const descriptor = textDescriptors[Number(node.data.slice(\"k-text:\".length))]", "const descriptor = textDescriptors()[Number(node.data.slice(\"k-text:\".length))]")
2880
+ if (dynamic === source) throw new Error("Navigation text descriptor specialization did not match binding-runtime.js")
2881
+ return dynamic
2882
+ }
2883
+
2127
2884
  function normalizeBase(value) {
2128
2885
  if (value == null || value === "" || value === "/") return ""
2129
2886
  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")