@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.
- package/CHANGELOG.md +46 -0
- package/dist/client.d.ts +18 -16
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +137 -165
- package/dist/client.js.map +1 -1
- package/dist/index-client.d.ts +5 -4
- package/dist/index-client.js +4 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -4
- package/dist/link-client.d.ts +6 -4
- package/dist/link-client.d.ts.map +1 -1
- package/dist/link-client.js +8 -4
- package/dist/link-client.js.map +1 -1
- package/dist/link-contract.d.ts +10 -7
- package/dist/link-contract.d.ts.map +1 -1
- package/dist/link-contract.js +11 -7
- package/dist/link-contract.js.map +1 -1
- package/dist/link.d.ts +6 -4
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +8 -4
- package/dist/link.js.map +1 -1
- package/dist/router.d.ts +57 -55
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +150 -157
- package/dist/router.js.map +1 -1
- package/dist/server.d.ts +18 -16
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +86 -99
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +13 -10
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +19 -20
- package/dist/snapshot.js.map +1 -1
- package/dist/virtual.d.ts +3 -2
- package/dist/virtual.js +1 -2
- package/dist/vite.d.ts +18 -15
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +144 -171
- package/dist/vite.js.map +1 -1
- package/package.json +8 -7
- package/src/client.ts +262 -0
- package/src/index-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/link-client.ts +10 -0
- package/src/link-contract.ts +17 -0
- package/src/link.ts +10 -0
- package/src/router.ts +282 -0
- package/src/server.ts +200 -0
- package/src/snapshot.ts +40 -0
- package/src/virtual.ts +5 -0
- package/src/vite.ts +256 -0
- package/dist/index-client.d.ts.map +0 -1
- package/dist/index-client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/virtual.d.ts.map +0 -1
- 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 }
|
package/src/index.ts
ADDED
|
@@ -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
|
+
}
|