improvements in sigpro

This commit is contained in:
2026-03-23 03:03:25 +01:00
parent f469d384cd
commit 8b742bd11a

View File

@@ -1,43 +1,76 @@
/** /**
* SigPro - Atomic Unified Reactive Engine * SigPro v2.0
* A lightweight, fine-grained reactivity system with built-in routing and plugin support.
* @author NatxoCC
*
* Type definitions available in sigpro.d.ts
*/ */
(() => { (() => {
/** @type {Function|null} Internal tracker for the currently executing reactive effect. */
let activeEffect = null; let activeEffect = null;
const effectQueue = new Set();
let isFlushScheduled = false;
let flushCount = 0;
// --- Motor de Batching con Protección (v1 + v2) ---
const flushQueue = () => {
isFlushScheduled = false;
flushCount++;
if (flushCount > 100) {
effectQueue.clear();
throw new Error("SigPro: Bucle infinito detectado");
}
const effects = Array.from(effectQueue);
effectQueue.clear();
effects.forEach(fn => fn());
// Resetear contador al final del microtask
queueMicrotask(() => flushCount = 0);
};
const scheduleFlush = (s) => {
effectQueue.add(s);
if (!isFlushScheduled) {
isFlushScheduled = true;
queueMicrotask(flushQueue);
}
};
/**
* Creates a reactive Signal, Computed value, or Store (Object of signals)
* @param {any|Function|Object} initial - Initial value, computed function, or state object
* @param {string} [key] - Optional localStorage key for persistence
* @returns {Function|Object} Reactive accessor/mutator or Store object
*/
const $ = (initial, key) => { const $ = (initial, key) => {
const subs = new Set(); const subs = new Set();
if (typeof initial === 'object' && initial !== null && !Array.isArray(initial) && typeof initial !== 'function' && !(initial instanceof Node)) { // 1. Objetos Reactivos (v2)
if (initial?.constructor === Object && !key) {
const store = {}; const store = {};
for (let k in initial) { for (let k in initial) store[k] = $(initial[k]);
store[k] = $(initial[k], key ? `${key}_${k}` : null);
}
return store; return store;
} }
// 2. Efectos y Computados con Limpieza (v1 + v2)
if (typeof initial === 'function') { if (typeof initial === 'function') {
let cached; let cached, running = false;
const cleanups = new Set();
const runner = () => { const runner = () => {
if (runner.el && !runner.el.isConnected) return; // GC: v2
if (running) return;
// Ejecutar limpiezas previas (v1)
cleanups.forEach(fn => fn());
cleanups.clear();
const prev = activeEffect; const prev = activeEffect;
activeEffect = runner; activeEffect = runner;
activeEffect.onCleanup = (fn) => cleanups.add(fn); // Registro de limpieza
running = true;
try { try {
const next = initial(); const next = initial();
if (!Object.is(cached, next)) { if (!Object.is(cached, next)) {
cached = next; cached = next;
subs.forEach(s => s()); subs.forEach(scheduleFlush);
}
} finally {
activeEffect = prev;
running = false;
} }
} finally { activeEffect = prev; }
}; };
runner(); runner();
return () => { return () => {
@@ -46,180 +79,162 @@
}; };
} }
// 3. Persistencia (v2)
if (key) { if (key) {
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved !== null) { if (saved !== null) try { initial = JSON.parse(saved); } catch (e) { }
try { initial = JSON.parse(saved); } catch (e) { }
}
} }
// 4. Señal Atómica
return (...args) => { return (...args) => {
if (args.length) { if (args.length) {
const next = typeof args[0] === 'function' ? args[0](initial) : args[0]; const next = typeof args[0] === 'function' ? args[0](initial) : args[0];
if (!Object.is(initial, next)) { if (!Object.is(initial, next)) {
initial = next; initial = next;
if (key) localStorage.setItem(key, JSON.stringify(initial)); if (key) localStorage.setItem(key, JSON.stringify(initial));
subs.forEach(s => s()); subs.forEach(scheduleFlush);
} }
} }
if (activeEffect) subs.add(activeEffect); if (activeEffect) {
subs.add(activeEffect);
if (activeEffect.onCleanup) activeEffect.onCleanup(() => subs.delete(activeEffect));
}
return initial; return initial;
}; };
}; };
/** // --- Motor de Renderizado con Modificadores de v1 ---
* Creates reactive HTML elements
* @param {string} tag - HTML tag name
* @param {Object} [props] - Attributes and event handlers
* @param {any} [content] - Child content
* @returns {HTMLElement}
*/
$.html = (tag, props = {}, content = []) => { $.html = (tag, props = {}, content = []) => {
const el = document.createElement(tag); const el = document.createElement(tag);
if (typeof props !== 'object' || props instanceof Node || Array.isArray(props) || typeof props === 'function') { if (props instanceof Node || Array.isArray(props) || typeof props !== 'object') {
content = props; content = props; props = {};
props = {};
} }
for (let [key, val] of Object.entries(props)) { for (let [key, val] of Object.entries(props)) {
if (key.startsWith('on')) { if (key.startsWith('on')) {
el.addEventListener(key.toLowerCase().slice(2), val); const [rawName, ...mods] = key.toLowerCase().slice(2).split('.');
} else if (key.startsWith('$')) { const handler = (e) => {
const attr = key.slice(1); if (mods.includes('prevent')) e.preventDefault();
if ((attr === 'value' || attr === 'checked') && typeof val === 'function') { if (mods.includes('stop')) e.stopPropagation();
const ev = attr === 'checked' ? 'change' : 'input';
el.addEventListener(ev, e => val(attr === 'checked' ? e.target.checked : e.target.value)); if (mods.some(m => m.startsWith('debounce'))) {
const ms = mods.find(m => m.startsWith('debounce')).split(':')[1] || 300;
clearTimeout(val._timer);
val._timer = setTimeout(() => val(e), ms);
} else {
val(e);
} }
$(() => { };
el.addEventListener(rawName, handler, { once: mods.includes('once') });
}
else if (key.startsWith('$')) {
const attr = key.slice(1);
const attrEff = () => {
const v = typeof val === 'function' ? val() : val; const v = typeof val === 'function' ? val() : val;
if (attr === 'value' || attr === 'checked') el[attr] = v; if (attr === 'value' || attr === 'checked') el[attr] = v;
else if (typeof v === 'boolean') el.toggleAttribute(attr, v); else if (typeof v === 'boolean') el.toggleAttribute(attr, v);
else el.setAttribute(attr, v ?? ''); else if (v == null) el.removeAttribute(attr);
}); else el.setAttribute(attr, v);
};
attrEff.el = el; $(attrEff);
if ((attr === 'value' || attr === 'checked') && typeof val === 'function') {
el.addEventListener(attr === 'checked' ? 'change' : 'input', e =>
val(attr === 'checked' ? e.target.checked : e.target.value)
);
}
} else el.setAttribute(key, val); } else el.setAttribute(key, val);
} }
const append = (c) => { const append = (c) => {
if (Array.isArray(c)) return c.forEach(append); if (Array.isArray(c)) return c.forEach(append);
if (typeof c === 'function') { if (typeof c === 'function') {
const node = document.createTextNode(''); let nodes = [document.createTextNode('')];
$(() => { const contentEff = () => {
const res = c(); const res = c();
if (res instanceof Node) { const nextNodes = (Array.isArray(res) ? res : [res]).map(i =>
if (node.parentNode) node.replaceWith(res); i instanceof Node ? i : document.createTextNode(i ?? '')
} else { );
node.textContent = res ?? ''; if (nextNodes.length === 0) nextNodes.push(document.createTextNode(''));
if (nodes[0].parentNode) {
const parent = nodes[0].parentNode;
nextNodes.forEach(n => parent.insertBefore(n, nodes[0]));
nodes.forEach(n => n.remove());
nodes = nextNodes;
} }
}); };
return el.appendChild(node); contentEff.el = nodes[0];
nodes.forEach(n => el.appendChild(n));
$(contentEff);
return;
} }
el.appendChild(c instanceof Node ? c : document.createTextNode(c ?? '')); el.appendChild(c instanceof Node ? c : document.createTextNode(c ?? ''));
}; };
append(content); append(content);
return el; return el;
}; };
const tags = [ const tags = ['div', 'span', 'p', 'h1', 'h2', 'ul', 'li', 'button', 'input', 'label', 'form', 'section', 'a', 'img'];
'div', 'span', 'p', 'section', 'nav', 'main', 'header', 'footer', 'article', 'aside',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'button', 'a', 'label', 'strong', 'em', 'code', 'pre', 'br', 'hr', 'small', 'i', 'b', 'u', 'mark',
'input', 'form', 'select', 'option', 'textarea', 'fieldset', 'legend', 'details', 'summary',
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'tfoot', 'caption',
'img', 'canvas', 'video', 'audio', 'svg', 'path', 'iframe'
];
tags.forEach(t => window[t] = (p, c) => $.html(t, p, c)); tags.forEach(t => window[t] = (p, c) => $.html(t, p, c));
/** // --- Router mejorado ---
* Mounts a component to the DOM
* @param {HTMLElement|Function} node - Component or element to mount
* @param {HTMLElement|string} [target=document.body] - Target element or selector
*/
$.mount = (node, target = document.body) => {
const el = typeof target === 'string' ? document.querySelector(target) : target;
if (el) {
el.innerHTML = '';
el.appendChild(typeof node === 'function' ? node() : node);
}
};
/**
* Initializes a hash-based router
* @param {Array<{path: string, component: Function|Promise|HTMLElement}>} routes
* @returns {HTMLElement}
*/
$.router = (routes) => { $.router = (routes) => {
// Señal persistente del path actual
const sPath = $(window.location.hash.replace(/^#/, "") || "/"); const sPath = $(window.location.hash.replace(/^#/, "") || "/");
// Listener nativo
window.addEventListener("hashchange", () => sPath(window.location.hash.replace(/^#/, "") || "/")); window.addEventListener("hashchange", () => sPath(window.location.hash.replace(/^#/, "") || "/"));
return $.html('div', [ const container = div({ class: "router-outlet" });
() => {
const current = sPath();
const cP = current.split('/').filter(Boolean);
const routeEff = () => {
const cur = sPath();
const cP = cur.split('/').filter(Boolean);
// Buscamos la ruta (incluyendo parámetros :id y wildcard *)
const route = routes.find(r => { const route = routes.find(r => {
const rP = r.path.split('/').filter(Boolean); const rP = r.path.split('/').filter(Boolean);
if (rP.length !== cP.length) return false; return rP.length === cP.length && rP.every((p, i) => p.startsWith(':') || p === cP[i]);
return rP.every((part, i) => part.startsWith(':') || part === cP[i]);
}) || routes.find(r => r.path === "*"); }) || routes.find(r => r.path === "*");
if (!route) return $.html('h1', "404 - Not Found"); if (!route) return container.replaceChildren(h1("404 - Not Found"));
const rP = route.path.split('/').filter(Boolean); // Extraer parámetros dinámicos
const params = {}; const params = {};
rP.forEach((part, i) => { route.path.split('/').filter(Boolean).forEach((p, i) => {
if (part.startsWith(':')) params[part.slice(1)] = cP[i]; if (p.startsWith(':')) params[p.slice(1)] = cP[i];
}); });
const result = typeof route.component === 'function' ? route.component(params) : route.component; const res = typeof route.component === 'function' ? route.component(params) : route.component;
if (result instanceof Promise) { // Renderizado Seguro con replaceChildren (v1 spirit)
const $lazyNode = $($.html('span', "Loading...")); if (res instanceof Promise) {
result.then(m => { const loader = span("Cargando...");
const content = m.default || m; container.replaceChildren(loader);
const finalView = typeof content === 'function' ? content(params) : content; res.then(c => container.replaceChildren(c instanceof Node ? c : document.createTextNode(c)));
$lazyNode(finalView); } else {
}); container.replaceChildren(res instanceof Node ? res : document.createTextNode(res));
return () => $lazyNode();
} }
return result instanceof Node ? result : $.html('span', String(result));
}
]);
}; };
/** routeEff.el = container; // Seguridad de SigPro v2
* Programmatic navigation $(routeEff);
* @param {string} path - Destination path
*/ return container;
};
// Vinculamos el método .go
$.router.go = (path) => { $.router.go = (path) => {
window.location.hash = path.startsWith('/') ? path : `/${path}`; const target = path.startsWith('/') ? path : `/${path}`;
window.location.hash = target;
}; };
/** $.mount = (node, target = 'body') => {
* Plugin system - extends SigPro or loads external scripts const el = typeof target === 'string' ? document.querySelector(target) : target;
* @param {Function|string|string[]} source - Plugin or script URL(s) if (el) { el.innerHTML = ''; el.appendChild(typeof node === 'function' ? node() : node); }
* @returns {Promise<SigPro>|SigPro}
*/
$.plugin = (source) => {
if (typeof source === 'function') {
source($);
return $;
}
const urls = Array.isArray(source) ? source : [source];
return Promise.all(urls.map(url => new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = () => {
console.log(`%c[SigPro] Plugin Loaded: ${url}`, "color: #51cf66; font-weight: bold;");
resolve();
};
script.onerror = () => reject(new Error(`[SigPro] Failed to load: ${url}`));
document.head.appendChild(script);
}))).then(() => $);
}; };
window.$ = $; window.$ = $;
})(); })();
export const { $ } = window;