@netrojs/fnetro 0.2.20 → 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 +218 -236
- package/core.ts +74 -175
- package/dist/client.d.ts +71 -49
- package/dist/client.js +176 -169
- 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 -192
- package/dist/types.d.ts +99 -0
- package/package.json +21 -18
- package/server.ts +262 -337
- package/types.ts +125 -0
package/client.ts
CHANGED
|
@@ -1,99 +1,58 @@
|
|
|
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, createMemo, createComponent } from 'solid-js'
|
|
7
|
-
import { hydrate } from 'solid-js/web'
|
|
8
6
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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,
|
|
13
37
|
} from './core'
|
|
14
38
|
|
|
15
|
-
//
|
|
16
|
-
// § 1 Compiled route cache (module-level, populated on boot)
|
|
17
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
18
|
-
|
|
19
|
-
interface CRoute { route: ResolvedRoute; cp: CompiledPath }
|
|
20
|
-
|
|
21
|
-
let _routes: CRoute[] = []
|
|
22
|
-
let _appLayout: LayoutDef | undefined
|
|
23
|
-
|
|
24
|
-
function findRoute(pathname: string) {
|
|
25
|
-
for (const { route, cp } of _routes) {
|
|
26
|
-
const params = matchPath(cp, pathname)
|
|
27
|
-
if (params !== null) return { route, params }
|
|
28
|
-
}
|
|
29
|
-
return null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
33
|
-
// § 2 Navigation state signal
|
|
34
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
35
|
-
|
|
36
|
-
interface NavState {
|
|
37
|
-
path: string
|
|
38
|
-
data: Record<string, unknown>
|
|
39
|
-
params: Record<string, string>
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Populated by createAppRoot(); exposed so navigate() can update it.
|
|
43
|
-
let _setNav: ((s: NavState) => void) | null = null
|
|
44
|
-
|
|
45
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
46
|
-
// § 3 Client middleware
|
|
47
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
48
|
-
|
|
49
|
-
const _mw: ClientMiddleware[] = []
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Register a client-side navigation middleware.
|
|
53
|
-
* Must be called **before** `boot()`.
|
|
54
|
-
*
|
|
55
|
-
* @example
|
|
56
|
-
* useClientMiddleware(async (url, next) => {
|
|
57
|
-
* if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
58
|
-
* await navigate('/login')
|
|
59
|
-
* return // cancel original navigation
|
|
60
|
-
* }
|
|
61
|
-
* await next()
|
|
62
|
-
* })
|
|
63
|
-
*/
|
|
64
|
-
export function useClientMiddleware(mw: ClientMiddleware): void {
|
|
65
|
-
_mw.push(mw)
|
|
66
|
-
}
|
|
39
|
+
// ── SEO ───────────────────────────────────────────────────────────────────────
|
|
67
40
|
|
|
68
|
-
|
|
69
|
-
const chain = [..._mw, async (_u: string, next: () => Promise<void>) => { await done(); await next() }]
|
|
70
|
-
let i = 0
|
|
71
|
-
const run = async (): Promise<void> => {
|
|
72
|
-
const fn = chain[i++]
|
|
73
|
-
if (fn) await fn(url, run)
|
|
74
|
-
}
|
|
75
|
-
await run()
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
79
|
-
// § 4 SEO — client-side <head> sync
|
|
80
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
81
|
-
|
|
82
|
-
function setMeta(selector: string, attr: string, val: string | undefined): void {
|
|
41
|
+
function setMeta(selector: string, attr: string, val?: string): void {
|
|
83
42
|
if (!val) { document.querySelector(selector)?.remove(); return }
|
|
84
43
|
let el = document.querySelector<HTMLMetaElement>(selector)
|
|
85
44
|
if (!el) {
|
|
86
45
|
el = document.createElement('meta')
|
|
87
|
-
|
|
88
|
-
|
|
46
|
+
// Destructuring with defaults avoids string|undefined from noUncheckedIndexedAccess
|
|
47
|
+
const [, attrName = '', attrVal = ''] = /\[([^=]+)="([^"]+)"\]/.exec(selector) ?? []
|
|
48
|
+
if (attrName) el.setAttribute(attrName, attrVal)
|
|
89
49
|
document.head.appendChild(el)
|
|
90
50
|
}
|
|
91
51
|
el.setAttribute(attr, val)
|
|
92
52
|
}
|
|
93
53
|
|
|
94
|
-
function syncSEO(seo: SEOMeta): void {
|
|
54
|
+
export function syncSEO(seo: SEOMeta): void {
|
|
95
55
|
if (seo.title) document.title = seo.title
|
|
96
|
-
|
|
97
56
|
setMeta('[name="description"]', 'content', seo.description)
|
|
98
57
|
setMeta('[name="keywords"]', 'content', seo.keywords)
|
|
99
58
|
setMeta('[name="robots"]', 'content', seo.robots)
|
|
@@ -108,220 +67,243 @@ function syncSEO(seo: SEOMeta): void {
|
|
|
108
67
|
setMeta('[name="twitter:description"]','content', seo.twitterDescription)
|
|
109
68
|
setMeta('[name="twitter:image"]', 'content', seo.twitterImage)
|
|
110
69
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
linkEl.rel = 'canonical'
|
|
118
|
-
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)
|
|
119
76
|
}
|
|
120
|
-
|
|
77
|
+
link.href = seo.canonical
|
|
121
78
|
} else {
|
|
122
|
-
|
|
79
|
+
link?.remove()
|
|
123
80
|
}
|
|
124
81
|
}
|
|
125
82
|
|
|
126
|
-
//
|
|
127
|
-
// § 5 Prefetch cache
|
|
128
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
// ── SPA data fetch + prefetch cache ──────────────────────────────────────────
|
|
129
84
|
|
|
130
|
-
interface
|
|
85
|
+
interface SpaPayload {
|
|
131
86
|
state: Record<string, unknown>
|
|
132
87
|
params: Record<string, string>
|
|
133
88
|
seo: SEOMeta
|
|
134
|
-
url: string
|
|
135
89
|
}
|
|
136
90
|
|
|
137
|
-
|
|
91
|
+
// Module-level cache so repeated visits to the same URL don't re-fetch
|
|
92
|
+
const _fetchCache = new Map<string, Promise<SpaPayload>>()
|
|
138
93
|
|
|
139
|
-
function
|
|
140
|
-
if (!
|
|
141
|
-
|
|
94
|
+
function fetchSPA(href: string): Promise<SpaPayload> {
|
|
95
|
+
if (!_fetchCache.has(href)) {
|
|
96
|
+
_fetchCache.set(
|
|
142
97
|
href,
|
|
143
|
-
fetch(href, { headers: { [SPA_HEADER]: '1' } })
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}),
|
|
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
|
+
}),
|
|
148
102
|
)
|
|
149
103
|
}
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
154
|
-
// § 6 navigate / prefetch
|
|
155
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
156
|
-
|
|
157
|
-
export interface NavigateOptions {
|
|
158
|
-
replace?: boolean
|
|
159
|
-
scroll?: boolean
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export async function navigate(to: string, opts: NavigateOptions = {}): Promise<void> {
|
|
163
|
-
const u = new URL(to, location.origin)
|
|
164
|
-
if (u.origin !== location.origin) { location.href = to; return }
|
|
165
|
-
if (!findRoute(u.pathname)) { location.href = to; return }
|
|
166
|
-
|
|
167
|
-
await runMiddleware(u.pathname, async () => {
|
|
168
|
-
try {
|
|
169
|
-
const payload = await fetchPayload(u.toString())
|
|
170
|
-
history[opts.replace ? 'replaceState' : 'pushState'](
|
|
171
|
-
{ url: u.pathname }, '', u.pathname,
|
|
172
|
-
)
|
|
173
|
-
if (opts.scroll !== false) window.scrollTo(0, 0)
|
|
174
|
-
|
|
175
|
-
_setNav?.({ path: u.pathname, data: payload.state ?? {}, params: payload.params ?? {} })
|
|
176
|
-
syncSEO(payload.seo ?? {})
|
|
177
|
-
} catch (err) {
|
|
178
|
-
console.error('[fnetro] Navigation error:', err)
|
|
179
|
-
location.href = to
|
|
180
|
-
}
|
|
181
|
-
})
|
|
104
|
+
return _fetchCache.get(href)!
|
|
182
105
|
}
|
|
183
106
|
|
|
184
|
-
/** Warm the prefetch cache for a URL on hover/focus/etc. */
|
|
185
107
|
export function prefetch(url: string): void {
|
|
186
108
|
try {
|
|
187
109
|
const u = new URL(url, location.origin)
|
|
188
|
-
if (u.origin
|
|
189
|
-
|
|
190
|
-
} catch { /* ignore invalid URLs */ }
|
|
110
|
+
if (u.origin === location.origin) fetchSPA(u.toString())
|
|
111
|
+
} catch { /* ignore malformed URLs */ }
|
|
191
112
|
}
|
|
192
113
|
|
|
193
|
-
//
|
|
194
|
-
// § 7 DOM event intercepts
|
|
195
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
196
|
-
|
|
197
|
-
function onLinkClick(e: MouseEvent): void {
|
|
198
|
-
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
199
|
-
const a = e.composedPath().find(
|
|
200
|
-
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
|
|
201
|
-
)
|
|
202
|
-
if (!a?.href) return
|
|
203
|
-
if (a.target && a.target !== '_self') return
|
|
204
|
-
if (a.hasAttribute('data-no-spa') || a.rel?.includes('external')) return
|
|
205
|
-
const u = new URL(a.href)
|
|
206
|
-
if (u.origin !== location.origin) return
|
|
207
|
-
e.preventDefault()
|
|
208
|
-
navigate(a.href)
|
|
209
|
-
}
|
|
114
|
+
// ── Client middleware ─────────────────────────────────────────────────────────
|
|
210
115
|
|
|
211
|
-
|
|
212
|
-
const a = e.composedPath().find(
|
|
213
|
-
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
|
|
214
|
-
)
|
|
215
|
-
if (a?.href) prefetch(a.href)
|
|
216
|
-
}
|
|
116
|
+
const _mw: ClientMiddleware[] = []
|
|
217
117
|
|
|
218
|
-
|
|
219
|
-
|
|
118
|
+
/**
|
|
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
|
+
* })
|
|
130
|
+
*/
|
|
131
|
+
export function useClientMiddleware(mw: ClientMiddleware): void {
|
|
132
|
+
_mw.push(mw)
|
|
220
133
|
}
|
|
221
134
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!m) {
|
|
236
|
-
// No match client-side — shouldn't happen but handle gracefully
|
|
237
|
-
return null as any
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const layout = m.route.layout !== undefined ? m.route.layout : props.appLayout
|
|
241
|
-
const pageEl = createComponent(m.route.page.Page as any, { ...data, url: path, params })
|
|
242
|
-
|
|
243
|
-
if (!layout) return pageEl
|
|
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)
|
|
144
|
+
}
|
|
145
|
+
await run()
|
|
146
|
+
}
|
|
244
147
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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)
|
|
165
|
+
}
|
|
251
166
|
|
|
252
|
-
|
|
167
|
+
/**
|
|
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
|
|
174
|
+
*/
|
|
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
|
|
253
182
|
}
|
|
254
183
|
|
|
255
|
-
//
|
|
256
|
-
// § 9 boot()
|
|
257
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
184
|
+
// ── boot() ────────────────────────────────────────────────────────────────────
|
|
258
185
|
|
|
259
186
|
export interface BootOptions extends AppConfig {
|
|
260
|
-
/**
|
|
187
|
+
/** Warm fetch cache on link hover. @default true */
|
|
261
188
|
prefetchOnHover?: boolean
|
|
262
189
|
}
|
|
263
190
|
|
|
264
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
|
+
|
|
265
198
|
const { pages } = resolveRoutes(options.routes, {
|
|
266
|
-
layout:
|
|
199
|
+
...(options.layout !== undefined && { layout: options.layout }),
|
|
267
200
|
middleware: [],
|
|
268
201
|
})
|
|
269
202
|
|
|
270
|
-
|
|
271
|
-
_appLayout = options.layout
|
|
272
|
-
|
|
273
|
-
const pathname = location.pathname
|
|
274
|
-
if (!findRoute(pathname)) {
|
|
275
|
-
console.warn(`[fnetro] No route matched "${pathname}" — skipping hydration`)
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Server-injected initial state (no refetch needed on first load)
|
|
203
|
+
// Read server-injected bootstrap data
|
|
280
204
|
const stateMap = (window as any)[STATE_KEY] as Record<string, Record<string, unknown>> ?? {}
|
|
281
|
-
const paramsMap = (window as any)[PARAMS_KEY] as Record<string, string> ?? {}
|
|
282
205
|
const seoData = (window as any)[SEO_KEY] as SEOMeta ?? {}
|
|
206
|
+
const pathname = location.pathname
|
|
283
207
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
params: paramsMap,
|
|
288
|
-
}
|
|
208
|
+
// Seed reactive store and sync SEO from server data (no network request)
|
|
209
|
+
updatePageData(stateMap[pathname] ?? {})
|
|
210
|
+
syncSEO(seoData)
|
|
289
211
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
})
|
|
233
|
+
|
|
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()
|
|
294
239
|
}
|
|
295
240
|
|
|
296
|
-
//
|
|
297
|
-
|
|
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
|
+
}
|
|
298
257
|
|
|
299
|
-
|
|
300
|
-
hydrate(
|
|
301
|
-
() => createComponent(AppRoot as any, { initial, appLayout: _appLayout }) as any,
|
|
302
|
-
container,
|
|
303
|
-
)
|
|
258
|
+
const href = new URL(to.fullPath, location.origin).toString()
|
|
304
259
|
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
307
280
|
if (options.prefetchOnHover !== false) {
|
|
308
|
-
document.addEventListener('mouseover',
|
|
281
|
+
document.addEventListener('mouseover', (e) => {
|
|
282
|
+
const a = (e as MouseEvent).composedPath()
|
|
283
|
+
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement)
|
|
284
|
+
if (a?.href) prefetch(a.href)
|
|
285
|
+
})
|
|
309
286
|
}
|
|
310
|
-
window.addEventListener('popstate', onPopState)
|
|
311
287
|
}
|
|
312
288
|
|
|
313
|
-
//
|
|
314
|
-
// § 10 Re-exports
|
|
315
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
289
|
+
// ── Re-exports ────────────────────────────────────────────────────────────────
|
|
316
290
|
|
|
317
291
|
export {
|
|
318
|
-
definePage, defineGroup, defineLayout, defineApiRoute,
|
|
319
|
-
resolveRoutes, compilePath, matchPath,
|
|
320
|
-
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,
|
|
321
295
|
} from './core'
|
|
322
296
|
|
|
323
297
|
export type {
|
|
324
298
|
AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
|
|
325
|
-
|
|
326
|
-
|
|
299
|
+
SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
|
|
300
|
+
ClientMiddleware, AsyncLoader,
|
|
327
301
|
} from './core'
|
|
302
|
+
|
|
303
|
+
// Vue Router composables re-exported for convenience
|
|
304
|
+
export {
|
|
305
|
+
useRoute,
|
|
306
|
+
useRouter,
|
|
307
|
+
RouterLink,
|
|
308
|
+
RouterView,
|
|
309
|
+
} from 'vue-router'
|