nextmaps
Version:
A package that returns json object of your Next.js routes
439 lines (396 loc) • 14.6 kB
text/typescript
import fs from "fs";
import path from "path";
// File patterns for API and auxiliary files
const FILE_PATTERNS = {
ROUTE: "route.ts",
LOADING: "loading.tsx",
ERROR: "error.tsx",
};
// Allowed page file extensions (and for layout files)
const allowedExtensions = ["tsx", "ts", "jsx", "js", "md", "mdx"];
// Determine the app directory
const appDirectory =
fs.existsSync(path.join(process.cwd(), "app"))
? path.join(process.cwd(), "app")
: fs.existsSync(path.join(process.cwd(), "src/app"))
? path.join(process.cwd(), "src/app")
: null;
if (!appDirectory) {
throw new Error("App directory not found");
}
const fileContentsCache = new Map<string, string>();
let currentFilePath: string | null = null;
let currentFileContent: string | null = null;
function setCurrentFile(filePath: string) {
currentFilePath = filePath;
if (fileContentsCache.has(filePath)) {
currentFileContent = fileContentsCache.get(filePath)!;
} else {
currentFileContent = fs.readFileSync(filePath, "utf8");
fileContentsCache.set(filePath, currentFileContent);
}
}
/**
* Represents a URL segment computed from a folder name.
*/
interface RouteSegment {
urlSegment: string; // What appears in the URL (for example: "blog", "..." or ".../..." for catch-all)
displayName: string; // A “clean” name for display (e.g. dynamic segments without brackets)
}
/**
* Given a folder name, returns a RouteSegment or null if that folder is not meant to affect the URL.
*
* Rules:
* - Folders starting with "@" (parallel routes) are ignored.
* - Folders that are entirely wrapped in parentheses (e.g. `(group)`) are route groups and are omitted.
* - Intercepting folders such as `(.)photo` have their intercept prefix stripped.
* - Optional catch-all segments (e.g. `[[...slug]]`) and catch-all segments (e.g. `[...slug]`)
* are rendered as two sets of ellipsis (".../..."), and the display name is the parameter name.
* - Standard dynamic segments (e.g. `[post]`) are rendered as a single ellipsis ("...").
* - Otherwise, the folder name is used as a static segment.
*/
function getRouteSegment(folderName: string): RouteSegment | null {
// Ignore parallel route folders.
if (folderName.startsWith("@")) return null;
// Intercepting routes: e.g. "(..)photo" or "(.)photo" → remove the intercept prefix.
const interceptMatch = folderName.match(/^\((\.+)\)(.+)$/);
if (interceptMatch) {
return { urlSegment: interceptMatch[2], displayName: interceptMatch[2] };
}
// Route groups: if the folder is entirely wrapped in parentheses (e.g. "(showcase)"), ignore it.
if (/^\(.*\)$/.test(folderName)) {
return null;
}
// Optional catch-all: [[...slug]]
const optionalCatchAllMatch = folderName.match(/^\[\[\.\.\.(.+)\]\]$/);
if (optionalCatchAllMatch) {
const paramName = optionalCatchAllMatch[1];
return { urlSegment: ".../...", displayName: paramName };
}
// Catch-all: [...slug]
const catchAllMatch = folderName.match(/^\[\.\.\.(.+)\]$/);
if (catchAllMatch) {
const paramName = catchAllMatch[1];
return { urlSegment: ".../...", displayName: paramName };
}
// Standard dynamic segment: [param]
if (folderName.startsWith("[") && folderName.endsWith("]")) {
const paramName = folderName.slice(1, -1);
return { urlSegment: "...", displayName: paramName };
}
// Otherwise, return the static folder name.
return { urlSegment: folderName, displayName: folderName };
}
/**
* Searches upward from a given page file's directory for a layout file.
* Returns the first layout file found (relative to process.cwd()), or null if none is found.
*/
function getActiveLayoutFile(pageFilePath: string): string | null {
let dir = path.dirname(pageFilePath);
// Loop until we reach the appDirectory (or beyond)
while (dir.startsWith(appDirectory)) {
for (const ext of allowedExtensions) {
const layoutPath = path.join(dir, `layout.${ext}`);
if (fs.existsSync(layoutPath)) {
return path.relative(process.cwd(), layoutPath);
}
}
// Move one directory up.
const parentDir = path.dirname(dir);
if (parentDir === dir) break;
dir = parentDir;
}
return null;
}
/**
* Checks for a metadata export.
*/
function hasMetadata() {
const metadataPatterns = [
{ pattern: "export const metadata", returnVal: "metadata" },
{ pattern: "export async function generateMetadata", returnVal: "generateMetadata" },
{ pattern: "export function generateMetadata", returnVal: "generateMetadata" },
{ pattern: "export const generateMetadata", returnVal: "generateMetadata" },
{ pattern: "export let metadata", returnVal: "metadata" },
{ pattern: "export var metadata", returnVal: "metadata" },
{ pattern: "export const metadata:", returnVal: "metadata" },
{ pattern: "export const generateMetadata:", returnVal: "generateMetadata" },
];
const found = metadataPatterns.find(({ pattern }) =>
currentFileContent!.includes(pattern)
);
return found ? found.returnVal : null;
}
/**
* Checks if the file is a client component.
* Only checks the very top of the file (after trimming whitespace).
*/
function isClientComponent() {
const trimmed = currentFileContent!.trimStart();
return trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'");
}
/**
* Checks if the file has a server action directive.
* Only checks the very top of the file.
*/
function hasServerAction() {
const trimmed = currentFileContent!.trimStart();
return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'");
}
function extractDynamicValue() {
const match = currentFileContent!.match(
/export (?:const|let|var) dynamic\s*=\s*['"]([^'"]+)['"]/
);
return match ? match[1] : "";
}
function extractRevalidateValue() {
const match = currentFileContent!.match(
/export (?:const|let|var) revalidate\s*=\s*(\d+)/
);
return match ? match[1] : "";
}
function extractFetchCacheValue() {
const match = currentFileContent!.match(
/export (?:const|let|var) fetchCache\s*=\s*['"]([^'"]+)['"]/
);
return match ? match[1] : "";
}
function hasParallelRoute() {
return currentFilePath!.includes("@");
}
function hasInterceptingRoute() {
return (
currentFilePath!.includes("(.)") ||
currentFilePath!.includes("(..)") ||
currentFilePath!.includes("(...)")
);
}
/**
* Extracts exported functions from the current file.
*
* Returns an object with:
* - defaultExport: the name of the default export (if any)
* - namedExports: an array of names for other exported functions
*
* It looks for function declarations (including async) as well as arrow function exports.
*/
function extractExportedFunctions() {
let defaultExport = "";
const defaultMatch =
currentFileContent!.match(/export default (async\s+function\s+|function\s+)?(\w+)/);
if (defaultMatch) {
defaultExport = defaultMatch[2] || defaultMatch[1] || "";
}
// Find non-default function declarations.
const namedDeclarationMatches = [
...currentFileContent!.matchAll(/export\s+(?!default)(?:async\s+)?function\s+(\w+)/g),
].map(match => match[1]);
// Find non-default arrow function exports.
const arrowMatches = [
...currentFileContent!.matchAll(/export\s+(?!default)(?:const|let|var)\s+(\w+)\s*=\s*\(?.*?\)?\s*=>/g),
].map(match => match[1]);
// Merge and remove duplicates.
const namedExports = Array.from(new Set([...namedDeclarationMatches, ...arrowMatches]));
return { defaultExport, namedExports };
}
/**
* Extracts the HTTP methods from an API route file.
*/
function extractHttpMethods() {
const methods: string[] = [];
const methodPatterns = [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"OPTIONS",
"HEAD",
];
// Check for a destructured handlers pattern (e.g. export const { GET, POST } = handlers)
const handlersMatch = currentFileContent!.match(
/export const \{([^}]+)\}\s*=\s*handlers/
);
if (handlersMatch) {
const destructuredMethods = handlersMatch[1]
.split(",")
.map((m) => m.trim());
return [destructuredMethods.join(" | ")];
}
// Otherwise, look for individual async function exports.
methodPatterns.forEach((method) => {
if (currentFileContent!.includes(`export async function ${method}`)) {
methods.push(method);
}
});
return methods.length > 0 ? methods : ["GET"];
}
/**
* Recursively lists all page routes as JSON objects.
*
* For each page file, we compute:
* - routePath: the URL a user would type (joining computed segments).
* - routeName: the display name from the last segment.
* - parentRoute: the URL of the parent route (all segments except the last).
* - activeLayout: the active layout file for that page (if one is found).
* - exportedFunctions: an object with the default export and any named exports.
*/
function listRoutes(
dir: string,
segments: RouteSegment[] = []
): any[] {
let routes: any[] = [];
try {
fs.readdirSync(dir, { withFileTypes: true }).forEach((dirent) => {
const fullPath = path.join(dir, dirent.name);
if (dirent.isDirectory() && !dirent.name.startsWith("_")) {
const seg = getRouteSegment(dirent.name);
const newSegments = seg ? [...segments, seg] : segments;
routes = routes.concat(listRoutes(fullPath, newSegments));
} else if (dirent.isFile()) {
const match = dirent.name.match(
/^page\.((?:tsx|ts|jsx|js|md|mdx))$/
);
if (match) {
setCurrentFile(fullPath);
// Compute the routePath by joining all segments.
let routePath =
segments.length > 0
? "/" + segments.map((s) => s.urlSegment).join("/")
: "/";
// If the last segment is a standard dynamic segment ("...") and the routePath doesn’t already end with a slash, add one.
if (
segments.length > 0 &&
segments[segments.length - 1].urlSegment === "..." &&
!routePath.endsWith("/")
) {
routePath += "/";
}
const parentRoute =
segments.length > 1
? "/" +
segments
.slice(0, segments.length - 1)
.map((s) => s.urlSegment)
.join("/")
: "/";
const routeName =
segments.length > 0 ? segments[segments.length - 1].displayName : "/";
const exportedFunctions = extractExportedFunctions();
const componentType = isClientComponent() ? "use client" : "server";
const metadataExport = hasMetadata();
const serverActionDirective = hasServerAction();
const dynamicValue = extractDynamicValue();
const revalidateValue = extractRevalidateValue();
const fetchCacheValue = extractFetchCacheValue();
const isParallel = hasParallelRoute();
const isIntercepting = hasInterceptingRoute();
const loadingFile = hasLoadingFile();
const errorFile = hasErrorFile();
const fileLocation = path.relative(process.cwd(), fullPath);
const activeLayout = getActiveLayoutFile(fullPath);
routes.push({
type: "page",
routeName,
routePath,
parentRoute,
file: fileLocation,
extension: match[1],
exportedFunctions, // contains { defaultExport, namedExports }
componentType,
metadata: metadataExport,
serverAction: serverActionDirective,
dynamic: dynamicValue,
revalidate: revalidateValue,
fetchCache: fetchCacheValue,
isParallel,
isIntercepting,
hasLoadingFile: loadingFile,
hasErrorFile: errorFile,
activeLayout,
});
}
}
});
} catch (error: any) {
console.error(`Error reading directory ${dir}:`, error.message);
}
return routes;
}
/**
* Recursively lists all API routes as JSON objects.
*
* For consistency, the API routes also compute a URL from the folder structure and track the parent route,
* and also extract exported functions.
*/
function listApiRoutes(
dir: string,
segments: RouteSegment[] = []
): any[] {
let apiRoutes: any[] = [];
fs.readdirSync(dir, { withFileTypes: true }).forEach((dirent) => {
const fullPath = path.join(dir, dirent.name);
if (dirent.isDirectory() && !dirent.name.startsWith("_")) {
const seg = getRouteSegment(dirent.name);
const newSegments = seg ? [...segments, seg] : segments;
apiRoutes = apiRoutes.concat(listApiRoutes(fullPath, newSegments));
} else if (dirent.isFile() && dirent.name === FILE_PATTERNS.ROUTE) {
setCurrentFile(fullPath);
const routePath =
segments.length > 0
? "/" + segments.map((s) => s.urlSegment).join("/")
: "/";
const parentRoute =
segments.length > 1
? "/" +
segments
.slice(0, segments.length - 1)
.map((s) => s.urlSegment)
.join("/")
: "/";
const exportedFunctions = extractExportedFunctions();
const functionName = exportedFunctions.defaultExport; // primary export for API routes
const methods = extractHttpMethods();
const fileLocation = path.relative(process.cwd(), fullPath);
methods.forEach((method) => {
apiRoutes.push({
type: "api",
httpMethod: method,
exportedFunctions, // includes defaultExport and any namedExports
routePath,
parentRoute,
file: fileLocation,
});
});
}
});
return apiRoutes;
}
/**
* The main function that explores routes.
* Returns a JSON object with two keys:
* - routes: an array of page route objects.
* - apiRoutes: an array of API route objects.
*/
export async function exploreRoutes() {
const routes = listRoutes(appDirectory);
const apiRoutes = listApiRoutes(appDirectory);
return {
routes,
apiRoutes,
};
}
/**
* Checks for the existence of a loading file in the current file's directory.
*/
function hasLoadingFile() {
const directory = path.dirname(currentFilePath!);
return fs.existsSync(path.join(directory, FILE_PATTERNS.LOADING));
}
/**
* Checks for the existence of an error file in the current file's directory.
*/
function hasErrorFile() {
const directory = path.dirname(currentFilePath!);
return fs.existsSync(path.join(directory, FILE_PATTERNS.ERROR));
}