@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/LICENSE +21 -0
- package/README.md +442 -0
- package/package.json +78 -0
- package/src/computation-context.js +11 -0
- package/src/control-flow.js +367 -0
- package/src/index.js +87 -0
- package/src/jsx-runtime.js +1058 -0
- package/src/merge-props.js +245 -0
- package/src/reactivity.js +408 -0
- package/src/router.js +919 -0
- package/src/split-props.js +42 -0
- package/src/utils.js +51 -0
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
|
+
}
|