@netrojs/fnetro 0.2.20 → 0.2.21
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/client.ts +122 -111
- package/dist/client.d.ts +16 -3
- package/dist/client.js +81 -67
- package/dist/server.js +12 -5
- package/package.json +3 -1
- package/server.ts +21 -9
package/client.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// FNetro · client.ts
|
|
3
|
-
// SolidJS hydration · SPA routing · client middleware · SEO
|
|
3
|
+
// SolidJS hydration · @solidjs/router SPA routing · client middleware · SEO
|
|
4
4
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
-
import { createSignal,
|
|
6
|
+
import { createSignal, createComponent, lazy, Suspense } from 'solid-js'
|
|
7
7
|
import { hydrate } from 'solid-js/web'
|
|
8
|
+
import { Router, Route } from '@solidjs/router'
|
|
8
9
|
import {
|
|
9
10
|
resolveRoutes, compilePath, matchPath,
|
|
10
11
|
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
|
|
@@ -30,20 +31,7 @@ function findRoute(pathname: string) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
33
|
-
// § 2
|
|
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
|
|
34
|
+
// § 2 Client middleware
|
|
47
35
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
48
36
|
|
|
49
37
|
const _mw: ClientMiddleware[] = []
|
|
@@ -76,7 +64,7 @@ async function runMiddleware(url: string, done: () => Promise<void>): Promise<vo
|
|
|
76
64
|
}
|
|
77
65
|
|
|
78
66
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
79
|
-
// §
|
|
67
|
+
// § 3 SEO — client-side <head> sync
|
|
80
68
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
81
69
|
|
|
82
70
|
function setMeta(selector: string, attr: string, val: string | undefined): void {
|
|
@@ -91,7 +79,7 @@ function setMeta(selector: string, attr: string, val: string | undefined): void
|
|
|
91
79
|
el.setAttribute(attr, val)
|
|
92
80
|
}
|
|
93
81
|
|
|
94
|
-
function syncSEO(seo: SEOMeta): void {
|
|
82
|
+
export function syncSEO(seo: SEOMeta): void {
|
|
95
83
|
if (seo.title) document.title = seo.title
|
|
96
84
|
|
|
97
85
|
setMeta('[name="description"]', 'content', seo.description)
|
|
@@ -124,7 +112,7 @@ function syncSEO(seo: SEOMeta): void {
|
|
|
124
112
|
}
|
|
125
113
|
|
|
126
114
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
127
|
-
// §
|
|
115
|
+
// § 4 Prefetch cache + SPA data fetching
|
|
128
116
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
129
117
|
|
|
130
118
|
interface NavPayload {
|
|
@@ -136,7 +124,7 @@ interface NavPayload {
|
|
|
136
124
|
|
|
137
125
|
const _cache = new Map<string, Promise<NavPayload>>()
|
|
138
126
|
|
|
139
|
-
function fetchPayload(href: string): Promise<NavPayload> {
|
|
127
|
+
export function fetchPayload(href: string): Promise<NavPayload> {
|
|
140
128
|
if (!_cache.has(href)) {
|
|
141
129
|
_cache.set(
|
|
142
130
|
href,
|
|
@@ -150,8 +138,81 @@ function fetchPayload(href: string): Promise<NavPayload> {
|
|
|
150
138
|
return _cache.get(href)!
|
|
151
139
|
}
|
|
152
140
|
|
|
141
|
+
/** Warm the prefetch cache for a URL on hover/focus/etc. */
|
|
142
|
+
export function prefetch(url: string): void {
|
|
143
|
+
try {
|
|
144
|
+
const u = new URL(url, location.origin)
|
|
145
|
+
if (u.origin !== location.origin || !findRoute(u.pathname)) return
|
|
146
|
+
fetchPayload(u.toString())
|
|
147
|
+
} catch { /* ignore invalid URLs */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
151
|
+
// § 5 Route components with data loading for @solidjs/router
|
|
152
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates a solid-router-compatible route component that:
|
|
156
|
+
* 1. On first render: uses server-injected state (no network request)
|
|
157
|
+
* 2. On SPA navigation: fetches data from the FNetro server handler
|
|
158
|
+
*/
|
|
159
|
+
function makeRouteComponent(
|
|
160
|
+
route: ResolvedRoute,
|
|
161
|
+
appLayout: LayoutDef | undefined,
|
|
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()
|
|
205
|
+
|
|
206
|
+
return createComponent(layout.Component as any, {
|
|
207
|
+
url: pathname,
|
|
208
|
+
params: params(),
|
|
209
|
+
get children() { return pageEl() },
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
153
214
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
154
|
-
// § 6 navigate / prefetch
|
|
215
|
+
// § 6 navigate / prefetch (convenience exports, wraps solid-router navigate)
|
|
155
216
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
156
217
|
|
|
157
218
|
export interface NavigateOptions {
|
|
@@ -159,6 +220,10 @@ export interface NavigateOptions {
|
|
|
159
220
|
scroll?: boolean
|
|
160
221
|
}
|
|
161
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Programmatic navigation — delegates to history API and triggers
|
|
225
|
+
* @solidjs/router's reactive location update.
|
|
226
|
+
*/
|
|
162
227
|
export async function navigate(to: string, opts: NavigateOptions = {}): Promise<void> {
|
|
163
228
|
const u = new URL(to, location.origin)
|
|
164
229
|
if (u.origin !== location.origin) { location.href = to; return }
|
|
@@ -166,14 +231,15 @@ export async function navigate(to: string, opts: NavigateOptions = {}): Promise<
|
|
|
166
231
|
|
|
167
232
|
await runMiddleware(u.pathname, async () => {
|
|
168
233
|
try {
|
|
234
|
+
// Prefetch/cache the payload so the route component can use it
|
|
169
235
|
const payload = await fetchPayload(u.toString())
|
|
170
236
|
history[opts.replace ? 'replaceState' : 'pushState'](
|
|
171
237
|
{ url: u.pathname }, '', u.pathname,
|
|
172
238
|
)
|
|
173
239
|
if (opts.scroll !== false) window.scrollTo(0, 0)
|
|
174
|
-
|
|
175
|
-
_setNav?.({ path: u.pathname, data: payload.state ?? {}, params: payload.params ?? {} })
|
|
176
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 }))
|
|
177
243
|
} catch (err) {
|
|
178
244
|
console.error('[fnetro] Navigation error:', err)
|
|
179
245
|
location.href = to
|
|
@@ -181,79 +247,8 @@ export async function navigate(to: string, opts: NavigateOptions = {}): Promise<
|
|
|
181
247
|
})
|
|
182
248
|
}
|
|
183
249
|
|
|
184
|
-
/** Warm the prefetch cache for a URL on hover/focus/etc. */
|
|
185
|
-
export function prefetch(url: string): void {
|
|
186
|
-
try {
|
|
187
|
-
const u = new URL(url, location.origin)
|
|
188
|
-
if (u.origin !== location.origin || !findRoute(u.pathname)) return
|
|
189
|
-
fetchPayload(u.toString())
|
|
190
|
-
} catch { /* ignore invalid URLs */ }
|
|
191
|
-
}
|
|
192
|
-
|
|
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
|
-
}
|
|
210
|
-
|
|
211
|
-
function onLinkHover(e: MouseEvent): void {
|
|
212
|
-
const a = e.composedPath().find(
|
|
213
|
-
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
|
|
214
|
-
)
|
|
215
|
-
if (a?.href) prefetch(a.href)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function onPopState(): void {
|
|
219
|
-
navigate(location.href, { replace: true, scroll: false })
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
223
|
-
// § 8 App root component (created inside hydrate's reactive owner)
|
|
224
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
225
|
-
|
|
226
|
-
function AppRoot(props: { initial: NavState; appLayout: LayoutDef | undefined }): any {
|
|
227
|
-
const [nav, setNav] = createSignal<NavState>(props.initial)
|
|
228
|
-
// Expose setter so navigate() can trigger re-renders
|
|
229
|
-
_setNav = setNav
|
|
230
|
-
|
|
231
|
-
const view = createMemo(() => {
|
|
232
|
-
const { path, data, params } = nav()
|
|
233
|
-
const m = findRoute(path)
|
|
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
|
|
244
|
-
|
|
245
|
-
return createComponent(layout.Component as any, {
|
|
246
|
-
url: path,
|
|
247
|
-
params,
|
|
248
|
-
get children() { return pageEl },
|
|
249
|
-
})
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
return view
|
|
253
|
-
}
|
|
254
|
-
|
|
255
250
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
256
|
-
// §
|
|
251
|
+
// § 7 boot()
|
|
257
252
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
258
253
|
|
|
259
254
|
export interface BootOptions extends AppConfig {
|
|
@@ -281,37 +276,50 @@ export async function boot(options: BootOptions): Promise<void> {
|
|
|
281
276
|
const paramsMap = (window as any)[PARAMS_KEY] as Record<string, string> ?? {}
|
|
282
277
|
const seoData = (window as any)[SEO_KEY] as SEOMeta ?? {}
|
|
283
278
|
|
|
284
|
-
const initial: NavState = {
|
|
285
|
-
path: pathname,
|
|
286
|
-
data: stateMap[pathname] ?? {},
|
|
287
|
-
params: paramsMap,
|
|
288
|
-
}
|
|
289
|
-
|
|
290
279
|
const container = document.getElementById('fnetro-app')
|
|
291
280
|
if (!container) {
|
|
292
281
|
console.error('[fnetro] #fnetro-app not found — aborting hydration')
|
|
293
282
|
return
|
|
294
283
|
}
|
|
295
284
|
|
|
296
|
-
|
|
297
|
-
|
|
285
|
+
const prefetchOnHover = options.prefetchOnHover !== false
|
|
286
|
+
|
|
287
|
+
// Build @solidjs/router <Route> elements for each resolved page
|
|
288
|
+
const routeElements = pages.map(route =>
|
|
289
|
+
createComponent(Route, {
|
|
290
|
+
path: route.fullPath,
|
|
291
|
+
component: makeRouteComponent(
|
|
292
|
+
route,
|
|
293
|
+
_appLayout,
|
|
294
|
+
stateMap,
|
|
295
|
+
paramsMap,
|
|
296
|
+
seoData,
|
|
297
|
+
prefetchOnHover,
|
|
298
|
+
),
|
|
299
|
+
}) as any
|
|
300
|
+
)
|
|
298
301
|
|
|
299
|
-
// Hydrate
|
|
302
|
+
// Hydrate with @solidjs/router wrapping all routes
|
|
300
303
|
hydrate(
|
|
301
|
-
() => createComponent(
|
|
304
|
+
() => createComponent(Router as any, {
|
|
305
|
+
get children() { return routeElements },
|
|
306
|
+
}) as any,
|
|
302
307
|
container,
|
|
303
308
|
)
|
|
304
309
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
310
|
+
// Hover prefetch
|
|
311
|
+
if (prefetchOnHover) {
|
|
312
|
+
document.addEventListener('mouseover', (e: MouseEvent) => {
|
|
313
|
+
const a = e.composedPath().find(
|
|
314
|
+
(el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
|
|
315
|
+
)
|
|
316
|
+
if (a?.href) prefetch(a.href)
|
|
317
|
+
})
|
|
309
318
|
}
|
|
310
|
-
window.addEventListener('popstate', onPopState)
|
|
311
319
|
}
|
|
312
320
|
|
|
313
321
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
314
|
-
// §
|
|
322
|
+
// § 8 Re-exports
|
|
315
323
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
316
324
|
|
|
317
325
|
export {
|
|
@@ -325,3 +333,6 @@ export type {
|
|
|
325
333
|
PageProps, LayoutProps, SEOMeta, HonoMiddleware, LoaderCtx,
|
|
326
334
|
ResolvedRoute, CompiledPath, ClientMiddleware,
|
|
327
335
|
} from './core'
|
|
336
|
+
|
|
337
|
+
// Re-export solid-router primitives for convenience
|
|
338
|
+
export { useNavigate, useParams, useLocation, A, useSearchParams } from '@solidjs/router'
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono, MiddlewareHandler, Context } from 'hono';
|
|
2
2
|
import { Component, JSX } from 'solid-js';
|
|
3
|
+
export { A, useLocation, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
|
3
4
|
|
|
4
5
|
type HonoMiddleware = MiddlewareHandler;
|
|
5
6
|
type LoaderCtx = Context;
|
|
@@ -124,17 +125,29 @@ declare const SEO_KEY = "__FNETRO_SEO__";
|
|
|
124
125
|
* })
|
|
125
126
|
*/
|
|
126
127
|
declare function useClientMiddleware(mw: ClientMiddleware): void;
|
|
128
|
+
declare function syncSEO(seo: SEOMeta): void;
|
|
129
|
+
interface NavPayload {
|
|
130
|
+
state: Record<string, unknown>;
|
|
131
|
+
params: Record<string, string>;
|
|
132
|
+
seo: SEOMeta;
|
|
133
|
+
url: string;
|
|
134
|
+
}
|
|
135
|
+
declare function fetchPayload(href: string): Promise<NavPayload>;
|
|
136
|
+
/** Warm the prefetch cache for a URL on hover/focus/etc. */
|
|
137
|
+
declare function prefetch(url: string): void;
|
|
127
138
|
interface NavigateOptions {
|
|
128
139
|
replace?: boolean;
|
|
129
140
|
scroll?: boolean;
|
|
130
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Programmatic navigation — delegates to history API and triggers
|
|
144
|
+
* @solidjs/router's reactive location update.
|
|
145
|
+
*/
|
|
131
146
|
declare function navigate(to: string, opts?: NavigateOptions): Promise<void>;
|
|
132
|
-
/** Warm the prefetch cache for a URL on hover/focus/etc. */
|
|
133
|
-
declare function prefetch(url: string): void;
|
|
134
147
|
interface BootOptions extends AppConfig {
|
|
135
148
|
/** Enable hover-based prefetching. @default true */
|
|
136
149
|
prefetchOnHover?: boolean;
|
|
137
150
|
}
|
|
138
151
|
declare function boot(options: BootOptions): Promise<void>;
|
|
139
152
|
|
|
140
|
-
export { type ApiRouteDef, type AppConfig, type BootOptions, type ClientMiddleware, type CompiledPath, type GroupDef, type HonoMiddleware, type LayoutDef, type LayoutProps, type LoaderCtx, type NavigateOptions, PARAMS_KEY, type PageDef, type PageProps, type ResolvedRoute, type Route, type SEOMeta, SEO_KEY, SPA_HEADER, STATE_KEY, boot, compilePath, defineApiRoute, defineGroup, defineLayout, definePage, matchPath, navigate, prefetch, resolveRoutes, useClientMiddleware };
|
|
153
|
+
export { type ApiRouteDef, type AppConfig, type BootOptions, type ClientMiddleware, type CompiledPath, type GroupDef, type HonoMiddleware, type LayoutDef, type LayoutProps, type LoaderCtx, type NavigateOptions, PARAMS_KEY, type PageDef, type PageProps, type ResolvedRoute, type Route, type SEOMeta, SEO_KEY, SPA_HEADER, STATE_KEY, boot, compilePath, defineApiRoute, defineGroup, defineLayout, definePage, fetchPayload, matchPath, navigate, prefetch, resolveRoutes, syncSEO, useClientMiddleware };
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// client.ts
|
|
2
|
-
import { createSignal,
|
|
2
|
+
import { createSignal, createComponent } from "solid-js";
|
|
3
3
|
import { hydrate } from "solid-js/web";
|
|
4
|
+
import { Router, Route } from "@solidjs/router";
|
|
4
5
|
|
|
5
6
|
// core.ts
|
|
6
7
|
function definePage(def) {
|
|
@@ -65,6 +66,7 @@ var PARAMS_KEY = "__FNETRO_PARAMS__";
|
|
|
65
66
|
var SEO_KEY = "__FNETRO_SEO__";
|
|
66
67
|
|
|
67
68
|
// client.ts
|
|
69
|
+
import { useNavigate, useParams, useLocation, A, useSearchParams } from "@solidjs/router";
|
|
68
70
|
var _routes = [];
|
|
69
71
|
var _appLayout;
|
|
70
72
|
function findRoute(pathname) {
|
|
@@ -74,7 +76,6 @@ function findRoute(pathname) {
|
|
|
74
76
|
}
|
|
75
77
|
return null;
|
|
76
78
|
}
|
|
77
|
-
var _setNav = null;
|
|
78
79
|
var _mw = [];
|
|
79
80
|
function useClientMiddleware(mw) {
|
|
80
81
|
_mw.push(mw);
|
|
@@ -146,6 +147,49 @@ function fetchPayload(href) {
|
|
|
146
147
|
}
|
|
147
148
|
return _cache.get(href);
|
|
148
149
|
}
|
|
150
|
+
function prefetch(url) {
|
|
151
|
+
try {
|
|
152
|
+
const u = new URL(url, location.origin);
|
|
153
|
+
if (u.origin !== location.origin || !findRoute(u.pathname)) return;
|
|
154
|
+
fetchPayload(u.toString());
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function makeRouteComponent(route, appLayout, initialState, initialParams, initialSeo, prefetchOnHover) {
|
|
159
|
+
return function FNetroRouteComponent(routerProps) {
|
|
160
|
+
const routeParams = routerProps.params ?? {};
|
|
161
|
+
const pathname = routerProps.location?.pathname ?? location.pathname;
|
|
162
|
+
const serverData = initialState[pathname];
|
|
163
|
+
const [data, setData] = createSignal(serverData ?? {});
|
|
164
|
+
const [params, setParams] = createSignal(serverData ? initialParams : routeParams);
|
|
165
|
+
if (!serverData) {
|
|
166
|
+
const url = new URL(pathname, location.origin).toString();
|
|
167
|
+
fetchPayload(url).then((payload) => {
|
|
168
|
+
setData(payload.state ?? {});
|
|
169
|
+
setParams(payload.params ?? {});
|
|
170
|
+
syncSEO(payload.seo ?? {});
|
|
171
|
+
}).catch((err) => {
|
|
172
|
+
console.error("[fnetro] Failed to load route data:", err);
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
syncSEO(initialSeo);
|
|
176
|
+
}
|
|
177
|
+
const layout = route.layout !== void 0 ? route.layout : appLayout;
|
|
178
|
+
const pageEl = () => createComponent(route.page.Page, {
|
|
179
|
+
...data(),
|
|
180
|
+
url: pathname,
|
|
181
|
+
params: params()
|
|
182
|
+
});
|
|
183
|
+
if (!layout) return pageEl();
|
|
184
|
+
return createComponent(layout.Component, {
|
|
185
|
+
url: pathname,
|
|
186
|
+
params: params(),
|
|
187
|
+
get children() {
|
|
188
|
+
return pageEl();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
}
|
|
149
193
|
async function navigate(to, opts = {}) {
|
|
150
194
|
const u = new URL(to, location.origin);
|
|
151
195
|
if (u.origin !== location.origin) {
|
|
@@ -165,66 +209,14 @@ async function navigate(to, opts = {}) {
|
|
|
165
209
|
u.pathname
|
|
166
210
|
);
|
|
167
211
|
if (opts.scroll !== false) window.scrollTo(0, 0);
|
|
168
|
-
_setNav?.({ path: u.pathname, data: payload.state ?? {}, params: payload.params ?? {} });
|
|
169
212
|
syncSEO(payload.seo ?? {});
|
|
213
|
+
window.dispatchEvent(new PopStateEvent("popstate", { state: history.state }));
|
|
170
214
|
} catch (err) {
|
|
171
215
|
console.error("[fnetro] Navigation error:", err);
|
|
172
216
|
location.href = to;
|
|
173
217
|
}
|
|
174
218
|
});
|
|
175
219
|
}
|
|
176
|
-
function prefetch(url) {
|
|
177
|
-
try {
|
|
178
|
-
const u = new URL(url, location.origin);
|
|
179
|
-
if (u.origin !== location.origin || !findRoute(u.pathname)) return;
|
|
180
|
-
fetchPayload(u.toString());
|
|
181
|
-
} catch {
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
function onLinkClick(e) {
|
|
185
|
-
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
186
|
-
const a = e.composedPath().find(
|
|
187
|
-
(el) => el instanceof HTMLAnchorElement
|
|
188
|
-
);
|
|
189
|
-
if (!a?.href) return;
|
|
190
|
-
if (a.target && a.target !== "_self") return;
|
|
191
|
-
if (a.hasAttribute("data-no-spa") || a.rel?.includes("external")) return;
|
|
192
|
-
const u = new URL(a.href);
|
|
193
|
-
if (u.origin !== location.origin) return;
|
|
194
|
-
e.preventDefault();
|
|
195
|
-
navigate(a.href);
|
|
196
|
-
}
|
|
197
|
-
function onLinkHover(e) {
|
|
198
|
-
const a = e.composedPath().find(
|
|
199
|
-
(el) => el instanceof HTMLAnchorElement
|
|
200
|
-
);
|
|
201
|
-
if (a?.href) prefetch(a.href);
|
|
202
|
-
}
|
|
203
|
-
function onPopState() {
|
|
204
|
-
navigate(location.href, { replace: true, scroll: false });
|
|
205
|
-
}
|
|
206
|
-
function AppRoot(props) {
|
|
207
|
-
const [nav, setNav] = createSignal(props.initial);
|
|
208
|
-
_setNav = setNav;
|
|
209
|
-
const view = createMemo(() => {
|
|
210
|
-
const { path, data, params } = nav();
|
|
211
|
-
const m = findRoute(path);
|
|
212
|
-
if (!m) {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
const layout = m.route.layout !== void 0 ? m.route.layout : props.appLayout;
|
|
216
|
-
const pageEl = createComponent(m.route.page.Page, { ...data, url: path, params });
|
|
217
|
-
if (!layout) return pageEl;
|
|
218
|
-
return createComponent(layout.Component, {
|
|
219
|
-
url: path,
|
|
220
|
-
params,
|
|
221
|
-
get children() {
|
|
222
|
-
return pageEl;
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
return view;
|
|
227
|
-
}
|
|
228
220
|
async function boot(options) {
|
|
229
221
|
const { pages } = resolveRoutes(options.routes, {
|
|
230
222
|
layout: options.layout,
|
|
@@ -240,28 +232,44 @@ async function boot(options) {
|
|
|
240
232
|
const stateMap = window[STATE_KEY] ?? {};
|
|
241
233
|
const paramsMap = window[PARAMS_KEY] ?? {};
|
|
242
234
|
const seoData = window[SEO_KEY] ?? {};
|
|
243
|
-
const initial = {
|
|
244
|
-
path: pathname,
|
|
245
|
-
data: stateMap[pathname] ?? {},
|
|
246
|
-
params: paramsMap
|
|
247
|
-
};
|
|
248
235
|
const container = document.getElementById("fnetro-app");
|
|
249
236
|
if (!container) {
|
|
250
237
|
console.error("[fnetro] #fnetro-app not found \u2014 aborting hydration");
|
|
251
238
|
return;
|
|
252
239
|
}
|
|
253
|
-
|
|
240
|
+
const prefetchOnHover = options.prefetchOnHover !== false;
|
|
241
|
+
const routeElements = pages.map(
|
|
242
|
+
(route) => createComponent(Route, {
|
|
243
|
+
path: route.fullPath,
|
|
244
|
+
component: makeRouteComponent(
|
|
245
|
+
route,
|
|
246
|
+
_appLayout,
|
|
247
|
+
stateMap,
|
|
248
|
+
paramsMap,
|
|
249
|
+
seoData,
|
|
250
|
+
prefetchOnHover
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
254
|
hydrate(
|
|
255
|
-
() => createComponent(
|
|
255
|
+
() => createComponent(Router, {
|
|
256
|
+
get children() {
|
|
257
|
+
return routeElements;
|
|
258
|
+
}
|
|
259
|
+
}),
|
|
256
260
|
container
|
|
257
261
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
262
|
+
if (prefetchOnHover) {
|
|
263
|
+
document.addEventListener("mouseover", (e) => {
|
|
264
|
+
const a = e.composedPath().find(
|
|
265
|
+
(el) => el instanceof HTMLAnchorElement
|
|
266
|
+
);
|
|
267
|
+
if (a?.href) prefetch(a.href);
|
|
268
|
+
});
|
|
261
269
|
}
|
|
262
|
-
window.addEventListener("popstate", onPopState);
|
|
263
270
|
}
|
|
264
271
|
export {
|
|
272
|
+
A,
|
|
265
273
|
PARAMS_KEY,
|
|
266
274
|
SEO_KEY,
|
|
267
275
|
SPA_HEADER,
|
|
@@ -272,9 +280,15 @@ export {
|
|
|
272
280
|
defineGroup,
|
|
273
281
|
defineLayout,
|
|
274
282
|
definePage,
|
|
283
|
+
fetchPayload,
|
|
275
284
|
matchPath,
|
|
276
285
|
navigate,
|
|
277
286
|
prefetch,
|
|
278
287
|
resolveRoutes,
|
|
279
|
-
|
|
288
|
+
syncSEO,
|
|
289
|
+
useClientMiddleware,
|
|
290
|
+
useLocation,
|
|
291
|
+
useNavigate,
|
|
292
|
+
useParams,
|
|
293
|
+
useSearchParams
|
|
280
294
|
};
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { createComponent } from "solid-js";
|
|
4
4
|
import { renderToStringAsync, generateHydrationScript } from "solid-js/web";
|
|
5
|
+
import { Router } from "@solidjs/router";
|
|
5
6
|
|
|
6
7
|
// core.ts
|
|
7
8
|
function definePage(def) {
|
|
@@ -179,13 +180,18 @@ async function renderPage(route, data, url, params, appLayout) {
|
|
|
179
180
|
const layout = route.layout !== void 0 ? route.layout : appLayout;
|
|
180
181
|
return renderToStringAsync(() => {
|
|
181
182
|
const pageEl = createComponent(route.page.Page, { ...data, url, params });
|
|
182
|
-
|
|
183
|
-
return createComponent(layout.Component, {
|
|
183
|
+
const content = layout ? createComponent(layout.Component, {
|
|
184
184
|
url,
|
|
185
185
|
params,
|
|
186
186
|
get children() {
|
|
187
187
|
return pageEl;
|
|
188
188
|
}
|
|
189
|
+
}) : pageEl;
|
|
190
|
+
return createComponent(Router, {
|
|
191
|
+
url,
|
|
192
|
+
get children() {
|
|
193
|
+
return content;
|
|
194
|
+
}
|
|
189
195
|
});
|
|
190
196
|
});
|
|
191
197
|
}
|
|
@@ -350,9 +356,10 @@ function fnetroVitePlugin(opts = {}) {
|
|
|
350
356
|
name: "fnetro:jsx",
|
|
351
357
|
enforce: "pre",
|
|
352
358
|
// Sync config hook — must return Omit<UserConfig, 'plugins'> | null
|
|
353
|
-
|
|
359
|
+
// Note: Vite 6+ deprecated `esbuild.jsx`; Vite 8 uses `oxc` instead.
|
|
360
|
+
config(_cfg, env) {
|
|
354
361
|
return {
|
|
355
|
-
|
|
362
|
+
oxc: {
|
|
356
363
|
jsx: "automatic",
|
|
357
364
|
jsxImportSource: "solid-js"
|
|
358
365
|
}
|
|
@@ -406,7 +413,7 @@ function fnetroVitePlugin(opts = {}) {
|
|
|
406
413
|
format: "es",
|
|
407
414
|
entryFileNames: "server.js"
|
|
408
415
|
},
|
|
409
|
-
external: (id) => NODE_BUILTINS.test(id) || id === "@hono/node-server" || id === "@hono/node-server/serve-static" || serverExternal.includes(id)
|
|
416
|
+
external: (id) => NODE_BUILTINS.test(id) || id === "@hono/node-server" || id === "@hono/node-server/serve-static" || id === "@solidjs/router" || id.startsWith("@solidjs/router/") || serverExternal.includes(id)
|
|
410
417
|
}
|
|
411
418
|
}
|
|
412
419
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netrojs/fnetro",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"description": "Full-stack Hono framework — SolidJS SSR/SPA, SEO, middleware, route groups, API routes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"solid-js": ">=1.9.11",
|
|
77
|
+
"@solidjs/router": ">=0.15.0",
|
|
77
78
|
"hono": ">=4.0.0",
|
|
78
79
|
"vite": ">=5.0.0",
|
|
79
80
|
"vite-plugin-solid": ">=2.11.11"
|
|
@@ -88,6 +89,7 @@
|
|
|
88
89
|
},
|
|
89
90
|
"devDependencies": {
|
|
90
91
|
"@hono/node-server": "^1.19.11",
|
|
92
|
+
"@solidjs/router": "^0.16.1",
|
|
91
93
|
"@types/node": "^22.0.0",
|
|
92
94
|
"hono": "^4.12.8",
|
|
93
95
|
"rimraf": "^6.1.3",
|
package/server.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Hono } from 'hono'
|
|
7
7
|
import { createComponent } from 'solid-js'
|
|
8
8
|
import { renderToStringAsync, generateHydrationScript } from 'solid-js/web'
|
|
9
|
+
import { Router } from '@solidjs/router'
|
|
9
10
|
import {
|
|
10
11
|
resolveRoutes, compilePath, matchPath,
|
|
11
12
|
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
|
|
@@ -237,13 +238,20 @@ async function renderPage(
|
|
|
237
238
|
|
|
238
239
|
return renderToStringAsync(() => {
|
|
239
240
|
const pageEl = createComponent(route.page.Page as AnyComponent, { ...data, url, params })
|
|
240
|
-
if (!layout) return pageEl as any
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
const content = layout
|
|
243
|
+
? createComponent(layout.Component as AnyComponent, {
|
|
244
|
+
url,
|
|
245
|
+
params,
|
|
246
|
+
get children() { return pageEl },
|
|
247
|
+
})
|
|
248
|
+
: pageEl
|
|
249
|
+
|
|
250
|
+
// Wrap in Router so hydration keys match the client-side <Router>
|
|
251
|
+
return createComponent(Router as AnyComponent, {
|
|
243
252
|
url,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}) as any
|
|
253
|
+
get children() { return content },
|
|
254
|
+
})
|
|
247
255
|
})
|
|
248
256
|
}
|
|
249
257
|
|
|
@@ -256,7 +264,7 @@ async function renderFullPage(
|
|
|
256
264
|
assets: ResolvedAssets,
|
|
257
265
|
): Promise<string> {
|
|
258
266
|
const pageSEO = typeof route.page.seo === 'function'
|
|
259
|
-
? route.page.seo(data
|
|
267
|
+
? route.page.seo(data, params)
|
|
260
268
|
: route.page.seo
|
|
261
269
|
const seo = mergeSEO(config.seo, pageSEO)
|
|
262
270
|
const title = seo.title ?? 'FNetro'
|
|
@@ -519,13 +527,15 @@ export function fnetroVitePlugin(opts: FNetroPluginOptions = {}): Plugin[] {
|
|
|
519
527
|
enforce: 'pre',
|
|
520
528
|
|
|
521
529
|
// Sync config hook — must return Omit<UserConfig, 'plugins'> | null
|
|
522
|
-
|
|
530
|
+
// Note: Vite 6+ deprecated `esbuild.jsx`; Vite 8 uses `oxc` instead.
|
|
531
|
+
config(_cfg: UserConfig, env: ConfigEnv): Omit<UserConfig, 'plugins'> | null {
|
|
532
|
+
// oxc is the new JSX transform pipeline in Vite 8+
|
|
523
533
|
return {
|
|
524
|
-
|
|
534
|
+
oxc: {
|
|
525
535
|
jsx: 'automatic',
|
|
526
536
|
jsxImportSource: 'solid-js',
|
|
527
537
|
},
|
|
528
|
-
}
|
|
538
|
+
} as any
|
|
529
539
|
},
|
|
530
540
|
|
|
531
541
|
async buildStart() {
|
|
@@ -589,6 +599,8 @@ export function fnetroVitePlugin(opts: FNetroPluginOptions = {}): Plugin[] {
|
|
|
589
599
|
NODE_BUILTINS.test(id) ||
|
|
590
600
|
id === '@hono/node-server' ||
|
|
591
601
|
id === '@hono/node-server/serve-static' ||
|
|
602
|
+
id === '@solidjs/router' ||
|
|
603
|
+
id.startsWith('@solidjs/router/') ||
|
|
592
604
|
serverExternal.includes(id),
|
|
593
605
|
},
|
|
594
606
|
},
|