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

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 (58) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +26 -5
  3. package/dist/client.d.ts +29 -16
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +352 -165
  6. package/dist/client.js.map +1 -1
  7. package/dist/index-client.d.ts +5 -4
  8. package/dist/index-client.js +4 -4
  9. package/dist/index.d.ts +5 -4
  10. package/dist/index.js +4 -4
  11. package/dist/link-client.d.ts +6 -4
  12. package/dist/link-client.d.ts.map +1 -1
  13. package/dist/link-client.js +10 -4
  14. package/dist/link-client.js.map +1 -1
  15. package/dist/link-contract.d.ts +12 -7
  16. package/dist/link-contract.d.ts.map +1 -1
  17. package/dist/link-contract.js +12 -7
  18. package/dist/link-contract.js.map +1 -1
  19. package/dist/link.d.ts +6 -4
  20. package/dist/link.d.ts.map +1 -1
  21. package/dist/link.js +8 -4
  22. package/dist/link.js.map +1 -1
  23. package/dist/router.d.ts +57 -55
  24. package/dist/router.d.ts.map +1 -1
  25. package/dist/router.js +150 -157
  26. package/dist/router.js.map +1 -1
  27. package/dist/server.d.ts +29 -16
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +159 -100
  30. package/dist/server.js.map +1 -1
  31. package/dist/snapshot.d.ts +13 -10
  32. package/dist/snapshot.d.ts.map +1 -1
  33. package/dist/snapshot.js +19 -20
  34. package/dist/snapshot.js.map +1 -1
  35. package/dist/virtual.d.ts +3 -2
  36. package/dist/virtual.js +1 -2
  37. package/dist/vite.d.ts +18 -15
  38. package/dist/vite.d.ts.map +1 -1
  39. package/dist/vite.js +144 -171
  40. package/dist/vite.js.map +1 -1
  41. package/package.json +9 -8
  42. package/src/client.ts +577 -0
  43. package/src/index-client.ts +3 -0
  44. package/src/index.ts +3 -0
  45. package/src/link-client.ts +11 -0
  46. package/src/link-contract.ts +20 -0
  47. package/src/link.ts +10 -0
  48. package/src/router.ts +282 -0
  49. package/src/server.ts +295 -0
  50. package/src/snapshot.ts +40 -0
  51. package/src/virtual.ts +5 -0
  52. package/src/vite.ts +256 -0
  53. package/dist/index-client.d.ts.map +0 -1
  54. package/dist/index-client.js.map +0 -1
  55. package/dist/index.d.ts.map +0 -1
  56. package/dist/index.js.map +0 -1
  57. package/dist/virtual.d.ts.map +0 -1
  58. package/dist/virtual.js.map +0 -1
package/src/client.ts ADDED
@@ -0,0 +1,577 @@
1
+ import {
2
+ binding,
3
+ cloneRenderable,
4
+ compiledRoot,
5
+ createComponentRenderable,
6
+ createCompiledScope,
7
+ createCompiledState,
8
+ source,
9
+ type CompiledComponentResult,
10
+ type CompiledRenderValue,
11
+ type CompiledRoot,
12
+ } from '@vidact/runtime'
13
+ import { hydrateRoot } from '@vidact/runtime/hydrate'
14
+
15
+ import {
16
+ loadRouteMatches,
17
+ matchRoutes,
18
+ resolveRouteMatches,
19
+ type LoadedRouteMatch,
20
+ type RouteComponentProps,
21
+ type RouteManifest,
22
+ type RouteManifestEntry,
23
+ type StartComponent,
24
+ } from './router.ts'
25
+ import {
26
+ decodeStartSnapshot,
27
+ VIDACT_START_NAVIGATION_HEADER,
28
+ VIDACT_START_SNAPSHOT_MEDIA_TYPE,
29
+ } from './snapshot.ts'
30
+
31
+ const DEFAULT_ROOT_ID = 'vidact-start-root'
32
+ const DEFAULT_SNAPSHOT_ID = 'vidact-start-snapshot'
33
+ /** How long a prefetched snapshot may stand in for a fresh navigation request. */
34
+ const PREFETCH_STALE_TIME = 30_000
35
+ /** Hover must rest on a link this long before it counts as intent to navigate. */
36
+ const PREFETCH_HOVER_DELAY = 50
37
+
38
+ export interface HydrateStartOptions {
39
+ readonly fetch?: typeof globalThis.fetch
40
+ readonly manifest: RouteManifest
41
+ /**
42
+ * Called when hydration could not attach to the server DOM and the root was rendered
43
+ * client-side instead. Defaults to a console warning: a silent recovery looks like a
44
+ * working page that merely flashes, and is far harder to notice than a warning.
45
+ */
46
+ readonly onRecoverableError?: (error: unknown) => void
47
+ readonly root?: ParentNode
48
+ readonly rootId?: string
49
+ readonly snapshot?: string
50
+ readonly snapshotId?: string
51
+ }
52
+
53
+ export interface StartNavigateOptions {
54
+ readonly replace?: boolean
55
+ readonly scroll?: boolean
56
+ }
57
+
58
+ export interface StartClient extends CompiledRoot {
59
+ readonly navigate: (to: string | URL, options?: StartNavigateOptions) => Promise<boolean>
60
+ /**
61
+ * Fetches the route's snapshot and code ahead of a navigation. A navigation to the same
62
+ * path and search within 30 seconds uses the prefetched snapshot instead of a new request.
63
+ */
64
+ readonly prefetch: (to: string | URL) => Promise<void>
65
+ }
66
+
67
+ type HistoryMode = 'none' | 'push' | 'replace'
68
+
69
+ type NavigationPayload =
70
+ | { readonly kind: 'document' }
71
+ | { readonly kind: 'snapshot'; readonly text: string }
72
+
73
+ interface PrefetchEntry {
74
+ readonly expires: number
75
+ readonly payload: Promise<NavigationPayload>
76
+ }
77
+
78
+ interface ClientRouteState {
79
+ readonly components: readonly ClientRouteComponent[]
80
+ readonly requestUrl: string
81
+ }
82
+
83
+ interface ClientRouteComponent {
84
+ readonly component: StartComponent
85
+ readonly entry: RouteManifestEntry
86
+ readonly loaderData: LoadedRouteMatch['loaderData']
87
+ readonly params: LoadedRouteMatch['params']
88
+ }
89
+
90
+ interface RetainedClientApplication {
91
+ readonly application: () => CompiledComponentResult
92
+ readonly state: ClientRouteState
93
+ readonly update: (state: ClientRouteState) => void
94
+ }
95
+
96
+ const routeComponentIdentities = new WeakMap<RouteManifestEntry, WeakMap<StartComponent, object>>()
97
+
98
+ export async function hydrateStart(options: HydrateStartOptions): Promise<StartClient> {
99
+ const snapshot = decodeStartSnapshot(
100
+ options.snapshot ?? readSnapshot(options.snapshotId ?? DEFAULT_SNAPSHOT_ID),
101
+ )
102
+ const initialUrl = new URL(snapshot.pathname, window.location.href)
103
+ const matches = matchRoutes(options.manifest, initialUrl.pathname)
104
+ if (matches.length === 0) throw new Error(`cannot hydrate unmatched route ${snapshot.pathname}`)
105
+ const request = new Request(initialUrl)
106
+ const loaded = await loadRouteMatches(matches, request, snapshot.loaderData)
107
+ const host =
108
+ options.root ?? document.querySelector(`#${CSS.escape(options.rootId ?? DEFAULT_ROOT_ID)}`)
109
+ if (host === null) throw new Error('Vidact Start hydration root is missing')
110
+
111
+ let application = createClientApplication(loaded, request.url)
112
+ const root = hydrateRoot(host, application.application, {
113
+ onRecoverableError:
114
+ options.onRecoverableError ??
115
+ ((error) => console.warn('[vidact/start] hydration recovered by re-rendering:', error)),
116
+ })
117
+ const fetchNavigation = options.fetch ?? window.fetch.bind(window)
118
+ let navigation = 0
119
+ let navigationController: AbortController | undefined
120
+ let disposed = false
121
+ const prefetchController = new AbortController()
122
+ const prefetches = new Map<string, PrefetchEntry>()
123
+ let hoverAnchor: HTMLAnchorElement | undefined
124
+ let hoverTimer: ReturnType<typeof setTimeout> | undefined
125
+ /** The snapshot key of the navigation in flight, which prefetching must not duplicate. */
126
+ let navigatingKey: string | undefined
127
+
128
+ async function requestNavigationPayload(
129
+ target: URL,
130
+ signal: AbortSignal,
131
+ ): Promise<NavigationPayload> {
132
+ const response = await fetchNavigation(target, {
133
+ credentials: 'same-origin',
134
+ headers: { [VIDACT_START_NAVIGATION_HEADER]: '1' },
135
+ signal,
136
+ })
137
+ if (
138
+ !response.ok ||
139
+ response.headers.get('content-type')?.split(';', 1)[0] !== VIDACT_START_SNAPSHOT_MEDIA_TYPE
140
+ ) {
141
+ return { kind: 'document' }
142
+ }
143
+ return { kind: 'snapshot', text: await response.text() }
144
+ }
145
+
146
+ async function prefetch(to: string | URL): Promise<void> {
147
+ if (disposed) return
148
+ const target = new URL(to, window.location.href)
149
+ if (!isClientNavigationUrl(target) || isCurrentDocument(target)) return
150
+ const targetMatches = matchRoutes(options.manifest, target.pathname)
151
+ if (targetMatches.length === 0) return
152
+ void resolveRouteMatches(targetMatches).catch(() => undefined)
153
+
154
+ const key = prefetchKey(target)
155
+ if (key === navigatingKey) return
156
+ const now = Date.now()
157
+ let entry = prefetches.get(key)
158
+ if (entry === undefined || entry.expires <= now) {
159
+ for (const [cachedKey, cached] of prefetches) {
160
+ if (cached.expires <= now) prefetches.delete(cachedKey)
161
+ }
162
+ const created: PrefetchEntry = {
163
+ expires: now + PREFETCH_STALE_TIME,
164
+ payload: requestNavigationPayload(target, prefetchController.signal),
165
+ }
166
+ created.payload.catch(() => {
167
+ if (prefetches.get(key) === created) prefetches.delete(key)
168
+ })
169
+ prefetches.set(key, created)
170
+ entry = created
171
+ }
172
+ await entry.payload.then(
173
+ () => undefined,
174
+ () => undefined,
175
+ )
176
+ }
177
+
178
+ /** Removes and returns a fresh prefetch so each snapshot backs at most one navigation. */
179
+ function takePrefetch(target: URL): Promise<NavigationPayload> | undefined {
180
+ const key = prefetchKey(target)
181
+ const entry = prefetches.get(key)
182
+ if (entry === undefined) return undefined
183
+ prefetches.delete(key)
184
+ return entry.expires > Date.now() ? entry.payload : undefined
185
+ }
186
+
187
+ async function navigate(
188
+ to: string | URL,
189
+ navigationOptions: StartNavigateOptions = {},
190
+ ): Promise<boolean> {
191
+ return performNavigation(
192
+ new URL(to, window.location.href),
193
+ navigationOptions.replace === true ? 'replace' : 'push',
194
+ navigationOptions.scroll !== false,
195
+ )
196
+ }
197
+
198
+ async function performNavigation(
199
+ target: URL,
200
+ historyMode: HistoryMode,
201
+ scroll: boolean,
202
+ ): Promise<boolean> {
203
+ if (disposed) throw new Error('cannot navigate an unmounted Vidact Start client')
204
+ if (!isClientNavigationUrl(target)) {
205
+ navigateDocument(target, historyMode === 'replace')
206
+ return false
207
+ }
208
+ const nextMatches = matchRoutes(options.manifest, target.pathname)
209
+ if (nextMatches.length === 0) {
210
+ navigateDocument(target, historyMode === 'replace')
211
+ return false
212
+ }
213
+
214
+ navigation += 1
215
+ const attempt = navigation
216
+ navigationController?.abort()
217
+ const controller = new AbortController()
218
+ navigationController = controller
219
+ navigatingKey = prefetchKey(target)
220
+ cancelHover()
221
+
222
+ // Route code downloads while the snapshot request is in flight instead of after it.
223
+ void resolveRouteMatches(nextMatches).catch(() => undefined)
224
+
225
+ try {
226
+ const prefetched = await takePrefetch(target)?.catch(() => undefined)
227
+ if (attempt !== navigation) return false
228
+ const payload = prefetched ?? (await requestNavigationPayload(target, controller.signal))
229
+ if (attempt !== navigation) return false
230
+ if (payload.kind === 'document') {
231
+ navigateDocument(target, historyMode === 'replace')
232
+ return false
233
+ }
234
+
235
+ const nextSnapshot = decodeStartSnapshot(payload.text)
236
+ const nextUrl = new URL(nextSnapshot.pathname, target.origin)
237
+ // Fragments never reach the server request. Preserve the caller's fragment unless
238
+ // the snapshot explicitly supplies one, including when the server redirects the path.
239
+ if (nextUrl.hash === '') nextUrl.hash = target.hash
240
+ const snapshotMatches = matchRoutes(options.manifest, nextUrl.pathname)
241
+ if (snapshotMatches.length === 0) {
242
+ navigateDocument(target, historyMode === 'replace')
243
+ return false
244
+ }
245
+ const nextRequest = new Request(nextUrl)
246
+ const nextLoaded = await loadRouteMatches(
247
+ snapshotMatches,
248
+ nextRequest,
249
+ nextSnapshot.loaderData,
250
+ )
251
+ if (attempt !== navigation) return false
252
+
253
+ const nextState = createClientRouteState(nextLoaded, nextRequest.url)
254
+ if (sharedRouteComponentCount(application.state, nextState) === 0) {
255
+ const replacement = createClientApplication(nextLoaded, nextRequest.url)
256
+ root.replace(replacement.application)
257
+ application = replacement
258
+ } else {
259
+ application.update(nextState)
260
+ }
261
+ updateHistory(nextUrl, historyMode)
262
+ if (scroll) scrollToLocation(nextUrl)
263
+ return true
264
+ } catch {
265
+ if (controller.signal.aborted || attempt !== navigation) return false
266
+ navigateDocument(target, historyMode === 'replace')
267
+ return false
268
+ } finally {
269
+ if (attempt === navigation) navigatingKey = undefined
270
+ }
271
+ }
272
+
273
+ function handleClick(event: MouseEvent): void {
274
+ const anchor = linkForEvent(event)
275
+ if (anchor === undefined) return
276
+ const target = new URL(anchor.href, window.location.href)
277
+ if (
278
+ target.pathname === window.location.pathname &&
279
+ target.search === window.location.search &&
280
+ target.hash !== window.location.hash
281
+ ) {
282
+ return
283
+ }
284
+ event.preventDefault()
285
+ void performNavigation(
286
+ target,
287
+ anchor.hasAttribute('data-vidact-start-replace') ? 'replace' : 'push',
288
+ true,
289
+ )
290
+ }
291
+
292
+ function handlePopState(): void {
293
+ void performNavigation(new URL(window.location.href), 'none', false)
294
+ }
295
+
296
+ function prefetchAnchor(anchor: HTMLAnchorElement): void {
297
+ void prefetch(anchor.href)
298
+ }
299
+
300
+ function cancelHover(): void {
301
+ if (hoverTimer !== undefined) clearTimeout(hoverTimer)
302
+ hoverTimer = undefined
303
+ hoverAnchor = undefined
304
+ }
305
+
306
+ function handlePointerOver(event: PointerEvent): void {
307
+ const anchor = prefetchLinkForTarget(event.target)
308
+ if (anchor === undefined || anchor === hoverAnchor || prefersReducedData()) return
309
+ cancelHover()
310
+ hoverAnchor = anchor
311
+ hoverTimer = setTimeout(() => {
312
+ hoverTimer = undefined
313
+ prefetchAnchor(anchor)
314
+ }, PREFETCH_HOVER_DELAY)
315
+ }
316
+
317
+ function handlePointerOut(event: PointerEvent): void {
318
+ if (hoverAnchor === undefined) return
319
+ const related = event.relatedTarget
320
+ if (related instanceof Node && hoverAnchor.contains(related)) return
321
+ cancelHover()
322
+ }
323
+
324
+ function handleFocusIn(event: FocusEvent): void {
325
+ const anchor = prefetchLinkForTarget(event.target)
326
+ if (anchor !== undefined && !prefersReducedData()) prefetchAnchor(anchor)
327
+ }
328
+
329
+ /** A press is a stronger signal than hover, so it prefetches even in data saver mode. */
330
+ function handlePointerDown(event: PointerEvent): void {
331
+ if (event.button !== 0) return
332
+ const anchor = prefetchLinkForTarget(event.target)
333
+ if (anchor !== undefined) prefetchAnchor(anchor)
334
+ }
335
+
336
+ document.addEventListener('click', handleClick)
337
+ document.addEventListener('pointerover', handlePointerOver)
338
+ document.addEventListener('pointerout', handlePointerOut)
339
+ document.addEventListener('focusin', handleFocusIn)
340
+ document.addEventListener('pointerdown', handlePointerDown)
341
+ window.addEventListener('popstate', handlePopState)
342
+
343
+ return {
344
+ mount: root.mount,
345
+ replace: root.replace,
346
+ navigate,
347
+ prefetch,
348
+ unmount() {
349
+ if (disposed) return
350
+ disposed = true
351
+ navigation += 1
352
+ navigationController?.abort()
353
+ prefetchController.abort()
354
+ prefetches.clear()
355
+ cancelHover()
356
+ document.removeEventListener('click', handleClick)
357
+ document.removeEventListener('pointerover', handlePointerOver)
358
+ document.removeEventListener('pointerout', handlePointerOut)
359
+ document.removeEventListener('focusin', handleFocusIn)
360
+ document.removeEventListener('pointerdown', handlePointerDown)
361
+ window.removeEventListener('popstate', handlePopState)
362
+ root.unmount()
363
+ },
364
+ }
365
+ }
366
+
367
+ function createClientApplication(
368
+ matches: readonly LoadedRouteMatch[],
369
+ requestUrl: string,
370
+ ): RetainedClientApplication {
371
+ const initialState = createClientRouteState(matches, requestUrl)
372
+ let currentState = initialState
373
+ let updateState: ((state: ClientRouteState) => void) | undefined
374
+ return {
375
+ application: () => {
376
+ const scope = createCompiledScope()
377
+ const routeSource = source(0)
378
+ const state = createCompiledState(scope, routeSource, currentState)
379
+ updateState = (nextState) => {
380
+ const previousState = currentState
381
+ try {
382
+ state.set(nextState)
383
+ currentState = nextState
384
+ } catch (error) {
385
+ try {
386
+ state.set(previousState)
387
+ } catch {
388
+ // Preserve the navigation failure that triggered the rollback.
389
+ }
390
+ currentState = previousState
391
+ throw error
392
+ }
393
+ }
394
+ const route = createRetainedRouteRenderable(scope, routeSource, state.get, 0)
395
+ return compiledRoot(scope, () => cloneRenderable(route))
396
+ },
397
+ get state() {
398
+ return currentState
399
+ },
400
+ update(nextState) {
401
+ if (updateState === undefined) {
402
+ throw new Error('cannot update a Vidact Start application before it mounts')
403
+ }
404
+ updateState(nextState)
405
+ },
406
+ }
407
+ }
408
+
409
+ function createClientRouteState(
410
+ matches: readonly LoadedRouteMatch[],
411
+ requestUrl: string,
412
+ ): ClientRouteState {
413
+ return {
414
+ components: matches.flatMap((match) => {
415
+ const component = match.definition.options.component
416
+ return component === undefined
417
+ ? []
418
+ : [{ component, entry: match.entry, loaderData: match.loaderData, params: match.params }]
419
+ }),
420
+ requestUrl,
421
+ }
422
+ }
423
+
424
+ function createRetainedRouteRenderable(
425
+ scope: ReturnType<typeof createCompiledScope>,
426
+ routeSource: ReturnType<typeof source>,
427
+ readState: () => ClientRouteState,
428
+ index: number,
429
+ ): CompiledRenderValue {
430
+ const initial = readState().components[index]
431
+ if (initial === undefined) return undefined
432
+ const identity = routeComponentIdentity(initial.entry, initial.component)
433
+ let lastProps = routeComponentProps(scope, routeSource, readState, index)
434
+ const input = binding(scope, routeSource, () => {
435
+ const current = readState().components[index]
436
+ if (
437
+ current === undefined ||
438
+ routeComponentIdentity(current.entry, current.component) !== identity
439
+ ) {
440
+ return lastProps
441
+ }
442
+ lastProps = routeComponentProps(scope, routeSource, readState, index)
443
+ return lastProps
444
+ })
445
+ return createComponentRenderable(initial.component as never, input, identity)
446
+ }
447
+
448
+ function routeComponentProps(
449
+ scope: ReturnType<typeof createCompiledScope>,
450
+ routeSource: ReturnType<typeof source>,
451
+ readState: () => ClientRouteState,
452
+ index: number,
453
+ ): RouteComponentProps {
454
+ const state = readState()
455
+ const match = state.components[index]!
456
+ return {
457
+ children: createRetainedRouteRenderable(scope, routeSource, readState, index + 1),
458
+ loaderData: match.loaderData,
459
+ params: match.params,
460
+ requestUrl: state.requestUrl,
461
+ }
462
+ }
463
+
464
+ function sharedRouteComponentCount(left: ClientRouteState, right: ClientRouteState): number {
465
+ const length = Math.min(left.components.length, right.components.length)
466
+ let index = 0
467
+ while (index < length) {
468
+ const previous = left.components[index]!
469
+ const next = right.components[index]!
470
+ if (previous.entry !== next.entry || previous.component !== next.component) break
471
+ index += 1
472
+ }
473
+ return index
474
+ }
475
+
476
+ function routeComponentIdentity(entry: RouteManifestEntry, component: StartComponent): object {
477
+ let componentIdentities = routeComponentIdentities.get(entry)
478
+ if (componentIdentities === undefined) {
479
+ componentIdentities = new WeakMap()
480
+ routeComponentIdentities.set(entry, componentIdentities)
481
+ }
482
+ let identity = componentIdentities.get(component)
483
+ if (identity === undefined) {
484
+ identity = {}
485
+ componentIdentities.set(component, identity)
486
+ }
487
+ return identity
488
+ }
489
+
490
+ function linkForEvent(event: MouseEvent): HTMLAnchorElement | undefined {
491
+ if (
492
+ event.defaultPrevented ||
493
+ event.button !== 0 ||
494
+ event.metaKey ||
495
+ event.ctrlKey ||
496
+ event.shiftKey ||
497
+ event.altKey
498
+ ) {
499
+ return undefined
500
+ }
501
+ return startLinkForTarget(event.target)
502
+ }
503
+
504
+ function prefetchLinkForTarget(target: EventTarget | null): HTMLAnchorElement | undefined {
505
+ const anchor = startLinkForTarget(target)
506
+ return anchor === undefined || anchor.dataset.vidactStartPrefetch === 'none' ? undefined : anchor
507
+ }
508
+
509
+ function startLinkForTarget(target: EventTarget | null): HTMLAnchorElement | undefined {
510
+ if (!(target instanceof Element)) return undefined
511
+ const anchor = target.closest<HTMLAnchorElement>('a[data-vidact-start-link]')
512
+ if (
513
+ anchor === null ||
514
+ anchor.hasAttribute('download') ||
515
+ (anchor.target !== '' && anchor.target !== '_self') ||
516
+ anchor.rel.split(/\s+/u).includes('external')
517
+ ) {
518
+ return undefined
519
+ }
520
+ const url = new URL(anchor.href, window.location.href)
521
+ return isClientNavigationUrl(url) ? anchor : undefined
522
+ }
523
+
524
+ function isClientNavigationUrl(url: URL): boolean {
525
+ return (
526
+ url.origin === window.location.origin && (url.protocol === 'http:' || url.protocol === 'https:')
527
+ )
528
+ }
529
+
530
+ function isCurrentDocument(url: URL): boolean {
531
+ return url.pathname === window.location.pathname && url.search === window.location.search
532
+ }
533
+
534
+ function prefetchKey(url: URL): string {
535
+ return `${url.pathname}${url.search}`
536
+ }
537
+
538
+ function prefersReducedData(): boolean {
539
+ const connection = (navigator as Navigator & { readonly connection?: { saveData?: boolean } })
540
+ .connection
541
+ return connection?.saveData === true
542
+ }
543
+
544
+ function updateHistory(url: URL, mode: HistoryMode): void {
545
+ if (mode === 'push') window.history.pushState({}, '', url)
546
+ else if (mode === 'replace') window.history.replaceState(window.history.state, '', url)
547
+ }
548
+
549
+ function scrollToLocation(url: URL): void {
550
+ if (url.hash.length > 1) {
551
+ let id: string
552
+ try {
553
+ id = decodeURIComponent(url.hash.slice(1))
554
+ } catch {
555
+ id = url.hash.slice(1)
556
+ }
557
+ const target = document.getElementById(id)
558
+ if (target !== null) {
559
+ target.scrollIntoView()
560
+ return
561
+ }
562
+ }
563
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
564
+ }
565
+
566
+ function navigateDocument(url: URL, replace: boolean): void {
567
+ if (replace) window.location.replace(url)
568
+ else window.location.assign(url)
569
+ }
570
+
571
+ function readSnapshot(id: string): string {
572
+ const element = document.querySelector(`#${CSS.escape(id)}`)
573
+ if (element === null) throw new Error(`Vidact Start snapshot ${id} is missing`)
574
+ return element.textContent ?? ''
575
+ }
576
+
577
+ 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,11 @@
1
+ import { compiledRoot, createNarrowCompiledScope, 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
+ const scope = createNarrowCompiledScope()
8
+ return compiledRoot(scope, () => createElement('a', anchorProps(props)))
9
+ }
10
+
11
+ export type { LinkProps }
@@ -0,0 +1,20 @@
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
+ /** `intent` (the default) prefetches on hover, focus, or press; `none` waits for the click. */
7
+ readonly prefetch?: 'intent' | 'none'
8
+ readonly reloadDocument?: boolean
9
+ readonly replace?: boolean
10
+ }
11
+
12
+ export function anchorProps(props: LinkProps): Record<string, unknown> {
13
+ const { prefetch = 'intent', reloadDocument = false, replace = false, ...attributes } = props
14
+ return {
15
+ ...attributes,
16
+ ...(reloadDocument ? {} : { 'data-vidact-start-link': '' }),
17
+ ...(replace ? { 'data-vidact-start-replace': '' } : {}),
18
+ ...(!reloadDocument && prefetch === 'none' ? { 'data-vidact-start-prefetch': 'none' } : {}),
19
+ }
20
+ }
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 }