safa-router 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safa-router",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
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",
@@ -34,10 +34,10 @@
34
34
  "license": "MIT",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "git+https://github.com/pishro-dev/safa-router.git"
37
+ "url": "git+https://github.com/Karan-Safaie-Qadi/SafaRouter.git"
38
38
  },
39
39
  "bugs": {
40
- "url": "https://github.com/pishro-dev/safa-router/issues"
40
+ "url": "https://github.com/Karan-Safaie-Qadi/SafaRouter/issues"
41
41
  },
42
- "homepage": "https://github.com/pishro-dev/safa-router#readme"
42
+ "homepage": "https://github.com/Karan-Safaie-Qadi/SafaRouter#readme"
43
43
  }
package/src/SafaRouter.js CHANGED
@@ -3,13 +3,13 @@ import { RouteTree } from './RouteTree.js'
3
3
  import { HistoryManager } from './HistoryManager.js'
4
4
  import { MiddlewareChain } from './MiddlewareChain.js'
5
5
  import { Link } from './Link.js'
6
- import { normalizePath, parseQuery, emit, createURL, isExternalURL } from './utils.js'
6
+ import { normalizePath, parseQuery, emit, createURL, isExternalURL, isDynamicSegment } from './utils.js'
7
7
  import { EVENTS, DEFAULT_CONFIG } from './constants.js'
8
8
  import { RouteLoadError, SafaError } from './errors.js'
9
9
 
10
10
  export class SafaRouter {
11
- static version = '1.0.0'
12
- static VERSION = '1.0.0'
11
+ static version = '1.1.0'
12
+ static VERSION = '1.1.0'
13
13
 
14
14
  constructor(options = {}) {
15
15
  this.config = { ...DEFAULT_CONFIG, ...options }
@@ -27,6 +27,7 @@ export class SafaRouter {
27
27
  })
28
28
  this._middleware = new MiddlewareChain()
29
29
  this._cache = new Map()
30
+ this._scrollMemory = new Map()
30
31
 
31
32
  this._pathname = '/'
32
33
  this._params = {}
@@ -39,6 +40,7 @@ export class SafaRouter {
39
40
  this._globalNotFound = this.config.notFound || null
40
41
  this._globalError = this.config.error || null
41
42
  this._globalLayout = this.config.layout || null
43
+ this._customTitle = null
42
44
 
43
45
  this._boundNav = this._onHistoryChange.bind(this)
44
46
  }
@@ -69,9 +71,7 @@ export class SafaRouter {
69
71
  return this
70
72
  }
71
73
 
72
- isStarted() {
73
- return this._started
74
- }
74
+ isStarted() { return this._started }
75
75
 
76
76
  destroy() {
77
77
  this._started = false
@@ -87,19 +87,13 @@ export class SafaRouter {
87
87
  }
88
88
 
89
89
  async push(url, state = {}) {
90
- if (isExternalURL(url)) {
91
- window.location.href = url
92
- return
93
- }
90
+ if (isExternalURL(url)) { window.location.href = url; return }
94
91
  const u = createURL(url) || new URL(url, location.origin)
95
92
  await this._navigate(normalizePath(u.pathname), 'push', parseQuery(u.search), state)
96
93
  }
97
94
 
98
95
  async replace(url, state = {}) {
99
- if (isExternalURL(url)) {
100
- window.location.replace(url)
101
- return
102
- }
96
+ if (isExternalURL(url)) { window.location.replace(url); return }
103
97
  const u = createURL(url) || new URL(url, location.origin)
104
98
  await this._navigate(normalizePath(u.pathname), 'replace', parseQuery(u.search), state)
105
99
  }
@@ -107,10 +101,7 @@ export class SafaRouter {
107
101
  back() { this._history.back() }
108
102
  forward() { this._history.forward() }
109
103
 
110
- reload() {
111
- this._resolve(this._pathname, 'replace')
112
- }
113
-
104
+ reload() { this._resolve(this._pathname, 'replace') }
114
105
  navigate(url) { return this.push(url) }
115
106
 
116
107
  getConfig() { return { ...this.config } }
@@ -123,26 +114,25 @@ export class SafaRouter {
123
114
  on(name, fn) {
124
115
  if (!this._events[name]) this._events[name] = []
125
116
  this._events[name].push(fn)
126
- return () => {
127
- this._events[name] = this._events[name].filter((f) => f !== fn)
128
- }
117
+ return () => { this._events[name] = this._events[name].filter(f => f !== fn) }
129
118
  }
130
119
 
131
120
  off(name, fn) {
132
121
  if (!this._events[name]) return
133
- this._events[name] = this._events[name].filter((f) => f !== fn)
122
+ this._events[name] = this._events[name].filter(f => f !== fn)
134
123
  }
135
124
 
136
125
  use(fn) { this._middleware.use(fn); return this }
137
126
 
127
+ beforeEach(fn) { return this.use(fn) }
128
+ afterEach(fn) { return this.on(EVENTS.AFTER_NAVIGATE, fn) }
129
+
138
130
  onError(fn) { return this.on(EVENTS.ERROR, fn) }
139
131
  onNotFound(fn) { return this.on(EVENTS.NOT_FOUND, fn) }
140
132
  onRouteChange(fn) { return this.on(EVENTS.ROUTE_CHANGE, fn) }
141
133
  onBeforeNavigate(fn) { return this.on(EVENTS.BEFORE_NAVIGATE, fn) }
142
134
 
143
- createLink(config) {
144
- return new Link({ ...config, router: this })
145
- }
135
+ createLink(config) { return new Link({ ...config, router: this }) }
146
136
 
147
137
  async prefetch(path) {
148
138
  const normalized = normalizePath(path)
@@ -160,12 +150,7 @@ export class SafaRouter {
160
150
  if (!routes || typeof routes !== 'object') return
161
151
  for (const [key, val] of Object.entries(routes)) {
162
152
  const isGroup = key.startsWith('(') && key.endsWith(')')
163
- const fp =
164
- isGroup
165
- ? base
166
- : base === '/'
167
- ? `/${key}`
168
- : `${base}/${key}`
153
+ const fp = isGroup ? base : base === '/' ? `/${key}` : `${base}/${key}`
169
154
  if (typeof val === 'object' && val !== null) {
170
155
  if (val.page) this._matcher.add(fp)
171
156
  if (val.children) walk(val.children, fp)
@@ -181,8 +166,27 @@ export class SafaRouter {
181
166
  const dir = (this.config.pagesDir || '').replace(/\/+$/, '')
182
167
  if (!dir) return null
183
168
  const normal = normalizePath(path)
184
- if (normal === '/') return `${dir}/index.html`
185
- return `${dir}${normal}.html`
169
+ if (normal === '/') return [`${dir}/index.html`]
170
+
171
+ const candidates = [
172
+ `${dir}${normal}.html`,
173
+ `${dir}${normal}/index.html`,
174
+ ]
175
+
176
+ const segs = normal.split('/').filter(Boolean)
177
+ const dynSeg = segs.findIndex(s => s.startsWith('['))
178
+ if (dynSeg >= 0) return candidates
179
+
180
+ const parent = segs.slice(0, -1).join('/')
181
+ const last = segs[segs.length - 1]
182
+ if (parent) {
183
+ candidates.push(`${dir}/${parent}/[slug].html`)
184
+ candidates.push(`${dir}/${parent}/[slug]/index.html`)
185
+ }
186
+ candidates.push(`${dir}/[slug].html`)
187
+ candidates.push(`${dir}/[slug]/index.html`)
188
+
189
+ return candidates
186
190
  }
187
191
 
188
192
  _hasRoutes() {
@@ -190,15 +194,46 @@ export class SafaRouter {
190
194
  }
191
195
 
192
196
  async _fetchPage(path) {
193
- const htmlPath = this._resolvePagePath(path)
194
- if (!htmlPath) return null
195
- try {
196
- const res = await fetch(htmlPath)
197
- if (!res.ok) return null
198
- return res.text()
199
- } catch {
200
- return null
197
+ const candidates = this._resolvePagePath(path)
198
+ if (!candidates) return null
199
+ for (const p of candidates) {
200
+ try {
201
+ const res = await fetch(p)
202
+ if (res.ok) {
203
+ const text = await res.text()
204
+ this._extractTitle(text)
205
+ return text
206
+ }
207
+ } catch { /* try next */ }
201
208
  }
209
+ return null
210
+ }
211
+
212
+ async _fetchSpecial(path, name) {
213
+ const dir = (this.config.pagesDir || '').replace(/\/+$/, '')
214
+ if (!dir) return null
215
+ const segs = path.split('/').filter(Boolean)
216
+ const dp = segs.slice(0, -1).join('/')
217
+ const candidates = [
218
+ dp ? `${dir}/${dp}/${name}` : null,
219
+ `${dir}/${name}`,
220
+ ].filter(Boolean)
221
+ for (const p of candidates) {
222
+ try {
223
+ const res = await fetch(p)
224
+ if (res.ok) return res.text()
225
+ } catch { /* try next */ }
226
+ }
227
+ return null
228
+ }
229
+
230
+ _extractTitle(html) {
231
+ const m = html.match(/<title[^>]*>([^<]+)<\/title>/i)
232
+ this._customTitle = m ? m[1].trim() : null
233
+ }
234
+
235
+ _renderHtmlLayout(html, children) {
236
+ return html.replace(/\{\s*children\s*\}/gi, children || '')
202
237
  }
203
238
 
204
239
  async _navigate(path, method, query = {}, state = {}) {
@@ -208,6 +243,7 @@ export class SafaRouter {
208
243
 
209
244
  async _resolve(path, method, query = {}, state = {}) {
210
245
  this._isLoading = true
246
+ this._customTitle = null
211
247
  emit(this._events, EVENTS.LOADING, { path, loading: true })
212
248
  emit(this._events, EVENTS.BEFORE_NAVIGATE, { path, method })
213
249
 
@@ -215,14 +251,10 @@ export class SafaRouter {
215
251
  const ctx = { path, method, query, cancelled: false, redirect: null }
216
252
  await this._middleware.run(ctx)
217
253
  if (ctx.redirect) return this._navigate(ctx.redirect, 'replace')
218
- if (ctx.cancelled) {
219
- this._isLoading = false
220
- return
221
- }
254
+ if (ctx.cancelled) { this._isLoading = false; return }
222
255
 
223
256
  const routeMatch = this._hasRoutes() ? this._routeTree.resolve(path) : null
224
257
  let pageContent, layoutFns = []
225
- let resolvedPath = path
226
258
 
227
259
  if (routeMatch) {
228
260
  if (routeMatch.node.loading) {
@@ -245,15 +277,36 @@ export class SafaRouter {
245
277
  }
246
278
  this._routeData = routeMatch
247
279
  } else if (this.config.pagesDir) {
280
+ const loadingHtml = await this._fetchSpecial(path, 'loading.html')
281
+ if (loadingHtml && this._targetEl) {
282
+ this._targetEl.innerHTML = loadingHtml
283
+ }
248
284
  pageContent = await this._fetchPage(path)
249
285
  if (pageContent === null) {
286
+ const notFoundHtml = await this._fetchSpecial(path, 'not-found.html')
287
+ if (notFoundHtml) {
288
+ if (method === 'push') this._history.push(path, state)
289
+ else if (method === 'replace') this._history.replace(path, state)
290
+ this._pathname = path
291
+ this._isLoading = false
292
+ if (this._targetEl) this._targetEl.innerHTML = notFoundHtml
293
+ return
294
+ }
250
295
  await this._handleNotFound(path, method)
251
296
  this._isLoading = false
252
297
  return
253
298
  }
254
299
  if (this._globalLayout) {
255
- const lfn = await this._loadComponent(this._globalLayout)
256
- if (lfn) layoutFns.push(lfn)
300
+ let lfn = this._globalLayout
301
+ if (typeof lfn === 'string') {
302
+ const res = await fetch(lfn)
303
+ if (res.ok) {
304
+ const html = await res.text()
305
+ lfn = ({ children }) => this._renderHtmlLayout(html, children)
306
+ }
307
+ }
308
+ const loaded = await this._loadComponent(lfn)
309
+ if (loaded) layoutFns.push(loaded)
257
310
  }
258
311
  this._routeData = { node: { page: null }, params: {}, layouts: [] }
259
312
  } else {
@@ -262,6 +315,8 @@ export class SafaRouter {
262
315
  return
263
316
  }
264
317
 
318
+ this._saveScroll()
319
+
265
320
  if (method === 'push') this._history.push(path, state)
266
321
  else if (method === 'replace') this._history.replace(path, state)
267
322
 
@@ -273,17 +328,12 @@ export class SafaRouter {
273
328
  this._render(pageContent, layoutFns)
274
329
 
275
330
  emit(this._events, EVENTS.ROUTE_CHANGE, {
276
- pathname: path,
277
- params: this._params,
278
- query: this._query,
331
+ pathname: path, params: this._params, query: this._query,
279
332
  })
280
333
  emit(this._events, EVENTS.AFTER_NAVIGATE, { pathname: path })
281
334
 
282
- if (this.config.scrollToTop) {
283
- window.scrollTo({ top: 0, behavior: 'smooth' })
284
- }
285
-
286
335
  this._updateTitle()
336
+ this._restoreScroll()
287
337
  this._focus()
288
338
  } catch (err) {
289
339
  this._isLoading = false
@@ -308,9 +358,7 @@ export class SafaRouter {
308
358
  this._targetEl.innerHTML = html
309
359
  this._bindLinks()
310
360
  if (duration > 0) {
311
- requestAnimationFrame(() => {
312
- this._targetEl.style.opacity = '1'
313
- })
361
+ requestAnimationFrame(() => { this._targetEl.style.opacity = '1' })
314
362
  }
315
363
  }
316
364
 
@@ -326,13 +374,31 @@ export class SafaRouter {
326
374
  return layoutFn({ children: content, params: this._params, router: this })
327
375
  }
328
376
 
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
+
329
392
  _updateTitle() {
330
393
  const template = this.config.titleTemplate
331
394
  if (!template) return
332
- const title = this._routeData?.node?.meta?.title ||
333
- this._routeData?.node?.segment ||
334
- this._pathname.replace(/[/-]/g, ' ').trim() ||
335
- 'Home'
395
+
396
+ let title = this._customTitle
397
+ if (!title) title = this._routeData?.node?.meta?.title
398
+ if (!title) title = this._routeData?.node?.segment
399
+ if (!title) title = this._pathname.replace(/[/-]/g, ' ').trim()
400
+ if (!title) title = 'Home'
401
+
336
402
  document.title = template.replace('%s', title.charAt(0).toUpperCase() + title.slice(1))
337
403
  }
338
404
 
@@ -363,9 +429,18 @@ export class SafaRouter {
363
429
  if (!mod) return null
364
430
  try {
365
431
  if (typeof mod === 'string') {
366
- const res = await fetch(mod)
367
- if (!res.ok) throw new Error(`Failed to load ${mod} (${res.status})`)
368
- return res.text()
432
+ let url = mod
433
+ if (this._params && Object.keys(this._params).length > 0) {
434
+ for (const [k, v] of Object.entries(this._params)) {
435
+ const val = Array.isArray(v) ? v.join('/') : String(v)
436
+ url = url.replace(`[${k}]`, val)
437
+ }
438
+ }
439
+ const res = await fetch(url)
440
+ if (!res.ok) throw new Error(`Failed to load ${url} (${res.status})`)
441
+ const text = await res.text()
442
+ this._extractTitle(text)
443
+ return text
369
444
  }
370
445
  if (typeof mod === 'function') {
371
446
  let result
@@ -388,6 +463,19 @@ export class SafaRouter {
388
463
  async _handleNotFound(path, method) {
389
464
  emit(this._events, EVENTS.NOT_FOUND, { path })
390
465
 
466
+ const notFoundHtml = this.config.pagesDir ? await this._fetchSpecial(path, 'not-found.html') : null
467
+ if (notFoundHtml) {
468
+ if (method === 'push') this._history.push(path)
469
+ else if (method === 'replace') this._history.replace(path)
470
+ this._pathname = path
471
+ if (this._targetEl) {
472
+ this._targetEl.innerHTML = this._globalLayout
473
+ ? await this._renderWithLayouts(notFoundHtml, [this._globalLayout], 0)
474
+ : notFoundHtml
475
+ }
476
+ return
477
+ }
478
+
391
479
  const notFound = this._routeData?.node?.notFound || this._globalNotFound
392
480
  if (notFound) {
393
481
  try {
@@ -407,6 +495,12 @@ export class SafaRouter {
407
495
  console.error('[SafaRouter]', err)
408
496
  emit(this._events, EVENTS.ERROR, { path, error: err })
409
497
 
498
+ const errorHtml = this.config.pagesDir ? await this._fetchSpecial(path, 'error.html') : null
499
+ if (errorHtml) {
500
+ if (this._targetEl) this._targetEl.innerHTML = errorHtml
501
+ return
502
+ }
503
+
410
504
  if (this._globalError) {
411
505
  try {
412
506
  const fn = await this._loadComponent(this._globalError)
@@ -416,11 +510,8 @@ export class SafaRouter {
416
510
  } catch { /* fall through */ }
417
511
  }
418
512
  if (this._targetEl) {
419
- try {
420
- this._targetEl.innerHTML = this._fallbackError(err)
421
- } catch {
422
- this._targetEl.textContent = `Error: ${err.message}`
423
- }
513
+ try { this._targetEl.innerHTML = this._fallbackError(err) }
514
+ catch { this._targetEl.textContent = `Error: ${err.message}` }
424
515
  }
425
516
  }
426
517
 
@@ -449,9 +540,7 @@ export class SafaRouter {
449
540
  get currentRoute() { return this._routeData }
450
541
  get matchedRoute() { return this._matcher.match(this._pathname) }
451
542
 
452
- getRoute(path) {
453
- return this._routeTree.resolve(normalizePath(path))
454
- }
543
+ getRoute(path) { return this._routeTree.resolve(normalizePath(path)) }
455
544
 
456
545
  _onHistoryChange({ path, action, state }) {
457
546
  if (action === 'popstate') {
package/src/constants.js CHANGED
@@ -39,7 +39,7 @@ export const DEFAULT_CONFIG = {
39
39
  target: '#app',
40
40
  basePath: '',
41
41
  useHash: false,
42
- scrollToTop: true,
42
+ scrollToTop: false,
43
43
  prefetch: true,
44
44
  cacheRoutes: true,
45
45
  titleTemplate: '%s — SafaRouter',