@symbo.ls/router 3.5.1 → 3.14.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/LICENSE +34 -0
- package/README.md +234 -0
- package/index.js +193 -260
- package/package.json +32 -7
- package/location.js +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Creative Commons Attribution-NonCommercial 4.0 International License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 symbo.ls
|
|
4
|
+
|
|
5
|
+
This work is licensed under the Creative Commons Attribution-NonCommercial
|
|
6
|
+
4.0 International License. To view a copy of this license, visit
|
|
7
|
+
https://creativecommons.org/licenses/by-nc/4.0/ or send a letter to
|
|
8
|
+
Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
|
|
9
|
+
|
|
10
|
+
You are free to:
|
|
11
|
+
|
|
12
|
+
Share — copy and redistribute the material in any medium or format
|
|
13
|
+
Adapt — remix, transform, and build upon the material
|
|
14
|
+
|
|
15
|
+
Under the following terms:
|
|
16
|
+
|
|
17
|
+
Attribution — You must give appropriate credit, provide a link to the
|
|
18
|
+
license, and indicate if changes were made. You may do so in any
|
|
19
|
+
reasonable manner, but not in any way that suggests the licensor endorses
|
|
20
|
+
you or your use.
|
|
21
|
+
|
|
22
|
+
NonCommercial — You may not use the material for commercial purposes.
|
|
23
|
+
|
|
24
|
+
No additional restrictions — You may not apply legal terms or
|
|
25
|
+
technological measures that legally restrict others from doing anything
|
|
26
|
+
the license permits.
|
|
27
|
+
|
|
28
|
+
THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
29
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
30
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
31
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
32
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
33
|
+
OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS IN THE
|
|
34
|
+
WORK.
|
package/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# @domql/router
|
|
2
|
+
|
|
3
|
+
Client-side router plugin for DOMQL. Handles route matching, navigation, scroll management, and state updates within DOMQL elements.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @domql/router
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Basic Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { router } from '@domql/router'
|
|
15
|
+
|
|
16
|
+
// Navigate to a path
|
|
17
|
+
router('/about', element)
|
|
18
|
+
|
|
19
|
+
// With state and options
|
|
20
|
+
router('/dashboard', element, { userId: 1 }, { scrollToTop: true })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Define routes on your DOMQL element:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
const App = {
|
|
27
|
+
routes: {
|
|
28
|
+
'/': HomePage,
|
|
29
|
+
'/about': AboutPage,
|
|
30
|
+
'/contact': ContactPage,
|
|
31
|
+
'/*': NotFoundPage
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Dynamic Route Params
|
|
37
|
+
|
|
38
|
+
Match routes with `:param` segments. Enable with `useParamsMatching: true`.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const App = {
|
|
42
|
+
routes: {
|
|
43
|
+
'/': HomePage,
|
|
44
|
+
'/:id': UserPage,
|
|
45
|
+
'/:category/:slug': ArticlePage,
|
|
46
|
+
'/*': NotFoundPage
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
router('/users/42', element, {}, { useParamsMatching: true })
|
|
51
|
+
// state.params = { id: '42' }
|
|
52
|
+
|
|
53
|
+
router('/tech/my-article', element, {}, { useParamsMatching: true })
|
|
54
|
+
// state.params = { category: 'tech', slug: 'my-article' }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Exact segments score higher than params, so `/about` will match a literal `/about` route before `/:id`.
|
|
58
|
+
|
|
59
|
+
## Query String Parsing
|
|
60
|
+
|
|
61
|
+
Query parameters are automatically parsed and stored in state.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
router('/search?q=hello&tag=a&tag=b', element)
|
|
65
|
+
// state.query = { q: 'hello', tag: ['a', 'b'] }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Duplicate keys are collected into arrays.
|
|
69
|
+
|
|
70
|
+
## Guards / Middleware
|
|
71
|
+
|
|
72
|
+
Run async guard functions before navigation. Return `true` to allow, `false` to block, or a string to redirect.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const authGuard = ({ element }) => {
|
|
76
|
+
if (!element.state.root.isLoggedIn) return '/login'
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const roleGuard = ({ params }) => {
|
|
81
|
+
if (params.section === 'admin') return false
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
router('/dashboard', element, {}, {
|
|
86
|
+
guards: [authGuard, roleGuard]
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Guard functions receive a context object:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
{
|
|
94
|
+
pathname, // full pathname
|
|
95
|
+
route, // matched route key
|
|
96
|
+
params, // dynamic route params
|
|
97
|
+
query, // parsed query string
|
|
98
|
+
hash, // URL hash
|
|
99
|
+
element, // DOMQL element
|
|
100
|
+
state // navigation state
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 404 Handling
|
|
105
|
+
|
|
106
|
+
Provide an `onNotFound` callback for unmatched routes:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
router('/unknown', element, {}, {
|
|
110
|
+
onNotFound: ({ pathname, route, element }) => {
|
|
111
|
+
console.warn(`No route found for ${pathname}`)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
You can also define a `/*` wildcard route as a catch-all fallback.
|
|
117
|
+
|
|
118
|
+
## Custom Router Element
|
|
119
|
+
|
|
120
|
+
Use `customRouterElement` in your `config.js` to route pages into a specific element within the component tree instead of the root. This is useful for persistent layouts where only the content area changes between routes.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
// symbols/config.js
|
|
124
|
+
export default {
|
|
125
|
+
router: {
|
|
126
|
+
customRouterElement: 'Folder.Content'
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The value is a dot-separated path resolved from the root element. For example, `'Folder.Content'` means the router will find `root.Folder.Content` and render page content inside it.
|
|
132
|
+
|
|
133
|
+
This allows you to define a layout once in the `/` (main) page and have sub-pages render inside a specific container:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
// pages/main.js — defines the persistent layout
|
|
137
|
+
export const main = {
|
|
138
|
+
extends: 'Layout',
|
|
139
|
+
Folder: {
|
|
140
|
+
Content: {
|
|
141
|
+
// default content for '/' route
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// pages/team.js — only defines content (rendered inside Folder.Content)
|
|
147
|
+
export const team = {
|
|
148
|
+
state: 'team',
|
|
149
|
+
extends: 'Grid',
|
|
150
|
+
childExtends: 'TeamItem',
|
|
151
|
+
childrenAs: 'state',
|
|
152
|
+
children: (el, s) => s.data,
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Options
|
|
157
|
+
|
|
158
|
+
| Option | Type | Default | Description |
|
|
159
|
+
|---|---|---|---|
|
|
160
|
+
| `level` | `number` | `0` | Route nesting level (which path segment to match) |
|
|
161
|
+
| `pushState` | `boolean` | `true` | Push to browser history |
|
|
162
|
+
| `initialRender` | `boolean` | `false` | Whether this is the initial page render |
|
|
163
|
+
| `scrollToTop` | `boolean` | `true` | Scroll to top after navigation |
|
|
164
|
+
| `scrollToNode` | `boolean` | `false` | Scroll within the element node |
|
|
165
|
+
| `scrollNode` | `Element` | `document.documentElement` | Node to scroll |
|
|
166
|
+
| `scrollToOffset` | `number` | `0` | Offset when scrolling to hash anchors |
|
|
167
|
+
| `scrollToOptions` | `object` | `{ behavior: 'smooth' }` | Options passed to `scrollTo()` |
|
|
168
|
+
| `useFragment` | `boolean` | `false` | Use fragment tag for content |
|
|
169
|
+
| `updateState` | `boolean` | `true` | Update element state on navigation |
|
|
170
|
+
| `contentElementKey` | `string` | `'content'` | Key for the content element slot |
|
|
171
|
+
| `removeOldElement` | `boolean` | `false` | Remove old content element before setting new |
|
|
172
|
+
| `useParamsMatching` | `boolean` | `false` | Enable dynamic `:param` route matching |
|
|
173
|
+
| `guards` | `function[]` | `undefined` | Array of guard/middleware functions |
|
|
174
|
+
| `onNotFound` | `function` | `undefined` | Callback when no route matches |
|
|
175
|
+
|
|
176
|
+
## Exported Utilities
|
|
177
|
+
|
|
178
|
+
### `getActiveRoute(level, route)`
|
|
179
|
+
|
|
180
|
+
Returns the active route segment at the given nesting level.
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
import { getActiveRoute } from '@domql/router'
|
|
184
|
+
|
|
185
|
+
getActiveRoute(0, '/users/42') // '/users'
|
|
186
|
+
getActiveRoute(1, '/users/42') // '/42'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### `parseQuery(search)`
|
|
190
|
+
|
|
191
|
+
Parses a query string into an object.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import { parseQuery } from '@domql/router'
|
|
195
|
+
|
|
196
|
+
parseQuery('?page=1&sort=name') // { page: '1', sort: 'name' }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### `matchRoute(pathname, routes, level)`
|
|
200
|
+
|
|
201
|
+
Matches a pathname against a routes object. Returns `{ key, content, params }`.
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
import { matchRoute } from '@domql/router'
|
|
205
|
+
|
|
206
|
+
const routes = { '/': Home, '/:id': Detail, '/*': NotFound }
|
|
207
|
+
const result = matchRoute('/42', routes)
|
|
208
|
+
// { key: '/:id', content: Detail, params: { id: '42' } }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `parseRoutePattern(pattern)`
|
|
212
|
+
|
|
213
|
+
Parses a route pattern string into segments, param definitions, and wildcard flag. Results are cached.
|
|
214
|
+
|
|
215
|
+
### `runGuards(guards, context)`
|
|
216
|
+
|
|
217
|
+
Runs an array of async guard functions sequentially. Returns `true`, `false`, or a redirect path string.
|
|
218
|
+
|
|
219
|
+
## Events
|
|
220
|
+
|
|
221
|
+
The router triggers an `onRouteChanged` event on the element after navigation completes. Listen for it in your element definition:
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
const App = {
|
|
225
|
+
routes: { ... },
|
|
226
|
+
onRouteChanged: (element, options) => {
|
|
227
|
+
console.log('Route changed:', element.state.route)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
package/index.js
CHANGED
|
@@ -1,140 +1,106 @@
|
|
|
1
|
-
|
|
2
|
-
const { setCookie, merge } = utils.default || utils
|
|
1
|
+
'use strict'
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
import { document, window, triggerEventOn } from '@symbo.ls/utils'
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
index = 0,
|
|
8
|
-
route = window.location.pathname
|
|
9
|
-
) => {
|
|
10
|
-
const routeArray = route.split('/')
|
|
11
|
-
const activeRoute = routeArray[index + 1]
|
|
12
|
-
if (activeRoute) {
|
|
13
|
-
return `/${activeRoute}`
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const getLastLocationPath = (
|
|
18
|
-
level = 0,
|
|
19
|
-
route = window.location.pathname
|
|
20
|
-
) => {
|
|
21
|
-
const routeArray = route.split('/')
|
|
22
|
-
const activeRoute = routeArray[routeArray.length - level - 1]
|
|
23
|
-
if (activeRoute) {
|
|
24
|
-
return `/${activeRoute}`
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// --- Dynamic route matching ---
|
|
5
|
+
// --- Route matching utilities ---
|
|
29
6
|
|
|
30
7
|
const paramPattern = /^:(.+)/
|
|
8
|
+
const wildcardPattern = /^\*$/
|
|
9
|
+
|
|
31
10
|
const routeCache = new Map()
|
|
32
11
|
|
|
33
|
-
export const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const params = {}
|
|
12
|
+
export const parseRoutePattern = (pattern) => {
|
|
13
|
+
const cached = routeCache.get(pattern)
|
|
14
|
+
if (cached) return cached
|
|
37
15
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
16
|
+
const segments = pattern.replace(/^\//, '').split('/')
|
|
17
|
+
const params = []
|
|
18
|
+
let hasWildcard = false
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < segments.length; i++) {
|
|
21
|
+
const match = segments[i].match(paramPattern)
|
|
22
|
+
if (match) {
|
|
23
|
+
params.push({ index: i, name: match[1] })
|
|
24
|
+
} else if (wildcardPattern.test(segments[i])) {
|
|
25
|
+
hasWildcard = true
|
|
42
26
|
}
|
|
43
27
|
}
|
|
44
28
|
|
|
45
|
-
|
|
29
|
+
const result = { segments, params, hasWildcard, pattern }
|
|
30
|
+
routeCache.set(pattern, result)
|
|
31
|
+
return result
|
|
46
32
|
}
|
|
47
33
|
|
|
48
|
-
export const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
34
|
+
export const matchRoute = (pathname, routes, level = 0) => {
|
|
35
|
+
const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
|
|
36
|
+
const relevantSegments = pathSegments.slice(level)
|
|
37
|
+
const routePath = '/' + (relevantSegments[0] || '')
|
|
52
38
|
|
|
53
|
-
// Fast path: direct match
|
|
54
|
-
if (routes[routeKey]) {
|
|
55
|
-
return { key: routeKey, content: routes[routeKey], params: {} }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Dynamic param matching
|
|
59
39
|
let bestMatch = null
|
|
60
40
|
let bestScore = -1
|
|
61
|
-
let
|
|
41
|
+
let matchedParams = {}
|
|
62
42
|
|
|
63
43
|
for (const key in routes) {
|
|
64
44
|
if (key === '/*') continue
|
|
65
|
-
const keyParts = key.replace(/^\//, '').split('/')
|
|
66
|
-
|
|
67
|
-
let score = 0
|
|
68
|
-
let matched = true
|
|
69
|
-
const params = {}
|
|
70
|
-
const len = Math.min(keyParts.length, relevantParts.length)
|
|
71
|
-
|
|
72
|
-
if (keyParts.length > relevantParts.length) continue
|
|
73
|
-
|
|
74
|
-
for (let i = 0; i < len; i++) {
|
|
75
|
-
const paramMatch = keyParts[i].match(paramPattern)
|
|
76
|
-
if (keyParts[i] === relevantParts[i]) {
|
|
77
|
-
score += 3
|
|
78
|
-
} else if (paramMatch) {
|
|
79
|
-
params[paramMatch[1]] = decodeURIComponent(relevantParts[i])
|
|
80
|
-
score += 1
|
|
81
|
-
} else {
|
|
82
|
-
matched = false
|
|
83
|
-
break
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
45
|
|
|
87
|
-
|
|
46
|
+
const parsed = parseRoutePattern(key)
|
|
47
|
+
const score = scoreMatch(relevantSegments, parsed)
|
|
48
|
+
|
|
49
|
+
if (score > bestScore) {
|
|
88
50
|
bestScore = score
|
|
89
51
|
bestMatch = key
|
|
90
|
-
|
|
52
|
+
matchedParams = extractParams(relevantSegments, parsed)
|
|
91
53
|
}
|
|
92
54
|
}
|
|
93
55
|
|
|
94
|
-
if (bestMatch) {
|
|
95
|
-
|
|
56
|
+
if (!bestMatch && routes['/*']) {
|
|
57
|
+
bestMatch = '/*'
|
|
96
58
|
}
|
|
97
59
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
60
|
+
return {
|
|
61
|
+
key: bestMatch,
|
|
62
|
+
content: bestMatch ? routes[bestMatch] : null,
|
|
63
|
+
params: matchedParams,
|
|
64
|
+
routePath
|
|
101
65
|
}
|
|
102
|
-
|
|
103
|
-
return { key: null, content: null, params: {} }
|
|
104
66
|
}
|
|
105
67
|
|
|
106
|
-
|
|
68
|
+
const scoreMatch = (pathSegments, parsed) => {
|
|
69
|
+
const { segments, hasWildcard } = parsed
|
|
107
70
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
71
|
+
if (!hasWildcard && segments.length !== pathSegments.length &&
|
|
72
|
+
segments.length !== 1) {
|
|
73
|
+
// For single-segment patterns, match just the first segment
|
|
74
|
+
if (segments.length > pathSegments.length) return -1
|
|
75
|
+
}
|
|
113
76
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (!match.content) break
|
|
77
|
+
let score = 0
|
|
78
|
+
const len = Math.min(segments.length, pathSegments.length)
|
|
117
79
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
// Check for nested routes
|
|
126
|
-
if (match.content.routes) {
|
|
127
|
-
currentRoutes = match.content.routes
|
|
128
|
-
currentLevel++
|
|
80
|
+
for (let i = 0; i < len; i++) {
|
|
81
|
+
if (segments[i] === pathSegments[i]) {
|
|
82
|
+
score += 3 // exact match
|
|
83
|
+
} else if (paramPattern.test(segments[i])) {
|
|
84
|
+
score += 1 // param match
|
|
85
|
+
} else if (wildcardPattern.test(segments[i])) {
|
|
86
|
+
score += 0.5
|
|
129
87
|
} else {
|
|
130
|
-
|
|
88
|
+
return -1 // no match
|
|
131
89
|
}
|
|
132
90
|
}
|
|
133
91
|
|
|
134
|
-
return
|
|
92
|
+
return score
|
|
135
93
|
}
|
|
136
94
|
|
|
137
|
-
|
|
95
|
+
const extractParams = (pathSegments, parsed) => {
|
|
96
|
+
const params = {}
|
|
97
|
+
for (const { index, name } of parsed.params) {
|
|
98
|
+
if (pathSegments[index]) {
|
|
99
|
+
params[name] = decodeURIComponent(pathSegments[index])
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return params
|
|
103
|
+
}
|
|
138
104
|
|
|
139
105
|
export const parseQuery = (search) => {
|
|
140
106
|
if (!search || search === '?') return {}
|
|
@@ -151,7 +117,7 @@ export const parseQuery = (search) => {
|
|
|
151
117
|
return params
|
|
152
118
|
}
|
|
153
119
|
|
|
154
|
-
// ---
|
|
120
|
+
// --- Route guards / middleware ---
|
|
155
121
|
|
|
156
122
|
export const runGuards = async (guards, context) => {
|
|
157
123
|
if (!guards || !guards.length) return true
|
|
@@ -163,234 +129,201 @@ export const runGuards = async (guards, context) => {
|
|
|
163
129
|
return true
|
|
164
130
|
}
|
|
165
131
|
|
|
166
|
-
// ---
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
win.onpopstate = async (e) => {
|
|
174
|
-
const { pathname, search, hash } = win.location
|
|
175
|
-
const url = pathname + search + hash
|
|
176
|
-
if (element && element.call) {
|
|
177
|
-
await element.call(
|
|
178
|
-
'router',
|
|
179
|
-
url,
|
|
180
|
-
element,
|
|
181
|
-
{},
|
|
182
|
-
{ pushState: false, scrollToTop: false, level: 0, event: e }
|
|
183
|
-
)
|
|
184
|
-
} else {
|
|
185
|
-
await router(url, element, {}, { pushState: false, scrollToTop: false, level: 0, event: e })
|
|
186
|
-
}
|
|
187
|
-
}
|
|
132
|
+
// --- Core router ---
|
|
133
|
+
|
|
134
|
+
export const getActiveRoute = (level = 0, route) => {
|
|
135
|
+
if (!route) route = (typeof window !== 'undefined' ? window.location.pathname : '/')
|
|
136
|
+
const routeArray = route.split('/')
|
|
137
|
+
const activeRoute = routeArray[level + 1]
|
|
138
|
+
if (activeRoute) return `/${activeRoute}`
|
|
188
139
|
}
|
|
189
140
|
|
|
190
|
-
export let lastPathname
|
|
141
|
+
export let lastPathname
|
|
191
142
|
export let lastLevel = 0
|
|
192
143
|
|
|
144
|
+
const normalizePath = (p) => (!p || p === 'srcdoc' || p === 'about:srcdoc') ? '/' : p
|
|
145
|
+
|
|
193
146
|
const defaultOptions = {
|
|
194
|
-
window: globalThis,
|
|
195
147
|
level: lastLevel,
|
|
196
|
-
initialRender: false,
|
|
197
148
|
pushState: true,
|
|
149
|
+
initialRender: false,
|
|
198
150
|
scrollToTop: true,
|
|
199
151
|
scrollToNode: false,
|
|
200
|
-
|
|
152
|
+
scrollNode: document && document.documentElement,
|
|
153
|
+
scrollBody: false,
|
|
201
154
|
useFragment: false,
|
|
202
|
-
authCheck: true,
|
|
203
155
|
updateState: true,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
preventUpdateTriggerStateUpdate: true
|
|
207
|
-
},
|
|
208
|
-
preventUpdate: ['Canvas', 'Modal'],
|
|
209
|
-
setOptions: {},
|
|
210
|
-
scrollToOffset: 120,
|
|
156
|
+
scrollToOffset: 0,
|
|
157
|
+
contentElementKey: 'content',
|
|
211
158
|
scrollToOptions: { behavior: 'smooth' },
|
|
212
|
-
useParamsMatching:
|
|
213
|
-
useNestedRoutes: false
|
|
159
|
+
useParamsMatching: true
|
|
214
160
|
}
|
|
215
161
|
|
|
216
|
-
const
|
|
162
|
+
export const router = async (path, el, state = {}, options = {}) => {
|
|
163
|
+
const element = el || this
|
|
164
|
+
const win = element?.context?.window || window
|
|
165
|
+
const doc = element?.context?.document || document
|
|
166
|
+
const opts = {
|
|
167
|
+
...defaultOptions,
|
|
168
|
+
...element.context.routerOptions,
|
|
169
|
+
...options
|
|
170
|
+
}
|
|
171
|
+
lastLevel = opts.lastLevel
|
|
217
172
|
|
|
218
|
-
|
|
219
|
-
export async function router(path = '/', el, state = {}, passedOptions = {}) {
|
|
220
|
-
const rootElem = this && this.getRoot()
|
|
221
|
-
const element = el && el.routes ? el : rootElem
|
|
222
|
-
const options = merge(passedOptions, defaultOptions)
|
|
223
|
-
lastLevel = options.level
|
|
173
|
+
const ref = element.__ref
|
|
224
174
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
element.call('closeModal')
|
|
232
|
-
if (options.event) { options.event.preventDefault() }
|
|
175
|
+
if (
|
|
176
|
+
(opts.contentElementKey !== 'content' &&
|
|
177
|
+
opts.contentElementKey !== ref.contentElementKey) ||
|
|
178
|
+
!ref.contentElementKey
|
|
179
|
+
) {
|
|
180
|
+
ref.contentElementKey = opts.contentElementKey || 'content'
|
|
233
181
|
}
|
|
234
182
|
|
|
235
|
-
const
|
|
183
|
+
const contentElementKey = ref.contentElementKey || opts.contentElementKey || 'content'
|
|
184
|
+
|
|
185
|
+
const origin = win.location.origin !== 'null' ? win.location.origin : 'http://localhost'
|
|
186
|
+
path = normalizePath(path)
|
|
187
|
+
const urlObj = new win.URL(origin + path)
|
|
236
188
|
const { pathname, search, hash } = urlObj
|
|
237
189
|
|
|
238
190
|
const query = parseQuery(search)
|
|
239
191
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const authRedirect = element.call('redirectBasedOnAuth', rootRoute)
|
|
245
|
-
if (authRedirect && rootRoute !== authRedirect) {
|
|
246
|
-
routeArray = authRedirect.split('/')
|
|
247
|
-
}
|
|
248
|
-
}
|
|
192
|
+
const rootNode = element.node
|
|
193
|
+
const hashChanged = hash && hash !== win.location.hash.slice(1)
|
|
194
|
+
const pathChanged = pathname !== lastPathname
|
|
195
|
+
lastPathname = pathname
|
|
249
196
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
} else if (options.useParamsMatching) {
|
|
263
|
-
const match = matchRoutes(element.routes, pathname, lastLevel)
|
|
264
|
-
routeInContent = match.key
|
|
197
|
+
// Route matching - support both simple and param-based
|
|
198
|
+
let route, routePath, content, params
|
|
199
|
+
if (opts.useParamsMatching) {
|
|
200
|
+
const match = matchRoute(pathname, element.routes, opts.level)
|
|
201
|
+
// FRAMEWORK-2: state.route must reflect the matched pattern key
|
|
202
|
+
// (e.g. `/players/:id`), not the literal first URL segment, so
|
|
203
|
+
// consumers and devtools can branch on which route handler fired.
|
|
204
|
+
// The first-segment value is still exposed as `routePath` for the
|
|
205
|
+
// rare consumer that needs the URL piece directly.
|
|
206
|
+
route = match.key
|
|
207
|
+
routePath = match.routePath
|
|
265
208
|
content = match.content
|
|
266
209
|
params = match.params
|
|
267
210
|
} else {
|
|
268
|
-
|
|
269
|
-
|
|
211
|
+
route = getActiveRoute(opts.level, pathname)
|
|
212
|
+
routePath = route
|
|
213
|
+
content = element.routes[route || '/'] || element.routes['/*']
|
|
270
214
|
params = {}
|
|
271
215
|
}
|
|
272
216
|
|
|
273
|
-
|
|
274
|
-
if (options.guards && options.guards.length) {
|
|
275
|
-
const guardCtx = { pathname, route, routeInContent, params, query, hash, element, state }
|
|
276
|
-
const guardResult = await runGuards(options.guards, guardCtx)
|
|
277
|
-
if (guardResult === false) return
|
|
278
|
-
if (typeof guardResult === 'string') {
|
|
279
|
-
return router.call(this, guardResult, el, state, { ...passedOptions, guards: [] })
|
|
280
|
-
}
|
|
281
|
-
}
|
|
217
|
+
const scrollNode = opts.scrollToNode ? rootNode : opts.scrollNode
|
|
282
218
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const isInitial = options.initialRender
|
|
288
|
-
const hashChanged = hash && hash !== options.window.location.hash
|
|
289
|
-
const pathChanged = pathname !== lastPathname
|
|
290
|
-
lastPathname = pathname
|
|
219
|
+
if (element.state?.root?.debugging) {
|
|
220
|
+
element.state.root.debugging = false
|
|
221
|
+
return
|
|
222
|
+
}
|
|
291
223
|
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
options.onNotFound({ pathname, route, routeInContent, element })
|
|
224
|
+
if (!content) {
|
|
225
|
+
if (opts.onNotFound) {
|
|
226
|
+
opts.onNotFound({ pathname, route, element })
|
|
227
|
+
} else if (!opts.silent) {
|
|
228
|
+
console.warn('[smbls/router] no content matched for path', pathname, '— available routes:', Object.keys(element.routes || {}))
|
|
298
229
|
}
|
|
299
230
|
return
|
|
300
231
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
232
|
+
|
|
233
|
+
// Run guards
|
|
234
|
+
if (opts.guards && opts.guards.length) {
|
|
235
|
+
const guardContext = { pathname, route, params, query, hash, element, state }
|
|
236
|
+
const guardResult = await runGuards(opts.guards, guardContext)
|
|
237
|
+
if (guardResult === false) return
|
|
238
|
+
if (typeof guardResult === 'string') {
|
|
239
|
+
// Redirect
|
|
240
|
+
return router(guardResult, el, state, { ...options, guards: [] })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// pushState always fires before render so the URL bar stays in sync even
|
|
245
|
+
// if the content-render step below no-ops due to an extends/ref mismatch.
|
|
246
|
+
if (opts.pushState) {
|
|
247
|
+
try {
|
|
248
|
+
win.history.pushState(state, null, pathname + (search || '') + (hash || ''))
|
|
249
|
+
} catch (e) {} // expected in sandboxed iframes (e.g. about:srcdoc) where pushState is restricted
|
|
310
250
|
}
|
|
311
251
|
|
|
312
252
|
if (pathChanged || !hashChanged) {
|
|
313
|
-
const stateUpdate = { route,
|
|
253
|
+
const stateUpdate = { route, routePath, hash, debugging: false }
|
|
314
254
|
if (Object.keys(params).length) stateUpdate.params = params
|
|
315
255
|
if (Object.keys(query).length) stateUpdate.query = query
|
|
316
256
|
|
|
317
|
-
if (
|
|
318
|
-
|
|
257
|
+
if (opts.updateState) {
|
|
258
|
+
element.state.update(
|
|
319
259
|
stateUpdate,
|
|
320
|
-
{
|
|
321
|
-
preventUpdate: [options.preventUpdate, 'content'],
|
|
322
|
-
updateByRouter: true,
|
|
323
|
-
...options.updateStateOptions
|
|
324
|
-
}
|
|
260
|
+
{ preventContentUpdate: true }
|
|
325
261
|
)
|
|
326
262
|
}
|
|
327
263
|
|
|
328
|
-
if (
|
|
329
|
-
element.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
{
|
|
343
|
-
tag: options.useFragment && 'fragment',
|
|
344
|
-
extends: content
|
|
345
|
-
},
|
|
346
|
-
{
|
|
347
|
-
lastElement,
|
|
348
|
-
routerContentElement: content,
|
|
349
|
-
preventUpdate: options.preventUpdate,
|
|
350
|
-
...options.setOptions
|
|
264
|
+
if (contentElementKey && opts.removeOldElement) {
|
|
265
|
+
element[contentElementKey].remove()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Merge parent's original content definition (styles like overflow, maxHeight)
|
|
269
|
+
// with the route's content component
|
|
270
|
+
const originContent = element.__ref?.origin?.content
|
|
271
|
+
const contentStyles = {}
|
|
272
|
+
if (originContent) {
|
|
273
|
+
for (const k in originContent) {
|
|
274
|
+
const v = originContent[k]
|
|
275
|
+
if (k === '__ref' || k === 'props' || k === 'node' || k === 'parent' || k === 'key') continue
|
|
276
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'object' && v !== null && !v.node && !v.__ref)) {
|
|
277
|
+
contentStyles[k] = v
|
|
351
278
|
}
|
|
352
|
-
|
|
279
|
+
}
|
|
353
280
|
}
|
|
354
|
-
|
|
355
|
-
|
|
281
|
+
|
|
282
|
+
const nextContent = {
|
|
283
|
+
...contentStyles,
|
|
284
|
+
...(typeof content === 'object' ? content : { extends: content })
|
|
285
|
+
}
|
|
286
|
+
if (opts.useFragment) nextContent.tag = 'fragment'
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
element.set(nextContent, { contentElementKey })
|
|
290
|
+
} catch (err) {
|
|
291
|
+
console.error('[smbls/router] failed to render route content', pathname, err)
|
|
356
292
|
}
|
|
357
293
|
}
|
|
358
294
|
|
|
359
|
-
|
|
360
|
-
const scrollNode = options.scrollDocument
|
|
361
|
-
? options.window.document.documentElement
|
|
362
|
-
: rootNode
|
|
363
|
-
if (options.scrollToTop) {
|
|
295
|
+
if (opts.scrollToTop && scrollNode?.scrollTo) {
|
|
364
296
|
scrollNode.scrollTo({
|
|
365
|
-
...(
|
|
297
|
+
...(opts.scrollToOptions || {}),
|
|
366
298
|
top: 0,
|
|
367
299
|
left: 0
|
|
368
300
|
})
|
|
369
301
|
}
|
|
370
|
-
if (
|
|
371
|
-
content.
|
|
372
|
-
...(
|
|
302
|
+
if (opts.scrollToNode && content[contentElementKey]?.node?.scrollTo) {
|
|
303
|
+
content[contentElementKey].node.scrollTo({
|
|
304
|
+
...(opts.scrollToOptions || {}),
|
|
373
305
|
top: 0,
|
|
374
306
|
left: 0
|
|
375
307
|
})
|
|
376
308
|
}
|
|
377
309
|
|
|
378
310
|
if (hash) {
|
|
379
|
-
const activeNode =
|
|
380
|
-
if (activeNode) {
|
|
311
|
+
const activeNode = doc.getElementById(hash)
|
|
312
|
+
if (activeNode && scrollNode?.scrollTo) {
|
|
381
313
|
const top =
|
|
382
314
|
activeNode.getBoundingClientRect().top +
|
|
383
315
|
rootNode.scrollTop -
|
|
384
|
-
(
|
|
316
|
+
(opts.scrollToOffset || 0)
|
|
385
317
|
scrollNode.scrollTo({
|
|
386
|
-
...(
|
|
318
|
+
...(opts.scrollToOptions || {}),
|
|
387
319
|
top,
|
|
388
320
|
left: 0
|
|
389
321
|
})
|
|
390
322
|
}
|
|
391
323
|
}
|
|
392
324
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
325
|
+
// trigger `on.routeChanged`
|
|
326
|
+
triggerEventOn('routeChanged', element, opts)
|
|
396
327
|
}
|
|
328
|
+
|
|
329
|
+
export default router
|
package/package.json
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbo.ls/router",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"
|
|
5
|
-
"license": "MIT",
|
|
3
|
+
"version": "3.14.1",
|
|
4
|
+
"license": "CC-BY-NC-4.0",
|
|
6
5
|
"type": "module",
|
|
6
|
+
"module": "./index.js",
|
|
7
|
+
"unpkg": "./dist/iife/index.js",
|
|
8
|
+
"jsdelivr": "./dist/iife/index.js",
|
|
9
|
+
"main": "./index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"source": "index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"*.js"
|
|
18
|
+
],
|
|
7
19
|
"scripts": {
|
|
8
|
-
"
|
|
20
|
+
"copy:package:cjs": "cp ../../build/package-cjs.json dist/cjs/package.json",
|
|
21
|
+
"build:esm": "NODE_ENV=$NODE_ENV esbuild *.js --target=es2020 --format=esm --outdir=dist/esm --define:process.env.NODE_ENV=process.env.NODE_ENV",
|
|
22
|
+
"build:cjs": "NODE_ENV=$NODE_ENV esbuild *.js --target=node18 --format=cjs --outdir=dist/cjs --define:process.env.NODE_ENV=process.env.NODE_ENV",
|
|
23
|
+
"build:iife": "NODE_ENV=$NODE_ENV esbuild index.js --bundle --target=es2020 --format=iife --global-name=DomqlRouter --outfile=dist/iife/index.js --define:process.env.NODE_ENV=process.env.NODE_ENV",
|
|
24
|
+
"build": "node ../../build/build.js",
|
|
25
|
+
"prepublish": "npm run build && npm run copy:package:cjs"
|
|
9
26
|
},
|
|
10
27
|
"dependencies": {
|
|
11
|
-
"@
|
|
28
|
+
"@symbo.ls/element": "^3.14.3",
|
|
29
|
+
"@symbo.ls/utils": "^3.14.2"
|
|
30
|
+
},
|
|
31
|
+
"gitHead": "9fc1b79b41cdc725ca6b24aec64920a599634681",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@babel/core": "^7.26.0"
|
|
12
34
|
},
|
|
13
|
-
"
|
|
14
|
-
"
|
|
35
|
+
"browser": "./index.js",
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
15
40
|
}
|
package/location.js
DELETED