@symbo.ls/router 3.1.2 → 3.3.0

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 (2) hide show
  1. package/index.js +240 -39
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -25,6 +25,168 @@ export const getLastLocationPath = (
25
25
  }
26
26
  }
27
27
 
28
+ // --- Dynamic route matching ---
29
+
30
+ const paramPattern = /^:(.+)/
31
+ const routeCache = new Map()
32
+
33
+ export const parseRouteParams = (pattern, pathname) => {
34
+ const patternParts = pattern.replace(/^\//, '').split('/')
35
+ const pathParts = pathname.replace(/^\//, '').split('/').filter(Boolean)
36
+ const params = {}
37
+
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])
42
+ }
43
+ }
44
+
45
+ return params
46
+ }
47
+
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] || '')
52
+
53
+ // Fast path: direct match
54
+ if (routes[routeKey]) {
55
+ return { key: routeKey, content: routes[routeKey], params: {} }
56
+ }
57
+
58
+ // Dynamic param matching
59
+ let bestMatch = null
60
+ let bestScore = -1
61
+ let bestParams = {}
62
+
63
+ for (const key in routes) {
64
+ 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
+
87
+ if (matched && score > bestScore) {
88
+ bestScore = score
89
+ bestMatch = key
90
+ bestParams = params
91
+ }
92
+ }
93
+
94
+ if (bestMatch) {
95
+ return { key: bestMatch, content: routes[bestMatch], params: bestParams }
96
+ }
97
+
98
+ // Fallback to wildcard
99
+ if (routes['/*']) {
100
+ return { key: '/*', content: routes['/*'], params: {} }
101
+ }
102
+
103
+ return { key: null, content: null, params: {} }
104
+ }
105
+
106
+ // --- Nested route resolution ---
107
+
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
113
+
114
+ while (currentLevel < segments.length && currentRoutes) {
115
+ const match = matchRoutes(currentRoutes, pathname, currentLevel)
116
+ if (!match.content) break
117
+
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++
129
+ } else {
130
+ break
131
+ }
132
+ }
133
+
134
+ return chain
135
+ }
136
+
137
+ // --- Query string parsing ---
138
+
139
+ export const parseQuery = (search) => {
140
+ if (!search || search === '?') return {}
141
+ const params = {}
142
+ const searchParams = new URLSearchParams(search)
143
+ searchParams.forEach((value, key) => {
144
+ if (params[key] !== undefined) {
145
+ if (!Array.isArray(params[key])) params[key] = [params[key]]
146
+ params[key].push(value)
147
+ } else {
148
+ params[key] = value
149
+ }
150
+ })
151
+ return params
152
+ }
153
+
154
+ // --- Guards / Middleware ---
155
+
156
+ export const runGuards = async (guards, context) => {
157
+ if (!guards || !guards.length) return true
158
+ for (const guard of guards) {
159
+ const result = await guard(context)
160
+ if (result === false) return false
161
+ if (typeof result === 'string') return result // redirect path
162
+ }
163
+ return true
164
+ }
165
+
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
+ }
188
+ }
189
+
28
190
  export let lastPathname = null
29
191
  export let lastLevel = 0
30
192
 
@@ -42,18 +204,21 @@ const defaultOptions = {
42
204
  updateStateOptions: {
43
205
  preventUpdate: ['Canvas'],
44
206
  preventUpdateTriggerStateUpdate: true
45
- // preventContentUpdate: true,
46
- // preventUpdate: ['MainHeaderCaptions', 'HeaderSubAside']
47
207
  },
48
208
  preventUpdate: ['Canvas', 'Modal'],
49
209
  setOptions: {},
50
210
  scrollToOffset: 120,
51
- scrollToOptions: { behavior: 'smooth' }
211
+ scrollToOptions: { behavior: 'smooth' },
212
+ useParamsMatching: false,
213
+ useNestedRoutes: false
52
214
  }
53
215
 
216
+ const lastElement = {}
217
+
54
218
  // eslint-disable-next-line default-param-last
55
- export async function router (path = '/', el, state = {}, passedOptions = {}) {
56
- const element = el || (this && this.getRoot())
219
+ export async function router(path = '/', el, state = {}, passedOptions = {}) {
220
+ const rootElem = this && this.getRoot()
221
+ const element = el && el.routes ? el : rootElem
57
222
  const options = merge(passedOptions, defaultOptions)
58
223
  lastLevel = options.level
59
224
 
@@ -63,12 +228,15 @@ export async function router (path = '/', el, state = {}, passedOptions = {}) {
63
228
  options.event.preventDefault()
64
229
  options.event.stopPropagation()
65
230
  }
66
- return element.call('closeModal')
231
+ element.call('closeModal')
232
+ if (options.event) { options.event.preventDefault() }
67
233
  }
68
234
 
69
235
  const urlObj = new options.window.URL(options.window.location.origin + path)
70
236
  const { pathname, search, hash } = urlObj
71
237
 
238
+ const query = parseQuery(search)
239
+
72
240
  const rootRoute = getLocationPath(0, path)
73
241
  let routeArray = pathname.split('/')
74
242
 
@@ -80,12 +248,37 @@ export async function router (path = '/', el, state = {}, passedOptions = {}) {
80
248
  }
81
249
 
82
250
  const route = routeArray.join('/').replace('//', '/')
83
- const routeInContent = `/${routeArray[lastLevel + 1] || ''}`
84
251
 
85
- // const isPreview = location.pathname.includes('/editor')
86
- // const pages = element.routes || element.context.pages
87
- // const content = pages[routeInContent] || pages['/*']
88
- const content = element.routes[routeInContent] || element.routes['/*']
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
265
+ content = match.content
266
+ params = match.params
267
+ } else {
268
+ routeInContent = `/${routeArray[lastLevel + 1] || ''}`
269
+ content = element.routes[routeInContent] || element.routes['/*']
270
+ params = {}
271
+ }
272
+
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
+ }
89
282
 
90
283
  const wasModal = element.call(
91
284
  'isModalRoute',
@@ -100,6 +293,9 @@ export async function router (path = '/', el, state = {}, passedOptions = {}) {
100
293
  !content ||
101
294
  (element.getRootState('debugging') && !options.allowRouterWhileInspect)
102
295
  ) {
296
+ if (options.onNotFound) {
297
+ options.onNotFound({ pathname, route, routeInContent, element })
298
+ }
103
299
  return
104
300
  }
105
301
  if (options.pushState && options.level === 0) {
@@ -114,11 +310,13 @@ export async function router (path = '/', el, state = {}, passedOptions = {}) {
114
310
  }
115
311
 
116
312
  if (pathChanged || !hashChanged) {
117
- // || (!rootRouteHasChanged && { preventUpdateTriggerStateUpdate: true, preventContentUpdate: true })
313
+ const stateUpdate = { route, hash, routeInContent, debugging: false }
314
+ if (Object.keys(params).length) stateUpdate.params = params
315
+ if (Object.keys(query).length) stateUpdate.query = query
118
316
 
119
317
  if (options.updateState) {
120
318
  await element.state.update(
121
- { route, hash, routeInContent, debugging: false },
319
+ stateUpdate,
122
320
  {
123
321
  preventUpdate: [options.preventUpdate, 'content'],
124
322
  updateByRouter: true,
@@ -127,31 +325,34 @@ export async function router (path = '/', el, state = {}, passedOptions = {}) {
127
325
  )
128
326
  }
129
327
 
130
- const hasSet = isInitial || !isInitial
131
- if (hasSet) {
132
- if (options.forceContentUpdate || (!isInitial && wasModal)) {
133
- await element.content.update(
134
- {},
135
- {
136
- preventUpdate: options.preventUpdate
137
- }
138
- )
139
- } else {
140
- await element.set(
141
- {
142
- tag: options.useFragment && 'fragment',
143
- extends: content
144
- },
145
- {
146
- // ...(!isInitial && wasModal && !options.forceContentUpdate && { preventContentUpdate: true }),
147
- preventUpdate: options.preventUpdate,
148
- ...options.setOptions
149
- }
150
- )
151
- }
152
- if (element.content) {
153
- element.content.__ref.__componentKey = route
154
- }
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
351
+ }
352
+ )
353
+ }
354
+ if (element.content) {
355
+ element.content.__ref.__componentKey = route
155
356
  }
156
357
  }
157
358
 
@@ -180,7 +381,7 @@ export async function router (path = '/', el, state = {}, passedOptions = {}) {
180
381
  const top =
181
382
  activeNode.getBoundingClientRect().top +
182
383
  rootNode.scrollTop -
183
- options.scrollToOffset || 0
384
+ (options.scrollToOffset || 0)
184
385
  scrollNode.scrollTo({
185
386
  ...(options.scrollToOptions || {}),
186
387
  top,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/router",
3
- "version": "3.1.2",
3
+ "version": "3.3.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,8 +8,8 @@
8
8
  "vpatch": "npm version patch && npm publish --access public"
9
9
  },
10
10
  "dependencies": {
11
- "@domql/utils": "^3.1.2"
11
+ "@domql/utils": "^3.2.3"
12
12
  },
13
- "gitHead": "429b36616aa04c8587a26ce3c129815115e35897",
13
+ "gitHead": "0be12bcdd99c265fdeded6d756a1a3616a943ffc",
14
14
  "source": "index.js"
15
15
  }