@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
110 lines (106 loc) • 4.36 kB
text/typescript
import type { Injector } from '@furystack/inject'
import { LocationService } from '../services/location-service.js'
import type { ExtractRouteHash, ExtractRouteQuery, ExtractRoutePaths, RouteAt } from './nested-route-types.js'
import type { NestedRoute } from './nested-router.js'
/**
* A pair of synchronous read helpers bound to a specific route tree. Use the
* factory {@link createNestedHooks} to produce one.
*/
export type NestedHooks<TRoutes extends Record<string, NestedRoute<any, any, any>>> = {
/**
* Reads and validates the current URL query string against the declared
* validator of the route at `path`. Returns the typed query value, or
* `null` when the route has no validator or the current URL's query does
* not satisfy it.
*
* This is a synchronous snapshot — for reactive use, subscribe to
* `locationService.onDeserializedLocationSearchChanged` and call this read
* on each change.
*/
getTypedQuery: <TPath extends ExtractRoutePaths<TRoutes>>(
injector: Injector,
path: TPath,
) => ExtractRouteQuery<RouteAt<TRoutes, TPath>> | null
/**
* Reads the current URL hash and narrows it against the declared literal
* tuple of the route at `path`. Returns the hash when it matches one of
* the route's declared literals, or `undefined` otherwise.
*
* This is a synchronous snapshot — for reactive use, subscribe to
* `locationService.onLocationHashChanged` and call this read on each change.
*/
getTypedHash: <TPath extends ExtractRoutePaths<TRoutes>>(
injector: Injector,
path: TPath,
) => ExtractRouteHash<RouteAt<TRoutes, TPath>> | undefined
}
/**
* Walks a route tree and returns the route value declared at the given path,
* matching each URL pattern segment-by-segment. The root `/` pattern is
* transparent: its children are searched with the full path.
*
* Exported for unit testing only — consumers should go through
* {@link createNestedHooks}.
*
* @internal
*/
export const walkRoute = (
routes: Record<string, NestedRoute<any, any, any>>,
path: string,
): NestedRoute<any, any, any> | undefined => {
for (const [pattern, route] of Object.entries(routes)) {
if (pattern === path) return route
if (pattern === '/' && route.children) {
const nested = walkRoute(route.children, path)
if (nested) return nested
} else if (route.children && path.startsWith(pattern)) {
const rest = path.slice(pattern.length)
if (rest.startsWith('/') || rest === '') {
const nested = walkRoute(route.children, rest || '/')
if (nested) return nested
}
}
}
return undefined
}
/**
* Creates a pair of type-safe synchronous read helpers (`getTypedQuery`,
* `getTypedHash`) bound to a specific route tree. The returned functions
* validate the current URL's query and hash against the route declared at
* the given path, returning typed values or `null`/`undefined` on mismatch.
*
* The concrete route tree is captured at factory time, so call sites only
* pass `(injector, path)`; the type-level narrowing is derived from the
* inferred tree generic.
*
* @typeParam TRoutes - The route tree type (inferred from the `routes` argument)
*
* @example
* ```typescript
* const { getTypedQuery, getTypedHash } = createNestedHooks(appRoutes)
*
* const query = getTypedQuery(injector, '/users') // typed
* const hash = getTypedHash(injector, '/users') // typed
* ```
*/
export const createNestedHooks = <TRoutes extends Record<string, NestedRoute<any, any, any>>>(
routes: TRoutes,
): NestedHooks<TRoutes> => {
return {
getTypedQuery: (injector, path) => {
const route = walkRoute(routes, path)
if (!route?.query) return null
const locationService = injector.get(LocationService)
const deserialized = locationService.onDeserializedLocationSearchChanged.getValue()
return route.query(deserialized) as never
},
getTypedHash: (injector, path) => {
const route = walkRoute(routes, path)
const declaredHash = route?.hash as readonly string[] | undefined
if (!declaredHash) return undefined
const locationService = injector.get(LocationService)
const currentHash = locationService.onLocationHashChanged.getValue()
return declaredHash.includes(currentHash) ? (currentHash as never) : undefined
},
}
}