/** * @wrnexus/router — file-based router. * * Maps the `app/` directory onto route tables: * app/pages/index.tsx -> GET / * app/pages/about.tsx -> GET /about * app/pages/users/[id].tsx-> GET /users/:id * app/api/hello.ts -> /api/hello * app/realtime/chat.ts -> /realtime/chat * app/pages/*.wrn api -> embedded /api/* routes * app/pages/*.wrn realtime -> embedded /realtime/* routes * app/middleware/*.ts -> global middleware (alphabetical) * app/components/*.wrn -> server-rendered components (by basename), * mounted in a page via data-component="" */ import { readFileSync } from "node:fs"; import { join, basename } from "node:path"; import { parse } from "@wrnexus/compiler"; import { isSafeIslandName, type Middleware } from "@wrnexus/core"; import { scanDir, type ScannedFile } from "./scan.ts"; import { compileRoutePattern, matchRoute, sortRoutes, type Route, type RouteMatch, } from "./match.ts"; export type { Route, RouteMatch } from "./match.ts"; export { compileRoutePattern, matchRoute, sortRoutes } from "./match.ts"; export { generateRoutesFile } from "./routes-gen.ts"; export interface ComponentRef { /** Validated component name (matches a `data-component` attribute). */ name: string; /** Absolute path to the component's `.wrn` module. */ file: string; } export interface Router { pages: Route[]; api: Route[]; realtime: Route[]; /** Absolute paths of middleware modules, in execution order. */ middlewareFiles: string[]; /** Server-rendered `.wrn` components, mounted via `data-component`. */ components: ComponentRef[]; /** Named page layouts (`app/layouts/.wrn`); a page picks one via `layout`. */ layouts: ComponentRef[]; /** Validation schemas (`app/schemas/.ts`) shared by API + forms. */ schemas: ComponentRef[]; matchPage(pathname: string): RouteMatch | null; matchApi(pathname: string): RouteMatch | null; matchRealtime(pathname: string): RouteMatch | null; } export interface RouterOptions { /** * Extra directories to scan for `.wrn` components (e.g. `@wrnexus/ui`). * Scanned before `app/components`, so an app component of the same name wins. */ componentDirs?: string[]; } /** * Convert a scanned file's relative path into a URL route pattern. * - strips the extension * - drops a trailing `index` segment * - prefixes with `prefix` (e.g. "/api") */ function fileToRoute(rel: string, prefix: string): string { const withoutExt = rel.replace(/\.(tsx|ts|wrn)$/, ""); const segments = withoutExt.split("/").filter(Boolean); if (segments[segments.length - 1] === "index") segments.pop(); const tail = segments.join("/"); const route = prefix + (tail ? "/" + tail : ""); return route === "" ? "/" : route; } function buildRoutes(files: ScannedFile[], prefix: string): Route[] { const routes = files.map((f): Route => { const raw = fileToRoute(f.rel, prefix); const { regex, paramNames } = compileRoutePattern(raw); return { raw, file: f.file, regex, paramNames }; }); return sortRoutes(routes); } function normalizeEmbeddedApiPath(path: string): string { if (path === "/api" || path.startsWith("/api/")) return path; return `/api${path.startsWith("/") ? path : `/${path}`}`; } function routeFromRaw(raw: string, file: string): Route { const { regex, paramNames } = compileRoutePattern(raw); return { raw, file, regex, paramNames }; } function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick { const api: Route[] = []; const realtime: Route[] = []; for (const file of pageFiles) { if (!file.file.endsWith(".wrn")) continue; const ast = parse(readFileSync(file.file, "utf8")); for (const block of ast.apis) { api.push(routeFromRaw(normalizeEmbeddedApiPath(block.path), file.file)); } for (const block of ast.realtimes) { if (!isSafeIslandName(block.name)) { console.warn(`[wrnexus] skipping realtime route with unsafe name: ${block.name}`); continue; } realtime.push(routeFromRaw(`/realtime/${block.name}`, file.file)); } } return { api, realtime }; } /** Scan an app directory and build all route tables. */ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { const pageFiles = scanDir(join(appDir, "pages")); const embedded = embeddedWireRoutes(pageFiles); const pages = buildRoutes(pageFiles, ""); const api = sortRoutes([...buildRoutes(scanDir(join(appDir, "api")), "/api"), ...embedded.api]); const realtime = sortRoutes([ ...buildRoutes(scanDir(join(appDir, "realtime")), "/realtime"), ...embedded.realtime, ]); // Middleware runs in deterministic (alphabetical) order. const middlewareFiles = scanDir(join(appDir, "middleware")) .map((f) => f.file) .sort(); // Components: `.wrn` files, keyed by name so later scans override earlier // ones. Library dirs (e.g. @wrnexus/ui) are scanned first; app/components last, // so an app component of the same name shadows the library's. const componentMap = new Map(); const scanComponents = (dir: string): void => { for (const f of scanDir(dir)) { if (!f.file.endsWith(".wrn")) continue; const name = basename(f.file).replace(/\.wrn$/, ""); if (!isSafeIslandName(name)) { console.warn(`[wrnexus] skipping component with unsafe name: ${name}`); continue; } componentMap.set(name, { name, file: f.file }); } }; for (const dir of opts.componentDirs ?? []) scanComponents(dir); scanComponents(join(appDir, "components")); const components = [...componentMap.values()]; // Named page layouts: app/layouts/.wrn. A page selects one with its // `layout` export; the layout wraps the page body via . const layouts: ComponentRef[] = []; for (const f of scanDir(join(appDir, "layouts"))) { if (!f.file.endsWith(".wrn")) continue; const name = basename(f.file).replace(/\.wrn$/, ""); if (!isSafeIslandName(name)) { console.warn(`[wrnexus] skipping layout with unsafe name: ${name}`); continue; } layouts.push({ name, file: f.file }); } // Validation schemas: app/schemas/.{ts,js}. Imported by API routes and // referenced by forms via `data-schema=""`. const schemas: ComponentRef[] = []; for (const f of scanDir(join(appDir, "schemas"))) { if (!/\.(ts|js)$/.test(f.file)) continue; const name = basename(f.file).replace(/\.(ts|js)$/, ""); if (!isSafeIslandName(name)) { console.warn(`[wrnexus] skipping schema with unsafe name: ${name}`); continue; } schemas.push({ name, file: f.file }); } return { pages, api, realtime, middlewareFiles, components, layouts, schemas, matchPage: (p) => matchRoute(pages, p), matchApi: (p) => matchRoute(api, p), matchRealtime: (p) => matchRoute(realtime, p), }; } /** Re-export for callers that load middleware modules themselves. */ export type { Middleware };