UNPKG

nextmaps

Version:

A package that returns json object of your Next.js routes

439 lines (396 loc) 14.6 kB
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)); }