first commit
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
# @wrnexus/router
|
||||
|
||||
> File-based router that maps an `app/` directory onto route tables and matches request paths against them.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/router` scans an application's `app/` directory once at startup and builds route tables for pages, API endpoints, realtime channels, middleware, server-rendered `.wrn` components, layouts, and validation schemas. It also compiles URL patterns (`/users/[id]`) into RegExps and matches request paths against them. Request input is never turned into a file path, which makes the router immune to path traversal. This is a server-side package used by the WrNexus runtime to resolve incoming requests, plus a codegen helper for compile-time typed links.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/router
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## Directory conventions
|
||||
|
||||
The router maps files under `appDir` onto routes:
|
||||
|
||||
```
|
||||
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 (rt) -> embedded /realtime/* routes
|
||||
app/middleware/*.ts -> global middleware (alphabetical)
|
||||
app/components/*.wrn -> server-rendered components (by basename)
|
||||
app/layouts/*.wrn -> named page layouts
|
||||
app/schemas/*.ts -> validation schemas
|
||||
```
|
||||
|
||||
Allowed route extensions are `.ts`, `.tsx`, and `.wrn`. Dotfiles and underscore-prefixed files are ignored. A trailing `index` segment is dropped from the route. `.wrn` pages may embed `api` and `realtime` blocks, which the router extracts and mounts under `/api/*` and `/realtime/*`.
|
||||
|
||||
## API
|
||||
|
||||
### `buildRouter(appDir, opts?): Router`
|
||||
|
||||
Scan an app directory and build all route tables.
|
||||
|
||||
```ts
|
||||
function buildRouter(appDir: string, opts?: RouterOptions): Router;
|
||||
|
||||
interface RouterOptions {
|
||||
/** Extra dirs scanned for `.wrn` components (e.g. `@wrnexus/ui`), before
|
||||
* `app/components`, so an app component of the same name wins. */
|
||||
componentDirs?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
The returned `Router` exposes the built tables plus per-kind matchers:
|
||||
|
||||
```ts
|
||||
interface Router {
|
||||
pages: Route[];
|
||||
api: Route[];
|
||||
realtime: Route[];
|
||||
/** Absolute paths of middleware modules, in execution order (alphabetical). */
|
||||
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;
|
||||
}
|
||||
|
||||
interface ComponentRef {
|
||||
/** Validated component name (matches a `data-component` attribute). */
|
||||
name: string;
|
||||
/** Absolute path to the component's `.wrn` module. */
|
||||
file: string;
|
||||
}
|
||||
```
|
||||
|
||||
Component, layout, and schema names are validated with `isSafeIslandName` from `@wrnexus/core`; unsafe names are skipped with a warning. Realtime channel names are validated the same way.
|
||||
|
||||
### Route matching
|
||||
|
||||
| Export | Signature | Description |
|
||||
| --------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `compileRoutePattern` | `(raw: string) => Pick<Route, "regex" \| "paramNames">` | Compile a `/users/[id]` pattern into a RegExp (with optional trailing slash) plus ordered param names. |
|
||||
| `matchRoute` | `(routes: Route[], pathname: string) => RouteMatch \| null` | Return the first route whose regex matches; captured params are `decodeURIComponent`-decoded. |
|
||||
| `sortRoutes` | `(routes: Route[]) => Route[]` | Order routes so static routes win over dynamic ones (fewer params first), then longer/more specific patterns first. |
|
||||
|
||||
```ts
|
||||
interface Route {
|
||||
raw: string; // e.g. "/users/[id]"
|
||||
file: string; // absolute path to the handling module
|
||||
regex: RegExp; // compiled matcher
|
||||
paramNames: string[]; // ordered dynamic param names
|
||||
}
|
||||
|
||||
interface RouteMatch {
|
||||
route: Route;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
### Typed-routes codegen
|
||||
|
||||
```ts
|
||||
function generateRoutesFile(pages: Route[]): string;
|
||||
```
|
||||
|
||||
Emits the source for `app/routes.gen.ts`: a `Routes` map (each page path → its `[param]` types), a `RoutePath` union, and an `href()` builder that fills params and rejects unknown paths at compile time. Entries are de-duplicated and sorted by path.
|
||||
|
||||
### Re-exports
|
||||
|
||||
`Middleware` (the type from `@wrnexus/core`) is re-exported for callers that load middleware modules themselves.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { buildRouter } from "@wrnexus/router";
|
||||
|
||||
const router = buildRouter("./app", {
|
||||
componentDirs: ["./node_modules/@wrnexus/ui/components"],
|
||||
});
|
||||
|
||||
// Resolve an incoming request.
|
||||
const match = router.matchPage("/users/42");
|
||||
if (match) {
|
||||
console.log(match.route.file); // absolute path to the page module
|
||||
console.log(match.params); // { id: "42" }
|
||||
}
|
||||
|
||||
const api = router.matchApi("/api/hello");
|
||||
const rt = router.matchRealtime("/realtime/chat");
|
||||
```
|
||||
|
||||
Generating the typed-routes file (as `wrnexus dev` does):
|
||||
|
||||
```ts
|
||||
import { generateRoutesFile } from "@wrnexus/router";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const router = buildRouter("./app");
|
||||
writeFileSync("./app/routes.gen.ts", generateRoutesFile(router.pages));
|
||||
```
|
||||
|
||||
```ts
|
||||
// Then, in app code, links are checked at compile time:
|
||||
import { href } from "./routes.gen.ts";
|
||||
|
||||
href("/users/[id]", { id: "42" }); // "/users/42"
|
||||
href("/about"); // "/about"
|
||||
href("/nope"); // type error: unknown path
|
||||
```
|
||||
|
||||
Lower-level pattern matching, if you need it directly:
|
||||
|
||||
```ts
|
||||
import { compileRoutePattern, matchRoute, sortRoutes, type Route } from "@wrnexus/router";
|
||||
|
||||
const { regex, paramNames } = compileRoutePattern("/posts/[slug]");
|
||||
const routes = sortRoutes([{ raw: "/posts/[slug]", file: "…", regex, paramNames }]);
|
||||
const m = matchRoute(routes, "/posts/hello"); // { route, params: { slug: "hello" } }
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Scanning uses `node:fs` (`existsSync`, `readdirSync`, `statSync`) and `node:path` — runs under Bun.
|
||||
- Depends on [`@wrnexus/compiler`](../compiler) to `parse` `.wrn` pages and extract embedded `api` / `realtime` blocks.
|
||||
- Depends on [`@wrnexus/core`](../core) for `isSafeIslandName` (name validation) and the `Middleware` type.
|
||||
- Missing route directories are tolerated — a route kind you don't use simply yields an empty table.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Route compilation + matching.
|
||||
*
|
||||
* A "route" is a URL pattern compiled to a RegExp. We support static segments
|
||||
* and dynamic `[param]` segments, e.g. `/users/[id]` -> `{ id }`.
|
||||
*/
|
||||
|
||||
export interface Route {
|
||||
/** The human-readable route pattern, e.g. `/users/[id]`. */
|
||||
raw: string;
|
||||
/** Absolute path to the module that handles this route. */
|
||||
file: string;
|
||||
/** Compiled matcher. */
|
||||
regex: RegExp;
|
||||
/** Ordered names of dynamic params captured by `regex`. */
|
||||
paramNames: string[];
|
||||
}
|
||||
|
||||
export interface RouteMatch {
|
||||
route: Route;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
/** Compile a `/users/[id]` style pattern into a RegExp + param names. */
|
||||
export function compileRoutePattern(raw: string): Pick<Route, "regex" | "paramNames"> {
|
||||
if (raw === "/") {
|
||||
return { regex: /^\/$/, paramNames: [] };
|
||||
}
|
||||
|
||||
const paramNames: string[] = [];
|
||||
const parts = raw
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
const dynamic = segment.match(/^\[(.+)\]$/);
|
||||
if (dynamic) {
|
||||
paramNames.push(dynamic[1]!);
|
||||
return "([^/]+)";
|
||||
}
|
||||
return segment.replace(ESCAPE_RE, "\\$&");
|
||||
});
|
||||
|
||||
// Allow an optional trailing slash.
|
||||
const regex = new RegExp("^/" + parts.join("/") + "/?$");
|
||||
return { regex, paramNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Order routes so that static routes win over dynamic ones, and longer/more
|
||||
* specific routes win over shorter ones. Sorting once keeps matching simple.
|
||||
*/
|
||||
export function sortRoutes(routes: Route[]): Route[] {
|
||||
return [...routes].sort((a, b) => {
|
||||
if (a.paramNames.length !== b.paramNames.length) {
|
||||
return a.paramNames.length - b.paramNames.length; // fewer params first
|
||||
}
|
||||
return b.raw.length - a.raw.length; // longer/more specific first
|
||||
});
|
||||
}
|
||||
|
||||
/** Find the first route whose pattern matches `pathname`. */
|
||||
export function matchRoute(routes: Route[], pathname: string): RouteMatch | null {
|
||||
for (const route of routes) {
|
||||
const m = route.regex.exec(pathname);
|
||||
if (!m) continue;
|
||||
const params: Record<string, string> = {};
|
||||
try {
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = decodeURIComponent(m[i + 1]!);
|
||||
});
|
||||
} catch {
|
||||
// A malformed percent-encoded path is not a valid route match. Treat it
|
||||
// as a 404 instead of allowing decodeURIComponent to become a 500.
|
||||
return null;
|
||||
}
|
||||
return { route, params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Typed-routes codegen. From the scanned page routes, emit `app/routes.gen.ts`
|
||||
* with a `Routes` map (path → param types) and an `href()` builder — so links
|
||||
* are checked at compile time (unknown path or missing param = type error).
|
||||
*/
|
||||
|
||||
import type { Route } from "./match.ts";
|
||||
|
||||
export function generateRoutesFile(pages: Route[]): string {
|
||||
const seen = new Set<string>();
|
||||
const entries: string[] = [];
|
||||
for (const page of [...pages].sort((a, b) => a.raw.localeCompare(b.raw))) {
|
||||
if (seen.has(page.raw)) continue;
|
||||
seen.add(page.raw);
|
||||
const type = page.paramNames.length
|
||||
? `{ ${page.paramNames.map((n) => `${JSON.stringify(n)}: string`).join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
entries.push(` ${JSON.stringify(page.raw)}: ${type};`);
|
||||
}
|
||||
|
||||
return `// AUTO-GENERATED by \`wrnexus dev\` — do not edit.
|
||||
// Typed routes: a compile-time map of every page path to its [param] types,
|
||||
// plus an href() builder that fills params and rejects unknown paths.
|
||||
|
||||
export interface Routes {
|
||||
${entries.join("\n") || " [path: string]: Record<string, string>;"}
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: Routes[P] extends Record<string, never> ? [] : [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, string>;
|
||||
return String(path)
|
||||
.split("/")
|
||||
.map((seg) =>
|
||||
seg.startsWith("[") && seg.endsWith("]")
|
||||
? encodeURIComponent(params[seg.slice(1, -1)] ?? "")
|
||||
: seg,
|
||||
)
|
||||
.join("/");
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Filesystem scanning for the file-based router.
|
||||
*
|
||||
* Scanning happens once at startup. We build a table of routes from the files
|
||||
* that exist on disk; request paths are later matched against that table.
|
||||
* Crucially, request input is NEVER turned into a file path — this is what
|
||||
* makes the router immune to path-traversal.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
/** Extensions we are willing to load as route modules. */
|
||||
const ALLOWED_EXTENSIONS = [".ts", ".tsx", ".wrn"] as const;
|
||||
|
||||
export interface ScannedFile {
|
||||
/** Absolute path to the file on disk. */
|
||||
file: string;
|
||||
/** Path relative to the scanned base directory, using forward slashes. */
|
||||
rel: string;
|
||||
}
|
||||
|
||||
function hasAllowedExtension(name: string): boolean {
|
||||
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext));
|
||||
}
|
||||
|
||||
/** Hidden files/dirs (dotfiles) and underscore-prefixed files are ignored. */
|
||||
function isIgnored(name: string): boolean {
|
||||
return name.startsWith(".") || name.startsWith("_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect allowed route files under `baseDir`.
|
||||
* Returns [] if the directory does not exist (a route kind may be unused).
|
||||
*/
|
||||
export function scanDir(baseDir: string): ScannedFile[] {
|
||||
if (!existsSync(baseDir)) return [];
|
||||
|
||||
const out: ScannedFile[] = [];
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (isIgnored(entry)) continue;
|
||||
const abs = join(dir, entry);
|
||||
const stats = statSync(abs);
|
||||
if (stats.isDirectory()) {
|
||||
walk(abs);
|
||||
} else if (stats.isFile() && hasAllowedExtension(entry)) {
|
||||
out.push({
|
||||
file: abs,
|
||||
rel: relative(baseDir, abs).split(sep).join("/"),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(baseDir);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { compileRoutePattern, generateRoutesFile, matchRoute, type Route } from "../src/index.ts";
|
||||
|
||||
function route(raw: string): Route {
|
||||
return { raw, file: raw, ...compileRoutePattern(raw) };
|
||||
}
|
||||
|
||||
test("generateRoutesFile emits a Routes map with param types", () => {
|
||||
const code = generateRoutesFile([route("/"), route("/about"), route("/users/[id]")]);
|
||||
expect(code).toContain('"/": Record<string, never>;');
|
||||
expect(code).toContain('"/about": Record<string, never>;');
|
||||
expect(code).toContain('"/users/[id]": { "id": string };');
|
||||
expect(code).toContain("export function href");
|
||||
});
|
||||
|
||||
test("generated href() fills params and leaves static paths intact", async () => {
|
||||
// Compile + import the generated module to exercise href() for real.
|
||||
const code = generateRoutesFile([
|
||||
route("/"),
|
||||
route("/users/[id]"),
|
||||
route("/org/[org]/team/[team]"),
|
||||
]);
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { writeFileSync, mkdirSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { pathToFileURL } = await import("node:url");
|
||||
const dir = join(tmpdir(), "wire-routes-test");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, `r${Date.now()}.ts`);
|
||||
writeFileSync(file, code);
|
||||
const mod = (await import(pathToFileURL(file).href)) as {
|
||||
href: (p: string, params?: Record<string, string>) => string;
|
||||
};
|
||||
expect(mod.href("/")).toBe("/");
|
||||
expect(mod.href("/users/[id]", { id: "42" })).toBe("/users/42");
|
||||
expect(mod.href("/org/[org]/team/[team]", { org: "acme", team: "core" })).toBe(
|
||||
"/org/acme/team/core",
|
||||
);
|
||||
expect(mod.href("/users/[id]", { id: "a b" })).toBe("/users/a%20b"); // encoded
|
||||
});
|
||||
|
||||
test("malformed encoded route params return no match instead of throwing", () => {
|
||||
expect(matchRoute([route("/users/[id]")], "/users/%E0%A4%A")).toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user