UNPKG

veko

Version:

Ultra-lightweight Node.js framework with ZERO dependencies - VSV components, Tailwind CSS, asset imports, SSR

429 lines (368 loc) 12.4 kB
/** * Veko Smart Page Router * File-based routing with support for all page types * * @module veko/core/router */ const fs = require('fs'); const path = require('path'); const { PageTypes, PageSymbols } = require('./page-types'); /** * Route metadata * @typedef {Object} RouteMeta * @property {string} path - Route path * @property {string} file - Source file * @property {string} type - Page type * @property {string} component - Component name * @property {boolean} [dynamic] - Has dynamic segments * @property {string[]} [params] - Dynamic parameter names */ /** * Smart Page Router * Handles file-based routing with page type detection */ class PageRouter { constructor(options = {}) { this.options = { pagesDir: options.pagesDir || 'pages', extensions: options.extensions || ['.jsv', '.tsv', '.jsx', '.tsx'], ...options }; // Route registry this.routes = new Map(); this.dynamicRoutes = []; // Ultra dynamic patterns this.ultraPatterns = options.ultraPatterns || []; this.ultraDirs = options.ultraDirs || []; this.ultraFiles = options.ultraFiles || []; } /** * Initialize router by scanning pages directory */ async init() { const pagesDir = path.join(process.cwd(), this.options.pagesDir); if (!fs.existsSync(pagesDir)) { console.warn(`Pages directory not found: ${pagesDir}`); return this; } await this.scanDirectory(pagesDir, ''); // Sort dynamic routes by specificity (more specific first) this.dynamicRoutes.sort((a, b) => { const aSpecificity = a.path.split('/').filter(s => !s.startsWith(':')).length; const bSpecificity = b.path.split('/').filter(s => !s.startsWith(':')).length; return bSpecificity - aSpecificity; }); return this; } /** * Scan a directory for page files */ async scanDirectory(dir, routePrefix) { const items = fs.readdirSync(dir, { withFileTypes: true }); for (const item of items) { const fullPath = path.join(dir, item.name); if (item.isDirectory()) { // Handle special directory names let newPrefix = routePrefix; if (item.name.startsWith('[') && item.name.endsWith(']')) { // Dynamic segment: [id] -> :id const paramName = item.name.slice(1, -1); newPrefix = `${routePrefix}/:${paramName}`; } else if (item.name.startsWith('[[') && item.name.endsWith(']]')) { // Optional catch-all: [[...slug]] -> :slug* const paramName = item.name.slice(2, -2).replace('...', ''); newPrefix = `${routePrefix}/:${paramName}*`; } else if (item.name.startsWith('[...') && item.name.endsWith(']')) { // Catch-all: [...slug] -> :slug+ const paramName = item.name.slice(4, -1); newPrefix = `${routePrefix}/:${paramName}+`; } else if (item.name.startsWith('(') && item.name.endsWith(')')) { // Route group: (admin) - doesn't affect URL newPrefix = routePrefix; } else if (item.name.startsWith('_')) { // Private directory: _components - skip continue; } else { newPrefix = `${routePrefix}/${item.name}`; } await this.scanDirectory(fullPath, newPrefix); } else { // Check if it's a page file const ext = path.extname(item.name); if (!this.options.extensions.includes(ext)) continue; // Skip private files if (item.name.startsWith('_')) continue; // Get route info const routeInfo = this.getRouteInfo(fullPath, routePrefix, item.name); if (routeInfo) { this.registerRoute(routeInfo); } } } } /** * Get route information from a file */ getRouteInfo(filePath, routePrefix, fileName) { // Remove extension and type suffix let routeName = fileName; for (const ext of this.options.extensions) { routeName = routeName.replace(ext, ''); } // Detect page type from filename let pageType = PageTypes.STATIC; if (routeName.endsWith('.static')) { pageType = PageTypes.STATIC; routeName = routeName.replace('.static', ''); } else if (routeName.endsWith('.ssg')) { pageType = PageTypes.SSG; routeName = routeName.replace('.ssg', ''); } else if (routeName.endsWith('.dynamic')) { pageType = PageTypes.DYNAMIC; routeName = routeName.replace('.dynamic', ''); } else if (routeName.endsWith('.ultra')) { pageType = PageTypes.ULTRA; routeName = routeName.replace('.ultra', ''); } else { // Check content for type detection const content = fs.readFileSync(filePath, 'utf-8'); pageType = this.detectTypeFromContent(content); } // Check if file/dir is in ultra config if (this.isUltraPath(filePath)) { pageType = PageTypes.ULTRA; } // Build route path let routePath = routePrefix; if (routeName === 'index') { // Index files map to parent directory routePath = routePrefix || '/'; } else if (routeName.startsWith('[') && routeName.endsWith(']')) { // Dynamic file name: [id].jsv -> :id const paramName = routeName.slice(1, -1); routePath = `${routePrefix}/:${paramName}`; } else if (routeName.startsWith('[...') && routeName.endsWith(']')) { // Catch-all: [...slug].jsv -> :slug+ const paramName = routeName.slice(4, -1); routePath = `${routePrefix}/:${paramName}+`; } else { routePath = `${routePrefix}/${routeName}`; } // Normalize path routePath = routePath.replace(/\/+/g, '/') || '/'; // Extract dynamic parameters const params = []; const paramMatches = routePath.match(/:(\w+)([*+])?/g); if (paramMatches) { for (const match of paramMatches) { params.push(match.slice(1).replace(/[*+]$/, '')); } } return { path: routePath, file: filePath, type: pageType, component: this.getComponentName(filePath), dynamic: params.length > 0, params }; } /** * Detect page type from file content */ detectTypeFromContent(content) { if (/export\s+(async\s+)?function\s+getStaticProps/.test(content)) { return PageTypes.SSG; } if (/export\s+(async\s+)?function\s+getServerProps/.test(content)) { return PageTypes.DYNAMIC; } if (/export\s+(async\s+)?function\s+getUltraConfig/.test(content)) { return PageTypes.ULTRA; } return PageTypes.STATIC; } /** * Check if path should be ultra-dynamic */ isUltraPath(filePath) { const relativePath = path.relative(process.cwd(), filePath); // Check explicit files if (this.ultraFiles.includes(relativePath)) { return true; } // Check directories for (const dir of this.ultraDirs) { if (relativePath.startsWith(dir)) { return true; } } // Check patterns (simple glob support) for (const pattern of this.ultraPatterns) { if (this.matchPattern(relativePath, pattern)) { return true; } } return false; } /** * Simple glob pattern matching */ matchPattern(filePath, pattern) { // Convert glob to regex const regex = new RegExp( '^' + pattern .replace(/\./g, '\\.') .replace(/\*\*/g, '.*') .replace(/\*/g, '[^/]*') .replace(/\?/g, '.') + '$' ); return regex.test(filePath); } /** * Get component name from file path */ getComponentName(filePath) { const basename = path.basename(filePath); return basename .replace(/\.(static|ssg|dynamic|ultra)/, '') .replace(/\.(jsv|tsv|jsx|tsx)$/, ''); } /** * Register a route */ registerRoute(routeInfo) { if (routeInfo.dynamic) { this.dynamicRoutes.push(routeInfo); } else { this.routes.set(routeInfo.path, routeInfo); } } /** * Match a URL to a route */ match(url) { // Parse URL const [pathname] = url.split('?'); const normalizedPath = pathname.replace(/\/+$/, '') || '/'; // Check exact matches first if (this.routes.has(normalizedPath)) { return { route: this.routes.get(normalizedPath), params: {} }; } // Check dynamic routes for (const route of this.dynamicRoutes) { const match = this.matchDynamicRoute(normalizedPath, route); if (match) { return { route, params: match.params }; } } return null; } /** * Match URL against dynamic route pattern */ matchDynamicRoute(pathname, route) { const patternParts = route.path.split('/').filter(Boolean); const pathParts = pathname.split('/').filter(Boolean); const params = {}; let patternIdx = 0; let pathIdx = 0; while (patternIdx < patternParts.length && pathIdx < pathParts.length) { const pattern = patternParts[patternIdx]; const segment = pathParts[pathIdx]; if (pattern.startsWith(':')) { // Dynamic segment const paramName = pattern.slice(1).replace(/[*+]$/, ''); const modifier = pattern.slice(-1); if (modifier === '+') { // Catch-all (required) params[paramName] = pathParts.slice(pathIdx).join('/'); return { params }; } else if (modifier === '*') { // Optional catch-all params[paramName] = pathParts.slice(pathIdx).join('/'); return { params }; } else { // Single segment params[paramName] = segment; } } else if (pattern !== segment) { // No match return null; } patternIdx++; pathIdx++; } // Check if we consumed all parts if (patternIdx === patternParts.length && pathIdx === pathParts.length) { return { params }; } // Check for optional catch-all at end if (patternIdx === patternParts.length - 1) { const lastPattern = patternParts[patternIdx]; if (lastPattern.endsWith('*')) { const paramName = lastPattern.slice(1, -1); params[paramName] = ''; return { params }; } } return null; } /** * Get all routes */ getAllRoutes() { const allRoutes = []; for (const [path, route] of this.routes) { allRoutes.push(route); } allRoutes.push(...this.dynamicRoutes); return allRoutes; } /** * Get routes by type */ getRoutesByType(type) { return this.getAllRoutes().filter(r => r.type === type); } /** * Print route summary */ printRoutes() { console.log('\n Routes:'); console.log(' ─────────────────────────────────────────────'); const allRoutes = this.getAllRoutes() .sort((a, b) => a.path.localeCompare(b.path)); for (const route of allRoutes) { const symbol = PageSymbols[route.type]; const typeLabel = route.type.toUpperCase().padEnd(7); const dynamicLabel = route.dynamic ? ' [dynamic]' : ''; console.log(` ${symbol} ${typeLabel} ${route.path}${dynamicLabel}`); } console.log(' ─────────────────────────────────────────────'); console.log(` Total: ${allRoutes.length} routes\n`); } /** * Generate route manifest */ generateManifest() { const manifest = { generated: new Date().toISOString(), routes: this.getAllRoutes().map(r => ({ path: r.path, type: r.type, component: r.component, dynamic: r.dynamic, params: r.params })) }; return manifest; } } module.exports = { PageRouter };