@plastic-js/plastic 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/router.js ADDED
@@ -0,0 +1,919 @@
1
+ import {
2
+ Match,
3
+ createContext,
4
+ createSignal,
5
+ h,
6
+ materializeNode,
7
+ registerCleanup,
8
+ useContext,
9
+ } from './jsx-runtime.js'
10
+
11
+ const normalizeTarget = (value)=> {
12
+ let target = String(value ?? '/').trim()
13
+ if (!target){
14
+ target = '/'
15
+ }
16
+ if (!target.startsWith('/')){
17
+ target = `/${target}`
18
+ }
19
+ return target
20
+ }
21
+
22
+ const createUrl = value=> new URL(normalizeTarget(value), 'https://plastic.local')
23
+
24
+ const stripQueryAndHash = target=> createUrl(target).pathname || '/'
25
+
26
+ const splitSegments = (path)=> {
27
+ const normalized = normalizePath(path)
28
+ if (normalized === '/'){
29
+ return []
30
+ }
31
+ return normalized.slice(1).split('/')
32
+ }
33
+
34
+ const parseQuery = (search)=> {
35
+ const query = {}
36
+ const params = new URLSearchParams(search || '')
37
+ for (const [key, value] of params.entries()){
38
+ if (Object.prototype.hasOwnProperty.call(query, key)){
39
+ const current = query[key]
40
+ query[key] = Array.isArray(current) ? [...current, value] : [current, value]
41
+ continue
42
+ }
43
+ query[key] = value
44
+ }
45
+ return query
46
+ }
47
+
48
+ const toSearchString = (input)=> {
49
+ if (typeof input === 'string'){
50
+ if (!input){
51
+ return ''
52
+ }
53
+ return input.startsWith('?') ? input : `?${input}`
54
+ }
55
+
56
+ const params = new URLSearchParams()
57
+ if (input instanceof URLSearchParams){
58
+ return input.toString() ? `?${input.toString()}` : ''
59
+ }
60
+
61
+ if (!input || typeof input !== 'object'){
62
+ return ''
63
+ }
64
+
65
+ Object.entries(input).forEach(([key, value])=> {
66
+ if (value == null){
67
+ return
68
+ }
69
+ if (Array.isArray(value)){
70
+ value.forEach((entry)=> {
71
+ if (entry != null){
72
+ params.append(key, String(entry))
73
+ }
74
+ })
75
+ return
76
+ }
77
+ params.set(key, String(value))
78
+ })
79
+
80
+ const text = params.toString()
81
+ return text ? `?${text}` : ''
82
+ }
83
+
84
+ const createLocation = (value)=> {
85
+ const url = createUrl(value)
86
+ const pathname = normalizePath(url.pathname || '/')
87
+ const search = url.search || ''
88
+ const hash = url.hash || ''
89
+ return {
90
+ pathname,
91
+ search,
92
+ hash,
93
+ query: parseQuery(search),
94
+ }
95
+ }
96
+
97
+ const normalizePath = (value)=> {
98
+ let path = stripQueryAndHash(normalizeTarget(value))
99
+ path = path.replace(/\/{2,}/g, '/')
100
+ if (path.length > 1){
101
+ path = path.replace(/\/+$/, '')
102
+ }
103
+ return path || '/'
104
+ }
105
+
106
+ const ensureTrailingSlash = (path)=> {
107
+ const normalized = normalizePath(path)
108
+ if (normalized === '/'){
109
+ return '/'
110
+ }
111
+ return normalized.endsWith('/') ? normalized : `${normalized}/`
112
+ }
113
+
114
+ const hasUriScheme = value=> (/^[A-Za-z][A-Za-z\d+\-.]*:/).test(value)
115
+
116
+ const isRelativePathValue = (value)=> {
117
+ if (typeof value !== 'string'){
118
+ return false
119
+ }
120
+ if (!value || value.startsWith('/')){
121
+ return false
122
+ }
123
+ if (value.startsWith('//') || hasUriScheme(value)){
124
+ return false
125
+ }
126
+ return true
127
+ }
128
+
129
+ const resolveMatchPath = (routeMatch)=> {
130
+ if (!routeMatch || routeMatch.path === '*'){
131
+ return null
132
+ }
133
+
134
+ const params = routeMatch.params || {}
135
+ const resolved = String(routeMatch.path)
136
+ .replace(/:([A-Za-z0-9_]+)/g, (_, key)=> {
137
+ const value = params[key]
138
+ if (value == null){
139
+ return `:${key}`
140
+ }
141
+ return encodeURIComponent(String(value))
142
+ })
143
+
144
+ return normalizePath(resolved)
145
+ }
146
+
147
+ const resolveRelativeTarget = (target, basePath)=> {
148
+ const url = new URL(target, `https://plastic.local${ensureTrailingSlash(basePath)}`)
149
+ return `${normalizePath(url.pathname || '/')}${url.search || ''}${url.hash || ''}`
150
+ }
151
+
152
+ const resolveNavigationTarget = ({
153
+ target,
154
+ routeMatch,
155
+ currentPath,
156
+ })=> {
157
+ const value = typeof target === 'function' ? target() : target
158
+ if (!isRelativePathValue(value)){
159
+ return normalizeTarget(value)
160
+ }
161
+
162
+ const basePath = resolveMatchPath(routeMatch) || normalizePath(currentPath || '/')
163
+ return resolveRelativeTarget(value, basePath)
164
+ }
165
+
166
+ // Contexts ─────────────────────────────────────────────────────────────────
167
+ // RouterContext: { currentPath: ()=> string, currentLocation: Signal<Location>, basePath: string }
168
+ // Provided by Router; updated by each parent Route so nested branches can
169
+ // read the accumulated base path.
170
+ const RouterContext = createContext(null)
171
+
172
+ // RouteMatchContext: RouteMatch
173
+ // Provided by buildRouteMatch so route components can read params/search/query.
174
+ const RouteMatchContext = createContext(null)
175
+
176
+ // NestedRoutesContext: RouteDescriptor[]
177
+ // Provided by a parent Route so that <Outlet> inside the component knows
178
+ // which child routes to render.
179
+ const NestedRoutesContext = createContext(null)
180
+
181
+ // Helpers ──────────────────────────────────────────────────────────────────
182
+
183
+ // Join a base path and a route segment, both already normalised.
184
+ // joinPaths('/settings', '/profile') → '/settings/profile'
185
+ // joinPaths('/', '/profile') → '/profile'
186
+ // joinPaths('/settings', '/') → '/settings' (index route)
187
+ const joinPaths = (base, segment)=> {
188
+ if (segment === '*'){
189
+ return '*'
190
+ }
191
+ if (segment === '/' || segment === ''){
192
+ return normalizePath(base)
193
+ }
194
+ const cleanBase = base === '/' ? '' : normalizePath(base)
195
+ const cleanSegment = segment.startsWith('/') ? segment : `/${segment}`
196
+ return normalizePath(cleanBase + cleanSegment)
197
+ }
198
+
199
+ const createRouteMatcher = (routePath)=> {
200
+ const routeSegments = splitSegments(routePath)
201
+ const matchSegments = (pathname, isPrefix)=> {
202
+ const pathSegments = splitSegments(pathname)
203
+ if (!isPrefix && routeSegments.length !== pathSegments.length){
204
+ return null
205
+ }
206
+ if (isPrefix && routeSegments.length > pathSegments.length){
207
+ return null
208
+ }
209
+
210
+ const params = {}
211
+ for (const [index, segment] of routeSegments.entries()){
212
+ const current = pathSegments[index]
213
+ if (current === undefined){
214
+ return null
215
+ }
216
+
217
+ if (segment.startsWith(':') && segment.length > 1){
218
+ params[segment.slice(1)] = current
219
+ continue
220
+ }
221
+
222
+ if (segment !== current){
223
+ return null
224
+ }
225
+ }
226
+
227
+ return params
228
+ }
229
+
230
+ return {
231
+ matchExact: pathname=> matchSegments(pathname, false),
232
+ matchPrefix: pathname=> matchSegments(pathname, true),
233
+ }
234
+ }
235
+
236
+ const createRouteMatchInfo = ({
237
+ routePath, pathname, params, location,
238
+ })=> ({
239
+ path: routePath,
240
+ pathname,
241
+ params,
242
+ search: location.search,
243
+ hash: location.hash,
244
+ query: location.query,
245
+ })
246
+
247
+ const normalizeGuardRedirect = (result)=> {
248
+ if (typeof result === 'string' || result instanceof URLSearchParams){
249
+ return String(result)
250
+ }
251
+
252
+ if (!result || typeof result !== 'object' || Array.isArray(result)){
253
+ return null
254
+ }
255
+
256
+ if (typeof result.to === 'string' || result.to instanceof URLSearchParams){
257
+ return String(result.to)
258
+ }
259
+
260
+ if (typeof result.pathname !== 'string'){
261
+ return null
262
+ }
263
+
264
+ const search = result.search ? toSearchString(result.search) : ''
265
+ const hash = result.hash ? String(result.hash).startsWith('#') ? String(result.hash) : `#${String(result.hash)}` : ''
266
+ return `${result.pathname}${search}${hash}`
267
+ }
268
+
269
+ const evaluateRouteGuard = ({
270
+ guard,
271
+ beforeEnter,
272
+ match,
273
+ currentLocation,
274
+ })=> {
275
+ const resolver = typeof guard === 'function' ? guard : typeof beforeEnter === 'function' ? beforeEnter : null
276
+ if (!resolver){
277
+ return {
278
+ allow: true,
279
+ redirectTo: null,
280
+ }
281
+ }
282
+
283
+ const result = resolver({
284
+ to: match,
285
+ from: currentLocation,
286
+ params: match.params,
287
+ query: match.query,
288
+ pathname: match.pathname,
289
+ search: match.search,
290
+ hash: match.hash,
291
+ })
292
+
293
+ if (result === true || result == null){
294
+ return {
295
+ allow: true,
296
+ redirectTo: null,
297
+ }
298
+ }
299
+
300
+ const redirectTo = normalizeGuardRedirect(result)
301
+ if (redirectTo){
302
+ return {
303
+ allow: false,
304
+ redirectTo,
305
+ }
306
+ }
307
+
308
+ return {
309
+ allow: false,
310
+ redirectTo: null,
311
+ }
312
+ }
313
+
314
+ // Build a reactive Match element from a flat list of RouteDescriptors.
315
+ // Parent routes (those with nested route children) use prefix matching;
316
+ // leaf routes use exact equality matching.
317
+ // basePath is the already-accumulated prefix at this router level (root-
318
+ // relative, so always starts with '/').
319
+ const buildRouteMatch = (routes, currentLocation, basePath)=> {
320
+ const nonDefault = routes.filter(r=> !r.isDefault)
321
+ const defaultRoute = routes.find(r=> r.isDefault)
322
+
323
+ // Compute the index of the currently active route reactively.
324
+ // Returning -1 means "no match" → Match falls through to defaultBranch.
325
+ const readCandidateMatch = ()=> {
326
+ const resolveCandidateMatch = (location, redirectDepth = 0)=> {
327
+ const pathname = location.pathname
328
+
329
+ for (const [i, r] of nonDefault.entries()){
330
+ const fullPath = joinPaths(basePath, r.when)
331
+ const isParent = r.nestedRoutes && r.nestedRoutes.length > 0
332
+ const matcher = createRouteMatcher(fullPath)
333
+ const params = isParent ? matcher.matchPrefix(pathname) : matcher.matchExact(pathname)
334
+ if (params){
335
+ const match = createRouteMatchInfo({
336
+ routePath: fullPath,
337
+ pathname,
338
+ params,
339
+ location,
340
+ })
341
+ const guardState = evaluateRouteGuard({
342
+ guard: r.guard,
343
+ beforeEnter: r.beforeEnter,
344
+ match,
345
+ currentLocation: location,
346
+ })
347
+ if (!guardState.allow){
348
+ if (guardState.redirectTo){
349
+ const target = normalizeTarget(guardState.redirectTo)
350
+ const currentTarget = `${location.pathname}${location.search}${location.hash}`
351
+ if (target !== currentTarget && redirectDepth < nonDefault.length){
352
+ sharedRouterState.navigate(target, {
353
+ replace: true,
354
+ })
355
+ return resolveCandidateMatch(createLocation(target), redirectDepth + 1)
356
+ }
357
+ }
358
+ continue
359
+ }
360
+
361
+ return {
362
+ index: i,
363
+ match,
364
+ }
365
+ }
366
+ }
367
+
368
+ let defaultMatch = null
369
+ if (defaultRoute){
370
+ defaultMatch = createRouteMatchInfo({
371
+ routePath: '*',
372
+ pathname,
373
+ params: {},
374
+ location,
375
+ })
376
+ }
377
+
378
+ return {
379
+ index: -1,
380
+ match: defaultMatch,
381
+ }
382
+ }
383
+
384
+ return resolveCandidateMatch(currentLocation())
385
+ }
386
+
387
+ const activeIndex = ()=> {
388
+ const candidate = readCandidateMatch()
389
+ return candidate.index
390
+ }
391
+
392
+ const cases = nonDefault.map((r, i)=> ({
393
+ when: i,
394
+ branch: ()=> {
395
+ const candidate = readCandidateMatch()
396
+ if (candidate.index !== i || !candidate.match){
397
+ return null
398
+ }
399
+ return h(RouteMatchContext.Provider, {
400
+ value: candidate.match,
401
+ children: ()=> r.branch(candidate.match),
402
+ })
403
+ },
404
+ }))
405
+
406
+ let defaultBranch
407
+ if (defaultRoute){
408
+ defaultBranch = ()=> {
409
+ const candidate = readCandidateMatch()
410
+ if (!candidate.match){
411
+ return defaultRoute.branch(null)
412
+ }
413
+ return h(RouteMatchContext.Provider, {
414
+ value: candidate.match,
415
+ children: ()=> defaultRoute.branch(candidate.match),
416
+ })
417
+ }
418
+ }
419
+
420
+ return h(Match, {
421
+ value: activeIndex,
422
+ cases,
423
+ ...defaultBranch ? { defaultBranch } : {},
424
+ })
425
+ }
426
+
427
+ const readCurrentLocation = (root = '/')=> {
428
+ if (typeof window === 'undefined'){
429
+ return createLocation('/')
430
+ }
431
+ const fullLocation = createLocation(`${window.location.pathname || '/'}${window.location.search || ''}${window.location.hash || ''}`)
432
+ const fullPath = fullLocation.pathname
433
+ const normalizedRoot = normalizePath(root)
434
+ if (normalizedRoot === '/'){
435
+ return fullLocation
436
+ }
437
+ if (fullPath.startsWith(normalizedRoot)){
438
+ const relative = fullPath.slice(normalizedRoot.length) || '/'
439
+ return {
440
+ ...fullLocation,
441
+ pathname: relative.startsWith('/') ? relative : `/${relative}`,
442
+ }
443
+ }
444
+ return fullLocation
445
+ }
446
+
447
+ const sharedRouterVersion = createSignal(0)
448
+ const sharedRouterState = {
449
+ currentLocation: createSignal(readCurrentLocation()),
450
+ navigate: ()=> {},
451
+ createHref: to=> normalizeTarget(to),
452
+ }
453
+
454
+ const touchSharedRouter = ()=> {
455
+ sharedRouterVersion(sharedRouterVersion() + 1)
456
+ }
457
+
458
+ const createNavigationApi = (setCurrentLocation, root = '/')=> {
459
+ const normalizedRoot = normalizePath(root)
460
+ const withRoot = (location)=> {
461
+ const rootedPathname = normalizedRoot === '/' ? location.pathname : normalizedRoot + (location.pathname === '/' ? '' : location.pathname)
462
+ return `${rootedPathname}${location.search}${location.hash}`
463
+ }
464
+
465
+ if (typeof window === 'undefined'){
466
+ return {
467
+ createHref: to=> withRoot(createLocation(to)),
468
+ navigate: ()=> {},
469
+ }
470
+ }
471
+
472
+ return {
473
+ createHref: to=> withRoot(createLocation(to)),
474
+ navigate: (to, options = {})=> {
475
+ const nextLocation = createLocation(to)
476
+ const target = withRoot(nextLocation)
477
+ const replace = Boolean(options.replace)
478
+ const method = replace ? 'replaceState' : 'pushState'
479
+ const state = options.state !== undefined ? options.state : window.history.state
480
+ window.history[method](state, '', target)
481
+ setCurrentLocation(nextLocation)
482
+ },
483
+ }
484
+ }
485
+
486
+ const Router = ({ children, root = '/' })=> {
487
+ const currentLocation = createSignal(readCurrentLocation(root))
488
+ const currentPath = ()=> currentLocation().pathname
489
+ const syncPath = ()=> {
490
+ currentLocation(readCurrentLocation(root))
491
+ }
492
+ if (typeof window !== 'undefined'){
493
+ window.addEventListener('popstate', syncPath)
494
+ registerCleanup(()=> {
495
+ window.removeEventListener('popstate', syncPath)
496
+ })
497
+ }
498
+
499
+ const navigation = createNavigationApi(currentLocation, root)
500
+ sharedRouterState.currentLocation = currentLocation
501
+ sharedRouterState.navigate = navigation.navigate
502
+ sharedRouterState.createHref = navigation.createHref
503
+ touchSharedRouter()
504
+
505
+ // All path matching happens in root-relative space (readCurrentPath strips
506
+ // the root prefix), so the base for the top-level match is always '/'.
507
+ const basePath = '/'
508
+ const routerCtx = {
509
+ currentLocation, currentPath, basePath,
510
+ }
511
+
512
+ const childArray = Array.isArray(children) ? children : [children]
513
+ const isRouteChild = child=> child instanceof Comment && child._routeDescriptor || child != null && typeof child === 'object' && child.tag === Route
514
+ const routeMarkers = childArray.filter(isRouteChild).map(child=> materializeNode(child)).filter(child=> child instanceof Comment && child._routeDescriptor)
515
+ const otherChildren = childArray.filter(child=> !isRouteChild(child))
516
+
517
+ const routes = routeMarkers.map(child=> child._routeDescriptor)
518
+
519
+ // Wrap the entire output in RouterContext.Provider with lazy children so
520
+ // that the Match (and every branch owner it creates) are nested inside the
521
+ // provider's owner. This ensures branch-level renderMatch calls can reach
522
+ // RouterContext via useContext's owner-chain walk.
523
+ return h(RouterContext.Provider, {
524
+ value: routerCtx,
525
+ children: ()=> {
526
+ const matchElement = buildRouteMatch(routes, currentLocation, basePath)
527
+ if (otherChildren.length === 0){
528
+ return matchElement
529
+ }
530
+
531
+ const fragment = document.createDocumentFragment()
532
+ otherChildren.forEach((child)=> {
533
+ const node = materializeNode(child)
534
+ if (node instanceof Node){
535
+ fragment.appendChild(node)
536
+ }
537
+ })
538
+ fragment.appendChild(materializeNode(matchElement))
539
+ return fragment
540
+ },
541
+ })
542
+ }
543
+
544
+ const Route = ({
545
+ path = '/',
546
+ index = false,
547
+ guard,
548
+ beforeEnter,
549
+ component,
550
+ children,
551
+ ...componentProps
552
+ })=> {
553
+ const expectedPath = index ? '/' : normalizePath(path)
554
+ const isDefault = expectedPath === '*' || path === '*'
555
+
556
+ // Partition children into nested Route markers and ordinary content.
557
+ // Function children (render props) cannot contain nested Route markers, so
558
+ // they are treated as leaf content only.
559
+ const isFunctionChildren = typeof children === 'function'
560
+ const childArray = isFunctionChildren || children === undefined ? [] : Array.isArray(children) ? children : [children]
561
+ const isNestedRouteChild = child=> child instanceof Comment && child._routeDescriptor || child != null && typeof child === 'object' && child.tag === Route
562
+
563
+ const nestedRouteMarkers = childArray.filter(isNestedRouteChild).map(child=> materializeNode(child)).filter(child=> child instanceof Comment && child._routeDescriptor)
564
+ const nestedRoutes = nestedRouteMarkers.map(c=> c._routeDescriptor)
565
+ const contentChildren = childArray.filter(child=> !isNestedRouteChild(child))
566
+
567
+ const hasNestedRoutes = nestedRoutes.length > 0
568
+ const hasContentChildren = contentChildren.length > 0
569
+
570
+ const renderMatch = (routeMatch = null)=> {
571
+ const routeProps = {
572
+ ...componentProps,
573
+ params: routeMatch ? routeMatch.params : {},
574
+ query: routeMatch ? routeMatch.query : {},
575
+ route: routeMatch,
576
+ }
577
+
578
+ if (hasNestedRoutes){
579
+ // Read the current routing context to obtain the accumulated base path
580
+ // and the currentPath signal. This call is safe because renderMatch is
581
+ // always invoked lazily, inside mountDynamic's branch owner, which is a
582
+ // descendant of the RouterContext.Provider set up by Router (or a parent
583
+ // Route).
584
+ const ctx = useContext(RouterContext)
585
+ const ctxBasePath = ctx ? ctx.basePath : '/'
586
+ const ctxCurrentLocation = ctx ? ctx.currentLocation : sharedRouterState.currentLocation
587
+ const newBasePath = joinPaths(ctxBasePath, expectedPath)
588
+ const newCtx = {
589
+ currentLocation: ctxCurrentLocation,
590
+ currentPath: ()=> ctxCurrentLocation().pathname,
591
+ basePath: newBasePath,
592
+ }
593
+
594
+ if (typeof component === 'function'){
595
+ // Render the component wrapped in updated Router + NestedRoutes
596
+ // contexts. Children passed as lazy functions ensure the provider
597
+ // owners are established *before* the component function runs, so
598
+ // useContext calls inside the component (e.g. inside <Outlet>) see
599
+ // the correct values.
600
+ return h(RouterContext.Provider, {
601
+ value: newCtx,
602
+ children: ()=> h(NestedRoutesContext.Provider, {
603
+ value: nestedRoutes,
604
+ children: ()=> h(component, routeProps),
605
+ }),
606
+ })
607
+ }
608
+
609
+ // Transparent parent route (no component): provide the updated context
610
+ // and directly build the nested Match so child routes are rendered.
611
+ return h(RouterContext.Provider, {
612
+ value: newCtx,
613
+ children: ()=> {
614
+ const nestedMatch = buildRouteMatch(nestedRoutes, ctxCurrentLocation, newBasePath)
615
+ if (!hasContentChildren){
616
+ return nestedMatch
617
+ }
618
+ const frag = document.createDocumentFragment()
619
+ contentChildren.forEach((child)=> {
620
+ const node = materializeNode(child)
621
+ if (node instanceof Node){
622
+ frag.appendChild(node)
623
+ }
624
+ })
625
+ frag.appendChild(materializeNode(nestedMatch))
626
+ return frag
627
+ },
628
+ })
629
+ }
630
+
631
+ // Leaf route: backward-compatible behaviour.
632
+ if (isFunctionChildren){
633
+ return children(routeProps)
634
+ }
635
+ if (hasContentChildren){
636
+ return contentChildren.length === 1 ? contentChildren[0] : contentChildren
637
+ }
638
+ if (typeof component === 'function'){
639
+ return h(component, routeProps)
640
+ }
641
+ return []
642
+ }
643
+
644
+ const marker = document.createComment('route')
645
+ marker._routeDescriptor = {
646
+ when: expectedPath, branch: renderMatch, isDefault, nestedRoutes, guard, beforeEnter,
647
+ }
648
+ return marker
649
+ }
650
+
651
+ // <Outlet /> renders the matched child route inside a parent route component.
652
+ // It reads RouterContext (updated base path) and NestedRoutesContext (child
653
+ // route descriptors) that were provided by the parent Route's renderMatch.
654
+ const Outlet = ()=> {
655
+ const ctx = useContext(RouterContext)
656
+ const nestedRoutes = useContext(NestedRoutesContext)
657
+
658
+ if (!ctx || !nestedRoutes || nestedRoutes.length === 0){
659
+ return null
660
+ }
661
+
662
+ return buildRouteMatch(nestedRoutes, ctx.currentLocation, ctx.basePath)
663
+ }
664
+
665
+ const useRoute = ()=> useContext(RouteMatchContext)
666
+
667
+ const useLocation = ()=> {
668
+ const ctx = useContext(RouterContext)
669
+ return ctx ? ctx.currentLocation : sharedRouterState.currentLocation
670
+ }
671
+
672
+ const useNavigate = ()=> (to, options = {})=> {
673
+ sharedRouterState.navigate(to, options)
674
+ }
675
+
676
+ const useParams = ()=> {
677
+ const route = useRoute()
678
+ return route ? route.params : {}
679
+ }
680
+
681
+ const useNavigationState = ()=> {
682
+ if (typeof window === 'undefined'){
683
+ return ()=> null
684
+ }
685
+ const location = useLocation()
686
+ // Reading location() as a dependency ensures the state is re-read after
687
+ // every navigation (pushState / replaceState both update the location signal).
688
+ return ()=> {
689
+ location()
690
+ return window.history.state
691
+ }
692
+ }
693
+
694
+ const useSearchParams = ()=> {
695
+ const location = useLocation()
696
+ const setSearchParams = (next, options = {})=> {
697
+ const current = location()
698
+ const nextValue = typeof next === 'function' ? next(current.query) : next
699
+ const nextSearch = toSearchString(nextValue)
700
+ const target = `${current.pathname}${nextSearch}${current.hash}`
701
+ sharedRouterState.navigate(target, options)
702
+ }
703
+ return [()=> location().query, setSearchParams]
704
+ }
705
+
706
+ const useMatch = (path)=> {
707
+ const location = useLocation()
708
+ const resolveTargetPath = ()=> {
709
+ const target = typeof path === 'function' ? path() : path
710
+ if (target === '*'){
711
+ return '*'
712
+ }
713
+ return stripQueryAndHash(normalizeTarget(target))
714
+ }
715
+
716
+ return ()=> {
717
+ const targetPath = resolveTargetPath()
718
+ if (targetPath === '*'){
719
+ return true
720
+ }
721
+
722
+ const currentPath = location().pathname
723
+ if (targetPath.includes(':')){
724
+ return createRouteMatcher(targetPath).matchExact(currentPath) !== null
725
+ }
726
+
727
+ return isNavLinkActive({
728
+ currentPath,
729
+ targetPath,
730
+ end: false,
731
+ })
732
+ }
733
+ }
734
+
735
+ const isPlainLeftClick = event=> event.button === 0 && !event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey
736
+
737
+ const resolveLinkTarget = ({
738
+ to,
739
+ routeMatch,
740
+ currentPath,
741
+ })=> resolveNavigationTarget({
742
+ target: to,
743
+ routeMatch,
744
+ currentPath,
745
+ })
746
+
747
+ const isPathPrefixMatch = (pathname, targetPath)=> pathname === targetPath || pathname.startsWith(`${targetPath}/`)
748
+
749
+ const isNavLinkActive = ({
750
+ currentPath,
751
+ targetPath,
752
+ end,
753
+ })=> {
754
+ if (targetPath === '/'){
755
+ return currentPath === '/'
756
+ }
757
+
758
+ if (end){
759
+ return currentPath === targetPath
760
+ }
761
+
762
+ return isPathPrefixMatch(currentPath, targetPath)
763
+ }
764
+
765
+ const Link = ({
766
+ to = '/',
767
+ replace = false,
768
+ onClick,
769
+ target,
770
+ children,
771
+ ...props
772
+ })=> {
773
+ const routeMatch = useRoute()
774
+ const location = useLocation()
775
+ const resolveTarget = ()=> resolveLinkTarget({
776
+ to,
777
+ routeMatch,
778
+ currentPath: location().pathname,
779
+ })
780
+ const href = ()=> {
781
+ sharedRouterVersion()
782
+ return sharedRouterState.createHref(resolveTarget())
783
+ }
784
+ const handleClick = (event)=> {
785
+ if (typeof onClick === 'function'){
786
+ onClick(event)
787
+ }
788
+ if (event.defaultPrevented || !isPlainLeftClick(event)){
789
+ return
790
+ }
791
+ if (target && target !== '_self'){
792
+ return
793
+ }
794
+ event.preventDefault()
795
+ sharedRouterState.navigate(resolveTarget(), { replace })
796
+ }
797
+
798
+ return h('a', {
799
+ ...props,
800
+ href,
801
+ onClick: handleClick,
802
+ ...target ? { target } : {},
803
+ }, children)
804
+ }
805
+
806
+ const NavLink = ({
807
+ to = '/',
808
+ replace = false,
809
+ onClick,
810
+ target,
811
+ children,
812
+ className,
813
+ activeClass = 'active',
814
+ ariaCurrent = 'page',
815
+ end = false,
816
+ ...props
817
+ })=> {
818
+ const location = useLocation()
819
+ const routeMatch = useRoute()
820
+ const resolveTarget = ()=> resolveLinkTarget({
821
+ to,
822
+ routeMatch,
823
+ currentPath: location().pathname,
824
+ })
825
+ const targetPath = ()=> stripQueryAndHash(resolveTarget())
826
+ const isActive = ()=> isNavLinkActive({
827
+ currentPath: location().pathname,
828
+ targetPath: targetPath(),
829
+ end,
830
+ })
831
+ const resolvedClassName = ()=> {
832
+ const baseClassName = typeof className === 'function' ? className() : className
833
+ const tokens = [baseClassName, isActive() ? activeClass : '']
834
+ .filter(value=> typeof value === 'string' && value.trim())
835
+ .map(value=> value.trim())
836
+ return tokens.join(' ')
837
+ }
838
+
839
+ return h(Link, {
840
+ ...props,
841
+ to,
842
+ replace,
843
+ onClick,
844
+ ...target ? { target } : {},
845
+ className: resolvedClassName,
846
+ 'aria-current': ()=> isActive() ? ariaCurrent : null,
847
+ }, children)
848
+ }
849
+
850
+ const navigate = (to, options = {})=> {
851
+ sharedRouterState.navigate(to, options)
852
+ }
853
+
854
+ // lazy(importFn, options?) — code-split a route component via dynamic import.
855
+ //
856
+ // importFn — a zero-argument function that returns a Promise<module>.
857
+ // The resolved module's `default` export is used as the component.
858
+ // options.fallback — optional component function or DOM node to render while
859
+ // the import is in flight. Defaults to null (render nothing).
860
+ //
861
+ // Returns a regular component function that can be used as the `component`
862
+ // prop of a <Route> or mounted directly. The import is started the first time
863
+ // the component is rendered; subsequent renders (and re-renders triggered by
864
+ // other signals) reuse the already-cached result synchronously.
865
+ //
866
+ // Because the LazyComponent reads the `resolved` signal during its render, any
867
+ // reactive context that called it (e.g. mountDynamic inside Match/Route) will
868
+ // automatically re-execute once the import settles and display the real component.
869
+ const lazy = (importFn, options = {})=> {
870
+ const resolved = createSignal(null)
871
+ let started = false
872
+
873
+ const ensureLoaded = ()=> {
874
+ if (started){
875
+ return
876
+ }
877
+ started = true
878
+ Promise.resolve(importFn()).then((mod)=> {
879
+ resolved(mod.default ?? mod)
880
+ return mod
881
+ }).catch((error)=> {
882
+ console.error(error)
883
+ })
884
+ }
885
+
886
+ const LazyComponent = (props)=> {
887
+ ensureLoaded()
888
+ return ()=> {
889
+ const Component = resolved()
890
+ if (!Component){
891
+ const { fallback } = options
892
+ if (typeof fallback === 'function'){
893
+ return h(fallback, {})
894
+ }
895
+ return fallback ?? null
896
+ }
897
+ return h(Component, props)
898
+ }
899
+ }
900
+
901
+ return LazyComponent
902
+ }
903
+
904
+ export {
905
+ lazy,
906
+ Link,
907
+ NavLink,
908
+ navigate,
909
+ Outlet,
910
+ Route,
911
+ Router,
912
+ useLocation,
913
+ useMatch,
914
+ useNavigate,
915
+ useNavigationState,
916
+ useParams,
917
+ useRoute,
918
+ useSearchParams,
919
+ }