pulse-js-framework 1.0.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,339 @@
1
+ /**
2
+ * Pulse - Reactive Primitives
3
+ *
4
+ * The core reactivity system based on "pulsations" -
5
+ * reactive values that propagate changes through the system.
6
+ */
7
+
8
+ // Current tracking context for automatic dependency collection
9
+ let currentEffect = null;
10
+ let batchDepth = 0;
11
+ let pendingEffects = new Set();
12
+ let isRunningEffects = false;
13
+
14
+ /**
15
+ * Pulse - A reactive value container
16
+ * When the value changes, it "pulses" to all its dependents
17
+ */
18
+ export class Pulse {
19
+ #value;
20
+ #subscribers = new Set();
21
+ #equals;
22
+
23
+ constructor(value, options = {}) {
24
+ this.#value = value;
25
+ this.#equals = options.equals ?? Object.is;
26
+ }
27
+
28
+ /**
29
+ * Get the current value and track dependency if in an effect context
30
+ */
31
+ get() {
32
+ if (currentEffect) {
33
+ this.#subscribers.add(currentEffect);
34
+ currentEffect.dependencies.add(this);
35
+ }
36
+ return this.#value;
37
+ }
38
+
39
+ /**
40
+ * Set a new value and notify all subscribers
41
+ */
42
+ set(newValue) {
43
+ if (this.#equals(this.#value, newValue)) return;
44
+ this.#value = newValue;
45
+ this.#notify();
46
+ }
47
+
48
+ /**
49
+ * Update value using a function
50
+ */
51
+ update(fn) {
52
+ this.set(fn(this.#value));
53
+ }
54
+
55
+ /**
56
+ * Subscribe to changes
57
+ */
58
+ subscribe(fn) {
59
+ const subscriber = { run: fn, dependencies: new Set() };
60
+ this.#subscribers.add(subscriber);
61
+ return () => this.#subscribers.delete(subscriber);
62
+ }
63
+
64
+ /**
65
+ * Create a derived pulse that recomputes when this changes
66
+ */
67
+ derive(fn) {
68
+ return computed(() => fn(this.get()));
69
+ }
70
+
71
+ /**
72
+ * Notify all subscribers of a change
73
+ */
74
+ #notify() {
75
+ // Copy subscribers to avoid mutation during iteration
76
+ const subs = [...this.#subscribers];
77
+
78
+ for (const subscriber of subs) {
79
+ if (batchDepth > 0 || isRunningEffects) {
80
+ pendingEffects.add(subscriber);
81
+ } else {
82
+ runEffect(subscriber);
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Unsubscribe a specific subscriber
89
+ */
90
+ _unsubscribe(subscriber) {
91
+ this.#subscribers.delete(subscriber);
92
+ }
93
+
94
+ /**
95
+ * Get value without tracking (for debugging/inspection)
96
+ */
97
+ peek() {
98
+ return this.#value;
99
+ }
100
+
101
+ /**
102
+ * Initialize value without triggering notifications (internal use)
103
+ */
104
+ _init(value) {
105
+ this.#value = value;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Run a single effect safely
111
+ */
112
+ function runEffect(effectFn) {
113
+ if (!effectFn || !effectFn.run) return;
114
+
115
+ try {
116
+ effectFn.run();
117
+ } catch (error) {
118
+ console.error('Effect error:', error);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Flush all pending effects
124
+ */
125
+ function flushEffects() {
126
+ if (isRunningEffects) return;
127
+
128
+ isRunningEffects = true;
129
+ let iterations = 0;
130
+ const maxIterations = 100; // Prevent infinite loops
131
+
132
+ try {
133
+ while (pendingEffects.size > 0 && iterations < maxIterations) {
134
+ iterations++;
135
+ const effects = [...pendingEffects];
136
+ pendingEffects.clear();
137
+
138
+ for (const effect of effects) {
139
+ runEffect(effect);
140
+ }
141
+ }
142
+
143
+ if (iterations >= maxIterations) {
144
+ console.warn('Pulse: Maximum effect iterations reached. Possible infinite loop.');
145
+ pendingEffects.clear();
146
+ }
147
+ } finally {
148
+ isRunningEffects = false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Create a simple pulse with an initial value
154
+ */
155
+ export function pulse(value, options) {
156
+ return new Pulse(value, options);
157
+ }
158
+
159
+ /**
160
+ * Create a computed pulse that automatically updates
161
+ * when its dependencies change
162
+ */
163
+ export function computed(fn) {
164
+ const p = new Pulse(undefined);
165
+ let initialized = false;
166
+
167
+ effect(() => {
168
+ const newValue = fn();
169
+ if (initialized) {
170
+ p._init(newValue); // Use _init to avoid triggering notifications during compute
171
+ } else {
172
+ p._init(newValue);
173
+ initialized = true;
174
+ }
175
+ });
176
+
177
+ // Override set to make it read-only
178
+ p.set = () => {
179
+ throw new Error('Cannot set a computed pulse directly');
180
+ };
181
+
182
+ return p;
183
+ }
184
+
185
+ /**
186
+ * Create an effect that runs when its dependencies change
187
+ */
188
+ export function effect(fn) {
189
+ const effectFn = {
190
+ run: () => {
191
+ // Clean up old dependencies
192
+ for (const dep of effectFn.dependencies) {
193
+ dep._unsubscribe(effectFn);
194
+ }
195
+ effectFn.dependencies.clear();
196
+
197
+ // Set as current effect for dependency tracking
198
+ const prevEffect = currentEffect;
199
+ currentEffect = effectFn;
200
+
201
+ try {
202
+ fn();
203
+ } catch (error) {
204
+ console.error('Effect execution error:', error);
205
+ } finally {
206
+ currentEffect = prevEffect;
207
+ }
208
+ },
209
+ dependencies: new Set()
210
+ };
211
+
212
+ // Run immediately to collect dependencies
213
+ effectFn.run();
214
+
215
+ // Return cleanup function
216
+ return () => {
217
+ for (const dep of effectFn.dependencies) {
218
+ dep._unsubscribe(effectFn);
219
+ }
220
+ effectFn.dependencies.clear();
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Batch multiple updates into one
226
+ * Effects only run after all updates complete
227
+ */
228
+ export function batch(fn) {
229
+ batchDepth++;
230
+ try {
231
+ fn();
232
+ } finally {
233
+ batchDepth--;
234
+ if (batchDepth === 0) {
235
+ flushEffects();
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Create a reactive state object from a plain object
242
+ * Each property becomes a pulse
243
+ */
244
+ export function createState(obj) {
245
+ const state = {};
246
+ const pulses = {};
247
+
248
+ for (const [key, value] of Object.entries(obj)) {
249
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
250
+ // Recursively create state for nested objects
251
+ state[key] = createState(value);
252
+ } else {
253
+ pulses[key] = new Pulse(value);
254
+
255
+ Object.defineProperty(state, key, {
256
+ get() {
257
+ return pulses[key].get();
258
+ },
259
+ set(newValue) {
260
+ pulses[key].set(newValue);
261
+ },
262
+ enumerable: true
263
+ });
264
+ }
265
+ }
266
+
267
+ // Expose the raw pulses for advanced use
268
+ state.$pulses = pulses;
269
+
270
+ // Helper to get a pulse by key
271
+ state.$pulse = (key) => pulses[key];
272
+
273
+ return state;
274
+ }
275
+
276
+ /**
277
+ * Watch specific pulses and run a callback when they change
278
+ */
279
+ export function watch(sources, callback) {
280
+ const sourcesArray = Array.isArray(sources) ? sources : [sources];
281
+
282
+ let oldValues = sourcesArray.map(s => s.peek());
283
+
284
+ return effect(() => {
285
+ const newValues = sourcesArray.map(s => s.get());
286
+ callback(newValues, oldValues);
287
+ oldValues = newValues;
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Create a pulse from a promise
293
+ */
294
+ export function fromPromise(promise, initialValue = undefined) {
295
+ const p = pulse(initialValue);
296
+ const loading = pulse(true);
297
+ const error = pulse(null);
298
+
299
+ promise
300
+ .then(value => {
301
+ batch(() => {
302
+ p.set(value);
303
+ loading.set(false);
304
+ });
305
+ })
306
+ .catch(err => {
307
+ batch(() => {
308
+ error.set(err);
309
+ loading.set(false);
310
+ });
311
+ });
312
+
313
+ return { value: p, loading, error };
314
+ }
315
+
316
+ /**
317
+ * Untrack - read pulses without creating dependencies
318
+ */
319
+ export function untrack(fn) {
320
+ const prevEffect = currentEffect;
321
+ currentEffect = null;
322
+ try {
323
+ return fn();
324
+ } finally {
325
+ currentEffect = prevEffect;
326
+ }
327
+ }
328
+
329
+ export default {
330
+ Pulse,
331
+ pulse,
332
+ computed,
333
+ effect,
334
+ batch,
335
+ createState,
336
+ watch,
337
+ fromPromise,
338
+ untrack
339
+ };
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Pulse Router - SPA routing system
3
+ *
4
+ * A simple but powerful router that integrates with Pulse reactivity
5
+ */
6
+
7
+ import { pulse, effect, batch } from './pulse.js';
8
+ import { el, mount } from './dom.js';
9
+
10
+ /**
11
+ * Parse a route pattern into a regex and extract param names
12
+ * Supports: /users/:id, /posts/:id/comments, /files/*path
13
+ */
14
+ function parsePattern(pattern) {
15
+ const paramNames = [];
16
+ let regexStr = pattern
17
+ // Escape special regex chars except : and *
18
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
19
+ // Handle wildcard params (*name)
20
+ .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
21
+ paramNames.push(name);
22
+ return '(.*)';
23
+ })
24
+ // Handle named params (:name)
25
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
26
+ paramNames.push(name);
27
+ return '([^/]+)';
28
+ });
29
+
30
+ // Ensure exact match
31
+ regexStr = `^${regexStr}$`;
32
+
33
+ return {
34
+ regex: new RegExp(regexStr),
35
+ paramNames
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Match a path against a route pattern
41
+ */
42
+ function matchRoute(pattern, path) {
43
+ const { regex, paramNames } = parsePattern(pattern);
44
+ const match = path.match(regex);
45
+
46
+ if (!match) return null;
47
+
48
+ const params = {};
49
+ paramNames.forEach((name, i) => {
50
+ params[name] = decodeURIComponent(match[i + 1]);
51
+ });
52
+
53
+ return params;
54
+ }
55
+
56
+ /**
57
+ * Parse query string into object
58
+ */
59
+ function parseQuery(search) {
60
+ const params = new URLSearchParams(search);
61
+ const query = {};
62
+ for (const [key, value] of params) {
63
+ if (key in query) {
64
+ // Multiple values for same key
65
+ if (Array.isArray(query[key])) {
66
+ query[key].push(value);
67
+ } else {
68
+ query[key] = [query[key], value];
69
+ }
70
+ } else {
71
+ query[key] = value;
72
+ }
73
+ }
74
+ return query;
75
+ }
76
+
77
+ /**
78
+ * Create a router instance
79
+ */
80
+ export function createRouter(options = {}) {
81
+ const {
82
+ routes = {},
83
+ mode = 'history', // 'history' or 'hash'
84
+ base = ''
85
+ } = options;
86
+
87
+ // Reactive state
88
+ const currentPath = pulse(getPath());
89
+ const currentRoute = pulse(null);
90
+ const currentParams = pulse({});
91
+ const currentQuery = pulse({});
92
+ const isLoading = pulse(false);
93
+
94
+ // Compiled routes
95
+ const compiledRoutes = [];
96
+ for (const [pattern, handler] of Object.entries(routes)) {
97
+ compiledRoutes.push({
98
+ pattern,
99
+ ...parsePattern(pattern),
100
+ handler
101
+ });
102
+ }
103
+
104
+ // Hooks
105
+ const beforeHooks = [];
106
+ const afterHooks = [];
107
+
108
+ /**
109
+ * Get current path based on mode
110
+ */
111
+ function getPath() {
112
+ if (mode === 'hash') {
113
+ return window.location.hash.slice(1) || '/';
114
+ }
115
+ let path = window.location.pathname;
116
+ if (base && path.startsWith(base)) {
117
+ path = path.slice(base.length) || '/';
118
+ }
119
+ return path;
120
+ }
121
+
122
+ /**
123
+ * Find matching route
124
+ */
125
+ function findRoute(path) {
126
+ for (const route of compiledRoutes) {
127
+ const params = matchRoute(route.pattern, path);
128
+ if (params !== null) {
129
+ return { route, params };
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Navigate to a path
137
+ */
138
+ async function navigate(path, options = {}) {
139
+ const { replace = false, query = {} } = options;
140
+
141
+ // Build full path with query
142
+ let fullPath = path;
143
+ const queryString = new URLSearchParams(query).toString();
144
+ if (queryString) {
145
+ fullPath += '?' + queryString;
146
+ }
147
+
148
+ // Run before hooks
149
+ for (const hook of beforeHooks) {
150
+ const result = await hook(path, currentPath.peek());
151
+ if (result === false) return false;
152
+ if (typeof result === 'string') {
153
+ return navigate(result, options);
154
+ }
155
+ }
156
+
157
+ // Update URL
158
+ const url = mode === 'hash' ? `#${fullPath}` : `${base}${fullPath}`;
159
+ if (replace) {
160
+ window.history.replaceState(null, '', url);
161
+ } else {
162
+ window.history.pushState(null, '', url);
163
+ }
164
+
165
+ // Update reactive state
166
+ await updateRoute(path, parseQuery(queryString));
167
+
168
+ return true;
169
+ }
170
+
171
+ /**
172
+ * Update the current route state
173
+ */
174
+ async function updateRoute(path, query = {}) {
175
+ const match = findRoute(path);
176
+
177
+ batch(() => {
178
+ currentPath.set(path);
179
+ currentQuery.set(query);
180
+
181
+ if (match) {
182
+ currentRoute.set(match.route);
183
+ currentParams.set(match.params);
184
+ } else {
185
+ currentRoute.set(null);
186
+ currentParams.set({});
187
+ }
188
+ });
189
+
190
+ // Run after hooks
191
+ for (const hook of afterHooks) {
192
+ await hook(path);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Handle browser navigation (back/forward)
198
+ */
199
+ function handlePopState() {
200
+ const path = getPath();
201
+ const query = parseQuery(window.location.search);
202
+ updateRoute(path, query);
203
+ }
204
+
205
+ /**
206
+ * Start listening to navigation events
207
+ */
208
+ function start() {
209
+ window.addEventListener('popstate', handlePopState);
210
+
211
+ // Initial route
212
+ const query = parseQuery(window.location.search);
213
+ updateRoute(getPath(), query);
214
+
215
+ return () => {
216
+ window.removeEventListener('popstate', handlePopState);
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Create a link element that uses the router
222
+ */
223
+ function link(path, content, options = {}) {
224
+ const href = mode === 'hash' ? `#${path}` : `${base}${path}`;
225
+ const a = el('a', content);
226
+ a.href = href;
227
+
228
+ a.addEventListener('click', (e) => {
229
+ // Allow ctrl/cmd+click for new tab
230
+ if (e.ctrlKey || e.metaKey) return;
231
+
232
+ e.preventDefault();
233
+ navigate(path, options);
234
+ });
235
+
236
+ // Add active class when route matches
237
+ effect(() => {
238
+ const current = currentPath.get();
239
+ if (current === path || (options.exact === false && current.startsWith(path))) {
240
+ a.classList.add(options.activeClass || 'active');
241
+ } else {
242
+ a.classList.remove(options.activeClass || 'active');
243
+ }
244
+ });
245
+
246
+ return a;
247
+ }
248
+
249
+ /**
250
+ * Router outlet - renders the current route's component
251
+ */
252
+ function outlet(container) {
253
+ if (typeof container === 'string') {
254
+ container = document.querySelector(container);
255
+ }
256
+
257
+ let currentView = null;
258
+ let cleanup = null;
259
+
260
+ effect(() => {
261
+ const route = currentRoute.get();
262
+ const params = currentParams.get();
263
+ const query = currentQuery.get();
264
+
265
+ // Cleanup previous view
266
+ if (cleanup) cleanup();
267
+ if (currentView) {
268
+ container.innerHTML = '';
269
+ }
270
+
271
+ if (route && route.handler) {
272
+ // Create context for the route handler
273
+ const ctx = {
274
+ params,
275
+ query,
276
+ path: currentPath.peek(),
277
+ navigate,
278
+ router
279
+ };
280
+
281
+ // Call handler and render result
282
+ const result = typeof route.handler === 'function'
283
+ ? route.handler(ctx)
284
+ : route.handler;
285
+
286
+ if (result instanceof Node) {
287
+ container.appendChild(result);
288
+ currentView = result;
289
+ } else if (result && typeof result.then === 'function') {
290
+ // Async component
291
+ isLoading.set(true);
292
+ result.then(component => {
293
+ isLoading.set(false);
294
+ const view = typeof component === 'function' ? component(ctx) : component;
295
+ if (view instanceof Node) {
296
+ container.appendChild(view);
297
+ currentView = view;
298
+ }
299
+ });
300
+ }
301
+ }
302
+ });
303
+
304
+ return container;
305
+ }
306
+
307
+ /**
308
+ * Add navigation guard
309
+ */
310
+ function beforeEach(hook) {
311
+ beforeHooks.push(hook);
312
+ return () => {
313
+ const index = beforeHooks.indexOf(hook);
314
+ if (index > -1) beforeHooks.splice(index, 1);
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Add after navigation hook
320
+ */
321
+ function afterEach(hook) {
322
+ afterHooks.push(hook);
323
+ return () => {
324
+ const index = afterHooks.indexOf(hook);
325
+ if (index > -1) afterHooks.splice(index, 1);
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Go back in history
331
+ */
332
+ function back() {
333
+ window.history.back();
334
+ }
335
+
336
+ /**
337
+ * Go forward in history
338
+ */
339
+ function forward() {
340
+ window.history.forward();
341
+ }
342
+
343
+ /**
344
+ * Go to specific history entry
345
+ */
346
+ function go(delta) {
347
+ window.history.go(delta);
348
+ }
349
+
350
+ const router = {
351
+ // Reactive state (read-only)
352
+ path: currentPath,
353
+ route: currentRoute,
354
+ params: currentParams,
355
+ query: currentQuery,
356
+ loading: isLoading,
357
+
358
+ // Methods
359
+ navigate,
360
+ start,
361
+ link,
362
+ outlet,
363
+ beforeEach,
364
+ afterEach,
365
+ back,
366
+ forward,
367
+ go,
368
+
369
+ // Utils
370
+ matchRoute,
371
+ parseQuery
372
+ };
373
+
374
+ return router;
375
+ }
376
+
377
+ /**
378
+ * Create a simple router for quick setup
379
+ */
380
+ export function simpleRouter(routes, target = '#app') {
381
+ const router = createRouter({ routes });
382
+ router.start();
383
+ router.outlet(target);
384
+ return router;
385
+ }
386
+
387
+ export default {
388
+ createRouter,
389
+ simpleRouter,
390
+ matchRoute,
391
+ parseQuery
392
+ };