@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 +8 -2
- package/README.md +27 -14
- package/framework/README.md +3 -3
- package/framework/build.mjs +130 -40
- package/framework/core.d.ts +1 -0
- 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 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
|
|
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
|
|
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
|
-
|
|
460
|
+
The legacy single-group form remains supported. Applications with multiple shared layouts use mutually exclusive `groups`:
|
|
461
461
|
|
|
462
|
-
|
|
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,
|
|
523
|
-
| React + Vite | 61,464 B | 202,842 B |
|
|
524
|
-
| Next.js | 190,090 B | 546,581 B | **
|
|
525
|
-
| Nuxt | 67,620 B | 195,953 B |
|
|
526
|
-
| SvelteKit | 32,
|
|
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
|
|
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
|
|
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 |
|
|
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.
|
|
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
|
|
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, 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
|
|
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
|
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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 (
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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 (
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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 =
|
|
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 =>
|
|
2864
|
-
if (
|
|
2865
|
-
const
|
|
2866
|
-
|
|
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(
|
|
2869
|
-
if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(
|
|
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(
|
|
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 ?? \"[]\") : []")
|
package/framework/core.d.ts
CHANGED
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.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 = []
|