@netrojs/fnetro 0.1.2 → 0.1.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/README.md +1194 -179
- package/client.ts +307 -307
- package/core.ts +734 -734
- package/package.json +91 -91
- package/server.ts +415 -415
package/client.ts
CHANGED
|
@@ -1,307 +1,307 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
// FNetro · client.ts
|
|
3
|
-
// SPA runtime · hook patching · navigation · prefetch · lifecycle
|
|
4
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
-
|
|
6
|
-
import { render } from 'hono/jsx/dom'
|
|
7
|
-
import { jsx } from 'hono/jsx'
|
|
8
|
-
import {
|
|
9
|
-
useState, useEffect, useMemo, useRef as useHonoRef,
|
|
10
|
-
useSyncExternalStore,
|
|
11
|
-
} from 'hono/jsx'
|
|
12
|
-
import {
|
|
13
|
-
__hooks, ref, reactive, computed, watchEffect, isRef,
|
|
14
|
-
SPA_HEADER, STATE_KEY, PARAMS_KEY,
|
|
15
|
-
type Ref, type AppConfig, type ResolvedRoute,
|
|
16
|
-
type LayoutDef,
|
|
17
|
-
} from './core'
|
|
18
|
-
import { resolveRoutes } from './core'
|
|
19
|
-
|
|
20
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
21
|
-
// § 1 Patch reactivity hooks for hono/jsx/dom
|
|
22
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Connect a Ref (or computed getter) to the current JSX component.
|
|
26
|
-
* Re-renders whenever the source changes.
|
|
27
|
-
*/
|
|
28
|
-
function clientUseValue<T>(source: Ref<T> | (() => T)): T {
|
|
29
|
-
if (isRef(source)) {
|
|
30
|
-
// Fast path: useSyncExternalStore is ideal for refs
|
|
31
|
-
return useSyncExternalStore(
|
|
32
|
-
(notify) => (source as any).subscribe(notify),
|
|
33
|
-
() => (source as any).peek?.() ?? source.value,
|
|
34
|
-
)
|
|
35
|
-
}
|
|
36
|
-
// Getter: wrap in a computed ref, then subscribe
|
|
37
|
-
const c = useMemo(() => computed(source as () => T), [source])
|
|
38
|
-
return useSyncExternalStore(
|
|
39
|
-
(notify) => (c as any).subscribe(notify),
|
|
40
|
-
() => (c as any).peek?.() ?? c.value,
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Component-local Ref — stable across re-renders, lost on unmount.
|
|
46
|
-
*/
|
|
47
|
-
function clientUseLocalRef<T>(init: T): Ref<T> {
|
|
48
|
-
// Create the ref once (stable ref object via hono's useRef)
|
|
49
|
-
const stableRef = useHonoRef<Ref<T> | null>(null)
|
|
50
|
-
if (stableRef.current === null) stableRef.current = ref(init)
|
|
51
|
-
const r = stableRef.current!
|
|
52
|
-
// Subscribe so mutations trigger re-render
|
|
53
|
-
useSyncExternalStore(
|
|
54
|
-
(notify) => (r as any).subscribe(notify),
|
|
55
|
-
() => (r as any).peek?.() ?? r.value,
|
|
56
|
-
)
|
|
57
|
-
return r
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Component-local reactive object — deep proxy, re-renders on any mutation.
|
|
62
|
-
*/
|
|
63
|
-
function clientUseLocalReactive<T extends object>(init: T): T {
|
|
64
|
-
const stableRef = useHonoRef<T | null>(null)
|
|
65
|
-
if (stableRef.current === null) stableRef.current = reactive(init)
|
|
66
|
-
const proxy = stableRef.current!
|
|
67
|
-
|
|
68
|
-
// watchEffect to re-render whenever any tracked key changes
|
|
69
|
-
const [tick, setTick] = useState(0)
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
return watchEffect(() => {
|
|
72
|
-
// Touch all keys to establish tracking
|
|
73
|
-
JSON.stringify(proxy)
|
|
74
|
-
// Schedule re-render (not on first run)
|
|
75
|
-
setTick(t => t + 1)
|
|
76
|
-
})
|
|
77
|
-
}, [])
|
|
78
|
-
|
|
79
|
-
return proxy
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Patch the module-level hook table
|
|
83
|
-
Object.assign(__hooks, {
|
|
84
|
-
useValue: clientUseValue,
|
|
85
|
-
useLocalRef: clientUseLocalRef,
|
|
86
|
-
useLocalReactive: clientUseLocalReactive,
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
90
|
-
// § 2 Path matching (mirrors server)
|
|
91
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
92
|
-
|
|
93
|
-
interface CompiledRoute {
|
|
94
|
-
route: ResolvedRoute
|
|
95
|
-
re: RegExp
|
|
96
|
-
keys: string[]
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function compileRoute(r: ResolvedRoute): CompiledRoute {
|
|
100
|
-
const keys: string[] = []
|
|
101
|
-
const src = r.fullPath
|
|
102
|
-
.replace(/\[\.\.\.([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '(.*)' })
|
|
103
|
-
.replace(/\[([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '([^/]+)' })
|
|
104
|
-
.replace(/\*/g, '(.*)')
|
|
105
|
-
return { route: r, re: new RegExp(`^${src}$`), keys }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function matchRoute(compiled: CompiledRoute[], pathname: string) {
|
|
109
|
-
for (const c of compiled) {
|
|
110
|
-
const m = pathname.match(c.re)
|
|
111
|
-
if (m) {
|
|
112
|
-
const params: Record<string, string> = {}
|
|
113
|
-
c.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]) })
|
|
114
|
-
return { route: c.route, params }
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return null
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
121
|
-
// § 3 Navigation lifecycle hooks
|
|
122
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
123
|
-
|
|
124
|
-
type NavListener = (url: string) => void | Promise<void>
|
|
125
|
-
const beforeNavListeners: NavListener[] = []
|
|
126
|
-
const afterNavListeners: NavListener[] = []
|
|
127
|
-
|
|
128
|
-
/** Called before each SPA navigation. Returning false cancels. */
|
|
129
|
-
export function onBeforeNavigate(fn: NavListener): () => void {
|
|
130
|
-
beforeNavListeners.push(fn)
|
|
131
|
-
return () => beforeNavListeners.splice(beforeNavListeners.indexOf(fn), 1)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Called after each SPA navigation (including initial boot). */
|
|
135
|
-
export function onAfterNavigate(fn: NavListener): () => void {
|
|
136
|
-
afterNavListeners.push(fn)
|
|
137
|
-
return () => afterNavListeners.splice(afterNavListeners.indexOf(fn), 1)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
141
|
-
// § 4 SPA navigation
|
|
142
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
143
|
-
|
|
144
|
-
let compiled: CompiledRoute[] = []
|
|
145
|
-
let currentConfig: AppConfig
|
|
146
|
-
let currentLayout: LayoutDef | undefined
|
|
147
|
-
const prefetchCache = new Map<string, Promise<any>>()
|
|
148
|
-
|
|
149
|
-
function fetchPage(url: string): Promise<any> {
|
|
150
|
-
if (!prefetchCache.has(url)) {
|
|
151
|
-
prefetchCache.set(url, fetch(url, {
|
|
152
|
-
headers: { [SPA_HEADER]: '1' }
|
|
153
|
-
}).then(r => r.json()))
|
|
154
|
-
}
|
|
155
|
-
return prefetchCache.get(url)!
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function renderPage(
|
|
159
|
-
route: ResolvedRoute,
|
|
160
|
-
data: object,
|
|
161
|
-
url: string,
|
|
162
|
-
params: Record<string, string>
|
|
163
|
-
) {
|
|
164
|
-
const container = document.getElementById('fnetro-app')!
|
|
165
|
-
const pageNode = (jsx as any)(route.page.Page, { ...data, url, params })
|
|
166
|
-
const layout = route.layout !== undefined ? route.layout : currentLayout
|
|
167
|
-
const tree = layout
|
|
168
|
-
? (jsx as any)(layout.Component, { url, params, children: pageNode })
|
|
169
|
-
: pageNode
|
|
170
|
-
render(tree, container)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export interface NavigateOptions {
|
|
174
|
-
replace?: boolean
|
|
175
|
-
scroll?: boolean
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export async function navigate(
|
|
179
|
-
to: string,
|
|
180
|
-
opts: NavigateOptions = {}
|
|
181
|
-
): Promise<void> {
|
|
182
|
-
const u = new URL(to, location.origin)
|
|
183
|
-
if (u.origin !== location.origin) { location.href = to; return }
|
|
184
|
-
|
|
185
|
-
// Run before-nav hooks
|
|
186
|
-
for (const fn of beforeNavListeners) await fn(u.pathname)
|
|
187
|
-
|
|
188
|
-
const match = matchRoute(compiled, u.pathname)
|
|
189
|
-
if (!match) { location.href = to; return }
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
const payload = await fetchPage(u.toString())
|
|
193
|
-
const method = opts.replace ? 'replaceState' : 'pushState'
|
|
194
|
-
history[method]({ url: u.pathname }, '', u.pathname)
|
|
195
|
-
if (opts.scroll !== false) window.scrollTo(0, 0)
|
|
196
|
-
await renderPage(match.route, payload.state ?? {}, u.pathname, payload.params ?? {})
|
|
197
|
-
// Cache state for popstate
|
|
198
|
-
;(window as any)[STATE_KEY] = {
|
|
199
|
-
...(window as any)[STATE_KEY],
|
|
200
|
-
[u.pathname]: payload.state ?? {}
|
|
201
|
-
}
|
|
202
|
-
for (const fn of afterNavListeners) await fn(u.pathname)
|
|
203
|
-
} catch (e) {
|
|
204
|
-
console.error('[fnetro] Navigation failed:', e)
|
|
205
|
-
location.href = to
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Warm the prefetch cache for a URL (call on hover / mousedown). */
|
|
210
|
-
export function prefetch(url: string): void {
|
|
211
|
-
const u = new URL(url, location.origin)
|
|
212
|
-
if (u.origin !== location.origin) return
|
|
213
|
-
if (!matchRoute(compiled, u.pathname)) return
|
|
214
|
-
fetchPage(u.toString())
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
218
|
-
// § 5 Click interceptor + popstate
|
|
219
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
220
|
-
|
|
221
|
-
function interceptClicks(e: MouseEvent) {
|
|
222
|
-
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
223
|
-
const a = e.composedPath().find(
|
|
224
|
-
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
|
|
225
|
-
)
|
|
226
|
-
if (!a?.href) return
|
|
227
|
-
if (a.target && a.target !== '_self') return
|
|
228
|
-
if (a.hasAttribute('data-no-spa') || a.rel?.includes('external')) return
|
|
229
|
-
const u = new URL(a.href)
|
|
230
|
-
if (u.origin !== location.origin) return
|
|
231
|
-
e.preventDefault()
|
|
232
|
-
navigate(a.href)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function interceptHover(e: MouseEvent) {
|
|
236
|
-
const a = e.composedPath().find(
|
|
237
|
-
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
|
|
238
|
-
)
|
|
239
|
-
if (a?.href) prefetch(a.href)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function onPopState() {
|
|
243
|
-
navigate(location.href, { replace: true, scroll: false })
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
247
|
-
// § 6 boot()
|
|
248
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
249
|
-
|
|
250
|
-
export interface BootOptions extends AppConfig {
|
|
251
|
-
/**
|
|
252
|
-
* Enable hover-based prefetching (default: true).
|
|
253
|
-
* Fires a SPA fetch when the user hovers any <a> that matches a route.
|
|
254
|
-
*/
|
|
255
|
-
prefetchOnHover?: boolean
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export async function boot(options: BootOptions): Promise<void> {
|
|
259
|
-
const { pages } = resolveRoutes(options.routes, {
|
|
260
|
-
layout: options.layout,
|
|
261
|
-
middleware: [],
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
compiled = pages.map(compileRoute)
|
|
265
|
-
currentConfig = options
|
|
266
|
-
currentLayout = options.layout
|
|
267
|
-
|
|
268
|
-
const pathname = location.pathname
|
|
269
|
-
const match = matchRoute(compiled, pathname)
|
|
270
|
-
|
|
271
|
-
if (!match) {
|
|
272
|
-
console.warn(`[fnetro] No route matched "${pathname}" — not hydrating`)
|
|
273
|
-
return
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Read server-injected state (no refetch!)
|
|
277
|
-
const stateMap: Record<string, object> = (window as any)[STATE_KEY] ?? {}
|
|
278
|
-
const paramsMap: Record<string, string> = (window as any)[PARAMS_KEY] ?? {}
|
|
279
|
-
const data = stateMap[pathname] ?? {}
|
|
280
|
-
|
|
281
|
-
await renderPage(match.route, data, pathname, paramsMap)
|
|
282
|
-
|
|
283
|
-
// Wire up navigation
|
|
284
|
-
document.addEventListener('click', interceptClicks)
|
|
285
|
-
if (options.prefetchOnHover !== false) {
|
|
286
|
-
document.addEventListener('mouseover', interceptHover)
|
|
287
|
-
}
|
|
288
|
-
window.addEventListener('popstate', onPopState)
|
|
289
|
-
|
|
290
|
-
for (const fn of afterNavListeners) await fn(pathname)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
294
|
-
// § 7 Re-export core for client code that imports only client.ts
|
|
295
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
296
|
-
export {
|
|
297
|
-
ref, shallowRef, reactive, shallowReactive, readonly,
|
|
298
|
-
computed, effect, watch, watchEffect, effectScope,
|
|
299
|
-
toRef, toRefs, unref, isRef, isReactive, isReadonly, markRaw, toRaw,
|
|
300
|
-
triggerRef, use, useLocalRef, useLocalReactive,
|
|
301
|
-
definePage, defineGroup, defineLayout, defineMiddleware, defineApiRoute,
|
|
302
|
-
} from './core'
|
|
303
|
-
export type {
|
|
304
|
-
Ref, ComputedRef, WritableComputedRef,
|
|
305
|
-
AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef,
|
|
306
|
-
WatchSource, WatchOptions,
|
|
307
|
-
} from './core'
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// FNetro · client.ts
|
|
3
|
+
// SPA runtime · hook patching · navigation · prefetch · lifecycle
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import { render } from 'hono/jsx/dom'
|
|
7
|
+
import { jsx } from 'hono/jsx'
|
|
8
|
+
import {
|
|
9
|
+
useState, useEffect, useMemo, useRef as useHonoRef,
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
} from 'hono/jsx'
|
|
12
|
+
import {
|
|
13
|
+
__hooks, ref, reactive, computed, watchEffect, isRef,
|
|
14
|
+
SPA_HEADER, STATE_KEY, PARAMS_KEY,
|
|
15
|
+
type Ref, type AppConfig, type ResolvedRoute,
|
|
16
|
+
type LayoutDef,
|
|
17
|
+
} from './core'
|
|
18
|
+
import { resolveRoutes } from './core'
|
|
19
|
+
|
|
20
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
21
|
+
// § 1 Patch reactivity hooks for hono/jsx/dom
|
|
22
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Connect a Ref (or computed getter) to the current JSX component.
|
|
26
|
+
* Re-renders whenever the source changes.
|
|
27
|
+
*/
|
|
28
|
+
function clientUseValue<T>(source: Ref<T> | (() => T)): T {
|
|
29
|
+
if (isRef(source)) {
|
|
30
|
+
// Fast path: useSyncExternalStore is ideal for refs
|
|
31
|
+
return useSyncExternalStore(
|
|
32
|
+
(notify) => (source as any).subscribe(notify),
|
|
33
|
+
() => (source as any).peek?.() ?? source.value,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
// Getter: wrap in a computed ref, then subscribe
|
|
37
|
+
const c = useMemo(() => computed(source as () => T), [source])
|
|
38
|
+
return useSyncExternalStore(
|
|
39
|
+
(notify) => (c as any).subscribe(notify),
|
|
40
|
+
() => (c as any).peek?.() ?? c.value,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Component-local Ref — stable across re-renders, lost on unmount.
|
|
46
|
+
*/
|
|
47
|
+
function clientUseLocalRef<T>(init: T): Ref<T> {
|
|
48
|
+
// Create the ref once (stable ref object via hono's useRef)
|
|
49
|
+
const stableRef = useHonoRef<Ref<T> | null>(null)
|
|
50
|
+
if (stableRef.current === null) stableRef.current = ref(init)
|
|
51
|
+
const r = stableRef.current!
|
|
52
|
+
// Subscribe so mutations trigger re-render
|
|
53
|
+
useSyncExternalStore(
|
|
54
|
+
(notify) => (r as any).subscribe(notify),
|
|
55
|
+
() => (r as any).peek?.() ?? r.value,
|
|
56
|
+
)
|
|
57
|
+
return r
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Component-local reactive object — deep proxy, re-renders on any mutation.
|
|
62
|
+
*/
|
|
63
|
+
function clientUseLocalReactive<T extends object>(init: T): T {
|
|
64
|
+
const stableRef = useHonoRef<T | null>(null)
|
|
65
|
+
if (stableRef.current === null) stableRef.current = reactive(init)
|
|
66
|
+
const proxy = stableRef.current!
|
|
67
|
+
|
|
68
|
+
// watchEffect to re-render whenever any tracked key changes
|
|
69
|
+
const [tick, setTick] = useState(0)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
return watchEffect(() => {
|
|
72
|
+
// Touch all keys to establish tracking
|
|
73
|
+
JSON.stringify(proxy)
|
|
74
|
+
// Schedule re-render (not on first run)
|
|
75
|
+
setTick(t => t + 1)
|
|
76
|
+
})
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
return proxy
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Patch the module-level hook table
|
|
83
|
+
Object.assign(__hooks, {
|
|
84
|
+
useValue: clientUseValue,
|
|
85
|
+
useLocalRef: clientUseLocalRef,
|
|
86
|
+
useLocalReactive: clientUseLocalReactive,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
90
|
+
// § 2 Path matching (mirrors server)
|
|
91
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
92
|
+
|
|
93
|
+
interface CompiledRoute {
|
|
94
|
+
route: ResolvedRoute
|
|
95
|
+
re: RegExp
|
|
96
|
+
keys: string[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compileRoute(r: ResolvedRoute): CompiledRoute {
|
|
100
|
+
const keys: string[] = []
|
|
101
|
+
const src = r.fullPath
|
|
102
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '(.*)' })
|
|
103
|
+
.replace(/\[([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '([^/]+)' })
|
|
104
|
+
.replace(/\*/g, '(.*)')
|
|
105
|
+
return { route: r, re: new RegExp(`^${src}$`), keys }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function matchRoute(compiled: CompiledRoute[], pathname: string) {
|
|
109
|
+
for (const c of compiled) {
|
|
110
|
+
const m = pathname.match(c.re)
|
|
111
|
+
if (m) {
|
|
112
|
+
const params: Record<string, string> = {}
|
|
113
|
+
c.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]) })
|
|
114
|
+
return { route: c.route, params }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
121
|
+
// § 3 Navigation lifecycle hooks
|
|
122
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
123
|
+
|
|
124
|
+
type NavListener = (url: string) => void | Promise<void>
|
|
125
|
+
const beforeNavListeners: NavListener[] = []
|
|
126
|
+
const afterNavListeners: NavListener[] = []
|
|
127
|
+
|
|
128
|
+
/** Called before each SPA navigation. Returning false cancels. */
|
|
129
|
+
export function onBeforeNavigate(fn: NavListener): () => void {
|
|
130
|
+
beforeNavListeners.push(fn)
|
|
131
|
+
return () => beforeNavListeners.splice(beforeNavListeners.indexOf(fn), 1)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Called after each SPA navigation (including initial boot). */
|
|
135
|
+
export function onAfterNavigate(fn: NavListener): () => void {
|
|
136
|
+
afterNavListeners.push(fn)
|
|
137
|
+
return () => afterNavListeners.splice(afterNavListeners.indexOf(fn), 1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
141
|
+
// § 4 SPA navigation
|
|
142
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
let compiled: CompiledRoute[] = []
|
|
145
|
+
let currentConfig: AppConfig
|
|
146
|
+
let currentLayout: LayoutDef | undefined
|
|
147
|
+
const prefetchCache = new Map<string, Promise<any>>()
|
|
148
|
+
|
|
149
|
+
function fetchPage(url: string): Promise<any> {
|
|
150
|
+
if (!prefetchCache.has(url)) {
|
|
151
|
+
prefetchCache.set(url, fetch(url, {
|
|
152
|
+
headers: { [SPA_HEADER]: '1' }
|
|
153
|
+
}).then(r => r.json()))
|
|
154
|
+
}
|
|
155
|
+
return prefetchCache.get(url)!
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function renderPage(
|
|
159
|
+
route: ResolvedRoute,
|
|
160
|
+
data: object,
|
|
161
|
+
url: string,
|
|
162
|
+
params: Record<string, string>
|
|
163
|
+
) {
|
|
164
|
+
const container = document.getElementById('fnetro-app')!
|
|
165
|
+
const pageNode = (jsx as any)(route.page.Page, { ...data, url, params })
|
|
166
|
+
const layout = route.layout !== undefined ? route.layout : currentLayout
|
|
167
|
+
const tree = layout
|
|
168
|
+
? (jsx as any)(layout.Component, { url, params, children: pageNode })
|
|
169
|
+
: pageNode
|
|
170
|
+
render(tree, container)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface NavigateOptions {
|
|
174
|
+
replace?: boolean
|
|
175
|
+
scroll?: boolean
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function navigate(
|
|
179
|
+
to: string,
|
|
180
|
+
opts: NavigateOptions = {}
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const u = new URL(to, location.origin)
|
|
183
|
+
if (u.origin !== location.origin) { location.href = to; return }
|
|
184
|
+
|
|
185
|
+
// Run before-nav hooks
|
|
186
|
+
for (const fn of beforeNavListeners) await fn(u.pathname)
|
|
187
|
+
|
|
188
|
+
const match = matchRoute(compiled, u.pathname)
|
|
189
|
+
if (!match) { location.href = to; return }
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const payload = await fetchPage(u.toString())
|
|
193
|
+
const method = opts.replace ? 'replaceState' : 'pushState'
|
|
194
|
+
history[method]({ url: u.pathname }, '', u.pathname)
|
|
195
|
+
if (opts.scroll !== false) window.scrollTo(0, 0)
|
|
196
|
+
await renderPage(match.route, payload.state ?? {}, u.pathname, payload.params ?? {})
|
|
197
|
+
// Cache state for popstate
|
|
198
|
+
;(window as any)[STATE_KEY] = {
|
|
199
|
+
...(window as any)[STATE_KEY],
|
|
200
|
+
[u.pathname]: payload.state ?? {}
|
|
201
|
+
}
|
|
202
|
+
for (const fn of afterNavListeners) await fn(u.pathname)
|
|
203
|
+
} catch (e) {
|
|
204
|
+
console.error('[fnetro] Navigation failed:', e)
|
|
205
|
+
location.href = to
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Warm the prefetch cache for a URL (call on hover / mousedown). */
|
|
210
|
+
export function prefetch(url: string): void {
|
|
211
|
+
const u = new URL(url, location.origin)
|
|
212
|
+
if (u.origin !== location.origin) return
|
|
213
|
+
if (!matchRoute(compiled, u.pathname)) return
|
|
214
|
+
fetchPage(u.toString())
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
218
|
+
// § 5 Click interceptor + popstate
|
|
219
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
220
|
+
|
|
221
|
+
function interceptClicks(e: MouseEvent) {
|
|
222
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
223
|
+
const a = e.composedPath().find(
|
|
224
|
+
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
|
|
225
|
+
)
|
|
226
|
+
if (!a?.href) return
|
|
227
|
+
if (a.target && a.target !== '_self') return
|
|
228
|
+
if (a.hasAttribute('data-no-spa') || a.rel?.includes('external')) return
|
|
229
|
+
const u = new URL(a.href)
|
|
230
|
+
if (u.origin !== location.origin) return
|
|
231
|
+
e.preventDefault()
|
|
232
|
+
navigate(a.href)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function interceptHover(e: MouseEvent) {
|
|
236
|
+
const a = e.composedPath().find(
|
|
237
|
+
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
|
|
238
|
+
)
|
|
239
|
+
if (a?.href) prefetch(a.href)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function onPopState() {
|
|
243
|
+
navigate(location.href, { replace: true, scroll: false })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
247
|
+
// § 6 boot()
|
|
248
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
249
|
+
|
|
250
|
+
export interface BootOptions extends AppConfig {
|
|
251
|
+
/**
|
|
252
|
+
* Enable hover-based prefetching (default: true).
|
|
253
|
+
* Fires a SPA fetch when the user hovers any <a> that matches a route.
|
|
254
|
+
*/
|
|
255
|
+
prefetchOnHover?: boolean
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function boot(options: BootOptions): Promise<void> {
|
|
259
|
+
const { pages } = resolveRoutes(options.routes, {
|
|
260
|
+
layout: options.layout,
|
|
261
|
+
middleware: [],
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
compiled = pages.map(compileRoute)
|
|
265
|
+
currentConfig = options
|
|
266
|
+
currentLayout = options.layout
|
|
267
|
+
|
|
268
|
+
const pathname = location.pathname
|
|
269
|
+
const match = matchRoute(compiled, pathname)
|
|
270
|
+
|
|
271
|
+
if (!match) {
|
|
272
|
+
console.warn(`[fnetro] No route matched "${pathname}" — not hydrating`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Read server-injected state (no refetch!)
|
|
277
|
+
const stateMap: Record<string, object> = (window as any)[STATE_KEY] ?? {}
|
|
278
|
+
const paramsMap: Record<string, string> = (window as any)[PARAMS_KEY] ?? {}
|
|
279
|
+
const data = stateMap[pathname] ?? {}
|
|
280
|
+
|
|
281
|
+
await renderPage(match.route, data, pathname, paramsMap)
|
|
282
|
+
|
|
283
|
+
// Wire up navigation
|
|
284
|
+
document.addEventListener('click', interceptClicks)
|
|
285
|
+
if (options.prefetchOnHover !== false) {
|
|
286
|
+
document.addEventListener('mouseover', interceptHover)
|
|
287
|
+
}
|
|
288
|
+
window.addEventListener('popstate', onPopState)
|
|
289
|
+
|
|
290
|
+
for (const fn of afterNavListeners) await fn(pathname)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
294
|
+
// § 7 Re-export core for client code that imports only client.ts
|
|
295
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
296
|
+
export {
|
|
297
|
+
ref, shallowRef, reactive, shallowReactive, readonly,
|
|
298
|
+
computed, effect, watch, watchEffect, effectScope,
|
|
299
|
+
toRef, toRefs, unref, isRef, isReactive, isReadonly, markRaw, toRaw,
|
|
300
|
+
triggerRef, use, useLocalRef, useLocalReactive,
|
|
301
|
+
definePage, defineGroup, defineLayout, defineMiddleware, defineApiRoute,
|
|
302
|
+
} from './core'
|
|
303
|
+
export type {
|
|
304
|
+
Ref, ComputedRef, WritableComputedRef,
|
|
305
|
+
AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef,
|
|
306
|
+
WatchSource, WatchOptions,
|
|
307
|
+
} from './core'
|