diff --git a/sigwork.js b/sigwork.js index 2f8f52d..048a835 100644 --- a/sigwork.js +++ b/sigwork.js @@ -1,587 +1,258 @@ -const DANGEROUS_PROTOCOLS = /^(javascript|data|vbscript):/i; -const DANGEROUS_ATTRIBUTES = /^on/i; +// --------------------------- +// SigPro v2 +// --------------------------- -const sanitizeUrl = (url) => { - const str = String(url ?? '').trim().toLowerCase(); - if (DANGEROUS_PROTOCOLS.test(str)) return '#'; - return str; -}; - -const sanitizeAttribute = (name, value) => { - if (value == null) return null; - const strValue = String(value); - if (DANGEROUS_ATTRIBUTES.test(name)) { - console.warn(`[SigPro] XSS prevention: blocked attribute "${name}"`); - return null; - } - if (name === 'src' || name === 'href') { - return sanitizeUrl(strValue); - } - return strValue; -}; - -let activeEffect = null; +// --- Scheduler --- let isScheduled = false; const queue = new Set(); +const flushQueue = () => { + const runs = [...queue]; + queue.clear(); + runs.forEach(fn => fn()); + isScheduled = false; +}; -const tick = () => { - while (queue.size) { - const runs = [...queue]; - queue.clear(); - runs.forEach(fn => fn()); +// --- Core State & Ownership --- +let activeEffect = null; +let currentOwner = null; +let currentContext = null; +const nodeContexts = new WeakMap(); + +// --- Security --- +const DANGEROUS = /^(javascript|data|vbscript):/i; +const sanitize = v => DANGEROUS.test(String(v)) ? '#' : v; + +// --- Effect System --- +export const effect = (fn) => { + const owner = currentOwner; + const runner = () => { + cleanup(); + const prevOwner = currentOwner; + const prevEffect = activeEffect; + currentOwner = owner; + activeEffect = runner; + runner.cleanupFns = []; + try { fn(); } + finally { currentOwner = prevOwner; activeEffect = prevEffect; } + }; + const cleanup = () => { + runner.deps?.forEach(dep => dep.delete(runner)); + runner.cleanupFns?.forEach(f => f()); + runner.deps?.clear(); + }; + runner.deps = new Set(); + runner.cleanupFns = []; + owner?.cleanups.add(cleanup); + runner(); + return cleanup; +}; + +// --- Signals --- +export const signal = (value) => { + const subs = new Set(); + return { + _isSig: true, + get value() { + if (activeEffect) { subs.add(activeEffect); activeEffect.deps.add(subs); } + return value; + }, + set value(v) { + if (v === value) return; + value = v; + subs.forEach(fn => queue.add(fn)); + if (!isScheduled) { isScheduled = true; queueMicrotask(flushQueue); } } - isScheduled = false; + }; }; -const schedule = (fn) => { - if (fn.d) return; - queue.add(fn); - if (!isScheduled) { - queueMicrotask(tick); - isScheduled = true; - } -}; - -const depend = (subs) => { - if (activeEffect && !activeEffect.c) { - subs.add(activeEffect); - activeEffect.e.add(subs); - } -}; - -export const effect = (fn, isScope = false) => { - let cleanup = null; - - const run = () => { - if (run.d) return; - const prev = activeEffect; - activeEffect = run; - const result = fn(); - if (typeof result === 'function') cleanup = result; - activeEffect = prev; - }; - - const stop = () => { - if (run.d) return; - run.d = true; - run.e.forEach(subs => subs.delete(run)); - run.e.clear(); - cleanup?.(); - run.c?.forEach(f => f()); - }; - - run.e = new Set(); - run.d = false; - if (isScope) run.c = []; - - run(); - activeEffect?.c?.push(stop); - - return stop; -}; - -effect.react = (fn) => { - const prev = activeEffect; - activeEffect = null; - const result = fn(); - activeEffect = prev; - return result; -}; - -export function $(initial, storageKey) { - const isComputed = typeof initial === 'function'; - - if (!isComputed) { - let value = initial; - const subs = new Set(); - - if (storageKey) { - try { - const saved = localStorage.getItem(storageKey); - if (saved !== null) value = JSON.parse(saved); - } catch { } - } - - const signalFn = (...args) => { - if (args.length === 0) { - return value; - } - const next = typeof args[0] === 'function' ? args[0](value) : args[0]; - if (Object.is(value, next)) return value; - value = next; - if (storageKey) { - try { - localStorage.setItem(storageKey, JSON.stringify(value)); - } catch { } - } - subs.forEach(fn => schedule(fn)); - return value; - }; - - signalFn.react = () => { - depend(subs); - return value; - }; - - return signalFn; - } - - let cached; - let dirty = true; - const subs = new Set(); - const fn = initial; - - effect(() => { - const newValue = fn(); - if (!Object.is(cached, newValue) || dirty) { - cached = newValue; - dirty = false; - subs.forEach(fn => schedule(fn)); - } - }); - - const computedFn = () => { - return cached; - }; - - computedFn.react = () => { - depend(subs); - return cached; - }; - - return computedFn; -} - -export const scope = (fn) => effect(fn, true); - -export const watch = (source, callback) => { - let first = true; - let oldValue; - - return effect(() => { - const newValue = source(); - if (!first) { - effect.react(() => callback(newValue, oldValue)); - } else { - first = false; - } - oldValue = newValue; - }); +export const computed = (fn) => { + const c = signal(fn()); + effect(() => c.value = fn()); + return { get value() { return c.value; } }; }; const reactiveCache = new WeakMap(); +export const reactive = (obj) => { + if (!obj || typeof obj !== 'object') return obj; + if (reactiveCache.has(obj)) return reactiveCache.get(obj); + const subs = {}; + const proxy = new Proxy(obj, { + get(t, k) { + if (!subs[k]) subs[k] = new Set(); + if (activeEffect) { subs[k].add(activeEffect); activeEffect.deps.add(subs[k]); } + const val = t[k]; + return val && typeof val === 'object' ? reactive(val) : val; + }, + set(t, k, v) { + if (t[k] === v) return true; + t[k] = v; + subs[k]?.forEach(fn => queue.add(fn)); + if (!isScheduled) { isScheduled = true; queueMicrotask(flushQueue); } + return true; + } + }); + reactiveCache.set(obj, proxy); + return proxy; +}; -export function $$(obj) { - if (reactiveCache.has(obj)) { - return reactiveCache.get(obj); +// --- Watch with cleanup --- +export const watch = (source, cb, options = {}) => { + let oldValue, firstRun = true, lastCleanup = null; + const stop = effect(() => { + const newValue = typeof source === 'function' ? source() : source.value; + + if (!firstRun || options.immediate) { + if (lastCleanup) { lastCleanup(); lastCleanup = null; } + const prevEffect = activeEffect; + activeEffect = null; + try { cb(newValue, oldValue, (fn) => { lastCleanup = fn; }); } + finally { activeEffect = prevEffect; } } - const subs = {}; + oldValue = newValue; + firstRun = false; + }); - const proxy = new Proxy(obj, { - get(target, key, receiver) { - const subsForKey = subs[key] ??= new Set(); - depend(subsForKey); - const val = Reflect.get(target, key, receiver); - return (val && typeof val === 'object') ? $$(val) : val; - }, - set(target, key, val, receiver) { - if (Object.is(target[key], val)) return true; - const success = Reflect.set(target, key, val, receiver); - if (subs[key]) { - subs[key].forEach(fn => schedule(fn)); - if (!isScheduled) { - queueMicrotask(tick); - isScheduled = true; - } - } - return success; - } - }); - - reactiveCache.set(obj, proxy); - return proxy; -} - -let context = null; - -export const onMount = (fn) => { - context?.m.push(fn); + return () => { stop(); if (lastCleanup) lastCleanup(); }; }; -export const onUnmount = (fn) => { - context?.u.push(fn); +// --- Lifecycle --- +export const onMount = (fn) => currentContext?.mount.push(fn); +export const onUnmount = (fn) => currentContext?.unmount.push(fn); +export const provide = (key, value) => { if (currentContext) currentContext.provide[key] = value; }; +export const inject = (key, def) => { + let ctx = currentContext; + while (ctx) { + if (ctx.provide[key] !== undefined) return ctx.provide[key]; + ctx = ctx.parent; + } + return def; }; -export const share = (key, value) => { - if (context) context.p[key] = value; +// --- Renderer --- +const isNode = v => v instanceof Node; + +export const destroy = (node) => { + if (!node) return; + const ctx = nodeContexts.get(node); + if (ctx) { + ctx.unmount.forEach(fn => fn()); + ctx.cleanups.forEach(fn => fn()); + nodeContexts.delete(node); + } + node.childNodes?.forEach(destroy); + node.remove(); }; -export const use = (key, defaultValue) => { - if (context && key in context.p) return context.p[key]; - return defaultValue; -}; - -export function createContext(defaultValue) { - const key = Symbol('context'); - - const useContext = () => { - let current = context; - while (current) { - if (key in current.p) { - return current.p[key]; - } - current = null; - } - if (defaultValue !== undefined) return defaultValue; - throw new Error(`Context not found: ${String(key)}`); - }; - - const Provider = ({ value, children }) => { - const prevContext = context; - if (!context) { - context = { m: [], u: [], p: {} }; - } - context.p[key] = value; - const result = h('div', { style: 'display: contents' }, children); - context = prevContext; - return result; - }; - - return { Provider, use: useContext }; -} - -export function createSharedContext(key, initialValue) { - if (context && !(key in context.p)) { - share(key, initialValue); - } - - return { - set: (value) => share(key, value), - get: () => use(key) - }; -} - -const isFn = (v) => typeof v === 'function'; -const isNode = (v) => v instanceof Node; - const append = (parent, child) => { - if (child === null) return; - - if (isFn(child)) { - const anchor = document.createTextNode(''); - parent.appendChild(anchor); - let nodes = []; - - effect(() => { - effect(() => { - const newNodes = [child()] - .flat(Infinity) - .map((node) => isFn(node) ? node() : node) - .flat(Infinity) - .filter((node) => node !== null) - .map((node) => isNode(node) ? node : document.createTextNode(String(node))); - - const oldNodes = nodes.filter(node => { - const keep = newNodes.includes(node); - if (!keep) remove(node); - return keep; - }); - - const oldIdxs = new Map(oldNodes.map((node, i) => [node, i])); - - for (let i = newNodes.length - 1, p = oldNodes.length - 1; i >= 0; i--) { - const node = newNodes[i]; - const ref = newNodes[i + 1] || anchor; - - if (!oldIdxs.has(node)) { - anchor.parentNode?.insertBefore(node, ref); - node.$c?.m.forEach(fn => fn()); - } else if (oldNodes[p] !== node) { - if (newNodes[i - 1] !== oldNodes[p]) { - anchor.parentNode?.insertBefore(oldNodes[p], node); - oldNodes[oldIdxs.get(node)] = oldNodes[p]; - oldNodes[p] = node; - p--; - } - anchor.parentNode?.insertBefore(node, ref); - } else { - p--; - } - } - nodes = newNodes; - }); - }, true); - } else if (isNode(child)) { - parent.appendChild(child); - } else { - parent.appendChild(document.createTextNode(String(child))); - } -}; - -const remove = (node) => { - const el = node; - el.$s?.(); - el.$l?.(() => node.remove()); - if (!el.$l) node.remove(); -}; - -const render = (fn, ...data) => { - let node; - const stop = effect(() => { - node = fn(...data); - if (isFn(node)) node = node(); - }, true); - if (node) node.$s = stop; - return node; -}; - -export const h = (tag, props, ...children) => { - props = props || {}; - children = children.flat(Infinity); - - if (isFn(tag)) { - const prev = context; - context = { m: [], u: [], p: { ...(prev?.p || {}) } }; - let el; - - const stop = effect(() => { - el = tag(props, { - children, - emit: (evt, ...args) => - props[`on${evt[0].toUpperCase()}${evt.slice(1)}`]?.(...args), - }); - return () => el.$c.u.forEach(fn => fn()); - }, true); - - if (isNode(el) || isFn(el)) { - el.$c = context; - el.$s = stop; - } - - context = prev; - return el; - } - - if (!tag) return () => children; - - let el; - let is_svg = false; - - try { - el = document.createElement(tag); - if (el instanceof HTMLUnknownElement) { - is_svg = true; - el = document.createElementNS("http://www.w3.org/2000/svg", tag); - } - } catch { - is_svg = true; - el = document.createElementNS("http://www.w3.org/2000/svg", tag); - } - - const booleanAttributes = ["disabled", "checked", "required", "readonly", "selected", "multiple", "autofocus"]; - - for (const key in props) { - if (key.startsWith('on')) { - const eventName = key.slice(2).toLowerCase(); - el.addEventListener(eventName, props[key]); - } else if (key === "ref") { - if (isFn(props[key])) { - props[key](el); - } else { - props[key].current = el; - } - } else if (isFn(props[key])) { - effect(() => { - const val = props[key](); - if (key === 'className') { - el.setAttribute('class', String(val ?? '')); - } else if (booleanAttributes.includes(key)) { - el[key] = !!val; - val ? el.setAttribute(key, '') : el.removeAttribute(key); - } else if (key in el && !is_svg) { - el[key] = val; - } else { - const safeVal = sanitizeAttribute(key, val); - if (safeVal !== null) el.setAttribute(key, safeVal); - } - }); - } else { - const value = props[key]; - if (key === 'className') { - el.setAttribute('class', String(value ?? '')); - } else if (booleanAttributes.includes(key)) { - el[key] = !!value; - value ? el.setAttribute(key, '') : el.removeAttribute(key); - } else if (key in el && !is_svg) { - el[key] = value; - } else { - const safeVal = sanitizeAttribute(key, value); - if (safeVal !== null) el.setAttribute(key, safeVal); - } - } - } - - children.forEach((child) => append(el, child)); - - return el; -}; - -export const If = (cond, renderFn, fallback = null) => { - let cached; - let current = null; - - return () => { - const show = isFn(cond) ? cond() : cond; - if (show !== current) { - cached = show ? render(renderFn) : (isFn(fallback) ? render(fallback) : fallback); - } - current = show; - return cached; - }; -}; - -export const For = (list, key, renderFn) => { - let cache = new Map(); - - return () => { - const next = new Map(); - const items = (isFn(list) ? list() : list.value || list); - - const nodes = items.map((item, index) => { - const idx = isFn(key) ? key(item, index) : key ? item[key] : index; - let node = cache.get(idx); - if (!node) { - node = render(renderFn, item, index); - } - next.set(idx, node); - return node; - }); - - cache = next; - return nodes; - }; -}; - -export const Transition = ({ enter: e, idle, leave: l }, { children: [c] }) => { - const decorate = (el) => { - if (!isNode(el)) return el; - - const addClass = (c) => c && el.classList.add(...c.split(' ')); - const removeClass = (c) => c && el.classList.remove(...c.split(' ')); - - if (e) { - requestAnimationFrame(() => { - addClass(e[1]); - requestAnimationFrame(() => { - addClass(e[0]); - removeClass(e[1]); - addClass(e[2]); - el.addEventListener('transitionend', () => { - removeClass(e[2]); - removeClass(e[0]); - addClass(idle); - }, { once: true }); - }); - }); - } - - if (l) { - el.$l = (done) => { - removeClass(idle); - addClass(l[1]); - requestAnimationFrame(() => { - addClass(l[0]); - removeClass(l[1]); - addClass(l[2]); - el.addEventListener('transitionend', () => { - removeClass(l[2]); - removeClass(l[0]); - done(); - }, { once: true }); - }); - }; - } - - return el; - }; - - if (!c) return null; - if (isFn(c)) { - return () => decorate(c()); - } - return decorate(c); -}; - -export const Router = (routes) => { - const getPath = () => window.location.hash.slice(1) || "/"; - const path = $(getPath()); - const params = $({}); - - const matchRoute = (path) => { - for (const route of routes) { - const routeParts = route.path.split("/").filter(Boolean); - const pathParts = path.split("/").filter(Boolean); - - if (routeParts.length !== pathParts.length) continue; - - const matchedParams = {}; - let ok = true; - - for (let i = 0; i < routeParts.length; i++) { - if (routeParts[i].startsWith(":")) { - matchedParams[routeParts[i].slice(1)] = pathParts[i]; - } else if (routeParts[i] !== pathParts[i]) { - ok = false; - break; - } - } - - if (ok) return { component: route.component, params: matchedParams }; - } - - const wildcard = routes.find(r => r.path === "*"); - if (wildcard) return { component: wildcard.component, params: {} }; - - return null; - }; - - window.addEventListener("hashchange", () => path(getPath())); - - const outlet = h("div"); - + if (child == null) return; + if (typeof child === 'function') { + const marker = document.createTextNode(''); + parent.appendChild(marker); + let nodes = []; effect(() => { - const matched = matchRoute(path()); - if (!matched) return; + const raw = child(); + const next = [raw].flat(Infinity) + .map(n => typeof n === 'function' ? n() : n) + .filter(n => n != null) + .map(n => isNode(n) ? n : document.createTextNode(String(n))); - params(matched.params); - - while (outlet.firstChild) outlet.removeChild(outlet.firstChild); - outlet.appendChild(h(matched.component)); + const nextSet = new Set(next); + nodes.forEach(n => { if (!nextSet.has(n)) destroy(n); }); + next.forEach((n, i) => { + if (nodes[i] !== n) marker.parentNode.insertBefore(n, nodes[i] || marker); + }); + nodes = next; }); - - return { - view: outlet, - to: (p) => { window.location.hash = p; }, - back: () => window.history.back(), - params: () => params, - }; + } else { + parent.appendChild(isNode(child) ? child : document.createTextNode(String(child))); + } }; -export const mount = (component, target, props) => { - const targetEl = typeof target === "string" ? document.querySelector(target) : target; - if (!targetEl) throw new Error("Target element not found"); - const el = h(component, props); - targetEl.appendChild(el); - el.$c?.m.forEach(fn => fn()); - return () => remove(el); +export const h = (tag, props = {}, ...children) => { + if (typeof tag === 'function') { + const prevCtx = currentContext; + const context = { mount: [], unmount: [], provide: {}, parent: prevCtx, cleanups: new Set() }; + currentContext = context; + const prevOwner = currentOwner; + currentOwner = context; + + const el = tag(props, { children: children.flat(Infinity) }); + if (isNode(el)) nodeContexts.set(el, context); + + currentContext = prevCtx; + currentOwner = prevOwner; + queueMicrotask(() => context.mount.forEach(fn => fn())); + return el; + } + + const el = document.createElement(tag); + for (const key in props) { + const val = props[key]; + + if (key.startsWith('on')) { + el.addEventListener(key.slice(2).toLowerCase(), val); + } + else if (typeof val === 'function' || (val && val._isSig)) { + effect(() => { + const v = typeof val === 'function' ? val() : val.value; + el[key] = (key === 'href' || key === 'src') ? sanitize(v) : v; + }); + } else { + el[key] = (key === 'href' || key === 'src') ? sanitize(val) : val; + } + } + + children.flat(Infinity).forEach(c => append(el, c)); + return el; }; -export default (target, root, props) => { - const el = h(root, props); - target.appendChild(el); - el.$c?.m.forEach(fn => fn()); - return () => remove(el); +// --- Conditionals & Loops --- +export const If = (cond, a, b) => () => cond() ? a() : (b ? b() : null); + +export const For = (list, key, render) => { + let cache = new Map(); + return () => { + const items = list(); + const nextCache = new Map(); + const nodes = items.map((item, i) => { + const k = key ? key(item, i) : i; + let node = cache.get(k); + if (!node) node = render(item, i); + nextCache.set(k, node); + return node; + }); + cache.forEach((n, k) => { if (!nextCache.has(k)) destroy(n); }); + cache = nextCache; + return nodes; + }; }; +// --- Router --- +export const Router = (routes) => { + const path = signal(window.location.hash.replace(/^#/, '') || '/'); + window.addEventListener('hashchange', () => path.value = window.location.hash.replace(/^#/, '') || '/'); + let view = null; + const outlet = h('div', { class: 'router-outlet' }); + effect(() => { + const route = routes.find(r => r.path === path.value) || routes.find(r => r.path === '*'); + if (view) destroy(view); + if (route) { + view = route.component(); + outlet.appendChild(view); + } + }); + return outlet; +}; + +// --- Mounting --- +export const mount = (root, target) => { + const container = typeof target === 'string' ? document.querySelector(target) : target; + const el = typeof root === 'function' ? root() : root; + container.replaceChildren(el); + return () => destroy(el); +}; + +// --- Export API --- +export default { signal, computed, effect, reactive, watch, h, If, For, Router, mount, provide, inject, onMount, onUnmount }; export { h as jsx, h as jsxs, h as Fragment }; \ No newline at end of file