@kudzujs/core 0.6.0 → 0.6.2

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 layout groups and conditional/keyed DOM-owned effects inside a navigation group remain deliberate post-Goal-A limits.
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,8 @@ 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
+
26
28
  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
29
 
28
30
  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.
@@ -39,7 +41,7 @@ The latest matched run used seven rotating production builds and seven rotating
39
41
 
40
42
  The navigation capability initially measured 128.7 ms because it paid one HTML round trip. Validated near-viewport document prefetch reduced the final seven-run median to 5.7 ms while Kudzu still transferred 62% less than SvelteKit and 83% less than React on the cold product load. A progressive-HTML grid shift found by the benchmark was fixed in the matched CSS; 30 focused Kudzu profiles and the final desktop/mobile cross-framework runs then recorded zero cold and warm CLS.
41
43
 
42
- The final seven-run build table measured 460.8 ms for Kudzu and 429.5 ms for React. A focused 21-run interleaved follow-up measured 578.6 ms and 542.5 ms under different machine load, leaving a repeatable 6-7% Kudzu loss. CPU profiling attributes the small-project difference primarily to TypeScript ESM/compiler startup. Replacing that parser or hiding startup work is outside Goal A; this is an explicit build-time tradeoff, while deploy bytes and every measured browser CPU, memory, and navigation metric remain lower than React.
44
+ The original final table measured 460.8 ms for Kudzu and 429.5 ms for React, and a focused follow-up confirmed a 6-7% loss primarily from TypeScript ESM/compiler startup. A post-gate startup fix now enables Node's native module compile cache before lazily loading the compiler. The first 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. Cache-disabled and cached builds emit byte-for-byte identical output.
43
45
 
44
46
  The mobile profile uses a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s download. Kudzu measured 420 ms cold LCP, 220 ms warm LCP, 5.6 ms interaction, 8.7 ms product-cart navigation, 4.1 ms optimistic rejection, and 158 ms rollback/error.
45
47
 
@@ -108,7 +110,7 @@ Each phase starts with one failing fixture and ends with correctness, browser, s
108
110
  1. **Benchmark harness**: freeze the commerce journey, network profiles, framework versions, generated artifacts, and measurement scripts before optimizing Kudzu.
109
111
  2. **Owned effects**: complete cleanup for conditional ranges and keyed items using the existing mount and unmount hooks. **Complete.**
110
112
  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.**
113
+ 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
114
  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
115
  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
116
 
package/README.md CHANGED
@@ -449,17 +449,17 @@ 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
+ Every configured identity must be an emitted exact route or `runtimeParams` bracket pattern and export the same layout function. The layout DOM, state, and top-level effects persist; route state, parameters, 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, malformed runtime paths, JavaScript failures, unsupported links, and routes outside the group retain native document navigation.
461
461
 
462
- Runtime bracket routes, multiple layout groups, and conditional or keyed effects inside a navigation group are not supported yet.
462
+ Multiple layout groups and conditional or keyed effects inside a navigation group are not supported yet.
463
463
 
464
464
  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
465
 
@@ -498,7 +498,7 @@ Supported:
498
498
  - Top-level and block-scoped JSX locals, terminal early returns, and exhaustive JSX assignment
499
499
  - Direct keyed local-state lists
500
500
  - Page-exported shared layouts with layout/route state lifetimes
501
- - Opt-in exact-route navigation with complete-document prefetch and native fallback
501
+ - Opt-in exact/runtime-route navigation with complete-document prefetch and native fallback
502
502
  - Layout- and route-lifetime effect mounts in navigation groups
503
503
 
504
504
  Not implemented yet:
@@ -534,7 +534,7 @@ The mobile profile uses a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and
534
534
  | Desktop | 324 ms | 140 ms | 3.7 ms | 5.7 ms | 3.3 ms | 110.8 ms | 0 |
535
535
  | Mobile | 420 ms | 220 ms | 5.6 ms | 8.7 ms | 4.1 ms | 158 ms | 0 |
536
536
 
537
- Small clean builds are the known tradeoff. Seven rotating production builds measured Kudzu at 460.8 ms and React at 429.5 ms; a separate 21-run interleaved check measured 578.6 ms and 542.5 ms. The repeatable 6–7% difference is primarily TypeScript ESM/compiler startup. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
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.
538
538
 
539
539
  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
540
 
package/bin/kudzu.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { build, dev } from "../framework/build.mjs"
3
+ import * as module from "node:module"
4
4
 
5
5
  const command = process.argv[2] ?? "dev"
6
6
 
7
- if (command === "build") {
8
- await build()
9
- } else if (command === "dev") {
10
- await dev()
7
+ if (command === "build" || command === "dev") {
8
+ module.enableCompileCache?.()
9
+ const { build, dev } = await import("../framework/build.mjs")
10
+ await (command === "build" ? build : dev)()
11
11
  } else {
12
12
  console.error(`Unknown command: ${command}\nUse: kudzu <build|dev>`)
13
13
  process.exitCode = 1
@@ -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 for one configured shared layout.
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 `navigation: { routes: ["/product", "/items/[id]"] }`. The configured identities must be unique emitted exact paths or `runtimeParams` patterns and export the same 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. 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. Multiple groups and fragment payloads 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 known tradeoff is a repeatable 6-7% clean-build loss against the matched small React build, primarily from TypeScript startup; failed handler-lowering and shared-Program experiments were not retained.
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.
@@ -22,7 +22,6 @@ export async function build({ quiet = false, minify = true } = {}) {
22
22
  const configuredStyles = normalizeStyles(config.styles, base)
23
23
  const navigationRoutes = normalizeNavigation(config.navigation)
24
24
  const navigationSet = new Set(navigationRoutes)
25
- const browserNavigationRoutes = navigationRoutes.map(route => withBase(base, route))
26
25
  const navigationAsset = assetPath(base, "assets/kudzu-navigation.js")
27
26
  const navigationId = navigationRoutes.length ? createHash("sha256").update(JSON.stringify([...navigationRoutes].sort())).digest("hex").slice(0, 16) : undefined
28
27
  const applicationId = navigationId ? `a-${navigationId}` : undefined
@@ -63,6 +62,7 @@ export async function build({ quiet = false, minify = true } = {}) {
63
62
  const rewrites = []
64
63
  const emittedRoutes = new Set()
65
64
  const emittedApplicationRoutes = new Set()
65
+ const navigationRecords = []
66
66
  const styleUrls = [...new Set([
67
67
  ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
68
68
  ...configuredStyles
@@ -99,10 +99,12 @@ export async function build({ quiet = false, minify = true } = {}) {
99
99
  emittedRoutes.add(routePath)
100
100
  emittedApplicationRoutes.add(applicationRoute)
101
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`)
102
+ if (typeof module.layout !== "function") throw new Error(`kudzu.config navigation emitted route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
104
103
  if (navigationLayout && navigationLayout !== module.layout) throw new Error("kudzu.config navigation routes must export the same layout function identity")
105
104
  navigationLayout = module.layout
105
+ navigationRecords.push(runtimeSchema
106
+ ? { id: applicationRoute, base: browserPath(base), segments: runtimeSchema.segments.map(segment => segment.literal ?? null) }
107
+ : { id: applicationRoute, path: routePath })
106
108
  }
107
109
  const result = await renderPage(module.default, {
108
110
  ...(module.metadata ?? {}),
@@ -112,13 +114,13 @@ export async function build({ quiet = false, minify = true } = {}) {
112
114
  effectAsset: assetPath(base, `assets/${effectPath}`),
113
115
  paramAsset: assetPath(base, `assets/${paramPath}`),
114
116
  runtimeParams: runtimeSchema?.params,
115
- ...(navigable ? { navigationAsset, applicationId, layoutId } : {})
117
+ ...(navigable ? { navigationAsset, applicationId, layoutId, routeId: applicationRoute } : {})
116
118
  }, props, module.layout)
117
119
  const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
118
120
  const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
119
121
  pageEntries.push({ route, html: result.html, usesDependencyRuntime })
120
122
  plans.push({ route: routePath, ...result.plan })
121
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
123
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime, navigable })
122
124
  if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
123
125
  if (result.hasBehaviors) {
124
126
  behaviorCount++
@@ -134,7 +136,8 @@ export async function build({ quiet = false, minify = true } = {}) {
134
136
  }
135
137
  }
136
138
 
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`)
139
+ for (const route of navigationRoutes) if (!emittedApplicationRoutes.has(route)) throw new Error(`kudzu.config navigation route ${JSON.stringify(route)} is not an emitted route`)
140
+ navigationRecords.sort((left, right) => (right.segments?.filter(segment => segment !== null).length ?? 0) - (left.segments?.filter(segment => segment !== null).length ?? 0) || left.id.localeCompare(right.id))
138
141
 
139
142
  const assetsDirectory = join(outputDirectory, "assets")
140
143
  await mkdir(assetsDirectory, { recursive: true })
@@ -158,6 +161,7 @@ export async function build({ quiet = false, minify = true } = {}) {
158
161
  const hasNativeHandlers = nativeModules.length > 0
159
162
  const hasEffects = effectEntries.length > 0
160
163
  const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
164
+ const hasNavigableParams = paramEntries.some(entry => entry.navigable)
161
165
  const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
162
166
  const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
163
167
  const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
@@ -234,12 +238,13 @@ export async function build({ quiet = false, minify = true } = {}) {
234
238
  })
235
239
  }
236
240
  if (navigationRoutes.length) {
237
- const navigationRuntime = (await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8"))
238
- .replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(browserNavigationRoutes))
241
+ let navigationRuntime = (await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8"))
242
+ .replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(navigationRecords))
239
243
  .replace("__KUDZU_APPLICATION_ID__", JSON.stringify(applicationId))
240
244
  .replace("__KUDZU_LAYOUT_ID__", JSON.stringify(layoutId))
241
245
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
242
- await writeJavaScript(join(assetsDirectory, "kudzu-navigation.js"), specializeNavigationEffects(navigationRuntime, hasNavigableEffects), minify)
246
+ navigationRuntime = specializeNavigationPatterns(navigationRuntime, navigationRecords.some(record => record.segments))
247
+ await writeJavaScript(join(assetsDirectory, "kudzu-navigation.js"), specializeNavigationEffects(navigationRuntime, hasNavigableEffects || hasNavigableParams), minify)
243
248
  }
244
249
  for (const handlerModule of handlerModules) {
245
250
  const output = join(assetsDirectory, handlerModule.path)
@@ -249,7 +254,7 @@ export async function build({ quiet = false, minify = true } = {}) {
249
254
  for (const entry of paramEntries) {
250
255
  const output = join(assetsDirectory, entry.path)
251
256
  await mkdir(dirname(output), { recursive: true })
252
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
257
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
253
258
  }
254
259
  for (const entry of effectEntries) {
255
260
  const output = join(assetsDirectory, entry.path)
@@ -319,26 +324,42 @@ function specializeNavigationEffects(source, enabled) {
319
324
  .replace(`
320
325
  async function mountInitial() {
321
326
  try {
322
- const effects = await loadCapabilities(validate(document))
323
- layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
324
- routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
327
+ const record = matchRoute(location.pathname)
328
+ if (!record) throw new Error("Initial navigation route does not match")
329
+ const capabilities = await loadCapabilities(validate(document, record))
330
+ capabilities.params?.(location.pathname)
331
+ layoutDispose = await capabilities.effects?.mountLayoutEffects?.() ?? noDispose
332
+ routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
325
333
  } catch (error) {
326
334
  console.error(error)
327
335
  }
328
336
  }
329
337
  `, "")
330
338
  .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")
339
+ .replace(" const capabilities = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
332
340
  .replace(" await routeDispose()\n if (current !== revision) return\n", "")
333
- .replace(" routeDispose = await effects?.mountRouteEffects?.() ?? noDispose\n", "")
341
+ .replace(" commit(incoming, parsed.nodes, capabilities.params, url.pathname)\n", " commit(incoming, parsed.nodes)\n")
342
+ .replace(" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "")
334
343
  .replace(`
335
344
  async function loadCapabilities(parsed) {
336
345
  const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
337
- return modules.find(module => typeof module.mountRouteEffects === "function")
346
+ const params = modules.filter(module => typeof module.initializeParams === "function")
347
+ const effects = modules.filter(module => typeof module.mountRouteEffects === "function")
348
+ if (params.length > 1 || effects.length > 1) throw new Error("Navigation document has duplicate route capabilities")
349
+ return { params: params[0]?.initializeParams, effects: effects[0] }
338
350
  }
339
351
  `, "")
340
352
  }
341
353
 
354
+ function specializeNavigationPatterns(source, enabled) {
355
+ if (enabled) return source
356
+ return source.replace(/function matchRoute\(pathname\) \{[\s\S]+?\n\}\n\nfunction fallback/, `function matchRoute(pathname) {
357
+ return routes.find(record => record.path === pathname)
358
+ }
359
+
360
+ function fallback`)
361
+ }
362
+
342
363
  function specializeNativeRuntime(source, events, modules) {
343
364
  const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
344
365
  const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
@@ -922,12 +943,14 @@ async function invokeCleanup() {
922
943
  }${disposal}`
923
944
  }
924
945
 
925
- function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
946
+ function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName, navigable) {
947
+ const prefix = navigable ? "export function initializeParams(pathname) {\n" : "let pathname = location.pathname\n"
948
+ const suffix = navigable ? "\n}" : ""
926
949
  return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
927
950
  const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
928
951
  const schema = ${inlineJson(schema.segments)}
929
952
  const params = ${inlineJson(params)}
930
- let path = location.pathname
953
+ ${prefix}let path = pathname
931
954
  if (base.length) {
932
955
  const pathSegments = path.slice(1).split("/")
933
956
  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 +978,7 @@ function decodeSegment(raw, param) {
955
978
  const decodedDots = value.replace(/%2e/gi, ".")
956
979
  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
980
  return value
958
- }`
981
+ }${suffix}`
959
982
  }
960
983
 
961
984
  function hasCaptureType(value, type) {
@@ -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
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
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.0",
3
+ "version": "0.6.2",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",