Skip to content

Instantly share code, notes, and snippets.

@jacob-ebey
Last active December 4, 2023 09:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jacob-ebey/436026784a377066f30faae9ef5d4004 to your computer and use it in GitHub Desktop.
Save jacob-ebey/436026784a377066f30faae9ef5d4004 to your computer and use it in GitHub Desktop.
vite-react-router-remix-glob-routes
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { globRoutes } from "@/lib/routes";
const router = createBrowserRouter(
globRoutes(import.meta.glob("./routes/**/route.tsx"))
);
function App() {
return <RouterProvider router={router} />;
}
export default App;
import type { DataRouteObject } from "react-router-dom";
export let paramPrefixChar = "$" as const;
export let escapeStart = "[" as const;
export let escapeEnd = "]" as const;
export let optionalStart = "(" as const;
export let optionalEnd = ")" as const;
type RouteModules = Record<string, unknown>;
type RouteManifest = Record<string, RouteInfo>;
type RouteInfo = {
file: string;
id: string;
parentId?: string;
path?: string;
index?: boolean;
};
export function globRoutes(
globRoutes: Record<string, () => Promise<unknown>>,
prefix: string = "routes"
) {
const manifest = flatRoutesUniversal(
".",
Object.entries(globRoutes)
.map(([path]) => path)
.sort((pathA, pathB) => pathA.length - pathB.length),
prefix
);
return createClientRoutes(
manifest,
Object.fromEntries(
Object.entries(globRoutes).map(([id, mod]) => [
id.slice(1).replace(/\/route\.tsx$/, ""),
mod,
])
)
);
}
const groupRoutesByParentId = (manifest: RouteManifest) => {
let routes: Record<string, RouteInfo[]> = {};
Object.values(manifest).forEach((route) => {
let parentId = route.parentId || "";
if (!routes[parentId]) {
routes[parentId] = [];
}
routes[parentId].push(route);
});
return routes;
};
function createClientRoutes(
manifest: RouteManifest,
routeModulesCache: RouteModules,
parentId: string = "",
routesByParentId: Record<string, RouteInfo[]> = groupRoutesByParentId(
manifest
),
needsRevalidation?: Set<string>
): DataRouteObject[] {
return (routesByParentId[parentId] || []).map((route) => {
let routeModule = routeModulesCache?.[route.id] as any;
let dataRoute: DataRouteObject = {
id: route.id,
index: route.index,
path: route.path,
lazy: routeModule,
};
let children = createClientRoutes(
manifest,
routeModulesCache,
route.id,
routesByParentId,
needsRevalidation
);
if (children.length > 0) dataRoute.children = children;
return dataRoute;
});
}
function flatRoutesUniversal(
appDirectory: string,
routes: string[],
prefix: string = "routes"
): RouteManifest {
let urlConflicts = new Map<string, RouteInfo[]>();
let routeManifest: RouteManifest = {};
let prefixLookup = new PrefixLookupTrie();
let uniqueRoutes = new Map<string, RouteInfo>();
let routeIdConflicts = new Map<string, string[]>();
// id -> file
let routeIds = new Map<string, string>();
for (let file of routes) {
let normalizedFile = normalizeSlashes(file);
let routeExt = normalizedFile.split(".").pop() || "";
let routeDir = normalizedFile.split("/").slice(0, -1).join("/");
let normalizedApp = normalizeSlashes(appDirectory);
let routeId =
routeDir === pathJoin(normalizedApp, prefix)
? pathRelative(normalizedApp, normalizedFile).slice(0, -routeExt.length)
: pathRelative(normalizedApp, routeDir);
let conflict = routeIds.get(routeId);
if (conflict) {
let currentConflicts = routeIdConflicts.get(routeId);
if (!currentConflicts) {
currentConflicts = [pathRelative(normalizedApp, conflict)];
}
currentConflicts.push(pathRelative(normalizedApp, normalizedFile));
routeIdConflicts.set(routeId, currentConflicts);
continue;
}
routeIds.set(routeId, normalizedFile);
}
let sortedRouteIds = Array.from(routeIds).sort(
([a], [b]) => b.length - a.length
);
for (let [routeId, file] of sortedRouteIds) {
let index = routeId.endsWith("_index");
let [segments, raw] = getRouteSegments(routeId.slice(prefix.length + 1));
let pathname = createRoutePath(segments, raw, index);
routeManifest[routeId] = {
file: file.slice(appDirectory.length + 1),
id: routeId,
path: pathname,
};
if (index) routeManifest[routeId].index = true;
let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => {
return [".", "/"].includes(value.slice(routeId.length).charAt(0));
});
prefixLookup.add(routeId);
if (childRouteIds.length > 0) {
for (let childRouteId of childRouteIds) {
routeManifest[childRouteId].parentId = routeId;
}
}
}
// path creation
let parentChildrenMap = new Map<string, RouteInfo[]>();
for (let [routeId] of sortedRouteIds) {
let config = routeManifest[routeId];
if (!config.parentId) continue;
let existingChildren = parentChildrenMap.get(config.parentId) || [];
existingChildren.push(config);
parentChildrenMap.set(config.parentId, existingChildren);
}
for (let [routeId] of sortedRouteIds) {
let config = routeManifest[routeId];
let originalPathname = config.path || "";
let pathname = config.path;
let parentConfig = config.parentId ? routeManifest[config.parentId] : null;
if (parentConfig?.path && pathname) {
pathname = pathname
.slice(parentConfig.path.length)
.replace(/^\//, "")
.replace(/\/$/, "");
}
if (!config.parentId) config.parentId = "";
config.path = pathname || undefined;
/**
* We do not try to detect path collisions for pathless layout route
* files because, by definition, they create the potential for route
* collisions _at that level in the tree_.
*
* Consider example where a user may want multiple pathless layout routes
* for different subfolders
*
* routes/
* account.tsx
* account._private.tsx
* account._private.orders.tsx
* account._private.profile.tsx
* account._public.tsx
* account._public.login.tsx
* account._public.perks.tsx
*
* In order to support both a public and private layout for `/account/*`
* URLs, we are creating a mutually exclusive set of URLs beneath 2
* separate pathless layout routes. In this case, the route paths for
* both account._public.tsx and account._private.tsx is the same
* (/account), but we're again not expecting to match at that level.
*
* By only ignoring this check when the final portion of the filename is
* pathless, we will still detect path collisions such as:
*
* routes/parent._pathless.foo.tsx
* routes/parent._pathless2.foo.tsx
*
* and
*
* routes/parent._pathless/index.tsx
* routes/parent._pathless2/index.tsx
*/
let lastRouteSegment = config.id
.replace(new RegExp(`^${prefix}/`), "")
.split(".")
.pop();
let isPathlessLayoutRoute =
lastRouteSegment &&
lastRouteSegment.startsWith("_") &&
lastRouteSegment !== "_index";
if (isPathlessLayoutRoute) {
continue;
}
let conflictRouteId = originalPathname + (config.index ? "?index" : "");
let conflict = uniqueRoutes.get(conflictRouteId);
uniqueRoutes.set(conflictRouteId, config);
if (conflict && (originalPathname || config.index)) {
let currentConflicts = urlConflicts.get(originalPathname);
if (!currentConflicts) currentConflicts = [conflict];
currentConflicts.push(config);
urlConflicts.set(originalPathname, currentConflicts);
continue;
}
}
if (routeIdConflicts.size > 0) {
for (let [routeId, files] of routeIdConflicts.entries()) {
console.error(getRouteIdConflictErrorMessage(routeId, files));
}
}
// report conflicts
if (urlConflicts.size > 0) {
for (let [path, routes] of urlConflicts.entries()) {
// delete all but the first route from the manifest
for (let i = 1; i < routes.length; i++) {
delete routeManifest[routes[i].id];
}
let files = routes.map((r) => r.file);
console.error(getRoutePathConflictErrorMessage(path, files));
}
}
return routeManifest;
}
export function normalizeSlashes(file: string) {
return file.split("\\").join("/");
}
type State =
| // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\')
"NORMAL"
// we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks
| "ESCAPE"
// we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence
| "OPTIONAL"
// we previously were in a opt fional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state
| "OPTIONAL_ESCAPE";
export function getRouteSegments(routeId: string): [string[], string[]] {
let routeSegments: string[] = [];
let rawRouteSegments: string[] = [];
let index = 0;
let routeSegment = "";
let rawRouteSegment = "";
let state: State = "NORMAL";
let pushRouteSegment = (segment: string, rawSegment: string) => {
if (!segment) return;
let notSupportedInRR = (segment: string, char: string) => {
throw new Error(
`Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` +
`If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.`
);
};
if (rawSegment.includes("*")) {
return notSupportedInRR(rawSegment, "*");
}
if (rawSegment.includes(":")) {
return notSupportedInRR(rawSegment, ":");
}
if (rawSegment.includes("/")) {
return notSupportedInRR(segment, "/");
}
routeSegments.push(segment);
rawRouteSegments.push(rawSegment);
};
while (index < routeId.length) {
let char = routeId[index];
index++; //advance to next char
switch (state) {
case "NORMAL": {
if (isSegmentSeparator(char)) {
pushRouteSegment(routeSegment, rawRouteSegment);
routeSegment = "";
rawRouteSegment = "";
state = "NORMAL";
break;
}
if (char === escapeStart) {
state = "ESCAPE";
rawRouteSegment += char;
break;
}
if (char === optionalStart) {
state = "OPTIONAL";
rawRouteSegment += char;
break;
}
if (!routeSegment && char == paramPrefixChar) {
if (index === routeId.length) {
routeSegment += "*";
rawRouteSegment += char;
} else {
routeSegment += ":";
rawRouteSegment += char;
}
break;
}
routeSegment += char;
rawRouteSegment += char;
break;
}
case "ESCAPE": {
if (char === escapeEnd) {
state = "NORMAL";
rawRouteSegment += char;
break;
}
routeSegment += char;
rawRouteSegment += char;
break;
}
case "OPTIONAL": {
if (char === optionalEnd) {
routeSegment += "?";
rawRouteSegment += char;
state = "NORMAL";
break;
}
if (char === escapeStart) {
state = "OPTIONAL_ESCAPE";
rawRouteSegment += char;
break;
}
if (!routeSegment && char === paramPrefixChar) {
if (index === routeId.length) {
routeSegment += "*";
rawRouteSegment += char;
} else {
routeSegment += ":";
rawRouteSegment += char;
}
break;
}
routeSegment += char;
rawRouteSegment += char;
break;
}
case "OPTIONAL_ESCAPE": {
if (char === escapeEnd) {
state = "OPTIONAL";
rawRouteSegment += char;
break;
}
routeSegment += char;
rawRouteSegment += char;
break;
}
}
}
// process remaining segment
pushRouteSegment(routeSegment, rawRouteSegment);
return [routeSegments, rawRouteSegments];
}
export function createRoutePath(
routeSegments: string[],
rawRouteSegments: string[],
isIndex?: boolean
) {
let result: string[] = [];
if (isIndex) {
routeSegments = routeSegments.slice(0, -1);
}
for (let index = 0; index < routeSegments.length; index++) {
let segment = routeSegments[index];
let rawSegment = rawRouteSegments[index];
// skip pathless layout segments
if (segment.startsWith("_") && rawSegment.startsWith("_")) {
continue;
}
// remove trailing slash
if (segment.endsWith("_") && rawSegment.endsWith("_")) {
segment = segment.slice(0, -1);
}
result.push(segment);
}
return result.length ? result.join("/") : undefined;
}
export function getRoutePathConflictErrorMessage(
pathname: string,
routes: string[]
) {
let [taken, ...others] = routes;
if (!pathname.startsWith("/")) {
pathname = "/" + pathname;
}
return (
`⚠️ Route Path Collision: "${pathname}"\n\n` +
`The following routes all define the same URL, only the first one will be used\n\n` +
`🟢 ${taken}\n` +
others.map((route) => `⭕️️ ${route}`).join("\n") +
"\n"
);
}
export function getRouteIdConflictErrorMessage(
routeId: string,
files: string[]
) {
let [taken, ...others] = files;
return (
`⚠️ Route ID Collision: "${routeId}"\n\n` +
`The following routes all define the same Route ID, only the first one will be used\n\n` +
`🟢 ${taken}\n` +
others.map((route) => `⭕️️ ${route}`).join("\n") +
"\n"
);
}
export function isSegmentSeparator(checkChar: string | undefined) {
if (!checkChar) return false;
return ["/", ".", "\\"].includes(checkChar);
}
const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol");
type PrefixLookupNode = {
[key: string]: PrefixLookupNode;
} & Record<typeof PrefixLookupTrieEndSymbol, boolean>;
class PrefixLookupTrie {
root: PrefixLookupNode = {
[PrefixLookupTrieEndSymbol]: false,
};
add(value: string) {
if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie");
let node = this.root;
for (let char of value) {
if (!node[char]) {
node[char] = {
[PrefixLookupTrieEndSymbol]: false,
};
}
node = node[char];
}
node[PrefixLookupTrieEndSymbol] = true;
}
findAndRemove(
prefix: string,
filter: (nodeValue: string) => boolean
): string[] {
let node = this.root;
for (let char of prefix) {
if (!node[char]) return [];
node = node[char];
}
return this.#findAndRemoveRecursive([], node, prefix, filter);
}
#findAndRemoveRecursive(
values: string[],
node: PrefixLookupNode,
prefix: string,
filter: (nodeValue: string) => boolean
): string[] {
for (let char of Object.keys(node)) {
this.#findAndRemoveRecursive(values, node[char], prefix + char, filter);
}
if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) {
node[PrefixLookupTrieEndSymbol] = false;
values.push(prefix);
}
return values;
}
}
function pathJoin(a: string, b: string) {
return a + "/" + b;
}
function pathRelative(a: string, b: string) {
return b.replace(a, "");
}
@jacob-ebey
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment