first commit
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @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="<name>"
|
||||
*/
|
||||
|
||||
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/<name>.wrn`); a page picks one via `layout`. */
|
||||
layouts: ComponentRef[];
|
||||
/** Validation schemas (`app/schemas/<name>.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<Router, "api" | "realtime"> {
|
||||
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<string, ComponentRef>();
|
||||
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/<name>.wrn. A page selects one with its
|
||||
// `layout` export; the layout wraps the page body via <slot>.
|
||||
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/<name>.{ts,js}. Imported by API routes and
|
||||
// referenced by forms via `data-schema="<name>"`.
|
||||
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 };
|
||||
Reference in New Issue
Block a user