safa-router 1.1.2 → 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/MiddlewareChain.js +37 -3
- package/src/PluginManager.js +87 -0
- package/src/RouteTree.js +33 -18
- package/src/SafaRouter.js +85 -33
- package/src/ScrollManager.js +76 -0
- package/src/TransitionsManager.js +80 -0
- package/src/constants.js +10 -0
- 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/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/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
|
@@ -2,13 +2,16 @@ import { RouteTree } from './RouteTree.js'
|
|
|
2
2
|
import { HistoryManager } from './HistoryManager.js'
|
|
3
3
|
import { MiddlewareChain } from './MiddlewareChain.js'
|
|
4
4
|
import { Link } from './Link.js'
|
|
5
|
-
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'
|
|
6
9
|
import { EVENTS, DEFAULT_CONFIG } from './constants.js'
|
|
7
10
|
import { RouteLoadError, SafaError } from './errors.js'
|
|
8
11
|
|
|
9
12
|
export class SafaRouter {
|
|
10
|
-
static version = '1.
|
|
11
|
-
static VERSION = '1.
|
|
13
|
+
static version = '1.2.0'
|
|
14
|
+
static VERSION = '1.2.0'
|
|
12
15
|
|
|
13
16
|
constructor(options = {}) {
|
|
14
17
|
this.config = { ...DEFAULT_CONFIG, ...options }
|
|
@@ -25,7 +28,7 @@ export class SafaRouter {
|
|
|
25
28
|
})
|
|
26
29
|
this._middleware = new MiddlewareChain()
|
|
27
30
|
this._cache = new Map()
|
|
28
|
-
this.
|
|
31
|
+
this._scrollManager = new ScrollManager()
|
|
29
32
|
|
|
30
33
|
this._pathname = '/'
|
|
31
34
|
this._params = {}
|
|
@@ -41,6 +44,15 @@ export class SafaRouter {
|
|
|
41
44
|
this._globalLayout = this.config.layout || null
|
|
42
45
|
this._customTitle = null
|
|
43
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
|
+
|
|
44
56
|
this._boundNav = this._onHistoryChange.bind(this)
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -63,6 +75,13 @@ export class SafaRouter {
|
|
|
63
75
|
this._routeTree = new RouteTree(this.config.routes || {})
|
|
64
76
|
this._history.init()
|
|
65
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
|
+
|
|
66
85
|
await this._resolve(this._history.path, 'replace')
|
|
67
86
|
emit(this._events, EVENTS.READY, { pathname: this._pathname })
|
|
68
87
|
this._started = true
|
|
@@ -73,6 +92,7 @@ export class SafaRouter {
|
|
|
73
92
|
|
|
74
93
|
destroy() {
|
|
75
94
|
this._started = false
|
|
95
|
+
this._plugins.ejectAll()
|
|
76
96
|
if (this._unsubHistory) {
|
|
77
97
|
this._unsubHistory()
|
|
78
98
|
this._unsubHistory = null
|
|
@@ -80,6 +100,7 @@ export class SafaRouter {
|
|
|
80
100
|
this._history.destroy()
|
|
81
101
|
for (const k of Object.keys(this._events)) this._events[k] = []
|
|
82
102
|
this._cache.clear()
|
|
103
|
+
this._scrollManager.clear()
|
|
83
104
|
this._targetEl = null
|
|
84
105
|
emit(this._events, EVENTS.DESTROY, {})
|
|
85
106
|
}
|
|
@@ -96,6 +117,15 @@ export class SafaRouter {
|
|
|
96
117
|
await this._navigate(normalizePath(u.pathname), 'replace', parseQuery(u.search), state)
|
|
97
118
|
}
|
|
98
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
|
+
|
|
99
129
|
back() { this._history.back() }
|
|
100
130
|
forward() { this._history.forward() }
|
|
101
131
|
|
|
@@ -132,6 +162,33 @@ export class SafaRouter {
|
|
|
132
162
|
|
|
133
163
|
createLink(config) { return new Link({ ...config, router: this }) }
|
|
134
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
|
+
|
|
135
192
|
async prefetch(path) {
|
|
136
193
|
const normalized = normalizePath(path)
|
|
137
194
|
if (this._cache.has(normalized)) return
|
|
@@ -301,7 +358,7 @@ export class SafaRouter {
|
|
|
301
358
|
|
|
302
359
|
if (this._navId !== navId) return
|
|
303
360
|
|
|
304
|
-
this.
|
|
361
|
+
this._scrollManager.save(this._pathname)
|
|
305
362
|
|
|
306
363
|
if (method === 'push') this._history.push(path, state)
|
|
307
364
|
else if (method === 'replace') this._history.replace(path, state)
|
|
@@ -311,9 +368,11 @@ export class SafaRouter {
|
|
|
311
368
|
this._query = query
|
|
312
369
|
this._isLoading = false
|
|
313
370
|
|
|
314
|
-
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 })
|
|
315
374
|
|
|
316
|
-
this.
|
|
375
|
+
this._scrollManager.restore(this._pathname, this.config.scrollToTop)
|
|
317
376
|
this._updateTitle()
|
|
318
377
|
this._focus()
|
|
319
378
|
|
|
@@ -336,15 +395,14 @@ export class SafaRouter {
|
|
|
336
395
|
? pageContent({ params: this._params, query: this._query, router: this })
|
|
337
396
|
: (pageContent || ''))
|
|
338
397
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
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()
|
|
348
406
|
}
|
|
349
407
|
}
|
|
350
408
|
|
|
@@ -360,21 +418,6 @@ export class SafaRouter {
|
|
|
360
418
|
return layoutFn({ children: content, params: this._params, router: this })
|
|
361
419
|
}
|
|
362
420
|
|
|
363
|
-
_saveScroll() {
|
|
364
|
-
this._scrollMemory.set(this._pathname, window.scrollY)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
_restoreScroll() {
|
|
368
|
-
if (this.config.scrollToTop) {
|
|
369
|
-
window.scrollTo({ top: 0 })
|
|
370
|
-
return
|
|
371
|
-
}
|
|
372
|
-
const saved = this._scrollMemory.get(this._pathname)
|
|
373
|
-
if (saved !== undefined) {
|
|
374
|
-
window.scrollTo(0, saved)
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
421
|
_updateTitle() {
|
|
379
422
|
const template = this.config.titleTemplate
|
|
380
423
|
if (!template) return
|
|
@@ -415,11 +458,20 @@ export class SafaRouter {
|
|
|
415
458
|
if (!mod) return null
|
|
416
459
|
try {
|
|
417
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
|
+
}
|
|
418
470
|
let url = mod
|
|
419
471
|
if (this._params && Object.keys(this._params).length > 0) {
|
|
420
472
|
for (const [k, v] of Object.entries(this._params)) {
|
|
421
473
|
const val = Array.isArray(v) ? v.join('/') : String(v)
|
|
422
|
-
url = url.
|
|
474
|
+
url = url.replaceAll(`[${k}]`, val)
|
|
423
475
|
}
|
|
424
476
|
}
|
|
425
477
|
const res = await fetch(url)
|
|
@@ -523,7 +575,7 @@ export class SafaRouter {
|
|
|
523
575
|
if (action === 'popstate') {
|
|
524
576
|
this._resolve(path, 'replace')
|
|
525
577
|
if (state && state._scrollY !== undefined && !this.config.scrollToTop) {
|
|
526
|
-
window.scrollTo(0, state._scrollY)
|
|
578
|
+
requestAnimationFrame(() => window.scrollTo(0, state._scrollY))
|
|
527
579
|
}
|
|
528
580
|
}
|
|
529
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 = {
|
|
@@ -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) {
|