@symbo.ls/router 3.5.1 → 3.14.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.
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,194 @@ 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, content, params
199
+ if (opts.useParamsMatching) {
200
+ const match = matchRoute(pathname, element.routes, opts.level)
201
+ route = match.routePath
265
202
  content = match.content
266
203
  params = match.params
267
204
  } else {
268
- routeInContent = `/${routeArray[lastLevel + 1] || ''}`
269
- content = element.routes[routeInContent] || element.routes['/*']
205
+ route = getActiveRoute(opts.level, pathname)
206
+ content = element.routes[route || '/'] || element.routes['/*']
270
207
  params = {}
271
208
  }
272
209
 
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
- }
210
+ const scrollNode = opts.scrollToNode ? rootNode : opts.scrollNode
282
211
 
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
212
+ if (element.state?.root?.debugging) {
213
+ element.state.root.debugging = false
214
+ return
215
+ }
291
216
 
292
- if (
293
- !content ||
294
- (element.getRootState('debugging') && !options.allowRouterWhileInspect)
295
- ) {
296
- if (options.onNotFound) {
297
- options.onNotFound({ pathname, route, routeInContent, element })
217
+ if (!content) {
218
+ if (opts.onNotFound) {
219
+ opts.onNotFound({ pathname, route, element })
220
+ } else if (!opts.silent) {
221
+ console.warn('[smbls/router] no content matched for path', pathname, '— available routes:', Object.keys(element.routes || {}))
298
222
  }
299
223
  return
300
224
  }
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
- )
225
+
226
+ // Run guards
227
+ if (opts.guards && opts.guards.length) {
228
+ const guardContext = { pathname, route, params, query, hash, element, state }
229
+ const guardResult = await runGuards(opts.guards, guardContext)
230
+ if (guardResult === false) return
231
+ if (typeof guardResult === 'string') {
232
+ // Redirect
233
+ return router(guardResult, el, state, { ...options, guards: [] })
234
+ }
235
+ }
236
+
237
+ // pushState always fires before render so the URL bar stays in sync even
238
+ // if the content-render step below no-ops due to an extends/ref mismatch.
239
+ if (opts.pushState) {
240
+ try {
241
+ win.history.pushState(state, null, pathname + (search || '') + (hash || ''))
242
+ } catch (e) {} // expected in sandboxed iframes (e.g. about:srcdoc) where pushState is restricted
310
243
  }
311
244
 
312
245
  if (pathChanged || !hashChanged) {
313
- const stateUpdate = { route, hash, routeInContent, debugging: false }
246
+ const stateUpdate = { route, hash, debugging: false }
314
247
  if (Object.keys(params).length) stateUpdate.params = params
315
248
  if (Object.keys(query).length) stateUpdate.query = query
316
249
 
317
- if (options.updateState) {
318
- await element.state.update(
250
+ if (opts.updateState) {
251
+ element.state.update(
319
252
  stateUpdate,
320
- {
321
- preventUpdate: [options.preventUpdate, 'content'],
322
- updateByRouter: true,
323
- ...options.updateStateOptions
324
- }
253
+ { preventContentUpdate: true }
325
254
  )
326
255
  }
327
256
 
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
257
+ if (contentElementKey && opts.removeOldElement) {
258
+ element[contentElementKey].remove()
259
+ }
260
+
261
+ // Merge parent's original content definition (styles like overflow, maxHeight)
262
+ // with the route's content component
263
+ const originContent = element.__ref?.origin?.content
264
+ const contentStyles = {}
265
+ if (originContent) {
266
+ for (const k in originContent) {
267
+ const v = originContent[k]
268
+ if (k === '__ref' || k === 'props' || k === 'node' || k === 'parent' || k === 'key') continue
269
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'object' && v !== null && !v.node && !v.__ref)) {
270
+ contentStyles[k] = v
351
271
  }
352
- )
272
+ }
353
273
  }
354
- if (element.content) {
355
- element.content.__ref.__componentKey = route
274
+
275
+ const nextContent = {
276
+ ...contentStyles,
277
+ ...(typeof content === 'object' ? content : { extends: content })
278
+ }
279
+ if (opts.useFragment) nextContent.tag = 'fragment'
280
+
281
+ try {
282
+ element.set(nextContent, { contentElementKey })
283
+ } catch (err) {
284
+ console.error('[smbls/router] failed to render route content', pathname, err)
356
285
  }
357
286
  }
358
287
 
359
- const rootNode = element.node
360
- const scrollNode = options.scrollDocument
361
- ? options.window.document.documentElement
362
- : rootNode
363
- if (options.scrollToTop) {
288
+ if (opts.scrollToTop && scrollNode?.scrollTo) {
364
289
  scrollNode.scrollTo({
365
- ...(options.scrollToOptions || {}),
290
+ ...(opts.scrollToOptions || {}),
366
291
  top: 0,
367
292
  left: 0
368
293
  })
369
294
  }
370
- if (options.scrollToNode) {
371
- content.content.node.scrollTo({
372
- ...(options.scrollToOptions || {}),
295
+ if (opts.scrollToNode && content[contentElementKey]?.node?.scrollTo) {
296
+ content[contentElementKey].node.scrollTo({
297
+ ...(opts.scrollToOptions || {}),
373
298
  top: 0,
374
299
  left: 0
375
300
  })
376
301
  }
377
302
 
378
303
  if (hash) {
379
- const activeNode = options.window.document.getElementById(hash.slice(1))
380
- if (activeNode) {
304
+ const activeNode = doc.getElementById(hash)
305
+ if (activeNode && scrollNode?.scrollTo) {
381
306
  const top =
382
307
  activeNode.getBoundingClientRect().top +
383
308
  rootNode.scrollTop -
384
- (options.scrollToOffset || 0)
309
+ (opts.scrollToOffset || 0)
385
310
  scrollNode.scrollTo({
386
- ...(options.scrollToOptions || {}),
311
+ ...(opts.scrollToOptions || {}),
387
312
  top,
388
313
  left: 0
389
314
  })
390
315
  }
391
316
  }
392
317
 
393
- if (route !== '/*') {
394
- setCookie('route', route)
395
- }
318
+ // trigger `on.routeChanged`
319
+ triggerEventOn('routeChanged', element, opts)
396
320
  }
321
+
322
+ 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.0",
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.0",
29
+ "@symbo.ls/utils": "^3.14.0"
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
- }