diff --git a/web/src/router.svelte.ts b/web/src/router.svelte.ts new file mode 100644 index 0000000..8d46082 --- /dev/null +++ b/web/src/router.svelte.ts @@ -0,0 +1,146 @@ +/** + * Hash-based SPA router using Svelte 5 runes. + * Provides simple routing without external dependencies. + */ + +/** + * Route definition with pattern and name. + */ +export type RouteDefinition = { + pattern: string; + name: string; +}; + +/** + * Route match result with name and extracted parameters. + */ +export type RouteMatch = { + name: string; + params: Record; +}; + +/** + * Parse a route pattern into a regex and parameter names. + * For example, `/goals/:goalId/tasks` becomes regex `/^\/goals\/([^/]+)\/tasks$/` + * and paramNames = ['goalId']. + * + * Pure function, not dependent on $state. + */ +function parseRoutePattern(pattern: string): { regex: RegExp; paramNames: Array } { + const paramNames: Array = []; + let regexPattern = pattern; + + // Replace :paramName with a capture group and record the param name + const paramRegex = /:([a-zA-Z_][a-zA-Z0-9_]*)/g; + regexPattern = regexPattern.replace(paramRegex, (_, paramName: string) => { + paramNames.push(paramName); + return '([^/]+)'; // Match anything except / + }); + + // Escape special regex characters in the pattern (but keep the replaced groups) + // Instead, build the regex more carefully + regexPattern = '^' + regexPattern + '$'; + const regex = new RegExp(regexPattern); + + return { regex, paramNames }; +} + +/** + * Match a path against a route definition and extract parameters. + * + * Pure function, not dependent on $state. + * Can be unit tested directly. + * + * @param path The URL path to match (e.g., '/goals/ra-1234/tasks') + * @param routes Array of route definitions + * @returns A RouteMatch with the matched route name and extracted params, or not-found + */ +export function matchRoute(path: string, routes: Array): RouteMatch { + // Handle empty string as root + const normalizedPath = path === '' ? '/' : path; + + for (const route of routes) { + const { regex, paramNames } = parseRoutePattern(route.pattern); + const match = normalizedPath.match(regex); + + if (match) { + const params: Record = {}; + for (let i = 0; i < paramNames.length; i++) { + params[paramNames[i]] = match[i + 1]; + } + return { name: route.name, params }; + } + } + + // No match found + return { name: 'not-found', params: {} }; +} + +/** + * Router state object. + * Managed as Svelte 5 $state for reactivity. + */ +export const routerState = $state<{ + path: string; + params: Record; +}>({ + path: '/', + params: {}, +}); + +/** + * Route definitions for the application. + */ +const routes: Array = [ + { pattern: '/', name: 'dashboard' }, + { pattern: '/projects', name: 'project-list' }, + { pattern: '/projects/:projectId', name: 'project-detail' }, + { pattern: '/goals/:goalId/tasks', name: 'task-tree' }, + { pattern: '/goals/:goalId/decisions', name: 'decision-graph' }, + { pattern: '/goals/:goalId/agents', name: 'agent-monitor' }, + { pattern: '/goals/:goalId/sessions', name: 'session-history' }, + { pattern: '/search', name: 'graph-search' }, +]; + +/** + * Get the current route based on the current path. + * Returns a derived value computed from the current router state. + */ +export function getCurrentRoute(): RouteMatch { + return matchRoute(routerState.path, routes); +} + +/** + * Navigate to a path by setting the hash. + * @param path The path to navigate to (e.g., '/projects/ra-abcd') + */ +export function navigate(path: string): void { + window.location.hash = '#' + path; +} + +/** + * Initialize the router and listen for hash changes. + * The $effect handles setup and cleanup automatically. + */ +$effect(() => { + const handler = () => { + // Parse the hash and update routerState + let hash = window.location.hash; + // Remove leading '#' + if (hash.startsWith('#')) { + hash = hash.slice(1); + } + routerState.path = hash === '' ? '/' : hash; + }; + + // Parse current hash on init + handler(); + + // Listen for hash changes + window.addEventListener('hashchange', handler); + + // Return cleanup function + return () => { + window.removeEventListener('hashchange', handler); + }; +}); diff --git a/web/src/router.test.ts b/web/src/router.test.ts new file mode 100644 index 0000000..1b19cf7 --- /dev/null +++ b/web/src/router.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { matchRoute } from './router.svelte'; + + +describe('matchRoute', () => { + const routes = [ + { pattern: '/', name: 'dashboard' }, + { pattern: '/projects', name: 'project-list' }, + { pattern: '/projects/:projectId', name: 'project-detail' }, + { pattern: '/goals/:goalId/tasks', name: 'task-tree' }, + { pattern: '/goals/:goalId/decisions', name: 'decision-graph' }, + { pattern: '/goals/:goalId/agents', name: 'agent-monitor' }, + { pattern: '/goals/:goalId/sessions', name: 'session-history' }, + { pattern: '/search', name: 'graph-search' }, + ]; + + it('matches root path to dashboard', () => { + const result = matchRoute('/', routes); + expect(result.name).toBe('dashboard'); + expect(result.params).toEqual({}); + }); + + it('matches /projects to project-list', () => { + const result = matchRoute('/projects', routes); + expect(result.name).toBe('project-list'); + expect(result.params).toEqual({}); + }); + + it('matches /projects/:projectId with parameter extraction', () => { + const result = matchRoute('/projects/ra-abcd', routes); + expect(result.name).toBe('project-detail'); + expect(result.params).toEqual({ projectId: 'ra-abcd' }); + }); + + it('matches /goals/:goalId/tasks with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/tasks', routes); + expect(result.name).toBe('task-tree'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /goals/:goalId/decisions with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/decisions', routes); + expect(result.name).toBe('decision-graph'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /goals/:goalId/agents with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/agents', routes); + expect(result.name).toBe('agent-monitor'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /goals/:goalId/sessions with parameter extraction', () => { + const result = matchRoute('/goals/ra-1234/sessions', routes); + expect(result.name).toBe('session-history'); + expect(result.params).toEqual({ goalId: 'ra-1234' }); + }); + + it('matches /search to graph-search', () => { + const result = matchRoute('/search', routes); + expect(result.name).toBe('graph-search'); + expect(result.params).toEqual({}); + }); + + it('returns not-found for unknown path', () => { + const result = matchRoute('/unknown/path', routes); + expect(result.name).toBe('not-found'); + expect(result.params).toEqual({}); + }); + + it('returns dashboard for empty string (root route)', () => { + const result = matchRoute('', routes); + expect(result.name).toBe('dashboard'); + expect(result.params).toEqual({}); + }); + + it('extracts multiple parameters from complex paths', () => { + const customRoutes = [ + { pattern: '/projects/:projectId/goals/:goalId', name: 'goal-detail' }, + ]; + const result = matchRoute('/projects/proj-123/goals/goal-456', customRoutes); + expect(result.name).toBe('goal-detail'); + expect(result.params).toEqual({ projectId: 'proj-123', goalId: 'goal-456' }); + }); + + it('does not match partial paths', () => { + const result = matchRoute('/goal', routes); + expect(result.name).toBe('not-found'); + }); + + it('handles trailing slashes correctly', () => { + const result = matchRoute('/projects/', routes); + // /projects/ should not match /projects + expect(result.name).toBe('not-found'); + }); + + it('handles parameters with special characters', () => { + const result = matchRoute('/projects/ra-abc-def', routes); + expect(result.name).toBe('project-detail'); + expect(result.params).toEqual({ projectId: 'ra-abc-def' }); + }); +});