@buzola/router 0.0.1
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/dist/__tests__/create-page.test.d.ts +2 -0
- package/dist/__tests__/create-page.test.d.ts.map +1 -0
- package/dist/__tests__/create-page.test.js +457 -0
- package/dist/__tests__/create-page.test.js.map +1 -0
- package/dist/__tests__/define-routes.test.d.ts +2 -0
- package/dist/__tests__/define-routes.test.d.ts.map +1 -0
- package/dist/__tests__/define-routes.test.js +42 -0
- package/dist/__tests__/define-routes.test.js.map +1 -0
- package/dist/__tests__/matcher.test.d.ts +2 -0
- package/dist/__tests__/matcher.test.d.ts.map +1 -0
- package/dist/__tests__/matcher.test.js +108 -0
- package/dist/__tests__/matcher.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +327 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/type-safety.test.d.ts +15 -0
- package/dist/__tests__/type-safety.test.d.ts.map +1 -0
- package/dist/__tests__/type-safety.test.js +163 -0
- package/dist/__tests__/type-safety.test.js.map +1 -0
- package/dist/__tests__/types.test.d.ts +2 -0
- package/dist/__tests__/types.test.d.ts.map +1 -0
- package/dist/__tests__/types.test.js +22 -0
- package/dist/__tests__/types.test.js.map +1 -0
- package/dist/define/create-page.d.ts +87 -0
- package/dist/define/create-page.d.ts.map +1 -0
- package/dist/define/create-page.js +305 -0
- package/dist/define/create-page.js.map +1 -0
- package/dist/define/define-routes.d.ts +34 -0
- package/dist/define/define-routes.d.ts.map +1 -0
- package/dist/define/define-routes.js +53 -0
- package/dist/define/define-routes.js.map +1 -0
- package/dist/engine/matcher.d.ts +8 -0
- package/dist/engine/matcher.d.ts.map +1 -0
- package/dist/engine/matcher.js +79 -0
- package/dist/engine/matcher.js.map +1 -0
- package/dist/engine/navigation-adapter.d.ts +13 -0
- package/dist/engine/navigation-adapter.d.ts.map +1 -0
- package/dist/engine/navigation-adapter.js +156 -0
- package/dist/engine/navigation-adapter.js.map +1 -0
- package/dist/engine/route-tree.d.ts +21 -0
- package/dist/engine/route-tree.d.ts.map +1 -0
- package/dist/engine/route-tree.js +95 -0
- package/dist/engine/route-tree.js.map +1 -0
- package/dist/engine/router.d.ts +162 -0
- package/dist/engine/router.d.ts.map +1 -0
- package/dist/engine/router.js +400 -0
- package/dist/engine/router.js.map +1 -0
- package/dist/engine/schema.d.ts +46 -0
- package/dist/engine/schema.d.ts.map +1 -0
- package/dist/engine/schema.js +110 -0
- package/dist/engine/schema.js.map +1 -0
- package/dist/engine/types.d.ts +183 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +2 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/engine/utils.d.ts +7 -0
- package/dist/engine/utils.d.ts.map +1 -0
- package/dist/engine/utils.js +17 -0
- package/dist/engine/utils.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/react/context.d.ts +21 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +7 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/error-boundary.d.ts +24 -0
- package/dist/react/error-boundary.d.ts.map +1 -0
- package/dist/react/error-boundary.js +37 -0
- package/dist/react/error-boundary.js.map +1 -0
- package/dist/react/hooks.d.ts +85 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +161 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +7 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/link.d.ts +42 -0
- package/dist/react/link.d.ts.map +1 -0
- package/dist/react/link.js +38 -0
- package/dist/react/link.js.map +1 -0
- package/dist/react/outlet.d.ts +13 -0
- package/dist/react/outlet.d.ts.map +1 -0
- package/dist/react/outlet.js +46 -0
- package/dist/react/outlet.js.map +1 -0
- package/dist/react/provider.d.ts +22 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +37 -0
- package/dist/react/provider.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/create-page.test.ts +588 -0
- package/src/__tests__/matcher.test.ts +127 -0
- package/src/__tests__/router.test.ts +424 -0
- package/src/__tests__/type-safety.test.tsx +207 -0
- package/src/define/create-page.tsx +472 -0
- package/src/engine/matcher.ts +88 -0
- package/src/engine/navigation-adapter.ts +191 -0
- package/src/engine/route-tree.ts +129 -0
- package/src/engine/router.ts +471 -0
- package/src/engine/schema.ts +130 -0
- package/src/engine/types.ts +203 -0
- package/src/engine/utils.ts +16 -0
- package/src/env.d.ts +120 -0
- package/src/index.ts +61 -0
- package/src/react/context.ts +24 -0
- package/src/react/error-boundary.tsx +56 -0
- package/src/react/hooks.ts +211 -0
- package/src/react/index.ts +16 -0
- package/src/react/link.tsx +93 -0
- package/src/react/outlet.tsx +67 -0
- package/src/react/provider.tsx +60 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { BuzolaNavigateEvent, NavigateOptions, NavigationAdapter } from './types'
|
|
2
|
+
|
|
3
|
+
// ─── Browser adapter ────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a NavigationAdapter that delegates to the browser's Navigation API.
|
|
7
|
+
*/
|
|
8
|
+
export function createBrowserNavigationAdapter(): NavigationAdapter {
|
|
9
|
+
const nav = (window as { navigation?: Navigation }).navigation
|
|
10
|
+
if (!nav) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'Navigation API is not available. Buzola requires a browser that supports the Navigation API.',
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const listeners = new Set<(event: BuzolaNavigateEvent) => void>()
|
|
17
|
+
let pendingViewTransition = false
|
|
18
|
+
|
|
19
|
+
const nativeHandler = (event: NavigateEvent) => {
|
|
20
|
+
const viewTransition = pendingViewTransition
|
|
21
|
+
pendingViewTransition = false
|
|
22
|
+
|
|
23
|
+
const buzolaEvent: BuzolaNavigateEvent = {
|
|
24
|
+
destination: { url: event.destination.url },
|
|
25
|
+
canIntercept: event.canIntercept,
|
|
26
|
+
navigationType: event.navigationType as BuzolaNavigateEvent['navigationType'],
|
|
27
|
+
userInitiated: event.userInitiated,
|
|
28
|
+
viewTransition,
|
|
29
|
+
intercept(options) {
|
|
30
|
+
event.intercept({
|
|
31
|
+
handler: options.handler,
|
|
32
|
+
scroll: options.scroll,
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const listener of listeners) {
|
|
38
|
+
listener(buzolaEvent)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
getCurrentURL() {
|
|
44
|
+
return new URL(nav.currentEntry!.url!)
|
|
45
|
+
},
|
|
46
|
+
navigate(url, options) {
|
|
47
|
+
if (options?.viewTransition) {
|
|
48
|
+
pendingViewTransition = true
|
|
49
|
+
}
|
|
50
|
+
nav.navigate(url, {
|
|
51
|
+
history: options?.replace ? 'replace' : 'push',
|
|
52
|
+
state: options?.state,
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
back() {
|
|
56
|
+
nav.back()
|
|
57
|
+
},
|
|
58
|
+
forward() {
|
|
59
|
+
nav.forward()
|
|
60
|
+
},
|
|
61
|
+
addEventListener(_type, handler) {
|
|
62
|
+
// Lazily register the native handler when the first listener is added
|
|
63
|
+
const wasEmpty = listeners.size === 0
|
|
64
|
+
listeners.add(handler)
|
|
65
|
+
if (wasEmpty) {
|
|
66
|
+
nav.addEventListener('navigate', nativeHandler)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
removeEventListener(_type, handler) {
|
|
70
|
+
listeners.delete(handler)
|
|
71
|
+
// Clean up the native handler when all listeners are removed
|
|
72
|
+
if (listeners.size === 0) {
|
|
73
|
+
nav.removeEventListener('navigate', nativeHandler)
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
getState() {
|
|
77
|
+
return nav.currentEntry?.getState?.()
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Memory adapter (for testing) ───────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface MemoryNavigationAdapterOptions {
|
|
85
|
+
initialURL?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface MemoryEntry {
|
|
89
|
+
url: string
|
|
90
|
+
state?: unknown
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create an in-memory NavigationAdapter for testing.
|
|
95
|
+
*/
|
|
96
|
+
export function createMemoryNavigationAdapter(
|
|
97
|
+
options: MemoryNavigationAdapterOptions = {},
|
|
98
|
+
): NavigationAdapter {
|
|
99
|
+
const { initialURL = 'http://localhost/' } = options
|
|
100
|
+
|
|
101
|
+
const entries: MemoryEntry[] = [{ url: initialURL }]
|
|
102
|
+
let currentIndex = 0
|
|
103
|
+
const listeners = new Set<(event: BuzolaNavigateEvent) => void>()
|
|
104
|
+
|
|
105
|
+
function emitNavigateEvent(
|
|
106
|
+
url: string,
|
|
107
|
+
navigationType: BuzolaNavigateEvent['navigationType'],
|
|
108
|
+
commitNavigation: () => void,
|
|
109
|
+
viewTransition?: boolean,
|
|
110
|
+
): void {
|
|
111
|
+
let intercepted = false
|
|
112
|
+
let interceptHandler: (() => Promise<void>) | undefined
|
|
113
|
+
|
|
114
|
+
const event: BuzolaNavigateEvent = {
|
|
115
|
+
destination: { url },
|
|
116
|
+
canIntercept: true,
|
|
117
|
+
navigationType,
|
|
118
|
+
userInitiated: false,
|
|
119
|
+
viewTransition,
|
|
120
|
+
intercept(options) {
|
|
121
|
+
intercepted = true
|
|
122
|
+
interceptHandler = options.handler
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const listener of listeners) {
|
|
127
|
+
listener(event)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (intercepted && interceptHandler) {
|
|
131
|
+
// Run the handler and only commit navigation state on success.
|
|
132
|
+
void interceptHandler().then(
|
|
133
|
+
() => {
|
|
134
|
+
commitNavigation()
|
|
135
|
+
},
|
|
136
|
+
(error) => {
|
|
137
|
+
// Guard aborts are expected — rethrow unexpected errors
|
|
138
|
+
if (error instanceof Error && error.name === 'NavigationAbortedError') return
|
|
139
|
+
throw error
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
} else {
|
|
143
|
+
// No interception — commit immediately
|
|
144
|
+
commitNavigation()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
getCurrentURL() {
|
|
150
|
+
return new URL(entries[currentIndex].url)
|
|
151
|
+
},
|
|
152
|
+
navigate(url, options) {
|
|
153
|
+
const resolved = new URL(url, entries[currentIndex].url).href
|
|
154
|
+
const type = options?.replace ? 'replace' : 'push'
|
|
155
|
+
|
|
156
|
+
emitNavigateEvent(resolved, type, () => {
|
|
157
|
+
if (options?.replace) {
|
|
158
|
+
entries[currentIndex] = { url: resolved, state: options.state }
|
|
159
|
+
} else {
|
|
160
|
+
// Remove any forward entries
|
|
161
|
+
entries.length = currentIndex + 1
|
|
162
|
+
entries.push({ url: resolved, state: options?.state })
|
|
163
|
+
currentIndex++
|
|
164
|
+
}
|
|
165
|
+
}, options?.viewTransition)
|
|
166
|
+
},
|
|
167
|
+
back() {
|
|
168
|
+
if (currentIndex > 0) {
|
|
169
|
+
emitNavigateEvent(entries[currentIndex - 1].url, 'traverse', () => {
|
|
170
|
+
currentIndex--
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
forward() {
|
|
175
|
+
if (currentIndex < entries.length - 1) {
|
|
176
|
+
emitNavigateEvent(entries[currentIndex + 1].url, 'traverse', () => {
|
|
177
|
+
currentIndex++
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
addEventListener(_type, handler) {
|
|
182
|
+
listeners.add(handler)
|
|
183
|
+
},
|
|
184
|
+
removeEventListener(_type, handler) {
|
|
185
|
+
listeners.delete(handler)
|
|
186
|
+
},
|
|
187
|
+
getState() {
|
|
188
|
+
return entries[currentIndex].state
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { RouteComponent, RouteConfig, RouteGuard, RouteNode, StandardSchema } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize a path segment — ensure it starts with "/" and strip trailing slashes.
|
|
5
|
+
*/
|
|
6
|
+
function normalizePath(path: string): string {
|
|
7
|
+
if (path === '' || path === '/') return '/'
|
|
8
|
+
const p = path.startsWith('/') ? path : `/${path}`
|
|
9
|
+
return p.endsWith('/') ? p.slice(0, -1) : p
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Join two path segments.
|
|
14
|
+
*/
|
|
15
|
+
function joinPaths(parent: string, child: string): string {
|
|
16
|
+
if (child === '/') return parent === '/' ? '/' : parent
|
|
17
|
+
const base = parent === '/' ? '' : parent
|
|
18
|
+
const segment = child.startsWith('/') ? child : `/${child}`
|
|
19
|
+
return base + segment
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CreateRouteNodeOptions {
|
|
23
|
+
path: string
|
|
24
|
+
fullPath: string
|
|
25
|
+
component?: RouteComponent
|
|
26
|
+
searchSchema?: StandardSchema
|
|
27
|
+
beforeEnter?: RouteGuard
|
|
28
|
+
children?: RouteNode[]
|
|
29
|
+
parent?: RouteNode
|
|
30
|
+
isLayout?: boolean
|
|
31
|
+
isIndex?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a route node with a compiled URLPattern.
|
|
36
|
+
*/
|
|
37
|
+
export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
|
|
38
|
+
const {
|
|
39
|
+
path,
|
|
40
|
+
fullPath,
|
|
41
|
+
component,
|
|
42
|
+
searchSchema,
|
|
43
|
+
beforeEnter,
|
|
44
|
+
children = [],
|
|
45
|
+
parent,
|
|
46
|
+
isLayout = false,
|
|
47
|
+
isIndex = false,
|
|
48
|
+
} = options
|
|
49
|
+
|
|
50
|
+
// Build the pattern — layouts get a prefix pattern for param extraction,
|
|
51
|
+
// index and leaf routes get an exact pattern for matching.
|
|
52
|
+
let pattern: URLPattern | undefined
|
|
53
|
+
let prefixPattern: URLPattern | undefined
|
|
54
|
+
if (!isLayout || isIndex) {
|
|
55
|
+
pattern = new URLPattern({ pathname: fullPath === '' ? '/' : fullPath })
|
|
56
|
+
}
|
|
57
|
+
if (isLayout && !isIndex) {
|
|
58
|
+
// Layouts get a prefix pattern that matches any path starting with their fullPath.
|
|
59
|
+
// The {/.*}? suffix optionally matches a trailing slash and anything after.
|
|
60
|
+
const prefix = fullPath === '' || fullPath === '/' ? '/' : fullPath
|
|
61
|
+
prefixPattern = new URLPattern({ pathname: prefix === '/' ? '/{.*}?' : `${prefix}{/.*}?` })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const node: RouteNode = {
|
|
65
|
+
id: `route:${fullPath || '/'}${isLayout ? ':layout' : ''}`,
|
|
66
|
+
path: normalizePath(path),
|
|
67
|
+
fullPath: normalizePath(fullPath),
|
|
68
|
+
pattern,
|
|
69
|
+
prefixPattern,
|
|
70
|
+
component,
|
|
71
|
+
searchSchema,
|
|
72
|
+
beforeEnter,
|
|
73
|
+
children,
|
|
74
|
+
parent,
|
|
75
|
+
isLayout,
|
|
76
|
+
isIndex,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Set parent reference on children
|
|
80
|
+
for (const child of node.children) {
|
|
81
|
+
child.parent = node
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return node
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a route tree from an array of RouteConfig.
|
|
89
|
+
*/
|
|
90
|
+
export function buildRouteTree(configs: RouteConfig[], parentPath = ''): RouteNode[] {
|
|
91
|
+
return buildRouteNodes(configs, parentPath)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildRouteNodes(configs: RouteConfig[], parentPath: string): RouteNode[] {
|
|
95
|
+
const nodes: RouteNode[] = []
|
|
96
|
+
|
|
97
|
+
for (const config of configs) {
|
|
98
|
+
const segment = config.isPathlessGroup ? '' : config.path
|
|
99
|
+
const hasChildren = config.children != null && config.children.length > 0
|
|
100
|
+
const isLayout = config.isLayout ?? hasChildren
|
|
101
|
+
const isIndex = config.isIndex ?? (!isLayout && (config.path === '/' || config.path === ''))
|
|
102
|
+
|
|
103
|
+
let fullPath: string
|
|
104
|
+
if (isIndex) {
|
|
105
|
+
fullPath = parentPath || '/'
|
|
106
|
+
} else {
|
|
107
|
+
fullPath = joinPaths(parentPath || '/', segment)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const children = config.children
|
|
111
|
+
? buildRouteNodes(config.children, config.isPathlessGroup ? parentPath : fullPath)
|
|
112
|
+
: []
|
|
113
|
+
|
|
114
|
+
const node = createRouteNode({
|
|
115
|
+
path: segment,
|
|
116
|
+
fullPath,
|
|
117
|
+
component: config.component,
|
|
118
|
+
searchSchema: config.searchSchema,
|
|
119
|
+
beforeEnter: config.beforeEnter,
|
|
120
|
+
children,
|
|
121
|
+
isLayout,
|
|
122
|
+
isIndex,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
nodes.push(node)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return nodes
|
|
129
|
+
}
|