@netrojs/fnetro 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core.ts CHANGED
@@ -1,734 +1,236 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // FNetro · core.ts
3
- // Full Vue-like reactivity + route / layout / middleware definitions
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 Dependency tracking
11
+ // § 1 Primitive aliases
10
12
  // ══════════════════════════════════════════════════════════════════════════════
11
13
 
12
- export type EffectFn = () => void | (() => void)
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
- // § 3 EffectScope
18
+ // § 2 SEO / head metadata
115
19
  // ══════════════════════════════════════════════════════════════════════════════
116
20
 
117
- let activeScope: EffectScope | undefined
118
-
119
- export class EffectScope {
120
- effects: ReactiveEffect[] = []
121
- cleanups: (() => void)[] = []
122
- active = true
123
-
124
- run<T>(fn: () => T): T {
125
- const prev = activeScope
126
- activeScope = this
127
- try { return fn() }
128
- finally { activeScope = prev }
129
- }
130
-
131
- stop() {
132
- if (this.active) {
133
- this.effects.forEach(e => e.stop())
134
- this.cleanups.forEach(fn => fn())
135
- this.active = false
136
- }
137
- }
138
-
139
- onCleanup(fn: () => void) { this.cleanups.push(fn) }
140
- }
141
-
142
- export function effectScope(): EffectScope {
143
- return new EffectScope()
144
- }
145
-
146
- export function getCurrentScope(): EffectScope | undefined {
147
- return activeScope
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
- // § 4 effect / watchEffect
56
+ // § 3 Component prop shapes
156
57
  // ══════════════════════════════════════════════════════════════════════════════
157
58
 
158
- export function effect(fn: EffectFn): () => void {
159
- const e = new ReactiveEffect(fn, undefined, activeScope)
160
- e.run()
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 function computed<T>(getter: () => T): ComputedRef<T>
309
- export function computed<T>(opts: { get: () => T; set: (v: T) => void }): WritableComputedRef<T>
310
- export function computed<T>(arg: (() => T) | { get: () => T; set: (v: T) => void }): ComputedRef<T> {
311
- if (typeof arg === 'function') {
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
- // § 7 Reactive proxy
71
+ // § 4 Route definitions
319
72
  // ══════════════════════════════════════════════════════════════════════════════
320
73
 
321
- const reactiveMap = new WeakMap<object, object>()
322
- const readonlyMap = new WeakMap<object, object>()
323
- const shallowReactiveMap = new WeakMap<object, object>()
324
-
325
- function toReactive<T>(value: T): T {
326
- return value !== null && typeof value === 'object' ? reactive(value as object) as T : value
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 function markRaw<T extends object>(value: T): T {
429
- ;(value as any)[MARK_RAW] = true
430
- return value
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 function toRaw<T>(observed: T): T {
434
- const raw = (observed as any)?.[RAW]
435
- return raw ? toRaw(raw) : observed
92
+ export interface LayoutDef {
93
+ readonly __type: 'layout'
94
+ Component: Component<LayoutProps>
436
95
  }
437
96
 
438
- export function isReactive(value: unknown): boolean {
439
- if (isReadonly(value)) return isReactive((value as any)[RAW])
440
- return !!(value && (value as any)[IS_REACTIVE])
97
+ export interface ApiRouteDef {
98
+ readonly __type: 'api'
99
+ path: string
100
+ register: (app: Hono, globalMiddleware: HonoMiddleware[]) => void
441
101
  }
442
102
 
443
- export function isReadonly(value: unknown): boolean {
444
- return !!(value && (value as any)[IS_READONLY])
445
- }
103
+ export type Route = PageDef<any> | GroupDef | ApiRouteDef
446
104
 
447
105
  // ══════════════════════════════════════════════════════════════════════════════
448
- // § 8 watch
106
+ // § 5 App config
449
107
  // ══════════════════════════════════════════════════════════════════════════════
450
108
 
451
- export type WatchSource<T = unknown> = Ref<T> | ComputedRef<T> | (() => T)
452
- export type MultiSource = WatchSource[] | readonly WatchSource[]
453
- type MapSources<T, Immediate = false> = {
454
- [K in keyof T]: T[K] extends WatchSource<infer V>
455
- ? Immediate extends true ? V | undefined : V
456
- : T[K] extends object ? T[K] : never
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
- // § 9 Component hooks (JSX-aware)
530
- // Server: returns plain values. Client: patched by client.ts
120
+ // § 6 Client middleware
531
121
  // ══════════════════════════════════════════════════════════════════════════════
532
122
 
533
- interface FNetroHooks {
534
- useValue<T>(r: Ref<T> | (() => T)): T
535
- useLocalRef<T>(init: T): Ref<T>
536
- useLocalReactive<T extends object>(init: T): T
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
- // § 10 Route / App definitions
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: string,
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
- // ── Internal route resolution ─────────────────────────────────────────────────
153
+ // ══════════════════════════════════════════════════════════════════════════════
154
+ // § 8 Internal route resolution
155
+ // ══════════════════════════════════════════════════════════════════════════════
682
156
 
683
157
  export interface ResolvedRoute {
684
- fullPath: string
685
- page: PageDef<any>
686
- layout: LayoutDef | false | undefined
687
- middleware: FNetroMiddleware[]
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: (PageDef<any> | GroupDef | ApiRouteDef)[],
165
+ routes: Route[],
692
166
  options: {
693
- prefix?: string
694
- middleware?: FNetroMiddleware[]
695
- layout?: LayoutDef | false
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: ApiRouteDef[] = []
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 = [...(options.middleware ?? []), ...(route.middleware ?? [])]
180
+ const mw = [...(options.middleware ?? []), ...(route.middleware ?? [])]
707
181
  const layout = route.layout !== undefined ? route.layout : options.layout
708
- const sub = resolveRoutes(route.routes, { prefix, middleware: mw, layout })
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
- const fullPath = (options.prefix ?? '') + route.path
713
- const layout = route.layout !== undefined ? route.layout : options.layout
714
- const middleware = [...(options.middleware ?? []), ...(route.middleware ?? [])]
715
- pages.push({ fullPath, page: route, layout, middleware })
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
- // ── Shared constants ──────────────────────────────────────────────────────────
723
- export const SPA_HEADER = 'x-fnetro-spa'
724
- export const STATE_KEY = '__FNETRO_STATE__'
725
- export const PARAMS_KEY = '__FNETRO_PARAMS__'
198
+ // ══════════════════════════════════════════════════════════════════════════════
199
+ // § 9 Path matching (used by both server + client)
200
+ // ══════════════════════════════════════════════════════════════════════════════
726
201
 
727
- // ── Utilities ─────────────────────────────────────────────────────────────────
728
- function hasChanged(a: unknown, b: unknown): boolean {
729
- return !Object.is(a, b)
202
+ export interface CompiledPath {
203
+ re: RegExp
204
+ keys: string[]
730
205
  }
731
206
 
732
- function hasOwn(obj: object, key: string): boolean {
733
- return Object.prototype.hasOwnProperty.call(obj, key)
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__'