apprun
Version:
JavaScript library that has Elm inspired architecture, event pub-sub and components
291 lines • 9.44 kB
JavaScript
/**
* AppRun Router Implementation with Hierarchical Matching
*
* This file provides URL routing capabilities:
* 1. Hash-based routing (#/path)
* - Works with SPA mode using hash fragments
* - Handles hashchange events automatically
* - No server configuration required
* 2. Path-based routing (/path)
* - Works with browser history API
* - Requires server configuration for SPA routing
* - Handles popstate events automatically
* 3. Event-based navigation
* - Routes trigger corresponding component events
* - Automatic route parameter extraction
* - Fallback to 404 handling for unknown routes
*
* Features:
* - Automatic route event firing with ROUTER_EVENT
* - 404 handling via ROUTER_404_EVENT for unmatched routes
* - History API integration for seamless navigation
* - Route parameter parsing and injection into events
* - URL pattern matching with parameter support
* - Global and component-level route handling
* - **NEW: Hierarchical route matching** - progressively tries parent routes
*
* Type Safety Improvements (v3.35.1):
* - Enhanced route type definitions
* - Better parameter type inference
* - Improved URL validation and error handling
*
* Usage:
* ```ts
* // Basic routing
* app.on('#/home', () => // Show home page);
* app.on('#/users/:id', (id) => // Show user profile);
* app.on('/api/*', (path) => // Handle API routes);
*
* // Navigate programmatically
* app.run('route', '#/home');
* route('/users/123'); // Direct routing
*
* // Hierarchical matching examples
* app.on('/api', (operation, id) => // Handle /api/users/123);
* app.on('#users', (id, action) => // Handle #users/123/edit);
*
* // Hierarchical Route Matching (NEW):
* // For URL: /api/v1/users/123
* // Router tries: /api/v1/users/123 → /api/v1/users → /api/v1 → /api → 404
* // If handler found at /api, it receives: ('v1', 'users', '123')
*
* // Base Path Support (NEW):
* app.basePath = '/myapp'; // For sub-directory deployments
* // Links: <a href="/users/123"> (relative paths)
* // Navigation: /myapp/users/123 (full path)
* // Routing: /users/123 (base path stripped)
*
* // Empty Path Handling (NEW):
* // For URL: "" (empty)
* // Router tries: # → / → #/ → 404 (in priority order)
*
* // 404 Behavior (ENHANCED):
* // Fires only at minimal levels: /a, #a, #/a, a
* // Never tries root handlers: /, #, #/
* ```
*/
import app from './app';
// Helper functions for hierarchical routing
/**
* Extract clean path segments from URL
* @param url - The URL to parse
* @returns Array of path segments
*/
function parsePathSegments(url) {
if (!url)
return [];
// Handle different URL types
if (url.startsWith('#/')) {
return url.substring(2).split('/');
}
else if (url.startsWith('#')) {
return url.substring(1).split('/');
}
else if (url.startsWith('/')) {
return url.substring(1).split('/');
}
else {
return url.split('/');
}
}
/**
* Normalize trailing slash - convert /a/ to /a
* @param url - The URL to normalize
* @returns Normalized URL
*/
function normalizeTrailingSlash(url) {
if (!url || url === '/' || url === '#' || url === '#/')
return url;
if (url.endsWith('/'))
return url.slice(0, -1);
return url;
}
/**
* Validate hierarchy depth and warn if too deep
* @param segments - Path segments to validate
*/
function validateHierarchyDepth(segments) {
// Count non-empty segments for depth validation
const nonEmptySegments = segments.filter(Boolean);
if (nonEmptySegments.length > 11) {
console.warn(`Deep route hierarchy detected: ${nonEmptySegments.join('/')} (${nonEmptySegments.length} levels)`);
}
}
/**
* Strip base path from URL
* @param url - The URL to process
* @param basePath - The base path to remove
* @returns URL with base path removed
*/
function stripBasePath(url, basePath) {
if (!basePath || basePath === '/' || basePath === '')
return url;
// Normalize base path
const normalizedBasePath = basePath.startsWith('/') ? basePath : '/' + basePath;
if (url.startsWith(normalizedBasePath)) {
const stripped = url.substring(normalizedBasePath.length);
return stripped.startsWith('/') ? stripped : '/' + stripped;
}
return url;
}
/**
* Generate hierarchy of routes to try (stops at minimal level)
* @param segments - Array of path segments
* @param routeType - Type of route (path, hash, hash-slash, non-prefixed)
* @returns Array of route names to try in order
*/
function generateRouteHierarchy(segments, routeType) {
const hierarchy = [];
// Build hierarchy from most specific to least specific
for (let i = segments.length; i > 0; i--) {
const currentSegments = segments.slice(0, i);
let routeName = '';
switch (routeType) {
case 'path':
routeName = '/' + currentSegments.join('/');
break;
case 'hash':
routeName = '#' + currentSegments.join('/');
break;
case 'hash-slash':
routeName = '#/' + currentSegments.join('/');
break;
case 'non-prefixed':
routeName = currentSegments.join('/');
break;
}
hierarchy.push(routeName);
}
return hierarchy;
}
/**
* Find handler in hierarchy and return handler info
* @param hierarchy - Array of route names to try
* @param originalSegments - Original path segments
* @returns Handler info if found, null otherwise
*/
function findHandlerInHierarchy(hierarchy, originalSegments) {
for (let i = 0; i < hierarchy.length; i++) {
const routeName = hierarchy[i];
const subscribers = app.find(routeName);
if (subscribers && subscribers.length > 0) {
// Found handler - calculate remaining parameters
const handlerDepth = hierarchy.length - i;
const parameters = originalSegments.slice(handlerDepth);
return {
eventName: routeName,
parameters: parameters
};
}
}
return null;
}
/**
* Handle empty path with priority order: # → / → #/ → 404
*/
function handleEmptyPath() {
// Try # first
const hashSubscribers = app.find('#');
if (hashSubscribers && hashSubscribers.length > 0) {
app.run('#');
app.run(ROUTER_EVENT, '#');
return;
}
// Try / second
const pathSubscribers = app.find('/');
if (pathSubscribers && pathSubscribers.length > 0) {
app.run('/');
app.run(ROUTER_EVENT, '/');
return;
}
// Try #/ third
const hashSlashSubscribers = app.find('#/');
if (hashSlashSubscribers && hashSlashSubscribers.length > 0) {
app.run('#/');
app.run(ROUTER_EVENT, '#/');
return;
}
// Fire 404 if no handlers found
console.warn('No subscribers for event: ');
app.run(ROUTER_404_EVENT, '');
app.run(ROUTER_EVENT, '');
}
/**
* Main hierarchical routing logic
* @param url - The URL to route
*/
function routeWithHierarchy(url) {
// Handle empty path
if (!url) {
handleEmptyPath();
return;
}
// Normalize trailing slash
url = normalizeTrailingSlash(url);
// Strip base path if configured
const basePath = app['basePath'];
if (basePath) {
url = stripBasePath(url, basePath);
}
// Parse segments and validate depth
const segments = parsePathSegments(url);
validateHierarchyDepth(segments);
// Determine route type
let routeType;
if (url.startsWith('#/')) {
routeType = 'hash-slash';
}
else if (url.startsWith('#')) {
routeType = 'hash';
}
else if (url.startsWith('/')) {
routeType = 'path';
}
else {
routeType = 'non-prefixed';
}
// Generate hierarchy
const hierarchy = generateRouteHierarchy(segments, routeType);
// Find handler in hierarchy
const handlerInfo = findHandlerInHierarchy(hierarchy, segments);
if (handlerInfo) {
// Found handler - publish route with parameters
publishRoute(handlerInfo.eventName, ...handlerInfo.parameters);
}
else {
// No handler found - fire 404 with original URL
if (hierarchy.length > 0) {
const minimalRoute = hierarchy[hierarchy.length - 1];
console.warn(`No subscribers for event: ${minimalRoute}`);
app.run(ROUTER_404_EVENT, url);
app.run(ROUTER_EVENT, url);
}
else {
handleEmptyPath();
}
}
}
const publishRoute = (name, ...args) => {
if (!name || name === ROUTER_EVENT || name === ROUTER_404_EVENT)
return;
const subscribers = app.find(name);
if (!subscribers || subscribers.length === 0) {
console.warn(`No subscribers for event: ${name}`);
app.run(ROUTER_404_EVENT, name, ...args);
}
else {
app.run(name, ...args);
}
app.run(ROUTER_EVENT, name, ...args);
};
export const ROUTER_EVENT = '//';
export const ROUTER_404_EVENT = '///';
export const route = (url) => {
if (app['lastUrl'] === url)
return; // Prevent duplicate routing
app['lastUrl'] = url;
// Use hierarchical routing logic
routeWithHierarchy(url);
};
export default route;
//# sourceMappingURL=router.js.map