@vidact/start 0.2.0-beta.0 → 0.2.0-beta.4

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 (57) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/client.d.ts +18 -16
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +137 -165
  5. package/dist/client.js.map +1 -1
  6. package/dist/index-client.d.ts +5 -4
  7. package/dist/index-client.js +4 -4
  8. package/dist/index.d.ts +5 -4
  9. package/dist/index.js +4 -4
  10. package/dist/link-client.d.ts +6 -4
  11. package/dist/link-client.d.ts.map +1 -1
  12. package/dist/link-client.js +8 -4
  13. package/dist/link-client.js.map +1 -1
  14. package/dist/link-contract.d.ts +10 -7
  15. package/dist/link-contract.d.ts.map +1 -1
  16. package/dist/link-contract.js +11 -7
  17. package/dist/link-contract.js.map +1 -1
  18. package/dist/link.d.ts +6 -4
  19. package/dist/link.d.ts.map +1 -1
  20. package/dist/link.js +8 -4
  21. package/dist/link.js.map +1 -1
  22. package/dist/router.d.ts +57 -55
  23. package/dist/router.d.ts.map +1 -1
  24. package/dist/router.js +150 -157
  25. package/dist/router.js.map +1 -1
  26. package/dist/server.d.ts +18 -16
  27. package/dist/server.d.ts.map +1 -1
  28. package/dist/server.js +86 -99
  29. package/dist/server.js.map +1 -1
  30. package/dist/snapshot.d.ts +13 -10
  31. package/dist/snapshot.d.ts.map +1 -1
  32. package/dist/snapshot.js +19 -20
  33. package/dist/snapshot.js.map +1 -1
  34. package/dist/virtual.d.ts +3 -2
  35. package/dist/virtual.js +1 -2
  36. package/dist/vite.d.ts +18 -15
  37. package/dist/vite.d.ts.map +1 -1
  38. package/dist/vite.js +144 -171
  39. package/dist/vite.js.map +1 -1
  40. package/package.json +8 -7
  41. package/src/client.ts +262 -0
  42. package/src/index-client.ts +3 -0
  43. package/src/index.ts +3 -0
  44. package/src/link-client.ts +10 -0
  45. package/src/link-contract.ts +17 -0
  46. package/src/link.ts +10 -0
  47. package/src/router.ts +282 -0
  48. package/src/server.ts +200 -0
  49. package/src/snapshot.ts +40 -0
  50. package/src/virtual.ts +5 -0
  51. package/src/vite.ts +256 -0
  52. package/dist/index-client.d.ts.map +0 -1
  53. package/dist/index-client.js.map +0 -1
  54. package/dist/index.d.ts.map +0 -1
  55. package/dist/index.js.map +0 -1
  56. package/dist/virtual.d.ts.map +0 -1
  57. package/dist/virtual.js.map +0 -1
package/src/client.ts ADDED
@@ -0,0 +1,262 @@
1
+ import {
2
+ cloneRenderable,
3
+ compiledRoot,
4
+ createCompiledScope,
5
+ type CompiledComponentResult,
6
+ type CompiledRoot,
7
+ } from '@vidact/runtime'
8
+ import { createElement, hydrateRoot } from '@vidact/runtime/hydrate'
9
+
10
+ import {
11
+ composeRouteMatches,
12
+ loadRouteMatches,
13
+ matchRoutes,
14
+ type LoadedRouteMatch,
15
+ type RouteManifest,
16
+ type StartComponent,
17
+ } from './router.ts'
18
+ import {
19
+ decodeStartSnapshot,
20
+ VIDACT_START_NAVIGATION_HEADER,
21
+ VIDACT_START_SNAPSHOT_MEDIA_TYPE,
22
+ } from './snapshot.ts'
23
+
24
+ const DEFAULT_ROOT_ID = 'vidact-start-root'
25
+ const DEFAULT_SNAPSHOT_ID = 'vidact-start-snapshot'
26
+
27
+ export interface HydrateStartOptions {
28
+ readonly fetch?: typeof globalThis.fetch
29
+ readonly manifest: RouteManifest
30
+ readonly root?: ParentNode
31
+ readonly rootId?: string
32
+ readonly snapshot?: string
33
+ readonly snapshotId?: string
34
+ }
35
+
36
+ export interface StartNavigateOptions {
37
+ readonly replace?: boolean
38
+ readonly scroll?: boolean
39
+ }
40
+
41
+ export interface StartClient extends CompiledRoot {
42
+ readonly navigate: (to: string | URL, options?: StartNavigateOptions) => Promise<boolean>
43
+ }
44
+
45
+ type HistoryMode = 'none' | 'push' | 'replace'
46
+
47
+ export async function hydrateStart(options: HydrateStartOptions): Promise<StartClient> {
48
+ const snapshot = decodeStartSnapshot(
49
+ options.snapshot ?? readSnapshot(options.snapshotId ?? DEFAULT_SNAPSHOT_ID),
50
+ )
51
+ const initialUrl = new URL(snapshot.pathname, window.location.href)
52
+ const matches = matchRoutes(options.manifest, initialUrl.pathname)
53
+ if (matches.length === 0) throw new Error(`cannot hydrate unmatched route ${snapshot.pathname}`)
54
+ const request = new Request(initialUrl)
55
+ const loaded = await loadRouteMatches(matches, request, snapshot.loaderData)
56
+ const host =
57
+ options.root ?? document.querySelector(`#${CSS.escape(options.rootId ?? DEFAULT_ROOT_ID)}`)
58
+ if (host === null) throw new Error('Vidact Start hydration root is missing')
59
+
60
+ const root = hydrateRoot(host, createClientApplication(loaded, request.url))
61
+ const fetchNavigation = options.fetch ?? window.fetch.bind(window)
62
+ let navigation = 0
63
+ let navigationController: AbortController | undefined
64
+ let disposed = false
65
+
66
+ async function navigate(
67
+ to: string | URL,
68
+ navigationOptions: StartNavigateOptions = {},
69
+ ): Promise<boolean> {
70
+ return performNavigation(
71
+ new URL(to, window.location.href),
72
+ navigationOptions.replace === true ? 'replace' : 'push',
73
+ navigationOptions.scroll !== false,
74
+ )
75
+ }
76
+
77
+ async function performNavigation(
78
+ target: URL,
79
+ historyMode: HistoryMode,
80
+ scroll: boolean,
81
+ ): Promise<boolean> {
82
+ if (disposed) throw new Error('cannot navigate an unmounted Vidact Start client')
83
+ if (!isClientNavigationUrl(target)) {
84
+ navigateDocument(target, historyMode === 'replace')
85
+ return false
86
+ }
87
+ const nextMatches = matchRoutes(options.manifest, target.pathname)
88
+ if (nextMatches.length === 0) {
89
+ navigateDocument(target, historyMode === 'replace')
90
+ return false
91
+ }
92
+
93
+ navigation += 1
94
+ const attempt = navigation
95
+ navigationController?.abort()
96
+ const controller = new AbortController()
97
+ navigationController = controller
98
+
99
+ try {
100
+ const response = await fetchNavigation(target, {
101
+ credentials: 'same-origin',
102
+ headers: { [VIDACT_START_NAVIGATION_HEADER]: '1' },
103
+ signal: controller.signal,
104
+ })
105
+ if (attempt !== navigation) return false
106
+ if (
107
+ !response.ok ||
108
+ response.headers.get('content-type')?.split(';', 1)[0] !== VIDACT_START_SNAPSHOT_MEDIA_TYPE
109
+ ) {
110
+ navigateDocument(target, historyMode === 'replace')
111
+ return false
112
+ }
113
+
114
+ const nextSnapshot = decodeStartSnapshot(await response.text())
115
+ const nextUrl = new URL(nextSnapshot.pathname, target.origin)
116
+ const snapshotMatches = matchRoutes(options.manifest, nextUrl.pathname)
117
+ if (snapshotMatches.length === 0) {
118
+ navigateDocument(target, historyMode === 'replace')
119
+ return false
120
+ }
121
+ const nextRequest = new Request(nextUrl)
122
+ const nextLoaded = await loadRouteMatches(
123
+ snapshotMatches,
124
+ nextRequest,
125
+ nextSnapshot.loaderData,
126
+ )
127
+ if (attempt !== navigation) return false
128
+
129
+ root.replace(createClientApplication(nextLoaded, nextRequest.url))
130
+ updateHistory(nextUrl, historyMode)
131
+ if (scroll) scrollToLocation(nextUrl)
132
+ return true
133
+ } catch {
134
+ if (controller.signal.aborted || attempt !== navigation) return false
135
+ navigateDocument(target, historyMode === 'replace')
136
+ return false
137
+ }
138
+ }
139
+
140
+ function handleClick(event: MouseEvent): void {
141
+ const anchor = linkForEvent(event)
142
+ if (anchor === undefined) return
143
+ const target = new URL(anchor.href, window.location.href)
144
+ if (
145
+ target.pathname === window.location.pathname &&
146
+ target.search === window.location.search &&
147
+ target.hash !== window.location.hash
148
+ ) {
149
+ return
150
+ }
151
+ event.preventDefault()
152
+ void performNavigation(
153
+ target,
154
+ anchor.hasAttribute('data-vidact-start-replace') ? 'replace' : 'push',
155
+ true,
156
+ )
157
+ }
158
+
159
+ function handlePopState(): void {
160
+ void performNavigation(new URL(window.location.href), 'none', false)
161
+ }
162
+
163
+ document.addEventListener('click', handleClick)
164
+ window.addEventListener('popstate', handlePopState)
165
+
166
+ return {
167
+ mount: root.mount,
168
+ replace: root.replace,
169
+ navigate,
170
+ unmount() {
171
+ if (disposed) return
172
+ disposed = true
173
+ navigation += 1
174
+ navigationController?.abort()
175
+ document.removeEventListener('click', handleClick)
176
+ window.removeEventListener('popstate', handlePopState)
177
+ root.unmount()
178
+ },
179
+ }
180
+ }
181
+
182
+ function createClientApplication(
183
+ matches: readonly LoadedRouteMatch[],
184
+ requestUrl: string,
185
+ ): () => CompiledComponentResult {
186
+ return () => {
187
+ const scope = createCompiledScope()
188
+ const application = composeRouteMatches(
189
+ matches,
190
+ (component, props) => createElement(component as never, props),
191
+ requestUrl,
192
+ )
193
+ return compiledRoot(scope, () => cloneRenderable(application))
194
+ }
195
+ }
196
+
197
+ function linkForEvent(event: MouseEvent): HTMLAnchorElement | undefined {
198
+ if (
199
+ event.defaultPrevented ||
200
+ event.button !== 0 ||
201
+ event.metaKey ||
202
+ event.ctrlKey ||
203
+ event.shiftKey ||
204
+ event.altKey
205
+ ) {
206
+ return undefined
207
+ }
208
+ const target = event.target
209
+ if (!(target instanceof Element)) return undefined
210
+ const anchor = target.closest<HTMLAnchorElement>('a[data-vidact-start-link]')
211
+ if (
212
+ anchor === null ||
213
+ anchor.hasAttribute('download') ||
214
+ (anchor.target !== '' && anchor.target !== '_self') ||
215
+ anchor.rel.split(/\s+/u).includes('external')
216
+ ) {
217
+ return undefined
218
+ }
219
+ const url = new URL(anchor.href, window.location.href)
220
+ return isClientNavigationUrl(url) ? anchor : undefined
221
+ }
222
+
223
+ function isClientNavigationUrl(url: URL): boolean {
224
+ return (
225
+ url.origin === window.location.origin && (url.protocol === 'http:' || url.protocol === 'https:')
226
+ )
227
+ }
228
+
229
+ function updateHistory(url: URL, mode: HistoryMode): void {
230
+ if (mode === 'push') window.history.pushState({}, '', url)
231
+ else if (mode === 'replace') window.history.replaceState(window.history.state, '', url)
232
+ }
233
+
234
+ function scrollToLocation(url: URL): void {
235
+ if (url.hash.length > 1) {
236
+ let id: string
237
+ try {
238
+ id = decodeURIComponent(url.hash.slice(1))
239
+ } catch {
240
+ id = url.hash.slice(1)
241
+ }
242
+ const target = document.getElementById(id)
243
+ if (target !== null) {
244
+ target.scrollIntoView()
245
+ return
246
+ }
247
+ }
248
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
249
+ }
250
+
251
+ function navigateDocument(url: URL, replace: boolean): void {
252
+ if (replace) window.location.replace(url)
253
+ else window.location.assign(url)
254
+ }
255
+
256
+ function readSnapshot(id: string): string {
257
+ const element = document.querySelector(`#${CSS.escape(id)}`)
258
+ if (element === null) throw new Error(`Vidact Start snapshot ${id} is missing`)
259
+ return element.textContent ?? ''
260
+ }
261
+
262
+ export type { StartComponent }
@@ -0,0 +1,3 @@
1
+ export * from './link-client.ts'
2
+ export * from './router.ts'
3
+ export * from './snapshot.ts'
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './link.ts'
2
+ export * from './router.ts'
3
+ export * from './snapshot.ts'
@@ -0,0 +1,10 @@
1
+ import type { CompiledRenderValue } from '@vidact/runtime'
2
+ import { createElement } from '@vidact/runtime/hydrate'
3
+
4
+ import { anchorProps, type LinkProps } from './link-contract.ts'
5
+
6
+ export function Link(props: LinkProps): CompiledRenderValue {
7
+ return createElement('a', anchorProps(props)) as unknown as CompiledRenderValue
8
+ }
9
+
10
+ export type { LinkProps }
@@ -0,0 +1,17 @@
1
+ import type { CompiledRenderValue } from '@vidact/runtime'
2
+
3
+ export interface LinkProps extends Readonly<Record<string, unknown>> {
4
+ readonly children?: CompiledRenderValue
5
+ readonly href: string
6
+ readonly reloadDocument?: boolean
7
+ readonly replace?: boolean
8
+ }
9
+
10
+ export function anchorProps(props: LinkProps): Record<string, unknown> {
11
+ const { reloadDocument = false, replace = false, ...attributes } = props
12
+ return {
13
+ ...attributes,
14
+ ...(reloadDocument ? {} : { 'data-vidact-start-link': '' }),
15
+ ...(replace ? { 'data-vidact-start-replace': '' } : {}),
16
+ }
17
+ }
package/src/link.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { CompiledRenderValue } from '@vidact/runtime'
2
+ import { createElement, type ServerChild } from '@vidact/runtime/server'
3
+
4
+ import { anchorProps, type LinkProps } from './link-contract.ts'
5
+
6
+ export function Link(props: LinkProps): CompiledRenderValue {
7
+ return createElement('a', anchorProps(props)) as ServerChild as unknown as CompiledRenderValue
8
+ }
9
+
10
+ export type { LinkProps }
package/src/router.ts ADDED
@@ -0,0 +1,282 @@
1
+ import type { CompiledRenderValue } from '@vidact/runtime'
2
+ import type { FrameworkValue } from '@vidact/runtime/framework/protocol'
3
+
4
+ const FILE_ROUTE = Symbol('Vidact.Start.FileRoute')
5
+
6
+ export type RouteParameters = Readonly<Record<string, string>>
7
+ export type StartComponent<LoaderData extends FrameworkValue = FrameworkValue> = {
8
+ bivarianceHack(props: RouteComponentProps<LoaderData>): unknown
9
+ }['bivarianceHack']
10
+
11
+ export interface RouteComponentProps<
12
+ LoaderData extends FrameworkValue = FrameworkValue,
13
+ > extends Readonly<Record<string, unknown>> {
14
+ readonly children?: CompiledRenderValue
15
+ readonly loaderData: LoaderData
16
+ readonly params: RouteParameters
17
+ readonly requestUrl: string
18
+ }
19
+
20
+ export interface RouteLoaderContext {
21
+ readonly parentData: Readonly<Record<string, FrameworkValue>>
22
+ readonly params: RouteParameters
23
+ readonly request: Request
24
+ }
25
+
26
+ export interface RouteServerContext {
27
+ readonly params: RouteParameters
28
+ readonly request: Request
29
+ }
30
+
31
+ export type RouteServerHandler = (context: RouteServerContext) => Response | Promise<Response>
32
+
33
+ export interface FileRouteOptions<LoaderData extends FrameworkValue = FrameworkValue> {
34
+ readonly component?: StartComponent<LoaderData>
35
+ readonly loader?: (context: RouteLoaderContext) => LoaderData | Promise<LoaderData>
36
+ readonly server?: {
37
+ readonly handlers: Readonly<Partial<Record<string, RouteServerHandler>>>
38
+ }
39
+ }
40
+
41
+ export interface FileRouteDefinition<LoaderData extends FrameworkValue = FrameworkValue> {
42
+ readonly [FILE_ROUTE]: true
43
+ readonly options: FileRouteOptions<LoaderData>
44
+ }
45
+
46
+ export interface RouteModule {
47
+ readonly Route?: FileRouteDefinition
48
+ readonly default?: FileRouteDefinition
49
+ }
50
+
51
+ export interface RouteManifestEntry {
52
+ readonly id: string
53
+ readonly parentId: string | null
54
+ readonly path: string
55
+ readonly load: () => PromiseLike<RouteModule>
56
+ }
57
+
58
+ export interface RouteManifest {
59
+ readonly entries: readonly RouteManifestEntry[]
60
+ readonly byId: ReadonlyMap<string, RouteManifestEntry>
61
+ }
62
+
63
+ export interface RouteMatch {
64
+ readonly entry: RouteManifestEntry
65
+ readonly params: RouteParameters
66
+ }
67
+
68
+ export interface ResolvedRouteMatch extends RouteMatch {
69
+ readonly definition: FileRouteDefinition
70
+ }
71
+
72
+ export interface LoadedRouteMatch extends ResolvedRouteMatch {
73
+ readonly loaderData: FrameworkValue
74
+ }
75
+
76
+ export function defineFileRoute<const LoaderData extends FrameworkValue = undefined>(
77
+ options: FileRouteOptions<LoaderData>,
78
+ ): FileRouteDefinition<LoaderData> {
79
+ return Object.freeze({ [FILE_ROUTE]: true as const, options: Object.freeze({ ...options }) })
80
+ }
81
+
82
+ export function isFileRouteDefinition(value: unknown): value is FileRouteDefinition {
83
+ return typeof value === 'object' && value !== null && FILE_ROUTE in value
84
+ }
85
+
86
+ export function createRouteManifest(entries: readonly RouteManifestEntry[]): RouteManifest {
87
+ const byId = new Map<string, RouteManifestEntry>()
88
+ for (const entry of entries) {
89
+ if (entry.id.length === 0) throw new TypeError('route id must be non-empty')
90
+ if (!entry.path.startsWith('/')) throw new TypeError(`route ${entry.id} path must start with /`)
91
+ if (byId.has(entry.id)) throw new TypeError(`duplicate route id ${entry.id}`)
92
+ byId.set(entry.id, Object.freeze({ ...entry }))
93
+ }
94
+ for (const entry of byId.values()) {
95
+ if (entry.parentId !== null && !byId.has(entry.parentId)) {
96
+ throw new TypeError(`route ${entry.id} has unknown parent ${entry.parentId}`)
97
+ }
98
+ assertAcyclicParentChain(entry, byId)
99
+ }
100
+ const frozenEntries = Object.freeze([...byId.values()])
101
+ return Object.freeze({ entries: frozenEntries, byId })
102
+ }
103
+
104
+ export function matchRoutes(manifest: RouteManifest, pathname: string): readonly RouteMatch[] {
105
+ const normalized = normalizePathname(pathname)
106
+ let selected: { entry: RouteManifestEntry; params: RouteParameters; score: number } | undefined
107
+ for (const entry of manifest.entries) {
108
+ const match = matchPath(entry.path, normalized)
109
+ if (match === undefined) continue
110
+ const score = routeScore(entry, manifest)
111
+ if (selected === undefined || score > selected.score) selected = { entry, params: match, score }
112
+ }
113
+ if (selected === undefined) return []
114
+ const chain: RouteManifestEntry[] = []
115
+ let entry: RouteManifestEntry | undefined = selected.entry
116
+ while (entry !== undefined) {
117
+ chain.push(entry)
118
+ entry = entry.parentId === null ? undefined : manifest.byId.get(entry.parentId)
119
+ }
120
+ return Object.freeze(
121
+ chain.toReversed().map((matchedEntry) =>
122
+ Object.freeze({
123
+ entry: matchedEntry,
124
+ params: Object.freeze({ ...selected.params }),
125
+ }),
126
+ ),
127
+ )
128
+ }
129
+
130
+ export async function loadRouteMatches(
131
+ matches: readonly RouteMatch[],
132
+ request: Request,
133
+ suppliedData: Readonly<Record<string, FrameworkValue>> = {},
134
+ ): Promise<readonly LoadedRouteMatch[]> {
135
+ return runRouteLoaders(await resolveRouteMatches(matches), request, suppliedData)
136
+ }
137
+
138
+ export async function resolveRouteMatches(
139
+ matches: readonly RouteMatch[],
140
+ ): Promise<readonly ResolvedRouteMatch[]> {
141
+ return Object.freeze(
142
+ await Promise.all(
143
+ matches.map(async (match) => {
144
+ const module = await match.entry.load()
145
+ return Object.freeze({
146
+ ...match,
147
+ definition: readRouteDefinition(match.entry.id, module),
148
+ })
149
+ }),
150
+ ),
151
+ )
152
+ }
153
+
154
+ export async function runRouteLoaders(
155
+ matches: readonly ResolvedRouteMatch[],
156
+ request: Request,
157
+ suppliedData: Readonly<Record<string, FrameworkValue>> = {},
158
+ ): Promise<readonly LoadedRouteMatch[]> {
159
+ const loaded: LoadedRouteMatch[] = []
160
+ const parentData: Record<string, FrameworkValue> = { ...suppliedData }
161
+ for (const match of matches) {
162
+ const loaderData = Object.hasOwn(suppliedData, match.entry.id)
163
+ ? suppliedData[match.entry.id]
164
+ : match.definition.options.loader === undefined
165
+ ? undefined
166
+ : // oxlint-disable-next-line no-await-in-loop -- Descendants receive settled parent data.
167
+ await match.definition.options.loader({
168
+ parentData: Object.freeze({ ...parentData }),
169
+ params: match.params,
170
+ request,
171
+ })
172
+ parentData[match.entry.id] = loaderData
173
+ loaded.push(Object.freeze({ ...match, loaderData }))
174
+ }
175
+ return Object.freeze(loaded)
176
+ }
177
+
178
+ export function composeRouteMatches(
179
+ matches: readonly LoadedRouteMatch[],
180
+ createElement: (component: StartComponent, props: RouteComponentProps) => unknown,
181
+ requestUrl: string,
182
+ ): unknown {
183
+ let children: CompiledRenderValue | undefined
184
+ for (let index = matches.length - 1; index >= 0; index -= 1) {
185
+ const match = matches[index]!
186
+ const component = match.definition.options.component
187
+ if (component === undefined) continue
188
+ children = createElement(component, {
189
+ children,
190
+ loaderData: match.loaderData,
191
+ params: match.params,
192
+ requestUrl,
193
+ }) as CompiledRenderValue
194
+ }
195
+ return children
196
+ }
197
+
198
+ export function routeLoaderData(
199
+ matches: readonly LoadedRouteMatch[],
200
+ ): Readonly<Record<string, FrameworkValue>> {
201
+ return Object.freeze(
202
+ Object.fromEntries(matches.map((match) => [match.entry.id, match.loaderData])) as Record<
203
+ string,
204
+ FrameworkValue
205
+ >,
206
+ )
207
+ }
208
+
209
+ function readRouteDefinition(id: string, module: RouteModule): FileRouteDefinition {
210
+ const definition = module.Route ?? module.default
211
+ if (!isFileRouteDefinition(definition)) {
212
+ throw new TypeError(`route module ${id} must export a Route created by defineFileRoute`)
213
+ }
214
+ return definition
215
+ }
216
+
217
+ function matchPath(pattern: string, pathname: string): RouteParameters | undefined {
218
+ if (pattern === '/') return pathname === '/' ? Object.freeze({}) : undefined
219
+ const patternSegments = segments(pattern)
220
+ const pathSegments = segments(pathname)
221
+ const parameters: Record<string, string> = {}
222
+ let pathIndex = 0
223
+ for (const segment of patternSegments) {
224
+ if (segment === '*') {
225
+ if (pathIndex >= pathSegments.length) return undefined
226
+ parameters['*'] = decodeSegment(pathSegments.slice(pathIndex).join('/'))
227
+ pathIndex = pathSegments.length
228
+ break
229
+ }
230
+ const value = pathSegments[pathIndex]
231
+ if (value === undefined) return undefined
232
+ if (segment.startsWith(':')) parameters[segment.slice(1)] = decodeSegment(value)
233
+ else if (segment !== value) return undefined
234
+ pathIndex += 1
235
+ }
236
+ return pathIndex === pathSegments.length ? Object.freeze(parameters) : undefined
237
+ }
238
+
239
+ function normalizePathname(pathname: string): string {
240
+ const path = pathname.split(/[?#]/u, 1)[0] ?? '/'
241
+ if (!path.startsWith('/')) throw new TypeError('route pathname must start with /')
242
+ return path.length > 1 ? path.replace(/\/+$/u, '') : '/'
243
+ }
244
+
245
+ function segments(pathname: string): string[] {
246
+ return pathname.split('/').filter(Boolean)
247
+ }
248
+
249
+ function decodeSegment(value: string): string {
250
+ try {
251
+ return decodeURIComponent(value)
252
+ } catch {
253
+ throw new TypeError(`invalid encoded route segment ${value}`)
254
+ }
255
+ }
256
+
257
+ function routeScore(entry: RouteManifestEntry, manifest: RouteManifest): number {
258
+ const specificity = segments(entry.path).reduce(
259
+ (score, segment) => score + (segment === '*' ? 1 : segment.startsWith(':') ? 10 : 100),
260
+ 0,
261
+ )
262
+ let depth = 0
263
+ let current: RouteManifestEntry | undefined = entry
264
+ while (current !== undefined) {
265
+ depth += 1
266
+ current = current.parentId === null ? undefined : manifest.byId.get(current.parentId)
267
+ }
268
+ return specificity * 1_000 + depth
269
+ }
270
+
271
+ function assertAcyclicParentChain(
272
+ entry: RouteManifestEntry,
273
+ byId: ReadonlyMap<string, RouteManifestEntry>,
274
+ ): void {
275
+ const visited = new Set<string>([entry.id])
276
+ let parentId = entry.parentId
277
+ while (parentId !== null) {
278
+ if (visited.has(parentId)) throw new TypeError(`route ${entry.id} has a cyclic parent chain`)
279
+ visited.add(parentId)
280
+ parentId = byId.get(parentId)?.parentId ?? null
281
+ }
282
+ }