@kudzujs/core 0.5.10 → 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"
@@ -20,6 +20,14 @@ export async function build({ quiet = false, minify = true } = {}) {
20
20
  const config = await loadConfig()
21
21
  const base = normalizeBase(config.base)
22
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
23
31
  await rm(workDirectory, { recursive: true, force: true })
24
32
  await rm(outputDirectory, { recursive: true, force: true })
25
33
  await mkdir(workDirectory, { recursive: true })
@@ -54,6 +62,7 @@ export async function build({ quiet = false, minify = true } = {}) {
54
62
  const paramEntries = []
55
63
  const rewrites = []
56
64
  const emittedRoutes = new Set()
65
+ const emittedApplicationRoutes = new Set()
57
66
  const styleUrls = [...new Set([
58
67
  ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
59
68
  ...configuredStyles
@@ -64,6 +73,7 @@ export async function build({ quiet = false, minify = true } = {}) {
64
73
  const compiledFile = compiledPath(pageFile)
65
74
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
66
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))
67
77
 
68
78
  const runtimeSchema = runtimeRouteSchema(module, pageFile)
69
79
  if (runtimeSchema) {
@@ -80,11 +90,20 @@ export async function build({ quiet = false, minify = true } = {}) {
80
90
  const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
81
91
  for (const { params, props } of entries) {
82
92
  const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
93
+ const applicationRoute = `/${route}`
83
94
  const routePath = withBase(base, `/${route}`)
95
+ const navigable = navigationSet.has(applicationRoute)
84
96
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
85
97
  const paramPath = `params/${route}/index.js`
86
98
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
87
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
+ }
88
107
  const result = await renderPage(module.default, {
89
108
  ...(module.metadata ?? {}),
90
109
  styles: styleUrls.length ? styleUrls : false,
@@ -92,14 +111,15 @@ export async function build({ quiet = false, minify = true } = {}) {
92
111
  runtimeAsset: runtimePlaceholder,
93
112
  effectAsset: assetPath(base, `assets/${effectPath}`),
94
113
  paramAsset: assetPath(base, `assets/${paramPath}`),
95
- runtimeParams: runtimeSchema?.params
96
- }, props)
114
+ runtimeParams: runtimeSchema?.params,
115
+ ...(navigable ? { navigationAsset, applicationId, layoutId } : {})
116
+ }, props, module.layout)
97
117
  const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
98
- const usesDependencyRuntime = hasDependencies && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
118
+ const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
99
119
  pageEntries.push({ route, html: result.html, usesDependencyRuntime })
100
120
  plans.push({ route: routePath, ...result.plan })
101
121
  if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
102
- if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime })
122
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
103
123
  if (result.hasBehaviors) {
104
124
  behaviorCount++
105
125
  if (!usesDependencyRuntime) regularBehaviorCount++
@@ -114,6 +134,8 @@ export async function build({ quiet = false, minify = true } = {}) {
114
134
  }
115
135
  }
116
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
+
117
139
  const assetsDirectory = join(outputDirectory, "assets")
118
140
  await mkdir(assetsDirectory, { recursive: true })
119
141
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
@@ -126,6 +148,7 @@ export async function build({ quiet = false, minify = true } = {}) {
126
148
  const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
127
149
  const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
128
150
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
151
+ const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
129
152
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
130
153
  const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
131
154
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
@@ -134,7 +157,8 @@ export async function build({ quiet = false, minify = true } = {}) {
134
157
  const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
135
158
  const hasNativeHandlers = nativeModules.length > 0
136
159
  const hasEffects = effectEntries.length > 0
137
- const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers
160
+ const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
161
+ const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
138
162
  const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
139
163
  const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
140
164
  for (const entry of pageEntries) {
@@ -143,9 +167,10 @@ export async function build({ quiet = false, minify = true } = {}) {
143
167
  const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
144
168
  await writeFile(join(routeDirectory, "index.html"), html)
145
169
  }
146
- if (behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
170
+ if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
147
171
  const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
148
- const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
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}")
149
174
  await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
150
175
  }
151
176
  if (hasDependencyRuntime) {
@@ -166,10 +191,11 @@ export async function build({ quiet = false, minify = true } = {}) {
166
191
  }
167
192
  if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
168
193
  if (bindingCount) {
169
- 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"))
170
195
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
171
196
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
172
197
  .replace('"./style.js"', '"./kudzu-style.js"')
198
+ if (navigationRoutes.length) bindingRuntime = specializeNavigationTextDescriptors(bindingRuntime)
173
199
  await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
174
200
  "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
175
201
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
@@ -194,6 +220,7 @@ export async function build({ quiet = false, minify = true } = {}) {
194
220
  __KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
195
221
  __KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
196
222
  __KUDZU_LIST_SEEDS__: String(hasListSeeds),
223
+ __KUDZU_LIST_EFFECTS__: String(hasListEffects),
197
224
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
198
225
  __KUDZU_LIST_MOUNTS__: String(hasListMounts)
199
226
  })
@@ -206,6 +233,14 @@ export async function build({ quiet = false, minify = true } = {}) {
206
233
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
207
234
  })
208
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
+ }
209
244
  for (const handlerModule of handlerModules) {
210
245
  const output = join(assetsDirectory, handlerModule.path)
211
246
  await mkdir(resolve(output, ".."), { recursive: true })
@@ -219,7 +254,9 @@ export async function build({ quiet = false, minify = true } = {}) {
219
254
  for (const entry of effectEntries) {
220
255
  const output = join(assetsDirectory, entry.path)
221
256
  await mkdir(dirname(output), { recursive: true })
222
- await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), 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)
223
260
  }
224
261
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
225
262
  for (const file of clientModules) {
@@ -265,6 +302,43 @@ function specializeEvents(source, events) {
265
302
  return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
266
303
  }
267
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
+
268
342
  function specializeNativeRuntime(source, events, modules) {
269
343
  const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
270
344
  const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
@@ -274,6 +348,7 @@ function specializeNativeRuntime(source, events, modules) {
274
348
  function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
275
349
  const hasCleanup = effects.some(effect => effect.cleanup)
276
350
  const hasDependencies = effects.some(effect => effect.dependencies?.length)
351
+ const hasOwners = effects.some(effect => effect.owner)
277
352
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
278
353
  const modules = moduleUrls.map(url => {
279
354
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -281,7 +356,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
281
356
  return module
282
357
  })
283
358
  const imports = [
284
- hasCleanup || hasDependencies
359
+ hasCleanup || hasDependencies || hasOwners
285
360
  ? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
286
361
  : `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
287
362
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
@@ -289,6 +364,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
289
364
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
290
365
  ]
291
366
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
367
+ if (hasOwners) return printOwnedEffectEntry(imports, effects, entries)
292
368
  if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
293
369
  const disposal = hasCleanup ? `
294
370
  let disposed = false
@@ -299,6 +375,7 @@ const dispose = root => {
299
375
  pending.clear()
300
376
  for (const record of records) invokeCleanup(record)
301
377
  }
378
+
302
379
  if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
303
380
  addEventListener("pagehide", event => {
304
381
  if (event.persisted) return
@@ -438,6 +515,325 @@ addEventListener("pagehide", event => {
438
515
  })`
439
516
  }
440
517
 
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
+
441
837
  function printSingleDependencyEffect(imports, effect, hasCleanup) {
442
838
  const disposal = hasCleanup ? `
443
839
  const dispose = root => {
@@ -875,10 +1271,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
875
1271
  const listValues = new WeakMap()
876
1272
  const listEventItems = new WeakMap()
877
1273
  const listConditions = new WeakMap()
1274
+ const listEffectEntries = new WeakMap()
878
1275
  let usesBehavior = false
879
1276
  let usesBinding = false
880
1277
  let usesConditional = false
881
1278
  let usesList = false
1279
+ let usesListEffects = false
882
1280
 
883
1281
  const collect = node => {
884
1282
  if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -983,6 +1381,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
983
1381
  }))
984
1382
  const componentSpecializations = new WeakMap()
985
1383
  const specializedDeclarations = new WeakSet()
1384
+ const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
986
1385
  for (const name of listComponentNames) {
987
1386
  let component = components.get(name)
988
1387
  const local = Boolean(component)
@@ -995,7 +1394,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
995
1394
  if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
996
1395
  const calls = jsxTagUses(sourceFile, name)
997
1396
  if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
998
- 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
+ }
999
1402
  if (local) specializedDeclarations.add(component.declaration)
1000
1403
  }
1001
1404
  const renderedLists = new WeakMap()
@@ -1003,7 +1406,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1003
1406
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
1004
1407
  const specialization = componentSpecializations.get(originalParts.root)
1005
1408
  const root = specialization?.root ?? originalParts.root
1006
- const callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
1409
+ let callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
1007
1410
  originalParts.callback,
1008
1411
  originalParts.callback.modifiers,
1009
1412
  originalParts.callback.typeParameters,
@@ -1023,6 +1426,20 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1023
1426
  validateListExpression(calculation, parts.item, originalParts.root, fail)
1024
1427
  }
1025
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
+ }
1026
1443
  renderedLists.set(node, parts)
1027
1444
  }
1028
1445
 
@@ -1066,25 +1483,33 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1066
1483
  return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
1067
1484
  }
1068
1485
 
1069
- if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
1070
- if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal 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")
1071
1493
  const [callback, dependencies] = node.arguments
1072
- if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
1073
- if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
1074
- if (callback.asteriskToken) fail(callback, "useEffect() callback cannot be a generator")
1075
- if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
1076
- if (!ts.isArrayLiteralExpression(dependencies)) fail(dependencies, "useEffect() dependencies must be a literal 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
+ }
1077
1502
  const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
1078
- if (invalidDependency) fail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
1503
+ if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
1079
1504
  if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
1080
- 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")
1081
1506
  const returns = effectReturns(callback)
1082
- 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")
1083
1508
  const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
1084
- if (invalidCleanup) fail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1085
- 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")
1086
1511
  const setters = settersForNode(node, settersByFunction)
1087
- 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)
1088
1513
  usesBehavior = true
1089
1514
  return factory.updateCallExpression(node, node.expression, node.typeArguments, [
1090
1515
  callback,
@@ -1093,7 +1518,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1093
1518
  factory.createStringLiteral(descriptor.exportName),
1094
1519
  descriptor.states,
1095
1520
  descriptor.scope,
1096
- factory.createStringLiteral(sourceLocation(node, sourceFile)),
1521
+ factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
1097
1522
  returns.cleanup ? factory.createTrue() : factory.createFalse()
1098
1523
  ])
1099
1524
  }
@@ -1207,6 +1632,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1207
1632
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
1208
1633
  behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
1209
1634
  }
1635
+ if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
1210
1636
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
1211
1637
  const behaviorImport = factory.createImportDeclaration(
1212
1638
  undefined,
@@ -1429,6 +1855,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
1429
1855
 
1430
1856
  let returned
1431
1857
  const calculations = []
1858
+ const effectCalls = []
1432
1859
  if (!ts.isBlock(component.body)) {
1433
1860
  returned = component.body
1434
1861
  } else {
@@ -1436,6 +1863,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
1436
1863
  const last = statements.pop()
1437
1864
  if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, "Keyed list component must end with one JSX return")
1438
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
+ }
1439
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")
1440
1871
  const declaration = statement.declarationList.declarations[0]
1441
1872
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Keyed list component locals must be initialized identifiers")
@@ -1454,7 +1885,8 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
1454
1885
  if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
1455
1886
  ts.setParentRecursive(root, false)
1456
1887
  root.parent = call.parent
1457
- return { root, calculations }
1888
+ const effects = effectCalls.map(source => ({ source, call: substituteClone(source, substitutions, factory, context) }))
1889
+ return { root, calculations, effects }
1458
1890
  }
1459
1891
 
1460
1892
  function substituteClone(root, substitutions, factory, context) {
@@ -1487,6 +1919,16 @@ function cloneAst(root, factory, context) {
1487
1919
  return visit(root)
1488
1920
  }
1489
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
+
1490
1932
  function addJsxAttribute(root, attribute, factory) {
1491
1933
  if (ts.isJsxSelfClosingElement(root)) {
1492
1934
  return factory.updateJsxSelfClosingElement(root, root.tagName, root.typeArguments, factory.updateJsxAttributes(root.attributes, [attribute, ...root.attributes.properties]))
@@ -2142,6 +2584,22 @@ function parseSourceFile(file, source) {
2142
2584
  return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
2143
2585
  }
2144
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
+
2145
2603
  function clientModulePath(file) {
2146
2604
  return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
2147
2605
  }
@@ -2399,6 +2857,30 @@ function normalizeStyles(value, base) {
2399
2857
  })
2400
2858
  }
2401
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
+
2402
2884
  function normalizeBase(value) {
2403
2885
  if (value == null || value === "" || value === "/") return ""
2404
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")