@kudzujs/core 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/GOAL_A.md CHANGED
@@ -8,7 +8,7 @@ The benchmark runner, framework fixtures, generated artifacts, and raw arrays ar
8
8
 
9
9
  ## Implementation Status
10
10
 
11
- **Goal A is complete for one explicitly configured exact-static route group with one shared layout.** Complete standalone documents, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Runtime bracket navigation, multiple layout groups, and conditional/keyed DOM-owned effects inside a navigation group remain deliberate post-Goal-A limits.
11
+ **Goal A is complete for one explicitly configured emitted-route group with one shared layout.** Complete standalone documents, exact and runtime-parameter navigation, native fallback, persistent layout state/effects, disposable route state/effects, optimistic workflows, desktop/mobile performance gates, and the dashboard expansion seam are covered. Multiple independent shared-layout groups were added post-Goal-A; conditional/keyed DOM-owned effects inside a navigation group remain a deliberate limit.
12
12
 
13
13
  - **Phase 1 complete**: a local six-route commerce fixture, locked React/Next/Nuxt/SvelteKit comparisons, and a reproducible artifact/build/Chrome runner validate the implementation.
14
14
  - **Phase 2 complete**: effects inside conditional ranges and supported keyed row components mount with their DOM owner, unsubscribe and clean up on removal, and remount without affecting effect-free output.
@@ -23,6 +23,12 @@ The capability-local prefetch/cache increased the commerce navigation asset from
23
23
 
24
24
  In the focused effect-enabled navigation fixture, mount support adds 171 B gzip to the same-route navigation asset and 37 B gzip to the shared runtime. The active-context guard adds 18 B gzip to `kudzu-effect.js` (257 B total); cache-safe route entries are 1,059-1,116 B gzip. The effect-free commerce specialization remains byte-for-byte unchanged.
25
25
 
26
+ The post-Goal-A runtime-pattern matcher increases the exact-only commerce navigation asset from 2,306 B to 2,461 B gzip. The mixed exact/runtime navigation fixture emits a 3,105 B gzip navigation asset and a 703 B gzip cache-safe parameter initializer.
27
+
28
+ The post-Goal-A multiple-group fixture emits a 7,448 B raw / 3,099 B gzip (`gzip -9`) mixed runtime/effect group asset, including one native exclusion for an overlapping ungrouped exact route, and a separately specialized 5,681 B raw / 2,448 B gzip exact effect-free group asset. These measurements do not revise the historical Goal A benchmark.
29
+
30
+ A current 0.6.2 desktop rerun of the matched six-route commerce fixture measured Kudzu at 462.1 ms build, 35,355 deploy bytes, 7,334 B gzip product JavaScript, 324/152 ms cold/warm LCP, 109.3 ms startup task, 3.9 ms interaction, and 6.1 ms product-cart navigation. React measured 508.5 ms build, 61,464 B gzip product JavaScript, 340/244 ms LCP, 166.2 ms startup task, 10.9 ms interaction, and 8.9 ms navigation. Raw arrays and the consolidated report are retained under the local demo benchmark workspace.
31
+
26
32
  The Phase 6 chart probe's complete initial module graph is 11,902 B raw / 5,331 B gzip. It adds no framework API or package and does not change the commerce benchmark fixture.
27
33
 
28
34
  The app-mode Kudzu fixture emits 34,879 deploy bytes. Its product route loads 15,800 raw / 7,215 gzip bytes of initial JavaScript, including the 2,306 B gzip navigation capability. Routes outside a configured application group retain the static zero-JavaScript and byte-for-byte gates.
@@ -108,7 +114,7 @@ Each phase starts with one failing fixture and ends with correctness, browser, s
108
114
  1. **Benchmark harness**: freeze the commerce journey, network profiles, framework versions, generated artifacts, and measurement scripts before optimizing Kudzu.
109
115
  2. **Owned effects**: complete cleanup for conditional ranges and keyed items using the existing mount and unmount hooks. **Complete.**
110
116
  3. **Layout and route scopes**: retain only declared layout state and dispose route-owned behavior on every completed transition. **Compiler ownership complete; transition behavior belongs to Phase 4.**
111
- 4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for exact static routes and top-level layout/route effects; conditional/keyed DOM-owned effects remain excluded.**
117
+ 4. **Opt-in navigation**: support eligible links, history, aborts, stale responses, focus, scroll, metadata, and native fallback. **Complete for emitted exact/runtime-parameter routes and top-level layout/route effects; conditional/keyed DOM-owned effects remain excluded.**
112
118
  5. **Business workflows**: close only fixture-proven gaps in forms, async requests, optimistic updates, and diagnostics. **Complete for the matched cart success/rejection flow.**
113
119
  6. **Expansion probe**: prove that one persistent mock stream and one imperative chart stub can mount, update, navigate, and dispose without adding a component runtime. **Compatibility probe complete; real telemetry and chart engines remain outside Goal A.**
114
120
 
package/README.md CHANGED
@@ -449,17 +449,30 @@ export default function ProductPage() {
449
449
  }
450
450
  ```
451
451
 
452
- Opt exact static routes into same-document navigation:
452
+ Opt emitted exact or runtime-parameter routes into same-document navigation:
453
453
 
454
454
  ```js
455
455
  export default {
456
- navigation: { routes: ["/product", "/cart"] }
456
+ navigation: { routes: ["/product", "/items/[id]"] }
457
457
  }
458
458
  ```
459
459
 
460
- Every configured route must be an exact static route and export the same layout function. The layout DOM, state, and top-level effects persist; route state and top-level effects reset after cleanup on each transition. Eligible ordinary anchors prefetch validated complete documents into a finite memory cache. Direct requests, reloads, JavaScript failures, unsupported links, and routes outside the group retain native document navigation.
460
+ The legacy single-group form remains supported. Applications with multiple shared layouts use mutually exclusive `groups`:
461
461
 
462
- Runtime bracket routes, multiple layout groups, and conditional or keyed effects inside a navigation group are not supported yet.
462
+ ```js
463
+ export default {
464
+ navigation: { groups: [
465
+ { routes: ["/product", "/items/[id]"] },
466
+ { routes: ["/account", "/settings"] }
467
+ ] }
468
+ }
469
+ ```
470
+
471
+ Every configured identity must be a unique emitted exact route or `runtimeParams` bracket pattern. Routes within each group must export the same layout function identity; different groups may export different layouts. Kudzu emits one deterministic, route-set-hashed navigation asset per group containing only that group's records and capabilities. Path domains may overlap within a group, where exact and more-specific matching wins, but overlapping exact/runtime or runtime/runtime domains across groups fail the build.
472
+
473
+ The layout DOM, state, and top-level effects persist within its group; route state, parameters, and top-level effects reset after cleanup on each transition. Eligible same-group anchors prefetch validated complete documents into a finite memory cache. Cross-group links, ungrouped routes, direct requests, reloads, malformed runtime paths, JavaScript failures, and unsupported links retain native document navigation.
474
+
475
+ Conditional or keyed effects inside a navigation group are not supported yet.
463
476
 
464
477
  This produces fast same-document route changes, but it does not add a coordinated transition animation. CSS entry animations can style newly inserted route content; exit and shared-element View Transitions are not integrated yet.
465
478
 
@@ -498,7 +511,7 @@ Supported:
498
511
  - Top-level and block-scoped JSX locals, terminal early returns, and exhaustive JSX assignment
499
512
  - Direct keyed local-state lists
500
513
  - Page-exported shared layouts with layout/route state lifetimes
501
- - Opt-in exact-route navigation with complete-document prefetch and native fallback
514
+ - Opt-in exact/runtime-route navigation with complete-document prefetch and native fallback
502
515
  - Layout- and route-lifetime effect mounts in navigation groups
503
516
 
504
517
  Not implemented yet:
@@ -519,22 +532,22 @@ Browser medians use seven rotating fresh Chrome profiles per target with 4x CPU
519
532
 
520
533
  | Target | Product JS gzip | Cold transfer | Cold LCP | Warm LCP | Startup task | Heap | Interaction | Product → cart |
521
534
  |---|---:|---:|---:|---:|---:|---:|---:|---:|
522
- | Kudzu | **7,215 B** | **34,809 B** | 324 ms | **140 ms** | **103.3 ms** | **648,844 B** | **3.7 ms** | **5.7 ms** |
523
- | React + Vite | 61,464 B | 202,842 B | 324 ms | 232 ms | 160.0 ms | 1,062,520 B | 10.5 ms | 8.3 ms |
524
- | Next.js | 190,090 B | 546,581 B | **316 ms** | 156 ms | 357.7 ms | 2,157,020 B | 11.4 ms | 26.9 ms |
525
- | Nuxt | 67,620 B | 195,953 B | 320 ms | 204 ms | 205.1 ms | 1,721,348 B | 3.9 ms | 33.0 ms |
526
- | SvelteKit | 32,473 B | 90,929 B | 340 ms | 172 ms | 125.8 ms | 999,496 B | 4.7 ms | 20.3 ms |
535
+ | Kudzu | **7,334 B** | **35,260 B** | 324 ms | **152 ms** | **109.3 ms** | **650,708 B** | **3.9 ms** | **6.1 ms** |
536
+ | React + Vite | 61,464 B | 202,842 B | 340 ms | 244 ms | 166.2 ms | 1,062,512 B | 10.9 ms | 8.9 ms |
537
+ | Next.js | 190,090 B | 546,581 B | **320 ms** | 168 ms | 413.0 ms | 2,158,160 B | 14.3 ms | 29.8 ms |
538
+ | Nuxt | 67,620 B | 195,953 B | 328 ms | 216 ms | 243.8 ms | 1,721,348 B | 4.3 ms | 28.4 ms |
539
+ | SvelteKit | 32,475 B | 90,934 B | 352 ms | 176 ms | 150.5 ms | 999,496 B | 5.2 ms | 24.0 ms |
527
540
 
528
- The Kudzu application emits 34,879 deploy bytes. Its 7,215 B gzip product graph includes the 2,306 B navigation capability. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch reduced the measured median to 5.7 ms while preserving complete documents and native fallback.
541
+ The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 6.1 ms in the current run while preserving complete documents and native fallback.
529
542
 
530
- The mobile profile uses a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
543
+ The mobile row is retained from the previous matched run using a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
531
544
 
532
545
  | Profile | Cold LCP | Warm LCP | Interaction | Product → cart | Reject feedback | Rollback/error | CLS |
533
546
  |---|---:|---:|---:|---:|---:|---:|---:|
534
- | Desktop | 324 ms | 140 ms | 3.7 ms | 5.7 ms | 3.3 ms | 110.8 ms | 0 |
547
+ | Desktop | 324 ms | 152 ms | 3.9 ms | 6.1 ms | 2.6 ms | 111.7 ms | 0 |
535
548
  | Mobile | 420 ms | 220 ms | 5.6 ms | 8.7 ms | 4.1 ms | 158 ms | 0 |
536
549
 
537
- Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. An empty-cache build measured 541.4 ms against the previous 547.6 ms path; after one warm-up, 31 interleaved artifact-clean builds measured Kudzu at 425.8 ms and React at 483.4 ms, making Kudzu 11.9% faster in that run. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
550
+ Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at 462.1 ms and React at 508.5 ms, making Kudzu 9.1% faster in that run. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
538
551
 
539
552
  These results describe this six-route fixture on one machine, not framework ecosystem size or every rendering mode. Prefetch improves an eligible warm application transition; it does not hide cold transfer, and direct loads remain complete standalone documents.
540
553
 
@@ -11,7 +11,7 @@
11
11
  - `serialization.js`: capture deserialization shared by binding and native handlers.
12
12
  - `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
13
13
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
14
- - `navigation-runtime.js`: optional exact-route complete-document prefetch, validation, finite memory caching, route-range replacement, history, focus, and native fallback for one configured shared layout.
14
+ - `navigation-runtime.js`: optional emitted-route complete-document prefetch, runtime segment matching, validation, finite memory caching, route-range replacement, history, focus, and native fallback, specialized once per configured shared-layout group.
15
15
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
16
16
  - `*.d.ts`: public TypeScript and JSX declarations.
17
17
 
@@ -19,6 +19,6 @@ Static routes receive no browser runtime. Command routes receive `runtime.js`; d
19
19
 
20
20
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
21
21
 
22
- `kudzu.config` may opt one exact static shared-layout group into same-document navigation with `navigation: { routes: ["/product", "/cart"] }`. The routes must be unique emitted paths and export the same layout function identity. Route effect entries export cache-safe layout and route mount functions: layout effects mount once per document session, route effects remount after each route insertion, and non-persisted page disposal cleans route before layout. Primitive dependencies and cleanup are supported; conditional/keyed DOM-owned effects fail with a source-located diagnostic in navigation groups. Eligible same-origin anchors prefetch complete documents when near the viewport, hovered, or focused; validated documents are cached only in memory by full URL while represented by the current route. Runtime bracket enhancement, multiple groups, and fragment payloads are not implemented.
22
+ `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects mount once per group session, route effects remount after each route insertion, and non-persisted page disposal cleans route before layout. Primitive dependencies and cleanup are supported; conditional/keyed DOM-owned effects fail with a source-located diagnostic in navigation groups. Fragment payloads and coordinated View Transitions are not implemented.
23
23
 
24
- The matched Goal A commerce profile emits 34,879 deploy bytes and loads 7,215 B gzip of product-route JavaScript, including 2,306 B for navigation. Validated prefetch reduced product-to-cart navigation from 128.7 ms to 5.7 ms. The CLI enables Node's native module compile cache before lazily importing the compiler: 31 interleaved artifact-clean builds after one warm-up measured Kudzu at 425.8 ms and React at 483.4 ms. The first empty-cache invocation remains unchanged within noise, and cache-disabled output is byte-for-byte identical.
24
+ The current matched commerce profile emits 35,355 deploy bytes and loads 7,334 B gzip of product-route JavaScript, including 2,425 B for navigation. Validated prefetch reduced the original 128.7 ms product-to-cart navigation to 6.1 ms in the current run. Seven interleaved artifact-clean builds after warm-up measured Kudzu at 462.1 ms and React at 508.5 ms. Cache-disabled output is byte-for-byte identical.
@@ -20,14 +20,18 @@ 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
+ const navigationGroups = normalizeNavigation(config.navigation)
24
+ const navigationRoutes = navigationGroups.flatMap(group => group.routes)
25
+ const navigationByRoute = new Map(navigationGroups.flatMap(group => group.routes.map(route => [route, group])))
26
+ for (const group of navigationGroups) {
27
+ group.assetPath = assetPath(base, `assets/${group.assetName}`)
28
+ group.applicationId = `a-${group.id}`
29
+ group.layoutId = `l-${group.id}`
30
+ group.records = []
31
+ group.routeRecords = []
32
+ group.hasEffects = false
33
+ group.hasParams = false
34
+ }
31
35
  await rm(workDirectory, { recursive: true, force: true })
32
36
  await rm(outputDirectory, { recursive: true, force: true })
33
37
  await mkdir(workDirectory, { recursive: true })
@@ -63,6 +67,7 @@ export async function build({ quiet = false, minify = true } = {}) {
63
67
  const rewrites = []
64
68
  const emittedRoutes = new Set()
65
69
  const emittedApplicationRoutes = new Set()
70
+ const emittedNavigationRecords = []
66
71
  const styleUrls = [...new Set([
67
72
  ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
68
73
  ...configuredStyles
@@ -92,17 +97,25 @@ export async function build({ quiet = false, minify = true } = {}) {
92
97
  const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
93
98
  const applicationRoute = `/${route}`
94
99
  const routePath = withBase(base, `/${route}`)
95
- const navigable = navigationSet.has(applicationRoute)
100
+ const navigationGroup = navigationByRoute.get(applicationRoute)
101
+ const navigable = Boolean(navigationGroup)
96
102
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
97
103
  const paramPath = `params/${route}/index.js`
98
104
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
99
105
  emittedRoutes.add(routePath)
100
106
  emittedApplicationRoutes.add(applicationRoute)
107
+ const navigationRecord = runtimeSchema
108
+ ? { id: applicationRoute, base: browserPath(base), segments: runtimeSchema.segments.map(segment => segment.literal ?? null) }
109
+ : { id: applicationRoute, path: routePath }
110
+ const routeRecord = { route: applicationRoute, segments: runtimeSchema ? navigationRecord.segments : exactRouteSegments(applicationRoute), record: navigationRecord, group: navigationGroup }
111
+ emittedNavigationRecords.push(routeRecord)
101
112
  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
113
+ if (typeof module.layout !== "function") throw new Error(`${navigationGroup.label} emitted route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
114
+ if (navigationGroup.layoutIdentity && navigationGroup.layoutIdentity !== module.layout) throw new Error(`${navigationGroup.label} routes ${JSON.stringify(navigationGroup.layoutRoute)} and ${JSON.stringify(applicationRoute)} must export the same layout function identity`)
115
+ navigationGroup.layoutIdentity = module.layout
116
+ navigationGroup.layoutRoute ??= applicationRoute
117
+ navigationGroup.records.push(navigationRecord)
118
+ navigationGroup.routeRecords.push(routeRecord)
106
119
  }
107
120
  const result = await renderPage(module.default, {
108
121
  ...(module.metadata ?? {}),
@@ -112,13 +125,17 @@ export async function build({ quiet = false, minify = true } = {}) {
112
125
  effectAsset: assetPath(base, `assets/${effectPath}`),
113
126
  paramAsset: assetPath(base, `assets/${paramPath}`),
114
127
  runtimeParams: runtimeSchema?.params,
115
- ...(navigable ? { navigationAsset, applicationId, layoutId } : {})
128
+ ...(navigable ? { navigationAsset: navigationGroup.assetPath, applicationId: navigationGroup.applicationId, layoutId: navigationGroup.layoutId, routeId: applicationRoute } : {})
116
129
  }, props, module.layout)
130
+ if (navigationGroup) {
131
+ navigationGroup.hasEffects ||= result.hasEffects
132
+ navigationGroup.hasParams ||= result.hasParams
133
+ }
117
134
  const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
118
135
  const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
119
136
  pageEntries.push({ route, html: result.html, usesDependencyRuntime })
120
137
  plans.push({ route: routePath, ...result.plan })
121
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
138
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime, navigable })
122
139
  if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
123
140
  if (result.hasBehaviors) {
124
141
  behaviorCount++
@@ -134,7 +151,13 @@ export async function build({ quiet = false, minify = true } = {}) {
134
151
  }
135
152
  }
136
153
 
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`)
154
+ for (const group of navigationGroups) for (const route of group.routes) if (!emittedApplicationRoutes.has(route)) throw new Error(`${group.label} route ${JSON.stringify(route)} is not an emitted route`)
155
+ rejectNavigationOverlap(navigationGroups)
156
+ for (const group of navigationGroups) {
157
+ const runtimeRecords = group.routeRecords.filter(record => record.record.segments)
158
+ for (const entry of emittedNavigationRecords) if (!entry.group && runtimeRecords.some(record => navigationDomainsOverlap(record, entry))) group.records.push({ ...entry.record, native: true })
159
+ group.records.sort((left, right) => (right.segments?.filter(segment => segment !== null).length ?? 0) - (left.segments?.filter(segment => segment !== null).length ?? 0) || left.id.localeCompare(right.id))
160
+ }
138
161
 
139
162
  const assetsDirectory = join(outputDirectory, "assets")
140
163
  await mkdir(assetsDirectory, { recursive: true })
@@ -233,13 +256,17 @@ export async function build({ quiet = false, minify = true } = {}) {
233
256
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
234
257
  })
235
258
  }
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)
259
+ if (navigationGroups.length) {
260
+ const navigationSource = await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8")
261
+ for (const group of navigationGroups) {
262
+ let navigationRuntime = navigationSource
263
+ .replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(group.records))
264
+ .replace("__KUDZU_APPLICATION_ID__", JSON.stringify(group.applicationId))
265
+ .replace("__KUDZU_LAYOUT_ID__", JSON.stringify(group.layoutId))
266
+ .replace('"./shared-runtime.js"', '"./kudzu.js"')
267
+ navigationRuntime = specializeNavigationPatterns(navigationRuntime, group.records.some(record => record.segments))
268
+ await writeJavaScript(join(assetsDirectory, group.assetName), specializeNavigationEffects(navigationRuntime, group.hasEffects || group.hasParams), minify)
269
+ }
243
270
  }
244
271
  for (const handlerModule of handlerModules) {
245
272
  const output = join(assetsDirectory, handlerModule.path)
@@ -249,7 +276,7 @@ export async function build({ quiet = false, minify = true } = {}) {
249
276
  for (const entry of paramEntries) {
250
277
  const output = join(assetsDirectory, entry.path)
251
278
  await mkdir(dirname(output), { recursive: true })
252
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
279
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
253
280
  }
254
281
  for (const entry of effectEntries) {
255
282
  const output = join(assetsDirectory, entry.path)
@@ -319,26 +346,42 @@ function specializeNavigationEffects(source, enabled) {
319
346
  .replace(`
320
347
  async function mountInitial() {
321
348
  try {
322
- const effects = await loadCapabilities(validate(document))
323
- layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
324
- routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
349
+ const record = matchRoute(location.pathname)
350
+ if (!record) throw new Error("Initial navigation route does not match")
351
+ const capabilities = await loadCapabilities(validate(document, record))
352
+ capabilities.params?.(location.pathname)
353
+ layoutDispose = await capabilities.effects?.mountLayoutEffects?.() ?? noDispose
354
+ routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
325
355
  } catch (error) {
326
356
  console.error(error)
327
357
  }
328
358
  }
329
359
  `, "")
330
360
  .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")
361
+ .replace(" const capabilities = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
332
362
  .replace(" await routeDispose()\n if (current !== revision) return\n", "")
333
- .replace(" routeDispose = await effects?.mountRouteEffects?.() ?? noDispose\n", "")
363
+ .replace(" commit(incoming, parsed.nodes, capabilities.params, url.pathname)\n", " commit(incoming, parsed.nodes)\n")
364
+ .replace(" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "")
334
365
  .replace(`
335
366
  async function loadCapabilities(parsed) {
336
367
  const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
337
- return modules.find(module => typeof module.mountRouteEffects === "function")
368
+ const params = modules.filter(module => typeof module.initializeParams === "function")
369
+ const effects = modules.filter(module => typeof module.mountRouteEffects === "function")
370
+ if (params.length > 1 || effects.length > 1) throw new Error("Navigation document has duplicate route capabilities")
371
+ return { params: params[0]?.initializeParams, effects: effects[0] }
338
372
  }
339
373
  `, "")
340
374
  }
341
375
 
376
+ function specializeNavigationPatterns(source, enabled) {
377
+ if (enabled) return source
378
+ return source.replace(/function matchRoute\(pathname\) \{[\s\S]+?\n\}\n\nfunction fallback/, `function matchRoute(pathname) {
379
+ return routes.find(record => record.path === pathname)
380
+ }
381
+
382
+ function fallback`)
383
+ }
384
+
342
385
  function specializeNativeRuntime(source, events, modules) {
343
386
  const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
344
387
  const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
@@ -922,12 +965,14 @@ async function invokeCleanup() {
922
965
  }${disposal}`
923
966
  }
924
967
 
925
- function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
968
+ function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName, navigable) {
969
+ const prefix = navigable ? "export function initializeParams(pathname) {\n" : "let pathname = location.pathname\n"
970
+ const suffix = navigable ? "\n}" : ""
926
971
  return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
927
972
  const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
928
973
  const schema = ${inlineJson(schema.segments)}
929
974
  const params = ${inlineJson(params)}
930
- let path = location.pathname
975
+ ${prefix}let path = pathname
931
976
  if (base.length) {
932
977
  const pathSegments = path.slice(1).split("/")
933
978
  if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
@@ -955,7 +1000,7 @@ function decodeSegment(raw, param) {
955
1000
  const decodedDots = value.replace(/%2e/gi, ".")
956
1001
  if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Runtime route parameter is invalid")
957
1002
  return value
958
- }`
1003
+ }${suffix}`
959
1004
  }
960
1005
 
961
1006
  function hasCaptureType(value, type) {
@@ -2860,19 +2905,64 @@ function normalizeStyles(value, base) {
2860
2905
  export function normalizeNavigation(value) {
2861
2906
  if (value === undefined) return []
2862
2907
  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`)
2908
+ if (Object.keys(value).some(key => !["routes", "groups"].includes(key))) throw new Error("kudzu.config navigation only supports routes or groups")
2909
+ if ((value.routes === undefined) === (value.groups === undefined)) throw new Error("kudzu.config navigation must define exactly one of routes or groups")
2910
+ const inputs = value.routes === undefined ? value.groups : [{ routes: value.routes }]
2911
+ if (!Array.isArray(inputs) || !inputs.length) throw new Error("kudzu.config navigation.groups must be a nonempty array")
2912
+ const groups = inputs.map((group, groupIndex) => {
2913
+ const label = value.routes === undefined ? `kudzu.config navigation.groups[${groupIndex}]` : "kudzu.config navigation"
2914
+ if (!isPlainRecord(group)) throw new Error(`${label} must be a plain object`)
2915
+ if (Object.keys(group).some(key => key !== "routes")) throw new Error(`${label} only supports routes`)
2916
+ if (!Array.isArray(group.routes) || !group.routes.length) throw new Error(`${label}.routes must be a nonempty array`)
2917
+ const routes = normalizeNavigationRoutes(group.routes, `${label}.routes`)
2918
+ const id = createHash("sha256").update(JSON.stringify([...routes].sort())).digest("hex").slice(0, 16)
2919
+ return { label, index: groupIndex, routes, routeSet: new Set(routes), id, assetName: value.routes === undefined ? `kudzu-navigation-${id}.js` : "kudzu-navigation.js" }
2920
+ })
2921
+ const identities = groups.flatMap(group => group.routes.map(route => [route, group.label]))
2922
+ const seenRoutes = new Map()
2923
+ for (const [route, label] of identities) {
2924
+ if (seenRoutes.has(route)) throw new Error(`${label} route ${JSON.stringify(route)} duplicates ${seenRoutes.get(route)}`)
2925
+ seenRoutes.set(route, label)
2926
+ }
2927
+ const seenAssets = new Map()
2928
+ for (const group of groups) {
2929
+ if (seenAssets.has(group.assetName)) throw new Error(`${group.label} navigation hash/asset collision with ${seenAssets.get(group.assetName)}`)
2930
+ seenAssets.set(group.assetName, group.label)
2931
+ }
2932
+ return groups
2933
+ }
2934
+
2935
+ function normalizeNavigationRoutes(values, label) {
2936
+ const routes = values.map((route, index) => {
2937
+ if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[?#\\\0]/.test(route) || /%(?:2f|5c)/i.test(route)) throw new Error(`${label}[${index}] must be a root-relative path without query, hash, or traversal`)
2867
2938
  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`)
2939
+ try { decoded = decodeURIComponent(route) } catch { throw new Error(`${label}[${index}] must be a root-relative path without query, hash, or traversal`) }
2940
+ if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(`${label}[${index}] must be a root-relative path without query, hash, or traversal`)
2870
2941
  return route
2871
2942
  })
2872
- if (new Set(routes).size !== routes.length) throw new Error("kudzu.config navigation.routes must contain unique paths")
2943
+ if (new Set(routes).size !== routes.length) throw new Error(`${label} must contain unique paths`)
2873
2944
  return routes
2874
2945
  }
2875
2946
 
2947
+ function exactRouteSegments(route) {
2948
+ return route.slice(1).split("/").map(segment => decodeURIComponent(segment))
2949
+ }
2950
+
2951
+ function rejectNavigationOverlap(groups) {
2952
+ for (let leftIndex = 0; leftIndex < groups.length; leftIndex++) for (let rightIndex = leftIndex + 1; rightIndex < groups.length; rightIndex++) {
2953
+ const leftGroup = groups[leftIndex]
2954
+ const rightGroup = groups[rightIndex]
2955
+ for (const left of leftGroup.routeRecords) for (const right of rightGroup.routeRecords) {
2956
+ if (!navigationDomainsOverlap(left, right)) continue
2957
+ throw new Error(`Navigation path domains overlap between ${leftGroup.label} route ${JSON.stringify(left.route)} and ${rightGroup.label} route ${JSON.stringify(right.route)}`)
2958
+ }
2959
+ }
2960
+ }
2961
+
2962
+ function navigationDomainsOverlap(left, right) {
2963
+ return left.segments.length === right.segments.length && left.segments.every((segment, index) => segment === null || right.segments[index] === null || segment === right.segments[index])
2964
+ }
2965
+
2876
2966
  function specializeNavigationTextDescriptors(source) {
2877
2967
  const dynamic = source
2878
2968
  .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 ?? \"[]\") : []")
@@ -57,6 +57,7 @@ export function renderPage<Props = Record<string, never>>(
57
57
  navigationAsset?: string
58
58
  applicationId?: string
59
59
  layoutId?: string
60
+ routeId?: string
60
61
  },
61
62
  props?: Props,
62
63
  layout?: (props: { children: unknown }) => unknown | Promise<unknown>
@@ -358,7 +358,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
358
358
  : ""
359
359
 
360
360
  return {
361
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}"` : ""}>${body}</body></html>`,
361
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}" data-k-route="${escapeAttribute(metadata.routeId)}"` : ""}>${body}</body></html>`,
362
362
  hasBehaviors: renderContext.hasBehaviors,
363
363
  hasEffects: renderContext.hasEffects,
364
364
  hasParams: renderContext.hasParams,
@@ -1,6 +1,6 @@
1
1
  import { browserState, mountDom, unmountDom } from "./shared-runtime.js"
2
2
 
3
- const routes = new Set(__KUDZU_NAVIGATION_ROUTES__)
3
+ const routes = __KUDZU_NAVIGATION_ROUTES__
4
4
  const applicationId = __KUDZU_APPLICATION_ID__
5
5
  const layoutId = __KUDZU_LAYOUT_ID__
6
6
  const navigationAsset = new URL(import.meta.url).pathname
@@ -46,9 +46,12 @@ discover()
46
46
 
47
47
  async function mountInitial() {
48
48
  try {
49
- const effects = await loadCapabilities(validate(document))
50
- layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
51
- routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
49
+ const record = matchRoute(location.pathname)
50
+ if (!record) throw new Error("Initial navigation route does not match")
51
+ const capabilities = await loadCapabilities(validate(document, record))
52
+ capabilities.params?.(location.pathname)
53
+ layoutDispose = await capabilities.effects?.mountLayoutEffects?.() ?? noDispose
54
+ routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
52
55
  } catch (error) {
53
56
  console.error(error)
54
57
  }
@@ -64,7 +67,7 @@ function eligibleAnchor(anchor) {
64
67
  if (anchor.relList?.contains("external")) return false
65
68
  const url = new URL(anchor.href)
66
69
  if (url.hash && url.pathname === location.pathname && url.search === location.search) return false
67
- return url.origin === location.origin && routes.has(url.pathname)
70
+ return url.origin === location.origin && Boolean(matchRoute(url.pathname))
68
71
  }
69
72
 
70
73
  function discover() {
@@ -94,7 +97,7 @@ function prefetchAnchor(anchor) {
94
97
  const url = new URL(anchor.href)
95
98
  prune([...document.querySelectorAll("a[href]")].filter(eligibleAnchor))
96
99
  if (documents.has(url.href)) return
97
- const pending = fetchDocument(url)
100
+ const pending = fetchDocument(url, matchRoute(url.pathname))
98
101
  documents.set(url.href, pending)
99
102
  pending.catch(() => {
100
103
  if (documents.get(url.href) === pending) documents.delete(url.href)
@@ -108,6 +111,8 @@ function prune(anchors) {
108
111
 
109
112
  async function navigate(url, push) {
110
113
  await ready
114
+ const record = matchRoute(url.pathname)
115
+ if (!record) return fallback(url, push)
111
116
  const current = ++revision
112
117
  request?.abort()
113
118
  request = new AbortController()
@@ -117,17 +122,17 @@ async function navigate(url, push) {
117
122
  const cached = documents.get(url.href)
118
123
  if (cached) {
119
124
  try { documentResult = await cached }
120
- catch { documentResult = await fetchDocument(url, request.signal) }
121
- } else documentResult = await fetchDocument(url, request.signal)
125
+ catch { documentResult = await fetchDocument(url, record, request.signal) }
126
+ } else documentResult = await fetchDocument(url, record, request.signal)
122
127
  documents.set(url.href, Promise.resolve(documentResult))
123
128
  const { incoming, parsed } = documentResult
124
- const effects = await loadCapabilities(parsed)
129
+ const capabilities = await loadCapabilities(parsed)
125
130
  if (current !== revision) return
126
131
  await routeDispose()
127
132
  if (current !== revision) return
128
- commit(incoming, parsed.nodes)
129
- routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
133
+ commit(incoming, parsed.nodes, capabilities.params, url.pathname)
130
134
  committed = true
135
+ routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
131
136
  if (push) history.pushState(null, "", url)
132
137
  updateHead(incoming)
133
138
  focusAndScroll(url)
@@ -135,26 +140,28 @@ async function navigate(url, push) {
135
140
  discover()
136
141
  } catch (error) {
137
142
  if (current !== revision || error.name === "AbortError") return
138
- if (push) location.assign(url.href)
139
- else location.reload()
143
+ fallback(url, push)
140
144
  if (committed) return
141
145
  }
142
146
  }
143
147
 
144
148
  async function loadCapabilities(parsed) {
145
149
  const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
146
- return modules.find(module => typeof module.mountRouteEffects === "function")
150
+ const params = modules.filter(module => typeof module.initializeParams === "function")
151
+ const effects = modules.filter(module => typeof module.mountRouteEffects === "function")
152
+ if (params.length > 1 || effects.length > 1) throw new Error("Navigation document has duplicate route capabilities")
153
+ return { params: params[0]?.initializeParams, effects: effects[0] }
147
154
  }
148
155
 
149
- async function fetchDocument(url, signal) {
156
+ async function fetchDocument(url, record, signal) {
150
157
  const response = await fetch(url, { signal, redirect: "manual", headers: { accept: "text/html" } })
151
158
  if (!response.ok || response.redirected || response.type === "opaqueredirect" || !response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("Navigation response is not successful nonredirected HTML")
152
159
  const incoming = new DOMParser().parseFromString(await response.text(), "text/html")
153
- return { incoming, parsed: validate(incoming) }
160
+ return { incoming, parsed: validate(incoming, record), record }
154
161
  }
155
162
 
156
- function validate(incoming) {
157
- if (incoming.body.dataset.kApplication !== applicationId || incoming.body.dataset.kLayout !== layoutId) throw new Error("Navigation document identity does not match")
163
+ function validate(incoming, record) {
164
+ if (incoming.body.dataset.kApplication !== applicationId || incoming.body.dataset.kLayout !== layoutId || incoming.body.dataset.kRoute !== record.id) throw new Error("Navigation document identity does not match")
158
165
  const starts = incoming.querySelectorAll("template[data-k-route-start]")
159
166
  const ends = incoming.querySelectorAll("template[data-k-route-end]")
160
167
  if (starts.length !== 1 || ends.length !== 1) throw new Error("Navigation document must contain exactly one route marker pair")
@@ -168,7 +175,7 @@ function validate(incoming) {
168
175
  return { nodes, assets: [...new Set(assets)] }
169
176
  }
170
177
 
171
- function commit(incoming, incomingNodes) {
178
+ function commit(incoming, incomingNodes, initializeParams, pathname) {
172
179
  const start = document.querySelector("template[data-k-route-start]")
173
180
  const end = document.querySelector("template[data-k-route-end]")
174
181
  if (!start || !end || document.querySelectorAll("template[data-k-route-start],template[data-k-route-end]").length !== 2) throw new Error("Current route markers are invalid")
@@ -179,11 +186,50 @@ function commit(incoming, incomingNodes) {
179
186
  for (const [id, value, compact] of JSON.parse(incoming.body.dataset.kState ?? "[]")) if (id.startsWith("r")) browserState.set(id, compact ? value[1].map(row => Object.fromEntries(value[0].map((field, index) => [field, row[index]]))) : value)
180
187
  if (incoming.body.dataset.kTextBindings === undefined) delete document.body.dataset.kTextBindings
181
188
  else document.body.dataset.kTextBindings = incoming.body.dataset.kTextBindings
189
+ document.body.dataset.kRoute = incoming.body.dataset.kRoute
182
190
  const nodes = incomingNodes.map(node => document.importNode(node, true))
183
191
  end.before(...nodes)
192
+ initializeParams?.(pathname)
184
193
  for (const node of nodes) mountDom(node)
185
194
  }
186
195
 
196
+ function matchRoute(pathname) {
197
+ const exact = routes.find(record => record.path === pathname)
198
+ if (exact) return exact.native ? undefined : exact
199
+ for (const record of routes) {
200
+ if (!record.segments) continue
201
+ try {
202
+ let path = pathname
203
+ if (record.base) {
204
+ const pathSegments = path.slice(1).split("/")
205
+ const baseSegments = record.base.slice(1).split("/").map(segment => decodeURIComponent(segment))
206
+ if (pathSegments.length < baseSegments.length || baseSegments.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) continue
207
+ path = `/${pathSegments.slice(baseSegments.length).join("/")}`
208
+ }
209
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
210
+ const segments = path.slice(1).split("/")
211
+ if (segments.length !== record.segments.length) continue
212
+ if (record.segments.every((literal, index) => {
213
+ const value = decodeSegment(segments[index], literal === null)
214
+ return literal === null || value === literal
215
+ })) return record.native ? undefined : record
216
+ } catch {}
217
+ }
218
+ }
219
+
220
+ function decodeSegment(raw, param) {
221
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Encoded separator")
222
+ const value = decodeURIComponent(raw)
223
+ const decodedDots = value.replace(/%2e/gi, ".")
224
+ if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Invalid runtime parameter")
225
+ return value
226
+ }
227
+
228
+ function fallback(url, push) {
229
+ if (push) location.assign(url.href)
230
+ else location.reload()
231
+ }
232
+
187
233
  function between(start, end) {
188
234
  if (start.parentNode !== end.parentNode) throw new Error("Route markers must share a parent")
189
235
  const nodes = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",