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 +4 -4
- package/src/SafaRouter.js +162 -73
- package/src/constants.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safa-router",
|
|
3
|
-
"version": "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/
|
|
37
|
+
"url": "git+https://github.com/Karan-Safaie-Qadi/SafaRouter.git"
|
|
38
38
|
},
|
|
39
39
|
"bugs": {
|
|
40
|
-
"url": "https://github.com/
|
|
40
|
+
"url": "https://github.com/Karan-Safaie-Qadi/SafaRouter/issues"
|
|
41
41
|
},
|
|
42
|
-
"homepage": "https://github.com/
|
|
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.
|
|
12
|
-
static VERSION = '1.
|
|
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(
|
|
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
|
-
|
|
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
|
|
194
|
-
if (!
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
256
|
-
if (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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
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
|
-
|
|
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') {
|