new structure

This commit is contained in:
2026-03-20 01:11:32 +01:00
parent d24bad018e
commit 4b4eaa083b
76 changed files with 578 additions and 72 deletions

91
packages/sigpro/plugin.js Normal file
View File

@@ -0,0 +1,91 @@
// plugins/sigpro-plugin-router.js
import fs from 'fs';
import path from 'path';
export default function sigproRouter() {
const virtualModuleId = 'virtual:sigpro-routes';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
function getFiles(dir) {
let results = [];
if (!fs.existsSync(dir)) return results;
const list = fs.readdirSync(dir);
list.forEach(file => {
const fullPath = path.resolve(dir, file);
const stat = fs.statSync(fullPath);
if (stat && stat.isDirectory()) {
results = results.concat(getFiles(fullPath));
} else if (file.endsWith('.js') || file.endsWith('.jsx')) {
results.push(fullPath);
}
});
return results;
}
function filePathToUrl(relativePath) {
let url = relativePath.replace(/\\/g, '/').replace(/\.jsx?$/, '');
if (url === 'index') {
return '/';
}
if (url.endsWith('/index')) {
url = url.slice(0, -6);
}
url = url.replace(/\[([^\]]+)\]/g, ':$1');
let finalPath = '/' + url.toLowerCase();
return finalPath.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
}
return {
name: 'sigpro-router',
resolveId(id) {
if (id === virtualModuleId) return resolvedVirtualModuleId;
},
load(id) {
if (id === resolvedVirtualModuleId) {
const pagesDir = path.resolve(process.cwd(), 'src/pages');
let files = getFiles(pagesDir);
files = files.sort((a, b) => {
const aRel = path.relative(pagesDir, a).replace(/\\/g, '/');
const bRel = path.relative(pagesDir, b).replace(/\\/g, '/');
const aDynamic = aRel.includes('[') || aRel.includes(':');
const bDynamic = bRel.includes('[') || bRel.includes(':');
if (aDynamic !== bDynamic) return aDynamic ? 1 : -1;
return bRel.length - aRel.length;
});
let imports = '';
let routeArray = 'export const routes = [\n';
console.log('\n🚀 [SigPro Router] Routes generated:');
files.forEach((fullPath, i) => {
const importPath = fullPath.replace(/\\/g, '/');
const relativePath = path.relative(pagesDir, fullPath).replace(/\\/g, '/');
const varName = `Page_${i}`;
let urlPath = filePathToUrl(relativePath);
const isDynamic = urlPath.includes(':');
imports += `import ${varName} from '${importPath}';\n`;
console.log(` ${isDynamic ? '🔗' : '📄'} ${urlPath.padEnd(30)} -> ${relativePath}`);
routeArray += ` { path: '${urlPath}', component: ${varName} },\n`;
});
routeArray += '];';
return `${imports}\n${routeArray}`;
}
}
};
}

1
packages/sigpro/plugin.min.js vendored Normal file
View File

@@ -0,0 +1 @@
import fs from"fs";import path from"path";export default function sigproRouter(){const e="virtual:sigpro-routes",r="\0"+e;function t(e){let r=[];if(!fs.existsSync(e))return r;return fs.readdirSync(e).forEach((n=>{const s=path.resolve(e,n),o=fs.statSync(s);o&&o.isDirectory()?r=r.concat(t(s)):(n.endsWith(".js")||n.endsWith(".jsx"))&&r.push(s)})),r}return{name:"sigpro-router",resolveId(t){if(t===e)return r},load(e){if(e===r){const e=path.resolve(process.cwd(),"src/pages");let r=t(e);r=r.sort(((r,t)=>{const n=path.relative(e,r).replace(/\\/g,"/"),s=path.relative(e,t).replace(/\\/g,"/"),o=n.includes("[")||n.includes(":");return o!==(s.includes("[")||s.includes(":"))?o?1:-1:s.length-n.length}));let n="",s="export const routes = [\n";return r.forEach(((r,t)=>{const o=r.replace(/\\/g,"/"),c=`Page_${t}`;let a=function(e){let r=e.replace(/\\/g,"/").replace(/\.jsx?$/,"");return"index"===r?"/":(r.endsWith("/index")&&(r=r.slice(0,-6)),r=r.replace(/\[([^\]]+)\]/g,":$1"),("/"+r.toLowerCase()).replace(/\/+/g,"/").replace(/\/$/,"")||"/")}(path.relative(e,r).replace(/\\/g,"/"));a.includes(":");n+=`import ${c} from '${o}';\n`,s+=` { path: '${a}', component: ${c} },\n`})),s+="];",`${n}\n${s}`}}}}

631
packages/sigpro/sigpro.js Normal file
View File

@@ -0,0 +1,631 @@
// 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 };

1
packages/sigpro/sigpro.min.js vendored Normal file

File diff suppressed because one or more lines are too long