Skip to content

Instantly share code, notes, and snippets.

@jacob-ebey
Last active December 21, 2022 22:14
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/cbb2bd5f91e9cbef3a7d1d429babfb1c to your computer and use it in GitHub Desktop.
Save jacob-ebey/cbb2bd5f91e9cbef3a7d1d429babfb1c to your computer and use it in GitHub Desktop.
Universal Flat Routes
/**
* 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 === ".";
}
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: ".[.*].",
});
});
});
});
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