Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
450 lines
11 KiB
JavaScript
450 lines
11 KiB
JavaScript
/**
|
|
* DSS Router
|
|
*
|
|
* Centralized hash-based routing with guards, lifecycle hooks, and
|
|
* declarative route definitions for enterprise-grade navigation management.
|
|
*
|
|
* @module router
|
|
*/
|
|
|
|
import { notifyError, ErrorCode } from './messaging.js';
|
|
|
|
/**
|
|
* Route configuration object
|
|
* @typedef {Object} RouteConfig
|
|
* @property {string} path - Route path (e.g., '/dashboard', '/projects')
|
|
* @property {string} name - Route name for programmatic navigation
|
|
* @property {Function} handler - Route handler function
|
|
* @property {Function} [beforeEnter] - Guard called before entering route
|
|
* @property {Function} [afterEnter] - Hook called after entering route
|
|
* @property {Function} [onLeave] - Hook called when leaving route
|
|
* @property {Object} [meta] - Route metadata
|
|
*/
|
|
|
|
/**
|
|
* Router class for centralized route management
|
|
*/
|
|
class Router {
|
|
constructor() {
|
|
this.routes = new Map();
|
|
this.currentRoute = null;
|
|
this.previousRoute = null;
|
|
this.defaultRoute = 'projects'; // Updated default for new architecture
|
|
this.isNavigating = false;
|
|
|
|
// Bind handlers
|
|
this.handleHashChange = this.handleHashChange.bind(this);
|
|
this.handlePopState = this.handlePopState.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Initialize the router
|
|
*/
|
|
init() {
|
|
// Register new feature module routes
|
|
this.registerAll([
|
|
{
|
|
path: '/projects',
|
|
name: 'Projects',
|
|
handler: () => this.loadModule('dss-projects-module', () => import('../modules/projects/ProjectsModule.js'))
|
|
},
|
|
{
|
|
path: '/config',
|
|
name: 'Configuration',
|
|
handler: () => this.loadModule('dss-config-module', () => import('../modules/config/ConfigModule.js'))
|
|
},
|
|
{
|
|
path: '/components',
|
|
name: 'Components',
|
|
handler: () => this.loadModule('dss-components-module', () => import('../modules/components/ComponentsModule.js'))
|
|
},
|
|
{
|
|
path: '/translations',
|
|
name: 'Translations',
|
|
handler: () => this.loadModule('dss-translations-module', () => import('../modules/translations/TranslationsModule.js'))
|
|
},
|
|
{
|
|
path: '/discovery',
|
|
name: 'Discovery',
|
|
handler: () => this.loadModule('dss-discovery-module', () => import('../modules/discovery/DiscoveryModule.js'))
|
|
},
|
|
{
|
|
path: '/admin',
|
|
name: 'Admin',
|
|
handler: () => this.loadModule('dss-admin-module', () => import('../modules/admin/AdminModule.js'))
|
|
}
|
|
]);
|
|
|
|
// Listen for hash changes
|
|
window.addEventListener('hashchange', this.handleHashChange);
|
|
window.addEventListener('popstate', this.handlePopState);
|
|
|
|
// Handle initial route
|
|
this.handleHashChange();
|
|
}
|
|
|
|
/**
|
|
* Helper to load dynamic modules into the stage
|
|
* @param {string} tagName - Custom element tag name
|
|
* @param {Function} importFn - Dynamic import function
|
|
*/
|
|
async loadModule(tagName, importFn) {
|
|
try {
|
|
// 1. Load the module file
|
|
await importFn();
|
|
|
|
// 2. Update the stage content
|
|
const stageContent = document.querySelector('#stage-workdesk-content');
|
|
if (stageContent) {
|
|
stageContent.innerHTML = '';
|
|
const element = document.createElement(tagName);
|
|
stageContent.appendChild(element);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to load module ${tagName}:`, error);
|
|
notifyError(`Failed to load module`, ErrorCode.SYSTEM_UNEXPECTED);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a route
|
|
* @param {RouteConfig} config - Route configuration
|
|
*/
|
|
register(config) {
|
|
if (!config.path) {
|
|
throw new Error('Route path is required');
|
|
}
|
|
|
|
if (!config.handler) {
|
|
throw new Error('Route handler is required');
|
|
}
|
|
|
|
// Normalize path (remove leading slash for hash routing)
|
|
const path = config.path.replace(/^\//, '');
|
|
|
|
this.routes.set(path, {
|
|
path,
|
|
name: config.name || path,
|
|
handler: config.handler,
|
|
beforeEnter: config.beforeEnter || null,
|
|
afterEnter: config.afterEnter || null,
|
|
onLeave: config.onLeave || null,
|
|
meta: config.meta || {},
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Register multiple routes
|
|
* @param {RouteConfig[]} routes - Array of route configurations
|
|
*/
|
|
registerAll(routes) {
|
|
routes.forEach(route => this.register(route));
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set default route
|
|
* @param {string} path - Default route path
|
|
*/
|
|
setDefaultRoute(path) {
|
|
this.defaultRoute = path.replace(/^\//, '');
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Navigate to a route
|
|
* @param {string} path - Route path or name
|
|
* @param {Object} [options] - Navigation options
|
|
* @param {boolean} [options.replace] - Replace history instead of push
|
|
* @param {Object} [options.state] - State to pass to route
|
|
*/
|
|
navigate(path, options = {}) {
|
|
const normalizedPath = path.replace(/^\//, '');
|
|
|
|
// Update hash
|
|
if (options.replace) {
|
|
window.location.replace(`#${normalizedPath}`);
|
|
} else {
|
|
window.location.hash = normalizedPath;
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Navigate to a route by name
|
|
* @param {string} name - Route name
|
|
* @param {Object} [options] - Navigation options
|
|
*/
|
|
navigateByName(name, options = {}) {
|
|
const route = Array.from(this.routes.values()).find(r => r.name === name);
|
|
|
|
if (!route) {
|
|
notifyError(`Route not found: ${name}`, ErrorCode.SYSTEM_UNEXPECTED);
|
|
return this;
|
|
}
|
|
|
|
return this.navigate(route.path, options);
|
|
}
|
|
|
|
/**
|
|
* Handle hash change event
|
|
*/
|
|
async handleHashChange() {
|
|
if (this.isNavigating) return;
|
|
|
|
this.isNavigating = true;
|
|
|
|
try {
|
|
// Get path from hash
|
|
let path = window.location.hash.replace(/^#/, '') || this.defaultRoute;
|
|
|
|
// Extract route and params
|
|
const { routePath, params } = this.parseRoute(path);
|
|
|
|
// Find route
|
|
const route = this.routes.get(routePath);
|
|
|
|
if (!route) {
|
|
console.warn(`Route not registered: ${routePath}, falling back to default`);
|
|
this.navigate(this.defaultRoute, { replace: true });
|
|
return;
|
|
}
|
|
|
|
// Call onLeave hook for previous route
|
|
if (this.currentRoute && this.currentRoute.onLeave) {
|
|
await this.callHook(this.currentRoute.onLeave, { from: this.currentRoute, to: route });
|
|
}
|
|
|
|
// Call beforeEnter guard
|
|
if (route.beforeEnter) {
|
|
const canEnter = await this.callGuard(route.beforeEnter, { route, params });
|
|
|
|
if (!canEnter) {
|
|
// Guard rejected, stay on current route or go to default
|
|
if (this.currentRoute) {
|
|
this.navigate(this.currentRoute.path, { replace: true });
|
|
} else {
|
|
this.navigate(this.defaultRoute, { replace: true });
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update route tracking
|
|
this.previousRoute = this.currentRoute;
|
|
this.currentRoute = route;
|
|
|
|
// Call route handler
|
|
await route.handler({ route, params, router: this });
|
|
|
|
// Call afterEnter hook
|
|
if (route.afterEnter) {
|
|
await this.callHook(route.afterEnter, { route, params, from: this.previousRoute });
|
|
}
|
|
|
|
// Emit route change event
|
|
this.emitRouteChange(route, params);
|
|
|
|
} catch (error) {
|
|
console.error('Router navigation error:', error);
|
|
notifyError('Navigation failed', ErrorCode.SYSTEM_UNEXPECTED, {
|
|
path: window.location.hash,
|
|
error: error.message,
|
|
});
|
|
} finally {
|
|
this.isNavigating = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle popstate event (browser back/forward)
|
|
*/
|
|
handlePopState(event) {
|
|
this.handleHashChange();
|
|
}
|
|
|
|
/**
|
|
* Parse route path and extract params
|
|
* @param {string} path - Route path
|
|
* @returns {Object} Route path and params
|
|
*/
|
|
parseRoute(path) {
|
|
// For now, simple implementation - just return the path
|
|
// Can be extended to support params like '/projects/:id'
|
|
const [routePath, queryString] = path.split('?');
|
|
|
|
const params = {};
|
|
if (queryString) {
|
|
new URLSearchParams(queryString).forEach((value, key) => {
|
|
params[key] = value;
|
|
});
|
|
}
|
|
|
|
return { routePath, params };
|
|
}
|
|
|
|
/**
|
|
* Call a route guard
|
|
* @param {Function} guard - Guard function
|
|
* @param {Object} context - Guard context
|
|
* @returns {Promise<boolean>} Whether navigation should proceed
|
|
*/
|
|
async callGuard(guard, context) {
|
|
try {
|
|
const result = await guard(context);
|
|
return result !== false; // Undefined or true = proceed
|
|
} catch (error) {
|
|
console.error('Route guard error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call a lifecycle hook
|
|
* @param {Function} hook - Hook function
|
|
* @param {Object} context - Hook context
|
|
*/
|
|
async callHook(hook, context) {
|
|
try {
|
|
await hook(context);
|
|
} catch (error) {
|
|
console.error('Route hook error:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit route change event
|
|
* @param {Object} route - Current route
|
|
* @param {Object} params - Route params
|
|
*/
|
|
emitRouteChange(route, params) {
|
|
window.dispatchEvent(new CustomEvent('route-changed', {
|
|
detail: {
|
|
route,
|
|
params,
|
|
previous: this.previousRoute,
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get current route
|
|
* @returns {Object|null} Current route config
|
|
*/
|
|
getCurrentRoute() {
|
|
return this.currentRoute;
|
|
}
|
|
|
|
/**
|
|
* Get previous route
|
|
* @returns {Object|null} Previous route config
|
|
*/
|
|
getPreviousRoute() {
|
|
return this.previousRoute;
|
|
}
|
|
|
|
/**
|
|
* Check if a route exists
|
|
* @param {string} path - Route path
|
|
* @returns {boolean} Whether route exists
|
|
*/
|
|
hasRoute(path) {
|
|
const normalizedPath = path.replace(/^\//, '');
|
|
return this.routes.has(normalizedPath);
|
|
}
|
|
|
|
/**
|
|
* Get route by path
|
|
* @param {string} path - Route path
|
|
* @returns {Object|null} Route config
|
|
*/
|
|
getRoute(path) {
|
|
const normalizedPath = path.replace(/^\//, '');
|
|
return this.routes.get(normalizedPath) || null;
|
|
}
|
|
|
|
/**
|
|
* Get all routes
|
|
* @returns {Array} All registered routes
|
|
*/
|
|
getAllRoutes() {
|
|
return Array.from(this.routes.values());
|
|
}
|
|
|
|
/**
|
|
* Go back in history
|
|
*/
|
|
back() {
|
|
window.history.back();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Go forward in history
|
|
*/
|
|
forward() {
|
|
window.history.forward();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Destroy the router (cleanup)
|
|
*/
|
|
destroy() {
|
|
window.removeEventListener('hashchange', this.handleHashChange);
|
|
window.removeEventListener('popstate', this.handlePopState);
|
|
this.routes.clear();
|
|
this.currentRoute = null;
|
|
this.previousRoute = null;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
const router = new Router();
|
|
|
|
// Export both the instance and the class
|
|
export { Router };
|
|
export default router;
|
|
|
|
/**
|
|
* Common route guards
|
|
*/
|
|
export const guards = {
|
|
/**
|
|
* Require authentication
|
|
*/
|
|
requireAuth({ route }) {
|
|
// Check if user is authenticated
|
|
// For now, always allow (implement auth later)
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Require specific permission
|
|
*/
|
|
requirePermission(permission) {
|
|
return ({ route }) => {
|
|
// Check if user has permission
|
|
// For now, always allow (implement RBAC later)
|
|
return true;
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Require project selected
|
|
*/
|
|
requireProject({ route, params }) {
|
|
const projectId = localStorage.getItem('dss_selected_project');
|
|
|
|
if (!projectId) {
|
|
notifyError('Please select a project first', ErrorCode.USER_ACTION_FORBIDDEN);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
};
|