Improvements SigPro
This commit is contained in:
2
index.js
2
index.js
@@ -1,2 +1,2 @@
|
||||
// index.js
|
||||
export * from './sigpro/sigpro.js';
|
||||
export * from './sigpro/index.js';
|
||||
|
||||
423
sigpro/index.js
Normal file
423
sigpro/index.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* SigPro Core
|
||||
*/
|
||||
let activeEffect = null;
|
||||
let currentOwner = null;
|
||||
const effectQueue = new Set();
|
||||
let isFlushing = false;
|
||||
const MOUNTED_NODES = new WeakMap();
|
||||
|
||||
/** flush */
|
||||
const flush = () => {
|
||||
if (isFlushing) return;
|
||||
isFlushing = true;
|
||||
while (effectQueue.size > 0) {
|
||||
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();
|
||||
}
|
||||
isFlushing = false;
|
||||
};
|
||||
|
||||
/** track */
|
||||
const track = (subs) => {
|
||||
if (activeEffect && !activeEffect._deleted) {
|
||||
subs.add(activeEffect);
|
||||
activeEffect._deps.add(subs);
|
||||
}
|
||||
};
|
||||
|
||||
/** trigger */
|
||||
const trigger = (subs) => {
|
||||
for (const eff of subs) {
|
||||
if (eff === activeEffect || eff._deleted) continue;
|
||||
if (eff._isComputed) {
|
||||
eff.markDirty();
|
||||
if (eff._subs) trigger(eff._subs);
|
||||
} else {
|
||||
effectQueue.add(eff);
|
||||
}
|
||||
}
|
||||
if (!isFlushing) queueMicrotask(flush);
|
||||
};
|
||||
|
||||
/** sweep */
|
||||
const sweep = (node) => {
|
||||
if (node._cleanups) {
|
||||
node._cleanups.forEach((f) => f());
|
||||
node._cleanups.clear();
|
||||
}
|
||||
node.childNodes?.forEach(sweep);
|
||||
};
|
||||
|
||||
/** _view */
|
||||
const _view = (fn) => {
|
||||
const cleanups = new Set();
|
||||
const prev = currentOwner;
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "contents";
|
||||
currentOwner = { cleanups };
|
||||
try {
|
||||
const res = fn({ onCleanup: (f) => cleanups.add(f) });
|
||||
const process = (n) => {
|
||||
if (!n) return;
|
||||
if (n._isRuntime) {
|
||||
cleanups.add(n.destroy);
|
||||
container.appendChild(n.container);
|
||||
} else if (Array.isArray(n)) n.forEach(process);
|
||||
else container.appendChild(n instanceof Node ? n : document.createTextNode(String(n)));
|
||||
};
|
||||
process(res);
|
||||
} finally { currentOwner = prev; }
|
||||
return {
|
||||
_isRuntime: true,
|
||||
container,
|
||||
destroy: () => {
|
||||
cleanups.forEach((f) => f());
|
||||
sweep(container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* const count = $(0); // Simple signal
|
||||
* const double = $(() => count() * 2); // Computed signal
|
||||
* const name = $("John", "user-name"); // Persisted signal
|
||||
*/
|
||||
|
||||
export const $ = (initial, key = null) => {
|
||||
if (typeof initial === "function") {
|
||||
const subs = new Set();
|
||||
let cached, dirty = true;
|
||||
const effect = () => {
|
||||
if (effect._deleted) return;
|
||||
effect._deps.forEach((s) => s.delete(effect));
|
||||
effect._deps.clear();
|
||||
const prev = activeEffect;
|
||||
activeEffect = effect;
|
||||
try {
|
||||
const val = initial();
|
||||
if (!Object.is(cached, val) || dirty) {
|
||||
cached = val;
|
||||
dirty = false;
|
||||
trigger(subs);
|
||||
}
|
||||
} finally { activeEffect = prev; }
|
||||
};
|
||||
effect._deps = new Set();
|
||||
effect._isComputed = true;
|
||||
effect._subs = subs;
|
||||
effect._deleted = false;
|
||||
effect.markDirty = () => (dirty = true);
|
||||
effect.stop = () => {
|
||||
effect._deleted = true;
|
||||
effect._deps.forEach((s) => s.delete(effect));
|
||||
subs.clear();
|
||||
};
|
||||
if (currentOwner) currentOwner.cleanups.add(effect.stop);
|
||||
return () => { if (dirty) effect(); track(subs); return cached; };
|
||||
}
|
||||
|
||||
let value = initial;
|
||||
if (key) {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved !== null) value = JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.warn("SigPro: LocalStorage locked", e);
|
||||
}
|
||||
}
|
||||
const subs = new Set();
|
||||
return (...args) => {
|
||||
if (args.length) {
|
||||
const next = typeof args[0] === "function" ? args[0](value) : args[0];
|
||||
if (!Object.is(value, next)) {
|
||||
value = next;
|
||||
if (key) localStorage.setItem(key, JSON.stringify(value));
|
||||
trigger(subs);
|
||||
}
|
||||
}
|
||||
track(subs);
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* $watch(() => console.log("Count is:", count()));
|
||||
* $watch([count], () => console.log("Only runs when count changes"));
|
||||
*/
|
||||
|
||||
export const $watch = (target, fn) => {
|
||||
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 = () => {
|
||||
if (runner._deleted) return;
|
||||
runner._deps.forEach((s) => s.delete(runner));
|
||||
runner._deps.clear();
|
||||
runner._cleanups.forEach((c) => c());
|
||||
runner._cleanups.clear();
|
||||
|
||||
const prevEffect = activeEffect;
|
||||
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._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();
|
||||
return runner.stop;
|
||||
};
|
||||
|
||||
/**
|
||||
* DOM element rendering engine with built-in reactivity.
|
||||
* @param {string} tag - HTML tag name (e.g., 'div', 'span').
|
||||
* @param {Object} [props] - Attributes, events (onEvent), or two-way bindings (value, checked).
|
||||
* @param {Array|any} [content] - Children: text, other nodes, or reactive signals.
|
||||
* @returns {HTMLElement} The configured reactive DOM element.
|
||||
*/
|
||||
export const $html = (tag, props = {}, content = []) => {
|
||||
if (props instanceof Node || Array.isArray(props) || typeof props !== "object") {
|
||||
content = props; props = {};
|
||||
}
|
||||
const el = document.createElement(tag), _sanitize = (key, val) => (key === 'src' || key === 'href') && String(val).toLowerCase().includes('javascript:') ? '#' : val;
|
||||
el._cleanups = new Set();
|
||||
|
||||
for (let [key, val] of Object.entries(props)) {
|
||||
if (key === "ref") { (typeof val === "function" ? val(el) : (val.current = el)); continue; }
|
||||
const isSignal = typeof val === "function", isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName), isBindAttr = (key === "value" || key === "checked");
|
||||
|
||||
if (isInput && isBindAttr && isSignal) {
|
||||
el._cleanups.add($watch(() => { const currentVal = val(); if (el[key] !== currentVal) el[key] = currentVal; }));
|
||||
const eventName = key === "checked" ? "change" : "input", handler = (event) => val(event.target[key]);
|
||||
el.addEventListener(eventName, handler);
|
||||
el._cleanups.add(() => el.removeEventListener(eventName, handler));
|
||||
} else if (key.startsWith("on")) {
|
||||
const eventName = key.slice(2).toLowerCase().split(".")[0], handler = (event) => val(event);
|
||||
el.addEventListener(eventName, handler);
|
||||
el._cleanups.add(() => el.removeEventListener(eventName, handler));
|
||||
} else if (isSignal) {
|
||||
el._cleanups.add($watch(() => {
|
||||
const currentVal = _sanitize(key, val());
|
||||
if (key === "class") el.className = currentVal || "";
|
||||
else currentVal == null ? el.removeAttribute(key) : el.setAttribute(key, currentVal);
|
||||
}));
|
||||
} else {
|
||||
el.setAttribute(key, _sanitize(key, val));
|
||||
}
|
||||
}
|
||||
|
||||
const append = (child) => {
|
||||
if (Array.isArray(child)) return child.forEach(append);
|
||||
if (typeof child === "function") {
|
||||
const marker = document.createTextNode("");
|
||||
el.appendChild(marker);
|
||||
let nodes = [];
|
||||
el._cleanups.add($watch(() => {
|
||||
const result = child(), nextNodes = (Array.isArray(result) ? result : [result]).map((item) =>
|
||||
item?._isRuntime ? item.container : item instanceof Node ? item : document.createTextNode(item ?? "")
|
||||
);
|
||||
nodes.forEach((node) => { sweep(node); node.remove(); });
|
||||
nextNodes.forEach((node) => marker.parentNode?.insertBefore(node, marker));
|
||||
nodes = nextNodes;
|
||||
}));
|
||||
} else el.appendChild(child instanceof Node ? child : document.createTextNode(child ?? ""));
|
||||
};
|
||||
append(content);
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conditional rendering component.
|
||||
* @param {Function|boolean} condition - Reactive signal or boolean value.
|
||||
* @param {Function|HTMLElement} thenVal - Content to show if true.
|
||||
* @param {Function|HTMLElement} [otherwiseVal] - Content to show if false (optional).
|
||||
* @returns {HTMLElement} A reactive container (display: contents).
|
||||
*/
|
||||
|
||||
export const $if = (condition, thenVal, otherwiseVal = null) => {
|
||||
const marker = document.createTextNode("");
|
||||
const container = $html("div", { style: "display:contents" }, [marker]);
|
||||
let current = null, last = null;
|
||||
$watch(() => {
|
||||
const state = !!(typeof condition === "function" ? condition() : condition);
|
||||
if (state !== last) {
|
||||
last = state;
|
||||
if (current) current.destroy();
|
||||
const branch = state ? thenVal : otherwiseVal;
|
||||
if (branch) {
|
||||
current = _view(() => typeof branch === "function" ? branch() : branch);
|
||||
container.insertBefore(current.container, marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
$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} A reactive container (display: contents).
|
||||
*/
|
||||
|
||||
export const $for = (source, render, keyFn) => {
|
||||
const marker = document.createTextNode("");
|
||||
const container = $html("div", { style: "display:contents" }, [marker]);
|
||||
const cache = new Map();
|
||||
$watch(() => {
|
||||
const items = (typeof source === "function" ? source() : source) || [];
|
||||
const newKeys = new Set();
|
||||
items.forEach((item, index) => {
|
||||
const key = keyFn(item, index);
|
||||
newKeys.add(key);
|
||||
let run = cache.get(key);
|
||||
if (!run) {
|
||||
run = _view(() => render(item, index));
|
||||
cache.set(key, run);
|
||||
}
|
||||
container.insertBefore(run.container, marker);
|
||||
});
|
||||
cache.forEach((run, key) => {
|
||||
if (!newKeys.has(key)) { run.destroy(); cache.delete(key); }
|
||||
});
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hash-based (#) routing system.
|
||||
* @param {Array<{path: string, component: Function}>} routes - Route definitions.
|
||||
* @returns {HTMLElement} The router outlet container.
|
||||
*/
|
||||
|
||||
export const $router = (routes) => {
|
||||
const sPath = $(window.location.hash.replace(/^#/, "") || "/");
|
||||
window.addEventListener("hashchange", () => sPath(window.location.hash.replace(/^#/, "") || "/"));
|
||||
const outlet = $html("div", { class: "router-outlet" });
|
||||
let current = null;
|
||||
|
||||
$watch([sPath], async () => {
|
||||
const path = sPath();
|
||||
const route = routes.find(r => {
|
||||
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 module = await route.component();
|
||||
const component = module?.default || module;
|
||||
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);
|
||||
|
||||
if (current) current.destroy();
|
||||
|
||||
current = _view(() => typeof component === "function" ? component(params) : component);
|
||||
outlet.appendChild(current.container);
|
||||
}
|
||||
});
|
||||
return outlet;
|
||||
};
|
||||
|
||||
$router.params = $({});
|
||||
$router.to = (path) => (window.location.hash = path.replace(/^#?\/?/, "#/"));
|
||||
$router.back = () => window.history.back();
|
||||
$router.path = () => window.location.hash.replace(/^#/, "") || "/";
|
||||
|
||||
/**
|
||||
* Mounts a component or node into a DOM target element.
|
||||
* It automatically handles the cleanup of any previously mounted SigPro instances
|
||||
* in that target to prevent memory leaks and duplicate renders.
|
||||
* * @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");
|
||||
* * // Mount using a direct element
|
||||
* const myApp = Div("Hello");
|
||||
* $mount(myApp, document.getElementById("app"));
|
||||
*/
|
||||
|
||||
export const $mount = (component, target) => {
|
||||
const el = typeof target === "string" ? document.querySelector(target) : target;
|
||||
if (!el) return;
|
||||
if (MOUNTED_NODES.has(el)) MOUNTED_NODES.get(el).destroy();
|
||||
const instance = _view(typeof component === "function" ? component : () => component);
|
||||
el.replaceChildren(instance.container);
|
||||
MOUNTED_NODES.set(el, instance);
|
||||
return instance;
|
||||
};
|
||||
|
||||
/** GLOBAL CORE REGISTRY */
|
||||
if (typeof window !== "undefined") {
|
||||
const SigPro = { $, $watch, $html, $if, $for, $router, $mount };
|
||||
|
||||
Object.keys(SigPro).forEach(key => { window[key] = SigPro[key] });
|
||||
|
||||
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(window, helperName, {
|
||||
value: (props, content) => $html(tagName, props, content),
|
||||
writable: false,
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
});
|
||||
});
|
||||
|
||||
window.SigPro = Object.freeze(SigPro);
|
||||
}
|
||||
|
||||
export default { $, $watch, $html, $if, $for, $router, $mount };
|
||||
452
sigpro/sigpro.js
452
sigpro/sigpro.js
@@ -1,452 +0,0 @@
|
||||
/**
|
||||
* SigPro Core
|
||||
*/
|
||||
(() => {
|
||||
|
||||
let activeEffect = null;
|
||||
let currentOwner = null;
|
||||
const effectQueue = new Set();
|
||||
let isFlushing = false;
|
||||
const MOUNTED_NODES = new WeakMap();
|
||||
|
||||
/** flush */
|
||||
const flush = () => {
|
||||
if (isFlushing) return;
|
||||
isFlushing = true;
|
||||
while (effectQueue.size > 0) {
|
||||
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();
|
||||
}
|
||||
isFlushing = false;
|
||||
};
|
||||
|
||||
/** track */
|
||||
const track = (subs) => {
|
||||
if (activeEffect && !activeEffect._deleted) {
|
||||
subs.add(activeEffect);
|
||||
activeEffect._deps.add(subs);
|
||||
}
|
||||
};
|
||||
|
||||
/** trigger */
|
||||
const trigger = (subs) => {
|
||||
for (const eff of subs) {
|
||||
if (eff === activeEffect || eff._deleted) continue;
|
||||
if (eff._isComputed) {
|
||||
eff.markDirty();
|
||||
if (eff._subs) trigger(eff._subs);
|
||||
} else {
|
||||
effectQueue.add(eff);
|
||||
}
|
||||
}
|
||||
if (!isFlushing) queueMicrotask(flush);
|
||||
};
|
||||
|
||||
/** sweep */
|
||||
const sweep = (node) => {
|
||||
if (node._cleanups) {
|
||||
node._cleanups.forEach((f) => f());
|
||||
node._cleanups.clear();
|
||||
}
|
||||
node.childNodes?.forEach(sweep);
|
||||
};
|
||||
|
||||
/** _view */
|
||||
const _view = (fn) => {
|
||||
const cleanups = new Set();
|
||||
const prev = currentOwner;
|
||||
const container = document.createElement("div");
|
||||
container.style.display = "contents";
|
||||
currentOwner = { cleanups };
|
||||
try {
|
||||
const res = fn({ onCleanup: (f) => cleanups.add(f) });
|
||||
const process = (n) => {
|
||||
if (!n) return;
|
||||
if (n._isRuntime) {
|
||||
cleanups.add(n.destroy);
|
||||
container.appendChild(n.container);
|
||||
} else if (Array.isArray(n)) n.forEach(process);
|
||||
else container.appendChild(n instanceof Node ? n : document.createTextNode(String(n)));
|
||||
};
|
||||
process(res);
|
||||
} finally { currentOwner = prev; }
|
||||
return {
|
||||
_isRuntime: true,
|
||||
container,
|
||||
destroy: () => {
|
||||
cleanups.forEach((f) => f());
|
||||
sweep(container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* const count = $(0); // Simple signal
|
||||
* const double = $(() => count() * 2); // Computed signal
|
||||
* const name = $("John", "user-name"); // Persisted signal
|
||||
*/
|
||||
|
||||
const $ = (initial, key = null) => {
|
||||
if (typeof initial === "function") {
|
||||
const subs = new Set();
|
||||
let cached, dirty = true;
|
||||
const effect = () => {
|
||||
if (effect._deleted) return;
|
||||
effect._deps.forEach((s) => s.delete(effect));
|
||||
effect._deps.clear();
|
||||
const prev = activeEffect;
|
||||
activeEffect = effect;
|
||||
try {
|
||||
const val = initial();
|
||||
if (!Object.is(cached, val) || dirty) {
|
||||
cached = val;
|
||||
dirty = false;
|
||||
trigger(subs);
|
||||
}
|
||||
} finally { activeEffect = prev; }
|
||||
};
|
||||
effect._deps = new Set();
|
||||
effect._isComputed = true;
|
||||
effect._subs = subs;
|
||||
effect._deleted = false;
|
||||
effect.markDirty = () => (dirty = true);
|
||||
effect.stop = () => {
|
||||
effect._deleted = true;
|
||||
effect._deps.forEach((s) => s.delete(effect));
|
||||
subs.clear();
|
||||
};
|
||||
if (currentOwner) currentOwner.cleanups.add(effect.stop);
|
||||
return () => { if (dirty) effect(); track(subs); return cached; };
|
||||
}
|
||||
|
||||
let value = initial;
|
||||
if (key) {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved !== null) value = JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.warn("SigPro: LocalStorage locked", e);
|
||||
}
|
||||
}
|
||||
const subs = new Set();
|
||||
return (...args) => {
|
||||
if (args.length) {
|
||||
const next = typeof args[0] === "function" ? args[0](value) : args[0];
|
||||
if (!Object.is(value, next)) {
|
||||
value = next;
|
||||
if (key) localStorage.setItem(key, JSON.stringify(value));
|
||||
trigger(subs);
|
||||
}
|
||||
}
|
||||
track(subs);
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* $watch(() => console.log("Count is:", count()));
|
||||
* $watch([count], () => console.log("Only runs when count changes"));
|
||||
*/
|
||||
|
||||
const $watch = (target, fn) => {
|
||||
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 = () => {
|
||||
if (runner._deleted) return;
|
||||
runner._deps.forEach((s) => s.delete(runner));
|
||||
runner._deps.clear();
|
||||
runner._cleanups.forEach((c) => c());
|
||||
runner._cleanups.clear();
|
||||
|
||||
const prevEffect = activeEffect;
|
||||
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._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();
|
||||
return runner.stop;
|
||||
};
|
||||
|
||||
/**
|
||||
* DOM element rendering engine with built-in reactivity.
|
||||
* @param {string} tag - HTML tag name (e.g., 'div', 'span').
|
||||
* @param {Object} [props] - Attributes, events (onEvent), or two-way bindings (value, checked).
|
||||
* @param {Array|any} [content] - Children: text, other nodes, or reactive signals.
|
||||
* @returns {HTMLElement} The configured reactive DOM element.
|
||||
*/
|
||||
|
||||
const $html = (tag, props = {}, content = []) => {
|
||||
if (props instanceof Node || Array.isArray(props) || typeof props !== "object") {
|
||||
content = props; props = {};
|
||||
}
|
||||
const el = document.createElement(tag);
|
||||
el._cleanups = new Set();
|
||||
|
||||
for (let [k, v] of Object.entries(props)) {
|
||||
if (k === "ref") {
|
||||
typeof v === "function" ? v(el) : (v.current = el);
|
||||
continue;
|
||||
}
|
||||
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(() => {
|
||||
const val = v();
|
||||
if (k === "class") el.className = val || "";
|
||||
else val == null ? el.removeAttribute(k) : el.setAttribute(k, val);
|
||||
}));
|
||||
} else {
|
||||
el.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
const append = (c) => {
|
||||
if (Array.isArray(c)) return c.forEach(append);
|
||||
if (typeof c === "function") {
|
||||
const marker = document.createTextNode("");
|
||||
el.appendChild(marker);
|
||||
let nodes = [];
|
||||
el._cleanups.add($watch(() => {
|
||||
const res = c();
|
||||
const next = (Array.isArray(res) ? res : [res]).map((i) =>
|
||||
i?._isRuntime ? i.container : i instanceof Node ? i : document.createTextNode(i ?? "")
|
||||
);
|
||||
nodes.forEach((n) => { sweep(n); n.remove(); });
|
||||
next.forEach((n) => marker.parentNode?.insertBefore(n, marker));
|
||||
nodes = next;
|
||||
}));
|
||||
} else el.appendChild(c instanceof Node ? c : document.createTextNode(c ?? ""));
|
||||
};
|
||||
append(content);
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conditional rendering component.
|
||||
* @param {Function|boolean} condition - Reactive signal or boolean value.
|
||||
* @param {Function|HTMLElement} thenVal - Content to show if true.
|
||||
* @param {Function|HTMLElement} [otherwiseVal] - Content to show if false (optional).
|
||||
* @returns {HTMLElement} A reactive container (display: contents).
|
||||
*/
|
||||
|
||||
const $if = (condition, thenVal, otherwiseVal = null) => {
|
||||
const marker = document.createTextNode("");
|
||||
const container = $html("div", { style: "display:contents" }, [marker]);
|
||||
let current = null, last = null;
|
||||
$watch(() => {
|
||||
const state = !!(typeof condition === "function" ? condition() : condition);
|
||||
if (state !== last) {
|
||||
last = state;
|
||||
if (current) current.destroy();
|
||||
const branch = state ? thenVal : otherwiseVal;
|
||||
if (branch) {
|
||||
current = _view(() => typeof branch === "function" ? branch() : branch);
|
||||
container.insertBefore(current.container, marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
$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} A reactive container (display: contents).
|
||||
*/
|
||||
|
||||
const $for = (source, render, keyFn) => {
|
||||
const marker = document.createTextNode("");
|
||||
const container = $html("div", { style: "display:contents" }, [marker]);
|
||||
const cache = new Map();
|
||||
$watch(() => {
|
||||
const items = (typeof source === "function" ? source() : source) || [];
|
||||
const newKeys = new Set();
|
||||
items.forEach((item, index) => {
|
||||
const key = keyFn(item, index);
|
||||
newKeys.add(key);
|
||||
let run = cache.get(key);
|
||||
if (!run) {
|
||||
run = _view(() => render(item, index));
|
||||
cache.set(key, run);
|
||||
}
|
||||
container.insertBefore(run.container, marker);
|
||||
});
|
||||
cache.forEach((run, key) => {
|
||||
if (!newKeys.has(key)) { run.destroy(); cache.delete(key); }
|
||||
});
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hash-based (#) routing system.
|
||||
* @param {Array<{path: string, component: Function}>} routes - Route definitions.
|
||||
* @returns {HTMLElement} The router outlet container.
|
||||
*/
|
||||
|
||||
const $router = (routes) => {
|
||||
const sPath = $(window.location.hash.replace(/^#/, "") || "/");
|
||||
window.addEventListener("hashchange", () => sPath(window.location.hash.replace(/^#/, "") || "/"));
|
||||
const outlet = Div({ class: "router-outlet" });
|
||||
let current = null;
|
||||
|
||||
$watch([sPath], () => {
|
||||
if (current) current.destroy();
|
||||
const path = sPath();
|
||||
const route = routes.find(r => {
|
||||
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;
|
||||
};
|
||||
|
||||
$router.params = $({});
|
||||
$router.to = (path) => (window.location.hash = path.replace(/^#?\/?/, "#/"));
|
||||
$router.back = () => window.history.back();
|
||||
$router.path = () => window.location.hash.replace(/^#/, "") || "/";
|
||||
|
||||
/**
|
||||
* Mounts a component or node into a DOM target element.
|
||||
* It automatically handles the cleanup of any previously mounted SigPro instances
|
||||
* in that target to prevent memory leaks and duplicate renders.
|
||||
* * @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");
|
||||
* * // Mount using a direct element
|
||||
* const myApp = Div("Hello");
|
||||
* $mount(myApp, document.getElementById("app"));
|
||||
*/
|
||||
|
||||
const $mount = (component, target) => {
|
||||
const el = typeof target === "string" ? document.querySelector(target) : target;
|
||||
if (!el) return;
|
||||
if (MOUNTED_NODES.has(el)) MOUNTED_NODES.get(el).destroy();
|
||||
const instance = _view(typeof component === "function" ? component : () => component);
|
||||
el.replaceChildren(instance.container);
|
||||
MOUNTED_NODES.set(el, instance);
|
||||
return instance;
|
||||
};
|
||||
|
||||
/* * Global API & DOM Tag Helpers Injection
|
||||
* ------------------------------------------------
|
||||
* This block exposes the SigPro core API ($, $if, etc.) and auto-generates
|
||||
* PascalCase helpers (Div, Span, Button) for all standard HTML tags.
|
||||
* * - Uses Object.defineProperty to prevent accidental overwriting (read-only).
|
||||
* - Enables a "Zero-Import" developer experience by attaching everything to 'window'.
|
||||
* - Maps every helper directly to the $html factory for instant DOM creation.
|
||||
*/
|
||||
|
||||
const core = { $, $if, $for, $watch, $mount, $router, $html };
|
||||
|
||||
for (const [name, fn] of Object.entries(core)) {
|
||||
Object.defineProperty(window, name, {
|
||||
value: fn,
|
||||
writable: false,
|
||||
configurable: false
|
||||
});
|
||||
}
|
||||
|
||||
/** HELPER TAGS */
|
||||
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);
|
||||
/** Protect Tags */
|
||||
Object.defineProperty(window, helperName, {
|
||||
value: (props, content) => $html(tagName, props, content),
|
||||
writable: false,
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
export const { $, $watch, $html, $if, $for, $router, $mount } = window;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
// /plugins/index.js
|
||||
export * from './sigpro-ui.js';
|
||||
export { UI } from './sigpro-ui.js';
|
||||
1370
ui/sigpro-ui.js
1370
ui/sigpro-ui.js
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,77 @@
|
||||
/**
|
||||
* SigPro Vite Plugin
|
||||
* SigPro Vite Plugin - File-based Routing
|
||||
* @module sigpro/vite
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import sigproRouter from './router.js';
|
||||
export default function sigproRouter() {
|
||||
const virtualModuleId = 'virtual:sigpro-routes';
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||
|
||||
/**
|
||||
* Vite plugin for SigPro file-based routing
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* // vite.config.js
|
||||
* import sigproRouter from 'sigpro/vite';
|
||||
*
|
||||
* export default {
|
||||
* plugins: [sigproRouter()]
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @param {import('./index.d.ts').SigProRouterOptions} [options] - Plugin configuration options
|
||||
* @returns {import('vite').Plugin} Vite plugin instance
|
||||
*/
|
||||
export default sigproRouter;
|
||||
// Helper para escanear archivos
|
||||
const getFiles = (dir) => {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { recursive: true })
|
||||
.filter(file => /\.(js|jsx)$/.test(file) && !path.basename(file).startsWith('_'))
|
||||
.map(file => path.resolve(dir, file));
|
||||
};
|
||||
|
||||
// Transformador de ruta de archivo a URL de router
|
||||
const pathToUrl = (pagesDir, filePath) => {
|
||||
let relative = path.relative(pagesDir, filePath)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\.(js|jsx)$/, '')
|
||||
.replace(/\/index$/, '')
|
||||
.replace(/^index$/, '');
|
||||
|
||||
return ('/' + relative)
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\[\.\.\.([^\]]+)\]/g, '*')
|
||||
.replace(/\[([^\]]+)\]/g, ':$1')
|
||||
.replace(/\/$/, '') || '/';
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'sigpro-router',
|
||||
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) return resolvedVirtualModuleId;
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id !== resolvedVirtualModuleId) return;
|
||||
|
||||
const root = process.cwd();
|
||||
const pagesDir = path.resolve(root, 'src/pages');
|
||||
|
||||
// Obtenemos y ordenamos archivos (rutas estáticas primero, luego dinámicas)
|
||||
const files = getFiles(pagesDir).sort((a, b) => {
|
||||
const urlA = pathToUrl(pagesDir, a);
|
||||
const urlB = pathToUrl(pagesDir, b);
|
||||
if (urlA.includes(':') && !urlB.includes(':')) return 1;
|
||||
if (!urlA.includes(':') && urlB.includes(':')) return -1;
|
||||
return urlB.length - urlA.length;
|
||||
});
|
||||
|
||||
let routeEntries = '';
|
||||
|
||||
files.forEach((fullPath) => {
|
||||
const urlPath = pathToUrl(pagesDir, fullPath);
|
||||
// Hacemos la ruta relativa al proyecto para que el import de Vite sea limpio
|
||||
const relativeImport = './' + path.relative(root, fullPath).replace(/\\/g, '/');
|
||||
|
||||
routeEntries += ` { path: '${urlPath}', component: async () => (await import('/${relativeImport}')).default },\n`;
|
||||
});
|
||||
|
||||
// Fallback 404 si no existe una ruta comodín
|
||||
if (!routeEntries.includes("path: '*'")) {
|
||||
routeEntries += ` { path: '*', component: () => document.createTextNode('404 - Not Found') },\n`;
|
||||
}
|
||||
|
||||
return `export const routes = [\n${routeEntries}];`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { sigproRouter };
|
||||
@@ -1,63 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export default function sigproRouter() {
|
||||
const virtualModuleId = 'virtual:sigpro-routes';
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||
|
||||
const getFiles = (dir) => {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { recursive: true })
|
||||
.filter(file => /\.(js|jsx)$/.test(file) && !path.basename(file).startsWith('_'))
|
||||
.map(file => path.resolve(dir, file));
|
||||
};
|
||||
|
||||
const pathToUrl = (pagesDir, filePath) => {
|
||||
let relative = path.relative(pagesDir, filePath)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\.(js|jsx)$/, '')
|
||||
.replace(/\/index$/, '')
|
||||
.replace(/^index$/, '');
|
||||
|
||||
let url = '/' + relative;
|
||||
|
||||
return url
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\[\.\.\.([^\]]+)\]/g, '*')
|
||||
.replace(/\[([^\]]+)\]/g, ':$1')
|
||||
.replace(/\/$/, '') || '/';
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'sigpro-router',
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) return resolvedVirtualModuleId;
|
||||
},
|
||||
load(id) {
|
||||
if (id !== resolvedVirtualModuleId) return;
|
||||
|
||||
const pagesDir = path.resolve(process.cwd(), 'src/pages');
|
||||
const files = getFiles(pagesDir).sort((a, b) => {
|
||||
const urlA = pathToUrl(pagesDir, a);
|
||||
const urlB = pathToUrl(pagesDir, b);
|
||||
if (urlA.includes(':') && !urlB.includes(':')) return 1;
|
||||
if (!urlA.includes(':') && urlB.includes(':')) return -1;
|
||||
return urlB.length - urlA.length;
|
||||
});
|
||||
|
||||
let routeEntries = '';
|
||||
|
||||
files.forEach((fullPath) => {
|
||||
const urlPath = pathToUrl(pagesDir, fullPath);
|
||||
const importPath = fullPath.replace(/\\/g, '/');
|
||||
routeEntries += ` { path: '${urlPath}', component: () => import('${importPath}') },\n`;
|
||||
});
|
||||
|
||||
if (!routeEntries.includes("path: '*'")) {
|
||||
routeEntries += ` { path: '*', component: () => span('404 - Not Found') },\n`;
|
||||
}
|
||||
|
||||
return `export const routes = [\n${routeEntries}];`;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user