232 lines
7.2 KiB
JavaScript
232 lines
7.2 KiB
JavaScript
// ============================================================
|
||
// SIGPRO CORE · Simple, Fast, Memory‑Safe
|
||
// ============================================================
|
||
let currentContext = null
|
||
|
||
const getContext = () => currentContext
|
||
const runInContext = (ctx, fn) => {
|
||
const prev = currentContext
|
||
currentContext = ctx
|
||
try { return fn() }
|
||
finally { currentContext = prev }
|
||
}
|
||
|
||
const onCleanup = (fn) => {
|
||
const ctx = getContext()
|
||
if (!ctx) throw new Error('onCleanup must be called within a reactive root')
|
||
ctx.cleanups.add(fn)
|
||
}
|
||
|
||
const onMount = (fn) => {
|
||
const ctx = getContext()
|
||
if (!ctx) throw new Error('onMount must be called within a reactive root')
|
||
queueMicrotask(() => runInContext(ctx, fn))
|
||
}
|
||
|
||
const createRoot = (fn) => {
|
||
const ctx = { cleanups: new Set(), parent: currentContext }
|
||
return runInContext(ctx, () => {
|
||
const result = fn(ctx)
|
||
const destroy = () => { ctx.cleanups.forEach(c => c()); ctx.cleanups.clear() }
|
||
if (result instanceof Node) result._destroy = destroy
|
||
return result
|
||
})
|
||
}
|
||
|
||
const createComponent = (fn) => (...args) => {
|
||
const parent = getContext()
|
||
const ctx = { cleanups: new Set(), parent }
|
||
if (parent) parent.cleanups.add(() => { ctx.cleanups.forEach(c => c()); ctx.cleanups.clear() })
|
||
return runInContext(ctx, () => fn(...args))
|
||
}
|
||
|
||
const createSignal = (initialValue) => {
|
||
const subscribers = new Set()
|
||
let value = initialValue
|
||
const signal = (...args) => {
|
||
if (args.length === 0) {
|
||
const ctx = getContext()
|
||
if (ctx?.activeEffect) {
|
||
subscribers.add(ctx.activeEffect)
|
||
ctx.activeEffect.deps.add(subscribers)
|
||
}
|
||
return value
|
||
}
|
||
const next = typeof args[0] === 'function' ? args[0](value) : args[0]
|
||
if (!Object.is(value, next)) {
|
||
value = next
|
||
;[...subscribers].forEach(e => e.run())
|
||
}
|
||
return value
|
||
}
|
||
return signal
|
||
}
|
||
|
||
const createEffect = (fn) => {
|
||
const ctx = getContext()
|
||
if (!ctx) throw new Error('createEffect must be called within a reactive root')
|
||
const effect = {
|
||
deps: new Set(),
|
||
run: () => {
|
||
effect.deps.forEach(d => d.delete(effect))
|
||
effect.deps.clear()
|
||
const prev = ctx.activeEffect
|
||
ctx.activeEffect = effect
|
||
try { fn() }
|
||
finally { ctx.activeEffect = prev }
|
||
}
|
||
}
|
||
onCleanup(() => {
|
||
effect.deps.forEach(d => d.delete(effect))
|
||
effect.deps.clear()
|
||
})
|
||
effect.run()
|
||
}
|
||
|
||
const mount = (component, selector) => {
|
||
const root = document.querySelector(selector)
|
||
if (!root) throw new Error(`Selector "${selector}" not found`)
|
||
if (root._destroy) root._destroy()
|
||
root.innerHTML = ''
|
||
const app = createRoot(component)
|
||
root.appendChild(app)
|
||
root._destroy = app._destroy
|
||
return app
|
||
}
|
||
|
||
// Helper para crear elementos (con soporte SVG)
|
||
const h = (tag, props, ...children) => {
|
||
const isSVG = /^(svg|path|circle|rect|line|polyline|polygon|g|defs|text|tspan|use)$/i.test(tag)
|
||
const el = isSVG
|
||
? document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||
: document.createElement(tag)
|
||
|
||
if (props) {
|
||
Object.entries(props).forEach(([k, v]) => {
|
||
if (k.startsWith('on')) {
|
||
const event = k.slice(2).toLowerCase()
|
||
el.addEventListener(event, v)
|
||
onCleanup(() => el.removeEventListener(event, v))
|
||
} else if (k === 'style' && typeof v === 'object') {
|
||
Object.assign(el.style, v)
|
||
} else {
|
||
el.setAttribute(k, v)
|
||
}
|
||
})
|
||
}
|
||
|
||
children.flat().forEach(c => {
|
||
if (c instanceof Node) el.appendChild(c)
|
||
else el.appendChild(document.createTextNode(String(c ?? '')))
|
||
})
|
||
return el
|
||
}
|
||
|
||
// ===== IF / FOR / ROUTER (API IDÉNTICA A B) =====
|
||
const If = createComponent((cond, thenFn) => {
|
||
const anchor = document.createTextNode('')
|
||
const container = h('div', { style: 'display:contents' }, anchor)
|
||
let current = null
|
||
createEffect(() => {
|
||
const show = !!cond()
|
||
if (show && !current) {
|
||
current = createRoot(() => thenFn())
|
||
container.insertBefore(current, anchor)
|
||
} else if (!show && current) {
|
||
if (current._destroy) current._destroy()
|
||
else current.remove()
|
||
current = null
|
||
}
|
||
})
|
||
onCleanup(() => current?._destroy?.())
|
||
return container
|
||
})
|
||
|
||
const For = createComponent((source, itemFn) => {
|
||
const container = h('div', { style: 'display:contents' })
|
||
const marker = document.createTextNode('')
|
||
container.appendChild(marker)
|
||
let cache = new Map()
|
||
createEffect(() => {
|
||
const items = source() || []
|
||
const newCache = new Map()
|
||
const order = []
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
const key = (item && typeof item === 'object' && 'id' in item) ? item.id : i
|
||
let view = cache.get(key)
|
||
if (!view) view = createRoot(() => itemFn(item, i))
|
||
newCache.set(key, view)
|
||
order.push(key)
|
||
cache.delete(key)
|
||
}
|
||
cache.forEach(v => v._destroy ? v._destroy() : v.remove?.())
|
||
cache = newCache
|
||
let anchor = marker
|
||
for (let i = order.length - 1; i >= 0; i--) {
|
||
const view = newCache.get(order[i])
|
||
if (view instanceof Node && view.nextSibling !== anchor)
|
||
container.insertBefore(view, anchor)
|
||
anchor = view
|
||
}
|
||
})
|
||
onCleanup(() => cache.forEach(v => v._destroy ? v._destroy() : v.remove?.()))
|
||
return container
|
||
})
|
||
|
||
const Router = createComponent(({ routes }) => {
|
||
const getHash = () => window.location.hash.slice(1) || '/'
|
||
const path = createSignal(getHash())
|
||
const handler = () => path(getHash())
|
||
window.addEventListener('hashchange', handler)
|
||
onCleanup(() => window.removeEventListener('hashchange', handler))
|
||
const outlet = h('div', {})
|
||
let currentView = null
|
||
createEffect(() => {
|
||
const cur = path()
|
||
const match = routes.find(r => {
|
||
const rParts = r.path.split('/').filter(Boolean)
|
||
const pParts = cur.split('/').filter(Boolean)
|
||
return rParts.length === pParts.length && rParts.every((p, i) => p.startsWith(':') || p === pParts[i])
|
||
}) || routes.find(r => r.path === '*')
|
||
if (match) {
|
||
if (currentView?._destroy) currentView._destroy()
|
||
else if (currentView) currentView.remove()
|
||
const params = {}
|
||
match.path.split('/').filter(Boolean).forEach((p, i) => {
|
||
if (p.startsWith(':')) params[p.slice(1)] = cur.split('/').filter(Boolean)[i]
|
||
})
|
||
currentView = createRoot(() => match.component(params))
|
||
outlet.appendChild(currentView)
|
||
}
|
||
})
|
||
onCleanup(() => currentView?._destroy?.())
|
||
return outlet
|
||
})
|
||
Router.to = (path) => window.location.hash = path.replace(/^#?\/?/, '#/')
|
||
Router.back = () => window.history.back()
|
||
Router.path = () => window.location.hash.slice(1) || '/'
|
||
|
||
// ===== API PÚBLICA =====
|
||
const SigPro = {
|
||
createRoot, createComponent, createSignal, createEffect,
|
||
onCleanup, onMount, mount, h,
|
||
If, For, Router
|
||
}
|
||
|
||
// ===== EXPOSICIÓN GLOBAL (OPCIONAL, COMO EN B) =====
|
||
if (typeof window !== 'undefined') {
|
||
Object.assign(window, SigPro)
|
||
const tags = 'div span p h1 h2 h3 h4 h5 h6 br hr section article aside nav main header footer ul ol li a em strong pre code form label input textarea select button img svg'.split(' ')
|
||
tags.forEach(t => {
|
||
const helper = t[0].toUpperCase() + t.slice(1)
|
||
window[helper] = (props, ...children) => h(t, props, children)
|
||
})
|
||
}
|
||
|
||
export {
|
||
createRoot, createComponent, createSignal, createEffect,
|
||
onCleanup, onMount, mount, h,
|
||
If, For, Router
|
||
}
|
||
export default SigPro |