release: WRNexusJS 0.4.0
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
* 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),
|
||||
* app/components/*.wrn -> server-rendered components (by declaration),
|
||||
* mounted in a page via data-component="<name>"
|
||||
*/
|
||||
|
||||
@@ -62,12 +62,23 @@ export interface Router {
|
||||
matchRealtime(pathname: string): RouteMatch | null;
|
||||
}
|
||||
|
||||
export interface ExternalRouteDefinition {
|
||||
kind: "page" | "api" | "realtime";
|
||||
path: string;
|
||||
entry: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
/** Package-owned routes registered by the plugin contribution system. */
|
||||
externalRoutes?: ExternalRouteDefinition[];
|
||||
/** Package-owned middleware executed before app/middleware. */
|
||||
middlewareFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,39 +150,92 @@ function warnRouteConflicts(kind: string, routes: Route[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge package routes with application routes. Exact application paths always
|
||||
* win so a package can safely provide defaults that an app may override.
|
||||
*/
|
||||
function mergeExternalRoutes(
|
||||
kind: string,
|
||||
applicationRoutes: Route[],
|
||||
externalRoutes: Route[],
|
||||
): Route[] {
|
||||
const applicationPaths = new Set(applicationRoutes.map((route) => route.raw));
|
||||
const retainedExternal = externalRoutes.filter((route) => {
|
||||
if (!applicationPaths.has(route.raw)) return true;
|
||||
console.warn(
|
||||
`[wrnexus] WRN-ROUTE-OVERRIDE: application ${kind} route '${route.raw}' overrides package route from ${route.file}`,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
return sortRoutes([...applicationRoutes, ...retainedExternal]);
|
||||
}
|
||||
|
||||
/** 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([
|
||||
const external = opts.externalRoutes ?? [];
|
||||
const externalPages = external
|
||||
.filter((route) => route.kind === "page")
|
||||
.map((route) => routeFromRaw(route.path, route.entry));
|
||||
const externalApi = external
|
||||
.filter((route) => route.kind === "api")
|
||||
.map((route) => routeFromRaw(route.path, route.entry));
|
||||
const externalRealtime = external
|
||||
.filter((route) => route.kind === "realtime")
|
||||
.map((route) => routeFromRaw(route.path, route.entry));
|
||||
const applicationPages = buildRoutes(pageFiles, "");
|
||||
const applicationApi = sortRoutes([
|
||||
...buildRoutes(scanDir(join(appDir, "api")), "/api"),
|
||||
...embedded.api,
|
||||
]);
|
||||
const applicationRealtime = sortRoutes([
|
||||
...buildRoutes(scanDir(join(appDir, "realtime")), "/realtime"),
|
||||
...embedded.realtime,
|
||||
]);
|
||||
const pages = mergeExternalRoutes("page", applicationPages, externalPages);
|
||||
const api = mergeExternalRoutes("API", applicationApi, externalApi);
|
||||
const realtime = mergeExternalRoutes("realtime", applicationRealtime, externalRealtime);
|
||||
|
||||
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)
|
||||
.sort();
|
||||
const middlewareFiles = [
|
||||
...new Set([
|
||||
...(opts.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.
|
||||
// Components are keyed case-insensitively by their declared name so later
|
||||
// scans override earlier ones without creating duplicate aliases. Library
|
||||
// dirs (e.g. @wrnexus/ui) are scanned first; app/components last.
|
||||
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$/, "");
|
||||
|
||||
const fileName = basename(f.file).replace(/\.wrn$/, "");
|
||||
let name = fileName;
|
||||
|
||||
try {
|
||||
const source = readFileSync(f.file, "utf8");
|
||||
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(source);
|
||||
if (declaration) name = declaration[1]!;
|
||||
} catch (error) {
|
||||
console.warn(`[wrnexus] unable to inspect component declaration in ${f.file}`, error);
|
||||
}
|
||||
|
||||
if (!isSafeIslandName(name)) {
|
||||
console.warn(`[wrnexus] skipping component with unsafe name: ${name}`);
|
||||
continue;
|
||||
}
|
||||
componentMap.set(name, { name, file: f.file });
|
||||
|
||||
componentMap.set(name.toLowerCase(), { name, file: f.file });
|
||||
}
|
||||
};
|
||||
for (const dir of opts.componentDirs ?? []) scanComponents(dir);
|
||||
@@ -220,3 +284,5 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
|
||||
|
||||
/** Re-export for callers that load middleware modules themselves. */
|
||||
export type { Middleware };
|
||||
export { routeName, nameRoutes, createRouteManifest, routeUrl, findNamedRoute } from "./named.ts";
|
||||
export type { NamedRoute, RouteManifestEntry } from "./named.ts";
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getRouteParams, type Route } from "./match.ts";
|
||||
|
||||
export interface NamedRoute extends Route {
|
||||
name: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RouteManifestEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
file: string;
|
||||
params: ReturnType<typeof getRouteParams>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function routeName(raw: string): string {
|
||||
const value = raw.replace(/^\/+|\/+$/g, "");
|
||||
return value
|
||||
? value.replace(/\[\[?\.\.\.|\[\[?|\]\]?/g, "").replace(/[^A-Za-z0-9]+/g, ".")
|
||||
: "index";
|
||||
}
|
||||
|
||||
export function nameRoutes(
|
||||
routes: readonly Route[],
|
||||
metadata: Record<string, Record<string, unknown>> = {},
|
||||
): NamedRoute[] {
|
||||
const used = new Map<string, number>();
|
||||
return routes.map((route) => {
|
||||
const base = routeName(route.raw);
|
||||
const count = (used.get(base) ?? 0) + 1;
|
||||
used.set(base, count);
|
||||
const name = count === 1 ? base : `${base}.${count}`;
|
||||
return { ...route, name, metadata: metadata[name] };
|
||||
});
|
||||
}
|
||||
|
||||
export function createRouteManifest(routes: readonly NamedRoute[]): RouteManifestEntry[] {
|
||||
return routes.map((route) => ({
|
||||
name: route.name,
|
||||
path: route.raw,
|
||||
file: route.file,
|
||||
params: route.paramMeta ?? getRouteParams(route.raw),
|
||||
metadata: route.metadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function routeUrl(
|
||||
route: Pick<NamedRoute, "raw" | "paramMeta">,
|
||||
params: Record<string, string | number | Array<string | number> | null | undefined> = {},
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
): string {
|
||||
const meta = route.paramMeta ?? getRouteParams(route.raw);
|
||||
let output = route.raw;
|
||||
for (const param of meta) {
|
||||
const value = params[param.name];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
if (!param.optional) throw new Error(`WRN-ROUTE-MISSING-PARAM: ${param.name}`);
|
||||
output = output.replace(new RegExp(`/\\[\\[?(?:\\.\\.\\.)?${param.name}\\??\\]\\]?`), "");
|
||||
continue;
|
||||
}
|
||||
const encoded = Array.isArray(value)
|
||||
? value.map((part) => encodeURIComponent(String(part))).join("/")
|
||||
: encodeURIComponent(String(value));
|
||||
output = output.replace(new RegExp(`\\[\\[?(?:\\.\\.\\.)?${param.name}\\??\\]\\]?`), encoded);
|
||||
}
|
||||
output = output.replace(/\/+/g, "/") || "/";
|
||||
if (query) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query))
|
||||
if (value !== undefined && value !== null) search.set(key, String(value));
|
||||
const text = search.toString();
|
||||
if (text) output += `?${text}`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function findNamedRoute(routes: readonly NamedRoute[], name: string): NamedRoute {
|
||||
const route = routes.find((candidate) => candidate.name === name);
|
||||
if (!route) throw new Error(`WRN-ROUTE-NAME-NOT-FOUND: ${name}`);
|
||||
return route;
|
||||
}
|
||||
Reference in New Issue
Block a user