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 CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "safa-router",
3
- "version": "1.1.2",
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
- ".": "./src/index.js",
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": "echo \"Tests coming soon\"",
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
  }
@@ -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
+ }
@@ -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
- this._stack = this._stack.filter(f => f !== fn)
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 = [...this._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 fp = isGroup ? normalizePath(base) : normalizePath(`${base}/${key}`)
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, fp)
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 seg = isGroup ? key : key.replace(/^\//, '')
102
- const node = new RouteNode({ segment: seg, fullPath: fp })
103
-
104
- if (typeof val === 'object' && val !== null) {
105
- if (val.meta) node.meta = val.meta
106
- if (val.layout) node.layout = val.layout
107
- if (val.page) node.page = val.page
108
- if (val.loading) node.loading = val.loading
109
- if (val.error) node.error = val.error
110
- if (val.notFound) node.notFound = val.notFound
111
- if (val.children) this._build(node, val.children, fp)
112
- } else if (typeof val === 'function') {
113
- node.page = val
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
- parent.addChild(node)
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 { normalizePath, parseQuery, emit, createURL, isExternalURL, isDynamicSegment } from './utils.js'
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.1.2'
11
- static VERSION = '1.1.2'
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._scrollMemory = new Map()
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._saveScroll()
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._render(pageContent, layoutFns)
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._restoreScroll()
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
- const duration = this.config.transitionDuration
340
- if (duration > 0) {
341
- this._targetEl.style.opacity = '0'
342
- this._targetEl.style.transition = `opacity ${duration}ms ease`
343
- }
344
- this._targetEl.innerHTML = html
345
- this._bindLinks()
346
- if (duration > 0) {
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.replace(`[${k}]`, val)
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
- return normalizePath(parts.filter(Boolean).join('/'))
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) {