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 CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "safa-router",
3
- "version": "1.1.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
- ".": "./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
+ }
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
 
@@ -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
+ }
@@ -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.replace(`[[...${key}]]`, v)
104
- path = path.replace(`[...${key}]`, v)
105
- path = path.replace(`[${key}]`, v)
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 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
@@ -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 { 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'
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.1.0'
12
- static VERSION = '1.1.0'
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._scrollMemory = new Map()
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 (path === this._pathname) return
241
- await this._resolve(path, method, query, state)
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 (ctx.redirect) return this._navigate(ctx.redirect, 'replace')
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._saveScroll()
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._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 })
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
- const duration = this.config.transitionDuration
354
- if (duration > 0) {
355
- this._targetEl.style.opacity = '0'
356
- this._targetEl.style.transition = `opacity ${duration}ms ease`
357
- }
358
- this._targetEl.innerHTML = html
359
- this._bindLinks()
360
- if (duration > 0) {
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.replace(`[${k}]`, val)
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
- 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) {