Files
sigpro/docs/src/api/routing.md
2026-03-17 12:01:25 +01:00

20 KiB

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.

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)

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)

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

// 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

// 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

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

// 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

// 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)

// 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 };
// 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);
// 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

// 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

// 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;
};
// 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

// 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;
};
// 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

// lazy.js
export const lazy = (loader) => {
  let component = null;
  
  return async (params) => {
    if (!component) {
      const module = await loader();
      component = module.default;
    }
    return component(params);
  };
};
// 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

// 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;
};
// 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:

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

// 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.