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.
- package/CHANGELOG.md +466 -0
- package/README.md +198 -0
- package/package.json +91 -0
- package/src/components/base-component.js +925 -0
- package/src/core/component-manager.js +306 -0
- package/src/core/dom-morph.js +234 -0
- package/src/core/event-bus.js +229 -0
- package/src/core/router.js +487 -0
- package/src/core/signal.js +114 -0
- package/src/framework.js +323 -0
- package/src/plugins/alerts/alerts-plugin.js +427 -0
- package/src/plugins/fonts/files/inter.js +4 -0
- package/src/plugins/fonts/files/jetbrains-mono.js +4 -0
- package/src/plugins/fonts/font-manifests.js +53 -0
- package/src/plugins/fonts/fonts-plugin.js +246 -0
- package/src/plugins/icons/default-icons.js +51 -0
- package/src/plugins/icons/icons-plugin.js +130 -0
- package/src/plugins/store/store-plugin.js +127 -0
- package/src/plugins/theme/base-styles.js +58 -0
- package/src/plugins/theme/theme-plugin.js +160 -0
- package/src/utils/decorators.js +51 -0
- package/src/utils/dom.js +40 -0
- package/src/utils/error-handler.js +442 -0
- package/src/utils/framework-debug.js +375 -0
- package/src/utils/logger.js +324 -0
- package/src/utils/notification.js +123 -0
- package/src/utils/performance.js +281 -0
- package/src/utils/storage.js +86 -0
- package/src/utils/sweet-alert.js +84 -0
- package/src/utils/validation.js +70 -0
- package/src/utils/validators.js +129 -0
- package/types/index.d.ts +524 -0
|
@@ -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
|
+
}
|