new structure

This commit is contained in:
2026-03-20 01:11:32 +01:00
parent d24bad018e
commit 4b4eaa083b
76 changed files with 578 additions and 72 deletions

View File

@@ -0,0 +1,784 @@
# 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.