UNPKG

burger-api

Version:

<p align="center"> <img src="https://github.com/user-attachments/assets/0d9b376e-1d89-479a-aa7f-e7ee3c6b2342" alt="BurgerAPI logo"/> </p>

233 lines (232 loc) 10.4 kB
// Import stuff from node import { readdirSync } from 'fs'; import * as path from 'path'; // Import utils import { cleanPrefix, normalizePath, compareRoutes, ROUTE_CONSTANTS, } from '../utils/index.js'; /** * PageRouter class for handling file-based page routing. * Loads pages from a directory structure and matches requests to the appropriate page handlers. * Supports dynamic segments (e.g., [id]) and uses default exports as page handlers. */ export class PageRouter { pagesDir; prefix; /** Array of loaded page definitions */ pages = []; /** * Constructor for the PageRouter class. * @param pagesDir The directory path where page modules are located. * @param prefix Optional prefix to prepend to all routes (e.g., "pages" becomes "/pages/..."). */ constructor(pagesDir, prefix = '') { this.pagesDir = pagesDir; this.prefix = prefix; if (!pagesDir) { throw new Error('Pages directory path must be provided'); } // Normalize the pagesDir path this.pagesDir = path.normalize(pagesDir); // Normalize the prefix if provided if (prefix) { this.prefix = cleanPrefix(prefix); } } /** * Loads page modules from the specified directory and adds them to the pages array. * After loading, sorts the pages to prioritize static routes over dynamic ones based on specificity. * @returns A promise that resolves when all page modules have been loaded and sorted. */ async loadPages() { // Clear the pages array this.pages = []; try { await this.scanDirectory(this.pagesDir); // Sort pages to ensure static routes are matched before dynamic ones this.pages.sort((a, b) => compareRoutes(a, b)); } catch (error) { console.error('Failed to load pages:', error); throw new Error(`Failed to load pages: ${error instanceof Error ? error.message : String(error)}`); } } /** * Recursively scans the directory for page modules and adds them to the pages array. * @param dir The current directory to scan. * @param basePath The base path for constructing the route path. */ async scanDirectory(dir, basePath = '') { // Track if a dynamic folder has been found at this directory level let dynamicFolderFound = false; try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); const relativePath = path.join(basePath, entry.name); if (entry.isDirectory()) { // Handle dynamic directories (e.g., [id]) if (entry.name.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) && entry.name.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END)) { if (dynamicFolderFound) { throw new Error(`Multiple dynamic page folders found in the same directory: '${entry.name}' conflicts with another dynamic folder.`); } dynamicFolderFound = true; } await this.scanDirectory(entryPath, relativePath); } else if (entry.isFile() && ROUTE_CONSTANTS.SUPPORTED_PAGE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { // Convert file path to route path and load the module const routePath = this.convertFilePathToRoute(relativePath); // Clean the route path const cleanedRoutePath = this.cleanRoutePath(routePath); // Get the module path const modulePath = path.resolve(entryPath); try { // Import the module const pageModule = await import(modulePath); // Get the default export as the page handler if (entry.name.endsWith('.tsx') && typeof pageModule.default !== 'function') { throw new Error(`Page at ${entryPath} must export a default function as its handler.`); } // Create page definition const pageDefWithSlash = { path: cleanedRoutePath + '/', handler: pageModule.default, middleware: pageModule.middleware, }; // Create page definition const pageDef = { path: cleanedRoutePath, handler: pageModule.default, middleware: pageModule.middleware, }; // Add the page definition to the pages array this.pages.push(pageDefWithSlash, pageDef); } catch (importError) { console.error(`Failed to import module at ${modulePath}:`, importError); } } } } catch (error) { console.error(`Error scanning directory ${dir}:`, error); throw error; } } /** * Converts a file path to a route path by processing dynamic segments, * removing certain directory indicators, and applying a prefix if specified. * @param filePath The file path to convert into a route path. * @returns The converted route path string (before extension cleaning). */ convertFilePathToRoute(filePath) { // Split path into segments const segments = filePath.split(path.sep); const resultSegments = []; for (let segment of segments) { if (!segment) continue; // Skip empty segments // Skip grouping segments (e.g., (group)) if (segment.startsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_START) && segment.endsWith(ROUTE_CONSTANTS.GROUPING_FOLDER_END)) continue; // Convert dynamic segments from [param] to :param if (segment.startsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_START) && segment.endsWith(ROUTE_CONSTANTS.DYNAMIC_FOLDER_END)) { const param = segment.slice(1, -1); resultSegments.push(ROUTE_CONSTANTS.DYNAMIC_SEGMENT_PREFIX + param); } else { resultSegments.push(segment); } } // Construct the route with leading slash let route = '/' + resultSegments.join('/'); // Apply prefix if provided if (this.prefix) { const cleanPrefixStr = cleanPrefix(this.prefix); route = '/' + cleanPrefixStr + route; } // Remove trailing slash unless it's the root if (route !== '/' && route.endsWith('/')) { route = route.slice(0, -1); } return route; } /** * Cleans a route path by handling index files and removing extensions. * @param routePath The route path to clean * @returns The cleaned route path */ cleanRoutePath(routePath) { // Break the route path into segments const pathSegments = routePath.split('/'); // Get the last segment if (pathSegments.length > 0) { const lastSegment = pathSegments[pathSegments.length - 1]; // Check if the last segment is an index file if (ROUTE_CONSTANTS.PAGE_INDEX_FILES.includes(lastSegment)) { // Remove the last segment for index files pathSegments.pop(); } else { // Remove file extension for non-index files const extensionIndex = lastSegment.lastIndexOf('.'); if (extensionIndex > 0) { pathSegments[pathSegments.length - 1] = lastSegment.substring(0, extensionIndex); } } } // Join the segments back together const finalPath = pathSegments.join('/') === '' ? '/' : pathSegments.join('/'); return finalPath; } /** * Resolves the given request by finding a matching page and extracting dynamic parameters. * @param request The request to resolve. * @returns An object containing the matched page and parameters, or an empty params object if no match. */ resolve(request) { const url = new URL(request.url); const reqPath = normalizePath(url.pathname); console.debug(`Resolving route for path: ${reqPath}`); for (const page of this.pages) { const match = this.matchRoute(reqPath, page.path); if (match) { console.debug(`Route matched: ${page.path} with params:`, match); return { page, params: match }; } } console.debug(`No matching route found for path: ${reqPath}`); return { params: {} }; } /** * Checks if the request path matches the page path, extracting dynamic parameters if matched. * @param requestPath The request path to check. * @param pagePath The page path to match against. * @returns A record of dynamic parameters if matched, otherwise null. */ matchRoute(requestPath, pagePath) { const reqSegments = requestPath.split('/').filter(Boolean); const pageSegments = pagePath.split('/').filter(Boolean); if (reqSegments.length !== pageSegments.length) { return null; } const params = {}; for (let i = 0; i < reqSegments.length; i++) { const pSegment = pageSegments[i]; const reqSegment = reqSegments[i]; if (pSegment.startsWith(ROUTE_CONSTANTS.DYNAMIC_SEGMENT_PREFIX)) { const paramName = pSegment.slice(ROUTE_CONSTANTS.DYNAMIC_SEGMENT_PREFIX.length); params[paramName] = reqSegment; } else if (pSegment !== reqSegment) { return null; } } return params; } }