Routing API 🌐
SigPro includes a simple yet powerful hash-based router designed for Single Page Applications (SPAs). It works everywhere with zero server configuration and integrates seamlessly with $.page for automatic cleanup.
Why Hash-Based Routing?
Hash routing (#/about) works everywhere - no server configuration needed. Perfect for:
- Static sites and SPAs
- GitHub Pages, Netlify, any static hosting
- Local development without a server
- Projects that need to work immediately
$.router(routes)
Creates a hash-based router that renders the matching component and handles navigation.
javascript
import { $, html } from 'sigpro';
import HomePage from './pages/Home.js';
import AboutPage from './pages/About.js';
import UserPage from './pages/User.js';
const routes = [
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
{ path: '/user/:id', component: UserPage },
];
// Mount the router
document.body.appendChild($.router(routes));📋 API Reference
$.router(routes)
| Parameter | Type | Description |
|---|---|---|
routes | Array<Route> | Array of route configurations |
Returns: HTMLDivElement - Container that renders the current page
$.router.go(path)
| Parameter | Type | Description |
|---|---|---|
path | string | Route path to navigate to (automatically adds leading slash) |
Route Object
| Property | Type | Description |
|---|---|---|
path | string or RegExp | Route pattern to match |
component | Function | Function that returns page content (receives params) |
🎯 Route Patterns
String Paths (Simple Routes)
javascript
const routes = [
// Static routes
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
{ path: '/contact', component: ContactPage },
// Routes with parameters
{ path: '/user/:id', component: UserPage },
{ path: '/user/:id/posts', component: UserPostsPage },
{ path: '/user/:id/posts/:postId', component: PostPage },
{ path: '/search/:query/page/:num', component: SearchPage },
];RegExp Paths (Advanced Routing)
javascript
const routes = [
// Match numeric IDs only
{ path: /^\/users\/(?<id>\d+)$/, component: UserPage },
// Match product slugs (letters, numbers, hyphens)
{ path: /^\/products\/(?<slug>[a-z0-9-]+)$/, component: ProductPage },
// Match blog posts by year/month
{ path: /^\/blog\/(?<year>\d{4})\/(?<month>\d{2})$/, component: BlogArchive },
// Match optional language prefix
{ path: /^\/(?<lang>en|es|fr)?\/?about$/, component: AboutPage },
// Match UUID format
{ path: /^\/items\/(?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/,
component: ItemPage },
];📦 Basic Examples
Simple Router Setup
javascript
// main.js
import { $, html } from 'sigpro';
import Home from './pages/Home.js';
import About from './pages/About.js';
import Contact from './pages/Contact.js';
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/contact', component: Contact },
];
const router = $.router(routes);
// Mount to DOM
document.body.appendChild(router);Page Components with Parameters
javascript
// pages/User.js
import { $, html } from 'sigpro';
export default (params) => $.page(() => {
// /user/42 → params = { id: '42' }
// /user/john/posts/123 → params = { id: 'john', postId: '123' }
const userId = params.id;
const userData = $(null);
$.effect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => userData(data));
});
return html`
<div class="user-page">
<h1>User Profile: ${userId}</h1>
${() => userData() ? html`
<p>Name: ${userData().name}</p>
<p>Email: ${userData().email}</p>
` : html`<p>Loading...</p>`}
</div>
`;
});Navigation
javascript
import { $, html } from 'sigpro';
// In templates
const NavBar = () => html`
<nav>
<a href="#/">Home</a>
<a href="#/about">About</a>
<a href="#/contact">Contact</a>
<a href="#/user/42">Profile</a>
<a href="#/search/js/page/1">Search</a>
<!-- Programmatic navigation -->
<button @click=${() => $.router.go('/about')}>
Go to About
</button>
<button @click=${() => $.router.go('contact')}>
Go to Contact (auto-adds leading slash)
</button>
</nav>
`;🚀 Advanced Examples
Complete Application with Layout
javascript
// App.js
import { $, html } from 'sigpro';
import HomePage from './pages/Home.js';
import AboutPage from './pages/About.js';
import UserPage from './pages/User.js';
import SettingsPage from './pages/Settings.js';
import NotFound from './pages/NotFound.js';
// Layout component with navigation
const Layout = (content) => html`
<div class="app">
<header class="header">
<h1>My SigPro App</h1>
<nav class="nav">
<a href="#/" class:active=${() => isActive('/')}>Home</a>
<a href="#/about" class:active=${() => isActive('/about')}>About</a>
<a href="#/user/42" class:active=${() => isActive('/user/42')}>Profile</a>
<a href="#/settings" class:active=${() => isActive('/settings')}>Settings</a>
</nav>
</header>
<main class="main">
${content}
</main>
<footer class="footer">
<p>© 2024 SigPro App</p>
</footer>
</div>
`;
// Helper to check active route
const isActive = (path) => {
const current = window.location.hash.replace(/^#/, '') || '/';
return current === path;
};
// Routes with layout
const routes = [
{ path: '/', component: (params) => Layout(HomePage(params)) },
{ path: '/about', component: (params) => Layout(AboutPage(params)) },
{ path: '/user/:id', component: (params) => Layout(UserPage(params)) },
{ path: '/settings', component: (params) => Layout(SettingsPage(params)) },
{ path: '/:path(.*)', component: (params) => Layout(NotFound(params)) }, // Catch-all
];
// Create and mount router
const router = $.router(routes);
document.body.appendChild(router);Nested Routes
javascript
// pages/Settings.js (parent route)
import { $, html } from 'sigpro';
import SettingsGeneral from './settings/General.js';
import SettingsSecurity from './settings/Security.js';
import SettingsNotifications from './settings/Notifications.js';
export default (params) => $.page(() => {
const section = params.section || 'general';
const sections = {
general: SettingsGeneral,
security: SettingsSecurity,
notifications: SettingsNotifications
};
const CurrentSection = sections[section];
return html`
<div class="settings">
<h1>Settings</h1>
<div class="settings-layout">
<nav class="settings-sidebar">
<a href="#/settings/general" class:active=${() => section === 'general'}>
General
</a>
<a href="#/settings/security" class:active=${() => section === 'security'}>
Security
</a>
<a href="#/settings/notifications" class:active=${() => section === 'notifications'}>
Notifications
</a>
</nav>
<div class="settings-content">
${CurrentSection(params)}
</div>
</div>
</div>
`;
});
// pages/settings/General.js
export default (params) => $.page(() => {
return html`
<div>
<h2>General Settings</h2>
<form>...</form>
</div>
`;
});
// Main router with nested routes
const routes = [
{ path: '/', component: HomePage },
{ path: '/settings/:section?', component: SettingsPage }, // Optional section param
];Protected Routes (Authentication)
javascript
// auth.js
import { $ } from 'sigpro';
const isAuthenticated = $(false);
const user = $(null);
export const checkAuth = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const response = await fetch('/api/verify');
if (response.ok) {
const userData = await response.json();
user(userData);
isAuthenticated(true);
return true;
}
} catch (e) {
// Handle error
}
}
isAuthenticated(false);
user(null);
return false;
};
export const requireAuth = (component) => (params) => {
if (isAuthenticated()) {
return component(params);
}
// Redirect to login
$.router.go('/login');
return null;
};
export { isAuthenticated, user };javascript
// pages/Dashboard.js (protected route)
import { $, html } from 'sigpro';
import { requireAuth, user } from '../auth.js';
const Dashboard = (params) => $.page(() => {
return html`
<div class="dashboard">
<h1>Welcome, ${() => user()?.name}!</h1>
<p>This is your protected dashboard.</p>
</div>
`;
});
export default requireAuth(Dashboard);javascript
// main.js with protected routes
import { $, html } from 'sigpro';
import { checkAuth } from './auth.js';
import HomePage from './pages/Home.js';
import LoginPage from './pages/Login.js';
import DashboardPage from './pages/Dashboard.js';
import AdminPage from './pages/Admin.js';
// Check auth on startup
checkAuth();
const routes = [
{ path: '/', component: HomePage },
{ path: '/login', component: LoginPage },
{ path: '/dashboard', component: DashboardPage }, // Protected
{ path: '/admin', component: AdminPage }, // Protected
];
document.body.appendChild($.router(routes));Route Transitions
javascript
// with-transitions.js
import { $, html } from 'sigpro';
export const createRouterWithTransitions = (routes) => {
const transitioning = $(false);
const currentView = $(null);
const nextView = $(null);
const container = document.createElement('div');
container.style.display = 'contents';
const renderWithTransition = async (newView) => {
if (currentView() === newView) return;
transitioning(true);
nextView(newView);
// Fade out
container.style.transition = 'opacity 0.2s';
container.style.opacity = '0';
await new Promise(resolve => setTimeout(resolve, 200));
// Update content
container.replaceChildren(newView);
currentView(newView);
// Fade in
container.style.opacity = '1';
await new Promise(resolve => setTimeout(resolve, 200));
transitioning(false);
container.style.transition = '';
};
const router = $.router(routes.map(route => ({
...route,
component: (params) => {
const view = route.component(params);
renderWithTransition(view);
return document.createComment('router-placeholder');
}
})));
return router;
};Breadcrumbs Navigation
javascript
// with-breadcrumbs.js
import { $, html } from 'sigpro';
export const createBreadcrumbs = (routes) => {
const breadcrumbs = $([]);
const updateBreadcrumbs = (path) => {
const parts = path.split('/').filter(Boolean);
const crumbs = [];
let currentPath = '';
parts.forEach((part, index) => {
currentPath += `/${part}`;
// Find matching route
const route = routes.find(r => {
if (r.path.includes(':')) {
const pattern = r.path.replace(/:[^/]+/g, part);
return pattern === currentPath;
}
return r.path === currentPath;
});
crumbs.push({
path: currentPath,
label: route?.name || part.charAt(0).toUpperCase() + part.slice(1),
isLast: index === parts.length - 1
});
});
breadcrumbs(crumbs);
};
// Listen to route changes
window.addEventListener('hashchange', () => {
const path = window.location.hash.replace(/^#/, '') || '/';
updateBreadcrumbs(path);
});
// Initial update
updateBreadcrumbs(window.location.hash.replace(/^#/, '') || '/');
return breadcrumbs;
};javascript
// Usage in layout
import { createBreadcrumbs } from './with-breadcrumbs.js';
const breadcrumbs = createBreadcrumbs(routes);
const Layout = (content) => html`
<div class="app">
<nav class="breadcrumbs">
${() => breadcrumbs().map(crumb => html`
${!crumb.isLast ? html`
<a href="#${crumb.path}">${crumb.label}</a>
<span class="separator">/</span>
` : html`
<span class="current">${crumb.label}</span>
`}
`)}
</nav>
<main>
${content}
</main>
</div>
`;Query Parameters
javascript
// with-query-params.js
export const getQueryParams = () => {
const hash = window.location.hash;
const queryStart = hash.indexOf('?');
if (queryStart === -1) return {};
const queryString = hash.slice(queryStart + 1);
const params = new URLSearchParams(queryString);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
};
export const updateQueryParams = (params) => {
const hash = window.location.hash.split('?')[0];
const queryString = new URLSearchParams(params).toString();
window.location.hash = queryString ? `${hash}?${queryString}` : hash;
};javascript
// Search page with query params
import { $, html } from 'sigpro';
import { getQueryParams, updateQueryParams } from './with-query-params.js';
export default (params) => $.page(() => {
// Get initial query from URL
const queryParams = getQueryParams();
const searchQuery = $(queryParams.q || '');
const page = $(parseInt(queryParams.page) || 1);
const results = $([]);
// Update URL when search changes
$.effect(() => {
updateQueryParams({
q: searchQuery() || undefined,
page: page() > 1 ? page() : undefined
});
});
// Fetch results when search or page changes
$.effect(() => {
if (searchQuery()) {
fetch(`/api/search?q=${searchQuery()}&page=${page()}`)
.then(res => res.json())
.then(data => results(data));
}
});
return html`
<div class="search-page">
<h1>Search</h1>
<input
type="search"
:value=${searchQuery}
placeholder="Search..."
@input=${(e) => {
searchQuery(e.target.value);
page(1); // Reset to first page on new search
}}
/>
<div class="results">
${results().map(item => html`
<div class="result">${item.title}</div>
`)}
</div>
${() => results().length ? html`
<div class="pagination">
<button
?disabled=${() => page() <= 1}
@click=${() => page(p => p - 1)}
>
Previous
</button>
<span>Page ${page}</span>
<button
?disabled=${() => results().length < 10}
@click=${() => page(p => p + 1)}
>
Next
</button>
</div>
` : ''}
</div>
`;
});Lazy Loading Routes
javascript
// lazy.js
export const lazy = (loader) => {
let component = null;
return async (params) => {
if (!component) {
const module = await loader();
component = module.default;
}
return component(params);
};
};javascript
// main.js with lazy loading
import { $, html } from 'sigpro';
import { lazy } from './lazy.js';
import Layout from './Layout.js';
const routes = [
{ path: '/', component: lazy(() => import('./pages/Home.js')) },
{ path: '/about', component: lazy(() => import('./pages/About.js')) },
{ path: '/dashboard', component: lazy(() => import('./pages/Dashboard.js')) },
{
path: '/admin',
component: lazy(() => import('./pages/Admin.js')),
// Show loading state
loading: () => html`<div class="loading">Loading admin panel...</div>`
},
];
// Wrap with layout
const routesWithLayout = routes.map(route => ({
...route,
component: (params) => Layout(route.component(params))
}));
document.body.appendChild($.router(routesWithLayout));Route Guards / Middleware
javascript
// middleware.js
export const withGuard = (component, guard) => (params) => {
const result = guard(params);
if (result === true) {
return component(params);
} else if (typeof result === 'string') {
$.router.go(result);
return null;
}
return result; // Custom component (e.g., AccessDenied)
};
// Guards
export const roleGuard = (requiredRole) => (params) => {
const userRole = localStorage.getItem('userRole');
if (userRole === requiredRole) return true;
if (!userRole) return '/login';
return AccessDeniedPage(params);
};
export const authGuard = () => (params) => {
const token = localStorage.getItem('token');
return token ? true : '/login';
};
export const pendingChangesGuard = (hasPendingChanges) => (params) => {
if (hasPendingChanges()) {
return ConfirmLeavePage(params);
}
return true;
};javascript
// Usage
import { withGuard, authGuard, roleGuard } from './middleware.js';
const routes = [
{ path: '/', component: HomePage },
{ path: '/profile', component: withGuard(ProfilePage, authGuard()) },
{
path: '/admin',
component: withGuard(AdminPage, roleGuard('admin'))
},
];📊 Route Matching Priority
Routes are matched in the order they are defined. More specific routes should come first:
javascript
const routes = [
// More specific first
{ path: '/user/:id/edit', component: EditUserPage },
{ path: '/user/:id/posts', component: UserPostsPage },
{ path: '/user/:id', component: UserPage },
// Static routes
{ path: '/about', component: AboutPage },
{ path: '/contact', component: ContactPage },
// Catch-all last
{ path: '/:path(.*)', component: NotFoundPage },
];🎯 Complete Example
javascript
// main.js - Complete application
import { $, html } from 'sigpro';
import { lazy } from './utils/lazy.js';
import { withGuard, authGuard } from './utils/middleware.js';
import Layout from './components/Layout.js';
// Lazy load pages
const HomePage = lazy(() => import('./pages/Home.js'));
const AboutPage = lazy(() => import('./pages/About.js'));
const LoginPage = lazy(() => import('./pages/Login.js'));
const DashboardPage = lazy(() => import('./pages/Dashboard.js'));
const UserPage = lazy(() => import('./pages/User.js'));
const SettingsPage = lazy(() => import('./pages/Settings.js'));
const NotFoundPage = lazy(() => import('./pages/NotFound.js'));
// Route configuration
const routes = [
{ path: '/', component: HomePage, name: 'Home' },
{ path: '/about', component: AboutPage, name: 'About' },
{ path: '/login', component: LoginPage, name: 'Login' },
{
path: '/dashboard',
component: withGuard(DashboardPage, authGuard()),
name: 'Dashboard'
},
{
path: '/user/:id',
component: UserPage,
name: 'User Profile'
},
{
path: '/settings/:section?',
component: withGuard(SettingsPage, authGuard()),
name: 'Settings'
},
{ path: '/:path(.*)', component: NotFoundPage, name: 'Not Found' },
];
// Wrap all routes with layout
const routesWithLayout = routes.map(route => ({
...route,
component: (params) => Layout(route.component(params))
}));
// Create and mount router
const router = $.router(routesWithLayout);
document.body.appendChild(router);
// Navigation helper (available globally)
window.navigate = $.router.go;📊 Summary
| Feature | Description |
|---|---|
| Hash-based | Works everywhere, no server config |
| Route Parameters | :param syntax for dynamic segments |
| RegExp Support | Advanced pattern matching |
| Query Parameters | Support for ?key=value in URLs |
| Programmatic Navigation | $.router.go(path) |
| Auto-cleanup | Works with $.page for memory management |
| Zero Dependencies | Pure vanilla JavaScript |
| Lazy Loading Ready | Easy code splitting |
Pro Tip: Order matters in route definitions - put more specific routes (with parameters) before static ones, and always include a catch-all route (404) at the end.