safa-router 1.0.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.
@@ -0,0 +1,95 @@
1
+ export class HistoryManager {
2
+ constructor({ useHash = false, basePath = '' } = {}) {
3
+ this._useHash = useHash
4
+ this._basePath = basePath
5
+ this._listeners = []
6
+ this._bound = this._onPop.bind(this)
7
+ }
8
+
9
+ init() {
10
+ window.addEventListener('popstate', this._bound)
11
+ }
12
+
13
+ destroy() {
14
+ window.removeEventListener('popstate', this._bound)
15
+ this._listeners = []
16
+ }
17
+
18
+ isSupported() {
19
+ return typeof window !== 'undefined' && !!window.history
20
+ }
21
+
22
+ get path() {
23
+ if (this._useHash) {
24
+ const h = location.hash.slice(1)
25
+ return h || '/'
26
+ }
27
+ let p = location.pathname
28
+ if (this._basePath && p.startsWith(this._basePath)) {
29
+ p = p.slice(this._basePath.length) || '/'
30
+ }
31
+ return p
32
+ }
33
+
34
+ push(url, state = {}) {
35
+ const scrollY = window.scrollY
36
+ history.pushState({ ...state, _scrollY: scrollY }, '', this._url(url))
37
+ this._notify(url, 'push')
38
+ }
39
+
40
+ replace(url, state = {}) {
41
+ const scrollY = window.scrollY
42
+ history.replaceState({ ...state, _scrollY: scrollY }, '', this._url(url))
43
+ this._notify(url, 'replace')
44
+ }
45
+
46
+ back() {
47
+ history.back()
48
+ }
49
+
50
+ forward() {
51
+ history.forward()
52
+ }
53
+
54
+ go(delta) {
55
+ history.go(delta)
56
+ }
57
+
58
+ get length() {
59
+ return history.length
60
+ }
61
+
62
+ get state() {
63
+ return history.state
64
+ }
65
+
66
+ clearState() {
67
+ history.replaceState(null, '', location.href)
68
+ }
69
+
70
+ onChange(fn) {
71
+ this._listeners.push(fn)
72
+ return () => {
73
+ this._listeners = this._listeners.filter(l => l !== fn)
74
+ }
75
+ }
76
+
77
+ _url(path) {
78
+ if (this._useHash) return `#${path}`
79
+ return this._basePath ? `${this._basePath}${path}` : path
80
+ }
81
+
82
+ _onPop(e) {
83
+ this._notify(this.path, 'popstate', e.state)
84
+ }
85
+
86
+ _notify(path, action, state) {
87
+ for (const fn of this._listeners) {
88
+ try {
89
+ fn({ path, action, state })
90
+ } catch (err) {
91
+ console.error('[SafaRouter] History listener error:', err)
92
+ }
93
+ }
94
+ }
95
+ }
package/src/Link.js ADDED
@@ -0,0 +1,97 @@
1
+ export class Link {
2
+ constructor(config = {}) {
3
+ this._href = config.href || '/'
4
+ this._children = config.children || this._href
5
+ this._className = config.className || ''
6
+ this._activeClass = config.activeClass || 'active'
7
+ this._router = config.router || null
8
+ this._attrs = config.attrs || {}
9
+ this._el = null
10
+ this._unsub = null
11
+ }
12
+
13
+ get href() { return this._href }
14
+ get element() { return this._el }
15
+
16
+ render(container) {
17
+ this._el = document.createElement('a')
18
+ if (container) {
19
+ if (typeof container === 'string') {
20
+ const el = document.querySelector(container)
21
+ if (el) el.appendChild(this._el)
22
+ } else if (container instanceof Node) {
23
+ container.appendChild(this._el)
24
+ }
25
+ }
26
+ this._el.href = this._router?.config?.useHash
27
+ ? `#${this._href}`
28
+ : this._href
29
+ if (this._className) this._el.className = this._className
30
+
31
+ if (typeof this._children === 'string') {
32
+ this._el.textContent = this._children
33
+ } else if (this._children instanceof Node) {
34
+ this._el.appendChild(this._children)
35
+ } else if (Array.isArray(this._children)) {
36
+ for (const c of this._children) {
37
+ if (c instanceof Node) this._el.appendChild(c)
38
+ }
39
+ }
40
+
41
+ for (const [k, v] of Object.entries(this._attrs)) {
42
+ this._el.setAttribute(k, v)
43
+ }
44
+
45
+ this._el.addEventListener('click', (e) => this._onClick(e))
46
+
47
+ if (this._router) {
48
+ if (this._router.config.prefetch) {
49
+ this._el.addEventListener('mouseenter', () => this._prefetch(), { once: true })
50
+ }
51
+ this._unsub = this._router.on('routechange', () => this._refresh())
52
+ this._refresh()
53
+ }
54
+
55
+ return this._el
56
+ }
57
+
58
+ _prefetch() {
59
+ if (this._router && this._router.prefetch) {
60
+ this._router.prefetch(this._href)
61
+ }
62
+ }
63
+
64
+ _onClick(e) {
65
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
66
+ if (e.button !== 0) return
67
+ e.preventDefault()
68
+ if (this._router) this._router.push(this._href)
69
+ }
70
+
71
+ _refresh() {
72
+ if (!this._el || !this._router) return
73
+ const cur = this._router.pathname
74
+ const active =
75
+ cur === this._href ||
76
+ (this._href !== '/' && cur.startsWith(this._href))
77
+ this._el.classList.toggle(this._activeClass, active)
78
+ }
79
+
80
+ setHref(href) {
81
+ this._href = href
82
+ if (this._el) {
83
+ this._el.href = this._router?.config?.useHash ? `#${href}` : href
84
+ }
85
+ }
86
+
87
+ destroy() {
88
+ if (this._unsub) {
89
+ this._unsub()
90
+ this._unsub = null
91
+ }
92
+ if (this._el) {
93
+ this._el.remove()
94
+ this._el = null
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,47 @@
1
+ export class MiddlewareChain {
2
+ constructor() {
3
+ this._stack = []
4
+ }
5
+
6
+ use(fn) {
7
+ if (typeof fn !== 'function') {
8
+ throw new Error('Middleware must be a function')
9
+ }
10
+ this._stack.push(fn)
11
+ return this
12
+ }
13
+
14
+ async run(ctx) {
15
+ let i = 0
16
+ const next = async () => {
17
+ if (i >= this._stack.length) return ctx
18
+ try {
19
+ return this._stack[i++](ctx, next)
20
+ } catch (err) {
21
+ console.error('[SafaRouter] Middleware error:', err)
22
+ throw err
23
+ }
24
+ }
25
+ return next()
26
+ }
27
+
28
+ clear() {
29
+ this._stack = []
30
+ return this
31
+ }
32
+
33
+ remove(fn) {
34
+ this._stack = this._stack.filter(f => f !== fn)
35
+ return this
36
+ }
37
+
38
+ clone() {
39
+ const chain = new MiddlewareChain()
40
+ chain._stack = [...this._stack]
41
+ return chain
42
+ }
43
+
44
+ get length() {
45
+ return this._stack.length
46
+ }
47
+ }
@@ -0,0 +1,156 @@
1
+ import { normalizePath } from './utils.js'
2
+ import { PARAM_PATTERNS, SEGMENT_TYPES, PATTERN_SCORES } from './constants.js'
3
+
4
+ class RoutePattern {
5
+ constructor(pattern) {
6
+ this.raw = pattern
7
+ this.path = normalizePath(pattern)
8
+ this.segments = this.path === '/' ? [] : this.path.split('/').filter(Boolean)
9
+ this.paramNames = []
10
+ this._validate()
11
+ this._compile()
12
+ }
13
+
14
+ _validate() {
15
+ const names = new Set()
16
+ for (const seg of this.segments) {
17
+ const m = seg.match(/^\[(?:\.\.\.)?([^\]]+)\]$/)
18
+ if (m) {
19
+ if (names.has(m[1])) {
20
+ throw new Error(`Duplicate param name "${m[1]}" in pattern "${this.raw}"`)
21
+ }
22
+ names.add(m[1])
23
+ }
24
+ }
25
+ }
26
+
27
+ _compile() {
28
+ let regexStr = '^'
29
+
30
+ for (const seg of this.segments) {
31
+ if (PARAM_PATTERNS.OPTIONAL_CATCH_ALL.test(seg)) {
32
+ const name = seg.match(PARAM_PATTERNS.OPTIONAL_CATCH_ALL)[1]
33
+ this.paramNames.push(name)
34
+ regexStr += '(?:/(.*))?'
35
+ } else if (PARAM_PATTERNS.CATCH_ALL.test(seg)) {
36
+ const name = seg.match(PARAM_PATTERNS.CATCH_ALL)[1]
37
+ this.paramNames.push(name)
38
+ regexStr += '/(.+)'
39
+ } else if (PARAM_PATTERNS.DYNAMIC.test(seg)) {
40
+ const name = seg.match(PARAM_PATTERNS.DYNAMIC)[1]
41
+ this.paramNames.push(name)
42
+ regexStr += '/([^/]+)'
43
+ } else if (PARAM_PATTERNS.GROUP.test(seg)) {
44
+ continue
45
+ } else {
46
+ regexStr += `/${seg}`
47
+ }
48
+ }
49
+
50
+ regexStr += '/?$'
51
+ this.regex = new RegExp(regexStr)
52
+ }
53
+
54
+ get score() {
55
+ let s = 0
56
+ for (const seg of this.segments) {
57
+ if (PARAM_PATTERNS.OPTIONAL_CATCH_ALL.test(seg)) {
58
+ s += PATTERN_SCORES[SEGMENT_TYPES.OPTIONAL_CATCH_ALL]
59
+ } else if (PARAM_PATTERNS.CATCH_ALL.test(seg)) {
60
+ s += PATTERN_SCORES[SEGMENT_TYPES.CATCH_ALL]
61
+ } else if (PARAM_PATTERNS.DYNAMIC.test(seg)) {
62
+ s += PATTERN_SCORES[SEGMENT_TYPES.DYNAMIC]
63
+ } else if (PARAM_PATTERNS.GROUP.test(seg)) {
64
+ continue
65
+ } else {
66
+ s += PATTERN_SCORES[SEGMENT_TYPES.STATIC]
67
+ }
68
+ }
69
+ return s
70
+ }
71
+
72
+ describe() {
73
+ return {
74
+ pattern: this.raw,
75
+ path: this.path,
76
+ segments: this.segments,
77
+ paramNames: this.paramNames,
78
+ score: this.score,
79
+ regex: this.regex.source,
80
+ }
81
+ }
82
+
83
+ match(url) {
84
+ const p = normalizePath(url)
85
+ const m = p.match(this.regex)
86
+ if (!m) return null
87
+
88
+ const params = {}
89
+ let idx = 0
90
+ for (const name of this.paramNames) {
91
+ const val = m[++idx]
92
+ if (val !== undefined) {
93
+ params[name] = val.includes('/') ? val.split('/') : val
94
+ }
95
+ }
96
+ return params
97
+ }
98
+
99
+ stringify(params = {}) {
100
+ let path = this.raw
101
+ for (const [key, val] of Object.entries(params)) {
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)
106
+ }
107
+ return normalizePath(path)
108
+ }
109
+ }
110
+
111
+ export class RouteMatcher {
112
+ constructor() {
113
+ this._patterns = []
114
+ }
115
+
116
+ static create(patterns) {
117
+ const matcher = new RouteMatcher()
118
+ if (patterns) matcher.addMultiple(patterns)
119
+ return matcher
120
+ }
121
+
122
+ add(pattern) {
123
+ const rp = new RoutePattern(pattern)
124
+ this._patterns.push(rp)
125
+ this._patterns.sort((a, b) => b.score - a.score)
126
+ return this
127
+ }
128
+
129
+ addMultiple(patterns) {
130
+ for (const p of patterns) this.add(p)
131
+ return this
132
+ }
133
+
134
+ match(url) {
135
+ for (const p of this._patterns) {
136
+ const params = p.match(url)
137
+ if (params !== null) {
138
+ return { pattern: p.raw, path: p.path, params }
139
+ }
140
+ }
141
+ return null
142
+ }
143
+
144
+ build(pattern, params) {
145
+ return new RoutePattern(pattern).stringify(params)
146
+ }
147
+
148
+ clear() {
149
+ this._patterns = []
150
+ return this
151
+ }
152
+
153
+ get patterns() {
154
+ return this._patterns.map(p => p.raw)
155
+ }
156
+ }
@@ -0,0 +1,187 @@
1
+ import { normalizePath, isRouteGroup } from './utils.js'
2
+
3
+ class RouteNode {
4
+ constructor({ segment, fullPath, meta } = {}) {
5
+ this.segment = segment || ''
6
+ this.fullPath = fullPath || ''
7
+ this.meta = meta || null
8
+ this.page = null
9
+ this.layout = null
10
+ this.loading = null
11
+ this.error = null
12
+ this.notFound = null
13
+ this.children = []
14
+ this.parent = null
15
+ this.isGroup = isRouteGroup(segment)
16
+ }
17
+
18
+ addChild(node) {
19
+ node.parent = this
20
+ this.children.push(node)
21
+ }
22
+
23
+ getLayoutChain() {
24
+ const chain = []
25
+ if (this.layout) chain.push(this.layout)
26
+ let cur = this.parent
27
+ while (cur) {
28
+ if (cur.layout) chain.unshift(cur.layout)
29
+ cur = cur.parent
30
+ }
31
+ return chain
32
+ }
33
+
34
+ _findChild(segment) {
35
+ for (const c of this.children) {
36
+ if (c.isGroup) {
37
+ const nested = c._findChild(segment)
38
+ if (nested) return nested
39
+ }
40
+ if (c.segment === segment) return c
41
+ }
42
+ return null
43
+ }
44
+
45
+ _findDynamic(segment) {
46
+ for (const c of this.children) {
47
+ if (c.isGroup) {
48
+ const nested = c._findDynamic(segment)
49
+ if (nested) return nested
50
+ }
51
+ if (c.segment.startsWith('[') && c.segment.endsWith(']')) return c
52
+ }
53
+ return null
54
+ }
55
+
56
+ _findCatchAll() {
57
+ for (const c of this.children) {
58
+ if (c.isGroup) {
59
+ const nested = c._findCatchAll()
60
+ if (nested) return nested
61
+ }
62
+ if (c.segment.startsWith('[...') || c.segment.startsWith('[[...')) return c
63
+ }
64
+ return null
65
+ }
66
+ }
67
+
68
+ export class RouteTree {
69
+ constructor(routes) {
70
+ this.root = new RouteNode({ fullPath: '/' })
71
+ this._build(this.root, routes, '/')
72
+ }
73
+
74
+ static create(routes) {
75
+ return new RouteTree(routes)
76
+ }
77
+
78
+ _build(parent, routes, base) {
79
+ if (!routes || typeof routes !== 'object') return
80
+
81
+ for (const [key, val] of Object.entries(routes)) {
82
+ const isGroup = isRouteGroup(key)
83
+ const fp = isGroup ? normalizePath(base) : normalizePath(`${base}/${key}`)
84
+ const isRoot = fp === '/' || key === '/'
85
+
86
+ if (isRoot) {
87
+ if (typeof val === 'object' && val !== null) {
88
+ if (val.meta) parent.meta = val.meta
89
+ if (val.layout) parent.layout = val.layout
90
+ if (val.page) parent.page = val.page
91
+ if (val.loading) parent.loading = val.loading
92
+ if (val.error) parent.error = val.error
93
+ if (val.notFound) parent.notFound = val.notFound
94
+ if (val.children) this._build(parent, val.children, fp)
95
+ } else if (typeof val === 'function') {
96
+ parent.page = val
97
+ }
98
+ continue
99
+ }
100
+
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
+ }
115
+
116
+ parent.addChild(node)
117
+ }
118
+ }
119
+
120
+ resolve(pathname) {
121
+ const segs = normalizePath(pathname).split('/').filter(Boolean)
122
+ return this._resolve(this.root, segs, 0, {})
123
+ }
124
+
125
+ _resolve(node, segs, idx, params) {
126
+ if (idx >= segs.length) {
127
+ if (node.page) {
128
+ return { node, params, layouts: node.getLayoutChain() }
129
+ }
130
+ const ca = node._findCatchAll()
131
+ if (ca && ca.page) {
132
+ return { node: ca, params: { ...params }, layouts: ca.getLayoutChain() }
133
+ }
134
+ return null
135
+ }
136
+
137
+ const seg = segs[idx]
138
+
139
+ const exact = node._findChild(seg)
140
+ if (exact) {
141
+ const res = this._resolve(exact, segs, idx + 1, { ...params })
142
+ if (res) return res
143
+ }
144
+
145
+ const dyn = node._findDynamic(seg)
146
+ if (dyn) {
147
+ const name = dyn.segment.replace(/^\[|\]$/g, '')
148
+ const res = this._resolve(dyn, segs, idx + 1, {
149
+ ...params,
150
+ [name]: seg,
151
+ })
152
+ if (res) return res
153
+ }
154
+
155
+ const ca = node._findCatchAll()
156
+ if (ca) {
157
+ const name = ca.segment.replace(/^\[\.\.\.|\]\]?$/g, '')
158
+ return {
159
+ node: ca,
160
+ params: { ...params, [name]: segs.slice(idx) },
161
+ layouts: ca.getLayoutChain(),
162
+ }
163
+ }
164
+
165
+ return null
166
+ }
167
+
168
+ flatten() {
169
+ const result = []
170
+ const walk = (node) => {
171
+ if (node.page) {
172
+ result.push({ path: node.fullPath, page: node.page, meta: node.meta })
173
+ }
174
+ for (const child of node.children) walk(child)
175
+ }
176
+ walk(this.root)
177
+ return result
178
+ }
179
+
180
+ find(pathname) {
181
+ return this.resolve(pathname)
182
+ }
183
+
184
+ getRoute(pathname) {
185
+ return this.resolve(pathname)
186
+ }
187
+ }