veko
Version:
Ultra-lightweight Node.js framework with ZERO dependencies - VSV components, Tailwind CSS, asset imports, SSR
429 lines (368 loc) • 12.4 kB
JavaScript
/**
* 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 };