@netrojs/fnetro 0.1.6 → 0.2.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/README.md +686 -871
- package/client.ts +220 -200
- package/core.ts +146 -644
- package/dist/client.d.ts +99 -155
- package/dist/client.js +177 -570
- package/dist/core.d.ts +69 -156
- package/dist/core.js +31 -452
- package/dist/server.d.ts +120 -179
- package/dist/server.js +278 -553
- package/package.json +17 -8
- package/server.ts +455 -247
package/core.ts
CHANGED
|
@@ -1,734 +1,236 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// FNetro · core.ts
|
|
3
|
-
//
|
|
3
|
+
// Shared types · route builders · path matching · SEO · constants
|
|
4
|
+
// Reactivity: consumers use solid-js primitives directly
|
|
4
5
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
6
7
|
import type { Context, MiddlewareHandler, Hono } from 'hono'
|
|
8
|
+
import type { Component, JSX } from 'solid-js'
|
|
7
9
|
|
|
8
10
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
9
|
-
// § 1
|
|
11
|
+
// § 1 Primitive aliases
|
|
10
12
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
11
13
|
|
|
12
|
-
export type
|
|
13
|
-
|
|
14
|
-
const RAW = Symbol('raw')
|
|
15
|
-
const IS_REACTIVE = Symbol('isReactive')
|
|
16
|
-
const IS_READONLY = Symbol('isReadonly')
|
|
17
|
-
const IS_REF = Symbol('isRef')
|
|
18
|
-
const MARK_RAW = Symbol('markRaw')
|
|
19
|
-
|
|
20
|
-
// Per-target, per-key subscriber sets
|
|
21
|
-
const targetMap = new WeakMap<object, Map<PropertyKey, Set<ReactiveEffect>>>()
|
|
22
|
-
|
|
23
|
-
let activeEffect: ReactiveEffect | null = null
|
|
24
|
-
let shouldTrack = true
|
|
25
|
-
let trackStack: boolean[] = []
|
|
26
|
-
|
|
27
|
-
function pauseTracking() { trackStack.push(shouldTrack); shouldTrack = false }
|
|
28
|
-
function resetTracking() { shouldTrack = trackStack.pop() ?? true }
|
|
29
|
-
|
|
30
|
-
function track(target: object, key: PropertyKey) {
|
|
31
|
-
if (!shouldTrack || !activeEffect) return
|
|
32
|
-
let depsMap = targetMap.get(target)
|
|
33
|
-
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
|
|
34
|
-
let dep = depsMap.get(key)
|
|
35
|
-
if (!dep) depsMap.set(key, (dep = new Set()))
|
|
36
|
-
trackEffect(activeEffect, dep)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function trackEffect(effect: ReactiveEffect, dep: Set<ReactiveEffect>) {
|
|
40
|
-
if (!dep.has(effect)) {
|
|
41
|
-
dep.add(effect)
|
|
42
|
-
effect.deps.push(dep)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function trigger(target: object, key: PropertyKey, newVal?: unknown, oldVal?: unknown) {
|
|
47
|
-
const depsMap = targetMap.get(target)
|
|
48
|
-
if (!depsMap) return
|
|
49
|
-
const effects: ReactiveEffect[] = []
|
|
50
|
-
const computedEffects: ReactiveEffect[] = []
|
|
51
|
-
|
|
52
|
-
depsMap.get(key)?.forEach(e => {
|
|
53
|
-
if (e !== activeEffect) {
|
|
54
|
-
e.computed ? computedEffects.push(e) : effects.push(e)
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
// Computed run first so dependents see fresh values
|
|
58
|
-
;[...computedEffects, ...effects].forEach(e => {
|
|
59
|
-
if (e.active) e.scheduler ? e.scheduler() : e.run()
|
|
60
|
-
})
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
64
|
-
// § 2 ReactiveEffect
|
|
65
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
66
|
-
|
|
67
|
-
export class ReactiveEffect {
|
|
68
|
-
deps: Set<ReactiveEffect>[] = []
|
|
69
|
-
active = true
|
|
70
|
-
cleanup?: () => void
|
|
71
|
-
computed = false
|
|
72
|
-
|
|
73
|
-
constructor(
|
|
74
|
-
public fn: () => any,
|
|
75
|
-
public scheduler?: () => void,
|
|
76
|
-
public scope?: EffectScope,
|
|
77
|
-
) {
|
|
78
|
-
scope?.effects.push(this)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
run(): any {
|
|
82
|
-
if (!this.active) return this.fn()
|
|
83
|
-
const prevEffect = activeEffect
|
|
84
|
-
const prevShouldTrack = shouldTrack
|
|
85
|
-
shouldTrack = true
|
|
86
|
-
activeEffect = this
|
|
87
|
-
this.cleanup?.()
|
|
88
|
-
this.cleanup = undefined
|
|
89
|
-
this.deps.length = 0
|
|
90
|
-
try {
|
|
91
|
-
const result = this.fn()
|
|
92
|
-
if (typeof result === 'function') this.cleanup = result
|
|
93
|
-
return result
|
|
94
|
-
} finally {
|
|
95
|
-
activeEffect = prevEffect
|
|
96
|
-
shouldTrack = prevShouldTrack
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
stop() {
|
|
101
|
-
if (this.active) {
|
|
102
|
-
cleanupEffect(this)
|
|
103
|
-
this.active = false
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function cleanupEffect(e: ReactiveEffect) {
|
|
109
|
-
e.deps.forEach(dep => dep.delete(e))
|
|
110
|
-
e.deps.length = 0
|
|
111
|
-
}
|
|
14
|
+
export type HonoMiddleware = MiddlewareHandler
|
|
15
|
+
export type LoaderCtx = Context
|
|
112
16
|
|
|
113
17
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
114
|
-
// §
|
|
18
|
+
// § 2 SEO / head metadata
|
|
115
19
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
116
20
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function onScopeDispose(fn: () => void) {
|
|
151
|
-
activeScope?.onCleanup(fn)
|
|
21
|
+
export interface SEOMeta {
|
|
22
|
+
// Basic
|
|
23
|
+
title?: string
|
|
24
|
+
description?: string
|
|
25
|
+
keywords?: string
|
|
26
|
+
author?: string
|
|
27
|
+
robots?: string
|
|
28
|
+
canonical?: string
|
|
29
|
+
themeColor?: string
|
|
30
|
+
// Open Graph
|
|
31
|
+
ogTitle?: string
|
|
32
|
+
ogDescription?: string
|
|
33
|
+
ogImage?: string
|
|
34
|
+
ogImageAlt?: string
|
|
35
|
+
ogImageWidth?: string
|
|
36
|
+
ogImageHeight?: string
|
|
37
|
+
ogUrl?: string
|
|
38
|
+
ogType?: string
|
|
39
|
+
ogSiteName?: string
|
|
40
|
+
ogLocale?: string
|
|
41
|
+
// Twitter / X
|
|
42
|
+
twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player'
|
|
43
|
+
twitterSite?: string
|
|
44
|
+
twitterCreator?: string
|
|
45
|
+
twitterTitle?: string
|
|
46
|
+
twitterDescription?: string
|
|
47
|
+
twitterImage?: string
|
|
48
|
+
twitterImageAlt?: string
|
|
49
|
+
// Structured data (JSON-LD)
|
|
50
|
+
jsonLd?: Record<string, unknown> | Record<string, unknown>[]
|
|
51
|
+
// Arbitrary extra <meta> tags
|
|
52
|
+
extra?: Array<{ name?: string; property?: string; httpEquiv?: string; content: string }>
|
|
152
53
|
}
|
|
153
54
|
|
|
154
55
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
155
|
-
// §
|
|
56
|
+
// § 3 Component prop shapes
|
|
156
57
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
157
58
|
|
|
158
|
-
export
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return () => e.stop()
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface WatchEffectOptions {
|
|
165
|
-
flush?: 'sync' | 'post'
|
|
166
|
-
onTrack?: (e: any) => void
|
|
167
|
-
onTrigger?: (e: any) => void
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function watchEffect(fn: EffectFn, opts?: WatchEffectOptions): () => void {
|
|
171
|
-
const e = new ReactiveEffect(fn, undefined, activeScope)
|
|
172
|
-
e.run()
|
|
173
|
-
return () => e.stop()
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
177
|
-
// § 5 Ref
|
|
178
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
179
|
-
|
|
180
|
-
export interface Ref<T = unknown> {
|
|
181
|
-
value: T
|
|
182
|
-
readonly [IS_REF]: true
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const refTarget = Symbol('refTarget')
|
|
186
|
-
|
|
187
|
-
class RefImpl<T> implements Ref<T> {
|
|
188
|
-
readonly [IS_REF] = true as const
|
|
189
|
-
private _value: T
|
|
190
|
-
private _subscribers = new Set<() => void>()
|
|
191
|
-
|
|
192
|
-
constructor(value: T, private readonly shallow = false) {
|
|
193
|
-
this._value = shallow ? value : toReactive(value)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
get value(): T {
|
|
197
|
-
track(this as any, refTarget)
|
|
198
|
-
this._subscribers.forEach(fn => { /* for useSyncExternalStore */ })
|
|
199
|
-
return this._value
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
set value(next: T) {
|
|
203
|
-
const newVal = this.shallow ? next : toReactive(next)
|
|
204
|
-
if (!hasChanged(newVal, this._value)) return
|
|
205
|
-
this._value = newVal
|
|
206
|
-
trigger(this as any, refTarget, newVal, this._value)
|
|
207
|
-
this._subscribers.forEach(fn => fn())
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Subscribe for useSyncExternalStore */
|
|
211
|
-
subscribe(fn: () => void): () => void {
|
|
212
|
-
this._subscribers.add(fn)
|
|
213
|
-
return () => this._subscribers.delete(fn)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
peek(): T { return this._value }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function ref<T>(value: T): Ref<T> {
|
|
220
|
-
return isRef(value) ? value as Ref<T> : new RefImpl(value)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function shallowRef<T>(value: T): Ref<T> {
|
|
224
|
-
return new RefImpl(value, true)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export function triggerRef(r: Ref): void {
|
|
228
|
-
if (r instanceof RefImpl) {
|
|
229
|
-
trigger(r as any, refTarget)
|
|
230
|
-
;(r as any)._subscribers.forEach((fn: () => void) => fn())
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function isRef<T = unknown>(r: unknown): r is Ref<T> {
|
|
235
|
-
return !!r && typeof r === 'object' && (r as any)[IS_REF] === true
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export function unref<T>(r: T | Ref<T>): T {
|
|
239
|
-
return isRef(r) ? r.value : r
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export function toRef<T extends object, K extends keyof T>(obj: T, key: K): Ref<T[K]> {
|
|
243
|
-
const r = new RefImpl<T[K]>(undefined as T[K], false)
|
|
244
|
-
Object.defineProperty(r, 'value', {
|
|
245
|
-
get() { track(r as any, refTarget); return obj[key] },
|
|
246
|
-
set(v: T[K]) { obj[key] = v; trigger(r as any, refTarget, v, obj[key]) }
|
|
247
|
-
})
|
|
248
|
-
return r
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export function toRefs<T extends object>(obj: T): { [K in keyof T]: Ref<T[K]> } {
|
|
252
|
-
const result = {} as { [K in keyof T]: Ref<T[K]> }
|
|
253
|
-
for (const key in obj) result[key] = toRef(obj, key)
|
|
254
|
-
return result
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
258
|
-
// § 6 Computed
|
|
259
|
-
// ══════════════════════════════════════════════════════════════════════════════
|
|
260
|
-
|
|
261
|
-
export interface WritableComputedRef<T> extends Ref<T> {
|
|
262
|
-
readonly effect: ReactiveEffect
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export interface ComputedRef<T> extends WritableComputedRef<T> {
|
|
266
|
-
readonly value: T
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
class ComputedRefImpl<T> implements Ref<T> {
|
|
270
|
-
readonly [IS_REF] = true as const
|
|
271
|
-
readonly effect: ReactiveEffect
|
|
272
|
-
private _value!: T
|
|
273
|
-
private _dirty = true
|
|
274
|
-
private _subscribers = new Set<() => void>()
|
|
275
|
-
|
|
276
|
-
constructor(getter: () => T, private setter?: (v: T) => void) {
|
|
277
|
-
this.effect = new ReactiveEffect(getter, () => {
|
|
278
|
-
if (!this._dirty) {
|
|
279
|
-
this._dirty = true
|
|
280
|
-
trigger(this as any, refTarget)
|
|
281
|
-
this._subscribers.forEach(fn => fn())
|
|
282
|
-
}
|
|
283
|
-
}, activeScope)
|
|
284
|
-
this.effect.computed = true
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
get value(): T {
|
|
288
|
-
track(this as any, refTarget)
|
|
289
|
-
if (this._dirty) {
|
|
290
|
-
this._dirty = false
|
|
291
|
-
this._value = this.effect.run()
|
|
292
|
-
}
|
|
293
|
-
return this._value
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
set value(v: T) {
|
|
297
|
-
this.setter?.(v)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
subscribe(fn: () => void): () => void {
|
|
301
|
-
this._subscribers.add(fn)
|
|
302
|
-
return () => this._subscribers.delete(fn)
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
peek(): T { return this._value }
|
|
59
|
+
export type PageProps<TData extends object = {}> = TData & {
|
|
60
|
+
url: string
|
|
61
|
+
params: Record<string, string>
|
|
306
62
|
}
|
|
307
63
|
|
|
308
|
-
export
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return new ComputedRefImpl(arg) as ComputedRef<T>
|
|
313
|
-
}
|
|
314
|
-
return new ComputedRefImpl(arg.get, arg.set) as ComputedRef<T>
|
|
64
|
+
export interface LayoutProps {
|
|
65
|
+
children: JSX.Element
|
|
66
|
+
url: string
|
|
67
|
+
params: Record<string, string>
|
|
315
68
|
}
|
|
316
69
|
|
|
317
70
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
318
|
-
// §
|
|
71
|
+
// § 4 Route definitions
|
|
319
72
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
320
73
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const arrayInstrumentations: Record<string, Function> = {}
|
|
330
|
-
;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
|
|
331
|
-
arrayInstrumentations[method] = function (this: unknown[], ...args: unknown[]) {
|
|
332
|
-
const arr = toRaw(this) as unknown[]
|
|
333
|
-
for (let i = 0; i < this.length; i++) track(arr, i)
|
|
334
|
-
let res = (arr as any)[method](...args)
|
|
335
|
-
if (res === -1 || res === false) res = (arr as any)[method](...args.map(toRaw))
|
|
336
|
-
return res
|
|
337
|
-
}
|
|
338
|
-
})
|
|
339
|
-
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
|
|
340
|
-
arrayInstrumentations[method] = function (this: unknown[], ...args: unknown[]) {
|
|
341
|
-
pauseTracking()
|
|
342
|
-
const res = (toRaw(this) as any)[method].apply(this, args)
|
|
343
|
-
resetTracking()
|
|
344
|
-
return res
|
|
345
|
-
}
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
function createHandler(shallow = false, readonly = false): ProxyHandler<object> {
|
|
349
|
-
return {
|
|
350
|
-
get(target, key, receiver) {
|
|
351
|
-
if (key === RAW) return target
|
|
352
|
-
if (key === IS_REACTIVE) return !readonly
|
|
353
|
-
if (key === IS_READONLY) return readonly
|
|
354
|
-
if (key === MARK_RAW) return (target as any)[MARK_RAW]
|
|
355
|
-
|
|
356
|
-
const isArray = Array.isArray(target)
|
|
357
|
-
if (!readonly && isArray && hasOwn(arrayInstrumentations, key as string)) {
|
|
358
|
-
return Reflect.get(arrayInstrumentations, key, receiver)
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const res = Reflect.get(target, key, receiver)
|
|
362
|
-
if (typeof key === 'symbol' || key === '__proto__') return res
|
|
363
|
-
|
|
364
|
-
if (!readonly) track(target, key)
|
|
365
|
-
|
|
366
|
-
if (shallow) return res
|
|
367
|
-
if (isRef(res)) return isArray ? res : res.value
|
|
368
|
-
return res !== null && typeof res === 'object' && !res[MARK_RAW]
|
|
369
|
-
? readonly ? readonlyProxy(res) : reactive(res)
|
|
370
|
-
: res
|
|
371
|
-
},
|
|
372
|
-
set(target, key, value, receiver) {
|
|
373
|
-
if (readonly) {
|
|
374
|
-
console.warn(`[fnetro] Cannot set "${String(key)}" on readonly object`)
|
|
375
|
-
return true
|
|
376
|
-
}
|
|
377
|
-
const oldVal = (target as any)[key]
|
|
378
|
-
const result = Reflect.set(target, key, value, receiver)
|
|
379
|
-
if (hasChanged(value, oldVal)) trigger(target, key, value, oldVal)
|
|
380
|
-
return result
|
|
381
|
-
},
|
|
382
|
-
deleteProperty(target, key) {
|
|
383
|
-
if (readonly) return true
|
|
384
|
-
const hadKey = hasOwn(target, key as string)
|
|
385
|
-
const result = Reflect.deleteProperty(target, key)
|
|
386
|
-
if (hadKey && result) trigger(target, key)
|
|
387
|
-
return result
|
|
388
|
-
},
|
|
389
|
-
has(target, key) {
|
|
390
|
-
const res = Reflect.has(target, key)
|
|
391
|
-
track(target, key)
|
|
392
|
-
return res
|
|
393
|
-
},
|
|
394
|
-
ownKeys(target) {
|
|
395
|
-
track(target, Array.isArray(target) ? 'length' : '__iterate__')
|
|
396
|
-
return Reflect.ownKeys(target)
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
export function reactive<T extends object>(target: T): T {
|
|
402
|
-
if (isReadonly(target)) return target
|
|
403
|
-
if ((target as any)[MARK_RAW]) return target
|
|
404
|
-
if (reactiveMap.has(target)) return reactiveMap.get(target) as T
|
|
405
|
-
const proxy = new Proxy(target, createHandler()) as T
|
|
406
|
-
reactiveMap.set(target, proxy)
|
|
407
|
-
return proxy
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
export function shallowReactive<T extends object>(target: T): T {
|
|
411
|
-
if (shallowReactiveMap.has(target)) return shallowReactiveMap.get(target) as T
|
|
412
|
-
const proxy = new Proxy(target, createHandler(true)) as T
|
|
413
|
-
shallowReactiveMap.set(target, proxy)
|
|
414
|
-
return proxy
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function readonlyProxy<T extends object>(target: T): T {
|
|
418
|
-
if (readonlyMap.has(target)) return readonlyMap.get(target) as T
|
|
419
|
-
const proxy = new Proxy(target, createHandler(false, true)) as T
|
|
420
|
-
readonlyMap.set(target, proxy)
|
|
421
|
-
return proxy
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
export function readonly<T extends object>(target: T): Readonly<T> {
|
|
425
|
-
return readonlyProxy(target)
|
|
74
|
+
export interface PageDef<TData extends object = {}> {
|
|
75
|
+
readonly __type: 'page'
|
|
76
|
+
path: string
|
|
77
|
+
middleware?: HonoMiddleware[]
|
|
78
|
+
loader?: (c: LoaderCtx) => TData | Promise<TData>
|
|
79
|
+
seo?: SEOMeta | ((data: TData, params: Record<string, string>) => SEOMeta)
|
|
80
|
+
layout?: LayoutDef | false
|
|
81
|
+
Page: Component<PageProps<TData>>
|
|
426
82
|
}
|
|
427
83
|
|
|
428
|
-
export
|
|
429
|
-
|
|
430
|
-
|
|
84
|
+
export interface GroupDef {
|
|
85
|
+
readonly __type: 'group'
|
|
86
|
+
prefix: string
|
|
87
|
+
layout?: LayoutDef | false
|
|
88
|
+
middleware?: HonoMiddleware[]
|
|
89
|
+
routes: Route[]
|
|
431
90
|
}
|
|
432
91
|
|
|
433
|
-
export
|
|
434
|
-
|
|
435
|
-
|
|
92
|
+
export interface LayoutDef {
|
|
93
|
+
readonly __type: 'layout'
|
|
94
|
+
Component: Component<LayoutProps>
|
|
436
95
|
}
|
|
437
96
|
|
|
438
|
-
export
|
|
439
|
-
|
|
440
|
-
|
|
97
|
+
export interface ApiRouteDef {
|
|
98
|
+
readonly __type: 'api'
|
|
99
|
+
path: string
|
|
100
|
+
register: (app: Hono, globalMiddleware: HonoMiddleware[]) => void
|
|
441
101
|
}
|
|
442
102
|
|
|
443
|
-
export
|
|
444
|
-
return !!(value && (value as any)[IS_READONLY])
|
|
445
|
-
}
|
|
103
|
+
export type Route = PageDef<any> | GroupDef | ApiRouteDef
|
|
446
104
|
|
|
447
105
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
448
|
-
// §
|
|
106
|
+
// § 5 App config
|
|
449
107
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
450
108
|
|
|
451
|
-
export
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
export interface WatchOptions<Immediate = boolean> {
|
|
460
|
-
immediate?: Immediate
|
|
461
|
-
deep?: boolean
|
|
462
|
-
once?: boolean
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
type StopHandle = () => void
|
|
466
|
-
type CleanupFn = (fn: () => void) => void
|
|
467
|
-
|
|
468
|
-
function traverse(value: unknown, seen = new Set()): unknown {
|
|
469
|
-
if (!value || typeof value !== 'object' || seen.has(value)) return value
|
|
470
|
-
seen.add(value)
|
|
471
|
-
if (isRef(value)) { traverse(value.value, seen); return value }
|
|
472
|
-
if (Array.isArray(value)) { value.forEach(v => traverse(v, seen)); return value }
|
|
473
|
-
for (const key in value as object) traverse((value as any)[key], seen)
|
|
474
|
-
return value
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function normalizeSource<T>(src: WatchSource<T> | MultiSource): () => any {
|
|
478
|
-
if (Array.isArray(src)) return () => src.map(s => isRef(s) ? s.value : s())
|
|
479
|
-
if (isRef(src)) return () => src.value
|
|
480
|
-
return src as () => T
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
export function watch<T>(
|
|
484
|
-
source: WatchSource<T>,
|
|
485
|
-
cb: (val: T, old: T | undefined, cleanup: CleanupFn) => void,
|
|
486
|
-
opts?: WatchOptions
|
|
487
|
-
): StopHandle
|
|
488
|
-
export function watch<T extends MultiSource>(
|
|
489
|
-
source: T,
|
|
490
|
-
cb: (val: MapSources<T>, old: MapSources<T, true>, cleanup: CleanupFn) => void,
|
|
491
|
-
opts?: WatchOptions
|
|
492
|
-
): StopHandle
|
|
493
|
-
export function watch(source: any, cb: any, opts: WatchOptions = {}): StopHandle {
|
|
494
|
-
const getter = opts.deep
|
|
495
|
-
? () => traverse(normalizeSource(source)())
|
|
496
|
-
: normalizeSource(source)
|
|
497
|
-
|
|
498
|
-
let oldVal: any = undefined
|
|
499
|
-
let cleanupFn: (() => void) | undefined
|
|
500
|
-
|
|
501
|
-
const cleanup: CleanupFn = (fn) => { cleanupFn = fn }
|
|
502
|
-
|
|
503
|
-
const job = () => {
|
|
504
|
-
if (!effect.active) return
|
|
505
|
-
cleanupFn?.(); cleanupFn = undefined
|
|
506
|
-
const newVal = effect.run()
|
|
507
|
-
if (opts.deep || hasChanged(newVal, oldVal)) {
|
|
508
|
-
cb(newVal, oldVal, cleanup)
|
|
509
|
-
oldVal = newVal
|
|
510
|
-
}
|
|
511
|
-
if (opts.once) effect.stop()
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const effect = new ReactiveEffect(getter, job, activeScope)
|
|
515
|
-
|
|
516
|
-
if (opts.immediate) {
|
|
517
|
-
cleanupFn?.(); cleanupFn = undefined
|
|
518
|
-
const val = effect.run()
|
|
519
|
-
cb(val, oldVal, cleanup)
|
|
520
|
-
oldVal = val
|
|
521
|
-
} else {
|
|
522
|
-
oldVal = effect.run()
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
return () => effect.stop()
|
|
109
|
+
export interface AppConfig {
|
|
110
|
+
layout?: LayoutDef
|
|
111
|
+
seo?: SEOMeta
|
|
112
|
+
middleware?: HonoMiddleware[]
|
|
113
|
+
routes: Route[]
|
|
114
|
+
notFound?: Component
|
|
115
|
+
htmlAttrs?: Record<string, string>
|
|
116
|
+
head?: string
|
|
526
117
|
}
|
|
527
118
|
|
|
528
119
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
529
|
-
// §
|
|
530
|
-
// Server: returns plain values. Client: patched by client.ts
|
|
120
|
+
// § 6 Client middleware
|
|
531
121
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
532
122
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// SSR fallbacks — no re-renders needed on server
|
|
540
|
-
export const __hooks: FNetroHooks = {
|
|
541
|
-
useValue: (r) => isRef(r) ? r.value : r(),
|
|
542
|
-
useLocalRef: (init) => ref(init),
|
|
543
|
-
useLocalReactive: (init) => reactive(init),
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Subscribe to a Ref or computed getter inside a JSX component.
|
|
548
|
-
* On the server, returns the current value (no reactivity needed).
|
|
549
|
-
* On the client, re-renders the component whenever the value changes.
|
|
550
|
-
*
|
|
551
|
-
* @example
|
|
552
|
-
* const count = ref(0)
|
|
553
|
-
* function Counter() {
|
|
554
|
-
* const n = use(count)
|
|
555
|
-
* return <button onClick={() => count.value++}>{n}</button>
|
|
556
|
-
* }
|
|
557
|
-
*/
|
|
558
|
-
export function use<T>(source: Ref<T> | (() => T)): T {
|
|
559
|
-
return __hooks.useValue(source)
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Create a component-local reactive Ref.
|
|
564
|
-
* Unlike module-level `ref()`, this is scoped to the component lifecycle.
|
|
565
|
-
*
|
|
566
|
-
* @example
|
|
567
|
-
* function Input() {
|
|
568
|
-
* const text = useLocalRef('')
|
|
569
|
-
* return <input value={use(text)} onInput={e => text.value = e.target.value} />
|
|
570
|
-
* }
|
|
571
|
-
*/
|
|
572
|
-
export function useLocalRef<T>(init: T): Ref<T> {
|
|
573
|
-
return __hooks.useLocalRef(init)
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Create a component-local reactive object.
|
|
578
|
-
* @example
|
|
579
|
-
* function Form() {
|
|
580
|
-
* const form = useLocalReactive({ name: '', email: '' })
|
|
581
|
-
* return <input value={form.name} onInput={e => form.name = e.target.value} />
|
|
582
|
-
* }
|
|
583
|
-
*/
|
|
584
|
-
export function useLocalReactive<T extends object>(init: T): T {
|
|
585
|
-
return __hooks.useLocalReactive(init)
|
|
586
|
-
}
|
|
123
|
+
export type ClientMiddleware = (
|
|
124
|
+
url: string,
|
|
125
|
+
next: () => Promise<void>,
|
|
126
|
+
) => Promise<void>
|
|
587
127
|
|
|
588
128
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
589
|
-
// §
|
|
129
|
+
// § 7 Builder functions
|
|
590
130
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
591
131
|
|
|
592
|
-
export type LoaderCtx = Context
|
|
593
|
-
export type FNetroMiddleware = MiddlewareHandler
|
|
594
|
-
export type AnyJSX = any
|
|
595
|
-
|
|
596
|
-
export interface PageDef<TData extends object = {}> {
|
|
597
|
-
readonly __type: 'page'
|
|
598
|
-
path: string
|
|
599
|
-
/** Middleware applied only to this route */
|
|
600
|
-
middleware?: FNetroMiddleware[]
|
|
601
|
-
/** Server-side data loader. Return value becomes Page props. */
|
|
602
|
-
loader?: (c: LoaderCtx) => TData | Promise<TData>
|
|
603
|
-
/** Override the group/app layout for this page. Pass `false` to use no layout. */
|
|
604
|
-
layout?: LayoutDef | false
|
|
605
|
-
/** The JSX page component */
|
|
606
|
-
Page: (props: TData & { url: string; params: Record<string, string> }) => AnyJSX
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
export interface GroupDef {
|
|
610
|
-
readonly __type: 'group'
|
|
611
|
-
/** URL prefix — e.g. '/admin' */
|
|
612
|
-
prefix: string
|
|
613
|
-
/** Layout override for all pages in this group */
|
|
614
|
-
layout?: LayoutDef | false
|
|
615
|
-
/** Middleware applied to every route in the group */
|
|
616
|
-
middleware?: FNetroMiddleware[]
|
|
617
|
-
/** Pages and nested groups */
|
|
618
|
-
routes: (PageDef<any> | GroupDef | ApiRouteDef)[]
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
export interface LayoutDef {
|
|
622
|
-
readonly __type: 'layout'
|
|
623
|
-
Component: (props: { children: AnyJSX; url: string; params: Record<string, string> }) => AnyJSX
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
export interface ApiRouteDef {
|
|
627
|
-
readonly __type: 'api'
|
|
628
|
-
/** Mount path — e.g. '/api' or '/api/admin' */
|
|
629
|
-
path: string
|
|
630
|
-
/** Register raw Hono routes on the provided sub-app */
|
|
631
|
-
register: (app: Hono, middleware: FNetroMiddleware[]) => void
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
export interface MiddlewareDef {
|
|
635
|
-
readonly __type: 'middleware'
|
|
636
|
-
handler: FNetroMiddleware
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
export interface AppConfig {
|
|
640
|
-
/** Default layout for all pages */
|
|
641
|
-
layout?: LayoutDef
|
|
642
|
-
/** Global middleware applied before every route */
|
|
643
|
-
middleware?: FNetroMiddleware[]
|
|
644
|
-
/** Top-level routes, groups, and API routes */
|
|
645
|
-
routes: (PageDef<any> | GroupDef | ApiRouteDef)[]
|
|
646
|
-
/** 404 page */
|
|
647
|
-
notFound?: () => AnyJSX
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// ── Builder functions ─────────────────────────────────────────────────────────
|
|
651
|
-
|
|
652
132
|
export function definePage<TData extends object = {}>(
|
|
653
|
-
def: Omit<PageDef<TData>, '__type'
|
|
133
|
+
def: Omit<PageDef<TData>, '__type'>,
|
|
654
134
|
): PageDef<TData> {
|
|
655
135
|
return { __type: 'page', ...def }
|
|
656
136
|
}
|
|
657
137
|
|
|
658
|
-
export function defineGroup(
|
|
659
|
-
def: Omit<GroupDef, '__type'>
|
|
660
|
-
): GroupDef {
|
|
138
|
+
export function defineGroup(def: Omit<GroupDef, '__type'>): GroupDef {
|
|
661
139
|
return { __type: 'group', ...def }
|
|
662
140
|
}
|
|
663
141
|
|
|
664
|
-
export function defineLayout(
|
|
665
|
-
Component: LayoutDef['Component']
|
|
666
|
-
): LayoutDef {
|
|
142
|
+
export function defineLayout(Component: Component<LayoutProps>): LayoutDef {
|
|
667
143
|
return { __type: 'layout', Component }
|
|
668
144
|
}
|
|
669
145
|
|
|
670
|
-
export function defineMiddleware(handler: FNetroMiddleware): MiddlewareDef {
|
|
671
|
-
return { __type: 'middleware', handler }
|
|
672
|
-
}
|
|
673
|
-
|
|
674
146
|
export function defineApiRoute(
|
|
675
|
-
path:
|
|
676
|
-
register: ApiRouteDef['register']
|
|
147
|
+
path: string,
|
|
148
|
+
register: ApiRouteDef['register'],
|
|
677
149
|
): ApiRouteDef {
|
|
678
150
|
return { __type: 'api', path, register }
|
|
679
151
|
}
|
|
680
152
|
|
|
681
|
-
//
|
|
153
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
154
|
+
// § 8 Internal route resolution
|
|
155
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
682
156
|
|
|
683
157
|
export interface ResolvedRoute {
|
|
684
|
-
fullPath:
|
|
685
|
-
page:
|
|
686
|
-
layout:
|
|
687
|
-
middleware:
|
|
158
|
+
fullPath: string
|
|
159
|
+
page: PageDef<any>
|
|
160
|
+
layout: LayoutDef | false | undefined
|
|
161
|
+
middleware: HonoMiddleware[]
|
|
688
162
|
}
|
|
689
163
|
|
|
690
164
|
export function resolveRoutes(
|
|
691
|
-
routes:
|
|
165
|
+
routes: Route[],
|
|
692
166
|
options: {
|
|
693
|
-
prefix?:
|
|
694
|
-
middleware?:
|
|
695
|
-
layout?:
|
|
696
|
-
} = {}
|
|
167
|
+
prefix?: string
|
|
168
|
+
middleware?: HonoMiddleware[]
|
|
169
|
+
layout?: LayoutDef | false
|
|
170
|
+
} = {},
|
|
697
171
|
): { pages: ResolvedRoute[]; apis: ApiRouteDef[] } {
|
|
698
172
|
const pages: ResolvedRoute[] = []
|
|
699
|
-
const apis:
|
|
173
|
+
const apis: ApiRouteDef[] = []
|
|
700
174
|
|
|
701
175
|
for (const route of routes) {
|
|
702
176
|
if (route.__type === 'api') {
|
|
703
177
|
apis.push({ ...route, path: (options.prefix ?? '') + route.path })
|
|
704
178
|
} else if (route.__type === 'group') {
|
|
705
179
|
const prefix = (options.prefix ?? '') + route.prefix
|
|
706
|
-
const mw
|
|
180
|
+
const mw = [...(options.middleware ?? []), ...(route.middleware ?? [])]
|
|
707
181
|
const layout = route.layout !== undefined ? route.layout : options.layout
|
|
708
|
-
const sub
|
|
182
|
+
const sub = resolveRoutes(route.routes, { prefix, middleware: mw, layout })
|
|
709
183
|
pages.push(...sub.pages)
|
|
710
184
|
apis.push(...sub.apis)
|
|
711
185
|
} else {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
186
|
+
pages.push({
|
|
187
|
+
fullPath: (options.prefix ?? '') + route.path,
|
|
188
|
+
page: route,
|
|
189
|
+
layout: route.layout !== undefined ? route.layout : options.layout,
|
|
190
|
+
middleware: [...(options.middleware ?? []), ...(route.middleware ?? [])],
|
|
191
|
+
})
|
|
716
192
|
}
|
|
717
193
|
}
|
|
718
194
|
|
|
719
195
|
return { pages, apis }
|
|
720
196
|
}
|
|
721
197
|
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
export const PARAMS_KEY = '__FNETRO_PARAMS__'
|
|
198
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
199
|
+
// § 9 Path matching (used by both server + client)
|
|
200
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
726
201
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
202
|
+
export interface CompiledPath {
|
|
203
|
+
re: RegExp
|
|
204
|
+
keys: string[]
|
|
730
205
|
}
|
|
731
206
|
|
|
732
|
-
function
|
|
733
|
-
|
|
207
|
+
export function compilePath(path: string): CompiledPath {
|
|
208
|
+
const keys: string[] = []
|
|
209
|
+
const src = path
|
|
210
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, (_, k: string) => { keys.push(k); return '(.*)' })
|
|
211
|
+
.replace(/\[([^\]]+)\]/g, (_, k: string) => { keys.push(k); return '([^/]+)' })
|
|
212
|
+
.replace(/\*/g, '(.*)')
|
|
213
|
+
return { re: new RegExp(`^${src}$`), keys }
|
|
734
214
|
}
|
|
215
|
+
|
|
216
|
+
export function matchPath(
|
|
217
|
+
compiled: CompiledPath,
|
|
218
|
+
pathname: string,
|
|
219
|
+
): Record<string, string> | null {
|
|
220
|
+
const m = pathname.match(compiled.re)
|
|
221
|
+
if (!m) return null
|
|
222
|
+
const params: Record<string, string> = {}
|
|
223
|
+
compiled.keys.forEach((k, i) => {
|
|
224
|
+
params[k] = decodeURIComponent(m[i + 1] ?? '')
|
|
225
|
+
})
|
|
226
|
+
return params
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
230
|
+
// § 10 Shared constants
|
|
231
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
232
|
+
|
|
233
|
+
export const SPA_HEADER = 'x-fnetro-spa'
|
|
234
|
+
export const STATE_KEY = '__FNETRO_STATE__'
|
|
235
|
+
export const PARAMS_KEY = '__FNETRO_PARAMS__'
|
|
236
|
+
export const SEO_KEY = '__FNETRO_SEO__'
|