safa-router 1.1.1 → 1.2.0
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/package.json +15 -5
- package/safa-router.d.ts +350 -0
- package/src/Link.js +1 -1
- package/src/MiddlewareChain.js +37 -3
- package/src/PluginManager.js +87 -0
- package/src/RouteMatcher.js +3 -3
- package/src/RouteTree.js +33 -18
- package/src/SafaRouter.js +100 -71
- package/src/ScrollManager.js +76 -0
- package/src/TransitionsManager.js +80 -0
- package/src/constants.js +11 -1
- package/src/index.js +5 -1
- package/src/ssr.js +61 -0
- package/src/utils.js +2 -1
package/package.json
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safa-router",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A professional standalone frontend router inspired by Next.js App Router — works with any framework or vanilla JS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
|
+
"types": "safa-router.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./src/index.js",
|
|
11
|
+
"types": "./safa-router.d.ts"
|
|
12
|
+
},
|
|
9
13
|
"./*": "./src/*.js"
|
|
10
14
|
},
|
|
11
15
|
"files": [
|
|
12
|
-
"src/"
|
|
16
|
+
"src/",
|
|
17
|
+
"safa-router.d.ts"
|
|
13
18
|
],
|
|
14
19
|
"scripts": {
|
|
15
20
|
"start": "npx serve test-app -l 3000",
|
|
16
|
-
"test": "
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:coverage": "vitest run --coverage",
|
|
17
24
|
"lint": "echo \"Add lint tool\"",
|
|
18
25
|
"prepublishOnly": "npm test"
|
|
19
26
|
},
|
|
@@ -39,5 +46,8 @@
|
|
|
39
46
|
"bugs": {
|
|
40
47
|
"url": "https://github.com/Karan-Safaie-Qadi/SafaRouter/issues"
|
|
41
48
|
},
|
|
42
|
-
"homepage": "https://github.com/Karan-Safaie-Qadi/SafaRouter#readme"
|
|
49
|
+
"homepage": "https://github.com/Karan-Safaie-Qadi/SafaRouter#readme",
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"vitest": "^4.1.9"
|
|
52
|
+
}
|
|
43
53
|
}
|
package/safa-router.d.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
declare module 'safa-router' {
|
|
2
|
+
// ─── Core ─────────────────────────────────────────
|
|
3
|
+
export class SafaRouter {
|
|
4
|
+
static version: string
|
|
5
|
+
static VERSION: string
|
|
6
|
+
|
|
7
|
+
constructor(options?: SafaRouterOptions)
|
|
8
|
+
start(target?: string | Element): Promise<this>
|
|
9
|
+
isStarted(): boolean
|
|
10
|
+
destroy(): void
|
|
11
|
+
|
|
12
|
+
push(url: string, state?: Record<string, any>): Promise<void>
|
|
13
|
+
replace(url: string, state?: Record<string, any>): Promise<void>
|
|
14
|
+
pushRoute(routeName: string, params?: Record<string, any>, query?: Record<string, any>): Promise<void>
|
|
15
|
+
back(): void
|
|
16
|
+
forward(): void
|
|
17
|
+
reload(): void
|
|
18
|
+
navigate(url: string): Promise<void>
|
|
19
|
+
|
|
20
|
+
getConfig(): SafaRouterOptions
|
|
21
|
+
readonly pathname: string
|
|
22
|
+
readonly params: Record<string, any>
|
|
23
|
+
readonly query: Record<string, any>
|
|
24
|
+
readonly loading: boolean
|
|
25
|
+
readonly currentRoute: RouteMatch | null
|
|
26
|
+
|
|
27
|
+
on(event: string, fn: (data: any) => void): () => void
|
|
28
|
+
off(event: string, fn: (data: any) => void): void
|
|
29
|
+
use(fn: MiddlewareFn): this
|
|
30
|
+
useNamed(name: string, fn: MiddlewareFn, priority?: number): this
|
|
31
|
+
beforeEach(fn: MiddlewareFn): this
|
|
32
|
+
afterEach(fn: (data: { pathname: string }) => void): () => void
|
|
33
|
+
middleware(name: string): this
|
|
34
|
+
insertMiddlewareBefore(refName: string, fn: MiddlewareFn, priority?: number): this
|
|
35
|
+
insertMiddlewareAfter(refName: string, fn: MiddlewareFn, priority?: number): this
|
|
36
|
+
|
|
37
|
+
onError(fn: (data: { path: string; error: Error }) => void): () => void
|
|
38
|
+
onNotFound(fn: (data: { path: string }) => void): () => void
|
|
39
|
+
onRouteChange(fn: (data: { pathname: string; params: any; query: any }) => void): () => void
|
|
40
|
+
onBeforeNavigate(fn: (data: { path: string; method: string }) => void): () => void
|
|
41
|
+
|
|
42
|
+
createLink(config: LinkConfig): Link
|
|
43
|
+
prefetch(path: string): Promise<void>
|
|
44
|
+
clearCache(): void
|
|
45
|
+
getRoute(path: string): RouteMatch | null
|
|
46
|
+
|
|
47
|
+
plugin(plugin: SafaPlugin): this
|
|
48
|
+
ejectPlugin(name: string): boolean
|
|
49
|
+
getPlugin(name: string): any
|
|
50
|
+
readonly plugins: string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SafaRouterOptions {
|
|
54
|
+
target?: string | Element
|
|
55
|
+
pageDir?: string
|
|
56
|
+
pagesDir?: string
|
|
57
|
+
layout?: LayoutComponent | string
|
|
58
|
+
routes?: RouteDefinition
|
|
59
|
+
notFound?: PageComponent | string
|
|
60
|
+
error?: PageComponent | string
|
|
61
|
+
basePath?: string
|
|
62
|
+
useHash?: boolean
|
|
63
|
+
scrollToTop?: boolean
|
|
64
|
+
prefetch?: boolean
|
|
65
|
+
cacheRoutes?: boolean
|
|
66
|
+
titleTemplate?: string
|
|
67
|
+
transitionDuration?: number
|
|
68
|
+
transitionEnterClass?: string
|
|
69
|
+
transitionExitClass?: string
|
|
70
|
+
transitionEnterActiveClass?: string
|
|
71
|
+
transitionExitActiveClass?: string
|
|
72
|
+
scrollRestoration?: 'auto' | 'manual'
|
|
73
|
+
plugins?: SafaPlugin[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Route Tree ───────────────────────────────────
|
|
77
|
+
export interface RouteDefinition {
|
|
78
|
+
[key: string]: RouteEntry
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RouteEntry {
|
|
82
|
+
page?: PageComponent | string
|
|
83
|
+
layout?: LayoutComponent | string
|
|
84
|
+
loading?: PageComponent | string
|
|
85
|
+
error?: PageComponent | string
|
|
86
|
+
notFound?: PageComponent | string
|
|
87
|
+
children?: RouteDefinition
|
|
88
|
+
meta?: { title?: string; name?: string; [key: string]: any }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RouteMatch {
|
|
92
|
+
node: RouteNodeData
|
|
93
|
+
params: Record<string, any>
|
|
94
|
+
layouts: LayoutComponent[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RouteNodeData {
|
|
98
|
+
page?: PageComponent | string
|
|
99
|
+
layout?: LayoutComponent | string
|
|
100
|
+
loading?: PageComponent | string
|
|
101
|
+
error?: PageComponent | string
|
|
102
|
+
notFound?: PageComponent | string
|
|
103
|
+
meta?: Record<string, any>
|
|
104
|
+
segment?: string
|
|
105
|
+
fullPath?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Components ───────────────────────────────────
|
|
109
|
+
export interface PageContext {
|
|
110
|
+
params: Record<string, any>
|
|
111
|
+
query: Record<string, any>
|
|
112
|
+
router: SafaRouter
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface LayoutContext extends PageContext {
|
|
116
|
+
children: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type PageComponent = (ctx: PageContext) => string | Promise<string>
|
|
120
|
+
export type LayoutComponent = (ctx: LayoutContext) => string | Promise<string>
|
|
121
|
+
|
|
122
|
+
// ─── Middleware ────────────────────────────────────
|
|
123
|
+
export interface MiddlewareContext {
|
|
124
|
+
path: string
|
|
125
|
+
method: string
|
|
126
|
+
query: Record<string, any>
|
|
127
|
+
cancelled: boolean
|
|
128
|
+
redirect: string | null
|
|
129
|
+
[key: string]: any
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type MiddlewareFn = (ctx: MiddlewareContext, next: () => Promise<any>) => any
|
|
133
|
+
|
|
134
|
+
export class MiddlewareChain {
|
|
135
|
+
constructor()
|
|
136
|
+
use(fn: MiddlewareFn, priority?: number): this
|
|
137
|
+
useNamed(name: string, fn: MiddlewareFn, priority?: number): this
|
|
138
|
+
run(ctx: MiddlewareContext): Promise<any>
|
|
139
|
+
clear(): this
|
|
140
|
+
remove(fn: MiddlewareFn | string): this
|
|
141
|
+
insertBefore(refName: string, fn: MiddlewareFn, priority?: number): this
|
|
142
|
+
insertAfter(refName: string, fn: MiddlewareFn, priority?: number): this
|
|
143
|
+
clone(): MiddlewareChain
|
|
144
|
+
readonly length: number
|
|
145
|
+
readonly stack: { name: string; priority: number }[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── History ───────────────────────────────────────
|
|
149
|
+
export class HistoryManager {
|
|
150
|
+
constructor(opts?: { useHash?: boolean; basePath?: string })
|
|
151
|
+
init(): void
|
|
152
|
+
destroy(): void
|
|
153
|
+
isSupported(): boolean
|
|
154
|
+
readonly path: string
|
|
155
|
+
push(url: string, state?: Record<string, any>): void
|
|
156
|
+
replace(url: string, state?: Record<string, any>): void
|
|
157
|
+
back(): void
|
|
158
|
+
forward(): void
|
|
159
|
+
go(delta: number): void
|
|
160
|
+
readonly length: number
|
|
161
|
+
readonly state: any
|
|
162
|
+
clearState(): void
|
|
163
|
+
onChange(fn: (data: { path: string; action: string; state: any }) => void): () => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Link ──────────────────────────────────────────
|
|
167
|
+
export interface LinkConfig {
|
|
168
|
+
href?: string
|
|
169
|
+
children?: string | Node | Node[]
|
|
170
|
+
className?: string
|
|
171
|
+
activeClass?: string
|
|
172
|
+
router?: SafaRouter
|
|
173
|
+
attrs?: Record<string, string>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class Link {
|
|
177
|
+
constructor(config?: LinkConfig)
|
|
178
|
+
readonly href: string
|
|
179
|
+
readonly element: HTMLAnchorElement | null
|
|
180
|
+
render(container?: string | Node): HTMLAnchorElement
|
|
181
|
+
setHref(href: string): void
|
|
182
|
+
destroy(): void
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Route Tree Core ───────────────────────────────
|
|
186
|
+
export class RouteTree {
|
|
187
|
+
constructor(routes: RouteDefinition)
|
|
188
|
+
static create(routes: RouteDefinition): RouteTree
|
|
189
|
+
resolve(pathname: string): RouteMatch | null
|
|
190
|
+
flatten(): { path: string; page: any; meta: any }[]
|
|
191
|
+
find(pathname: string): RouteMatch | null
|
|
192
|
+
getRoute(pathname: string): RouteMatch | null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Route Matcher ─────────────────────────────────
|
|
196
|
+
export class RouteMatcher {
|
|
197
|
+
constructor()
|
|
198
|
+
static create(patterns: string[]): RouteMatcher
|
|
199
|
+
add(pattern: string): this
|
|
200
|
+
addMultiple(patterns: string[]): this
|
|
201
|
+
match(url: string): { pattern: string; path: string; params: Record<string, any> } | null
|
|
202
|
+
build(pattern: string, params: Record<string, any>): string
|
|
203
|
+
clear(): this
|
|
204
|
+
readonly patterns: string[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Plugin System ────────────────────────────────
|
|
208
|
+
export interface SafaPlugin {
|
|
209
|
+
name: string
|
|
210
|
+
version?: string
|
|
211
|
+
install?: (router: SafaRouter) => void | (() => void) | Promise<void | (() => void)>
|
|
212
|
+
middleware?: MiddlewareFn
|
|
213
|
+
onBeforeNavigate?: (data: { path: string; method: string }) => void
|
|
214
|
+
onAfterNavigate?: (data: { pathname: string }) => void
|
|
215
|
+
onRouteChange?: (data: { pathname: string; params: any; query: any }) => void
|
|
216
|
+
onError?: (data: { path: string; error: Error }) => void
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export class PluginManager {
|
|
220
|
+
constructor(router: SafaRouter)
|
|
221
|
+
use(plugin: SafaPlugin): this
|
|
222
|
+
eject(name: string): boolean
|
|
223
|
+
get(name: string): SafaPlugin | null
|
|
224
|
+
list(): string[]
|
|
225
|
+
has(name: string): boolean
|
|
226
|
+
ejectAll(): void
|
|
227
|
+
readonly count: number
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Transitions ──────────────────────────────────
|
|
231
|
+
export interface TransitionsConfig {
|
|
232
|
+
transitionDuration?: number
|
|
233
|
+
transitionEnterClass?: string
|
|
234
|
+
transitionExitClass?: string
|
|
235
|
+
transitionEnterActiveClass?: string
|
|
236
|
+
transitionExitActiveClass?: string
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export class TransitionsManager {
|
|
240
|
+
constructor(config?: TransitionsConfig)
|
|
241
|
+
run(el: Element, renderFn: () => Promise<void> | void): Promise<void>
|
|
242
|
+
cancelExit(el: Element): void
|
|
243
|
+
cancelEnter(el: Element): void
|
|
244
|
+
setDuration(ms: number): void
|
|
245
|
+
readonly config: TransitionsConfig
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Scroll ────────────────────────────────────────
|
|
249
|
+
export class ScrollManager {
|
|
250
|
+
constructor()
|
|
251
|
+
save(pathname: string): void
|
|
252
|
+
restore(pathname: string, scrollToTop?: boolean): void
|
|
253
|
+
trackScrollElement(el: HTMLElement): void
|
|
254
|
+
untrackScrollElement(el: HTMLElement): void
|
|
255
|
+
restoreElementScroll(pathname: string, container: HTMLElement): void
|
|
256
|
+
clear(): void
|
|
257
|
+
has(pathname: string): boolean
|
|
258
|
+
readonly size: number
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── SSR ───────────────────────────────────────────
|
|
262
|
+
export function matchRoute(path: string, routes?: RouteDefinition): {
|
|
263
|
+
path: string
|
|
264
|
+
params: Record<string, any>
|
|
265
|
+
query: Record<string, any>
|
|
266
|
+
node: RouteNodeData
|
|
267
|
+
layouts: LayoutComponent[]
|
|
268
|
+
} | null
|
|
269
|
+
|
|
270
|
+
export function matchPattern(path: string, patterns?: string[]): {
|
|
271
|
+
pattern: string
|
|
272
|
+
path: string
|
|
273
|
+
params: Record<string, any>
|
|
274
|
+
} | null
|
|
275
|
+
|
|
276
|
+
export function renderRoute(path: string, routes?: RouteDefinition, context?: { router?: SafaRouter }): Promise<string | null>
|
|
277
|
+
|
|
278
|
+
export function routeExists(path: string, routes?: RouteDefinition): boolean
|
|
279
|
+
|
|
280
|
+
export function listRoutes(routes?: RouteDefinition): { path: string; page: any; meta: any }[]
|
|
281
|
+
|
|
282
|
+
// ─── Utils ─────────────────────────────────────────
|
|
283
|
+
export function normalizePath(path: string): string
|
|
284
|
+
export function parseQuery(search: string): Record<string, string>
|
|
285
|
+
export function joinPaths(...parts: string[]): string
|
|
286
|
+
export function createURL(path: string, base?: string): URL | null
|
|
287
|
+
export function isExternalURL(url: string): boolean
|
|
288
|
+
export function isSamePath(a: string, b: string): boolean
|
|
289
|
+
export function isDynamicSegment(segment: string): boolean
|
|
290
|
+
export function isCatchAllSegment(segment: string): boolean
|
|
291
|
+
export function isOptionalCatchAll(segment: string): boolean
|
|
292
|
+
export function isRouteGroupSegment(segment: string): boolean
|
|
293
|
+
export function extractParamName(segment: string): string | null
|
|
294
|
+
export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): T
|
|
295
|
+
export function useRouter(router: SafaRouter): {
|
|
296
|
+
readonly state: { pathname: string; params: any; query: any; loading: boolean }
|
|
297
|
+
subscribe(fn: (state: any) => void): () => void
|
|
298
|
+
push: SafaRouter['push']
|
|
299
|
+
replace: SafaRouter['replace']
|
|
300
|
+
back: SafaRouter['back']
|
|
301
|
+
forward: SafaRouter['forward']
|
|
302
|
+
navigate: SafaRouter['navigate']
|
|
303
|
+
unsubscribe: () => void
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Helpers ────────────────────────────────────────
|
|
307
|
+
export function bindLinks(router: SafaRouter, scope?: Document | Element): void
|
|
308
|
+
export function prefetchOnHover(router: SafaRouter): void
|
|
309
|
+
|
|
310
|
+
// ─── Constants ──────────────────────────────────────
|
|
311
|
+
export const EVENTS: {
|
|
312
|
+
BEFORE_NAVIGATE: 'beforenavigate'
|
|
313
|
+
NAVIGATE: 'navigate'
|
|
314
|
+
ROUTE_CHANGE: 'routechange'
|
|
315
|
+
AFTER_NAVIGATE: 'afternavigate'
|
|
316
|
+
BEFORE_RENDER: 'beforerender'
|
|
317
|
+
AFTER_RENDER: 'afterrender'
|
|
318
|
+
ERROR: 'error'
|
|
319
|
+
NOT_FOUND: 'notfound'
|
|
320
|
+
LOADING: 'loading'
|
|
321
|
+
READY: 'ready'
|
|
322
|
+
DESTROY: 'destroy'
|
|
323
|
+
LINK_CLICK: 'linkclick'
|
|
324
|
+
PLUGIN_INSTALL: 'plugininstall'
|
|
325
|
+
PLUGIN_EJECT: 'plugineject'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const DEFAULT_CONFIG: SafaRouterOptions
|
|
329
|
+
|
|
330
|
+
// ─── Errors ─────────────────────────────────────────
|
|
331
|
+
export class SafaError extends Error {
|
|
332
|
+
code: string
|
|
333
|
+
toJSON(): { name: string; message: string; code: string }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export class RouteNotFoundError extends SafaError {
|
|
337
|
+
pathname: string
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export class NavigationError extends SafaError {
|
|
341
|
+
pathname: string
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export class RouteLoadError extends SafaError {
|
|
345
|
+
pathname: string
|
|
346
|
+
original: Error
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export class NavigationAbortError extends SafaError {}
|
|
350
|
+
}
|
package/src/Link.js
CHANGED
|
@@ -73,7 +73,7 @@ export class Link {
|
|
|
73
73
|
const cur = this._router.pathname
|
|
74
74
|
const active =
|
|
75
75
|
cur === this._href ||
|
|
76
|
-
(this._href !== '/' && cur.startsWith(this._href))
|
|
76
|
+
(this._href !== '/' && (cur.startsWith(this._href + '/') || cur === this._href))
|
|
77
77
|
this._el.classList.toggle(this._activeClass, active)
|
|
78
78
|
}
|
|
79
79
|
|
package/src/MiddlewareChain.js
CHANGED
|
@@ -3,14 +3,22 @@ export class MiddlewareChain {
|
|
|
3
3
|
this._stack = []
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
use(fn) {
|
|
6
|
+
use(fn, priority = 0) {
|
|
7
7
|
if (typeof fn !== 'function') {
|
|
8
8
|
throw new Error('Middleware must be a function')
|
|
9
9
|
}
|
|
10
|
+
fn._priority = priority
|
|
11
|
+
fn._name = fn.name || ''
|
|
10
12
|
this._stack.push(fn)
|
|
13
|
+
this._stack.sort((a, b) => (b._priority || 0) - (a._priority || 0))
|
|
11
14
|
return this
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
useNamed(name, fn, priority = 0) {
|
|
18
|
+
fn._name = name
|
|
19
|
+
return this.use(fn, priority)
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
async run(ctx) {
|
|
15
23
|
let i = 0
|
|
16
24
|
const next = async () => {
|
|
@@ -31,17 +39,43 @@ export class MiddlewareChain {
|
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
remove(fn) {
|
|
34
|
-
|
|
42
|
+
if (typeof fn === 'string') {
|
|
43
|
+
this._stack = this._stack.filter(f => f._name !== fn)
|
|
44
|
+
} else {
|
|
45
|
+
this._stack = this._stack.filter(f => f !== fn)
|
|
46
|
+
}
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
insertBefore(refName, fn, priority = 0) {
|
|
51
|
+
const idx = this._stack.findIndex(f => f._name === refName)
|
|
52
|
+
if (idx === -1) { this.use(fn, priority); return this }
|
|
53
|
+
fn._priority = priority
|
|
54
|
+
fn._name = fn.name || ''
|
|
55
|
+
this._stack.splice(idx, 0, fn)
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
insertAfter(refName, fn, priority = 0) {
|
|
60
|
+
const idx = this._stack.findIndex(f => f._name === refName)
|
|
61
|
+
if (idx === -1) { this.use(fn, priority); return this }
|
|
62
|
+
fn._priority = priority
|
|
63
|
+
fn._name = fn.name || ''
|
|
64
|
+
this._stack.splice(idx + 1, 0, fn)
|
|
35
65
|
return this
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
clone() {
|
|
39
69
|
const chain = new MiddlewareChain()
|
|
40
|
-
chain._stack =
|
|
70
|
+
chain._stack = this._stack.map(f => f)
|
|
41
71
|
return chain
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
get length() {
|
|
45
75
|
return this._stack.length
|
|
46
76
|
}
|
|
77
|
+
|
|
78
|
+
get stack() {
|
|
79
|
+
return this._stack.map(f => ({ name: f._name, priority: f._priority || 0 }))
|
|
80
|
+
}
|
|
47
81
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export class PluginManager {
|
|
2
|
+
constructor(router) {
|
|
3
|
+
this._router = router
|
|
4
|
+
this._plugins = new Map()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
use(plugin) {
|
|
8
|
+
if (!plugin || typeof plugin !== 'object') {
|
|
9
|
+
throw new Error('Plugin must be an object with a name property')
|
|
10
|
+
}
|
|
11
|
+
if (!plugin.name) {
|
|
12
|
+
throw new Error('Plugin must have a name')
|
|
13
|
+
}
|
|
14
|
+
if (this._plugins.has(plugin.name)) {
|
|
15
|
+
console.warn(`[SafaRouter] Plugin "${plugin.name}" already registered, skipping`)
|
|
16
|
+
return this
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const wrapped = { ...plugin, _cleanup: [] }
|
|
20
|
+
|
|
21
|
+
if (typeof plugin.install === 'function') {
|
|
22
|
+
const result = plugin.install(this._router)
|
|
23
|
+
if (result && typeof result.then === 'function') {
|
|
24
|
+
result.then(cleanup => {
|
|
25
|
+
if (typeof cleanup === 'function') wrapped._cleanup.push(cleanup)
|
|
26
|
+
})
|
|
27
|
+
} else if (typeof result === 'function') {
|
|
28
|
+
wrapped._cleanup.push(result)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof plugin.onBeforeNavigate === 'function') {
|
|
33
|
+
const unsub = this._router.onBeforeNavigate(plugin.onBeforeNavigate)
|
|
34
|
+
wrapped._cleanup.push(unsub)
|
|
35
|
+
}
|
|
36
|
+
if (typeof plugin.onAfterNavigate === 'function') {
|
|
37
|
+
const unsub = this._router.afterEach(plugin.onAfterNavigate)
|
|
38
|
+
wrapped._cleanup.push(unsub)
|
|
39
|
+
}
|
|
40
|
+
if (typeof plugin.onRouteChange === 'function') {
|
|
41
|
+
const unsub = this._router.onRouteChange(plugin.onRouteChange)
|
|
42
|
+
wrapped._cleanup.push(unsub)
|
|
43
|
+
}
|
|
44
|
+
if (typeof plugin.onError === 'function') {
|
|
45
|
+
const unsub = this._router.onError(plugin.onError)
|
|
46
|
+
wrapped._cleanup.push(unsub)
|
|
47
|
+
}
|
|
48
|
+
if (typeof plugin.middleware === 'function') {
|
|
49
|
+
this._router.use(plugin.middleware)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this._plugins.set(plugin.name, wrapped)
|
|
53
|
+
return this
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
eject(name) {
|
|
57
|
+
const plugin = this._plugins.get(name)
|
|
58
|
+
if (!plugin) return false
|
|
59
|
+
for (const cleanup of plugin._cleanup) {
|
|
60
|
+
try { cleanup() } catch {}
|
|
61
|
+
}
|
|
62
|
+
this._plugins.delete(name)
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(name) {
|
|
67
|
+
return this._plugins.get(name) || null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list() {
|
|
71
|
+
return Array.from(this._plugins.keys())
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
has(name) {
|
|
75
|
+
return this._plugins.has(name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ejectAll() {
|
|
79
|
+
for (const name of this._plugins.keys()) {
|
|
80
|
+
this.eject(name)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get count() {
|
|
85
|
+
return this._plugins.size
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/RouteMatcher.js
CHANGED
|
@@ -100,9 +100,9 @@ class RoutePattern {
|
|
|
100
100
|
let path = this.raw
|
|
101
101
|
for (const [key, val] of Object.entries(params)) {
|
|
102
102
|
const v = Array.isArray(val) ? val.join('/') : String(val)
|
|
103
|
-
path = path.
|
|
104
|
-
path = path.
|
|
105
|
-
path = path.
|
|
103
|
+
path = path.replaceAll(`[[...${key}]]`, v)
|
|
104
|
+
path = path.replaceAll(`[...${key}]`, v)
|
|
105
|
+
path = path.replaceAll(`[${key}]`, v)
|
|
106
106
|
}
|
|
107
107
|
return normalizePath(path)
|
|
108
108
|
}
|
package/src/RouteTree.js
CHANGED
|
@@ -80,8 +80,7 @@ export class RouteTree {
|
|
|
80
80
|
|
|
81
81
|
for (const [key, val] of Object.entries(routes)) {
|
|
82
82
|
const isGroup = isRouteGroup(key)
|
|
83
|
-
const
|
|
84
|
-
const isRoot = fp === '/' || key === '/'
|
|
83
|
+
const isRoot = key === '/' || key === ''
|
|
85
84
|
|
|
86
85
|
if (isRoot) {
|
|
87
86
|
if (typeof val === 'object' && val !== null) {
|
|
@@ -91,29 +90,45 @@ export class RouteTree {
|
|
|
91
90
|
if (val.loading) parent.loading = val.loading
|
|
92
91
|
if (val.error) parent.error = val.error
|
|
93
92
|
if (val.notFound) parent.notFound = val.notFound
|
|
94
|
-
if (val.children) this._build(parent, val.children,
|
|
93
|
+
if (val.children) this._build(parent, val.children, base)
|
|
95
94
|
} else if (typeof val === 'function') {
|
|
96
95
|
parent.page = val
|
|
97
96
|
}
|
|
98
97
|
continue
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
100
|
+
const segs = (isGroup ? key : key.replace(/^\//, '')).split('/')
|
|
101
|
+
let cursor = parent
|
|
102
|
+
let currentBase = base
|
|
103
|
+
|
|
104
|
+
for (let si = 0; si < segs.length; si++) {
|
|
105
|
+
const seg = segs[si]
|
|
106
|
+
const isLast = si === segs.length - 1
|
|
107
|
+
const childBase = isGroup ? currentBase : normalizePath(`${currentBase}/${seg}`)
|
|
108
|
+
|
|
109
|
+
let child = cursor._findChild(seg)
|
|
110
|
+
if (!child) {
|
|
111
|
+
child = new RouteNode({ segment: seg, fullPath: childBase })
|
|
112
|
+
cursor.addChild(child)
|
|
113
|
+
}
|
|
115
114
|
|
|
116
|
-
|
|
115
|
+
if (isLast) {
|
|
116
|
+
if (typeof val === 'object' && val !== null) {
|
|
117
|
+
if (val.meta) child.meta = val.meta
|
|
118
|
+
if (val.layout) child.layout = val.layout
|
|
119
|
+
if (val.page) child.page = val.page
|
|
120
|
+
if (val.loading) child.loading = val.loading
|
|
121
|
+
if (val.error) child.error = val.error
|
|
122
|
+
if (val.notFound) child.notFound = val.notFound
|
|
123
|
+
if (val.children) this._build(child, val.children, childBase)
|
|
124
|
+
} else if (typeof val === 'function') {
|
|
125
|
+
child.page = val
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cursor = child
|
|
130
|
+
currentBase = childBase
|
|
131
|
+
}
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
134
|
|
package/src/SafaRouter.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { RouteMatcher } from './RouteMatcher.js'
|
|
2
1
|
import { RouteTree } from './RouteTree.js'
|
|
3
2
|
import { HistoryManager } from './HistoryManager.js'
|
|
4
3
|
import { MiddlewareChain } from './MiddlewareChain.js'
|
|
5
4
|
import { Link } from './Link.js'
|
|
6
|
-
import {
|
|
5
|
+
import { PluginManager } from './PluginManager.js'
|
|
6
|
+
import { TransitionsManager } from './TransitionsManager.js'
|
|
7
|
+
import { ScrollManager } from './ScrollManager.js'
|
|
8
|
+
import { normalizePath, parseQuery, emit, createURL, isExternalURL } from './utils.js'
|
|
7
9
|
import { EVENTS, DEFAULT_CONFIG } from './constants.js'
|
|
8
10
|
import { RouteLoadError, SafaError } from './errors.js'
|
|
9
11
|
|
|
10
12
|
export class SafaRouter {
|
|
11
|
-
static version = '1.
|
|
12
|
-
static VERSION = '1.
|
|
13
|
+
static version = '1.2.0'
|
|
14
|
+
static VERSION = '1.2.0'
|
|
13
15
|
|
|
14
16
|
constructor(options = {}) {
|
|
15
17
|
this.config = { ...DEFAULT_CONFIG, ...options }
|
|
@@ -20,14 +22,13 @@ export class SafaRouter {
|
|
|
20
22
|
this._events = {}
|
|
21
23
|
for (const key of Object.values(EVENTS)) this._events[key] = []
|
|
22
24
|
|
|
23
|
-
this._matcher = new RouteMatcher()
|
|
24
25
|
this._history = new HistoryManager({
|
|
25
26
|
useHash: this.config.useHash,
|
|
26
27
|
basePath: this.config.basePath,
|
|
27
28
|
})
|
|
28
29
|
this._middleware = new MiddlewareChain()
|
|
29
30
|
this._cache = new Map()
|
|
30
|
-
this.
|
|
31
|
+
this._scrollManager = new ScrollManager()
|
|
31
32
|
|
|
32
33
|
this._pathname = '/'
|
|
33
34
|
this._params = {}
|
|
@@ -36,12 +37,22 @@ export class SafaRouter {
|
|
|
36
37
|
this._isLoading = false
|
|
37
38
|
this._started = false
|
|
38
39
|
this._targetEl = null
|
|
40
|
+
this._navId = 0
|
|
39
41
|
|
|
40
42
|
this._globalNotFound = this.config.notFound || null
|
|
41
43
|
this._globalError = this.config.error || null
|
|
42
44
|
this._globalLayout = this.config.layout || null
|
|
43
45
|
this._customTitle = null
|
|
44
46
|
|
|
47
|
+
this._transitions = new TransitionsManager({
|
|
48
|
+
transitionDuration: this.config.transitionDuration,
|
|
49
|
+
transitionEnterClass: this.config.transitionEnterClass,
|
|
50
|
+
transitionExitClass: this.config.transitionExitClass,
|
|
51
|
+
transitionEnterActiveClass: this.config.transitionEnterActiveClass,
|
|
52
|
+
transitionExitActiveClass: this.config.transitionExitActiveClass,
|
|
53
|
+
})
|
|
54
|
+
this._plugins = new PluginManager(this)
|
|
55
|
+
|
|
45
56
|
this._boundNav = this._onHistoryChange.bind(this)
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -62,9 +73,15 @@ export class SafaRouter {
|
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
this._routeTree = new RouteTree(this.config.routes || {})
|
|
65
|
-
this._seedMatcher()
|
|
66
76
|
this._history.init()
|
|
67
77
|
this._unsubHistory = this._history.onChange(this._boundNav)
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(this.config.plugins)) {
|
|
80
|
+
for (const plugin of this.config.plugins) {
|
|
81
|
+
this._plugins.use(plugin)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
await this._resolve(this._history.path, 'replace')
|
|
69
86
|
emit(this._events, EVENTS.READY, { pathname: this._pathname })
|
|
70
87
|
this._started = true
|
|
@@ -75,6 +92,7 @@ export class SafaRouter {
|
|
|
75
92
|
|
|
76
93
|
destroy() {
|
|
77
94
|
this._started = false
|
|
95
|
+
this._plugins.ejectAll()
|
|
78
96
|
if (this._unsubHistory) {
|
|
79
97
|
this._unsubHistory()
|
|
80
98
|
this._unsubHistory = null
|
|
@@ -82,6 +100,7 @@ export class SafaRouter {
|
|
|
82
100
|
this._history.destroy()
|
|
83
101
|
for (const k of Object.keys(this._events)) this._events[k] = []
|
|
84
102
|
this._cache.clear()
|
|
103
|
+
this._scrollManager.clear()
|
|
85
104
|
this._targetEl = null
|
|
86
105
|
emit(this._events, EVENTS.DESTROY, {})
|
|
87
106
|
}
|
|
@@ -98,6 +117,15 @@ export class SafaRouter {
|
|
|
98
117
|
await this._navigate(normalizePath(u.pathname), 'replace', parseQuery(u.search), state)
|
|
99
118
|
}
|
|
100
119
|
|
|
120
|
+
async pushRoute(routeName, params = {}, query = {}) {
|
|
121
|
+
const routes = this.config.routes || {}
|
|
122
|
+
const flat = this._routeTree.flatten()
|
|
123
|
+
const matched = flat.find(r => r.meta?.name === routeName || r.path === routeName)
|
|
124
|
+
if (!matched) throw new SafaError(`Route "${routeName}" not found`, 'ROUTE_NOT_FOUND')
|
|
125
|
+
const qs = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : ''
|
|
126
|
+
return this.push(matched.path + qs)
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
back() { this._history.back() }
|
|
102
130
|
forward() { this._history.forward() }
|
|
103
131
|
|
|
@@ -134,6 +162,33 @@ export class SafaRouter {
|
|
|
134
162
|
|
|
135
163
|
createLink(config) { return new Link({ ...config, router: this }) }
|
|
136
164
|
|
|
165
|
+
plugin(plugin) { return this._plugins.use(plugin) }
|
|
166
|
+
|
|
167
|
+
ejectPlugin(name) { return this._plugins.eject(name) }
|
|
168
|
+
|
|
169
|
+
getPlugin(name) { return this._plugins.get(name) }
|
|
170
|
+
|
|
171
|
+
get plugins() { return this._plugins.list() }
|
|
172
|
+
|
|
173
|
+
useNamed(name, fn, priority = 0) {
|
|
174
|
+
this._middleware.useNamed(name, fn, priority)
|
|
175
|
+
return this
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
middleware(name) {
|
|
179
|
+
return this._middleware.remove(name)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
insertMiddlewareBefore(refName, fn, priority = 0) {
|
|
183
|
+
this._middleware.insertBefore(refName, fn, priority)
|
|
184
|
+
return this
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
insertMiddlewareAfter(refName, fn, priority = 0) {
|
|
188
|
+
this._middleware.insertAfter(refName, fn, priority)
|
|
189
|
+
return this
|
|
190
|
+
}
|
|
191
|
+
|
|
137
192
|
async prefetch(path) {
|
|
138
193
|
const normalized = normalizePath(path)
|
|
139
194
|
if (this._cache.has(normalized)) return
|
|
@@ -145,23 +200,6 @@ export class SafaRouter {
|
|
|
145
200
|
|
|
146
201
|
clearCache() { this._cache.clear() }
|
|
147
202
|
|
|
148
|
-
_seedMatcher() {
|
|
149
|
-
const walk = (routes, base) => {
|
|
150
|
-
if (!routes || typeof routes !== 'object') return
|
|
151
|
-
for (const [key, val] of Object.entries(routes)) {
|
|
152
|
-
const isGroup = key.startsWith('(') && key.endsWith(')')
|
|
153
|
-
const fp = isGroup ? base : base === '/' ? `/${key}` : `${base}/${key}`
|
|
154
|
-
if (typeof val === 'object' && val !== null) {
|
|
155
|
-
if (val.page) this._matcher.add(fp)
|
|
156
|
-
if (val.children) walk(val.children, fp)
|
|
157
|
-
} else if (typeof val === 'function') {
|
|
158
|
-
this._matcher.add(fp)
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
walk(this.config.routes || {}, '/')
|
|
163
|
-
}
|
|
164
|
-
|
|
165
203
|
_resolvePagePath(path) {
|
|
166
204
|
const dir = (this.config.pagesDir || '').replace(/\/+$/, '')
|
|
167
205
|
if (!dir) return null
|
|
@@ -236,12 +274,14 @@ export class SafaRouter {
|
|
|
236
274
|
return html.replace(/\{\s*children\s*\}/gi, children || '')
|
|
237
275
|
}
|
|
238
276
|
|
|
239
|
-
async _navigate(path, method, query = {}, state = {}) {
|
|
240
|
-
if (
|
|
241
|
-
|
|
277
|
+
async _navigate(path, method, query = {}, state = {}, depth = 0) {
|
|
278
|
+
if (depth > 10) { console.error('[SafaRouter] Redirect loop detected'); return }
|
|
279
|
+
if (path === this._pathname && depth === 0) return
|
|
280
|
+
await this._resolve(path, method, query, state, depth)
|
|
242
281
|
}
|
|
243
282
|
|
|
244
|
-
async _resolve(path, method, query = {}, state = {}) {
|
|
283
|
+
async _resolve(path, method, query = {}, state = {}, depth = 0) {
|
|
284
|
+
const navId = ++this._navId
|
|
245
285
|
this._isLoading = true
|
|
246
286
|
this._customTitle = null
|
|
247
287
|
emit(this._events, EVENTS.LOADING, { path, loading: true })
|
|
@@ -250,7 +290,8 @@ export class SafaRouter {
|
|
|
250
290
|
try {
|
|
251
291
|
const ctx = { path, method, query, cancelled: false, redirect: null }
|
|
252
292
|
await this._middleware.run(ctx)
|
|
253
|
-
if (
|
|
293
|
+
if (this._navId !== navId) return
|
|
294
|
+
if (ctx.redirect) return this._navigate(ctx.redirect, 'replace', {}, {}, depth + 1)
|
|
254
295
|
if (ctx.cancelled) { this._isLoading = false; return }
|
|
255
296
|
|
|
256
297
|
const routeMatch = this._hasRoutes() ? this._routeTree.resolve(path) : null
|
|
@@ -315,7 +356,9 @@ export class SafaRouter {
|
|
|
315
356
|
return
|
|
316
357
|
}
|
|
317
358
|
|
|
318
|
-
this.
|
|
359
|
+
if (this._navId !== navId) return
|
|
360
|
+
|
|
361
|
+
this._scrollManager.save(this._pathname)
|
|
319
362
|
|
|
320
363
|
if (method === 'push') this._history.push(path, state)
|
|
321
364
|
else if (method === 'replace') this._history.replace(path, state)
|
|
@@ -325,16 +368,18 @@ export class SafaRouter {
|
|
|
325
368
|
this._query = query
|
|
326
369
|
this._isLoading = false
|
|
327
370
|
|
|
328
|
-
this.
|
|
371
|
+
emit(this._events, EVENTS.BEFORE_RENDER, { pathname: path })
|
|
372
|
+
await this._render(pageContent, layoutFns)
|
|
373
|
+
emit(this._events, EVENTS.AFTER_RENDER, { pathname: path })
|
|
374
|
+
|
|
375
|
+
this._scrollManager.restore(this._pathname, this.config.scrollToTop)
|
|
376
|
+
this._updateTitle()
|
|
377
|
+
this._focus()
|
|
329
378
|
|
|
330
379
|
emit(this._events, EVENTS.ROUTE_CHANGE, {
|
|
331
380
|
pathname: path, params: this._params, query: this._query,
|
|
332
381
|
})
|
|
333
382
|
emit(this._events, EVENTS.AFTER_NAVIGATE, { pathname: path })
|
|
334
|
-
|
|
335
|
-
this._updateTitle()
|
|
336
|
-
this._restoreScroll()
|
|
337
|
-
this._focus()
|
|
338
383
|
} catch (err) {
|
|
339
384
|
this._isLoading = false
|
|
340
385
|
await this._handleError(path, err)
|
|
@@ -350,15 +395,14 @@ export class SafaRouter {
|
|
|
350
395
|
? pageContent({ params: this._params, query: this._query, router: this })
|
|
351
396
|
: (pageContent || ''))
|
|
352
397
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
requestAnimationFrame(() => { this._targetEl.style.opacity = '1' })
|
|
398
|
+
if (this.config.transitionDuration > 0) {
|
|
399
|
+
await this._transitions.run(this._targetEl, async () => {
|
|
400
|
+
this._targetEl.innerHTML = html
|
|
401
|
+
this._bindLinks()
|
|
402
|
+
})
|
|
403
|
+
} else {
|
|
404
|
+
this._targetEl.innerHTML = html
|
|
405
|
+
this._bindLinks()
|
|
362
406
|
}
|
|
363
407
|
}
|
|
364
408
|
|
|
@@ -374,21 +418,6 @@ export class SafaRouter {
|
|
|
374
418
|
return layoutFn({ children: content, params: this._params, router: this })
|
|
375
419
|
}
|
|
376
420
|
|
|
377
|
-
_saveScroll() {
|
|
378
|
-
this._scrollMemory.set(this._pathname, window.scrollY)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
_restoreScroll() {
|
|
382
|
-
if (this.config.scrollToTop) {
|
|
383
|
-
window.scrollTo({ top: 0 })
|
|
384
|
-
return
|
|
385
|
-
}
|
|
386
|
-
const saved = this._scrollMemory.get(this._pathname)
|
|
387
|
-
if (saved !== undefined) {
|
|
388
|
-
window.scrollTo(0, saved)
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
421
|
_updateTitle() {
|
|
393
422
|
const template = this.config.titleTemplate
|
|
394
423
|
if (!template) return
|
|
@@ -429,11 +458,20 @@ export class SafaRouter {
|
|
|
429
458
|
if (!mod) return null
|
|
430
459
|
try {
|
|
431
460
|
if (typeof mod === 'string') {
|
|
461
|
+
if (mod.endsWith('.js') || mod.endsWith('.mjs')) {
|
|
462
|
+
try {
|
|
463
|
+
const resolved = await import(mod)
|
|
464
|
+
const page = resolved.default || resolved
|
|
465
|
+
return page
|
|
466
|
+
} catch {
|
|
467
|
+
// fall through to fetch
|
|
468
|
+
}
|
|
469
|
+
}
|
|
432
470
|
let url = mod
|
|
433
471
|
if (this._params && Object.keys(this._params).length > 0) {
|
|
434
472
|
for (const [k, v] of Object.entries(this._params)) {
|
|
435
473
|
const val = Array.isArray(v) ? v.join('/') : String(v)
|
|
436
|
-
url = url.
|
|
474
|
+
url = url.replaceAll(`[${k}]`, val)
|
|
437
475
|
}
|
|
438
476
|
}
|
|
439
477
|
const res = await fetch(url)
|
|
@@ -442,15 +480,7 @@ export class SafaRouter {
|
|
|
442
480
|
this._extractTitle(text)
|
|
443
481
|
return text
|
|
444
482
|
}
|
|
445
|
-
if (typeof mod === 'function')
|
|
446
|
-
let result
|
|
447
|
-
try { result = mod() } catch { return mod }
|
|
448
|
-
if (result && typeof result.then === 'function') {
|
|
449
|
-
const resolved = await result
|
|
450
|
-
return resolved && resolved.default ? resolved.default : resolved
|
|
451
|
-
}
|
|
452
|
-
return result !== undefined ? result : mod
|
|
453
|
-
}
|
|
483
|
+
if (typeof mod === 'function') return mod
|
|
454
484
|
return mod
|
|
455
485
|
} catch (e) {
|
|
456
486
|
throw new RouteLoadError(
|
|
@@ -538,7 +568,6 @@ export class SafaRouter {
|
|
|
538
568
|
}
|
|
539
569
|
|
|
540
570
|
get currentRoute() { return this._routeData }
|
|
541
|
-
get matchedRoute() { return this._matcher.match(this._pathname) }
|
|
542
571
|
|
|
543
572
|
getRoute(path) { return this._routeTree.resolve(normalizePath(path)) }
|
|
544
573
|
|
|
@@ -546,7 +575,7 @@ export class SafaRouter {
|
|
|
546
575
|
if (action === 'popstate') {
|
|
547
576
|
this._resolve(path, 'replace')
|
|
548
577
|
if (state && state._scrollY !== undefined && !this.config.scrollToTop) {
|
|
549
|
-
window.scrollTo(0, state._scrollY)
|
|
578
|
+
requestAnimationFrame(() => window.scrollTo(0, state._scrollY))
|
|
550
579
|
}
|
|
551
580
|
}
|
|
552
581
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class ScrollManager {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._memory = new Map()
|
|
4
|
+
this._observers = new Map()
|
|
5
|
+
this._elementScroll = new Map()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
save(pathname) {
|
|
9
|
+
this._memory.set(pathname, window.scrollY)
|
|
10
|
+
for (const [el, rect] of this._elementScroll) {
|
|
11
|
+
const key = `${pathname}::${el}`
|
|
12
|
+
this._memory.set(key, rect)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
restore(pathname, scrollToTop = false) {
|
|
17
|
+
if (scrollToTop) {
|
|
18
|
+
window.scrollTo({ top: 0, behavior: 'instant' })
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const saved = this._memory.get(pathname)
|
|
22
|
+
if (saved !== undefined && typeof saved === 'number') {
|
|
23
|
+
requestAnimationFrame(() => window.scrollTo(0, saved))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
trackScrollElement(el) {
|
|
28
|
+
if (this._observers.has(el)) return
|
|
29
|
+
let ticking = false
|
|
30
|
+
const handler = () => {
|
|
31
|
+
if (!ticking) {
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
this._elementScroll.set(el, el.scrollTop)
|
|
34
|
+
ticking = false
|
|
35
|
+
})
|
|
36
|
+
ticking = true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
el.addEventListener('scroll', handler, { passive: true })
|
|
40
|
+
this._observers.set(el, handler)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
untrackScrollElement(el) {
|
|
44
|
+
const handler = this._observers.get(el)
|
|
45
|
+
if (handler) {
|
|
46
|
+
el.removeEventListener('scroll', handler)
|
|
47
|
+
this._observers.delete(el)
|
|
48
|
+
this._elementScroll.delete(el)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
restoreElementScroll(pathname, container) {
|
|
53
|
+
const key = `${pathname}::${container}`
|
|
54
|
+
const saved = this._memory.get(key)
|
|
55
|
+
if (saved !== undefined) {
|
|
56
|
+
container.scrollTop = saved
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clear() {
|
|
61
|
+
this._memory.clear()
|
|
62
|
+
this._elementScroll.clear()
|
|
63
|
+
for (const [el, handler] of this._observers) {
|
|
64
|
+
el.removeEventListener('scroll', handler)
|
|
65
|
+
}
|
|
66
|
+
this._observers.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
has(pathname) {
|
|
70
|
+
return this._memory.has(pathname)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get size() {
|
|
74
|
+
return this._memory.size
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export class TransitionsManager {
|
|
2
|
+
constructor(config = {}) {
|
|
3
|
+
this._duration = config.transitionDuration || 0
|
|
4
|
+
this._enterClass = config.transitionEnterClass || 'page-enter'
|
|
5
|
+
this._exitClass = config.transitionExitClass || 'page-exit'
|
|
6
|
+
this._enterActiveClass = config.transitionEnterActiveClass || 'page-enter-active'
|
|
7
|
+
this._exitActiveClass = config.transitionExitActiveClass || 'page-exit-active'
|
|
8
|
+
this._custom = config.transitionCustom || null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async run(el, renderFn) {
|
|
12
|
+
if (this._duration <= 0 && !this._custom) {
|
|
13
|
+
return renderFn()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
await this._exit(el)
|
|
17
|
+
await renderFn()
|
|
18
|
+
await this._enter(el)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async _exit(el) {
|
|
22
|
+
if (!el || this._duration <= 0) return
|
|
23
|
+
const { promise, resolve } = this._createDeferred()
|
|
24
|
+
el.classList.add(this._exitClass)
|
|
25
|
+
el.classList.add(this._exitActiveClass)
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
el.classList.remove(this._exitClass)
|
|
28
|
+
el.classList.remove(this._exitActiveClass)
|
|
29
|
+
resolve()
|
|
30
|
+
}, this._duration)
|
|
31
|
+
promise._timer = timer
|
|
32
|
+
return promise
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async _enter(el) {
|
|
36
|
+
if (!el || this._duration <= 0) return
|
|
37
|
+
const { promise, resolve } = this._createDeferred()
|
|
38
|
+
el.classList.add(this._enterClass)
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
el.classList.add(this._enterActiveClass)
|
|
41
|
+
el.classList.remove(this._enterClass)
|
|
42
|
+
})
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
el.classList.remove(this._enterActiveClass)
|
|
45
|
+
resolve()
|
|
46
|
+
}, this._duration)
|
|
47
|
+
promise._timer = timer
|
|
48
|
+
return promise
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
cancelExit(el) {
|
|
52
|
+
if (!el) return
|
|
53
|
+
el.classList.remove(this._exitClass, this._exitActiveClass)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cancelEnter(el) {
|
|
57
|
+
if (!el) return
|
|
58
|
+
el.classList.remove(this._enterClass, this._enterActiveClass)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_createDeferred() {
|
|
62
|
+
let resolve
|
|
63
|
+
const promise = new Promise(r => { resolve = r })
|
|
64
|
+
return { promise, resolve }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setDuration(ms) {
|
|
68
|
+
this._duration = ms
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get config() {
|
|
72
|
+
return {
|
|
73
|
+
duration: this._duration,
|
|
74
|
+
enterClass: this._enterClass,
|
|
75
|
+
exitClass: this._exitClass,
|
|
76
|
+
enterActiveClass: this._enterActiveClass,
|
|
77
|
+
exitActiveClass: this._exitActiveClass,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/constants.js
CHANGED
|
@@ -4,12 +4,16 @@ export const EVENTS = {
|
|
|
4
4
|
NAVIGATE: 'navigate',
|
|
5
5
|
ROUTE_CHANGE: 'routechange',
|
|
6
6
|
AFTER_NAVIGATE: 'afternavigate',
|
|
7
|
+
BEFORE_RENDER: 'beforerender',
|
|
8
|
+
AFTER_RENDER: 'afterrender',
|
|
7
9
|
ERROR: 'error',
|
|
8
10
|
NOT_FOUND: 'notfound',
|
|
9
11
|
LOADING: 'loading',
|
|
10
12
|
READY: 'ready',
|
|
11
13
|
DESTROY: 'destroy',
|
|
12
14
|
LINK_CLICK: 'linkclick',
|
|
15
|
+
PLUGIN_INSTALL: 'plugininstall',
|
|
16
|
+
PLUGIN_EJECT: 'plugineject',
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export const SEGMENT_TYPES = {
|
|
@@ -23,7 +27,7 @@ export const SEGMENT_TYPES = {
|
|
|
23
27
|
export const PARAM_PATTERNS = {
|
|
24
28
|
DYNAMIC: /^\[([^\]]+)\]$/,
|
|
25
29
|
CATCH_ALL: /^\[\.\.\.([^\]]+)\]$/,
|
|
26
|
-
OPTIONAL_CATCH_ALL: /^\[\[\.\.\.[^\]]
|
|
30
|
+
OPTIONAL_CATCH_ALL: /^\[\[\.\.\.([^\]]+)\]\]$/,
|
|
27
31
|
GROUP: /^\(([^)]+)\)$/,
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -44,4 +48,10 @@ export const DEFAULT_CONFIG = {
|
|
|
44
48
|
cacheRoutes: true,
|
|
45
49
|
titleTemplate: '%s — SafaRouter',
|
|
46
50
|
transitionDuration: 0,
|
|
51
|
+
transitionEnterClass: 'page-enter',
|
|
52
|
+
transitionExitClass: 'page-exit',
|
|
53
|
+
transitionEnterActiveClass: 'page-enter-active',
|
|
54
|
+
transitionExitActiveClass: 'page-exit-active',
|
|
55
|
+
scrollRestoration: 'auto',
|
|
56
|
+
plugins: [],
|
|
47
57
|
}
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,9 @@ export { RouteMatcher } from './RouteMatcher.js'
|
|
|
4
4
|
export { HistoryManager } from './HistoryManager.js'
|
|
5
5
|
export { MiddlewareChain } from './MiddlewareChain.js'
|
|
6
6
|
export { Link } from './Link.js'
|
|
7
|
+
export { PluginManager } from './PluginManager.js'
|
|
8
|
+
export { TransitionsManager } from './TransitionsManager.js'
|
|
9
|
+
export { ScrollManager } from './ScrollManager.js'
|
|
7
10
|
export {
|
|
8
11
|
SafaError,
|
|
9
12
|
RouteNotFoundError,
|
|
@@ -11,6 +14,7 @@ export {
|
|
|
11
14
|
RouteLoadError,
|
|
12
15
|
NavigationAbortError,
|
|
13
16
|
} from './errors.js'
|
|
14
|
-
export { normalizePath, parseQuery, joinPaths, createURL, isExternalURL, isSamePath, isDynamicSegment, isCatchAllSegment, isOptionalCatchAll, isRouteGroupSegment, useRouter } from './utils.js'
|
|
17
|
+
export { normalizePath, parseQuery, joinPaths, createURL, isExternalURL, isSamePath, isDynamicSegment, isCatchAllSegment, isOptionalCatchAll, isRouteGroupSegment, useRouter, debounce } from './utils.js'
|
|
15
18
|
export { bindLinks, prefetchOnHover } from './link-helper.js'
|
|
16
19
|
export { EVENTS, DEFAULT_CONFIG } from './constants.js'
|
|
20
|
+
export { matchRoute, matchPattern, renderRoute, routeExists, listRoutes } from './ssr.js'
|
package/src/ssr.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { RouteTree } from './RouteTree.js'
|
|
2
|
+
import { RouteMatcher } from './RouteMatcher.js'
|
|
3
|
+
import { normalizePath, parseQuery } from './utils.js'
|
|
4
|
+
|
|
5
|
+
export function matchRoute(path, routes = {}) {
|
|
6
|
+
const tree = new RouteTree(routes)
|
|
7
|
+
const normalized = normalizePath(path)
|
|
8
|
+
const [pathname, search] = normalized.split('?')
|
|
9
|
+
const match = tree.resolve(pathname)
|
|
10
|
+
if (!match) return null
|
|
11
|
+
return {
|
|
12
|
+
path: pathname,
|
|
13
|
+
params: match.params,
|
|
14
|
+
query: parseQuery(search ? `?${search}` : ''),
|
|
15
|
+
node: match.node,
|
|
16
|
+
layouts: match.layouts,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function matchPattern(path, patterns = []) {
|
|
21
|
+
const matcher = RouteMatcher.create(patterns)
|
|
22
|
+
return matcher.match(normalizePath(path))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderRoute(path, routes = {}, context = {}) {
|
|
26
|
+
const match = matchRoute(path, routes)
|
|
27
|
+
if (!match) return null
|
|
28
|
+
|
|
29
|
+
async function renderComponent(mod) {
|
|
30
|
+
if (!mod) return ''
|
|
31
|
+
if (typeof mod === 'function') {
|
|
32
|
+
const result = mod({ params: match.params, router: context.router })
|
|
33
|
+
if (result && typeof result.then === 'function') return result
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
return String(mod)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function renderWithLayouts(pageContent, layoutFns, idx) {
|
|
40
|
+
if (idx >= layoutFns.length) {
|
|
41
|
+
return renderComponent(pageContent)
|
|
42
|
+
}
|
|
43
|
+
const content = await renderWithLayouts(pageContent, layoutFns, idx + 1)
|
|
44
|
+
const layoutFn = layoutFns[idx]
|
|
45
|
+
if (typeof layoutFn === 'function') {
|
|
46
|
+
return layoutFn({ children: content, params: match.params, router: context.router })
|
|
47
|
+
}
|
|
48
|
+
return String(layoutFn).replace(/\{\s*children\s*\}/gi, content)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return renderWithLayouts(match.node.page, match.layouts, 0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function routeExists(path, routes = {}) {
|
|
55
|
+
return matchRoute(path, routes) !== null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listRoutes(routes = {}) {
|
|
59
|
+
const tree = new RouteTree(routes)
|
|
60
|
+
return tree.flatten()
|
|
61
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -13,7 +13,8 @@ export function parseQuery(search) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function joinPaths(...parts) {
|
|
16
|
-
|
|
16
|
+
const joined = parts.filter(Boolean).join('/')
|
|
17
|
+
return joined.startsWith('/') ? normalizePath(joined) : '/' + normalizePath(joined)
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function isDynamicSegment(segment) {
|