Update sigpro-ssr.js

This commit is contained in:
Natxo
2026-03-28 22:11:53 +01:00
committed by GitHub
parent 87b468454c
commit df9cd0c234

View File

@@ -1,87 +1,234 @@
/** /**
* SigPro Core v1.1.13 - Full SSR & Hydration Support * SigPro Core v1.2.0 SSR + Hydration Support
*/ */
(() => { (() => {
const isServer = typeof window === "undefined";
const isServer = typeof window === "undefined" || typeof document === "undefined";
const _global = isServer ? global : window; const _global = isServer ? global : window;
let activeEffect = null; let activeEffect = null;
let currentOwner = null; let currentOwner = null;
let SSR_MODE = false;
let HYDRATE_PTR = null; // Puntero al nodo real en el DOM durante hidratación
// --- MOCK DOM PARA NODE.JS ---
const _doc = !isServer ? document : {
createElement: (tag) => ({ tagName: tag.toUpperCase(), childNodes: [], appendChild: () => {}, setAttribute: () => {}, style: {} }),
createTextNode: (txt) => ({ nodeType: 3, textContent: txt }),
createComment: (txt) => ({ nodeType: 8, textContent: txt })
};
// --- REACTIVITY CORE ---
const effectQueue = new Set(); const effectQueue = new Set();
let isFlushing = false; let isFlushing = false;
const MOUNTED_NODES = new WeakMap();
let SSR_MODE = false;
let HYDRATE_PTR = null;
const _doc = !isServer ? document : {
createElement: (tag) => ({
tagName: tag.toUpperCase(),
style: {},
childNodes: [],
_cleanups: new Set(),
appendChild: () => { },
setAttribute: () => { },
removeAttribute: () => { },
addEventListener: () => { },
removeEventListener: () => { },
insertBefore: () => { },
remove: () => { }
}),
createTextNode: (txt) => ({ nodeType: 3, textContent: txt, remove: () => { } }),
createComment: (txt) => ({ nodeType: 8, textContent: txt, remove: () => { } })
};
/** Flushes all pending effects in the queue */
const flush = () => { const flush = () => {
if (isFlushing) return;
isFlushing = true; isFlushing = true;
for (const eff of Array.from(effectQueue).sort((a, b) => (a.depth || 0) - (b.depth || 0))) { while (effectQueue.size > 0) {
if (!eff._deleted) eff(); const sorted = Array.from(effectQueue).sort((a, b) => (a.depth || 0) - (b.depth || 0));
effectQueue.clear();
for (const eff of sorted) if (!eff._deleted) eff();
} }
effectQueue.clear();
isFlushing = false; isFlushing = false;
}; };
/** Tracks the current active effect in the given subscriptions set */
const track = (subs) => { const track = (subs) => {
if (SSR_MODE || !activeEffect || activeEffect._deleted) return; if (SSR_MODE || !activeEffect || activeEffect._deleted) return;
subs.add(activeEffect); subs.add(activeEffect);
activeEffect._deps.add(subs); activeEffect._deps.add(subs);
}; };
/** Triggers all effects subscribed to the given set */
const trigger = (subs) => { const trigger = (subs) => {
if (SSR_MODE) return; if (SSR_MODE) return;
for (const eff of subs) { for (const eff of subs) {
if (eff === activeEffect || eff._deleted) continue; if (eff === activeEffect || eff._deleted) continue;
eff._isComputed ? (eff.markDirty(), eff._subs && trigger(eff._subs)) : effectQueue.add(eff); if (eff._isComputed) {
eff.markDirty();
if (eff._subs) trigger(eff._subs);
} else {
effectQueue.add(eff);
}
} }
if (!isFlushing) queueMicrotask(flush); if (!isFlushing) queueMicrotask(flush);
}; };
// --- SIGNALS ($) --- /** Recursively sweeps and cleans up a node and its children */
const sweep = (node) => {
if (node && node._cleanups) {
node._cleanups.forEach((f) => f());
node._cleanups.clear();
}
if (node && node.childNodes) {
node.childNodes.forEach(sweep);
}
};
/** Internal view factory that creates reactive views */
const _view = (fn) => {
const cleanups = new Set();
const prev = currentOwner;
currentOwner = { cleanups };
try {
const res = fn({ onCleanup: (f) => cleanups.add(f) });
if (SSR_MODE || isServer) {
const toString = (n) => {
if (!n && n !== 0) return '';
if (n._isRuntime) return n._ssrString || '';
if (Array.isArray(n)) return n.map(toString).join('');
if (n && typeof n === 'object' && n.ssr) return n.ssr;
if (n && typeof n === 'object' && n.nodeType) {
if (n.tagName) return `<${n.tagName.toLowerCase()}>${n.textContent || ''}</${n.tagName.toLowerCase()}>`;
return n.textContent || '';
}
return String(n);
};
return {
_isRuntime: true,
_ssrString: toString(res),
destroy: () => { }
};
}
const container = _doc.createElement("div");
container.style.display = "contents";
const process = (n) => {
if (!n && n !== 0) return;
if (n._isRuntime) {
cleanups.add(n.destroy);
container.appendChild(n.container);
} else if (Array.isArray(n)) {
n.forEach(process);
} else if (n && typeof n === 'object' && n.nodeType) {
container.appendChild(n);
} else {
container.appendChild(_doc.createTextNode(String(n)));
}
};
process(res);
return {
_isRuntime: true,
container,
_ssrString: null,
destroy: () => {
cleanups.forEach((f) => f());
sweep(container);
container.remove();
},
};
} finally {
currentOwner = prev;
}
};
/**
* Creates a reactive Signal or a Computed Value.
*
* @param {any|Function} initial - Initial value or a getter function for computed state.
* @param {string} [key] - Optional. Key for automatic persistence in localStorage.
* @returns {Function} Signal getter/setter. Use `sig()` to read and `sig(val)` to write.
*
* @example
* // Simple signal
* const count = $(0);
* @example
* // Computed signal
* const double = $(() => count() * 2);
* @example
* // Persisted signal
* const name = $("John", "user-name");
*/
const $ = (initial, key = null) => { const $ = (initial, key = null) => {
if (typeof initial === "function") { if (typeof initial === "function") {
const subs = new Set(); const subs = new Set();
let cached, dirty = true; let cached, dirty = true;
const effect = () => { const effect = () => {
if (effect._deleted) return; if (effect._deleted) return;
effect._deps.forEach(s => s.delete(effect)); effect._deps.forEach((s) => s.delete(effect));
effect._deps.clear(); effect._deps.clear();
const prev = activeEffect; const prev = activeEffect;
activeEffect = effect; activeEffect = effect;
try { try {
const val = initial(); const val = initial();
if (!Object.is(cached, val) || dirty) { cached = val; dirty = false; if (!SSR_MODE) trigger(subs); } if (!Object.is(cached, val) || dirty) {
cached = val;
dirty = false;
if (!SSR_MODE && !isServer) trigger(subs);
}
} finally { activeEffect = prev; } } finally { activeEffect = prev; }
}; };
effect._deps = new Set(); effect._deps = new Set();
effect._isComputed = true; effect._isComputed = true;
effect._subs = subs; effect._subs = subs;
effect._deleted = false;
effect.markDirty = () => (dirty = true); effect.markDirty = () => (dirty = true);
effect.stop = () => { effect._deleted = true; effect._deps.forEach(s => s.delete(effect)); }; effect.stop = () => {
if (currentOwner && !SSR_MODE) currentOwner.cleanups.add(effect.stop); effect._deleted = true;
effect(); // Primera ejecución effect._deps.forEach((s) => s.delete(effect));
return () => { if (dirty && !SSR_MODE) effect(); track(subs); return cached; }; subs.clear();
};
if (currentOwner && !SSR_MODE && !isServer) {
currentOwner.cleanups.add(effect.stop);
}
if (SSR_MODE || isServer) effect();
return (...args) => {
if (args.length && !SSR_MODE && !isServer) {
const next = typeof args[0] === "function" ? args[0](cached) : args[0];
if (!Object.is(cached, next)) {
cached = next;
if (key) {
try { localStorage.setItem(key, JSON.stringify(cached)); } catch (e) { }
}
trigger(subs);
}
}
track(subs);
return cached;
};
} }
let value = initial; let value = initial;
if (key && !isServer) { if (key && !SSR_MODE && !isServer) {
const saved = localStorage.getItem(key); try {
if (saved) try { value = JSON.parse(saved); } catch { value = saved; } const saved = localStorage.getItem(key);
if (saved !== null) {
try { value = JSON.parse(saved); } catch { value = saved; }
}
} catch (e) { }
} }
const subs = new Set(); const subs = new Set();
return (...args) => { return (...args) => {
if (args.length && !SSR_MODE) { if (args.length && !SSR_MODE && !isServer) {
const next = typeof args[0] === "function" ? args[0](value) : args[0]; const next = typeof args[0] === "function" ? args[0](value) : args[0];
if (!Object.is(value, next)) { if (!Object.is(value, next)) {
value = next; value = next;
if (key && !isServer) localStorage.setItem(key, JSON.stringify(value)); if (key) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
}
trigger(subs); trigger(subs);
} }
} }
@@ -90,213 +237,555 @@
}; };
}; };
/**
* Watches for signal changes and executes a side effect.
* Handles automatic cleanup of previous effects.
*
* @param {Function|Array} target - Function to execute or Array of signals for explicit dependency tracking.
* @param {Function} [fn] - If the first parameter is an Array, this is the callback function.
* @returns {Function} Function to manually stop the watcher.
* @example
* // Automatic dependency tracking
* $watch(() => console.log("Count is:", count()));
* @example
* // Explicit dependency tracking
* $watch([count], () => console.log("Only runs when count changes"));
*/
const $watch = (target, fn) => { const $watch = (target, fn) => {
if (SSR_MODE) return () => {}; if (SSR_MODE || isServer) return () => { };
const isArr = Array.isArray(target);
const cb = isArr ? fn : target; const isExplicit = Array.isArray(target);
const callback = isExplicit ? fn : target;
const depsInput = isExplicit ? target : null;
if (typeof callback !== "function") return () => { };
const owner = currentOwner;
const runner = () => { const runner = () => {
if (runner._deleted) return; if (runner._deleted) return;
runner._deps.forEach(s => s.delete(runner)); runner._deps.clear(); runner._deps.forEach((s) => s.delete(runner));
runner._cleanups.forEach(c => c()); runner._cleanups.clear(); runner._deps.clear();
const prevEff = activeEffect, prevOwn = currentOwner; runner._cleanups.forEach((c) => c());
activeEffect = runner; currentOwner = { cleanups: runner._cleanups }; runner._cleanups.clear();
runner.depth = prevEff ? prevEff.depth + 1 : 0;
try { isArr ? (cb(), target.forEach(d => typeof d === "function" && d())) : cb(); } const prevEffect = activeEffect;
finally { activeEffect = prevEff; currentOwner = prevOwn; } const prevOwner = currentOwner;
activeEffect = runner;
currentOwner = { cleanups: runner._cleanups };
runner.depth = prevEffect ? prevEffect.depth + 1 : 0;
try {
if (isExplicit) {
activeEffect = null;
callback();
activeEffect = runner;
depsInput.forEach(d => typeof d === "function" && d());
} else {
callback();
}
} finally {
activeEffect = prevEffect;
currentOwner = prevOwner;
}
}; };
runner._deps = new Set(); runner._cleanups = new Set();
runner.stop = () => { runner._deleted = true; runner._deps.forEach(s => s.delete(runner)); runner._cleanups.forEach(c => c()); }; runner._deps = new Set();
if (currentOwner) currentOwner.cleanups.add(runner.stop); runner._cleanups = new Set();
runner._deleted = false;
runner.stop = () => {
if (runner._deleted) return;
runner._deleted = true;
effectQueue.delete(runner);
runner._deps.forEach((s) => s.delete(runner));
runner._cleanups.forEach((c) => c());
if (owner) owner.cleanups.delete(runner.stop);
};
if (owner) owner.cleanups.add(runner.stop);
runner(); runner();
return runner.stop; return runner.stop;
}; };
// --- VIRTUAL VIEW / HYDRATION ENGINE --- /**
const _view = (fn) => { * DOM element rendering engine with built-in reactivity.
const cleanups = new Set(); *
const prev = currentOwner; * @param {string} tag - HTML tag name (e.g., 'div', 'span').
currentOwner = { cleanups }; * @param {Object} [props] - Attributes, events (onEvent), or two-way bindings (value, checked).
try { * @param {Array|any} [content] - Children: text, other nodes, or reactive signals.
const res = fn({ onCleanup: f => cleanups.add(f) }); * @returns {HTMLElement|Object} The configured reactive DOM element (or SSR object).
if (SSR_MODE) { *
const toStr = (n) => { * @example
if (Array.isArray(n)) return n.map(toStr).join(''); * // Basic usage
return n?._isRuntime ? n._ssrString : (n?.ssr || String(n ?? '')); * const button = Button({ onClick: () => alert("Clicked!") }, "Click me");
}; * @example
return { _isRuntime: true, _ssrString: toStr(res) }; * // With reactive content
} * const counter = Div(
const container = _doc.createElement("div"); * Span(() => `Count: ${count()}`),
container.style.display = "contents"; * Button({ onClick: () => count(count() + 1) }, "Increment")
const process = (n) => { * );
if (!n) return; * @example
if (n._isRuntime) container.appendChild(n.container); * // Two-way binding
else if (Array.isArray(n)) n.forEach(process); * const input = Input({ value: name, onInput: (e) => name(e.target.value) });
else container.appendChild(n.nodeType ? n : _doc.createTextNode(String(n))); */
};
process(res);
return { _isRuntime: true, container, destroy: () => { cleanups.forEach(f => f()); container.remove(); } };
} finally { currentOwner = prev; }
};
// --- HTML TAG ENGINE ---
const $html = (tag, props = {}, content = []) => { const $html = (tag, props = {}, content = []) => {
if (props.nodeType || Array.isArray(props) || typeof props !== "object") { content = props; props = {}; } // Normalize arguments
if (props && (props.nodeType || Array.isArray(props) || (typeof props !== "object"))) {
if (SSR_MODE) { content = props;
let attrs = ''; props = {};
for (let [k, v] of Object.entries(props)) { }
if (k === "ref" || k.startsWith("on")) continue;
const val = typeof v === "function" ? v() : v; if (SSR_MODE || isServer) {
if (val !== false && val != null) attrs += ` ${k === "class" ? "class" : k}="${val}"`; let attrs = '';
} let hasDynamic = false;
const children = [].concat(content).map(c => {
const v = typeof c === "function" ? c() : c; for (let [k, v] of Object.entries(props || {})) {
return v?._isRuntime ? v._ssrString : (v?.ssr || String(v ?? '')); if (k === "ref" || k.startsWith("on")) continue;
}).join('');
return { ssr: `<${tag}${attrs}>${children}</${tag}>` }; if (typeof v === "function") {
hasDynamic = true;
const val = v();
if (k === "class" && val) attrs += ` class="${val}"`;
else if (val != null && val !== false) attrs += ` ${k}="${val}"`;
} else if (v !== undefined && v !== null && v !== false) {
if (k === "class") attrs += ` class="${v}"`;
else attrs += ` ${k}="${v}"`;
}
}
const processChild = (c) => {
if (!c && c !== 0) return '';
if (typeof c === "function") {
hasDynamic = true;
const res = c();
if (res && res._isRuntime) return res._ssrString || '';
if (Array.isArray(res)) return res.map(processChild).join('');
return String(res ?? '');
}
if (c && c._isRuntime) return c._ssrString || '';
if (Array.isArray(c)) return c.map(processChild).join('');
if (c && typeof c === 'object' && c.ssr) return c.ssr;
return String(c ?? '');
};
const children = Array.isArray(content)
? content.map(processChild).join('')
: processChild(content);
const result = `<${tag}${attrs}>${children}</${tag}>`;
return { ssr: result, _isRuntime: false };
} }
// CLIENT / HYDRATION
let el; let el;
if (HYDRATE_PTR && HYDRATE_PTR.tagName === tag.toUpperCase()) { if (HYDRATE_PTR && HYDRATE_PTR.tagName === tag.toUpperCase()) {
el = HYDRATE_PTR; el = HYDRATE_PTR;
HYDRATE_PTR = el.firstChild; // Entramos al primer hijo para la recursión HYDRATE_PTR = el.firstChild;
} else { } else {
el = _doc.createElement(tag); el = _doc.createElement(tag);
} }
el._cleanups = el._cleanups || new Set(); el._cleanups = el._cleanups || new Set();
for (let [k, v] of Object.entries(props)) {
if (k === "ref") { typeof v === "function" ? v(el) : (v.current = el); continue; } for (let [k, v] of Object.entries(props || {})) {
if (k.startsWith("on")) { if (k === "ref") {
const name = k.slice(2).toLowerCase(); typeof v === "function" ? v(el) : (v.current = el);
el.addEventListener(name, v); continue;
el._cleanups.add(() => el.removeEventListener(name, v)); }
} else if (typeof v === "function") {
const isSignal = typeof v === "function";
const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
const isBindAttr = (k === "value" || k === "checked");
if (isInput && isBindAttr && isSignal) {
el._cleanups.add($watch(() => {
const val = v();
if (el[k] !== val) el[k] = val;
}));
const evt = k === "checked" ? "change" : "input";
const handler = (e) => v(e.target[k]);
el.addEventListener(evt, handler);
el._cleanups.add(() => el.removeEventListener(evt, handler));
} else if (k.startsWith("on")) {
const name = k.slice(2).toLowerCase().split(".")[0];
const handler = (e) => v(e);
el.addEventListener(name, handler);
el._cleanups.add(() => el.removeEventListener(name, handler));
} else if (isSignal) {
el._cleanups.add($watch(() => { el._cleanups.add($watch(() => {
const val = v(); const val = v();
if (k === "class") el.className = val || ""; if (k === "class") el.className = val || "";
else val == null ? el.removeAttribute(k) : el.setAttribute(k, val); else if (val == null) el.removeAttribute(k);
else el.setAttribute(k, val);
})); }));
} else if (!el.hasAttribute(k)) el.setAttribute(k, v); } else if (v !== undefined && v !== null && v !== false) {
if (!el.hasAttribute(k)) el.setAttribute(k, v);
}
} }
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 marker = _doc.createTextNode(""); const marker = _doc.createTextNode("");
if (!HYDRATE_PTR) el.appendChild(marker);
if (HYDRATE_PTR) {
el.insertBefore(marker, HYDRATE_PTR);
} else {
el.appendChild(marker);
}
let nodes = []; let nodes = [];
el._cleanups.add($watch(() => { el._cleanups.add($watch(() => {
const res = c(); const res = c();
const next = (Array.isArray(res) ? res : [res]).map(i => i?._isRuntime ? i.container : (i instanceof Node ? i : _doc.createTextNode(i ?? ""))); const next = (Array.isArray(res) ? res : [res]).map((i) => {
nodes.forEach(n => n.remove()); if (i && i._isRuntime) return i.container;
next.forEach(n => marker.parentNode?.insertBefore(n, marker)); if (i && i.nodeType) return i;
return _doc.createTextNode(i ?? "");
});
nodes.forEach((n) => { sweep(n); n.remove(); });
next.forEach((n) => marker.parentNode?.insertBefore(n, marker));
nodes = next; nodes = next;
})); }));
} else if (c && c._isRuntime) {
if (!HYDRATE_PTR) el.appendChild(c.container);
} else if (c && c.nodeType) {
if (!HYDRATE_PTR) el.appendChild(c);
} else { } else {
const child = c?._isRuntime ? c.container : (c instanceof Node ? c : _doc.createTextNode(String(c ?? ""))); if (HYDRATE_PTR && HYDRATE_PTR.nodeType === 3) {
if (!HYDRATE_PTR) el.appendChild(child); const textNode = HYDRATE_PTR;
else if (HYDRATE_PTR.nodeType === 3) HYDRATE_PTR = HYDRATE_PTR.nextSibling; // Saltar texto ya hidratado HYDRATE_PTR = textNode.nextSibling;
} else {
const textNode = _doc.createTextNode(c ?? "");
if (!HYDRATE_PTR) el.appendChild(textNode);
}
} }
}; };
append(content); append(content);
if (el === HYDRATE_PTR?.parentNode) HYDRATE_PTR = el.nextSibling; // Volvemos al nivel superior if (el === HYDRATE_PTR?.parentNode) HYDRATE_PTR = el.nextSibling;
return el; return el;
}; };
// --- CONTROL FLOW --- /**
const $if = (cond, t, f = null) => { * Conditional rendering component.
if (SSR_MODE) { *
const b = (typeof cond === "function" ? cond() : cond) ? t : f; * @param {Function|boolean} condition - Reactive signal or boolean value.
return b ? (typeof b === "function" ? b() : b) : ''; * @param {Function|HTMLElement} thenVal - Content to show if true.
* @param {Function|HTMLElement} [otherwiseVal] - Content to show if false (optional).
* @returns {HTMLElement|string} A reactive container (or SSR string).
* @example
* $if(show, () => Div("Visible"), () => Div("Hidden"))
*/
const $if = (condition, thenVal, otherwiseVal = null) => {
if (SSR_MODE || isServer) {
const state = !!(typeof condition === "function" ? condition() : condition);
const branch = state ? thenVal : otherwiseVal;
if (!branch) return '';
const result = typeof branch === "function" ? branch() : branch;
if (result && result._isRuntime) return result._ssrString || '';
if (result && result.ssr) return result.ssr;
if (typeof result === 'string') return result;
return String(result);
} }
const marker = _doc.createTextNode(""); const marker = _doc.createTextNode("");
const container = $html("div", { style: "display:contents" }, [marker]); const container = $html("div", { style: "display:contents" }, [marker]);
let curr = null, last = null; let current = null, last = null;
$watch(() => { $watch(() => {
const s = !!(typeof cond === "function" ? cond() : cond); const state = !!(typeof condition === "function" ? condition() : condition);
if (s !== last) { if (state !== last) {
last = s; if (curr) curr.destroy(); last = state;
const b = s ? t : f; if (current) current.destroy();
if (b) { curr = _view(() => typeof b === "function" ? b() : b); container.insertBefore(curr.container, marker); } const branch = state ? thenVal : otherwiseVal;
if (branch) {
current = _view(() => typeof branch === "function" ? branch() : branch);
container.insertBefore(current.container, marker);
}
} }
}); });
return container; return container;
}; };
const $for = (src, itemFn, keyFn) => { /**
if (SSR_MODE) { * Negated conditional rendering.
return ((typeof src === "function" ? src() : src) || []).map((item, i) => { * @param {Function|boolean} condition - Reactive signal or boolean value.
const r = itemFn(item, i); * @param {Function|HTMLElement} thenVal - Content to show if false.
return r?._isRuntime ? r._ssrString : (r?.ssr || String(r)); * @param {Function|HTMLElement} [otherwiseVal] - Content to show if true (optional).
*/
$if.not = (condition, thenVal, otherwiseVal) =>
$if(() => !(typeof condition === "function" ? condition() : condition), thenVal, otherwiseVal);
/**
* Optimized reactive loop with key-based reconciliation.
*
* @param {Function|Array} source - Signal containing an Array of data.
* @param {Function} render - Function receiving (item, index) and returning a node.
* @param {Function} keyFn - Function to extract a unique key from the item.
* @returns {HTMLElement|string} A reactive container (or SSR string).
* @example
* $for(items, (item) => Li(item.name), (item) => item.id)
*/
const $for = (source, render, keyFn) => {
if (SSR_MODE || isServer) {
const items = (typeof source === "function" ? source() : source) || [];
return items.map((item, index) => {
const result = render(item, index);
if (result && result._isRuntime) return result._ssrString || '';
if (result && result.ssr) return result.ssr;
if (typeof result === 'string') return result;
return String(result);
}).join(''); }).join('');
} }
const marker = _doc.createTextNode(""); const marker = _doc.createTextNode("");
const container = $html("div", { style: "display:contents" }, [marker]); const container = $html("div", { style: "display:contents" }, [marker]);
const cache = new Map(); const cache = new Map();
$watch(() => { $watch(() => {
const items = (typeof src === "function" ? src() : src) || []; const items = (typeof source === "function" ? source() : source) || [];
const newKeys = new Set(); const newKeys = new Set();
items.forEach((item, i) => { items.forEach((item, index) => {
const k = keyFn(item, i); newKeys.add(k); const key = keyFn(item, index);
let run = cache.get(k); newKeys.add(key);
if (!run) { run = _view(() => itemFn(item, i)); cache.set(k, run); } let run = cache.get(key);
if (!run) {
run = _view(() => render(item, index));
cache.set(key, run);
}
container.insertBefore(run.container, marker); container.insertBefore(run.container, marker);
}); });
cache.forEach((r, k) => { if (!newKeys.has(k)) { r.destroy(); cache.delete(k); }}); cache.forEach((run, key) => {
if (!newKeys.has(key)) { run.destroy(); cache.delete(key); }
});
}); });
return container; return container;
}; };
// --- ROUTER --- /**
* Hash-based (#) routing system with parameter support.
*
* @param {Array<{path: string, component: Function}>} routes - Route definitions.
* @returns {HTMLElement|string} The router outlet container (or SSR string).
* @example
* const routes = [
* { path: "/", component: Home },
* { path: "/user/:id", component: UserProfile },
* { path: "*", component: NotFound }
* ];
* $router(routes);
*/
const $router = (routes) => { const $router = (routes) => {
const getPath = () => SSR_MODE ? $router._ssrPath : (window.location.hash.replace(/^#/, "") || "/"); const getPath = () => {
if (SSR_MODE) { if (SSR_MODE) return $router._ssrPath;
return window.location.hash.replace(/^#/, "") || "/";
};
if (SSR_MODE || isServer) {
const path = getPath(); const path = getPath();
const r = routes.find(rt => rt.path === path || rt.path === "*") || routes[0]; const route = routes.find(r => {
const res = r.component({}); const rp = r.path.split("/").filter(Boolean);
return typeof res === "function" ? $ssr(res) : (res?.ssr || String(res)); const pp = path.split("/").filter(Boolean);
return rp.length === pp.length && rp.every((p, i) => p.startsWith(":") || p === pp[i]);
}) || routes.find(r => r.path === "*");
if (route) {
const params = {};
route.path.split("/").filter(Boolean).forEach((p, i) => {
if (p.startsWith(":")) params[p.slice(1)] = path.split("/").filter(Boolean)[i];
});
const result = route.component(params);
if (typeof result === "function") return $ssr(result);
if (result && result._isRuntime) return result._ssrString || '';
if (result && result.ssr) return result.ssr;
return String(result);
}
return '';
} }
const sPath = $(getPath()); const sPath = $(getPath());
window.addEventListener("hashchange", () => sPath(getPath())); window.addEventListener("hashchange", () => sPath(getPath()));
const outlet = $html("div", { class: "router-outlet" }); const outlet = Div({ class: "router-outlet" });
let curr = null; let current = null;
$watch([sPath], () => { $watch([sPath], () => {
if (curr) curr.destroy(); if (current) current.destroy();
const r = routes.find(rt => rt.path === sPath() || rt.path === "*") || routes[0]; const path = sPath();
curr = _view(() => r.component({})); const route = routes.find(r => {
outlet.appendChild(curr.container); const rp = r.path.split("/").filter(Boolean);
const pp = path.split("/").filter(Boolean);
return rp.length === pp.length && rp.every((p, i) => p.startsWith(":") || p === pp[i]);
}) || routes.find(r => r.path === "*");
if (route) {
const params = {};
route.path.split("/").filter(Boolean).forEach((p, i) => {
if (p.startsWith(":")) params[p.slice(1)] = path.split("/").filter(Boolean)[i];
});
if ($router.params) $router.params(params);
current = _view(() => {
const res = route.component(params);
return typeof res === "function" ? res() : res;
});
outlet.appendChild(current.container);
}
}); });
return outlet; return outlet;
}; };
/** Reactive route parameters signal */
$router.params = $({});
/** Navigate to a route */
$router.to = (path) => {
if (SSR_MODE || isServer) return;
window.location.hash = path.replace(/^#?\/?/, "#/");
};
/** Go back in history */
$router.back = () => {
if (SSR_MODE || isServer) return;
window.history.back();
};
/** Get current route path */
$router.path = () => {
if (SSR_MODE || isServer) return $router._ssrPath || "/";
return window.location.hash.replace(/^#/, "") || "/";
};
/** Internal SSR path storage */
$router._ssrPath = "/"; $router._ssrPath = "/";
// --- PUBLIC API --- /**
_global.$ssr = (comp, path = "/") => { * Mounts a component or node into a DOM target element.
const prev = SSR_MODE; SSR_MODE = true; $router._ssrPath = path; * It automatically handles the cleanup of any previously mounted SigPro instances
try { return _view(typeof comp === "function" ? comp : () => comp)._ssrString; } * in that target to prevent memory leaks and duplicate renders.
finally { SSR_MODE = prev; } *
}; * @param {Function|HTMLElement} component - The component function to render or a pre-built DOM node.
* @param {string|HTMLElement} target - A CSS selector string or a direct DOM element to mount into.
* @returns {Object|undefined} The view instance containing the `container` and `destroy` method, or undefined if target is not found.
* @example
* // Mount using a component function
* $mount(() => Div({ class: "app" }, "Hello World"), "#root");
* @example
* // Mount using a direct element
* const myApp = Div("Hello");
* $mount(myApp, document.getElementById("app"));
*/
const $mount = (component, target) => {
if (SSR_MODE || isServer) return;
_global.$mount = (comp, target) => {
const el = typeof target === "string" ? document.querySelector(target) : target; const el = typeof target === "string" ? document.querySelector(target) : target;
if (!el) return; if (!el) return;
if (el.firstChild) {
if (el.firstChild && el.firstChild.nodeType === 1) {
HYDRATE_PTR = el.firstChild; HYDRATE_PTR = el.firstChild;
const inst = _view(typeof comp === "function" ? comp : () => comp); const instance = _view(typeof component === "function" ? component : () => component);
HYDRATE_PTR = null; HYDRATE_PTR = null;
return inst; return instance;
} }
const inst = _view(typeof comp === "function" ? comp : () => comp);
el.replaceChildren(inst.container); if (MOUNTED_NODES.has(el)) MOUNTED_NODES.get(el).destroy();
return inst; const instance = _view(typeof component === "function" ? component : () => component);
el.replaceChildren(instance.container);
MOUNTED_NODES.set(el, instance);
return instance;
}; };
_global.$ = $; _global.$watch = $watch; _global.$html = $html; _global.$if = $if; _global.$for = $for; _global.$router = $router; // ============================================================================
// SSR API ($ssr)
// ============================================================================
const tags = 'div span p h1 h2 h3 h4 h5 h6 br hr section article aside nav main header footer ul ol li a em strong i b u mark code form label input textarea select button table tr th td img video audio svg'.split(' '); /**
tags.forEach(t => _global[t.charAt(0).toUpperCase() + t.slice(1)] = (p, c) => $html(t, p, c)); * Server-Side Rendering: Converts a component to an HTML string.
*
* @param {Function|HTMLElement} component - The component to render.
* @param {string} [path="/"] - Optional route path for SSR routing.
* @returns {string} The rendered HTML string.
*
* @example
* // In Node.js
* const html = $ssr(App, "/users/123");
* res.send(`<!DOCTYPE html><body><div id="app">${html}</div></body>`);
*/
const $ssr = (component, path = "/") => {
const prev = SSR_MODE;
SSR_MODE = true;
$router._ssrPath = path;
try {
const result = _view(typeof component === "function" ? component : () => component);
return result._ssrString || '';
} catch (err) {
console.error('SSR Error:', err);
return `<div>Error rendering component: ${err.message}</div>`;
} finally {
SSR_MODE = prev;
}
};
// ============================================================================
// GLOBAL API INJECTION
// ============================================================================
const core = { $, $if, $for, $watch, $mount, $router, $html, $ssr };
for (const [name, fn] of Object.entries(core)) {
Object.defineProperty(_global, name, {
value: fn,
writable: false,
configurable: false
});
}
// ============================================================================
// HTML TAG HELPERS
// ============================================================================
/**
* Auto-generated HTML tag helpers (Div, Span, Button, etc.)
* These provide a JSX-like experience without compilation.
*
* @example
* Div({ class: "container" },
* H1("Title"),
* Button({ onClick: handleClick }, "Click")
* )
*/
const tags = `div span p h1 h2 h3 h4 h5 h6 br hr section article aside nav main header footer address
ul ol li dl dt dd a em strong small i b u mark time sub sup pre code blockquote details summary dialog
form label input textarea select button option fieldset legend table thead tbody tfoot tr th td caption
img video audio canvas svg iframe picture source progress meter`.split(/\s+/);
tags.forEach((tagName) => {
const helperName = tagName.charAt(0).toUpperCase() + tagName.slice(1);
Object.defineProperty(_global, helperName, {
value: (props, content) => $html(tagName, props, content),
writable: false,
configurable: true,
enumerable: true
});
});
})(); })();
// ============================================================================
// MODULE EXPORTS
// ============================================================================
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
$: global.$,
$watch: global.$watch,
$html: global.$html,
$if: global.$if,
$for: global.$for,
$router: global.$router,
$mount: global.$mount,
$ssr: global.$ssr
};
}
export const { $, $watch, $html, $if, $for, $router, $mount, $ssr } = typeof window !== 'undefined' ? window : global;