632 lines
19 KiB
JavaScript
632 lines
19 KiB
JavaScript
// Global state for tracking the current reactive effect
|
|
let activeEffect = null;
|
|
const effectQueue = new Set();
|
|
let isFlushScheduled = false;
|
|
let flushCount = 0;
|
|
|
|
const flushEffectQueue = () => {
|
|
isFlushScheduled = false;
|
|
flushCount++;
|
|
|
|
if (flushCount > 100) {
|
|
effectQueue.clear();
|
|
flushCount = 0;
|
|
throw new Error("SigPro: Infinite reactive loop detected.");
|
|
}
|
|
|
|
try {
|
|
const effects = Array.from(effectQueue);
|
|
effectQueue.clear();
|
|
for (const effect of effects) effect.run();
|
|
} catch (error) {
|
|
console.error("SigPro Flush Error:", error);
|
|
} finally {
|
|
setTimeout(() => {
|
|
flushCount = 0;
|
|
}, 0);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates a reactive signal
|
|
* @param {any} initialValue - Initial value or getter function
|
|
* @returns {Function} Signal getter/setter function
|
|
*/
|
|
const $ = (initialValue) => {
|
|
const subscribers = new Set();
|
|
let signal;
|
|
|
|
if (typeof initialValue === "function") {
|
|
let isDirty = true;
|
|
let cachedValue;
|
|
|
|
const computedEffect = {
|
|
dependencies: new Set(),
|
|
markDirty: () => {
|
|
if (!isDirty) {
|
|
isDirty = true;
|
|
subscribers.forEach((sub) => {
|
|
if (sub.markDirty) sub.markDirty();
|
|
effectQueue.add(sub);
|
|
});
|
|
if (!isFlushScheduled && effectQueue.size) {
|
|
isFlushScheduled = true;
|
|
queueMicrotask(flushEffectQueue);
|
|
}
|
|
}
|
|
},
|
|
run: () => {
|
|
computedEffect.dependencies.forEach((dep) => dep.delete(computedEffect));
|
|
computedEffect.dependencies.clear();
|
|
const prev = activeEffect;
|
|
activeEffect = computedEffect;
|
|
try {
|
|
cachedValue = initialValue();
|
|
} finally {
|
|
activeEffect = prev;
|
|
isDirty = false;
|
|
}
|
|
},
|
|
};
|
|
|
|
signal = () => {
|
|
if (activeEffect) {
|
|
subscribers.add(activeEffect);
|
|
activeEffect.dependencies.add(subscribers);
|
|
}
|
|
if (isDirty) computedEffect.run();
|
|
return cachedValue;
|
|
};
|
|
} else {
|
|
signal = (...args) => {
|
|
if (args.length) {
|
|
const next = typeof args[0] === "function" ? args[0](initialValue) : args[0];
|
|
if (!Object.is(initialValue, next)) {
|
|
initialValue = next;
|
|
subscribers.forEach((sub) => {
|
|
if (sub.markDirty) sub.markDirty();
|
|
effectQueue.add(sub);
|
|
});
|
|
if (!isFlushScheduled && effectQueue.size) {
|
|
isFlushScheduled = true;
|
|
queueMicrotask(flushEffectQueue);
|
|
}
|
|
}
|
|
}
|
|
if (activeEffect) {
|
|
subscribers.add(activeEffect);
|
|
activeEffect.dependencies.add(subscribers);
|
|
}
|
|
return initialValue;
|
|
};
|
|
}
|
|
return signal;
|
|
};
|
|
|
|
let currentPageCleanups = null;
|
|
|
|
/**
|
|
* Creates a reactive effect that runs when dependencies change
|
|
* @param {Function} effectFn - The effect function to run
|
|
* @returns {Function} Cleanup function to stop the effect
|
|
*/
|
|
const $e = (effectFn) => {
|
|
const effect = {
|
|
dependencies: new Set(),
|
|
cleanupHandlers: new Set(),
|
|
run() {
|
|
this.cleanupHandlers.forEach((h) => h());
|
|
this.cleanupHandlers.clear();
|
|
this.dependencies.forEach((dep) => dep.delete(this));
|
|
this.dependencies.clear();
|
|
|
|
const prev = activeEffect;
|
|
activeEffect = this;
|
|
try {
|
|
const res = effectFn();
|
|
if (typeof res === "function") this.cleanupHandlers.add(res);
|
|
} finally {
|
|
activeEffect = prev;
|
|
}
|
|
},
|
|
stop() {
|
|
this.cleanupHandlers.forEach((h) => h());
|
|
this.dependencies.forEach((dep) => dep.delete(this));
|
|
},
|
|
};
|
|
|
|
if (currentPageCleanups) currentPageCleanups.push(() => effect.stop());
|
|
if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop());
|
|
|
|
effect.run();
|
|
return () => effect.stop();
|
|
};
|
|
|
|
/**
|
|
* Persistent signal with localStorage
|
|
* @param {string} key - Storage key
|
|
* @param {any} initialValue - Default value if none stored
|
|
* @param {Storage} [storage=localStorage] - Storage type (localStorage/sessionStorage)
|
|
* @returns {Function} Signal that persists to storage
|
|
*/
|
|
const $s = (key, initialValue, storage = localStorage) => {
|
|
let initial;
|
|
try {
|
|
const saved = storage.getItem(key);
|
|
if (saved !== null) {
|
|
initial = JSON.parse(saved);
|
|
} else {
|
|
initial = initialValue;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Error reading ${key} from storage:`, e);
|
|
initial = initialValue;
|
|
storage.removeItem(key);
|
|
}
|
|
|
|
const signal = $(initial);
|
|
|
|
$e(() => {
|
|
try {
|
|
const value = signal();
|
|
if (value === undefined || value === null) {
|
|
storage.removeItem(key);
|
|
} else {
|
|
storage.setItem(key, JSON.stringify(value));
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Error saving ${key} to storage:`, e);
|
|
}
|
|
});
|
|
|
|
return signal;
|
|
};
|
|
|
|
/**
|
|
* Tagged template literal for creating reactive HTML
|
|
* @param {string[]} strings - Template strings
|
|
* @param {...any} values - Dynamic values
|
|
* @returns {DocumentFragment} Reactive document fragment
|
|
* @see {@link https://developer.mozilla.org/es/docs/Glossary/Cross-site_scripting}
|
|
*/
|
|
const html = (strings, ...values) => {
|
|
const templateCache = html._templateCache ?? (html._templateCache = new WeakMap());
|
|
|
|
const getNodeByPath = (root, path) => path.reduce((node, index) => node?.childNodes?.[index], root);
|
|
|
|
const applyTextContent = (node, values) => {
|
|
const parts = node.textContent.split("{{part}}");
|
|
const parent = node.parentNode;
|
|
let valueIndex = 0;
|
|
|
|
parts.forEach((part, index) => {
|
|
if (part) parent.insertBefore(document.createTextNode(part), node);
|
|
if (index < parts.length - 1) {
|
|
const currentValue = values[valueIndex++];
|
|
const startMarker = document.createComment("s");
|
|
const endMarker = document.createComment("e");
|
|
parent.insertBefore(startMarker, node);
|
|
parent.insertBefore(endMarker, node);
|
|
|
|
if (typeof currentValue === "function") {
|
|
let lastResult;
|
|
$e(() => {
|
|
const result = currentValue();
|
|
if (result === lastResult) return;
|
|
lastResult = result;
|
|
updateContent(result);
|
|
});
|
|
} else {
|
|
updateContent(currentValue);
|
|
}
|
|
|
|
function updateContent(result) {
|
|
if (typeof result !== "object" && !Array.isArray(result)) {
|
|
const textNode = startMarker.nextSibling;
|
|
const safeText = String(result ?? "");
|
|
|
|
if (textNode !== endMarker && textNode?.nodeType === 3) {
|
|
textNode.textContent = safeText;
|
|
} else {
|
|
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
|
|
parent.insertBefore(document.createTextNode(safeText), endMarker);
|
|
}
|
|
} else {
|
|
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
|
|
|
|
const items = Array.isArray(result) ? result : [result];
|
|
const fragment = document.createDocumentFragment();
|
|
items.forEach((item) => {
|
|
if (item == null || item === false) return;
|
|
const nodeItem = item instanceof Node ? item : document.createTextNode(item);
|
|
fragment.appendChild(nodeItem);
|
|
});
|
|
parent.insertBefore(fragment, endMarker);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
node.remove();
|
|
};
|
|
|
|
let cachedTemplate = templateCache.get(strings);
|
|
if (!cachedTemplate) {
|
|
const template = document.createElement("template");
|
|
template.innerHTML = strings.join("{{part}}");
|
|
|
|
const dynamicNodes = [];
|
|
const treeWalker = document.createTreeWalker(template.content, 133);
|
|
|
|
const getNodePath = (node) => {
|
|
const path = [];
|
|
while (node && node !== template.content) {
|
|
let index = 0;
|
|
for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) index++;
|
|
path.push(index);
|
|
node = node.parentNode;
|
|
}
|
|
return path.reverse();
|
|
};
|
|
|
|
let currentNode;
|
|
while ((currentNode = treeWalker.nextNode())) {
|
|
let isDynamic = false;
|
|
const nodeInfo = {
|
|
type: currentNode.nodeType,
|
|
path: getNodePath(currentNode),
|
|
parts: [],
|
|
};
|
|
|
|
if (currentNode.nodeType === 1) {
|
|
for (let i = 0; i < currentNode.attributes.length; i++) {
|
|
const attribute = currentNode.attributes[i];
|
|
if (attribute.value.includes("{{part}}")) {
|
|
nodeInfo.parts.push({ name: attribute.name });
|
|
isDynamic = true;
|
|
}
|
|
}
|
|
} else if (currentNode.nodeType === 3 && currentNode.textContent.includes("{{part}}")) {
|
|
isDynamic = true;
|
|
}
|
|
|
|
if (isDynamic) dynamicNodes.push(nodeInfo);
|
|
}
|
|
|
|
templateCache.set(strings, (cachedTemplate = { template, dynamicNodes }));
|
|
}
|
|
|
|
const fragment = cachedTemplate.template.content.cloneNode(true);
|
|
let valueIndex = 0;
|
|
|
|
const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({
|
|
node: getNodeByPath(fragment, nodeInfo.path),
|
|
info: nodeInfo,
|
|
}));
|
|
|
|
targets.forEach(({ node, info }) => {
|
|
if (!node) return;
|
|
|
|
if (info.type === 1) {
|
|
info.parts.forEach((part) => {
|
|
const currentValue = values[valueIndex++];
|
|
const attributeName = part.name;
|
|
const firstChar = attributeName[0];
|
|
|
|
if (firstChar === "@") {
|
|
const [eventName, ...modifiers] = attributeName.slice(1).split(".");
|
|
|
|
const handlerWrapper = (e) => {
|
|
if (modifiers.includes("prevent")) e.preventDefault();
|
|
if (modifiers.includes("stop")) e.stopPropagation();
|
|
if (modifiers.includes("self") && e.target !== node) return;
|
|
|
|
if (modifiers.some((m) => m.startsWith("debounce"))) {
|
|
const ms = modifiers.find((m) => m.startsWith("debounce"))?.split(":")[1] || 300;
|
|
clearTimeout(node._debounceTimer);
|
|
node._debounceTimer = setTimeout(() => currentValue(e), ms);
|
|
return;
|
|
}
|
|
|
|
if (modifiers.includes("once")) {
|
|
node.removeEventListener(eventName, handlerWrapper);
|
|
}
|
|
|
|
currentValue(e);
|
|
};
|
|
|
|
node.addEventListener(eventName, handlerWrapper, {
|
|
passive: modifiers.includes("passive"),
|
|
capture: modifiers.includes("capture"),
|
|
});
|
|
} else if (firstChar === ":") {
|
|
const propertyName = attributeName.slice(1);
|
|
const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input";
|
|
|
|
if (typeof currentValue === "function") {
|
|
$e(() => {
|
|
const value = currentValue();
|
|
if (node[propertyName] !== value) node[propertyName] = value;
|
|
});
|
|
} else {
|
|
node[propertyName] = currentValue;
|
|
}
|
|
|
|
node.addEventListener(eventType, () => {
|
|
const value = eventType === "change" ? node.checked : node.value;
|
|
if (typeof currentValue === "function") currentValue(value);
|
|
});
|
|
} else if (firstChar === "?") {
|
|
const attrName = attributeName.slice(1);
|
|
|
|
if (typeof currentValue === "function") {
|
|
$e(() => {
|
|
const result = currentValue();
|
|
node.toggleAttribute(attrName, !!result);
|
|
});
|
|
} else {
|
|
node.toggleAttribute(attrName, !!currentValue);
|
|
}
|
|
} else if (firstChar === ".") {
|
|
const propertyName = attributeName.slice(1);
|
|
|
|
if (typeof currentValue === "function") {
|
|
$e(() => {
|
|
const result = currentValue();
|
|
node[propertyName] = result;
|
|
if (result != null && typeof result !== "object" && typeof result !== "boolean") {
|
|
node.setAttribute(propertyName, result);
|
|
}
|
|
});
|
|
} else {
|
|
node[propertyName] = currentValue;
|
|
if (currentValue != null && typeof currentValue !== "object" && typeof currentValue !== "boolean") {
|
|
node.setAttribute(propertyName, currentValue);
|
|
}
|
|
}
|
|
} else {
|
|
if (typeof currentValue === "function") {
|
|
$e(() => node.setAttribute(attributeName, currentValue()));
|
|
} else {
|
|
node.setAttribute(attributeName, currentValue);
|
|
}
|
|
}
|
|
});
|
|
} else if (info.type === 3) {
|
|
const placeholderCount = node.textContent.split("{{part}}").length - 1;
|
|
applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount));
|
|
valueIndex += placeholderCount;
|
|
}
|
|
});
|
|
|
|
return fragment;
|
|
};
|
|
|
|
/**
|
|
* Creates a page with automatic cleanup
|
|
* @param {Function} setupFunction - Page setup function that receives props
|
|
* @returns {Function} A function that creates page instances with props
|
|
*/
|
|
const $p = (setupFunction) => {
|
|
const tagName = "page-" + Math.random().toString(36).substring(2, 9);
|
|
|
|
customElements.define(
|
|
tagName,
|
|
class extends HTMLElement {
|
|
connectedCallback() {
|
|
this.style.display = "contents";
|
|
this._cleanups = [];
|
|
currentPageCleanups = this._cleanups;
|
|
|
|
try {
|
|
const result = setupFunction({
|
|
params: JSON.parse(this.getAttribute("params") || "{}"),
|
|
onUnmount: (fn) => this._cleanups.push(fn),
|
|
});
|
|
this.appendChild(result instanceof Node ? result : document.createTextNode(String(result)));
|
|
} finally {
|
|
currentPageCleanups = null;
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._cleanups.forEach((fn) => fn());
|
|
this._cleanups = [];
|
|
this.innerHTML = "";
|
|
}
|
|
},
|
|
);
|
|
|
|
return (props = {}) => {
|
|
const el = document.createElement(tagName);
|
|
el.setAttribute("params", JSON.stringify(props));
|
|
return el;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Creates a custom web component with reactive properties
|
|
* @param {string} tagName - Custom element tag name
|
|
* @param {Function} setupFunction - Component setup function
|
|
* @param {string[]} observedAttributes - Array of observed attributes
|
|
* @param {boolean} useShadowDOM - Enable Shadow DOM (default: false)
|
|
*/
|
|
const $c = (tagName, setupFunction, observedAttributes = [], useShadowDOM = false) => {
|
|
if (customElements.get(tagName)) return;
|
|
|
|
customElements.define(
|
|
tagName,
|
|
class extends HTMLElement {
|
|
static get observedAttributes() {
|
|
return observedAttributes;
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._propertySignals = {};
|
|
this.cleanupFunctions = [];
|
|
|
|
if (useShadowDOM) {
|
|
this._root = this.attachShadow({ mode: "open" });
|
|
} else {
|
|
this._root = this;
|
|
}
|
|
|
|
observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined)));
|
|
}
|
|
|
|
connectedCallback() {
|
|
const frozenChildren = [...this.childNodes];
|
|
this._root.innerHTML = "";
|
|
|
|
observedAttributes.forEach((attr) => {
|
|
const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr);
|
|
|
|
Object.defineProperty(this, attr, {
|
|
get: () => this._propertySignals[attr](),
|
|
set: (value) => {
|
|
const processedValue = value === "false" ? false : value === "" && attr !== "value" ? true : value;
|
|
this._propertySignals[attr](processedValue);
|
|
},
|
|
configurable: true,
|
|
});
|
|
|
|
if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue;
|
|
});
|
|
|
|
const context = {
|
|
select: (selector) => this._root.querySelector(selector),
|
|
selectAll: (selector) => this._root.querySelectorAll(selector),
|
|
slot: (name) =>
|
|
frozenChildren.filter((node) => {
|
|
const slotName = node.nodeType === 1 ? node.getAttribute("slot") : null;
|
|
return name ? slotName === name : !slotName;
|
|
}),
|
|
emit: (name, detail) => this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })),
|
|
host: this,
|
|
root: this._root,
|
|
onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn),
|
|
};
|
|
|
|
const result = setupFunction(this._propertySignals, context);
|
|
if (result instanceof Node) this._root.appendChild(result);
|
|
}
|
|
|
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
if (this[name] !== newValue) this[name] = newValue;
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.cleanupFunctions.forEach((cleanupFn) => cleanupFn());
|
|
this.cleanupFunctions = [];
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Ultra-simple fetch wrapper with optional loading signal
|
|
* @param {string} url - Endpoint URL
|
|
* @param {Object} data - Data to send (automatically JSON.stringify'd)
|
|
* @param {Function} [loading] - Optional signal function to track loading state
|
|
* @returns {Promise<Object|null>} Parsed JSON response or null on error
|
|
*/
|
|
const $f = async (url, data, loading) => {
|
|
if (loading) loading(true);
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
const text = await res.text();
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
console.warn("Invalid JSON response");
|
|
return null;
|
|
}
|
|
} catch (e) {
|
|
return null;
|
|
} finally {
|
|
if (loading) loading(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates a router for hash-based navigation
|
|
* @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations
|
|
* @returns {HTMLDivElement} Router container element
|
|
*/
|
|
const $r = (routes) => {
|
|
const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/";
|
|
const container = document.createElement("div");
|
|
container.style.display = "contents";
|
|
|
|
const matchRoute = (path, routePath) => {
|
|
if (!routePath.includes(":")) {
|
|
return routePath === path ? {} : null;
|
|
}
|
|
|
|
const parts = routePath.split("/");
|
|
const pathParts = path.split("/");
|
|
|
|
if (parts.length !== pathParts.length) return null;
|
|
|
|
const params = {};
|
|
for (let i = 0; i < parts.length; i++) {
|
|
if (parts[i].startsWith(":")) {
|
|
params[parts[i].slice(1)] = pathParts[i];
|
|
} else if (parts[i] !== pathParts[i]) {
|
|
return null;
|
|
}
|
|
}
|
|
return params;
|
|
};
|
|
|
|
const render = () => {
|
|
const path = getCurrentPath();
|
|
let matchedRoute = null;
|
|
let routeParams = {};
|
|
|
|
for (const route of routes) {
|
|
const params = matchRoute(path, route.path);
|
|
if (params !== null) {
|
|
matchedRoute = route;
|
|
routeParams = params;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const view = matchedRoute ? matchedRoute.component(routeParams) : Object.assign(document.createElement("h1"), { textContent: "404" });
|
|
|
|
container.replaceChildren(view instanceof Node ? view : document.createTextNode(String(view ?? "")));
|
|
};
|
|
|
|
window.addEventListener("hashchange", render);
|
|
render();
|
|
return container;
|
|
};
|
|
|
|
$r.go = (path) => {
|
|
const targetPath = path.startsWith("/") ? path : `/${path}`;
|
|
if (window.location.hash !== `#${targetPath}`) {
|
|
window.location.hash = targetPath;
|
|
}
|
|
};
|
|
|
|
/* Can customize the name of your functions */
|
|
|
|
$.effect = $e;
|
|
$.page = $p;
|
|
$.component = $c;
|
|
$.fetch = $f;
|
|
$.router = $r;
|
|
$.storage = $s;
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.$ = $;
|
|
}
|
|
export { $, html };
|