@netrojs/vono 0.0.1
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/LICENSE +21 -0
- package/README.md +768 -0
- package/client.ts +309 -0
- package/core.ts +151 -0
- package/dist/client.d.ts +199 -0
- package/dist/client.js +287 -0
- package/dist/core.d.ts +167 -0
- package/dist/core.js +96 -0
- package/dist/server.d.ts +212 -0
- package/dist/server.js +451 -0
- package/dist/types.d.ts +120 -0
- package/package.json +103 -0
- package/server.ts +590 -0
- package/types.ts +149 -0
package/client.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Vono · client.ts
|
|
3
|
+
// Vue 3 SSR hydration · Vue Router SPA · reactive page data · SEO sync
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import {
|
|
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,
|
|
37
|
+
} from './core'
|
|
38
|
+
|
|
39
|
+
// ── SEO ───────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function setMeta(selector: string, attr: string, val?: string): void {
|
|
42
|
+
if (!val) { document.querySelector(selector)?.remove(); return }
|
|
43
|
+
let el = document.querySelector<HTMLMetaElement>(selector)
|
|
44
|
+
if (!el) {
|
|
45
|
+
el = document.createElement('meta')
|
|
46
|
+
// Destructuring with defaults avoids string|undefined from noUncheckedIndexedAccess
|
|
47
|
+
const [, attrName = '', attrVal = ''] = /\[([^=]+)="([^"]+)"\]/.exec(selector) ?? []
|
|
48
|
+
if (attrName) el.setAttribute(attrName, attrVal)
|
|
49
|
+
document.head.appendChild(el)
|
|
50
|
+
}
|
|
51
|
+
el.setAttribute(attr, val)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function syncSEO(seo: SEOMeta): void {
|
|
55
|
+
if (seo.title) document.title = seo.title
|
|
56
|
+
setMeta('[name="description"]', 'content', seo.description)
|
|
57
|
+
setMeta('[name="keywords"]', 'content', seo.keywords)
|
|
58
|
+
setMeta('[name="robots"]', 'content', seo.robots)
|
|
59
|
+
setMeta('[name="theme-color"]', 'content', seo.themeColor)
|
|
60
|
+
setMeta('[property="og:title"]', 'content', seo.ogTitle)
|
|
61
|
+
setMeta('[property="og:description"]', 'content', seo.ogDescription)
|
|
62
|
+
setMeta('[property="og:image"]', 'content', seo.ogImage)
|
|
63
|
+
setMeta('[property="og:url"]', 'content', seo.ogUrl)
|
|
64
|
+
setMeta('[property="og:type"]', 'content', seo.ogType)
|
|
65
|
+
setMeta('[name="twitter:card"]', 'content', seo.twitterCard)
|
|
66
|
+
setMeta('[name="twitter:title"]', 'content', seo.twitterTitle)
|
|
67
|
+
setMeta('[name="twitter:description"]','content', seo.twitterDescription)
|
|
68
|
+
setMeta('[name="twitter:image"]', 'content', seo.twitterImage)
|
|
69
|
+
|
|
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)
|
|
76
|
+
}
|
|
77
|
+
link.href = seo.canonical
|
|
78
|
+
} else {
|
|
79
|
+
link?.remove()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── SPA data fetch + prefetch cache ──────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
interface SpaPayload {
|
|
86
|
+
state: Record<string, unknown>
|
|
87
|
+
params: Record<string, string>
|
|
88
|
+
seo: SEOMeta
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Module-level cache so repeated visits to the same URL don't re-fetch
|
|
92
|
+
const _fetchCache = new Map<string, Promise<SpaPayload>>()
|
|
93
|
+
|
|
94
|
+
function fetchSPA(href: string): Promise<SpaPayload> {
|
|
95
|
+
if (!_fetchCache.has(href)) {
|
|
96
|
+
_fetchCache.set(
|
|
97
|
+
href,
|
|
98
|
+
fetch(href, { headers: { [SPA_HEADER]: '1' } }).then(r => {
|
|
99
|
+
if (!r.ok) throw new Error(`[vono] ${r.status} ${r.statusText} — ${href}`)
|
|
100
|
+
return r.json() as Promise<SpaPayload>
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
return _fetchCache.get(href)!
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function prefetch(url: string): void {
|
|
108
|
+
try {
|
|
109
|
+
const u = new URL(url, location.origin)
|
|
110
|
+
if (u.origin === location.origin) fetchSPA(u.toString())
|
|
111
|
+
} catch { /* ignore malformed URLs */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Client middleware ─────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const _mw: ClientMiddleware[] = []
|
|
117
|
+
|
|
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)
|
|
133
|
+
}
|
|
134
|
+
|
|
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
|
+
}
|
|
147
|
+
|
|
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
|
+
}
|
|
166
|
+
|
|
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('[vono] usePageData() must be called inside a component setup().')
|
|
180
|
+
}
|
|
181
|
+
return data
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── boot() ────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export interface BootOptions extends AppConfig {
|
|
187
|
+
/** Warm fetch cache on link hover. @default true */
|
|
188
|
+
prefetchOnHover?: boolean
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function boot(options: BootOptions): Promise<void> {
|
|
192
|
+
const container = document.getElementById('vono-app')
|
|
193
|
+
if (!container) {
|
|
194
|
+
console.error('[vono] #vono-app not found — aborting hydration.')
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { pages } = resolveRoutes(options.routes, {
|
|
199
|
+
...(options.layout !== undefined && { layout: options.layout }),
|
|
200
|
+
middleware: [],
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// Read server-injected bootstrap data
|
|
204
|
+
const stateMap = (window as any)[STATE_KEY] as Record<string, Record<string, unknown>> ?? {}
|
|
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: 'VonoRoute',
|
|
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()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// createSSRApp: tells Vue to hydrate existing DOM instead of re-rendering
|
|
242
|
+
const app = createSSRApp({ name: 'VonoApp', 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.__VONO_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('[vono] 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)
|
|
284
|
+
if (a?.href) prefetch(a.href)
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Re-exports ────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export {
|
|
292
|
+
definePage, defineGroup, defineLayout, defineApiRoute, isAsyncLoader,
|
|
293
|
+
resolveRoutes, compilePath, matchPath, toVueRouterPath,
|
|
294
|
+
SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY,
|
|
295
|
+
} from './core'
|
|
296
|
+
|
|
297
|
+
export type {
|
|
298
|
+
AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
|
|
299
|
+
SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
|
|
300
|
+
ClientMiddleware, AsyncLoader, InferPageData,
|
|
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'
|
package/core.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Vono · core.ts
|
|
3
|
+
// Route builders · path matching · route resolution · async-loader detection
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import type { Component } from 'vue'
|
|
7
|
+
import type {
|
|
8
|
+
PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
|
|
9
|
+
ResolvedRoute, CompiledPath, HonoMiddleware, AsyncLoader, LoaderCtx,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
// ── Async-loader detection ────────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// A Vue component (SFC compiled by vite-plugin-vue) always carries one or more
|
|
15
|
+
// of these brand properties. A plain () => import('./Page.vue') factory has
|
|
16
|
+
// none of them, so checking for their absence is sufficient for real-world use.
|
|
17
|
+
|
|
18
|
+
const VUE_BRANDS = ['__name', '__file', '__vccOpts', 'setup', 'render', 'data', 'components'] as const
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns true when `c` is an async factory function (i.e. `() => import(...)`)
|
|
22
|
+
* rather than a resolved Vue component object.
|
|
23
|
+
*
|
|
24
|
+
* Used by both server.ts (to resolve the import before SSR) and client.ts
|
|
25
|
+
* (to wrap with defineAsyncComponent for lazy hydration).
|
|
26
|
+
*/
|
|
27
|
+
export function isAsyncLoader(c: unknown): c is AsyncLoader {
|
|
28
|
+
if (typeof c !== 'function') return false
|
|
29
|
+
const f = c as unknown as Record<string, unknown>
|
|
30
|
+
for (const brand of VUE_BRANDS) {
|
|
31
|
+
if (brand in f) return false
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Builder functions ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Define a page route with full type inference.
|
|
40
|
+
*
|
|
41
|
+
* TypeScript infers `TData` automatically from the `loader` return type, so
|
|
42
|
+
* you rarely need to supply the generic manually. Export the page constant and
|
|
43
|
+
* use `InferPageData<typeof myPage>` in your component for a single source of
|
|
44
|
+
* truth.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* export const postPage = definePage({
|
|
48
|
+
* path: '/post/[slug]',
|
|
49
|
+
* loader: async (c) => fetchPost(c.req.param('slug')),
|
|
50
|
+
* component: () => import('./pages/post.vue'),
|
|
51
|
+
* })
|
|
52
|
+
* export type PostData = InferPageData<typeof postPage>
|
|
53
|
+
*/
|
|
54
|
+
export function definePage<TData extends object = Record<string, never>>(
|
|
55
|
+
def: Omit<PageDef<TData>, '__type'>,
|
|
56
|
+
): PageDef<TData> {
|
|
57
|
+
return { __type: 'page', ...def }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function defineGroup(def: Omit<GroupDef, '__type'>): GroupDef {
|
|
61
|
+
return { __type: 'group', ...def }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wrap a Vue layout component (must render <slot />) as a Vono layout. */
|
|
65
|
+
export function defineLayout(component: Component): LayoutDef {
|
|
66
|
+
return { __type: 'layout', component }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function defineApiRoute(
|
|
70
|
+
path: string,
|
|
71
|
+
register: ApiRouteDef['register'],
|
|
72
|
+
): ApiRouteDef {
|
|
73
|
+
return { __type: 'api', path, register }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Path matching (Vono [param] syntax → RegExp) ────────────────────────────
|
|
77
|
+
|
|
78
|
+
export function compilePath(path: string): CompiledPath {
|
|
79
|
+
const keys: string[] = []
|
|
80
|
+
const src = path
|
|
81
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, (_, k: string) => { keys.push(k); return '(.*)' })
|
|
82
|
+
.replace(/\[([^\]]+)\]/g, (_, k: string) => { keys.push(k); return '([^/]+)' })
|
|
83
|
+
.replace(/\*/g, '(.*)')
|
|
84
|
+
return { re: new RegExp(`^${src}$`), keys }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function matchPath(
|
|
88
|
+
cp: CompiledPath,
|
|
89
|
+
pathname: string,
|
|
90
|
+
): Record<string, string> | null {
|
|
91
|
+
const m = pathname.match(cp.re)
|
|
92
|
+
if (!m) return null
|
|
93
|
+
const params: Record<string, string> = {}
|
|
94
|
+
cp.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1] ?? '') })
|
|
95
|
+
return params
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convert Vono `[param]` syntax to Vue Router `:param` syntax.
|
|
100
|
+
*
|
|
101
|
+
* `/posts/[slug]` → `/posts/:slug`
|
|
102
|
+
* `/files/[...path]` → `/files/:path(.*)*`
|
|
103
|
+
*/
|
|
104
|
+
export function toVueRouterPath(vonoPath: string): string {
|
|
105
|
+
return vonoPath
|
|
106
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, ':$1(.*)*')
|
|
107
|
+
.replace(/\[([^\]]+)\]/g, ':$1')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Route resolution ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function resolveRoutes(
|
|
113
|
+
routes: Route[],
|
|
114
|
+
options: {
|
|
115
|
+
prefix?: string
|
|
116
|
+
middleware?: HonoMiddleware[]
|
|
117
|
+
layout?: LayoutDef | false
|
|
118
|
+
} = {},
|
|
119
|
+
): { pages: ResolvedRoute[]; apis: ApiRouteDef[] } {
|
|
120
|
+
const pages: ResolvedRoute[] = []
|
|
121
|
+
const apis: ApiRouteDef[] = []
|
|
122
|
+
|
|
123
|
+
for (const route of routes) {
|
|
124
|
+
if (route.__type === 'api') {
|
|
125
|
+
apis.push({ ...route, path: (options.prefix ?? '') + route.path })
|
|
126
|
+
} else if (route.__type === 'group') {
|
|
127
|
+
const prefix = (options.prefix ?? '') + route.prefix
|
|
128
|
+
const mw = [...(options.middleware ?? []), ...(route.middleware ?? [])]
|
|
129
|
+
const layout = route.layout !== undefined ? route.layout : options.layout
|
|
130
|
+
const sub = resolveRoutes(route.routes, {
|
|
131
|
+
prefix,
|
|
132
|
+
middleware: mw,
|
|
133
|
+
...(layout !== undefined && { layout }),
|
|
134
|
+
})
|
|
135
|
+
pages.push(...sub.pages)
|
|
136
|
+
apis.push(...sub.apis)
|
|
137
|
+
} else {
|
|
138
|
+
pages.push({
|
|
139
|
+
fullPath: (options.prefix ?? '') + route.path,
|
|
140
|
+
page: route,
|
|
141
|
+
layout: route.layout !== undefined ? route.layout : options.layout,
|
|
142
|
+
middleware: [...(options.middleware ?? []), ...(route.middleware ?? [])],
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { pages, apis }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Re-export all types so `import from '@netrojs/vono'` (root export) works
|
|
151
|
+
export * from './types'
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Component } from 'vue';
|
|
2
|
+
import { Hono, MiddlewareHandler, Context } from 'hono';
|
|
3
|
+
export { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
|
|
4
|
+
|
|
5
|
+
type HonoMiddleware = MiddlewareHandler;
|
|
6
|
+
type LoaderCtx = Context;
|
|
7
|
+
interface SEOMeta {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
keywords?: string;
|
|
11
|
+
author?: string;
|
|
12
|
+
robots?: string;
|
|
13
|
+
canonical?: string;
|
|
14
|
+
themeColor?: string;
|
|
15
|
+
ogTitle?: string;
|
|
16
|
+
ogDescription?: string;
|
|
17
|
+
ogImage?: string;
|
|
18
|
+
ogImageAlt?: string;
|
|
19
|
+
ogUrl?: string;
|
|
20
|
+
ogType?: string;
|
|
21
|
+
ogSiteName?: string;
|
|
22
|
+
twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player';
|
|
23
|
+
twitterSite?: string;
|
|
24
|
+
twitterTitle?: string;
|
|
25
|
+
twitterDescription?: string;
|
|
26
|
+
twitterImage?: string;
|
|
27
|
+
/** Structured data injected as <script type="application/ld+json">. */
|
|
28
|
+
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
29
|
+
}
|
|
30
|
+
type AsyncLoader = () => Promise<{
|
|
31
|
+
default: Component;
|
|
32
|
+
} | Component>;
|
|
33
|
+
interface PageDef<TData extends object = Record<string, never>> {
|
|
34
|
+
readonly __type: 'page';
|
|
35
|
+
path: string;
|
|
36
|
+
middleware?: HonoMiddleware[];
|
|
37
|
+
loader?: (c: LoaderCtx) => TData | Promise<TData>;
|
|
38
|
+
seo?: SEOMeta | ((data: TData, params: Record<string, string>) => SEOMeta);
|
|
39
|
+
/** Override or disable the app-level layout for this route. */
|
|
40
|
+
layout?: LayoutDef | false;
|
|
41
|
+
/**
|
|
42
|
+
* The Vue component to render for this route.
|
|
43
|
+
* Use () => import('./Page.vue') for automatic code splitting.
|
|
44
|
+
*/
|
|
45
|
+
component: Component | AsyncLoader;
|
|
46
|
+
}
|
|
47
|
+
interface GroupDef {
|
|
48
|
+
readonly __type: 'group';
|
|
49
|
+
prefix: string;
|
|
50
|
+
layout?: LayoutDef | false;
|
|
51
|
+
middleware?: HonoMiddleware[];
|
|
52
|
+
routes: Route[];
|
|
53
|
+
}
|
|
54
|
+
interface LayoutDef {
|
|
55
|
+
readonly __type: 'layout';
|
|
56
|
+
/** Vue layout component — must contain <slot /> for page content. */
|
|
57
|
+
component: Component;
|
|
58
|
+
}
|
|
59
|
+
interface ApiRouteDef {
|
|
60
|
+
readonly __type: 'api';
|
|
61
|
+
path: string;
|
|
62
|
+
register: (app: Hono, globalMiddleware: HonoMiddleware[]) => void;
|
|
63
|
+
}
|
|
64
|
+
type Route = PageDef<any> | GroupDef | ApiRouteDef;
|
|
65
|
+
interface AppConfig {
|
|
66
|
+
layout?: LayoutDef;
|
|
67
|
+
seo?: SEOMeta;
|
|
68
|
+
middleware?: HonoMiddleware[];
|
|
69
|
+
routes: Route[];
|
|
70
|
+
notFound?: Component;
|
|
71
|
+
htmlAttrs?: Record<string, string>;
|
|
72
|
+
/** Extra HTML injected into <head> (e.g. font preloads). */
|
|
73
|
+
head?: string;
|
|
74
|
+
}
|
|
75
|
+
interface ResolvedRoute {
|
|
76
|
+
fullPath: string;
|
|
77
|
+
page: PageDef<any>;
|
|
78
|
+
layout: LayoutDef | false | undefined;
|
|
79
|
+
middleware: HonoMiddleware[];
|
|
80
|
+
}
|
|
81
|
+
interface CompiledPath {
|
|
82
|
+
re: RegExp;
|
|
83
|
+
keys: string[];
|
|
84
|
+
}
|
|
85
|
+
type ClientMiddleware = (url: string, next: () => Promise<void>) => Promise<void>;
|
|
86
|
+
/** Custom request header that identifies an SPA navigation (JSON payload). */
|
|
87
|
+
declare const SPA_HEADER = "x-vono-spa";
|
|
88
|
+
/** window key for SSR-injected per-page loader data. */
|
|
89
|
+
declare const STATE_KEY = "__VONO_STATE__";
|
|
90
|
+
/** window key for SSR-injected URL params. */
|
|
91
|
+
declare const PARAMS_KEY = "__VONO_PARAMS__";
|
|
92
|
+
/** window key for SSR-injected SEO meta. */
|
|
93
|
+
declare const SEO_KEY = "__VONO_SEO__";
|
|
94
|
+
/**
|
|
95
|
+
* Vue provide/inject key for the reactive page-data object.
|
|
96
|
+
* Symbol.for() ensures the same reference across module instances (SSR safe).
|
|
97
|
+
*/
|
|
98
|
+
declare const DATA_KEY: unique symbol;
|
|
99
|
+
/**
|
|
100
|
+
* Extract the loader data type from a `PageDef` returned by `definePage()`.
|
|
101
|
+
*
|
|
102
|
+
* This enables you to define the data type exactly once — inferred from the
|
|
103
|
+
* loader — and import it into page components for `usePageData<T>()`.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* // app/routes.ts
|
|
107
|
+
* export const homePage = definePage({
|
|
108
|
+
* path: '/',
|
|
109
|
+
* loader: async () => ({ title: 'Hello', count: 42 }),
|
|
110
|
+
* component: () => import('./pages/home.vue'),
|
|
111
|
+
* })
|
|
112
|
+
* export type HomeData = InferPageData<typeof homePage>
|
|
113
|
+
* // HomeData = { title: string; count: number }
|
|
114
|
+
*
|
|
115
|
+
* // app/pages/home.vue
|
|
116
|
+
* import type { HomeData } from '../routes'
|
|
117
|
+
* const data = usePageData<HomeData>()
|
|
118
|
+
*/
|
|
119
|
+
type InferPageData<T> = T extends PageDef<infer D> ? D : never;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns true when `c` is an async factory function (i.e. `() => import(...)`)
|
|
123
|
+
* rather than a resolved Vue component object.
|
|
124
|
+
*
|
|
125
|
+
* Used by both server.ts (to resolve the import before SSR) and client.ts
|
|
126
|
+
* (to wrap with defineAsyncComponent for lazy hydration).
|
|
127
|
+
*/
|
|
128
|
+
declare function isAsyncLoader(c: unknown): c is AsyncLoader;
|
|
129
|
+
/**
|
|
130
|
+
* Define a page route with full type inference.
|
|
131
|
+
*
|
|
132
|
+
* TypeScript infers `TData` automatically from the `loader` return type, so
|
|
133
|
+
* you rarely need to supply the generic manually. Export the page constant and
|
|
134
|
+
* use `InferPageData<typeof myPage>` in your component for a single source of
|
|
135
|
+
* truth.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* export const postPage = definePage({
|
|
139
|
+
* path: '/post/[slug]',
|
|
140
|
+
* loader: async (c) => fetchPost(c.req.param('slug')),
|
|
141
|
+
* component: () => import('./pages/post.vue'),
|
|
142
|
+
* })
|
|
143
|
+
* export type PostData = InferPageData<typeof postPage>
|
|
144
|
+
*/
|
|
145
|
+
declare function definePage<TData extends object = Record<string, never>>(def: Omit<PageDef<TData>, '__type'>): PageDef<TData>;
|
|
146
|
+
declare function defineGroup(def: Omit<GroupDef, '__type'>): GroupDef;
|
|
147
|
+
/** Wrap a Vue layout component (must render <slot />) as a Vono layout. */
|
|
148
|
+
declare function defineLayout(component: Component): LayoutDef;
|
|
149
|
+
declare function defineApiRoute(path: string, register: ApiRouteDef['register']): ApiRouteDef;
|
|
150
|
+
declare function compilePath(path: string): CompiledPath;
|
|
151
|
+
declare function matchPath(cp: CompiledPath, pathname: string): Record<string, string> | null;
|
|
152
|
+
/**
|
|
153
|
+
* Convert Vono `[param]` syntax to Vue Router `:param` syntax.
|
|
154
|
+
*
|
|
155
|
+
* `/posts/[slug]` → `/posts/:slug`
|
|
156
|
+
* `/files/[...path]` → `/files/:path(.*)*`
|
|
157
|
+
*/
|
|
158
|
+
declare function toVueRouterPath(vonoPath: string): string;
|
|
159
|
+
declare function resolveRoutes(routes: Route[], options?: {
|
|
160
|
+
prefix?: string;
|
|
161
|
+
middleware?: HonoMiddleware[];
|
|
162
|
+
layout?: LayoutDef | false;
|
|
163
|
+
}): {
|
|
164
|
+
pages: ResolvedRoute[];
|
|
165
|
+
apis: ApiRouteDef[];
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
declare function syncSEO(seo: SEOMeta): void;
|
|
169
|
+
declare function prefetch(url: string): void;
|
|
170
|
+
/**
|
|
171
|
+
* Register a client-side navigation middleware.
|
|
172
|
+
* Must be called **before** `boot()`.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* useClientMiddleware(async (url, next) => {
|
|
176
|
+
* if (!isLoggedIn() && url.startsWith('/dashboard')) {
|
|
177
|
+
* await navigate('/login')
|
|
178
|
+
* return
|
|
179
|
+
* }
|
|
180
|
+
* await next()
|
|
181
|
+
* })
|
|
182
|
+
*/
|
|
183
|
+
declare function useClientMiddleware(mw: ClientMiddleware): void;
|
|
184
|
+
/**
|
|
185
|
+
* Access the current page's loader data inside any Vue component.
|
|
186
|
+
* The returned object is reactive — it updates automatically on navigation.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* const data = usePageData<{ title: string; posts: Post[] }>()
|
|
190
|
+
* // data.title is typed and reactive
|
|
191
|
+
*/
|
|
192
|
+
declare function usePageData<T extends Record<string, unknown> = Record<string, unknown>>(): T;
|
|
193
|
+
interface BootOptions extends AppConfig {
|
|
194
|
+
/** Warm fetch cache on link hover. @default true */
|
|
195
|
+
prefetchOnHover?: boolean;
|
|
196
|
+
}
|
|
197
|
+
declare function boot(options: BootOptions): Promise<void>;
|
|
198
|
+
|
|
199
|
+
export { type ApiRouteDef, type AppConfig, type AsyncLoader, type BootOptions, type ClientMiddleware, type CompiledPath, DATA_KEY, type GroupDef, type HonoMiddleware, type InferPageData, type LayoutDef, type LoaderCtx, PARAMS_KEY, type PageDef, type ResolvedRoute, type Route, type SEOMeta, SEO_KEY, SPA_HEADER, STATE_KEY, boot, compilePath, defineApiRoute, defineGroup, defineLayout, definePage, isAsyncLoader, matchPath, prefetch, resolveRoutes, syncSEO, toVueRouterPath, useClientMiddleware, usePageData };
|