UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

661 lines (611 loc) 20.2 kB
import { $inject, type Async, createPrimitive, KIND, type Middleware, OPTIONS, Primitive, type Static, type TSchema, } from "alepha"; import { $cache } from "alepha/cache"; import type { Head } from "alepha/react/head"; import type { ServerRequest } from "alepha/server"; import type { FC, ReactNode } from "react"; import { PAGE_PRELOAD_KEY } from "../constants/PAGE_PRELOAD_KEY.ts"; import type { Redirection } from "../errors/Redirection.ts"; import type { ReactRouterState } from "../providers/ReactPageProvider.ts"; import { ReactPageService } from "../services/ReactPageService.ts"; /** * Main primitive for defining a React route in the application. * * The $page primitive is the core building block for creating type-safe, SSR-enabled React routes. * It provides a declarative way to define pages with powerful features: * * **Routing & Navigation** * - URL pattern matching with parameters (e.g., `/users/:id`) * - Nested routing with parent-child relationships * - Type-safe URL parameter and query string validation * * **Data Loading** * - Server-side data fetching with the `loader` function * - Automatic serialization and hydration for SSR * - Access to request context, URL params, and parent data * * **Component Loading** * - Direct component rendering or lazy loading for code splitting * - Client-only rendering when browser APIs are needed * - Automatic fallback handling during hydration * * **Performance Optimization** * - Static generation for pre-rendered pages at build time * - Server-side caching with configurable TTL and providers * - Code splitting through lazy component loading * * **Error Handling** * - Custom error handlers with support for redirects * - Hierarchical error handling (child → parent) * - HTTP status code handling (404, 401, etc.) * * @example Simple page with data fetching * ```typescript * const userProfile = $page({ * path: "/users/:id", * schema: { * params: z.object({ id: z.integer() }), * query: z.object({ tab: z.text().optional() }) * }, * loader: async ({ params }) => { * const user = await userApi.getUser(params.id); * return { user }; * }, * lazy: () => import("./UserProfile.tsx") * }); * ``` * * @example Nested routing with error handling * ```typescript * const projectSection = $page({ * path: "/projects/:id", * children: () => [projectBoard, projectSettings], * loader: async ({ params }) => { * const project = await projectApi.get(params.id); * return { project }; * }, * errorHandler: (error) => { * if (HttpError.is(error, 404)) { * return <ProjectNotFound />; * } * } * }); * ``` * * @example Static generation with caching * ```typescript * const blogPost = $page({ * path: "/blog/:slug", * static: { * entries: posts.map(p => ({ params: { slug: p.slug } })) * }, * loader: async ({ params }) => { * const post = await loadPost(params.slug); * return { post }; * } * }); * ``` */ export const $page = < TConfig extends PageConfigSchema = PageConfigSchema, TProps extends object = TPropsDefault, TPropsParent extends object = TPropsParentDefault, >( options: PagePrimitiveOptions<TConfig, TProps, TPropsParent>, ): PagePrimitive<TConfig, TProps, TPropsParent> => { return createPrimitive(PagePrimitive<TConfig, TProps, TPropsParent>, options); }; // --------------------------------------------------------------------------------------------------------------------- export interface PagePrimitiveOptions< TConfig extends PageConfigSchema = PageConfigSchema, TProps extends object = TPropsDefault, TPropsParent extends object = TPropsParentDefault, > { /** * Identifier name for the page. Must be unique. * * @default Primitive key */ name?: string; /** * Add a pathname to the page. * * Pathname can contain parameters, like `/post/:slug`. * * @default "" */ path?: string; /** * Add an input schema to define: * - `params`: parameters from the pathname. * - `query`: query parameters from the URL. */ schema?: TConfig; /** * Middleware to apply to the loader function. * Works the same as `use` on `$action` and `$job`. * * @example * ```ts * dashboard = $page({ * use: [$cache({ ttl: [5, "minutes"] })], * loader: async ({ params }) => this.dashboardService.getData(), * lazy: () => import("./Dashboard.tsx"), * }); * ``` */ use?: Middleware[]; /** * Load data before rendering the page. * * This function receives * - the request context (params, query, etc.) * - the parent props (if page has a parent) * * > In SSR, the returned data will be serialized and sent to the client, then reused during the client-side hydration. * * Loader can be stopped by throwing an error, which will be handled by the `errorHandler` function. * It's common to throw a `NotFoundError` to display a 404 page. * * RedirectError can be thrown to redirect the user to another page. */ loader?: (context: PageLoader<TConfig, TPropsParent>) => Async<TProps>; /** * Default props to pass to the component when rendering the page. * * Resolved props from the `resolve` function will override these default props. */ props?: () => Partial<TProps>; /** * The component to render when the page is loaded. * * If `lazy` is defined, this will be ignored. * Prefer using `lazy` to improve the initial loading time. */ component?: FC<TProps & TPropsParent>; /** * Lazy load the component when the page is loaded. * * It's recommended to use this for components to improve the initial loading time * and enable code-splitting. */ lazy?: () => Promise<{ default: FC<TProps & TPropsParent> }>; /** * Attach child pages to create nested routes, adopting them as children of * this page. * * Use this when you want a parent to own children it cannot modify — most * notably pages that come from an injected router in another package, whose * `$page` definitions are frozen and cannot declare `parent` themselves. * * ```ts * layout = $page({ * path: "/app", * children: () => [ * this.productRouter.catalogPage, // from $inject(ProductRouter) * this.productRouter.checkoutPage, * ], * }); * ``` * * Use a thunk (`() => [...]`) when the children are defined later in the * same class. * * **Declare each edge from one side only.** If a child already sets * `parent: thisPage`, do NOT also add it to `children` — the link is * already established, and declaring it on both sides creates a TypeScript * circular dependency between the two class fields (each references the * other before it is initialised). */ children?: Array<PagePrimitive> | (() => Array<PagePrimitive>); /** * Define a parent page for nested routing. * * Use this when you own the child page and can edit its definition — it is * the simplest way to nest routes and reads top-down. For pages you do NOT * own (e.g. pages exposed by an injected router from another package), let * the parent adopt them via its `children` option instead. * * **Declare each edge from one side only.** If you set `parent` here, do * NOT also add this page to the parent's `children` array — the link is * already established, and declaring it on both sides creates a TypeScript * circular dependency between the two class fields. */ parent?: PagePrimitive<PageConfigSchema, TPropsParent, any>; /** * UI-affordance predicate for this page's navigation entry — **NOT security**. * For real access control, gate the route with `use: [$secure({ permissions })]` * (server-enforced); `can` is only consulted by navigation surfaces (sidebar, * breadcrumbs, command palette), never by the router. * * Receives a `{ has }` context (a permission probe equivalent to * `useAuth().has`) so it can express arbitrary access logic — including * OR-of-permissions, which `nav.permission` (AND) cannot. * * - `true` (or omitted) → nav entry visible and enabled. * - `"disabled"` → visible but disabled (greyed — e.g. insufficient role / paywalled). * - `false` → hidden. * * For the common "needs these permissions" case, prefer the declarative * {@link PageNav.permission} instead. */ can?: (ctx: PageCanContext) => boolean | "disabled"; /** * Navigation metadata — declares this page's presence in navigation * surfaces: the sidebar, the breadcrumb trail, and a command palette * (Spotlight). A page WITHOUT `nav` is route-only (reachable by URL but not * listed). * * `$page` is pure React, so `label` / `icon` / `description` / `badge` accept * `ReactNode` (pass `<Users />` directly). * * Visibility is UI-only (NOT security — gate the route with `use: [$secure(...)]`): * an entry is hidden when `nav.hidden`, when `nav.permission` is set and not * fully granted, or when `can()` returns `false`; it renders disabled when * `can()` returns `"disabled"`. */ nav?: PageNav; /** * Catch any error from the `loader` function or during `rendering`. * * Expected to return one of the following: * - a ReactNode to render an error page * - a Redirection to redirect the user * - undefined to let the error propagate * * If not defined, the error will be thrown and handled by the server or client error handler. * If a leaf $page does not define an error handler, the error can be caught by parent pages. * * @example Catch a 404 from API and render a custom not found component: * ```ts * loader: async ({ params, query }) => { * api.fetch("/api/resource", { params, query }); * }, * errorHandler: (error, context) => { * if (HttpError.is(error, 404)) { * return <ResourceNotFound />; * } * } * ``` * * @example Catch an 401 error and redirect the user to the login page: * ```ts * loader: async ({ params, query }) => { * // but the user is not authenticated * api.fetch("/api/resource", { params, query }); * }, * errorHandler: (error, context) => { * if (HttpError.is(error, 401)) { * // throwing a Redirection is also valid! * return new Redirection("/login"); * } * } * ``` */ errorHandler?: ErrorHandler; /** * If true, the page will be considered as a static page, immutable and cacheable. * Replace boolean by an object to define static entries. (e.g. list of params/query) * * Browser-side: it only works with the build pipeline, which can pre-render the page at build time. * * Server-side: It will act as timeless cached page. You can use `cache` to configure the cache behavior. */ static?: | boolean | { entries?: Array<Partial<PageRequestConfig<TConfig>>>; }; /** * Enable or disable server-side rendering for this page. * * - `true` (default): the page component is rendered on the server and * hydrated on the client. * - `false`: the loader still runs on the server (so data is preloaded and * serialized for hydration), but the component is rendered only on the * client. The server emits no HTML for this page. * * **Decided at the leaf, inherited as default by descendants.** * * The effective value is determined by the matched leaf page: walk up the * parent chain and use the nearest explicit `ssr` value. Setting * `ssr: false` on a parent therefore acts as the default for its children; * a child can override with `ssr: true`. * * Skipping rendering while keeping the loader is the recommended strategy * for CPU-constrained server environments (e.g. Cloudflare Workers) and * heavy admin/dashboard views where SSR provides little SEO value. * * @example * ```ts * root = $page({ ssr: false }); // default for children * home = $page({ parent: root, ssr: true }); // overrides → SSR * about = $page({ parent: root }); // inherits → no SSR * ``` * * @default true */ ssr?: boolean; /** * Called before the server response is sent to the client. (server only) */ onServerResponse?: (request: ServerRequest) => unknown; /** * Called when user enters the page. (browser only) * * Useful for browser-only side effects like analytics, scroll management, * or focus handling that don't need to return data to the component. * * @example * ```ts * onEnter: () => { * analytics.trackPageView("/dashboard"); * window.scrollTo(0, 0); * } * ``` */ onEnter?: () => void; /** * Called when user leaves the page. (browser only) */ onLeave?: () => void; /** * @experimental * * Add a css animation when the page is loaded or unloaded. * It uses CSS animations, so you need to define the keyframes in your CSS. * * @example Simple animation name * ```ts * animation: "fadeIn" * ``` * * CSS example: * ```css * @keyframes fadeIn { * from { opacity: 0; } * to { opacity: 1; } * } * ``` * * @example Detailed animation * ```ts * animation: { * enter: { name: "fadeIn", duration: 300 }, * exit: { name: "fadeOut", duration: 200, timing: "ease-in-out" }, * } * ``` * * @example Only exit animation * ```ts * animation: { * exit: "fadeOut" * } * ``` * * @example With custom timing function * ```ts * animation: { * enter: { name: "fadeIn", duration: 300, timing: "cubic-bezier(0.4, 0, 0.2, 1)" }, * exit: { name: "fadeOut", duration: 200, timing: "ease-in-out" }, * } * ``` */ animation?: PageAnimation; /** * Head configuration for the page (title, meta tags, etc.). * * Can be a static object or a function that receives resolved props. * * @example Static head * ```ts * head: { * title: "My Page", * description: "Page description", * } * ``` * * @example Dynamic head based on props * ```ts * head: (props) => ({ * title: props.user.name, * description: `Profile of ${props.user.name}`, * }) * ``` */ head?: Head | ((props: TProps, previous?: Head) => Head); /** * Redirect to another path when this page is matched. * * This is a shorthand for throwing a `Redirection` in the loader. * The redirect is performed before any loader or component rendering. * * @example * ```ts * home = $page({ * path: "/", * redirect: "/dashboard", * }); * ``` */ redirect?: string; /** * Label for the page, used for navigation menus or breadcrumbs. * * This is optional and can be used by the application to display a user-friendly name for the page. * It has no functional impact on routing or rendering. */ label?: string; /** * Source path for SSR module preloading. * * This is automatically injected by the viteAlephaPreload plugin. * It maps to the source file path used in Vite's SSR manifest. * * @internal */ [PAGE_PRELOAD_KEY]?: string; } // --------------------------------------------------------------------------------------------------------------------- export class PagePrimitive< TConfig extends PageConfigSchema = PageConfigSchema, TProps extends object = TPropsDefault, TPropsParent extends object = TPropsParentDefault, > extends Primitive<PagePrimitiveOptions<TConfig, TProps, TPropsParent>> { protected readonly reactPageService = $inject(ReactPageService); protected onInit() { if (this.options.static) { this.options.use ??= []; if (!this.options.use.some((m) => m[OPTIONS]?.name === "$cache")) { this.options.use.push( $cache({ name: `page:${this.name}`, provider: "memory", ttl: [1, "week"], }), ); } } } public get name(): string { return this.options.name ?? this.config.propertyKey; } /** * For testing or build purposes. * * This will render the page (HTML layout included or not) and return the HTML + context. * Only valid for server-side rendering, it will throw an error if called on the client-side. */ public async render( options?: PagePrimitiveRenderOptions, ): Promise<PagePrimitiveRenderResult> { return this.reactPageService.render(this.name, options); } public async fetch(options?: PagePrimitiveRenderOptions): Promise<{ html: string; response: Response; }> { return this.reactPageService.fetch(this.options.path || "", options); } } $page[KIND] = PagePrimitive; // --------------------------------------------------------------------------------------------------------------------- /** * Context passed to {@link PagePrimitiveOptions.can} — a permission probe * (equivalent to `useAuth().has`) supplied by the navigation builder. */ export interface PageCanContext { has: (permission: string) => boolean; } /** * Navigation metadata for a page — see {@link PagePrimitiveOptions.nav}. * Consumed by the sidebar, breadcrumbs and the command palette, all derived * from the route tree (no separate nav list). */ export interface PageNav { /** * Label for nav surfaces. Falls back to the page's `label`, then `head.title`. */ label?: ReactNode; /** * Leading icon — a React element, e.g. `icon: <Users />`. */ icon?: ReactNode; /** * Section the entry belongs to: sidebar group heading + command-palette section. */ group?: string; /** * Sort order within the group (ascending). */ order?: number; /** * Permission(s) required to SEE the entry — a single string or an array * (ALL required, matching `$secure({ permissions })`). UI gate only; the * real route gate is `use: [$secure(...)]`. For OR / custom logic use `can`. */ permission?: string | string[]; /** * Extra search terms for the command palette (synonyms / aliases beyond the label). */ keywords?: string[]; /** * Short description — command-palette subtitle / sidebar tooltip. */ description?: ReactNode; /** * Trailing badge (unread count, "New", …) for the sidebar entry. */ badge?: ReactNode; /** * Keep the route but exclude it from nav surfaces (e.g. a `/users/:id` detail page). */ hidden?: boolean; } export type ErrorHandler = ( error: Error, state: ReactRouterState, ) => ReactNode | Redirection | undefined; export interface PageConfigSchema { query?: TSchema; params?: TSchema; } export type TPropsDefault = any; export type TPropsParentDefault = {}; export interface PagePrimitiveRenderOptions { params?: Record<string, string>; query?: Record<string, string>; /** * If true, the HTML layout will be included in the response. * If false, only the page content will be returned. * * @default true */ html?: boolean; hydration?: boolean; } export interface PagePrimitiveRenderResult { html: string; state: ReactRouterState; redirect?: string; } export interface PageRequestConfig< TConfig extends PageConfigSchema = PageConfigSchema, > { params: TConfig["params"] extends TSchema ? Static<TConfig["params"]> : Record<string, string>; query: TConfig["query"] extends TSchema ? Static<TConfig["query"]> : Record<string, string>; } export type PageLoader< TConfig extends PageConfigSchema = PageConfigSchema, TPropsParent extends object = TPropsParentDefault, > = PageRequestConfig<TConfig> & TPropsParent & Omit<ReactRouterState, "layers" | "onError">; export type PageAnimation = | PageAnimationObject | ((state: ReactRouterState) => PageAnimationObject | undefined); type PageAnimationObject = | CssAnimationName | { enter?: CssAnimation | CssAnimationName; exit?: CssAnimation | CssAnimationName; }; type CssAnimationName = string; type CssAnimation = { name: string; duration?: number; timing?: string; };