vanillaforge 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Event Bus System
3
+ *
4
+ * Centralized event management system for the VanillaForge.
5
+ * Provides publish-subscribe functionality for loose coupling between components.
6
+ *
7
+ * @author VanillaForge Team
8
+ * @version 3.0.0
9
+ * @since 2025-06-14
10
+ */
11
+
12
+ import { Logger } from '../utils/logger.js';
13
+
14
+ /**
15
+ * Event Bus class for application-wide event management
16
+ *
17
+ * Implements the publish-subscribe pattern for decoupled communication
18
+ * between different parts of the application.
19
+ */
20
+ export class EventBus {
21
+ /**
22
+ * Initialize the event bus
23
+ */
24
+ constructor(logger) {
25
+ this.logger = logger || new Logger('EventBus');
26
+ this.listeners = new Map();
27
+ this.eventHistory = [];
28
+ this.maxHistorySize = 100;
29
+ }
30
+
31
+ /**
32
+ * Subscribe to an event
33
+ *
34
+ * @param {string} event - Event name to listen for
35
+ * @param {Function} callback - Function to call when event is emitted
36
+ * @param {Object} [options={}] - Subscription options
37
+ * @param {number} [options.priority=0] - Event handler priority (higher = called first)
38
+ * @param {Object} [options.context] - Context object for the callback
39
+ * @returns {Function} Unsubscribe function
40
+ */
41
+ on(event, callback, options = {}) {
42
+ if (typeof callback !== 'function') {
43
+ throw new Error('Callback must be a function.');
44
+ }
45
+
46
+ if (!this.listeners.has(event)) {
47
+ this.listeners.set(event, []);
48
+ }
49
+
50
+ const listeners = this.listeners.get(event);
51
+ const listener = {
52
+ callback,
53
+ once: options.once || false,
54
+ priority: options.priority || 0,
55
+ };
56
+
57
+ listeners.push(listener);
58
+ listeners.sort((a, b) => b.priority - a.priority);
59
+
60
+ return () => this.off(event, callback);
61
+ }
62
+
63
+ once(event, callback, options = {}) {
64
+ return this.on(event, callback, { ...options, once: true });
65
+ }
66
+
67
+ off(event, callback) {
68
+ if (!this.listeners.has(event)) {
69
+ return;
70
+ }
71
+
72
+ const listeners = this.listeners.get(event);
73
+ const index = listeners.findIndex(l => l.callback === callback);
74
+
75
+ if (index !== -1) {
76
+ listeners.splice(index, 1);
77
+ }
78
+ }
79
+
80
+
81
+ emit(event, data = null) {
82
+ if (!this.listeners.has(event)) {
83
+ return;
84
+ }
85
+
86
+ const listeners = this.listeners.get(event).slice();
87
+ this.addToHistory({ event, data, timestamp: new Date().toISOString() });
88
+
89
+ for (const listener of listeners) {
90
+ try {
91
+ listener.callback(data);
92
+ } catch (error) {
93
+ this.logger.error(`Error in event listener for ${event}`, error);
94
+ }
95
+
96
+ if (listener.once) {
97
+ this.off(event, listener.callback);
98
+ }
99
+ }
100
+ }
101
+
102
+
103
+ /**
104
+ * Add event to history
105
+ *
106
+ * @private
107
+ * @param {Object} eventData - Event data to add to history
108
+ */
109
+ addToHistory(eventData) {
110
+ this.eventHistory.unshift(eventData);
111
+
112
+ // Limit history size
113
+ if (this.eventHistory.length > this.maxHistorySize) {
114
+ this.eventHistory = this.eventHistory.slice(0, this.maxHistorySize);
115
+ }
116
+ }
117
+
118
+
119
+ /**
120
+ * Get all listeners for an event
121
+ *
122
+ * @param {string} event - Event name
123
+ * @returns {Array} Array of listener objects
124
+ */
125
+ getListeners(event) {
126
+ const regular = this.listeners.get(event) || [];
127
+ const once = this.onceListeners.get(event) || [];
128
+
129
+ return {
130
+ regular: regular.map(l => ({
131
+ id: l.id,
132
+ priority: l.priority,
133
+ createdAt: l.createdAt
134
+ })),
135
+ once: once.map(l => ({
136
+ id: l.id,
137
+ priority: l.priority,
138
+ createdAt: l.createdAt
139
+ }))
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Get event statistics
145
+ *
146
+ * @returns {Object} Event bus statistics
147
+ */
148
+ getStats() {
149
+ const allEvents = new Set([
150
+ ...this.listeners.keys(),
151
+ ...this.onceListeners.keys()
152
+ ]);
153
+
154
+ const eventStats = {};
155
+ for (const event of allEvents) {
156
+ const regular = this.listeners.get(event) || [];
157
+ const once = this.onceListeners.get(event) || [];
158
+
159
+ eventStats[event] = {
160
+ regularListeners: regular.length,
161
+ onceListeners: once.length,
162
+ total: regular.length + once.length
163
+ };
164
+ }
165
+
166
+ return {
167
+ totalEvents: allEvents.size,
168
+ totalListeners: Array.from(allEvents).reduce((sum, event) => {
169
+ return sum + eventStats[event].total;
170
+ }, 0),
171
+ eventStats,
172
+ historySize: this.eventHistory.length
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Get recent event history
178
+ *
179
+ * @param {number} [limit=10] - Maximum number of events to return
180
+ * @returns {Array} Recent events
181
+ */
182
+ getHistory(limit = 10) {
183
+ return this.eventHistory.slice(0, limit);
184
+ }
185
+ /**
186
+ * Remove all listeners
187
+ */
188
+ removeAllListeners(event) {
189
+ if (event) {
190
+ this.listeners.delete(event);
191
+ } else {
192
+ this.listeners.clear();
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Clear event history
198
+ */
199
+ clearHistory() {
200
+ const historySize = this.eventHistory.length;
201
+ this.eventHistory = [];
202
+
203
+ this.logger.debug('Event history cleared', { historySize });
204
+ }
205
+
206
+ /**
207
+ * Cleanup event bus - remove all listeners and clear history
208
+ */
209
+ cleanup() {
210
+ this.removeAllListeners();
211
+ this.clearHistory();
212
+ this.logger.info('Event bus cleaned up');
213
+ }
214
+
215
+ /**
216
+ * Set debug mode for enhanced logging
217
+ *
218
+ * @param {boolean} enabled - Whether to enable debug mode
219
+ */
220
+ setDebugMode(enabled) {
221
+ this.debugMode = enabled;
222
+ if (enabled) {
223
+ this.logger.info('Event bus debug mode enabled');
224
+ } else {
225
+ this.logger.info('Event bus debug mode disabled');
226
+ }
227
+ }
228
+
229
+ }
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Router System
3
+ *
4
+ * Handles client-side routing for VanillaForge applications.
5
+ * Manages navigation, route protection, and URL state management.
6
+ *
7
+ * @author VanillaForge Team
8
+ * @version 1.0.0
9
+ * @since 2025-06-15
10
+ */
11
+
12
+ import { Logger } from '../utils/logger.js';
13
+ import { ErrorHandler, ErrorType } from '../utils/error-handler.js';
14
+
15
+ /**
16
+ * Router class for client-side navigation
17
+ *
18
+ * Manages application routes, navigation, and URL state without full page reloads.
19
+ */
20
+ export class Router {
21
+ /**
22
+ * Initialize the router
23
+ *
24
+ * @param {EventBus} eventBus - Application event bus
25
+ * @param {Logger} logger - Logger instance
26
+ * @param {ErrorHandler} errorHandler - Error handler instance
27
+ * @param {Object} config - Router configuration
28
+ */
29
+ constructor(eventBus, logger, errorHandler, config = {}) {
30
+ this.eventBus = eventBus;
31
+ this.logger = logger || new Logger('Router');
32
+ this.errorHandler = errorHandler || new ErrorHandler();
33
+ this.config = {
34
+ basePath: '',
35
+ mode: 'history',
36
+ fallback: '/404',
37
+ ...config
38
+ };
39
+ this.routes = new Map();
40
+ this.currentRoute = null;
41
+ this.isInitialized = false;
42
+ this.beforeNavigationCallbacks = [];
43
+ this.afterNavigationCallbacks = [];
44
+ this.isNavigating = false;
45
+
46
+ // Normalize base path
47
+ this.basePath = this.config.basePath.replace(/\/+$/, ''); // Remove trailing slashes
48
+
49
+ this.handlePopState = this.handlePopState.bind(this);
50
+ this.handleLinkClick = this.handleLinkClick.bind(this);
51
+ }
52
+
53
+ /**
54
+ * Initialize the router
55
+ *
56
+ * @returns {Promise<void>}
57
+ */
58
+ async init() {
59
+ try {
60
+ this.logger.info('Initializing router...');
61
+
62
+ // Set up event listeners
63
+ this.setupEventListeners();
64
+
65
+ // Handle initial route
66
+ await this.handleInitialRoute();
67
+
68
+ this.isInitialized = true;
69
+ this.logger.info('Router initialized successfully');
70
+
71
+ } catch (error) {
72
+ this.logger.error('Failed to initialize router', error);
73
+ this.errorHandler.handleError(error, ErrorType.SYSTEM);
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Alias for init() to maintain compatibility with FrameworkApp
80
+ */
81
+ async initialize() {
82
+ return this.init();
83
+ }
84
+
85
+ /**
86
+ * Alias for init() to maintain compatibility with FrameworkApp
87
+ */
88
+ async start() {
89
+ if (!this.isInitialized) {
90
+ await this.init();
91
+ }
92
+ this.logger.info('Router started');
93
+ }
94
+
95
+ /**
96
+ * Add a route to the router
97
+ *
98
+ * @param {string} path - Route path
99
+ * @param {Object|Function} config - Route configuration object or component class
100
+ */
101
+ addRoute(path, config) {
102
+ // Support both object config and direct component class
103
+ const routeConfig = typeof config === 'function' ? {
104
+ component: config,
105
+ protected: false,
106
+ title: null
107
+ } : config;
108
+
109
+ this.routes.set(path, {
110
+ path,
111
+ name: routeConfig.name || path.replace('/', '') || 'home',
112
+ component: routeConfig.component,
113
+ loader: routeConfig.loader || null,
114
+ protected: routeConfig.protected || false,
115
+ requiredRole: routeConfig.requiredRole || null,
116
+ title: routeConfig.title || 'VanillaForge App',
117
+ beforeEnter: routeConfig.beforeEnter || null,
118
+ afterEnter: routeConfig.afterEnter || null
119
+ });
120
+
121
+ this.logger.debug(`Route added: ${path}`, routeConfig);
122
+ }
123
+
124
+ /**
125
+ * Set up event listeners
126
+ *
127
+ * @private
128
+ */
129
+ setupEventListeners() {
130
+ // Handle browser back/forward buttons
131
+ window.addEventListener('popstate', this.handlePopState);
132
+
133
+ // Handle link clicks for SPA navigation
134
+ document.addEventListener('click', this.handleLinkClick);
135
+
136
+ // Allow components to request navigation over the event bus
137
+ this.eventBus.on('router:navigate', (payload) => {
138
+ const path = typeof payload === 'string' ? payload : payload?.path;
139
+ const options = (payload && typeof payload === 'object') ? payload.options : undefined;
140
+ if (path) this.navigateTo(path, options);
141
+ });
142
+
143
+ this.logger.debug('Event listeners set up');
144
+ }
145
+ /**
146
+ * Handle initial route when the app starts
147
+ *
148
+ * @private
149
+ */
150
+ async handleInitialRoute() {
151
+ const currentPath = window.location.pathname;
152
+ const resolvedPath = this.resolvePathFromBase(currentPath);
153
+ await this.navigateTo(resolvedPath, { replace: true });
154
+ }
155
+ /**
156
+ * Handle browser back/forward navigation
157
+ *
158
+ * @param {PopStateEvent} event - Pop state event
159
+ * @private
160
+ */
161
+ async handlePopState(_event) {
162
+ const path = window.location.pathname;
163
+ const resolvedPath = this.resolvePathFromBase(path);
164
+ await this.navigateTo(resolvedPath, { fromPopState: true });
165
+ }
166
+ /**
167
+ * Handle link clicks for SPA navigation
168
+ *
169
+ * @param {Event} event - Click event
170
+ * @private
171
+ */
172
+ handleLinkClick(event) {
173
+ const link = event.target.closest('a');
174
+
175
+ if (!link || !link.href) return;
176
+
177
+ // Only handle internal links
178
+ const url = new URL(link.href);
179
+ if (url.origin !== window.location.origin) return;
180
+
181
+ // Skip if link has download attribute or opens in new tab
182
+ if (link.download || link.target === '_blank') return;
183
+
184
+ // Skip if modifier keys are pressed
185
+ if (event.ctrlKey || event.metaKey || event.shiftKey) return;
186
+
187
+ event.preventDefault();
188
+ const resolvedPath = this.resolvePathFromBase(url.pathname);
189
+ this.navigateTo(resolvedPath + url.search + url.hash);
190
+ }
191
+
192
+ /**
193
+ * Navigate to a specific path
194
+ *
195
+ * @param {string} path - Path to navigate to
196
+ * @param {Object} options - Navigation options
197
+ * @returns {Promise<boolean>} - Success status
198
+ */
199
+ async navigateTo(path, options = {}) {
200
+ if (this.isNavigating) {
201
+ this.logger.warn('Navigation already in progress.');
202
+ return;
203
+ }
204
+
205
+ this.isNavigating = true;
206
+ this.logger.info(`Navigating to ${path}`);
207
+
208
+ try {
209
+ const { route, params } = this.findRoute(path); if (!route) {
210
+ this.logger.warn(`No route found for path: ${path}`);
211
+ this.eventBus.emit('router:not-found', { path });
212
+
213
+ // Fall back to the configured route (e.g. a 404 component) so the user
214
+ // sees something instead of a blank screen. Guard against looping when
215
+ // the fallback itself is unregistered.
216
+ const fallback = this.config.fallback;
217
+ if (fallback && path !== fallback && this.findRoute(fallback).route) {
218
+ this.logger.info(`Routing to fallback: ${fallback}`);
219
+ this.isNavigating = false;
220
+ return this.navigateTo(fallback, { replace: true });
221
+ }
222
+
223
+ this.isNavigating = false;
224
+ return;
225
+ }
226
+
227
+ const canNavigate = await this.runBeforeNavigationCallbacks(route, path);
228
+ if (!canNavigate) {
229
+ this.isNavigating = false;
230
+ return;
231
+ } if (!options.fromPopState) {
232
+ const fullPath = this.addBasePath(path);
233
+ const url = fullPath + (options.query ? `?${new URLSearchParams(options.query)}` : '');
234
+ window.history[options.replace ? 'replaceState' : 'pushState']({ path }, '', url);
235
+ }
236
+
237
+ this.currentRoute = { ...route, params };
238
+ document.title = route.title || 'VanillaForge App';
239
+
240
+ let loaderData;
241
+ if (route.loader) {
242
+ try {
243
+ loaderData = await route.loader({ params, path });
244
+ } catch (err) {
245
+ this.logger.error('Route loader failed', err);
246
+ }
247
+ }
248
+
249
+ this.eventBus.emit('router:load-component', {
250
+ component: route.component,
251
+ route: this.currentRoute,
252
+ loaderData,
253
+ });
254
+
255
+ await this.runAfterNavigationCallbacks(this.currentRoute, path);
256
+ this.eventBus.emit('router:navigated', { route: this.currentRoute, path });
257
+ } catch (error) {
258
+ this.errorHandler.handleError(error, { path, options });
259
+ } finally {
260
+ this.isNavigating = false;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Navigate (alias for navigateTo)
266
+ */
267
+ navigate(path, options = {}) {
268
+ return this.navigateTo(path, options);
269
+ }
270
+
271
+ /**
272
+ * Get current route
273
+ */
274
+ getCurrentRoute() {
275
+ return this.currentRoute;
276
+ }
277
+
278
+ /**
279
+ * Find a route that matches the given path
280
+ *
281
+ * @param {string} path - Path to match
282
+ * @returns {Object|null} - Matching route or null
283
+ * @private
284
+ */
285
+ findRoute(path) {
286
+ for (const [routePath, route] of this.routes) {
287
+ const { isMatch, params } = this.matchesRoute(path, routePath);
288
+ if (isMatch) {
289
+ return { route, params };
290
+ }
291
+ }
292
+ return { route: null, params: {} };
293
+ }
294
+
295
+ /**
296
+ * Check if a path matches a route pattern
297
+ *
298
+ * @param {string} path - Actual path
299
+ * @param {string} routePattern - Route pattern
300
+ * @returns {boolean} - Whether they match
301
+ * @private
302
+ */
303
+ matchesRoute(path, routePattern) {
304
+ const params = {};
305
+ const pathParts = path.split('/').filter(p => p);
306
+ const routeParts = routePattern.split('/').filter(p => p);
307
+
308
+ if (routeParts.length !== pathParts.length) {
309
+ return { isMatch: false, params };
310
+ }
311
+
312
+ const isMatch = routeParts.every((part, index) => {
313
+ if (part.startsWith(':')) {
314
+ params[part.substring(1)] = pathParts[index];
315
+ return true;
316
+ }
317
+ return part === pathParts[index];
318
+ });
319
+
320
+ return { isMatch, params };
321
+ }
322
+
323
+ /**
324
+ * Load the component for a route
325
+ *
326
+ * @param {Object} route - Route object
327
+ * @private
328
+ */ async loadRouteComponent(route) {
329
+ if (typeof route.component === 'string') {
330
+ // Component name - emit event to component manager
331
+ this.eventBus.emit('router:load-component', {
332
+ component: route.component,
333
+ route
334
+ });
335
+ } else if (typeof route.component === 'function') {
336
+ // Component class - emit event to component manager
337
+ this.eventBus.emit('router:load-component', {
338
+ component: route.component,
339
+ route
340
+ });
341
+ } else {
342
+ throw new Error(`Invalid component type for route: ${route.path}`);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Run before navigation callbacks
348
+ *
349
+ * @param {Object} route - Route object
350
+ * @param {string} path - Target path
351
+ * @returns {Promise<boolean>} - Whether navigation should continue
352
+ * @private
353
+ */
354
+ async runBeforeNavigationCallbacks(route, path) {
355
+ for (const callback of this.beforeNavigationCallbacks) {
356
+ try {
357
+ const result = await callback(route, path);
358
+ if (result === false) {
359
+ this.logger.debug('Navigation cancelled by before callback');
360
+ return false;
361
+ }
362
+ } catch (error) {
363
+ this.logger.error('Before navigation callback failed', error);
364
+ return false;
365
+ }
366
+ }
367
+
368
+ if (route.beforeEnter) {
369
+ try {
370
+ const result = await route.beforeEnter(route, path);
371
+ if (result === false) {
372
+ this.logger.debug('Navigation cancelled by route beforeEnter');
373
+ return false;
374
+ }
375
+ } catch (error) {
376
+ this.logger.error('Route beforeEnter failed', error);
377
+ return false;
378
+ }
379
+ }
380
+
381
+ return true;
382
+ }
383
+
384
+ /**
385
+ * Run after navigation callbacks
386
+ *
387
+ * @param {Object} route - Route object
388
+ * @param {string} path - Current path
389
+ * @private
390
+ */
391
+ async runAfterNavigationCallbacks(route, path) {
392
+ for (const callback of this.afterNavigationCallbacks) {
393
+ try {
394
+ await callback(route, path);
395
+ } catch (error) {
396
+ this.logger.error('After navigation callback failed', error);
397
+ }
398
+ }
399
+
400
+ if (route.afterEnter) {
401
+ try {
402
+ await route.afterEnter(route, path);
403
+ } catch (error) {
404
+ this.logger.error('Route afterEnter failed', error);
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Add before navigation callback
411
+ *
412
+ * @param {Function} callback - Callback function
413
+ */
414
+ beforeNavigation(callback) {
415
+ this.beforeNavigationCallbacks.push(callback);
416
+ }
417
+
418
+ /**
419
+ * Add after navigation callback
420
+ *
421
+ * @param {Function} callback - Callback function
422
+ */
423
+ afterNavigation(callback) {
424
+ this.afterNavigationCallbacks.push(callback);
425
+ }
426
+
427
+ /**
428
+ * Clean up router
429
+ */
430
+ async cleanup() {
431
+ window.removeEventListener('popstate', this.handlePopState);
432
+ document.removeEventListener('click', this.handleLinkClick);
433
+
434
+ this.routes.clear();
435
+ this.beforeNavigationCallbacks = [];
436
+ this.afterNavigationCallbacks = [];
437
+ this.currentRoute = null;
438
+ this.isInitialized = false;
439
+
440
+ this.logger.info('Router cleaned up');
441
+ }
442
+
443
+ /**
444
+ * Resolve base path from current path
445
+ *
446
+ * @param {string} path - Full path including base path
447
+ * @returns {string} - Path without base path
448
+ * @private
449
+ */
450
+ resolvePathFromBase(path) {
451
+ let resolved = path;
452
+
453
+ if (this.basePath && resolved.startsWith(this.basePath)) {
454
+ resolved = resolved.slice(this.basePath.length) || '/';
455
+ }
456
+
457
+ // Treat a trailing directory entry file (index.html) as the root of its
458
+ // directory, so serving from a subfolder still matches the '/' route.
459
+ resolved = resolved.replace(/\/?index\.html?$/i, '/');
460
+
461
+ if (!resolved.startsWith('/')) resolved = '/' + resolved;
462
+ return resolved;
463
+ }
464
+
465
+ /**
466
+ * Add base path to path
467
+ *
468
+ * @param {string} path - Path to add base to
469
+ * @returns {string} - Path with base path
470
+ * @private
471
+ */
472
+ addBasePath(path) {
473
+ if (!this.basePath) return path;
474
+
475
+ const normalizedPath = path.startsWith('/') ? path : '/' + path;
476
+ return this.basePath + normalizedPath;
477
+ }
478
+
479
+ /**
480
+ * Get current base path
481
+ *
482
+ * @returns {string} - Current base path
483
+ */
484
+ getBasePath() {
485
+ return this.basePath;
486
+ }
487
+ }