@kudzujs/core 0.6.2 → 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 +5 -1
- package/README.md +24 -11
- package/framework/README.md +3 -3
- package/framework/build.mjs +101 -34
- package/framework/core.d.ts +1 -0
- package/framework/navigation-runtime.js +2 -2
- 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 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
|
|
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.
|
|
@@ -25,6 +25,10 @@ In the focused effect-enabled navigation fixture, mount support adds 171 B gzip
|
|
|
25
25
|
|
|
26
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
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
|
+
|
|
28
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.
|
|
29
33
|
|
|
30
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.
|
package/README.md
CHANGED
|
@@ -457,9 +457,22 @@ export default {
|
|
|
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
|
|
|
@@ -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 emitted-route complete-document prefetch, runtime segment matching, validation, finite memory caching, route-range replacement, history, focus, and native fallback
|
|
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 emitted shared-layout group into same-document navigation with `navigation: { routes: ["/product", "/items/[id]"] }`. The
|
|
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,13 +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
|
-
|
|
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
|
+
}
|
|
30
35
|
await rm(workDirectory, { recursive: true, force: true })
|
|
31
36
|
await rm(outputDirectory, { recursive: true, force: true })
|
|
32
37
|
await mkdir(workDirectory, { recursive: true })
|
|
@@ -62,7 +67,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
62
67
|
const rewrites = []
|
|
63
68
|
const emittedRoutes = new Set()
|
|
64
69
|
const emittedApplicationRoutes = new Set()
|
|
65
|
-
const
|
|
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,19 +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 (typeof module.layout !== "function") throw new Error(
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
108
119
|
}
|
|
109
120
|
const result = await renderPage(module.default, {
|
|
110
121
|
...(module.metadata ?? {}),
|
|
@@ -114,8 +125,12 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
114
125
|
effectAsset: assetPath(base, `assets/${effectPath}`),
|
|
115
126
|
paramAsset: assetPath(base, `assets/${paramPath}`),
|
|
116
127
|
runtimeParams: runtimeSchema?.params,
|
|
117
|
-
...(navigable ? { navigationAsset, applicationId, layoutId, routeId: applicationRoute } : {})
|
|
128
|
+
...(navigable ? { navigationAsset: navigationGroup.assetPath, applicationId: navigationGroup.applicationId, layoutId: navigationGroup.layoutId, routeId: applicationRoute } : {})
|
|
118
129
|
}, props, module.layout)
|
|
130
|
+
if (navigationGroup) {
|
|
131
|
+
navigationGroup.hasEffects ||= result.hasEffects
|
|
132
|
+
navigationGroup.hasParams ||= result.hasParams
|
|
133
|
+
}
|
|
119
134
|
const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
|
|
120
135
|
const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
|
|
121
136
|
pageEntries.push({ route, html: result.html, usesDependencyRuntime })
|
|
@@ -136,8 +151,13 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
136
151
|
}
|
|
137
152
|
}
|
|
138
153
|
|
|
139
|
-
for (const route of
|
|
140
|
-
|
|
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
|
+
}
|
|
141
161
|
|
|
142
162
|
const assetsDirectory = join(outputDirectory, "assets")
|
|
143
163
|
await mkdir(assetsDirectory, { recursive: true })
|
|
@@ -161,7 +181,6 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
161
181
|
const hasNativeHandlers = nativeModules.length > 0
|
|
162
182
|
const hasEffects = effectEntries.length > 0
|
|
163
183
|
const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
|
|
164
|
-
const hasNavigableParams = paramEntries.some(entry => entry.navigable)
|
|
165
184
|
const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
|
|
166
185
|
const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
|
|
167
186
|
const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
|
|
@@ -237,14 +256,17 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
237
256
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
238
257
|
})
|
|
239
258
|
}
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
}
|
|
248
270
|
}
|
|
249
271
|
for (const handlerModule of handlerModules) {
|
|
250
272
|
const output = join(assetsDirectory, handlerModule.path)
|
|
@@ -2883,19 +2905,64 @@ function normalizeStyles(value, base) {
|
|
|
2883
2905
|
export function normalizeNavigation(value) {
|
|
2884
2906
|
if (value === undefined) return []
|
|
2885
2907
|
if (!isPlainRecord(value)) throw new Error("kudzu.config navigation must be a plain object")
|
|
2886
|
-
if (Object.keys(value).some(key =>
|
|
2887
|
-
if (
|
|
2888
|
-
const
|
|
2889
|
-
|
|
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`)
|
|
2890
2938
|
let decoded
|
|
2891
|
-
try { decoded = decodeURIComponent(route) } catch { throw new Error(
|
|
2892
|
-
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`)
|
|
2893
2941
|
return route
|
|
2894
2942
|
})
|
|
2895
|
-
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`)
|
|
2896
2944
|
return routes
|
|
2897
2945
|
}
|
|
2898
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
|
+
|
|
2899
2966
|
function specializeNavigationTextDescriptors(source) {
|
|
2900
2967
|
const dynamic = source
|
|
2901
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
|
@@ -195,7 +195,7 @@ function commit(incoming, incomingNodes, initializeParams, pathname) {
|
|
|
195
195
|
|
|
196
196
|
function matchRoute(pathname) {
|
|
197
197
|
const exact = routes.find(record => record.path === pathname)
|
|
198
|
-
if (exact) return exact
|
|
198
|
+
if (exact) return exact.native ? undefined : exact
|
|
199
199
|
for (const record of routes) {
|
|
200
200
|
if (!record.segments) continue
|
|
201
201
|
try {
|
|
@@ -212,7 +212,7 @@ function matchRoute(pathname) {
|
|
|
212
212
|
if (record.segments.every((literal, index) => {
|
|
213
213
|
const value = decodeSegment(segments[index], literal === null)
|
|
214
214
|
return literal === null || value === literal
|
|
215
|
-
})) return record
|
|
215
|
+
})) return record.native ? undefined : record
|
|
216
216
|
} catch {}
|
|
217
217
|
}
|
|
218
218
|
}
|