sigpro 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.
package/sigpro.js ADDED
@@ -0,0 +1,474 @@
1
+ // Global state for tracking the current reactive effect
2
+ let activeEffect = null;
3
+
4
+ // Queue for batched effect updates
5
+ const effectQueue = new Set();
6
+ let isFlushScheduled = false;
7
+
8
+ /**
9
+ * Flushes all pending effects in the queue
10
+ * Executes all queued jobs and clears the queue
11
+ */
12
+ const flushEffectQueue = () => {
13
+ isFlushScheduled = false;
14
+ try {
15
+ for (const effect of effectQueue) {
16
+ effect.run();
17
+ }
18
+ effectQueue.clear();
19
+ } catch (error) {
20
+ console.error("SigPro Flush Error:", error);
21
+ }
22
+ };
23
+
24
+ /**
25
+ * Creates a reactive signal
26
+ * @param {any} initialValue - Initial value or getter function
27
+ * @returns {Function} Signal getter/setter function
28
+ */
29
+ export const $ = (initialValue) => {
30
+ const subscribers = new Set();
31
+
32
+ if (typeof initialValue === "function") {
33
+ // Computed signal case
34
+ let isDirty = true;
35
+ let cachedValue;
36
+
37
+ const computedEffect = {
38
+ dependencies: new Set(),
39
+ cleanupHandlers: new Set(),
40
+ markDirty: () => {
41
+ if (!isDirty) {
42
+ isDirty = true;
43
+ subscribers.forEach((subscriber) => {
44
+ if (subscriber.markDirty) subscriber.markDirty();
45
+ effectQueue.add(subscriber);
46
+ });
47
+ }
48
+ },
49
+ run: () => {
50
+ // Clear old dependencies
51
+ computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect));
52
+ computedEffect.dependencies.clear();
53
+
54
+ const previousEffect = activeEffect;
55
+ activeEffect = computedEffect;
56
+ try {
57
+ cachedValue = initialValue();
58
+ } finally {
59
+ activeEffect = previousEffect;
60
+ isDirty = false;
61
+ }
62
+ },
63
+ };
64
+
65
+ return () => {
66
+ if (activeEffect) {
67
+ subscribers.add(activeEffect);
68
+ activeEffect.dependencies.add(subscribers);
69
+ }
70
+ if (isDirty) computedEffect.run();
71
+ return cachedValue;
72
+ };
73
+ }
74
+
75
+ // Regular signal case
76
+ return (...args) => {
77
+ if (args.length) {
78
+ const nextValue = typeof args[0] === "function" ? args[0](initialValue) : args[0];
79
+ if (!Object.is(initialValue, nextValue)) {
80
+ initialValue = nextValue;
81
+ subscribers.forEach((subscriber) => {
82
+ if (subscriber.markDirty) subscriber.markDirty();
83
+ effectQueue.add(subscriber);
84
+ });
85
+ if (!isFlushScheduled && effectQueue.size) {
86
+ isFlushScheduled = true;
87
+ queueMicrotask(flushEffectQueue);
88
+ }
89
+ }
90
+ }
91
+ if (activeEffect) {
92
+ subscribers.add(activeEffect);
93
+ activeEffect.dependencies.add(subscribers);
94
+ }
95
+ return initialValue;
96
+ };
97
+ };
98
+
99
+ /**
100
+ * Creates a reactive effect that runs when dependencies change
101
+ * @param {Function} effectFn - The effect function to run
102
+ * @returns {Function} Cleanup function to stop the effect
103
+ */
104
+ export const $$ = (effectFn) => {
105
+ const effect = {
106
+ dependencies: new Set(),
107
+ cleanupHandlers: new Set(),
108
+ run() {
109
+ // Run cleanup handlers
110
+ this.cleanupHandlers.forEach((handler) => handler());
111
+ this.cleanupHandlers.clear();
112
+
113
+ // Clear old dependencies
114
+ this.dependencies.forEach((dependencySet) => dependencySet.delete(this));
115
+ this.dependencies.clear();
116
+
117
+ const previousEffect = activeEffect;
118
+ activeEffect = this;
119
+ try {
120
+ const result = effectFn();
121
+ if (typeof result === "function") this.cleanupFunction = result;
122
+ } finally {
123
+ activeEffect = previousEffect;
124
+ }
125
+ },
126
+ stop() {
127
+ this.cleanupHandlers.forEach((handler) => handler());
128
+ this.dependencies.forEach((dependencySet) => dependencySet.delete(this));
129
+ this.cleanupFunction?.();
130
+ },
131
+ };
132
+
133
+ if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop());
134
+ effect.run();
135
+ return () => effect.stop();
136
+ };
137
+
138
+ /**
139
+ * Tagged template literal for creating reactive HTML
140
+ * @param {string[]} strings - Template strings
141
+ * @param {...any} values - Dynamic values
142
+ * @returns {DocumentFragment} Reactive document fragment
143
+ */
144
+ export const html = (strings, ...values) => {
145
+ const templateCache = html._templateCache ?? (html._templateCache = new WeakMap());
146
+
147
+ /**
148
+ * Gets a node by path from root
149
+ * @param {Node} root - Root node
150
+ * @param {number[]} path - Path indices
151
+ * @returns {Node} Target node
152
+ */
153
+ const getNodeByPath = (root, path) =>
154
+ path.reduce((node, index) => node?.childNodes?.[index], root);
155
+
156
+ /**
157
+ * Applies reactive text content to a node
158
+ * @param {Node} node - Target node
159
+ * @param {any[]} values - Values to insert
160
+ */
161
+ const applyTextContent = (node, values) => {
162
+ const parts = node.textContent.split("{{part}}");
163
+ const parent = node.parentNode;
164
+ let valueIndex = 0;
165
+
166
+ parts.forEach((part, index) => {
167
+ if (part) parent.insertBefore(document.createTextNode(part), node);
168
+ if (index < parts.length - 1) {
169
+ const currentValue = values[valueIndex++];
170
+ const startMarker = document.createComment("s");
171
+ const endMarker = document.createComment("e");
172
+ parent.insertBefore(startMarker, node);
173
+ parent.insertBefore(endMarker, node);
174
+
175
+ let lastResult;
176
+ $$(() => {
177
+ let result = typeof currentValue === "function" ? currentValue() : currentValue;
178
+ if (result === lastResult) return;
179
+ lastResult = result;
180
+
181
+ if (typeof result !== "object" && !Array.isArray(result)) {
182
+ const textNode = startMarker.nextSibling;
183
+ if (textNode !== endMarker && textNode?.nodeType === 3) {
184
+ textNode.textContent = result ?? "";
185
+ } else {
186
+ while (startMarker.nextSibling !== endMarker)
187
+ parent.removeChild(startMarker.nextSibling);
188
+ parent.insertBefore(document.createTextNode(result ?? ""), endMarker);
189
+ }
190
+ return;
191
+ }
192
+
193
+ // Handle arrays or objects
194
+ while (startMarker.nextSibling !== endMarker)
195
+ parent.removeChild(startMarker.nextSibling);
196
+
197
+ const items = Array.isArray(result) ? result : [result];
198
+ const fragment = document.createDocumentFragment();
199
+ items.forEach(item => {
200
+ if (item == null || item === false) return;
201
+ const nodeItem = item instanceof Node ? item : document.createTextNode(item);
202
+ fragment.appendChild(nodeItem);
203
+ });
204
+ parent.insertBefore(fragment, endMarker);
205
+ });
206
+ }
207
+ });
208
+ node.remove();
209
+ };
210
+
211
+ // Get or create template from cache
212
+ let cachedTemplate = templateCache.get(strings);
213
+ if (!cachedTemplate) {
214
+ const template = document.createElement("template");
215
+ template.innerHTML = strings.join("{{part}}");
216
+
217
+ const dynamicNodes = [];
218
+ const treeWalker = document.createTreeWalker(template.content, 133); // NodeFilter.SHOW_ALL
219
+
220
+ /**
221
+ * Gets path indices for a node
222
+ * @param {Node} node - Target node
223
+ * @returns {number[]} Path indices
224
+ */
225
+ const getNodePath = (node) => {
226
+ const path = [];
227
+ while (node && node !== template.content) {
228
+ let index = 0;
229
+ for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling)
230
+ index++;
231
+ path.push(index);
232
+ node = node.parentNode;
233
+ }
234
+ return path.reverse();
235
+ };
236
+
237
+ let currentNode;
238
+ while ((currentNode = treeWalker.nextNode())) {
239
+ let isDynamic = false;
240
+ const nodeInfo = {
241
+ type: currentNode.nodeType,
242
+ path: getNodePath(currentNode),
243
+ parts: []
244
+ };
245
+
246
+ if (currentNode.nodeType === 1) { // Element node
247
+ for (let i = 0; i < currentNode.attributes.length; i++) {
248
+ const attribute = currentNode.attributes[i];
249
+ if (attribute.value.includes("{{part}}")) {
250
+ nodeInfo.parts.push({ name: attribute.name });
251
+ isDynamic = true;
252
+ }
253
+ }
254
+ } else if (currentNode.nodeType === 3 && currentNode.textContent.includes("{{part}}")) {
255
+ // Text node
256
+ isDynamic = true;
257
+ }
258
+
259
+ if (isDynamic) dynamicNodes.push(nodeInfo);
260
+ }
261
+
262
+ templateCache.set(strings, (cachedTemplate = { template, dynamicNodes }));
263
+ }
264
+
265
+ const fragment = cachedTemplate.template.content.cloneNode(true);
266
+ let valueIndex = 0;
267
+
268
+ // Get target nodes before applyTextContent modifies the DOM
269
+ const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({
270
+ node: getNodeByPath(fragment, nodeInfo.path),
271
+ info: nodeInfo
272
+ }));
273
+
274
+ targets.forEach(({ node, info }) => {
275
+ if (!node) return;
276
+
277
+ if (info.type === 1) { // Element node
278
+ info.parts.forEach((part) => {
279
+ const currentValue = values[valueIndex++];
280
+ const attributeName = part.name;
281
+ const firstChar = attributeName[0];
282
+
283
+ if (firstChar === "@") {
284
+ // Event listener
285
+ node.addEventListener(attributeName.slice(1), currentValue);
286
+ } else if (firstChar === ":") {
287
+ // Two-way binding
288
+ const propertyName = attributeName.slice(1);
289
+ const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input";
290
+
291
+ $$(() => {
292
+ const value = typeof currentValue === "function" ? currentValue() : currentValue;
293
+ if (node[propertyName] !== value) node[propertyName] = value;
294
+ });
295
+
296
+ node.addEventListener(eventType, () => {
297
+ const value = eventType === "change" ? node.checked : node.value;
298
+ if (typeof currentValue === "function") currentValue(value);
299
+ });
300
+ } else if (firstChar === "?") {
301
+ // Boolean attribute
302
+ const attrName = attributeName.slice(1);
303
+ $$(() => {
304
+ const result = typeof currentValue === "function" ? currentValue() : currentValue;
305
+ node.toggleAttribute(attrName, !!result);
306
+ });
307
+ } else if (firstChar === ".") {
308
+ // Property binding
309
+ const propertyName = attributeName.slice(1);
310
+ $$(() => {
311
+ let result = typeof currentValue === "function" ? currentValue() : currentValue;
312
+ node[propertyName] = result;
313
+ if (result != null && typeof result !== "object" && typeof result !== "boolean") {
314
+ node.setAttribute(propertyName, result);
315
+ }
316
+ });
317
+ } else {
318
+ // Regular attribute
319
+ if (typeof currentValue === "function") {
320
+ $$(() => node.setAttribute(attributeName, currentValue()));
321
+ } else {
322
+ node.setAttribute(attributeName, currentValue);
323
+ }
324
+ }
325
+ });
326
+ } else if (info.type === 3) { // Text node
327
+ const placeholderCount = node.textContent.split("{{part}}").length - 1;
328
+ applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount));
329
+ valueIndex += placeholderCount;
330
+ }
331
+ });
332
+
333
+ return fragment;
334
+ };
335
+
336
+ /**
337
+ * Creates a custom web component with reactive properties
338
+ * @param {string} tagName - Custom element tag name
339
+ * @param {Function} setupFunction - Component setup function
340
+ * @param {string[]} observedAttributes - Array of observed attributes
341
+ */
342
+ export const $component = (tagName, setupFunction, observedAttributes = []) => {
343
+ if (customElements.get(tagName)) return;
344
+
345
+ customElements.define(
346
+ tagName,
347
+ class extends HTMLElement {
348
+ static get observedAttributes() {
349
+ return observedAttributes;
350
+ }
351
+
352
+ constructor() {
353
+ super();
354
+ this._propertySignals = {};
355
+ this.cleanupFunctions = [];
356
+ observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined)));
357
+ }
358
+
359
+ connectedCallback() {
360
+ const frozenChildren = [...this.childNodes];
361
+ this.innerHTML = "";
362
+
363
+ observedAttributes.forEach((attr) => {
364
+ const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr);
365
+
366
+ Object.defineProperty(this, attr, {
367
+ get: () => this._propertySignals[attr](),
368
+ set: (value) => {
369
+ const processedValue = value === "false" ? false : value === "" && attr !== "value" ? true : value;
370
+ this._propertySignals[attr](processedValue);
371
+ },
372
+ configurable: true,
373
+ });
374
+
375
+ if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue;
376
+ });
377
+
378
+ const context = {
379
+ select: (selector) => this.querySelector(selector),
380
+ slot: (name) =>
381
+ frozenChildren.filter((node) => {
382
+ const slotName = node.nodeType === 1 ? node.getAttribute("slot") : null;
383
+ return name ? slotName === name : !slotName;
384
+ }),
385
+ emit: (name, detail) => this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })),
386
+ host: this,
387
+ onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn),
388
+ };
389
+
390
+ const result = setupFunction(this._propertySignals, context);
391
+ if (result instanceof Node) this.appendChild(result);
392
+ }
393
+
394
+ attributeChangedCallback(name, oldValue, newValue) {
395
+ if (this[name] !== newValue) this[name] = newValue;
396
+ }
397
+
398
+ disconnectedCallback() {
399
+ this.cleanupFunctions.forEach((cleanupFn) => cleanupFn());
400
+ this.cleanupFunctions = [];
401
+ }
402
+ },
403
+ );
404
+ };
405
+
406
+ /**
407
+ * Creates a router for hash-based navigation
408
+ * @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations
409
+ * @returns {HTMLDivElement} Router container element
410
+ */
411
+ export const $router = (routes) => {
412
+ /**
413
+ * Gets current path from hash
414
+ * @returns {string} Current path
415
+ */
416
+ const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/";
417
+
418
+ const currentPath = $(getCurrentPath());
419
+ const container = document.createElement("div");
420
+ container.style.display = "contents";
421
+
422
+ window.addEventListener("hashchange", () => {
423
+ const nextPath = getCurrentPath();
424
+ if (currentPath() !== nextPath) currentPath(nextPath);
425
+ });
426
+
427
+ $$(() => {
428
+ const path = currentPath();
429
+ let matchedRoute = null;
430
+ let routeParams = {};
431
+
432
+ for (const route of routes) {
433
+ if (route.path instanceof RegExp) {
434
+ const match = path.match(route.path);
435
+ if (match) {
436
+ matchedRoute = route;
437
+ routeParams = match.groups || { id: match[1] };
438
+ break;
439
+ }
440
+ } else if (route.path === path) {
441
+ matchedRoute = route;
442
+ break;
443
+ }
444
+ }
445
+
446
+ const previousEffect = activeEffect;
447
+ activeEffect = null;
448
+
449
+ try {
450
+ const view = matchedRoute
451
+ ? matchedRoute.component(routeParams)
452
+ : html`
453
+ <h1>404</h1>
454
+ `;
455
+
456
+ container.replaceChildren(
457
+ view instanceof Node ? view : document.createTextNode(view ?? "")
458
+ );
459
+ } finally {
460
+ activeEffect = previousEffect;
461
+ }
462
+ });
463
+
464
+ return container;
465
+ };
466
+
467
+ /**
468
+ * Navigates to a specific route
469
+ * @param {string} path - Target path
470
+ */
471
+ $router.go = (path) => {
472
+ const targetPath = path.startsWith("/") ? path : `/${path}`;
473
+ if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath;
474
+ };