@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 +5 -3
- package/README.md +6 -6
- package/bin/kudzu.mjs +5 -5
- package/framework/README.md +3 -3
- package/framework/build.mjs +42 -19
- package/framework/core.mjs +1 -1
- package/framework/navigation-runtime.js +65 -19
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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", "/
|
|
456
|
+
navigation: { routes: ["/product", "/items/[id]"] }
|
|
457
457
|
}
|
|
458
458
|
```
|
|
459
459
|
|
|
460
|
-
Every configured
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
import * as module from "node:module"
|
|
4
4
|
|
|
5
5
|
const command = process.argv[2] ?? "dev"
|
|
6
6
|
|
|
7
|
-
if (command === "build") {
|
|
8
|
-
|
|
9
|
-
}
|
|
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
|
package/framework/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
238
|
-
.replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(
|
|
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
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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 =
|
|
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) {
|
package/framework/core.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = []
|