@netrojs/fnetro 0.2.21 → 0.3.0
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/README.md +185 -878
- package/client.ts +213 -242
- package/core.ts +74 -175
- package/dist/client.d.ts +69 -60
- package/dist/client.js +170 -177
- package/dist/core.d.ts +57 -40
- package/dist/core.js +50 -28
- package/dist/server.d.ts +69 -66
- package/dist/server.js +178 -199
- package/dist/types.d.ts +99 -0
- package/package.json +21 -20
- package/server.ts +263 -350
- package/types.ts +125 -0
package/client.ts
CHANGED
|
@@ -1,79 +1,51 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// FNetro · client.ts
|
|
3
|
-
//
|
|
3
|
+
// Vue 3 SSR hydration · Vue Router SPA · reactive page data · SEO sync
|
|
4
4
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
-
import { createSignal, createComponent, lazy, Suspense } from 'solid-js'
|
|
7
|
-
import { hydrate } from 'solid-js/web'
|
|
8
|
-
import { Router, Route } from '@solidjs/router'
|
|
9
6
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
createSSRApp,
|
|
8
|
+
defineAsyncComponent,
|
|
9
|
+
defineComponent,
|
|
10
|
+
h,
|
|
11
|
+
inject,
|
|
12
|
+
reactive,
|
|
13
|
+
readonly,
|
|
14
|
+
type Component,
|
|
15
|
+
type InjectionKey,
|
|
16
|
+
} from 'vue'
|
|
17
|
+
import {
|
|
18
|
+
createRouter,
|
|
19
|
+
createWebHistory,
|
|
20
|
+
RouterView,
|
|
21
|
+
} from 'vue-router'
|
|
22
|
+
import {
|
|
23
|
+
isAsyncLoader,
|
|
24
|
+
resolveRoutes,
|
|
25
|
+
toVueRouterPath,
|
|
26
|
+
compilePath,
|
|
27
|
+
matchPath,
|
|
28
|
+
SPA_HEADER,
|
|
29
|
+
STATE_KEY,
|
|
30
|
+
PARAMS_KEY,
|
|
31
|
+
SEO_KEY,
|
|
32
|
+
DATA_KEY,
|
|
33
|
+
type AppConfig,
|
|
34
|
+
type LayoutDef,
|
|
35
|
+
type SEOMeta,
|
|
36
|
+
type ClientMiddleware,
|
|
14
37
|
} from './core'
|
|
15
38
|
|
|
16
|
-
//
|
|
17
|
-
// § 1 Compiled route cache (module-level, populated on boot)
|
|
18
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
19
|
-
|
|
20
|
-
interface CRoute { route: ResolvedRoute; cp: CompiledPath }
|
|
21
|
-
|
|
22
|
-
let _routes: CRoute[] = []
|
|
23
|
-
let _appLayout: LayoutDef | undefined
|
|
24
|
-
|
|
25
|
-
function findRoute(pathname: string) {
|
|
26
|
-
for (const { route, cp } of _routes) {
|
|
27
|
-
const params = matchPath(cp, pathname)
|
|
28
|
-
if (params !== null) return { route, params }
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
34
|
-
// § 2 Client middleware
|
|
35
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
36
|
-
|
|
37
|
-
const _mw: ClientMiddleware[] = []
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Register a client-side navigation middleware.
|
|
41
|
-
* Must be called **before** `boot()`.
|
|
42
|
-
*
|
|
43
|
-
* @example
|
|
44
|
-
* useClientMiddleware(async (url, next) => {
|
|
45
|
-
* if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
46
|
-
* await navigate('/login')
|
|
47
|
-
* return // cancel original navigation
|
|
48
|
-
* }
|
|
49
|
-
* await next()
|
|
50
|
-
* })
|
|
51
|
-
*/
|
|
52
|
-
export function useClientMiddleware(mw: ClientMiddleware): void {
|
|
53
|
-
_mw.push(mw)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function runMiddleware(url: string, done: () => Promise<void>): Promise<void> {
|
|
57
|
-
const chain = [..._mw, async (_u: string, next: () => Promise<void>) => { await done(); await next() }]
|
|
58
|
-
let i = 0
|
|
59
|
-
const run = async (): Promise<void> => {
|
|
60
|
-
const fn = chain[i++]
|
|
61
|
-
if (fn) await fn(url, run)
|
|
62
|
-
}
|
|
63
|
-
await run()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
67
|
-
// § 3 SEO — client-side <head> sync
|
|
68
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
39
|
+
// ── SEO ───────────────────────────────────────────────────────────────────────
|
|
69
40
|
|
|
70
|
-
function setMeta(selector: string, attr: string, val
|
|
41
|
+
function setMeta(selector: string, attr: string, val?: string): void {
|
|
71
42
|
if (!val) { document.querySelector(selector)?.remove(); return }
|
|
72
43
|
let el = document.querySelector<HTMLMetaElement>(selector)
|
|
73
44
|
if (!el) {
|
|
74
45
|
el = document.createElement('meta')
|
|
75
|
-
|
|
76
|
-
|
|
46
|
+
// Destructuring with defaults avoids string|undefined from noUncheckedIndexedAccess
|
|
47
|
+
const [, attrName = '', attrVal = ''] = /\[([^=]+)="([^"]+)"\]/.exec(selector) ?? []
|
|
48
|
+
if (attrName) el.setAttribute(attrName, attrVal)
|
|
77
49
|
document.head.appendChild(el)
|
|
78
50
|
}
|
|
79
51
|
el.setAttribute(attr, val)
|
|
@@ -81,7 +53,6 @@ function setMeta(selector: string, attr: string, val: string | undefined): void
|
|
|
81
53
|
|
|
82
54
|
export function syncSEO(seo: SEOMeta): void {
|
|
83
55
|
if (seo.title) document.title = seo.title
|
|
84
|
-
|
|
85
56
|
setMeta('[name="description"]', 'content', seo.description)
|
|
86
57
|
setMeta('[name="keywords"]', 'content', seo.keywords)
|
|
87
58
|
setMeta('[name="robots"]', 'content', seo.robots)
|
|
@@ -96,243 +67,243 @@ export function syncSEO(seo: SEOMeta): void {
|
|
|
96
67
|
setMeta('[name="twitter:description"]','content', seo.twitterDescription)
|
|
97
68
|
setMeta('[name="twitter:image"]', 'content', seo.twitterImage)
|
|
98
69
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
linkEl.rel = 'canonical'
|
|
106
|
-
document.head.appendChild(linkEl)
|
|
70
|
+
let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
|
71
|
+
if (seo.canonical) {
|
|
72
|
+
if (!link) {
|
|
73
|
+
link = document.createElement('link')
|
|
74
|
+
link.rel = 'canonical'
|
|
75
|
+
document.head.appendChild(link)
|
|
107
76
|
}
|
|
108
|
-
|
|
77
|
+
link.href = seo.canonical
|
|
109
78
|
} else {
|
|
110
|
-
|
|
79
|
+
link?.remove()
|
|
111
80
|
}
|
|
112
81
|
}
|
|
113
82
|
|
|
114
|
-
//
|
|
115
|
-
// § 4 Prefetch cache + SPA data fetching
|
|
116
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
// ── SPA data fetch + prefetch cache ──────────────────────────────────────────
|
|
117
84
|
|
|
118
|
-
interface
|
|
85
|
+
interface SpaPayload {
|
|
119
86
|
state: Record<string, unknown>
|
|
120
87
|
params: Record<string, string>
|
|
121
88
|
seo: SEOMeta
|
|
122
|
-
url: string
|
|
123
89
|
}
|
|
124
90
|
|
|
125
|
-
|
|
91
|
+
// Module-level cache so repeated visits to the same URL don't re-fetch
|
|
92
|
+
const _fetchCache = new Map<string, Promise<SpaPayload>>()
|
|
126
93
|
|
|
127
|
-
|
|
128
|
-
if (!
|
|
129
|
-
|
|
94
|
+
function fetchSPA(href: string): Promise<SpaPayload> {
|
|
95
|
+
if (!_fetchCache.has(href)) {
|
|
96
|
+
_fetchCache.set(
|
|
130
97
|
href,
|
|
131
|
-
fetch(href, { headers: { [SPA_HEADER]: '1' } })
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}),
|
|
98
|
+
fetch(href, { headers: { [SPA_HEADER]: '1' } }).then(r => {
|
|
99
|
+
if (!r.ok) throw new Error(`[fnetro] ${r.status} ${r.statusText} — ${href}`)
|
|
100
|
+
return r.json() as Promise<SpaPayload>
|
|
101
|
+
}),
|
|
136
102
|
)
|
|
137
103
|
}
|
|
138
|
-
return
|
|
104
|
+
return _fetchCache.get(href)!
|
|
139
105
|
}
|
|
140
106
|
|
|
141
|
-
/** Warm the prefetch cache for a URL on hover/focus/etc. */
|
|
142
107
|
export function prefetch(url: string): void {
|
|
143
108
|
try {
|
|
144
109
|
const u = new URL(url, location.origin)
|
|
145
|
-
if (u.origin
|
|
146
|
-
|
|
147
|
-
} catch { /* ignore invalid URLs */ }
|
|
110
|
+
if (u.origin === location.origin) fetchSPA(u.toString())
|
|
111
|
+
} catch { /* ignore malformed URLs */ }
|
|
148
112
|
}
|
|
149
113
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
114
|
+
// ── Client middleware ─────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const _mw: ClientMiddleware[] = []
|
|
153
117
|
|
|
154
118
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
119
|
+
* Register a client-side navigation middleware.
|
|
120
|
+
* Must be called **before** `boot()`.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* useClientMiddleware(async (url, next) => {
|
|
124
|
+
* if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
125
|
+
* await navigate('/login')
|
|
126
|
+
* return
|
|
127
|
+
* }
|
|
128
|
+
* await next()
|
|
129
|
+
* })
|
|
158
130
|
*/
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
initialState: Record<string, unknown>,
|
|
163
|
-
initialParams: Record<string, string>,
|
|
164
|
-
initialSeo: SEOMeta,
|
|
165
|
-
prefetchOnHover: boolean,
|
|
166
|
-
) {
|
|
167
|
-
// The component returned here is used as @solidjs/router's <Route component>
|
|
168
|
-
return function FNetroRouteComponent(routerProps: any) {
|
|
169
|
-
// routerProps.params comes from @solidjs/router's URL matching
|
|
170
|
-
const routeParams: Record<string, string> = routerProps.params ?? {}
|
|
171
|
-
const pathname: string = routerProps.location?.pathname ?? location.pathname
|
|
172
|
-
|
|
173
|
-
// Determine the data source:
|
|
174
|
-
// - If this matches the server's initial state key, use it directly (no fetch needed on first load)
|
|
175
|
-
// - Otherwise fetch from the server via the SPA JSON endpoint
|
|
176
|
-
const serverData = initialState[pathname] as Record<string, unknown> | undefined
|
|
177
|
-
const [data, setData] = createSignal<Record<string, unknown>>(serverData ?? {})
|
|
178
|
-
const [params, setParams] = createSignal<Record<string, string>>(serverData ? initialParams : routeParams)
|
|
179
|
-
|
|
180
|
-
// Load data if we don't have it yet from the server
|
|
181
|
-
if (!serverData) {
|
|
182
|
-
const url = new URL(pathname, location.origin).toString()
|
|
183
|
-
fetchPayload(url).then(payload => {
|
|
184
|
-
setData(payload.state ?? {})
|
|
185
|
-
setParams(payload.params ?? {})
|
|
186
|
-
syncSEO(payload.seo ?? {})
|
|
187
|
-
}).catch(err => {
|
|
188
|
-
console.error('[fnetro] Failed to load route data:', err)
|
|
189
|
-
})
|
|
190
|
-
} else {
|
|
191
|
-
// Sync SEO for the initial page from server-injected data
|
|
192
|
-
syncSEO(initialSeo)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Render the page (and optional layout wrapper)
|
|
196
|
-
const layout = route.layout !== undefined ? route.layout : appLayout
|
|
197
|
-
|
|
198
|
-
const pageEl = () => createComponent(route.page.Page as any, {
|
|
199
|
-
...data(),
|
|
200
|
-
url: pathname,
|
|
201
|
-
params: params(),
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
if (!layout) return pageEl()
|
|
131
|
+
export function useClientMiddleware(mw: ClientMiddleware): void {
|
|
132
|
+
_mw.push(mw)
|
|
133
|
+
}
|
|
205
134
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
135
|
+
async function runMw(url: string, done: () => Promise<void>): Promise<void> {
|
|
136
|
+
const chain: ClientMiddleware[] = [
|
|
137
|
+
..._mw,
|
|
138
|
+
async (_: string, next: () => Promise<void>) => { await done(); await next() },
|
|
139
|
+
]
|
|
140
|
+
let i = 0
|
|
141
|
+
const run = async (): Promise<void> => {
|
|
142
|
+
const fn = chain[i++]
|
|
143
|
+
if (fn) await fn(url, run)
|
|
211
144
|
}
|
|
145
|
+
await run()
|
|
212
146
|
}
|
|
213
147
|
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
148
|
+
// ── Reactive page data ────────────────────────────────────────────────────────
|
|
149
|
+
//
|
|
150
|
+
// A single module-level reactive object that lives for the app's lifetime.
|
|
151
|
+
// On SPA navigation it is updated in-place so page components re-render
|
|
152
|
+
// reactively without being unmounted.
|
|
153
|
+
//
|
|
154
|
+
// The app provides it as readonly via DATA_KEY so page components cannot
|
|
155
|
+
// mutate it directly.
|
|
156
|
+
|
|
157
|
+
const _pageData = reactive<Record<string, unknown>>({})
|
|
158
|
+
|
|
159
|
+
function updatePageData(newData: Record<string, unknown>): void {
|
|
160
|
+
// Delete keys that are no longer present in the new data
|
|
161
|
+
for (const k of Object.keys(_pageData)) {
|
|
162
|
+
if (!(k in newData)) delete _pageData[k]
|
|
163
|
+
}
|
|
164
|
+
Object.assign(_pageData, newData)
|
|
221
165
|
}
|
|
222
166
|
|
|
223
167
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
168
|
+
* Access the current page's loader data inside any Vue component.
|
|
169
|
+
* The returned object is reactive — it updates automatically on navigation.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* const data = usePageData<{ title: string; posts: Post[] }>()
|
|
173
|
+
* // data.title is typed and reactive
|
|
226
174
|
*/
|
|
227
|
-
export
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// Prefetch/cache the payload so the route component can use it
|
|
235
|
-
const payload = await fetchPayload(u.toString())
|
|
236
|
-
history[opts.replace ? 'replaceState' : 'pushState'](
|
|
237
|
-
{ url: u.pathname }, '', u.pathname,
|
|
238
|
-
)
|
|
239
|
-
if (opts.scroll !== false) window.scrollTo(0, 0)
|
|
240
|
-
syncSEO(payload.seo ?? {})
|
|
241
|
-
// Dispatch a popstate-like event so @solidjs/router's location signal updates
|
|
242
|
-
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }))
|
|
243
|
-
} catch (err) {
|
|
244
|
-
console.error('[fnetro] Navigation error:', err)
|
|
245
|
-
location.href = to
|
|
246
|
-
}
|
|
247
|
-
})
|
|
175
|
+
export function usePageData<T extends Record<string, unknown> = Record<string, unknown>>(): T {
|
|
176
|
+
// DATA_KEY is typed as symbol; cast to InjectionKey for strong inference
|
|
177
|
+
const data = inject(DATA_KEY as InjectionKey<T>)
|
|
178
|
+
if (data === undefined) {
|
|
179
|
+
throw new Error('[fnetro] usePageData() must be called inside a component setup().')
|
|
180
|
+
}
|
|
181
|
+
return data
|
|
248
182
|
}
|
|
249
183
|
|
|
250
|
-
//
|
|
251
|
-
// § 7 boot()
|
|
252
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
184
|
+
// ── boot() ────────────────────────────────────────────────────────────────────
|
|
253
185
|
|
|
254
186
|
export interface BootOptions extends AppConfig {
|
|
255
|
-
/**
|
|
187
|
+
/** Warm fetch cache on link hover. @default true */
|
|
256
188
|
prefetchOnHover?: boolean
|
|
257
189
|
}
|
|
258
190
|
|
|
259
191
|
export async function boot(options: BootOptions): Promise<void> {
|
|
192
|
+
const container = document.getElementById('fnetro-app')
|
|
193
|
+
if (!container) {
|
|
194
|
+
console.error('[fnetro] #fnetro-app not found — aborting hydration.')
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
260
198
|
const { pages } = resolveRoutes(options.routes, {
|
|
261
|
-
layout:
|
|
199
|
+
...(options.layout !== undefined && { layout: options.layout }),
|
|
262
200
|
middleware: [],
|
|
263
201
|
})
|
|
264
202
|
|
|
265
|
-
|
|
266
|
-
_appLayout = options.layout
|
|
267
|
-
|
|
268
|
-
const pathname = location.pathname
|
|
269
|
-
if (!findRoute(pathname)) {
|
|
270
|
-
console.warn(`[fnetro] No route matched "${pathname}" — skipping hydration`)
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Server-injected initial state (no refetch needed on first load)
|
|
203
|
+
// Read server-injected bootstrap data
|
|
275
204
|
const stateMap = (window as any)[STATE_KEY] as Record<string, Record<string, unknown>> ?? {}
|
|
276
|
-
const paramsMap = (window as any)[PARAMS_KEY] as Record<string, string> ?? {}
|
|
277
205
|
const seoData = (window as any)[SEO_KEY] as SEOMeta ?? {}
|
|
206
|
+
const pathname = location.pathname
|
|
207
|
+
|
|
208
|
+
// Seed reactive store and sync SEO from server data (no network request)
|
|
209
|
+
updatePageData(stateMap[pathname] ?? {})
|
|
210
|
+
syncSEO(seoData)
|
|
211
|
+
|
|
212
|
+
// Build Vue Router route table
|
|
213
|
+
// Async loaders are wrapped with defineAsyncComponent for code splitting.
|
|
214
|
+
const vueRoutes = pages.map(r => {
|
|
215
|
+
const layout = r.layout !== undefined ? r.layout : options.layout
|
|
216
|
+
const comp = r.page.component
|
|
217
|
+
|
|
218
|
+
const PageComp: Component = isAsyncLoader(comp)
|
|
219
|
+
? defineAsyncComponent(comp)
|
|
220
|
+
: comp as Component
|
|
221
|
+
|
|
222
|
+
const routeComp: Component = layout
|
|
223
|
+
? defineComponent({
|
|
224
|
+
name: 'FNetroRoute',
|
|
225
|
+
setup: () => () => h((layout as LayoutDef).component as Component, null, {
|
|
226
|
+
default: () => h(PageComp),
|
|
227
|
+
}),
|
|
228
|
+
})
|
|
229
|
+
: PageComp
|
|
230
|
+
|
|
231
|
+
return { path: toVueRouterPath(r.fullPath), component: routeComp }
|
|
232
|
+
})
|
|
278
233
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
234
|
+
// Pre-load the current route's async chunk BEFORE hydrating to guarantee the
|
|
235
|
+
// client VDOM matches the SSR HTML (avoids hydration mismatch on first load).
|
|
236
|
+
const currentRoute = pages.find(r => matchPath(compilePath(r.fullPath), pathname) !== null)
|
|
237
|
+
if (currentRoute && isAsyncLoader(currentRoute.page.component)) {
|
|
238
|
+
await currentRoute.page.component()
|
|
283
239
|
}
|
|
284
240
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
241
|
+
// createSSRApp: tells Vue to hydrate existing DOM instead of re-rendering
|
|
242
|
+
const app = createSSRApp({ name: 'FNetroApp', render: () => h(RouterView) })
|
|
243
|
+
app.provide(DATA_KEY as InjectionKey<typeof _pageData>, readonly(_pageData))
|
|
244
|
+
|
|
245
|
+
const router = createRouter({ history: createWebHistory(), routes: vueRoutes })
|
|
246
|
+
|
|
247
|
+
// Track whether this is the initial (server-hydrated) navigation.
|
|
248
|
+
// We skip data fetching for the first navigation — the server already
|
|
249
|
+
// injected the data into window.__FNETRO_STATE__.
|
|
250
|
+
let isInitialNav = true
|
|
251
|
+
|
|
252
|
+
router.beforeEach(async (to, _from, next) => {
|
|
253
|
+
if (isInitialNav) {
|
|
254
|
+
isInitialNav = false
|
|
255
|
+
return next()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const href = new URL(to.fullPath, location.origin).toString()
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await runMw(to.fullPath, async () => {
|
|
262
|
+
const payload = await fetchSPA(href)
|
|
263
|
+
updatePageData(payload.state ?? {})
|
|
264
|
+
syncSEO(payload.seo ?? {})
|
|
265
|
+
window.scrollTo(0, 0)
|
|
266
|
+
})
|
|
267
|
+
next()
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error('[fnetro] Navigation error:', err)
|
|
270
|
+
// Hard navigate as fallback — the server will handle the request
|
|
271
|
+
location.href = to.fullPath
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
app.use(router)
|
|
276
|
+
await router.isReady()
|
|
277
|
+
app.mount(container)
|
|
278
|
+
|
|
279
|
+
// Hover prefetch — warm the fetch cache before the user clicks
|
|
280
|
+
if (options.prefetchOnHover !== false) {
|
|
281
|
+
document.addEventListener('mouseover', (e) => {
|
|
282
|
+
const a = (e as MouseEvent).composedPath()
|
|
283
|
+
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement)
|
|
316
284
|
if (a?.href) prefetch(a.href)
|
|
317
285
|
})
|
|
318
286
|
}
|
|
319
287
|
}
|
|
320
288
|
|
|
321
|
-
//
|
|
322
|
-
// § 8 Re-exports
|
|
323
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
289
|
+
// ── Re-exports ────────────────────────────────────────────────────────────────
|
|
324
290
|
|
|
325
291
|
export {
|
|
326
|
-
definePage, defineGroup, defineLayout, defineApiRoute,
|
|
327
|
-
resolveRoutes, compilePath, matchPath,
|
|
328
|
-
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
|
|
292
|
+
definePage, defineGroup, defineLayout, defineApiRoute, isAsyncLoader,
|
|
293
|
+
resolveRoutes, compilePath, matchPath, toVueRouterPath,
|
|
294
|
+
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY,
|
|
329
295
|
} from './core'
|
|
330
296
|
|
|
331
297
|
export type {
|
|
332
298
|
AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
|
|
333
|
-
|
|
334
|
-
|
|
299
|
+
SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
|
|
300
|
+
ClientMiddleware, AsyncLoader,
|
|
335
301
|
} from './core'
|
|
336
302
|
|
|
337
|
-
//
|
|
338
|
-
export {
|
|
303
|
+
// Vue Router composables re-exported for convenience
|
|
304
|
+
export {
|
|
305
|
+
useRoute,
|
|
306
|
+
useRouter,
|
|
307
|
+
RouterLink,
|
|
308
|
+
RouterView,
|
|
309
|
+
} from 'vue-router'
|