+>;
diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts
index 1e7d736cf..496f2ed55 100644
--- a/packages/vitnode/src/lib/plugin.ts
+++ b/packages/vitnode/src/lib/plugin.ts
@@ -5,6 +5,7 @@ import type {
ContentSelect,
ContentSystemField,
} from "../content/types";
+import type { PluginRouteDefinition } from "../routing/types";
import type { ItemNavAdmin } from "../views/admin/layouts/sidebar/nav/item";
import type { LocaleMessagesMap } from "./i18n/types";
@@ -182,6 +183,19 @@ export interface BuildPluginReturn {
contentTypes?: ContentTypeFrontendRegistration[];
messages?: LocaleMessagesMap;
pluginId: P;
+ /**
+ * Public pages this plugin contributes, declared rather than shipped as a
+ * framework's route files.
+ *
+ * Additive and optional: a plugin with a `src/routes/` tree keeps working
+ * exactly as it did, because that tree is still copied into every Next.js app
+ * by `scripts/prepare-plugins-files.ts`. This is the parallel path - the one an
+ * application that is not Next.js can read - and `buildPluginRouteManifest`
+ * turns every plugin's list into the application's route manifest.
+ *
+ * Nothing in this package renders them yet. See `src/routing/`.
+ */
+ routes?: PluginRouteDefinition[];
}
export function buildPlugin
(
diff --git a/packages/vitnode/src/routing/boundaries.test.ts b/packages/vitnode/src/routing/boundaries.test.ts
new file mode 100644
index 000000000..ccacd101d
--- /dev/null
+++ b/packages/vitnode/src/routing/boundaries.test.ts
@@ -0,0 +1,85 @@
+// @vitest-environment node
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+const filesUnder = (directory: string): string[] => {
+ const entries: string[] = [];
+
+ for (const name of readdirSync(directory)) {
+ const path = join(directory, name);
+
+ if (statSync(path).isDirectory()) {
+ entries.push(...filesUnder(path));
+ continue;
+ }
+
+ if (/\.tsx?$/.test(name)) entries.push(path);
+ }
+
+ return entries;
+};
+
+const importsFrom = (path: string): string[] =>
+ [
+ ...readFileSync(path, "utf8").matchAll(
+ /from\s+"([^"]+)"|import\s+"([^"]+)"/g,
+ ),
+ ]
+ .map(match => match[1] ?? match[2])
+ .filter((specifier): specifier is string => Boolean(specifier));
+
+/**
+ * The rule this layer exists to keep.
+ *
+ * A plugin route manifest is VitNode configuration data. It is read while a
+ * Next.js app builds, while a TanStack Start app builds, and by a plain
+ * `vitest` process with no framework loaded at all - so a single import of
+ * `next/*` or `@tanstack/*` here does not fail in review, it fails for whoever
+ * is not using that framework.
+ *
+ * Stated as "imports nothing but its own files" rather than as a list of banned
+ * packages, because a list is something somebody has to remember to extend.
+ */
+describe("the routing layer is framework-neutral", () => {
+ const files = filesUnder(here).filter(path => !/\.test\.tsx?$/.test(path));
+
+ it("has files to check", () => {
+ // Every assertion below is vacuously true against an empty list.
+ expect(files.length).toBeGreaterThan(3);
+ });
+
+ it("imports nothing but its own modules", () => {
+ const offenders = files.flatMap(path =>
+ importsFrom(path)
+ .filter(specifier => !specifier.startsWith("."))
+ .map(specifier => `${relative(here, path)} -> ${specifier}`),
+ );
+
+ expect(offenders).toEqual([]);
+ });
+
+ it.each([
+ "@tanstack/react-router",
+ "@tanstack/react-start",
+ "next",
+ "next-intl",
+ "react",
+ "server-only",
+ ])("never imports %s", forbidden => {
+ // Redundant with the rule above by construction, and worth writing anyway:
+ // this is the list a failure should name, and these are the packages a
+ // future contributor will actually be tempted to reach for.
+ const offenders = files.filter(path =>
+ importsFrom(path).some(
+ specifier =>
+ specifier === forbidden || specifier.startsWith(`${forbidden}/`),
+ ),
+ );
+
+ expect(offenders.map(path => relative(here, path))).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/routing/errors.ts b/packages/vitnode/src/routing/errors.ts
new file mode 100644
index 000000000..a7866878f
--- /dev/null
+++ b/packages/vitnode/src/routing/errors.ts
@@ -0,0 +1,46 @@
+export type PluginRouteErrorCode =
+ | "duplicate-id"
+ | "duplicate-path"
+ | "invalid-area"
+ | "invalid-entry"
+ | "invalid-id"
+ | "invalid-path"
+ | "invalid-plugin-id"
+ | "malformed-route";
+
+export interface PluginRouteErrorDetails {
+ code: PluginRouteErrorCode;
+ /** The route that already owned the id or the path, on a collision. */
+ conflictsWith?: { pluginId: string; routeId: string };
+ path?: string;
+ pluginId: string;
+ routeId?: string;
+}
+
+/**
+ * A plugin route that cannot be part of a manifest.
+ *
+ * Thrown rather than collected, and thrown on the first problem: a manifest with
+ * two plugins claiming `/blog` has no correct interpretation, and picking one is
+ * how an install silently serves the wrong page for a release. The structured
+ * fields are here so a build tool can render the failure its own way without
+ * parsing the message.
+ */
+export class PluginRouteError extends Error {
+ constructor(message: string, details: PluginRouteErrorDetails) {
+ super(message);
+
+ this.name = "PluginRouteError";
+ this.code = details.code;
+ this.conflictsWith = details.conflictsWith;
+ this.path = details.path;
+ this.pluginId = details.pluginId;
+ this.routeId = details.routeId;
+ }
+
+ readonly code: PluginRouteErrorCode;
+ readonly conflictsWith?: { pluginId: string; routeId: string };
+ readonly path?: string;
+ readonly pluginId: string;
+ readonly routeId?: string;
+}
diff --git a/packages/vitnode/src/routing/index.ts b/packages/vitnode/src/routing/index.ts
new file mode 100644
index 000000000..6812a4c28
--- /dev/null
+++ b/packages/vitnode/src/routing/index.ts
@@ -0,0 +1,41 @@
+/**
+ * Plugin routing, as VitNode data.
+ *
+ * A plugin says "I have a page called `hello`, it lives at `/example/hello`, and
+ * this module renders it". This layer turns every such declaration in an
+ * application into one validated, deterministically ordered manifest, and stops
+ * there - it renders nothing, resolves no modules and imports nothing
+ * framework-shaped.
+ *
+ * That boundary is the whole point. The route trees plugins ship today are
+ * *Next.js* route trees, copied file by file into an app's `src/app` by
+ * `scripts/prepare-plugins-files.ts`, which is why a VitNode plugin currently
+ * cannot contribute a page to an application that is not Next.js. Nothing here
+ * replaces that yet: it is the parallel path, and both are live.
+ */
+export type { PluginRouteErrorCode, PluginRouteErrorDetails } from "./errors";
+export { PluginRouteError } from "./errors";
+export {
+ buildPluginRouteManifest,
+ comparePluginRoutes,
+ pluginRouteId,
+} from "./manifest";
+
+export type { ParseRoutePathResult } from "./path";
+export {
+ formatRoutePath,
+ parseRoutePath,
+ routeMatchKey,
+ routeMatchKeyFromTanStackPath,
+ toNextRoutePath,
+ toTanStackRoutePath,
+} from "./path";
+export type {
+ PluginRoute,
+ PluginRouteArea,
+ PluginRouteDefinition,
+ PluginRouteManifest,
+ PluginRouteSegment,
+ PluginRouteSource,
+} from "./types";
+export { PLUGIN_ROUTE_AREAS, PLUGIN_ROUTE_ID_SEPARATOR } from "./types";
diff --git a/packages/vitnode/src/routing/manifest.test.ts b/packages/vitnode/src/routing/manifest.test.ts
new file mode 100644
index 000000000..b0171b11d
--- /dev/null
+++ b/packages/vitnode/src/routing/manifest.test.ts
@@ -0,0 +1,357 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import type { BuildPluginReturn } from "../lib/plugin";
+import type { PluginRouteDefinition, PluginRouteSource } from "./types";
+
+import { PluginRouteError } from "./errors";
+import {
+ buildPluginRouteManifest,
+ comparePluginRoutes,
+ pluginRouteId,
+} from "./manifest";
+
+const route = (id: string, path: string): PluginRouteDefinition => ({
+ entry: `routes/${id}`,
+ id,
+ path,
+});
+
+const example = (...routes: PluginRouteDefinition[]): PluginRouteSource => ({
+ pluginId: "@vitnode/example",
+ routes,
+});
+
+const blog = (...routes: PluginRouteDefinition[]): PluginRouteSource => ({
+ pluginId: "@vitnode/blog",
+ routes,
+});
+
+/** The error a call threw, typed - `expect().toThrow` only sees the message. */
+const thrownBy = (build: () => unknown): PluginRouteError => {
+ try {
+ build();
+ } catch (error) {
+ if (error instanceof PluginRouteError) return error;
+ throw error;
+ }
+
+ throw new Error("expected a PluginRouteError");
+};
+
+describe("route ids", () => {
+ it("namespaces a route id by its plugin", () => {
+ // The same key `framework/plugin-routes` registers the module loader under,
+ // so a manifest entry addresses its own module with no translation step.
+ expect(pluginRouteId("@vitnode/example", "hello")).toBe(
+ "@vitnode/example:hello",
+ );
+ });
+
+ it("lets two plugins use the same local id", () => {
+ const manifest = buildPluginRouteManifest([
+ example(route("index", "/example")),
+ blog(route("index", "/blog")),
+ ]);
+
+ expect(manifest.map(entry => entry.id)).toEqual([
+ "@vitnode/blog:index",
+ "@vitnode/example:index",
+ ]);
+ });
+});
+
+describe("the seam with the generated module registry", () => {
+ it("takes an app's configured plugin list exactly as it is", () => {
+ // The call an application makes: `buildPluginRouteManifest(config.plugins)`.
+ // `BuildPluginReturn` is not imported by the routing layer - it reaches the
+ // AdminCP nav and the Content Engine, and through them React - so the two
+ // types meet structurally or not at all. This is where that is checked.
+ const plugins: BuildPluginReturn[] = [
+ {
+ pluginId: "@vitnode/example",
+ routes: [route("hello", "/example/hello")],
+ },
+ { pluginId: "@vitnode/blog" },
+ ];
+
+ expect(buildPluginRouteManifest(plugins).map(entry => entry.id)).toEqual([
+ "@vitnode/example:hello",
+ ]);
+ });
+
+ it("declares the two fields the registry reads, and no more", () => {
+ // `framework/plugin-routes` takes `id` and `entry` off these same records
+ // and generates a lazy import for each. A definition is assignable to that
+ // shape by construction, which is what lets one list in a plugin's
+ // `routes/manifest.ts` serve both layers.
+ const declaration: { entry: string; id: string } = route("hello", "/x");
+
+ expect(declaration).toMatchObject({ entry: "routes/hello", id: "hello" });
+ });
+
+ it("addresses a module by the key that registry is keyed on", () => {
+ const [route] = buildPluginRouteManifest([
+ example({ entry: "routes/hello", id: "hello", path: "/example/hello" }),
+ ]);
+
+ expect(route.id).toBe(`${route.pluginId}:${route.routeId}`);
+ expect(route.entry).toBe("routes/hello");
+ });
+});
+
+describe("normalising a declaration", () => {
+ it("fills in the defaults a plugin left out", () => {
+ const [route] = buildPluginRouteManifest([
+ example({ entry: "routes/x", id: "x", path: "/example/x/" }),
+ ]);
+
+ expect(route).toEqual({
+ area: "main",
+ entry: "routes/x",
+ id: "@vitnode/example:x",
+ path: "/example/x",
+ pluginId: "@vitnode/example",
+ routeId: "x",
+ segments: [
+ { kind: "static", value: "example" },
+ { kind: "static", value: "x" },
+ ],
+ });
+ });
+
+ it("keeps an explicit area", () => {
+ const [route] = buildPluginRouteManifest([
+ example({
+ area: "main",
+ entry: "routes/hello",
+ id: "hello",
+ path: "/example/hello",
+ }),
+ ]);
+
+ expect(route.area).toBe("main");
+ });
+
+ it("is an empty manifest when nothing declares a route", () => {
+ expect(
+ buildPluginRouteManifest([
+ { pluginId: "@vitnode/example" },
+ { pluginId: "@vitnode/blog", routes: [] },
+ ]),
+ ).toEqual([]);
+ });
+});
+
+/**
+ * The property the whole manifest rests on: two installs with the same plugins
+ * in a different order resolve the same URLs to the same pages.
+ */
+describe("ordering is decided by the paths, not by the registration order", () => {
+ const routes = [
+ example(
+ route("slug", "/example/:slug"),
+ route("new", "/example/new"),
+ route("index", "/example"),
+ ),
+ blog(route("post", "/blog/:postId/comments"), route("index", "/blog")),
+ ];
+
+ it("puts static segments before parameters at the same depth", () => {
+ expect(buildPluginRouteManifest(routes).map(entry => entry.path)).toEqual([
+ "/blog",
+ "/blog/:postId/comments",
+ "/example",
+ "/example/new",
+ "/example/:slug",
+ ]);
+ });
+
+ it("gives the same order whichever plugin registered first", () => {
+ const forwards = buildPluginRouteManifest(routes);
+ const backwards = buildPluginRouteManifest([...routes].reverse());
+
+ expect(backwards.map(entry => entry.id)).toEqual(
+ forwards.map(entry => entry.id),
+ );
+ });
+
+ it("gives the same order whichever route a plugin declared first", () => {
+ const declared = buildPluginRouteManifest([
+ example(route("index", "/example"), route("slug", "/example/:slug")),
+ ]);
+ const reversed = buildPluginRouteManifest([
+ example(route("slug", "/example/:slug"), route("index", "/example")),
+ ]);
+
+ expect(reversed.map(entry => entry.path)).toEqual(
+ declared.map(entry => entry.path),
+ );
+ });
+
+ it("is a total order, so a sort of a manifest is a no-op", () => {
+ const manifest = buildPluginRouteManifest(routes);
+
+ expect([...manifest].sort(comparePluginRoutes)).toEqual(manifest);
+ });
+});
+
+describe("collisions are errors, never resolutions", () => {
+ it("names both plugins when two claim the same path", () => {
+ const error = thrownBy(() =>
+ buildPluginRouteManifest([
+ example(route("hello", "/hello")),
+ blog(route("greeting", "/hello")),
+ ]),
+ );
+
+ expect(error.code).toBe("duplicate-path");
+ expect(error.path).toBe("/hello");
+ expect(error.pluginId).toBe("@vitnode/blog");
+ expect(error.conflictsWith).toEqual({
+ pluginId: "@vitnode/example",
+ routeId: "@vitnode/example:hello",
+ });
+ expect(error.message).toContain("/hello");
+ expect(error.message).toContain("@vitnode/example");
+ expect(error.message).toContain("@vitnode/blog");
+ });
+
+ it("catches a collision that only normalisation reveals", () => {
+ expect(() =>
+ buildPluginRouteManifest([
+ example(route("a", "/hello")),
+ blog(route("b", "/hello/")),
+ ]),
+ ).toThrow(PluginRouteError);
+ });
+
+ it("treats two paths that differ only by a parameter name as one path", () => {
+ // `/example/:slug` and `/example/:id` match exactly the same URLs.
+ const error = thrownBy(() =>
+ buildPluginRouteManifest([
+ example(route("a", "/example/:slug")),
+ blog(route("b", "/example/:id")),
+ ]),
+ );
+
+ expect(error.code).toBe("duplicate-path");
+ // Both spellings, because neither plugin author wrote the other's.
+ expect(error.message).toContain("/example/:slug");
+ expect(error.message).toContain("/example/:id");
+ });
+
+ it("rejects one plugin declaring the same id twice", () => {
+ const error = thrownBy(() =>
+ buildPluginRouteManifest([
+ example(route("hello", "/a"), route("hello", "/b")),
+ ]),
+ );
+
+ expect(error.code).toBe("duplicate-id");
+ expect(error.message).toContain("@vitnode/example:hello");
+ });
+});
+
+describe("malformed declarations", () => {
+ const build = (source: unknown) =>
+ buildPluginRouteManifest([source] as PluginRouteSource[]);
+
+ it("rejects an empty plugin id", () => {
+ for (const pluginId of ["", " ", undefined]) {
+ expect(thrownBy(() => build({ pluginId, routes: [] })).code).toBe(
+ "invalid-plugin-id",
+ );
+ }
+ });
+
+ it("rejects a route that is not an object", () => {
+ expect(
+ thrownBy(() => build({ pluginId: "@vitnode/example", routes: ["/x"] }))
+ .code,
+ ).toBe("malformed-route");
+ });
+
+ it("rejects a missing or unusable id", () => {
+ for (const id of [
+ undefined,
+ "",
+ "with space",
+ "-leading-dash",
+ "../escape",
+ ]) {
+ expect(
+ thrownBy(() =>
+ build({
+ pluginId: "@vitnode/example",
+ routes: [{ entry: "routes/x", id, path: "/x" }],
+ }),
+ ).code,
+ ).toBe("invalid-id");
+ }
+ });
+
+ it("rejects a missing or malformed path", () => {
+ for (const path of [undefined, "", "x", "/x/[id]"]) {
+ const error = thrownBy(() =>
+ build({
+ pluginId: "@vitnode/example",
+ routes: [{ entry: "routes/x", id: "x", path }],
+ }),
+ );
+
+ expect(error.code).toBe("invalid-path");
+ expect(error.routeId).toBe("x");
+ }
+ });
+
+ /**
+ * A path a router would match case-insensitively but this layer would compare
+ * as two different strings. Rejected here rather than lowercased, and the
+ * failure names the plugin - see `path.test.ts` for the rule itself.
+ */
+ it("rejects an uppercase path, naming the plugin", () => {
+ const error = thrownBy(() =>
+ build({
+ pluginId: "@vitnode/example",
+ routes: [{ entry: "routes/x", id: "x", path: "/Example" }],
+ }),
+ );
+
+ expect(error.code).toBe("invalid-path");
+ expect(error.pluginId).toBe("@vitnode/example");
+ expect(error.message).toContain('Write "example"');
+ });
+
+ it("rejects an entry an application could never import", () => {
+ for (const entry of [
+ undefined,
+ "",
+ "/routes/x",
+ "routes/../../secret",
+ "routes/x.tsx",
+ "routes/x'\\n",
+ ]) {
+ expect(
+ thrownBy(() =>
+ build({
+ pluginId: "@vitnode/example",
+ routes: [{ entry, id: "x", path: "/x" }],
+ }),
+ ).code,
+ ).toBe("invalid-entry");
+ }
+ });
+
+ it("rejects an unknown area", () => {
+ const error = thrownBy(() =>
+ build({
+ pluginId: "@vitnode/example",
+ routes: [{ area: "admin", entry: "routes/x", id: "x", path: "/x" }],
+ }),
+ );
+
+ expect(error.code).toBe("invalid-area");
+ expect(error.message).toContain("main");
+ });
+});
diff --git a/packages/vitnode/src/routing/manifest.ts b/packages/vitnode/src/routing/manifest.ts
new file mode 100644
index 000000000..69c8b1a22
--- /dev/null
+++ b/packages/vitnode/src/routing/manifest.ts
@@ -0,0 +1,259 @@
+import type {
+ PluginRoute,
+ PluginRouteDefinition,
+ PluginRouteManifest,
+ PluginRouteSegment,
+ PluginRouteSource,
+} from "./types";
+
+import { PluginRouteError } from "./errors";
+import { parseRoutePath, routeMatchKey } from "./path";
+import { PLUGIN_ROUTE_AREAS, PLUGIN_ROUTE_ID_SEPARATOR } from "./types";
+
+/**
+ * A `/`-separated identifier, and nothing that could escape a string literal.
+ *
+ * The same rule `framework/plugin-routes` applies to an id and to an entry, for
+ * a reason this layer does not share - it writes both into a generated import.
+ * They are stated identically anyway: an id this layer accepts and that one
+ * rejects would be a route that validates and then fails the build.
+ */
+const SEGMENTED =
+ /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
+
+/** An entry is a package export subpath, and export maps add the extension. */
+const ENTRY_EXTENSION = /\.[cm]?[jt]sx?$/;
+
+/**
+ * A route's globally unique id.
+ *
+ * Namespaced by the plugin so two plugins can both call their landing page
+ * `"index"` - which they will - without either having to know the other exists.
+ */
+export const pluginRouteId = (pluginId: string, routeId: string): string =>
+ `${pluginId}${PLUGIN_ROUTE_ID_SEPARATOR}${routeId}`;
+
+/**
+ * The order routes are declared in must not decide which one wins.
+ *
+ * Compared segment by segment: a static segment sorts before a parameter at the
+ * same depth, so `/blog/new` precedes `/blog/:slug` no matter who registered
+ * first; equal kinds compare by their text, and a shorter path precedes a longer
+ * one that starts the same way. Comparison is by code unit rather than
+ * `localeCompare`, because a route table that reorders itself on a machine with
+ * a different locale is a bug that only reproduces on someone else's laptop.
+ *
+ * The id breaks the remaining tie, and ids are unique, so the order is total.
+ */
+const compareSegments = (
+ a: PluginRouteSegment[],
+ b: PluginRouteSegment[],
+): number => {
+ const shared = Math.min(a.length, b.length);
+
+ for (let index = 0; index < shared; index += 1) {
+ const left = a[index];
+ const right = b[index];
+
+ if (left.kind !== right.kind) {
+ return left.kind === "static" ? -1 : 1;
+ }
+
+ const leftText = left.kind === "static" ? left.value : left.name;
+ const rightText = right.kind === "static" ? right.value : right.name;
+
+ if (leftText !== rightText) {
+ return leftText < rightText ? -1 : 1;
+ }
+ }
+
+ return a.length - b.length;
+};
+
+export const comparePluginRoutes = (a: PluginRoute, b: PluginRoute): number => {
+ const bySegments = compareSegments(a.segments, b.segments);
+
+ if (bySegments !== 0) return bySegments;
+ if (a.id === b.id) return 0;
+
+ return a.id < b.id ? -1 : 1;
+};
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const readEntry = (
+ entry: string | undefined,
+ pluginId: string,
+ routeId: string,
+): string => {
+ const fail = (reason: string): never => {
+ throw new PluginRouteError(
+ `Plugin route "${routeId}" from ${pluginId} has an invalid entry ${JSON.stringify(entry)}: ${reason}.`,
+ { code: "invalid-entry", pluginId, routeId },
+ );
+ };
+
+ if (typeof entry !== "string" || !SEGMENTED.test(entry)) {
+ return fail(
+ 'expected a package export subpath such as "routes/example-page" - "/"-separated segments of letters, digits, ".", "_" and "-", with no leading slash and no ".." segment',
+ );
+ }
+
+ if (ENTRY_EXTENSION.test(entry)) {
+ return fail(
+ "an entry is a package export subpath and the plugin's export map adds the extension - drop it",
+ );
+ }
+
+ return entry;
+};
+
+const readDefinition = (
+ definition: unknown,
+ pluginId: string,
+ index: number,
+): PluginRoute => {
+ if (!isRecord(definition)) {
+ throw new PluginRouteError(
+ `Plugin ${pluginId} declared a route at index ${index} that is not an object.`,
+ { code: "malformed-route", pluginId },
+ );
+ }
+
+ // The only cast in the module. `routes` is typed, but a plugin is JavaScript
+ // by the time it is registered and its config is written by hand, so the
+ // fields are read defensively and the types are re-established here.
+ const { area, entry, id, path } =
+ definition as Partial;
+
+ if (typeof id !== "string" || !SEGMENTED.test(id)) {
+ throw new PluginRouteError(
+ `Plugin ${pluginId} declared a route at index ${index} with an invalid id ${JSON.stringify(id)} - use letters, digits, ".", "-" and "_".`,
+ { code: "invalid-id", pluginId },
+ );
+ }
+
+ if (area !== undefined && !PLUGIN_ROUTE_AREAS.includes(area)) {
+ throw new PluginRouteError(
+ `Plugin route "${id}" from ${pluginId} declares the unknown area ${JSON.stringify(area)}. Known areas: ${PLUGIN_ROUTE_AREAS.join(", ")}.`,
+ { code: "invalid-area", pluginId, routeId: id },
+ );
+ }
+
+ if (typeof path !== "string") {
+ throw new PluginRouteError(
+ `Plugin route "${id}" from ${pluginId} declares no path (got ${JSON.stringify(path)}).`,
+ { code: "invalid-path", pluginId, routeId: id },
+ );
+ }
+
+ const parsed = parseRoutePath(path);
+
+ if (!parsed.ok) {
+ throw new PluginRouteError(
+ `Plugin route "${id}" from ${pluginId} has an invalid path: ${parsed.reason}.`,
+ { code: "invalid-path", path, pluginId, routeId: id },
+ );
+ }
+
+ return {
+ area: area ?? "main",
+ entry: readEntry(entry, pluginId, id),
+ id: pluginRouteId(pluginId, id),
+ path: parsed.path,
+ pluginId,
+ routeId: id,
+ segments: parsed.segments,
+ };
+};
+
+/**
+ * Every plugin route in an application, validated and deterministically ordered.
+ *
+ * Pure, and total in the only sense that matters: it either returns a manifest
+ * no framework can misread, or it throws a {@link PluginRouteError} naming the
+ * plugin, the route and - on a collision - both sides of it. There is no third
+ * outcome where a route is quietly dropped, because a page that silently stops
+ * existing is the failure mode this whole function is for.
+ *
+ * Registration order affects nothing but which plugin an error message calls
+ * "first".
+ */
+export const buildPluginRouteManifest = (
+ sources: PluginRouteSource[],
+): PluginRouteManifest => {
+ const routes: PluginRoute[] = [];
+ const byId = new Map();
+ const byPath = new Map();
+
+ for (const source of sources) {
+ const pluginId = isRecord(source) ? source.pluginId : undefined;
+
+ if (typeof pluginId !== "string" || !/^\S+$/.test(pluginId)) {
+ throw new PluginRouteError(
+ `A plugin registered routes without a plugin id (got ${JSON.stringify(pluginId)}).`,
+ { code: "invalid-plugin-id", pluginId: "" },
+ );
+ }
+
+ const declared = (source.routes ?? []) as unknown[];
+
+ if (!Array.isArray(declared)) {
+ throw new PluginRouteError(
+ `Plugin ${pluginId} declared \`routes\` that is not an array.`,
+ { code: "malformed-route", pluginId },
+ );
+ }
+
+ declared.forEach((definition, index) => {
+ const route = readDefinition(definition, pluginId, index);
+ const existingById = byId.get(route.id);
+
+ if (existingById) {
+ throw new PluginRouteError(
+ `Duplicate plugin route id "${route.id}": declared twice by ${pluginId}.`,
+ {
+ code: "duplicate-id",
+ conflictsWith: {
+ pluginId: existingById.pluginId,
+ routeId: existingById.id,
+ },
+ path: route.path,
+ pluginId,
+ routeId: route.id,
+ },
+ );
+ }
+
+ // Keyed on the URLs the route matches rather than on its text, so
+ // `/blog/:slug` and `/blog/:postId` collide - they are one route spelled
+ // twice. Area-scoped, because the same pathname under two different
+ // layouts would be two different URLs; only one area exists today.
+ const pathKey = `${route.area} ${routeMatchKey(route.segments)}`;
+ const existingByPath = byPath.get(pathKey);
+
+ if (existingByPath) {
+ throw new PluginRouteError(
+ `Plugin route path collision on "${route.path}" (${route.area}): ${existingByPath.pluginId} already owns "${existingByPath.path}" as "${existingByPath.id}", and ${pluginId} declares "${route.path}" as "${route.id}". Two plugins cannot serve the same path - rename one of them.`,
+ {
+ code: "duplicate-path",
+ conflictsWith: {
+ pluginId: existingByPath.pluginId,
+ routeId: existingByPath.id,
+ },
+ path: route.path,
+ pluginId,
+ routeId: route.id,
+ },
+ );
+ }
+
+ byId.set(route.id, route);
+ byPath.set(pathKey, route);
+ routes.push(route);
+ });
+ }
+
+ return routes.sort(comparePluginRoutes);
+};
diff --git a/packages/vitnode/src/routing/path.test.ts b/packages/vitnode/src/routing/path.test.ts
new file mode 100644
index 000000000..5d365f64d
--- /dev/null
+++ b/packages/vitnode/src/routing/path.test.ts
@@ -0,0 +1,332 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import {
+ formatRoutePath,
+ parseRoutePath,
+ routeMatchKey,
+ routeMatchKeyFromTanStackPath,
+ toNextRoutePath,
+ toTanStackRoutePath,
+} from "./path";
+
+const parse = (path: string) => {
+ const result = parseRoutePath(path);
+
+ if (!result.ok) throw new Error(result.reason);
+
+ return result;
+};
+
+const reason = (path: string): string => {
+ const result = parseRoutePath(path);
+
+ if (result.ok) throw new Error(`"${path}" was accepted`);
+
+ return result.reason;
+};
+
+describe("the shapes a VitNode route path represents", () => {
+ it("reads a static route", () => {
+ expect(parse("/example").segments).toEqual([
+ { kind: "static", value: "example" },
+ ]);
+ });
+
+ it("reads a nested static route", () => {
+ expect(parse("/example/hello/there").segments).toEqual([
+ { kind: "static", value: "example" },
+ { kind: "static", value: "hello" },
+ { kind: "static", value: "there" },
+ ]);
+ });
+
+ it("reads a dynamic segment", () => {
+ expect(parse("/example/:slug").segments).toEqual([
+ { kind: "static", value: "example" },
+ { kind: "param", name: "slug" },
+ ]);
+ });
+
+ it("reads a dynamic segment nested under another", () => {
+ expect(parse("/example/:categoryId/posts/:postId").segments).toEqual([
+ { kind: "static", value: "example" },
+ { kind: "param", name: "categoryId" },
+ { kind: "static", value: "posts" },
+ { kind: "param", name: "postId" },
+ ]);
+ });
+
+ it("reads the root as no segments at all", () => {
+ expect(parse("/")).toEqual({ ok: true, path: "/", segments: [] });
+ });
+
+ it("keeps dots, dashes and underscores in a static segment", () => {
+ expect(parse("/example/robots.txt/a-b_c").segments).toEqual([
+ { kind: "static", value: "example" },
+ { kind: "static", value: "robots.txt" },
+ { kind: "static", value: "a-b_c" },
+ ]);
+ });
+});
+
+describe("normalisation", () => {
+ it("drops a trailing slash", () => {
+ expect(parse("/example/hello/").path).toBe("/example/hello");
+ });
+
+ it("round-trips a path through its segments", () => {
+ for (const path of ["/", "/example", "/example/:slug/comments"]) {
+ expect(formatRoutePath(parse(path).segments)).toBe(path);
+ }
+ });
+
+ it("reports the normalised path, not the one it was given", () => {
+ expect(parse("/example/:slug/").path).toBe("/example/:slug");
+ });
+});
+
+describe("paths a plugin may not declare", () => {
+ it("needs a leading slash", () => {
+ expect(reason("example")).toContain('must start with "/"');
+ });
+
+ it("rejects an empty path", () => {
+ expect(reason("")).toContain("non-empty string");
+ });
+
+ it("rejects an empty segment", () => {
+ expect(reason("/example//hello")).toContain("empty segment");
+ expect(reason("//")).toContain("empty segment");
+ });
+
+ it("rejects a query string, a hash and whitespace", () => {
+ expect(reason("/example?page=2")).toContain("query string");
+ expect(reason("/example#top")).toContain("hash");
+ expect(reason("/example/hello world")).toContain("whitespace");
+ });
+
+ it("rejects the same parameter twice", () => {
+ expect(reason("/example/:id/nested/:id")).toContain('declares ":id" twice');
+ });
+
+ it("rejects a parameter that is not an identifier", () => {
+ expect(reason("/example/:1st")).toContain("not a valid parameter name");
+ expect(reason("/example/:")).toContain("not a valid parameter name");
+ });
+});
+
+/**
+ * One canonical spelling, because a router only has one.
+ *
+ * The routers that consume this manifest match paths case-insensitively, so
+ * `/Example` and `/example` are one URL to a browser and two strings to
+ * `routeMatchKey` - a collision the validation could not see. Rejected rather
+ * than lowercased: a plugin's public URL must not change behind its author's
+ * back, and a build error is how they find out.
+ */
+describe("static segments are lowercase", () => {
+ it("accepts a lowercase path", () => {
+ expect(parse("/example").path).toBe("/example");
+ expect(parse("/blog/post").path).toBe("/blog/post");
+ expect(parse("/example/:slug").path).toBe("/example/:slug");
+ });
+
+ it("rejects an uppercase segment, and says what to write instead", () => {
+ expect(reason("/Example")).toContain("uppercase letters");
+ expect(reason("/Example")).toContain('Write "example"');
+ expect(reason("/BLOG/post")).toContain('Write "blog"');
+ expect(reason("/blog/My-Post")).toContain('Write "my-post"');
+ });
+
+ /**
+ * A parameter's name never reaches a URL - it is a variable name - so the
+ * identifier rules it already had are the right ones.
+ */
+ it("still allows camelCase parameter names", () => {
+ expect(parse("/blog/:postId").segments).toEqual([
+ { kind: "static", value: "blog" },
+ { kind: "param", name: "postId" },
+ ]);
+ });
+
+ it("keeps naming the framework syntaxes ahead of the case rule", () => {
+ // `[Slug]` and `$Slug` are uppercase *and* the wrong syntax. The syntax is
+ // the useful thing to say.
+ expect(reason("/example/[Slug]")).toContain("Next.js filesystem syntax");
+ expect(reason("/example/$Slug")).toContain("TanStack Router syntax");
+ });
+});
+
+/**
+ * The two syntaxes this representation exists to be independent of.
+ *
+ * A plugin author coming from either framework writes the one they know, so the
+ * failure has to name the syntax and hand back the VitNode spelling rather than
+ * saying "invalid path".
+ */
+describe("framework syntax is rejected by name", () => {
+ it("rejects Next.js filesystem syntax", () => {
+ expect(reason("/example/[slug]")).toContain("Next.js filesystem syntax");
+ expect(reason("/example/[slug]")).toContain('write ":slug"');
+ });
+
+ it("rejects TanStack Router syntax", () => {
+ expect(reason("/example/$slug")).toContain("TanStack Router syntax");
+ expect(reason("/example/$slug")).toContain('write ":slug"');
+ });
+});
+
+/**
+ * Deferred on purpose - inventoried in the Stage 5 notes rather than guessed at.
+ *
+ * Core ships two catch-alls today (`admin/content/[...slug]` and the
+ * `@breadcrumb` slots), both of which are AdminCP or parallel-route machinery
+ * that this stage does not cover. Accepting `/x/*` here would mean deciding what
+ * it means before anything needs it.
+ */
+describe("route shapes this prototype defers", () => {
+ it("rejects a catch-all", () => {
+ expect(reason("/example/*")).toContain("catch-all");
+ expect(reason("/example/[...slug]")).toContain("Next.js filesystem syntax");
+ });
+
+ it("rejects an optional segment", () => {
+ expect(reason("/example/:slug?")).toContain("optional segment");
+ });
+
+ it("rejects a repeating segment", () => {
+ expect(reason("/example/:slug*")).toContain("repeating segment");
+ expect(reason("/example/:slug+")).toContain("repeating segment");
+ });
+});
+
+describe("conversions to the frameworks that consume the manifest", () => {
+ it.each([
+ ["/", "/", "/"],
+ ["/example", "/example", "/example"],
+ ["/example/:slug", "/example/[slug]", "/example/$slug"],
+ [
+ "/example/:categoryId/posts/:postId",
+ "/example/[categoryId]/posts/[postId]",
+ "/example/$categoryId/posts/$postId",
+ ],
+ ])("converts %s", (path, next, tanstack) => {
+ const { segments } = parse(path);
+
+ expect(toNextRoutePath(segments)).toBe(next);
+ expect(toTanStackRoutePath(segments)).toBe(tanstack);
+ });
+
+ it("is a pure function of the segments, not of the string it came from", () => {
+ // The conversions never see a path, so there is no second parser to
+ // disagree with the first one.
+ expect(toTanStackRoutePath(parse("/example/:slug/").segments)).toBe(
+ "/example/$slug",
+ );
+ });
+});
+
+describe("the URLs a path matches", () => {
+ it("is the path itself when nothing is dynamic", () => {
+ expect(routeMatchKey(parse("/example/hello").segments)).toBe(
+ "/example/hello",
+ );
+ expect(routeMatchKey(parse("/").segments)).toBe("/");
+ });
+
+ it("does not depend on what a parameter is called", () => {
+ // The two paths a plugin author writes when they have not read the other
+ // plugin's routes. They match the same URLs, so the manifest has to see
+ // them as one.
+ expect(routeMatchKey(parse("/example/:slug").segments)).toBe(
+ routeMatchKey(parse("/example/:postId").segments),
+ );
+ });
+
+ it("keeps a parameter distinct from a static segment", () => {
+ expect(routeMatchKey(parse("/example/:slug").segments)).not.toBe(
+ routeMatchKey(parse("/example/slug").segments),
+ );
+ });
+
+ it("keeps depth distinct", () => {
+ expect(routeMatchKey(parse("/example/:a/:b").segments)).not.toBe(
+ routeMatchKey(parse("/example/:a").segments),
+ );
+ });
+});
+
+/**
+ * The same key space, entered from a path an application's router already holds.
+ *
+ * This is what lets plugin-vs-application collisions be the same question as
+ * plugin-vs-plugin instead of a second rule that agrees until somebody edits one.
+ * `$id` is read as input syntax; nothing here imports a router.
+ */
+describe("the URLs a TanStack path matches", () => {
+ const key = routeMatchKeyFromTanStackPath;
+
+ it("agrees with the canonical key for the same route", () => {
+ expect(key("/example/hello")).toBe(
+ routeMatchKey(parse("/example/hello").segments),
+ );
+ expect(key("/blog/$slug")).toBe(
+ routeMatchKey(parse("/blog/:slug").segments),
+ );
+ expect(key("/")).toBe(routeMatchKey(parse("/").segments));
+ });
+
+ /**
+ * The case the old exact-string comparison missed: two syntaxes, two parameter
+ * names, one URL space.
+ */
+ it("does not depend on what a parameter is called", () => {
+ expect(key("/users/$id")).toBe(key("/users/$userId"));
+ expect(key("/users/$id")).toBe(
+ routeMatchKey(parse("/users/:userId").segments),
+ );
+ expect(key("/blog/$slug/comments")).toBe(
+ routeMatchKey(parse("/blog/:postId/comments").segments),
+ );
+ });
+
+ it("keeps a static segment distinct from a parameter", () => {
+ expect(key("/users/new")).not.toBe(key("/users/$id"));
+ expect(key("/users/new")).not.toBe(
+ routeMatchKey(parse("/users/:id").segments),
+ );
+ });
+
+ /**
+ * An index route under a layout joins to `/blog/`, which is the same URL as
+ * `/blog` - so a plugin claiming `/blog` has to collide with it.
+ */
+ it("treats one trailing slash as formatting", () => {
+ expect(key("/discover/")).toBe(key("/discover"));
+ expect(key("/discover/")).toBe(routeMatchKey(parse("/discover").segments));
+ expect(key("/")).toBe("/");
+ });
+
+ /**
+ * A splat swallows every remaining segment and a parameter swallows one, so
+ * they are not the same URL space and must not share a key. No canonical
+ * VitNode path can produce this marker - catch-alls are rejected - so a plugin
+ * route can never collide with an application splat by key.
+ */
+ it("keeps a splat distinct from a parameter", () => {
+ expect(key("/api/$")).toBe("/api/**");
+ expect(key("/api/$")).not.toBe(key("/api/$id"));
+ expect(key("/api/$")).not.toBe(routeMatchKey(parse("/api/:id").segments));
+ });
+
+ /**
+ * An application's own route files are not held to the plugin lowercase rule,
+ * and a router would match `/Users` and `/users` as one URL either way.
+ */
+ it("compares an application path case-insensitively", () => {
+ expect(key("/Users/$id")).toBe(
+ routeMatchKey(parse("/users/:userId").segments),
+ );
+ });
+});
diff --git a/packages/vitnode/src/routing/path.ts b/packages/vitnode/src/routing/path.ts
new file mode 100644
index 000000000..b80a9ab29
--- /dev/null
+++ b/packages/vitnode/src/routing/path.ts
@@ -0,0 +1,279 @@
+import type { PluginRouteSegment } from "./types";
+
+/**
+ * A static segment: a literal piece of URL, and lowercase.
+ *
+ * Percent-encoding, spaces and uppercase are all left out. A plugin author who
+ * needs one of the first two in a public URL has a naming problem, not a routing
+ * problem, and a route table full of `%20` is nobody's idea of a good time.
+ *
+ * Uppercase is excluded for a sharper reason: the routers that consume this
+ * manifest match paths **case-insensitively**, so `/Example` and `/example`
+ * answer the same URL. Accepting both would mean two manifest paths that
+ * `routeMatchKey` calls different and a browser calls identical - a collision the
+ * validation could not see. One canonical spelling removes the question.
+ */
+const STATIC_SEGMENT = /^[a-z0-9][a-z0-9._-]*$/;
+
+/** A parameter name, i.e. a JavaScript-ish identifier - it becomes one. */
+const PARAM_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+
+/** Next.js filesystem syntax: `[id]`, `[...slug]`, `[[...slug]]`. */
+const NEXT_SEGMENT = /^\[.*\]$/;
+
+export type ParseRoutePathResult =
+ | { ok: false; reason: string }
+ | { ok: true; path: string; segments: PluginRouteSegment[] };
+
+const parseSegment = (
+ raw: string,
+): { reason: string } | { segment: PluginRouteSegment } => {
+ if (raw.length === 0) {
+ return { reason: "it has an empty segment" };
+ }
+
+ if (NEXT_SEGMENT.test(raw)) {
+ const name = raw.replace(/^\[+\.{0,3}|\]+$/g, "");
+
+ return {
+ reason: `"${raw}" is Next.js filesystem syntax - write ":${name || "name"}" instead`,
+ };
+ }
+
+ if (raw.startsWith("$")) {
+ return {
+ reason: `"${raw}" is TanStack Router syntax - write ":${raw.slice(1) || "name"}" instead`,
+ };
+ }
+
+ if (raw === "*" || raw === "**") {
+ return {
+ reason: `"${raw}" is a catch-all segment, which VitNode route paths do not represent yet`,
+ };
+ }
+
+ if (raw.startsWith(":")) {
+ const name = raw.slice(1);
+
+ if (name.endsWith("?")) {
+ return {
+ reason: `"${raw}" is an optional segment, which VitNode route paths do not represent yet`,
+ };
+ }
+
+ if (name.endsWith("*") || name.endsWith("+")) {
+ return {
+ reason: `"${raw}" is a repeating segment, which VitNode route paths do not represent yet`,
+ };
+ }
+
+ if (!PARAM_NAME.test(name)) {
+ return {
+ reason: `":${name}" is not a valid parameter name - use letters, digits and underscores, starting with a letter`,
+ };
+ }
+
+ return { segment: { kind: "param", name } };
+ }
+
+ if (raw.includes("?")) {
+ return {
+ reason: `"${raw}" looks like a query string, which is not part of a route path`,
+ };
+ }
+
+ // Named before the general rule, and never lowercased silently: a plugin's
+ // public URL changing behind its author's back is worse than a build error
+ // that says exactly what to write.
+ if (/[A-Z]/.test(raw)) {
+ return {
+ reason: `"${raw}" has uppercase letters - VitNode route paths are lowercase, because a router matches them case-insensitively and "/${raw}" and "/${raw.toLowerCase()}" would be one URL. Write "${raw.toLowerCase()}" instead`,
+ };
+ }
+
+ if (!STATIC_SEGMENT.test(raw)) {
+ return {
+ reason: `"${raw}" is not a valid path segment - use lowercase letters, digits, "-", "_" and "."`,
+ };
+ }
+
+ return { segment: { kind: "static", value: raw } };
+};
+
+/**
+ * Reads a canonical VitNode route path.
+ *
+ * The one fallible function in this module, and the only place a path string is
+ * ever interpreted. Everything else takes segments, which cannot be malformed,
+ * so no caller has to remember to handle an error twice.
+ *
+ * Returns a result rather than throwing: the manifest builder wants to attach
+ * the plugin and the route id to the failure, and an exception thrown from here
+ * would not know either.
+ */
+export const parseRoutePath = (path: string): ParseRoutePathResult => {
+ if (typeof path !== "string" || path.length === 0) {
+ return { ok: false, reason: "a route path must be a non-empty string" };
+ }
+
+ if (!path.startsWith("/")) {
+ return { ok: false, reason: `"${path}" must start with "/"` };
+ }
+
+ if (/[#\s]/.test(path)) {
+ return {
+ ok: false,
+ reason: `"${path}" must not contain whitespace or a hash`,
+ };
+ }
+
+ if (path === "/") {
+ return { ok: true, path: "/", segments: [] };
+ }
+
+ // One trailing slash is a formatting difference, not a different route.
+ const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
+ const segments: PluginRouteSegment[] = [];
+ const params = new Set();
+
+ for (const raw of trimmed.slice(1).split("/")) {
+ const parsed = parseSegment(raw);
+
+ if ("reason" in parsed) {
+ return {
+ ok: false,
+ reason: `"${path}" is not a valid path: ${parsed.reason}`,
+ };
+ }
+
+ if (parsed.segment.kind === "param") {
+ if (params.has(parsed.segment.name)) {
+ return {
+ ok: false,
+ reason: `"${path}" declares ":${parsed.segment.name}" twice`,
+ };
+ }
+
+ params.add(parsed.segment.name);
+ }
+
+ segments.push(parsed.segment);
+ }
+
+ return { ok: true, path: formatRoutePath(segments), segments };
+};
+
+/** Segments back to their canonical VitNode path. */
+export function formatRoutePath(segments: PluginRouteSegment[]): string {
+ if (segments.length === 0) return "/";
+
+ return `/${segments
+ .map(segment =>
+ segment.kind === "param" ? `:${segment.name}` : segment.value,
+ )
+ .join("/")}`;
+}
+
+/**
+ * Segments to Next.js filesystem syntax, `/blog/[slug]`.
+ *
+ * Here rather than in the Next.js layer because it is the same three lines as
+ * its TanStack twin, and keeping the pair together is what stops the two
+ * conversions from drifting into two different ideas of what a path is.
+ */
+export const toNextRoutePath = (segments: PluginRouteSegment[]): string => {
+ if (segments.length === 0) return "/";
+
+ return `/${segments
+ .map(segment =>
+ segment.kind === "param" ? `[${segment.name}]` : segment.value,
+ )
+ .join("/")}`;
+};
+
+/** Segments to TanStack Router syntax, `/blog/$slug`. */
+export const toTanStackRoutePath = (segments: PluginRouteSegment[]): string => {
+ if (segments.length === 0) return "/";
+
+ return `/${segments
+ .map(segment =>
+ segment.kind === "param" ? `$${segment.name}` : segment.value,
+ )
+ .join("/")}`;
+};
+
+/**
+ * The set of URLs a path matches, as a comparable string.
+ *
+ * `/blog/:slug` and `/blog/:postId` are two spellings of one route: they match
+ * exactly the same URLs, and an application that accepted both would answer
+ * `/blog/hello` differently depending on which plugin loaded first. Collapsing
+ * every parameter to `:` is what turns that into a collision the manifest can
+ * refuse rather than a race it silently resolves.
+ */
+export const routeMatchKey = (segments: PluginRouteSegment[]): string => {
+ if (segments.length === 0) return "/";
+
+ return `/${segments
+ .map(segment => (segment.kind === "param" ? ":" : segment.value))
+ .join("/")}`;
+};
+
+/**
+ * A splat, in a {@link routeMatchKeyFromTanStackPath} key.
+ *
+ * Deliberately not `:`. A splat swallows every remaining segment and a parameter
+ * swallows exactly one, so `/api/$` and `/api/:id` do *not* match the same URLs -
+ * `/api/a/b` reaches only the first. Giving them one key would break the single
+ * promise this whole key space makes: equal keys mean equal sets of URLs. No
+ * canonical VitNode path can produce this marker, because `parseRoutePath`
+ * rejects catch-alls outright, so a plugin route can never collide with an
+ * application's splat by key.
+ */
+const MATCH_KEY_SPLAT = "**";
+
+/**
+ * {@link routeMatchKey}, for a path already written in TanStack Router syntax.
+ *
+ * The second entrance to one key space, and the reason plugin-vs-plugin and
+ * plugin-vs-application collisions are the same question asked twice rather than
+ * two rules that agree until somebody edits one. A plugin route arrives as parsed
+ * segments and goes through `routeMatchKey`; an application's own route arrives
+ * as the string its router already holds - `/users/$id` - and comes through here.
+ * Both land on `/users/:`, so they compare.
+ *
+ * /users/$id -> /users/:
+ * /users/$userId -> /users/: (a parameter's name is not part of a URL)
+ * /users/new -> /users/new (a router tells static from dynamic)
+ * /blog/$slug/x -> /blog/:/x
+ * /discover/ -> /discover (an index route under a layout)
+ * /api/$ -> /api/** (see MATCH_KEY_SPLAT)
+ *
+ * Framework-neutral despite the name: `$id` is treated as *input syntax*, the
+ * same way `toTanStackRoutePath` treats it as output syntax. Nothing here imports
+ * a router, and nothing here may - see `boundaries.test.ts`.
+ */
+export const routeMatchKeyFromTanStackPath = (path: string): string => {
+ // A route may declare `/`, and a layout's index child joins to `/blog/` -
+ // which is the same URL as `/blog`. One trailing slash is formatting.
+ const trimmed =
+ path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
+
+ if (trimmed === "" || trimmed === "/") return "/";
+
+ return `/${trimmed
+ .replace(/^\//, "")
+ .split("/")
+ .filter(segment => segment.length > 0)
+ .map(segment => {
+ if (segment === "$") return MATCH_KEY_SPLAT;
+ if (segment.startsWith("$")) return ":";
+
+ // Lowercased because a router matches case-insensitively, so an
+ // application route at `/Users` and a plugin route at `/users` are one
+ // URL. Plugin paths are already lowercase by construction - `parseRoutePath`
+ // refuses anything else - and an app's own route files are not.
+ return segment.toLowerCase();
+ })
+ .join("/")}`;
+};
diff --git a/packages/vitnode/src/routing/types.ts b/packages/vitnode/src/routing/types.ts
new file mode 100644
index 000000000..ec8a3d777
--- /dev/null
+++ b/packages/vitnode/src/routing/types.ts
@@ -0,0 +1,107 @@
+/**
+ * Where a plugin route mounts in the application.
+ *
+ * One member, on purpose. Stage 5 is about public pages: the AdminCP has its own
+ * layout, its own staff permissions and its own breadcrumbs, and none of that is
+ * decided here. Adding `"admin"` later is a one-line change plus whatever
+ * interprets it - which is exactly the point of keeping the list here rather than
+ * letting every route invent its own string.
+ */
+export type PluginRouteArea = "main";
+
+/** Every area a route may declare. */
+export const PLUGIN_ROUTE_AREAS: PluginRouteArea[] = ["main"];
+
+/**
+ * Separates a plugin id from a route id. Not legal inside either half.
+ *
+ * The same separator `framework/plugin-routes` keys its generated module
+ * registry by, so a manifest entry's `id` *is* the key that registry is looked
+ * up with. Two layers, one identifier, and nothing has to translate between
+ * them.
+ */
+export const PLUGIN_ROUTE_ID_SEPARATOR = ":";
+
+/** One parsed segment of a canonical VitNode route path. */
+export type PluginRouteSegment =
+ { kind: "param"; name: string } | { kind: "static"; value: string };
+
+/**
+ * A page route contributed by a plugin, as the plugin declares it.
+ *
+ * Deliberately four fields, two of which are the two `framework/plugin-routes`
+ * already reads - so one list in a plugin's `routes/manifest.ts` serves both:
+ * the build tool takes `id` and `entry` and generates a lazy import, and this
+ * layer takes `path` and `area` and decides what URL that import answers.
+ *
+ * Everything else a page needs - its data, its metadata, its cache policy, who
+ * may see it - is either the component's business or a question this prototype
+ * has not earned an answer to yet.
+ */
+export interface PluginRouteDefinition {
+ /** Defaults to `"main"`. */
+ area?: PluginRouteArea;
+ /**
+ * Package export subpath of the module that renders this route, e.g.
+ * `"routes/example-page"`, imported as
+ * `"@vitnode/example/routes/example-page"`.
+ *
+ * A subpath rather than a full specifier, because the plugin id is already on
+ * the record; a subpath rather than a file path, so a plugin can move the
+ * implementation inside its `dist` without breaking every app that installs
+ * it; extensionless, because the plugin's export map adds the extension.
+ */
+ entry: string;
+ /**
+ * Stable identifier, unique within the plugin. It survives a path change -
+ * that is what makes it worth having - so name it after the page, not the URL.
+ */
+ id: string;
+ /**
+ * Canonical VitNode path: `/blog`, `/blog/:slug`, `/blog/:slug/comments`.
+ *
+ * Neither Next's `[slug]` nor TanStack's `$slug`. See `./path` for the
+ * conversions, and for the shapes this prototype rejects rather than guesses
+ * at.
+ */
+ path: string;
+}
+
+/** One route in a built manifest: validated, normalised and parsed. */
+export interface PluginRoute {
+ area: PluginRouteArea;
+ entry: string;
+ /**
+ * Globally unique, `":"` - and the key
+ * `framework/plugin-routes` registers the route's module loader under.
+ */
+ id: string;
+ /** Canonical path, normalised (no trailing slash). */
+ path: string;
+ pluginId: string;
+ /** The plugin-local half of {@link PluginRoute.id}, as declared. */
+ routeId: string;
+ /** `path`, already parsed - so nothing downstream has to parse it again. */
+ segments: PluginRouteSegment[];
+}
+
+/**
+ * Every plugin route in an application, deterministically ordered.
+ *
+ * A plain array rather than a wrapper object: it is a list of routes, and a
+ * wrapper would only be somewhere to put the fields this stage was asked not to
+ * invent.
+ */
+export type PluginRouteManifest = PluginRoute[];
+
+/**
+ * The part of a registered plugin the manifest reads.
+ *
+ * Structural, not `BuildPluginReturn`: that type reaches the AdminCP nav and the
+ * Content Engine, which reach React and Next, and this module has to stay
+ * loadable anywhere. A `BuildPluginReturn` satisfies this shape as it is.
+ */
+export interface PluginRouteSource {
+ pluginId: string;
+ routes?: PluginRouteDefinition[];
+}
diff --git a/packages/vitnode/src/views/search/search-feed-content.test.tsx b/packages/vitnode/src/views/search/search-feed-content.test.tsx
index c2b2f3901..679ff6d2d 100644
--- a/packages/vitnode/src/views/search/search-feed-content.test.tsx
+++ b/packages/vitnode/src/views/search/search-feed-content.test.tsx
@@ -23,12 +23,10 @@ vi.mock("@/lib/fetcher-client", () => ({
fetcherClient: (...args: unknown[]) => fetcherClient(...args),
}));
-const { classifySearchFeedHref, SearchFeedContent } = await import(
- "./search-feed-content"
-);
-const { searchFeedQueryKey, searchFeedQueryOptions } = await import(
- "./search-feed-query"
-);
+const { classifySearchFeedHref, SearchFeedContent } =
+ await import("./search-feed-content");
+const { searchFeedQueryKey, searchFeedQueryOptions } =
+ await import("./search-feed-query");
const messages = {
core: {
@@ -408,6 +406,7 @@ describe("the loading state", () => {
it("renders skeletons until the first page arrives", async () => {
let resolvePage: (value: {
json: () => Promise;
+ ok: boolean;
}) => void = () => undefined;
fetcherClient.mockReturnValue(
@@ -422,7 +421,10 @@ describe("the loading state", () => {
container.querySelectorAll('[data-slot="skeleton"]').length,
).toBeGreaterThan(0);
- resolvePage({ ok: true, json: async () => Promise.resolve(page([item()])) });
+ resolvePage({
+ ok: true,
+ json: async () => Promise.resolve(page([item()])),
+ });
expect(await screen.findByText("First post")).toBeDefined();
});
});
diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx
index 1c3809dce..3c05e9fc1 100644
--- a/plugins/example/src/config.tsx
+++ b/plugins/example/src/config.tsx
@@ -6,6 +6,7 @@ import { articleContentType } from "@/content/article";
import { categoryContentType } from "@/content/category";
import messages from "./locales";
+import { routes } from "./routes/manifest";
/**
* Registering the content types is the whole frontend integration: the AdminCP
@@ -15,6 +16,9 @@ export const examplePlugin = () =>
buildPlugin({
pluginId: "@vitnode/example",
messages,
+ // Stage 5: the same list `routes/manifest.ts` hands the build tool, so a
+ // route is declared once whichever path an app reads it through.
+ routes,
contentTypes: [
contentTypeAdmin({
definition: articleContentType,
diff --git a/plugins/example/src/routes/example-page.tsx b/plugins/example/src/routes/example-page.tsx
new file mode 100644
index 000000000..6807bf51a
--- /dev/null
+++ b/plugins/example/src/routes/example-page.tsx
@@ -0,0 +1,31 @@
+/**
+ * The page `routes/manifest.ts` declares, and the first plugin route module a
+ * VitNode app bundles rather than copies.
+ *
+ * Zero imports, which is the point rather than an accident. It is compiled into
+ * this package's `dist` and imported by the app as
+ * `@vitnode/example/routes/example-page`, so it has to be renderable by whatever
+ * framework the app happens to use - and today those are Next.js and TanStack
+ * Start at the same time. Anything from `next/*`, `next-intl` or a router would
+ * pin it to one of them; a component that only needs JSX is pinned to neither.
+ *
+ * It exports a default component because that is how every VitNode plugin page
+ * already exports itself, and because a default export is the one name a
+ * generated registry can rely on without being told.
+ */
+const ExamplePage = () => (
+
+
+ Example plugin route
+
+
+
+ This page lives in @vitnode/example and is served by the app
+ that installed it. It was never copied into the app's source: the app
+ generated a literal import for it from the plugin's route manifest,
+ and the bundler put it in its own chunk.
+
+
+);
+
+export default ExamplePage;
diff --git a/plugins/example/src/routes/manifest.ts b/plugins/example/src/routes/manifest.ts
new file mode 100644
index 000000000..8b28bc9d1
--- /dev/null
+++ b/plugins/example/src/routes/manifest.ts
@@ -0,0 +1,37 @@
+import type { PluginRouteDefinition } from "@vitnode/core/routing";
+
+/**
+ * The routes this plugin contributes to whatever app installs it.
+ *
+ * Plain data, and framework-neutral by construction: an `entry` is a *package
+ * export subpath*, so `"routes/example-page"` is imported as
+ * `"@vitnode/example/routes/example-page"` and resolves through this package's
+ * export map to its build output. Nothing here imports a router, and nothing
+ * here imports a page - so an app can read this list at build time, in Node,
+ * without pulling a single React component into the process.
+ *
+ * That is what lets the app generate literal `import()` calls for these modules
+ * instead of building specifiers at runtime: the ids and entries are known before
+ * the bundler runs, so Rollup gives each page its own lazily fetched chunk and
+ * the browser never has to ask which plugins are installed.
+ *
+ * Route *semantics* - the URL a route is served at, its area, its loader, its
+ * metadata, its permissions - belong on these records too, and are owned by the
+ * plugin route manifest contract (`@vitnode/core/routing`) rather than by the
+ * two fields the build reads. `path` is the first of them: `/example` in the
+ * canonical VitNode spelling, which is neither Next's `[id]` nor TanStack's
+ * `$id`, and `area` defaults to `"main"`. The registry generator reads `id` and
+ * `entry` and ignores the rest, so this list can keep growing without the build
+ * changing.
+ *
+ * `config.tsx` hands this same array to `buildPlugin({ routes })`, so a Next.js
+ * app that registers the plugin the usual way declares exactly the same routes -
+ * one list, read by both paths.
+ */
+export const routes: PluginRouteDefinition[] = [
+ {
+ entry: "routes/example-page",
+ id: "example-page",
+ path: "/example",
+ },
+];
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd6ff12ab..74f2360e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -383,6 +383,9 @@ importers:
eslint:
specifier: ^10.7.0
version: 10.7.0(jiti@2.7.0)
+ jiti:
+ specifier: ^2.7.0
+ version: 2.7.0
jsdom:
specifier: ^29.1.1
version: 29.1.1