pulse-js-framework 1.4.8 → 1.4.9

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.
@@ -7,9 +7,11 @@
7
7
  * - Import statement support
8
8
  * - Slot-based component composition
9
9
  * - CSS scoping with unique class prefixes
10
+ * - Source map generation
10
11
  */
11
12
 
12
13
  import { NodeType } from './parser.js';
14
+ import { SourceMapGenerator } from './sourcemap.js';
13
15
 
14
16
  /** Generate a unique scope ID for CSS scoping */
15
17
  const generateScopeId = () => 'p' + Math.random().toString(36).substring(2, 8);
@@ -29,13 +31,92 @@ const STATEMENT_TOKEN_TYPES = new Set(['IF', 'FOR', 'EACH']);
29
31
  export class Transformer {
30
32
  constructor(ast, options = {}) {
31
33
  this.ast = ast;
32
- this.options = { runtime: 'pulse-js-framework/runtime', minify: false, scopeStyles: true, ...options };
34
+ this.options = {
35
+ runtime: 'pulse-js-framework/runtime',
36
+ minify: false,
37
+ scopeStyles: true,
38
+ sourceMap: false, // Enable source map generation
39
+ sourceFileName: null, // Original .pulse file name
40
+ sourceContent: null, // Original source content (for inline source maps)
41
+ ...options
42
+ };
33
43
  this.stateVars = new Set();
34
44
  this.propVars = new Set();
35
45
  this.propDefaults = new Map();
36
46
  this.actionNames = new Set();
37
47
  this.importedComponents = new Map();
38
48
  this.scopeId = this.options.scopeStyles ? generateScopeId() : null;
49
+
50
+ // Source map tracking
51
+ this.sourceMap = null;
52
+ this._currentLine = 0;
53
+ this._currentColumn = 0;
54
+
55
+ // Initialize source map generator if enabled
56
+ if (this.options.sourceMap) {
57
+ this.sourceMap = new SourceMapGenerator({
58
+ file: this.options.sourceFileName?.replace('.pulse', '.js') || 'output.js'
59
+ });
60
+ if (this.options.sourceFileName) {
61
+ this.sourceMap.addSource(
62
+ this.options.sourceFileName,
63
+ this.options.sourceContent
64
+ );
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Add a mapping to the source map
71
+ * @param {Object} original - Original position {line, column} (1-based)
72
+ * @param {string} name - Optional identifier name
73
+ */
74
+ _addMapping(original, name = null) {
75
+ if (!this.sourceMap || !original) return;
76
+
77
+ this.sourceMap.addMapping({
78
+ generated: {
79
+ line: this._currentLine,
80
+ column: this._currentColumn
81
+ },
82
+ original: {
83
+ line: original.line - 1, // Convert to 0-based
84
+ column: original.column - 1
85
+ },
86
+ source: this.options.sourceFileName,
87
+ name
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Track output position when writing code
93
+ * @param {string} code - Generated code
94
+ * @returns {string} The same code (for chaining)
95
+ */
96
+ _trackCode(code) {
97
+ for (const char of code) {
98
+ if (char === '\n') {
99
+ this._currentLine++;
100
+ this._currentColumn = 0;
101
+ } else {
102
+ this._currentColumn++;
103
+ }
104
+ }
105
+ return code;
106
+ }
107
+
108
+ /**
109
+ * Write code with optional source mapping
110
+ * @param {string} code - Code to write
111
+ * @param {Object} original - Original position {line, column}
112
+ * @param {string} name - Optional identifier name
113
+ * @returns {string} The code
114
+ */
115
+ _emit(code, original = null, name = null) {
116
+ if (original) {
117
+ this._addMapping(original, name);
118
+ }
119
+ return this._trackCode(code);
39
120
  }
40
121
 
41
122
  /**
@@ -100,7 +181,32 @@ export class Transformer {
100
181
  // Component export
101
182
  parts.push(this.generateExport());
102
183
 
103
- return parts.filter(Boolean).join('\n\n');
184
+ const code = parts.filter(Boolean).join('\n\n');
185
+
186
+ // Track the generated code for source map positions
187
+ if (this.sourceMap) {
188
+ this._trackCode(code);
189
+ }
190
+
191
+ return code;
192
+ }
193
+
194
+ /**
195
+ * Transform AST and return result with optional source map
196
+ * @returns {Object} Result with code and optional sourceMap
197
+ */
198
+ transformWithSourceMap() {
199
+ const code = this.transform();
200
+
201
+ if (!this.sourceMap) {
202
+ return { code, sourceMap: null };
203
+ }
204
+
205
+ return {
206
+ code,
207
+ sourceMap: this.sourceMap.toJSON(),
208
+ sourceMapComment: this.sourceMap.toComment()
209
+ };
104
210
  }
105
211
 
106
212
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.4.8",
3
+ "version": "1.4.9",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -42,6 +42,7 @@
42
42
  "default": "./runtime/logger.js"
43
43
  },
44
44
  "./runtime/hmr": {
45
+ "types": "./types/hmr.d.ts",
45
46
  "default": "./runtime/hmr.js"
46
47
  },
47
48
  "./compiler": {
@@ -70,8 +71,9 @@
70
71
  "LICENSE"
71
72
  ],
72
73
  "scripts": {
73
- "test": "npm run test:compiler && npm run test:pulse && npm run test:dom && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze",
74
+ "test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze",
74
75
  "test:compiler": "node test/compiler.test.js",
76
+ "test:sourcemap": "node test/sourcemap.test.js",
75
77
  "test:pulse": "node test/pulse.test.js",
76
78
  "test:dom": "node test/dom.test.js",
77
79
  "test:router": "node test/router.test.js",
package/runtime/router.js CHANGED
@@ -10,11 +10,328 @@
10
10
  * - Per-route and global guards
11
11
  * - Scroll restoration
12
12
  * - Lazy-loaded routes
13
+ * - Middleware support
13
14
  */
14
15
 
15
16
  import { pulse, effect, batch } from './pulse.js';
16
17
  import { el } from './dom.js';
17
18
 
19
+ /**
20
+ * Lazy load helper for route components
21
+ * Wraps a dynamic import to provide loading states and error handling
22
+ *
23
+ * @param {function} importFn - Dynamic import function () => import('./Component.js')
24
+ * @param {Object} options - Lazy loading options
25
+ * @param {function} options.loading - Loading component function
26
+ * @param {function} options.error - Error component function
27
+ * @param {number} options.timeout - Timeout in ms (default: 10000)
28
+ * @param {number} options.delay - Delay before showing loading (default: 200)
29
+ * @returns {function} Lazy route handler
30
+ *
31
+ * @example
32
+ * const routes = {
33
+ * '/dashboard': lazy(() => import('./Dashboard.js')),
34
+ * '/settings': lazy(() => import('./Settings.js'), {
35
+ * loading: () => el('div.spinner', 'Loading...'),
36
+ * error: (err) => el('div.error', `Failed to load: ${err.message}`),
37
+ * timeout: 5000
38
+ * })
39
+ * };
40
+ */
41
+ export function lazy(importFn, options = {}) {
42
+ const {
43
+ loading: LoadingComponent = null,
44
+ error: ErrorComponent = null,
45
+ timeout = 10000,
46
+ delay = 200
47
+ } = options;
48
+
49
+ // Cache for loaded component
50
+ let cachedComponent = null;
51
+ let loadPromise = null;
52
+
53
+ return function lazyHandler(ctx) {
54
+ // Return cached component if already loaded
55
+ if (cachedComponent) {
56
+ return typeof cachedComponent === 'function'
57
+ ? cachedComponent(ctx)
58
+ : cachedComponent.default
59
+ ? cachedComponent.default(ctx)
60
+ : cachedComponent.render
61
+ ? cachedComponent.render(ctx)
62
+ : cachedComponent;
63
+ }
64
+
65
+ // Create container for async loading
66
+ const container = el('div.lazy-route');
67
+ let loadingTimer = null;
68
+ let timeoutTimer = null;
69
+
70
+ // Start loading if not already
71
+ if (!loadPromise) {
72
+ loadPromise = importFn();
73
+ }
74
+
75
+ // Delay showing loading state to avoid flash
76
+ if (LoadingComponent && delay > 0) {
77
+ loadingTimer = setTimeout(() => {
78
+ if (!cachedComponent) {
79
+ container.replaceChildren(LoadingComponent());
80
+ }
81
+ }, delay);
82
+ } else if (LoadingComponent) {
83
+ container.replaceChildren(LoadingComponent());
84
+ }
85
+
86
+ // Set timeout for loading
87
+ const timeoutPromise = timeout > 0
88
+ ? new Promise((_, reject) => {
89
+ timeoutTimer = setTimeout(() => {
90
+ reject(new Error(`Lazy load timeout after ${timeout}ms`));
91
+ }, timeout);
92
+ })
93
+ : null;
94
+
95
+ // Race between load and timeout
96
+ const loadWithTimeout = timeoutPromise
97
+ ? Promise.race([loadPromise, timeoutPromise])
98
+ : loadPromise;
99
+
100
+ loadWithTimeout
101
+ .then(module => {
102
+ clearTimeout(loadingTimer);
103
+ clearTimeout(timeoutTimer);
104
+
105
+ // Cache the component
106
+ cachedComponent = module;
107
+
108
+ // Get the component from module
109
+ const Component = module.default || module;
110
+ const result = typeof Component === 'function'
111
+ ? Component(ctx)
112
+ : Component.render
113
+ ? Component.render(ctx)
114
+ : Component;
115
+
116
+ // Replace loading with actual component
117
+ if (result instanceof Node) {
118
+ container.replaceChildren(result);
119
+ }
120
+ })
121
+ .catch(err => {
122
+ clearTimeout(loadingTimer);
123
+ clearTimeout(timeoutTimer);
124
+ loadPromise = null; // Allow retry
125
+
126
+ if (ErrorComponent) {
127
+ container.replaceChildren(ErrorComponent(err));
128
+ } else {
129
+ console.error('Lazy load error:', err);
130
+ container.replaceChildren(
131
+ el('div.lazy-error', `Failed to load component: ${err.message}`)
132
+ );
133
+ }
134
+ });
135
+
136
+ return container;
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Preload a lazy component without rendering
142
+ * Useful for prefetching on hover or when likely to navigate
143
+ *
144
+ * @param {function} lazyHandler - Lazy handler created with lazy()
145
+ * @returns {Promise} Resolves when component is loaded
146
+ *
147
+ * @example
148
+ * const DashboardLazy = lazy(() => import('./Dashboard.js'));
149
+ * // Preload on link hover
150
+ * link.addEventListener('mouseenter', () => preload(DashboardLazy));
151
+ */
152
+ export function preload(lazyHandler) {
153
+ // Trigger the lazy handler with a dummy context to start loading
154
+ // The result is discarded, but the component will be cached
155
+ return new Promise(resolve => {
156
+ const result = lazyHandler({});
157
+ if (result instanceof Promise) {
158
+ result.then(resolve);
159
+ } else {
160
+ // Already loaded
161
+ resolve(result);
162
+ }
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Middleware context passed to each middleware function
168
+ * @typedef {Object} MiddlewareContext
169
+ * @property {NavigationTarget} to - Target route
170
+ * @property {NavigationTarget} from - Source route
171
+ * @property {Object} meta - Shared metadata between middlewares
172
+ * @property {function} redirect - Redirect to another path
173
+ * @property {function} abort - Abort navigation
174
+ */
175
+
176
+ /**
177
+ * Create a middleware runner for the router
178
+ * Middlewares are executed in order, each can modify context or abort navigation
179
+ *
180
+ * @param {Array<function>} middlewares - Array of middleware functions
181
+ * @returns {function} Runner function
182
+ *
183
+ * @example
184
+ * const authMiddleware = async (ctx, next) => {
185
+ * if (ctx.to.meta.requiresAuth && !isAuthenticated()) {
186
+ * return ctx.redirect('/login');
187
+ * }
188
+ * await next();
189
+ * };
190
+ *
191
+ * const loggerMiddleware = async (ctx, next) => {
192
+ * console.log('Navigating to:', ctx.to.path);
193
+ * const start = Date.now();
194
+ * await next();
195
+ * console.log('Navigation took:', Date.now() - start, 'ms');
196
+ * };
197
+ *
198
+ * const router = createRouter({
199
+ * routes,
200
+ * middleware: [loggerMiddleware, authMiddleware]
201
+ * });
202
+ */
203
+ function createMiddlewareRunner(middlewares) {
204
+ return async function runMiddleware(context) {
205
+ let index = 0;
206
+ let aborted = false;
207
+ let redirectPath = null;
208
+
209
+ // Create enhanced context with redirect and abort
210
+ const ctx = {
211
+ ...context,
212
+ meta: {},
213
+ redirect: (path) => {
214
+ redirectPath = path;
215
+ },
216
+ abort: () => {
217
+ aborted = true;
218
+ }
219
+ };
220
+
221
+ async function next() {
222
+ if (aborted || redirectPath) return;
223
+ if (index >= middlewares.length) return;
224
+
225
+ const middleware = middlewares[index++];
226
+ await middleware(ctx, next);
227
+ }
228
+
229
+ await next();
230
+
231
+ return {
232
+ aborted,
233
+ redirectPath,
234
+ meta: ctx.meta
235
+ };
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Radix Trie for efficient route matching
241
+ * Provides O(path length) lookup instead of O(routes count)
242
+ */
243
+ class RouteTrie {
244
+ constructor() {
245
+ this.root = { children: new Map(), route: null, paramName: null, isWildcard: false };
246
+ }
247
+
248
+ /**
249
+ * Insert a route into the trie
250
+ */
251
+ insert(pattern, route) {
252
+ const segments = pattern === '/' ? [''] : pattern.split('/').filter(Boolean);
253
+ let node = this.root;
254
+
255
+ for (const segment of segments) {
256
+ let key;
257
+ let paramName = null;
258
+ let isWildcard = false;
259
+
260
+ if (segment.startsWith(':')) {
261
+ // Dynamic segment - :param
262
+ key = ':';
263
+ paramName = segment.slice(1);
264
+ } else if (segment.startsWith('*')) {
265
+ // Wildcard segment - *path
266
+ key = '*';
267
+ paramName = segment.slice(1) || 'wildcard';
268
+ isWildcard = true;
269
+ } else {
270
+ // Static segment
271
+ key = segment;
272
+ }
273
+
274
+ if (!node.children.has(key)) {
275
+ node.children.set(key, {
276
+ children: new Map(),
277
+ route: null,
278
+ paramName,
279
+ isWildcard
280
+ });
281
+ }
282
+ node = node.children.get(key);
283
+ }
284
+
285
+ node.route = route;
286
+ }
287
+
288
+ /**
289
+ * Find a matching route for a path
290
+ */
291
+ find(path) {
292
+ const segments = path === '/' ? [''] : path.split('/').filter(Boolean);
293
+ return this._findRecursive(this.root, segments, 0, {});
294
+ }
295
+
296
+ _findRecursive(node, segments, index, params) {
297
+ // End of path
298
+ if (index === segments.length) {
299
+ if (node.route) {
300
+ return { route: node.route, params };
301
+ }
302
+ return null;
303
+ }
304
+
305
+ const segment = segments[index];
306
+
307
+ // Try static match first (most specific)
308
+ if (node.children.has(segment)) {
309
+ const result = this._findRecursive(node.children.get(segment), segments, index + 1, params);
310
+ if (result) return result;
311
+ }
312
+
313
+ // Try dynamic param match
314
+ if (node.children.has(':')) {
315
+ const paramNode = node.children.get(':');
316
+ const newParams = { ...params, [paramNode.paramName]: decodeURIComponent(segment) };
317
+ const result = this._findRecursive(paramNode, segments, index + 1, newParams);
318
+ if (result) return result;
319
+ }
320
+
321
+ // Try wildcard match (catches all remaining segments)
322
+ if (node.children.has('*')) {
323
+ const wildcardNode = node.children.get('*');
324
+ const remaining = segments.slice(index).map(decodeURIComponent).join('/');
325
+ return {
326
+ route: wildcardNode.route,
327
+ params: { ...params, [wildcardNode.paramName]: remaining }
328
+ };
329
+ }
330
+
331
+ return null;
332
+ }
333
+ }
334
+
18
335
  /**
19
336
  * Parse a route pattern into a regex and extract param names
20
337
  * Supports: /users/:id, /posts/:id/comments, /files/*path, * (catch-all)
@@ -126,9 +443,13 @@ export function createRouter(options = {}) {
126
443
  routes = {},
127
444
  mode = 'history', // 'history' or 'hash'
128
445
  base = '',
129
- scrollBehavior = null // Function to control scroll restoration
446
+ scrollBehavior = null, // Function to control scroll restoration
447
+ middleware: initialMiddleware = [] // Middleware functions
130
448
  } = options;
131
449
 
450
+ // Middleware array (mutable for dynamic registration)
451
+ const middleware = [...initialMiddleware];
452
+
132
453
  // Reactive state
133
454
  const currentPath = pulse(getPath());
134
455
  const currentRoute = pulse(null);
@@ -140,6 +461,9 @@ export function createRouter(options = {}) {
140
461
  // Scroll positions for history
141
462
  const scrollPositions = new Map();
142
463
 
464
+ // Route trie for O(path length) lookups
465
+ const routeTrie = new RouteTrie();
466
+
143
467
  // Compile routes (supports nested routes)
144
468
  const compiledRoutes = [];
145
469
 
@@ -148,11 +472,16 @@ export function createRouter(options = {}) {
148
472
  const normalized = normalizeRoute(pattern, config);
149
473
  const fullPattern = parentPath + pattern;
150
474
 
151
- compiledRoutes.push({
475
+ const route = {
152
476
  ...normalized,
153
477
  pattern: fullPattern,
154
478
  ...parsePattern(fullPattern)
155
- });
479
+ };
480
+
481
+ compiledRoutes.push(route);
482
+
483
+ // Insert into trie for fast lookup
484
+ routeTrie.insert(fullPattern, route);
156
485
 
157
486
  // Compile children (nested routes)
158
487
  if (normalized.children) {
@@ -183,15 +512,22 @@ export function createRouter(options = {}) {
183
512
  }
184
513
 
185
514
  /**
186
- * Find matching route
515
+ * Find matching route using trie for O(path length) lookup
187
516
  */
188
517
  function findRoute(path) {
518
+ // Use trie for efficient lookup
519
+ const result = routeTrie.find(path);
520
+ if (result) {
521
+ return result;
522
+ }
523
+
524
+ // Fallback to catch-all route if exists
189
525
  for (const route of compiledRoutes) {
190
- const params = matchRoute(route.pattern, path);
191
- if (params !== null) {
192
- return { route, params };
526
+ if (route.pattern === '*') {
527
+ return { route, params: {} };
193
528
  }
194
529
  }
530
+
195
531
  return null;
196
532
  }
197
533
 
@@ -234,6 +570,20 @@ export function createRouter(options = {}) {
234
570
  meta: match?.route?.meta || {}
235
571
  };
236
572
 
573
+ // Run middleware if configured
574
+ if (middleware.length > 0) {
575
+ const runMiddleware = createMiddlewareRunner(middleware);
576
+ const middlewareResult = await runMiddleware({ to, from });
577
+ if (middlewareResult.aborted) {
578
+ return false;
579
+ }
580
+ if (middlewareResult.redirectPath) {
581
+ return navigate(middlewareResult.redirectPath, { replace: true });
582
+ }
583
+ // Merge middleware meta into route meta
584
+ Object.assign(to.meta, middlewareResult.meta);
585
+ }
586
+
237
587
  // Run global beforeEach hooks
238
588
  for (const hook of beforeHooks) {
239
589
  const result = await hook(to, from);
@@ -427,7 +777,7 @@ export function createRouter(options = {}) {
427
777
  // Cleanup previous view
428
778
  if (cleanup) cleanup();
429
779
  if (currentView) {
430
- container.innerHTML = '';
780
+ container.replaceChildren();
431
781
  }
432
782
 
433
783
  if (route && route.handler) {
@@ -466,6 +816,19 @@ export function createRouter(options = {}) {
466
816
  return container;
467
817
  }
468
818
 
819
+ /**
820
+ * Add middleware dynamically
821
+ * @param {function} middlewareFn - Middleware function (ctx, next) => {}
822
+ * @returns {function} Unregister function
823
+ */
824
+ function use(middlewareFn) {
825
+ middleware.push(middlewareFn);
826
+ return () => {
827
+ const index = middleware.indexOf(middlewareFn);
828
+ if (index > -1) middleware.splice(index, 1);
829
+ };
830
+ }
831
+
469
832
  /**
470
833
  * Add navigation guard
471
834
  */
@@ -559,6 +922,7 @@ export function createRouter(options = {}) {
559
922
  start,
560
923
  link,
561
924
  outlet,
925
+ use,
562
926
  beforeEach,
563
927
  beforeResolve,
564
928
  afterEach,
@@ -591,6 +955,8 @@ export function simpleRouter(routes, target = '#app') {
591
955
  export default {
592
956
  createRouter,
593
957
  simpleRouter,
958
+ lazy,
959
+ preload,
594
960
  matchRoute,
595
961
  parseQuery
596
962
  };