@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.
Files changed (5) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +234 -0
  3. package/index.js +193 -260
  4. package/package.json +32 -7
  5. 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
- import * as utils from '@domql/utils'
2
- const { setCookie, merge } = utils.default || utils
1
+ 'use strict'
3
2
 
4
- export * from './location.js'
3
+ import { document, window, triggerEventOn } from '@symbo.ls/utils'
5
4
 
6
- export const getLocationPath = (
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 parseRouteParams = (pattern, pathname) => {
34
- const patternParts = pattern.replace(/^\//, '').split('/')
35
- const pathParts = pathname.replace(/^\//, '').split('/').filter(Boolean)
36
- const params = {}
12
+ export const parseRoutePattern = (pattern) => {
13
+ const cached = routeCache.get(pattern)
14
+ if (cached) return cached
37
15
 
38
- for (let i = 0; i < patternParts.length; i++) {
39
- const match = patternParts[i].match(paramPattern)
40
- if (match && pathParts[i]) {
41
- params[match[1]] = decodeURIComponent(pathParts[i])
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
- return params
29
+ const result = { segments, params, hasWildcard, pattern }
30
+ routeCache.set(pattern, result)
31
+ return result
46
32
  }
47
33
 
48
- export const matchRoutes = (routes, pathname, level = 0) => {
49
- const pathParts = pathname.replace(/^\//, '').split('/').filter(Boolean)
50
- const relevantParts = pathParts.slice(level)
51
- const routeKey = '/' + (relevantParts[0] || '')
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 bestParams = {}
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
- if (matched && score > bestScore) {
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
- bestParams = params
52
+ matchedParams = extractParams(relevantSegments, parsed)
91
53
  }
92
54
  }
93
55
 
94
- if (bestMatch) {
95
- return { key: bestMatch, content: routes[bestMatch], params: bestParams }
56
+ if (!bestMatch && routes['/*']) {
57
+ bestMatch = '/*'
96
58
  }
97
59
 
98
- // Fallback to wildcard
99
- if (routes['/*']) {
100
- return { key: '/*', content: routes['/*'], params: {} }
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
- // --- Nested route resolution ---
68
+ const scoreMatch = (pathSegments, parsed) => {
69
+ const { segments, hasWildcard } = parsed
107
70
 
108
- export const resolveNestedRoute = (routes, pathname, level = 0) => {
109
- const segments = pathname.replace(/^\//, '').split('/').filter(Boolean)
110
- const chain = []
111
- let currentRoutes = routes
112
- let currentLevel = level
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
- while (currentLevel < segments.length && currentRoutes) {
115
- const match = matchRoutes(currentRoutes, pathname, currentLevel)
116
- if (!match.content) break
77
+ let score = 0
78
+ const len = Math.min(segments.length, pathSegments.length)
117
79
 
118
- chain.push({
119
- level: currentLevel,
120
- key: match.key,
121
- content: match.content,
122
- params: match.params
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
- break
88
+ return -1 // no match
131
89
  }
132
90
  }
133
91
 
134
- return chain
92
+ return score
135
93
  }
136
94
 
137
- // --- Query string parsing ---
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
- // --- Guards / Middleware ---
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
- // --- Pop state handler ---
167
-
168
- let popStateFired = false
169
- export const onPopState = (element) => {
170
- if (popStateFired) return
171
- popStateFired = true
172
- const win = (element && element.context && element.context.window) || globalThis
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 = null
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
- scrollDocument: false,
152
+ scrollNode: document && document.documentElement,
153
+ scrollBody: false,
201
154
  useFragment: false,
202
- authCheck: true,
203
155
  updateState: true,
204
- updateStateOptions: {
205
- preventUpdate: ['Canvas'],
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: false,
213
- useNestedRoutes: false
159
+ useParamsMatching: true
214
160
  }
215
161
 
216
- const lastElement = {}
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
- // eslint-disable-next-line default-param-last
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
- const isModal = element.getRootState('activeModal')
226
- if (isModal) {
227
- if (options.event) {
228
- options.event.preventDefault()
229
- options.event.stopPropagation()
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 urlObj = new options.window.URL(options.window.location.origin + path)
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 rootRoute = getLocationPath(0, path)
241
- let routeArray = pathname.split('/')
242
-
243
- if (options.authCheck) {
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
- const route = routeArray.join('/').replace('//', '/')
251
-
252
- // Route matching - support dynamic params and nested routes
253
- let routeInContent, content, params
254
- if (options.useNestedRoutes) {
255
- const chain = resolveNestedRoute(element.routes, pathname, lastLevel)
256
- const leaf = chain[chain.length - 1]
257
- if (leaf) {
258
- routeInContent = leaf.key
259
- content = leaf.content
260
- params = chain.reduce((acc, c) => ({ ...acc, ...c.params }), {})
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
- routeInContent = `/${routeArray[lastLevel + 1] || ''}`
269
- content = element.routes[routeInContent] || element.routes['/*']
211
+ route = getActiveRoute(opts.level, pathname)
212
+ routePath = route
213
+ content = element.routes[route || '/'] || element.routes['/*']
270
214
  params = {}
271
215
  }
272
216
 
273
- // Run custom guards
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
- const wasModal = element.call(
284
- 'isModalRoute',
285
- options.window.location.pathname.split('#')[0]
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
- !content ||
294
- (element.getRootState('debugging') && !options.allowRouterWhileInspect)
295
- ) {
296
- if (options.onNotFound) {
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
- if (options.pushState && options.level === 0) {
302
- options.window.history.pushState(
303
- {
304
- prevUrl: options.window.location.href,
305
- ...state
306
- },
307
- null,
308
- route + (search ? `${search}` : '') + (hash ? `${hash}` : '')
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, hash, routeInContent, debugging: false }
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 (options.updateState) {
318
- await element.state.update(
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.content &&
330
- (options.forceContentUpdate || (!isInitial && wasModal))
331
- ) {
332
- await element.content?.update(
333
- {},
334
- {
335
- preventUpdate: options.preventUpdate
336
- }
337
- )
338
- } else {
339
- lastElement.content = content
340
- element.removeContent()
341
- await element.set(
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
- if (element.content) {
355
- element.content.__ref.__componentKey = route
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
- const rootNode = element.node
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
- ...(options.scrollToOptions || {}),
297
+ ...(opts.scrollToOptions || {}),
366
298
  top: 0,
367
299
  left: 0
368
300
  })
369
301
  }
370
- if (options.scrollToNode) {
371
- content.content.node.scrollTo({
372
- ...(options.scrollToOptions || {}),
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 = options.window.document.getElementById(hash.slice(1))
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
- (options.scrollToOffset || 0)
316
+ (opts.scrollToOffset || 0)
385
317
  scrollNode.scrollTo({
386
- ...(options.scrollToOptions || {}),
318
+ ...(opts.scrollToOptions || {}),
387
319
  top,
388
320
  left: 0
389
321
  })
390
322
  }
391
323
  }
392
324
 
393
- if (route !== '/*') {
394
- setCookie('route', route)
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.5.1",
4
- "main": "index.js",
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
- "vpatch": "npm version patch && npm publish --access public"
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
- "@domql/utils": "^3.5.1"
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
- "gitHead": "0be12bcdd99c265fdeded6d756a1a3616a943ffc",
14
- "source": "index.js"
35
+ "browser": "./index.js",
36
+ "sideEffects": false,
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
15
40
  }
package/location.js DELETED
@@ -1,3 +0,0 @@
1
- export const onLeave = msg => {
2
- window.onbeforeunload = () => msg || 'Are you sure you want to leave?'
3
- }