Last active
December 21, 2022 22:14
-
-
Save jacob-ebey/cbb2bd5f91e9cbef3a7d1d429babfb1c to your computer and use it in GitHub Desktop.
Universal Flat 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
/** | |
* Create route configs from a list of routes using the flat routes conventions. | |
* @param appDirectory The absolute root directory the routes were looked up from. | |
* @param routePaths The absolute route paths. | |
* @param prefix The prefix to strip off of the routes. | |
*/ | |
export function flatRoutesUniversal( | |
appDirectory: string, | |
routePaths: string[], | |
prefix: string = "routes" | |
) { | |
const routes = {}; | |
const sortedRoutes = routePaths | |
.sort((a, b) => (a.length - b.length > 0 ? 1 : -1)) | |
.map((routePath) => | |
routePath.slice(appDirectory.length + 1).replace(/\\/g, "/") | |
); | |
const processedIds: string[] = []; | |
for (const routePath of sortedRoutes) { | |
const routeId = routeIdFromPath(routePath); | |
if (routes[routeId]) { | |
throw new Error(`Duplicate route: ${routeId}`); | |
} | |
let parentId = ""; | |
for (let processedId of processedIds) { | |
if (routeId.startsWith(processedId.replace(/\$$/, "*"))) { | |
parentId = processedId; | |
break; | |
} | |
} | |
routes[routeId] = { | |
id: routeId, | |
path: pathFromRouteId(routeId, parentId || prefix), | |
parentId: parentId || "root", | |
file: routePath, | |
}; | |
if (isIndexRoute(routeId)) { | |
routes[routeId].index = true; | |
} | |
processedIds.unshift(routeId); | |
} | |
return Object.values(routes); | |
} | |
function routeIdFromPath(relativePath: string) { | |
return ( | |
relativePath | |
.split(".") | |
// remove file extension | |
.slice(0, -1) | |
.join(".") | |
); | |
} | |
function pathFromRouteId(routeId: string, parentId: string) { | |
let parentPath = ""; | |
if (parentId) { | |
parentPath = getRouteSegments(parentId, true)[0].join("/"); | |
} | |
if (parentPath.startsWith("/")) { | |
parentPath = parentPath.substring(1); | |
} | |
let routePath = getRouteSegments(routeId, true)[0].join("/"); | |
if (routePath.startsWith("/")) { | |
routePath = routePath.substring(1); | |
} | |
let pathname = parentPath | |
? routePath.slice(parentPath.length + 1) | |
: routePath; | |
if (pathname.endsWith("index") || pathname.endsWith("/_index")) { | |
pathname = pathname.replace(/index$/, ""); | |
} | |
if (pathname.startsWith("/")) { | |
pathname = pathname.substring(1); | |
} | |
return pathname || undefined; | |
} | |
function isIndexRoute(routeId) { | |
return routeId.endsWith("index"); | |
} | |
// TODO: Add support for optional segments | |
function getRouteSegments(name: string, toPath: boolean = true) { | |
const routeSegments: string[] = []; | |
const separators: string[] = []; | |
let index = 0; | |
let routeSegment = ""; | |
let state = "START"; | |
let subState = "NORMAL"; | |
const pushRouteSegment = (routeSegment) => { | |
if (routeSegment) { | |
routeSegments.push(routeSegment); | |
} | |
}; | |
while (index < name.length) { | |
const char = name[index]; | |
index++; // advance to next character | |
if (state == "START") { | |
// process existing segment | |
pushRouteSegment(routeSegment); | |
routeSegment = ""; | |
state = "PATH"; | |
subState = "NORMAL"; | |
if (char === "_") { | |
subState = "PATHLESS"; | |
} | |
} | |
if (state == "PATH") { | |
switch (subState) { | |
case "PATHLESS": | |
if (isPathSeparator(char)) { | |
state = "START"; | |
break; | |
} | |
break; | |
case "NORMAL": | |
if (isPathSeparator(char)) { | |
state = "START"; | |
separators.push(char); | |
break; | |
} | |
if (toPath && char === "[") { | |
subState = "ESCAPE"; | |
break; | |
} | |
if (toPath && !routeSegment && char == "$") { | |
if (index === name.length) { | |
routeSegment += "*"; | |
} else { | |
routeSegment += ":"; | |
} | |
break; | |
} | |
routeSegment += char; | |
break; | |
case "ESCAPE": | |
if ( | |
toPath && | |
char === "]" && | |
name[index - 1] !== "[" && | |
name[index + 1] !== "]" | |
) { | |
subState = "NORMAL"; | |
break; | |
} | |
routeSegment += char; | |
break; | |
} | |
} | |
} | |
// process remaining segment | |
pushRouteSegment(routeSegment); | |
return [routeSegments, separators]; | |
} | |
function isPathSeparator(char) { | |
return char === "/" || char === "."; | |
} |
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 { describe, it } from "node:test"; | |
import { expect } from "expect"; | |
import { flatRoutesUniversal } from "../src"; | |
describe("flatRoutesUniversal", () => { | |
describe("default paths", () => { | |
it("should return the correct route hierarchy", () => { | |
const files = [ | |
"/test/root/app/routes/$.tsx", | |
"/test/root/app/routes/index.tsx", | |
"/test/root/app/routes/about.tsx", | |
"/test/root/app/routes/about.index.tsx", | |
"/test/root/app/routes/about.faq.tsx", | |
"/test/root/app/routes/about.$splat.tsx", | |
"/test/root/app/routes/about.$.tsx", | |
// escape special characters | |
"/test/root/app/routes/about.[$splat].tsx", | |
"/test/root/app/routes/about.[[].tsx", | |
"/test/root/app/routes/about.[]].tsx", | |
"/test/root/app/routes/about.[.].tsx", | |
"/test/root/app/routes/about.[*].tsx", | |
"/test/root/app/routes/about.[.[.*].].tsx", | |
]; | |
const routes = flatRoutesUniversal("/test/root/app", files, "routes"); | |
expect(routes).toHaveLength(files.length); | |
expect(routes).toContainEqual({ | |
id: "routes/$", | |
parentId: "root", | |
file: "routes/$.tsx", | |
path: "*", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/index", | |
parentId: "root", | |
file: "routes/index.tsx", | |
index: true, | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about", | |
parentId: "root", | |
file: "routes/about.tsx", | |
path: "about", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.index", | |
parentId: "routes/about", | |
file: "routes/about.index.tsx", | |
index: true, | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.faq", | |
parentId: "routes/about", | |
file: "routes/about.faq.tsx", | |
path: "faq", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.$splat", | |
parentId: "routes/about", | |
file: "routes/about.$splat.tsx", | |
path: ":splat", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.$", | |
parentId: "routes/about", | |
file: "routes/about.$.tsx", | |
path: "*", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.[$splat]", | |
parentId: "routes/about", | |
file: "routes/about.[$splat].tsx", | |
path: "$splat", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.[[]", | |
parentId: "routes/about", | |
file: "routes/about.[[].tsx", | |
path: "[", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.[]]", | |
parentId: "routes/about", | |
file: "routes/about.[]].tsx", | |
path: "]", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.[.]", | |
parentId: "routes/about", | |
file: "routes/about.[.].tsx", | |
path: ".", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.[*]", | |
parentId: "routes/about", | |
file: "routes/about.[*].tsx", | |
path: "*", | |
}); | |
expect(routes).toContainEqual({ | |
id: "routes/about.[.[.*].]", | |
parentId: "routes/about", | |
file: "routes/about.[.[.*].].tsx", | |
path: ".[.*].", | |
}); | |
}); | |
}); | |
}); |
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 * as path from "node:path"; | |
import fg from "fast-glob"; | |
import { flatRoutesUniversal } from "flat-routes-universal"; | |
/** @type {import('@remix-run/dev').AppConfig} */ | |
export default { | |
serverModuleFormat: "esm", | |
devServerBroadcastDelay: 2000, | |
ignoredRouteFiles: ["**/*.*"], | |
routes: async () => { | |
const appDirectory = path.resolve(path.join(process.cwd(), "app")); | |
const routePaths = fg.sync("routes/*.{ts,tsx}", { | |
absolute: true, | |
cwd: appDirectory, | |
}); | |
const routes = flatRoutesUniversal(appDirectory, routePaths); | |
console.log({ routePaths, routes }); | |
return routes; | |
}, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment