Skip to content

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)

ParameterTypeDescription
routesArray<Route>Array of route configurations

Returns: HTMLDivElement - Container that renders the current page

$.router.go(path)

ParameterTypeDescription
pathstringRoute path to navigate to (automatically adds leading slash)

Route Object

PropertyTypeDescription
pathstring or RegExpRoute pattern to match
componentFunctionFunction 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>
  `;
});
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;
};
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

FeatureDescription
Hash-basedWorks everywhere, no server config
Route Parameters:param syntax for dynamic segments
RegExp SupportAdvanced pattern matching
Query ParametersSupport for ?key=value in URLs
Programmatic Navigation$.router.go(path)
Auto-cleanupWorks with $.page for memory management
Zero DependenciesPure vanilla JavaScript
Lazy Loading ReadyEasy 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.