release: WRNexusJS 0.3.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -23,12 +23,19 @@ import {
|
||||
compileRoutePattern,
|
||||
matchRoute,
|
||||
sortRoutes,
|
||||
findRouteConflicts,
|
||||
type Route,
|
||||
type RouteMatch,
|
||||
} from "./match.ts";
|
||||
|
||||
export type { Route, RouteMatch } from "./match.ts";
|
||||
export { compileRoutePattern, matchRoute, sortRoutes } from "./match.ts";
|
||||
export {
|
||||
compileRoutePattern,
|
||||
getRouteParams,
|
||||
matchRoute,
|
||||
sortRoutes,
|
||||
findRouteConflicts,
|
||||
} from "./match.ts";
|
||||
export { generateRoutesFile } from "./routes-gen.ts";
|
||||
|
||||
export interface ComponentRef {
|
||||
@@ -69,10 +76,13 @@ export interface RouterOptions {
|
||||
* - drops a trailing `index` segment
|
||||
* - prefixes with `prefix` (e.g. "/api")
|
||||
*/
|
||||
function fileToRoute(rel: string, prefix: string): string {
|
||||
export function fileToRoute(rel: string, prefix = ""): string {
|
||||
const withoutExt = rel.replace(/\.(tsx|ts|wrn)$/, "");
|
||||
const segments = withoutExt.split("/").filter(Boolean);
|
||||
if (segments[segments.length - 1] === "index") segments.pop();
|
||||
const segments = withoutExt
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")));
|
||||
if (["index", "page"].includes(segments[segments.length - 1] ?? "")) segments.pop();
|
||||
const tail = segments.join("/");
|
||||
const route = prefix + (tail ? "/" + tail : "");
|
||||
return route === "" ? "/" : route;
|
||||
@@ -81,8 +91,8 @@ function fileToRoute(rel: string, prefix: string): string {
|
||||
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 };
|
||||
const { regex, paramNames, paramMeta } = compileRoutePattern(raw);
|
||||
return { raw, file: f.file, regex, paramNames, paramMeta };
|
||||
});
|
||||
return sortRoutes(routes);
|
||||
}
|
||||
@@ -93,8 +103,8 @@ function normalizeEmbeddedApiPath(path: string): string {
|
||||
}
|
||||
|
||||
function routeFromRaw(raw: string, file: string): Route {
|
||||
const { regex, paramNames } = compileRoutePattern(raw);
|
||||
return { raw, file, regex, paramNames };
|
||||
const { regex, paramNames, paramMeta } = compileRoutePattern(raw);
|
||||
return { raw, file, regex, paramNames, paramMeta };
|
||||
}
|
||||
|
||||
function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick<Router, "api" | "realtime"> {
|
||||
@@ -121,6 +131,14 @@ function embeddedWireRoutes(pageFiles: ScannedFile[]): Pick<Router, "api" | "rea
|
||||
return { api, realtime };
|
||||
}
|
||||
|
||||
function warnRouteConflicts(kind: string, routes: Route[]): void {
|
||||
for (const conflict of findRouteConflicts(routes)) {
|
||||
console.warn(
|
||||
`[wrnexus] WRN-ROUTE-CONFLICT: duplicate ${kind} route '${conflict.raw}' in ${conflict.files.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan an app directory and build all route tables. */
|
||||
export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
|
||||
const pageFiles = scanDir(join(appDir, "pages"));
|
||||
@@ -132,6 +150,10 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
|
||||
...embedded.realtime,
|
||||
]);
|
||||
|
||||
warnRouteConflicts("page", pages);
|
||||
warnRouteConflicts("API", api);
|
||||
warnRouteConflicts("realtime", realtime);
|
||||
|
||||
// Middleware runs in deterministic (alphabetical) order.
|
||||
const middlewareFiles = scanDir(join(appDir, "middleware"))
|
||||
.map((f) => f.file)
|
||||
|
||||
+126
-28
@@ -1,10 +1,20 @@
|
||||
/**
|
||||
* 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 }`.
|
||||
* Supported segments:
|
||||
* [id] required parameter
|
||||
* [id?] optional parameter
|
||||
* [[id]] optional parameter (directory-friendly form)
|
||||
* [...slug] required catch-all
|
||||
* [[...slug]] optional catch-all
|
||||
*/
|
||||
|
||||
export interface RouteParam {
|
||||
name: string;
|
||||
optional: boolean;
|
||||
catchAll: boolean;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
/** The human-readable route pattern, e.g. `/users/[id]`. */
|
||||
raw: string;
|
||||
@@ -14,6 +24,8 @@ export interface Route {
|
||||
regex: RegExp;
|
||||
/** Ordered names of dynamic params captured by `regex`. */
|
||||
paramNames: string[];
|
||||
/** Rich parameter metadata. Optional for compatibility with old manifests. */
|
||||
paramMeta?: RouteParam[];
|
||||
}
|
||||
|
||||
export interface RouteMatch {
|
||||
@@ -23,52 +35,138 @@ export interface RouteMatch {
|
||||
|
||||
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: [] };
|
||||
function parseParamSegment(segment: string): RouteParam | null {
|
||||
let inner: string | null = null;
|
||||
let optional = false;
|
||||
|
||||
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||
inner = segment.slice(2, -2);
|
||||
optional = true;
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
inner = segment.slice(1, -1);
|
||||
if (inner.endsWith("?")) {
|
||||
optional = true;
|
||||
inner = inner.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
const paramNames: string[] = [];
|
||||
const parts = raw
|
||||
if (inner === null) return null;
|
||||
const catchAll = inner.startsWith("...");
|
||||
const name = catchAll ? inner.slice(3) : inner;
|
||||
if (!/^[A-Za-z_$][\w$-]*$/.test(name)) {
|
||||
throw new Error(`WRN-ROUTE-PARAM: Invalid route parameter '${segment}'.`);
|
||||
}
|
||||
return { name, optional, catchAll };
|
||||
}
|
||||
|
||||
/** Return parameter metadata without requiring callers to inspect the regex. */
|
||||
export function getRouteParams(raw: string): RouteParam[] {
|
||||
return raw
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
const dynamic = segment.match(/^\[(.+)\]$/);
|
||||
if (dynamic) {
|
||||
paramNames.push(dynamic[1]!);
|
||||
return "([^/]+)";
|
||||
}
|
||||
return segment.replace(ESCAPE_RE, "\\$&");
|
||||
});
|
||||
.map(parseParamSegment)
|
||||
.filter((value): value is RouteParam => value !== null);
|
||||
}
|
||||
|
||||
// Allow an optional trailing slash.
|
||||
const regex = new RegExp("^/" + parts.join("/") + "/?$");
|
||||
return { regex, paramNames };
|
||||
/** Compile a WRNexus route pattern into a RegExp + parameter metadata. */
|
||||
export function compileRoutePattern(
|
||||
raw: string,
|
||||
): Pick<Route, "regex" | "paramNames" | "paramMeta"> {
|
||||
if (raw === "/") {
|
||||
return { regex: /^\/$/, paramNames: [], paramMeta: [] };
|
||||
}
|
||||
|
||||
const paramMeta: RouteParam[] = [];
|
||||
let source = "^";
|
||||
const segments = raw.split("/").filter(Boolean);
|
||||
|
||||
for (const segment of segments) {
|
||||
const param = parseParamSegment(segment);
|
||||
if (!param) {
|
||||
source += `/${segment.replace(ESCAPE_RE, "\\$&")}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (paramMeta.some((existing) => existing.name === param.name)) {
|
||||
throw new Error(
|
||||
`WRN-ROUTE-DUPLICATE-PARAM: Parameter '${param.name}' appears more than once in '${raw}'.`,
|
||||
);
|
||||
}
|
||||
paramMeta.push(param);
|
||||
|
||||
const capture = param.catchAll ? "(.+?)" : "([^/]+)";
|
||||
source += param.optional ? `(?:/${capture})?` : `/${capture}`;
|
||||
}
|
||||
|
||||
source += "/?$";
|
||||
return {
|
||||
regex: new RegExp(source),
|
||||
paramNames: paramMeta.map((param) => param.name),
|
||||
paramMeta,
|
||||
};
|
||||
}
|
||||
|
||||
function routeSpecificity(route: Route): number[] {
|
||||
const segments = route.raw.split("/").filter(Boolean);
|
||||
let staticCount = 0;
|
||||
let requiredCount = 0;
|
||||
let optionalCount = 0;
|
||||
let catchAllCount = 0;
|
||||
for (const segment of segments) {
|
||||
const param = parseParamSegment(segment);
|
||||
if (!param) staticCount++;
|
||||
else if (param.catchAll) catchAllCount++;
|
||||
else if (param.optional) optionalCount++;
|
||||
else requiredCount++;
|
||||
}
|
||||
return [
|
||||
staticCount,
|
||||
requiredCount,
|
||||
-optionalCount,
|
||||
-catchAllCount,
|
||||
segments.length,
|
||||
route.raw.length,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Order routes so that static routes win over dynamic ones, and longer/more
|
||||
* specific routes win over shorter ones. Sorting once keeps matching simple.
|
||||
* Order routes so static and constrained routes win over optional/catch-all
|
||||
* routes. The ordering remains deterministic for identical specificity.
|
||||
*/
|
||||
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
|
||||
const as = routeSpecificity(a);
|
||||
const bs = routeSpecificity(b);
|
||||
for (let i = 0; i < as.length; i++) {
|
||||
if (as[i] !== bs[i]) return bs[i]! - as[i]!;
|
||||
}
|
||||
return b.raw.length - a.raw.length; // longer/more specific first
|
||||
return a.raw.localeCompare(b.raw) || a.file.localeCompare(b.file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Find duplicate URL patterns before request handling starts. */
|
||||
export function findRouteConflicts(routes: Route[]): Array<{ raw: string; files: string[] }> {
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const route of routes) {
|
||||
const files = grouped.get(route.raw) ?? [];
|
||||
files.push(route.file);
|
||||
grouped.set(route.raw, files);
|
||||
}
|
||||
return [...grouped]
|
||||
.filter(([, files]) => files.length > 1)
|
||||
.map(([raw, files]) => ({ raw, files }));
|
||||
}
|
||||
|
||||
/** 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 match = route.regex.exec(pathname);
|
||||
if (!match) continue;
|
||||
const params: Record<string, string> = {};
|
||||
try {
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = decodeURIComponent(m[i + 1]!);
|
||||
route.paramNames.forEach((name, index) => {
|
||||
const value = match[index + 1];
|
||||
if (value !== undefined) params[name] = decodeURIComponent(value);
|
||||
});
|
||||
} catch {
|
||||
// A malformed percent-encoded path is not a valid route match. Treat it
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* 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).
|
||||
* with a `Routes` map (path -> param types) and an `href()` builder.
|
||||
*/
|
||||
|
||||
import type { Route } from "./match.ts";
|
||||
import { getRouteParams, type Route } from "./match.ts";
|
||||
|
||||
export function generateRoutesFile(pages: Route[]): string {
|
||||
const seen = new Set<string>();
|
||||
@@ -12,35 +11,73 @@ export function generateRoutesFile(pages: Route[]): 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("; ")} }`
|
||||
const params = page.paramMeta ?? getRouteParams(page.raw);
|
||||
const type = params.length
|
||||
? `{ ${params
|
||||
.map((param) => {
|
||||
const key = `${JSON.stringify(param.name)}${param.optional ? "?" : ""}`;
|
||||
const value = param.catchAll ? "string | readonly string[]" : "string";
|
||||
return `${key}: ${value}`;
|
||||
})
|
||||
.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.
|
||||
return `// AUTO-GENERATED by \`wrnexus dev\` - do not edit.
|
||||
// Typed routes support required, optional, and catch-all parameters.
|
||||
|
||||
export interface Routes {
|
||||
${entries.join("\n") || " [path: string]: Record<string, string>;"}
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
if (value === undefined) return "";
|
||||
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: Routes[P] extends Record<string, never> ? [] : [params: Routes[P]]
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [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("/");
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
const output: string[] = [];
|
||||
for (const segment of String(path).split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||
optional = true;
|
||||
name = segment.slice(2, -2);
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
name = segment.slice(1, -1);
|
||||
if (name.endsWith("?")) {
|
||||
optional = true;
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
output.push(segment);
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("...")) {
|
||||
catchAll = true;
|
||||
name = name.slice(3);
|
||||
}
|
||||
const value = params[name];
|
||||
if (value === undefined && optional) continue;
|
||||
if (value === undefined) throw new Error(\`WRN-ROUTE-MISSING-PARAM: Missing route parameter '\${name}'.\`);
|
||||
output.push(encodeRouteValue(value, catchAll));
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,77 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { compileRoutePattern, generateRoutesFile, matchRoute, type Route } from "../src/index.ts";
|
||||
import {
|
||||
compileRoutePattern,
|
||||
fileToRoute,
|
||||
generateRoutesFile,
|
||||
matchRoute,
|
||||
sortRoutes,
|
||||
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]")]);
|
||||
const code = generateRoutesFile([
|
||||
route("/"),
|
||||
route("/about"),
|
||||
route("/users/[id]"),
|
||||
route("/docs/[[...slug]]"),
|
||||
]);
|
||||
expect(code).toContain('"/": Record<string, never>;');
|
||||
expect(code).toContain('"/about": Record<string, never>;');
|
||||
expect(code).toContain('"/users/[id]": { "id": string };');
|
||||
expect(code).toContain('"/docs/[[...slug]]": { "slug"?: string | readonly 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.
|
||||
test("generated href() fills required, optional and catch-all params", async () => {
|
||||
const code = generateRoutesFile([
|
||||
route("/"),
|
||||
route("/users/[id]"),
|
||||
route("/org/[org]/team/[team]"),
|
||||
route("/blog/[[page]]"),
|
||||
route("/docs/[...slug]"),
|
||||
]);
|
||||
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");
|
||||
const dir = join(tmpdir(), "wrnexus-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;
|
||||
href: (p: string, params?: Record<string, 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
|
||||
expect(mod.href("/users/[id]", { id: "a b" })).toBe("/users/a%20b");
|
||||
expect(mod.href("/blog/[[page]]")).toBe("/blog");
|
||||
expect(mod.href("/docs/[...slug]", { slug: ["guide", "start here"] })).toBe(
|
||||
"/docs/guide/start%20here",
|
||||
);
|
||||
});
|
||||
|
||||
test("matches optional and catch-all routes", () => {
|
||||
expect(matchRoute([route("/blog/[[page]]")], "/blog")?.params).toEqual({});
|
||||
expect(matchRoute([route("/blog/[[page]]")], "/blog/2")?.params).toEqual({ page: "2" });
|
||||
expect(matchRoute([route("/docs/[...slug]")], "/docs/a/b")?.params).toEqual({ slug: "a/b" });
|
||||
expect(matchRoute([route("/docs/[[...slug]]")], "/docs")?.params).toEqual({});
|
||||
});
|
||||
|
||||
test("static routes sort ahead of dynamic and catch-all routes", () => {
|
||||
const sorted = sortRoutes([route("/docs/[...slug]"), route("/docs/[id]"), route("/docs/new")]);
|
||||
expect(sorted.map((item) => item.raw)).toEqual(["/docs/new", "/docs/[id]", "/docs/[...slug]"]);
|
||||
});
|
||||
|
||||
test("route groups and page/index filenames do not affect URLs", () => {
|
||||
expect(fileToRoute("(marketing)/pricing/page.wrn")).toBe("/pricing");
|
||||
expect(fileToRoute("(dashboard)/index.wrn")).toBe("/");
|
||||
});
|
||||
|
||||
test("malformed encoded route params return no match instead of throwing", () => {
|
||||
|
||||
Reference in New Issue
Block a user