Last active
December 4, 2023 09:13
-
-
Save jacob-ebey/436026784a377066f30faae9ef5d4004 to your computer and use it in GitHub Desktop.
vite-react-router-remix-glob-routes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, ""); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Use this instead: https://gist.github.com/jacob-ebey/0ae28137fb7be1dba93702a0d612bcad