From ffd163b55b67bd598cead00dee8fa50d7094e7cb Mon Sep 17 00:00:00 2001 From: Natxo <1172351+natxocc@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:35:35 +0100 Subject: [PATCH] Uploaded --- Readme.md | 1395 +++++++++++++++++++++++++++++++++++++++++++++++++++++ sigpro.js | 474 ++++++++++++++++++ 2 files changed, 1869 insertions(+) create mode 100644 Readme.md create mode 100644 sigpro.js diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..a17ff4e --- /dev/null +++ b/Readme.md @@ -0,0 +1,1395 @@ +# SigPro ?? + +A minimalist reactive library for building web interfaces with signals, effects, and native web components. No compilation, no virtual DOM, just pure JavaScript and intelligent reactivity. + +[![npm version](https://img.shields.io/npm/v/sigpro.svg)](https://www.npmjs.com/package/sigpro) +[![bundle size](https://img.shields.io/bundlephobia/minzip/sigpro)](https://bundlephobia.com/package/sigpro) +[![license](https://img.shields.io/npm/l/sigpro)](https://github.com/yourusername/sigpro/blob/main/LICENSE) + +## ?? Installation + +```bash +npm install sigpro +``` + +Or directly in the browser: +```html + +``` + +## ?? Philosophy + +SigPro (Signal Professional) embraces the web platform. Built on top of Custom Elements and reactive proxies, it offers a development experience similar to modern frameworks but with a minimal footprint and zero dependencies. + +**Core Principles:** +- ?? **True Reactivity** - Automatic dependency tracking, no manual subscriptions +- ? **Surgical Updates** - Only the exact nodes that depend on changed values are updated +- ?? **Web Standards** - Built on Custom Elements, no custom rendering engine +- ?? **Intuitive API** - Learn once, use everywhere +- ?? **Predictable** - No magic, just signals and effects + +## ?? API Reference + +--- + +### `$(initialValue)` - Signals + +Creates a reactive value that notifies dependents when changed. + +#### Basic Signal (Getter/Setter) + +```typescript +import { $ } from 'sigpro'; + +// Create a signal +const count = $(0); + +// Read value (outside reactive context) +console.log(count()); // 0 + +// Write value +count(5); +count(prev => prev + 1); // Use function for previous value + +// Read with dependency tracking (inside effect) +$$(() => { + console.log(count()); // Will be registered as dependency +}); +``` + +#### Computed Signal + +```typescript +import { $, $$ } from 'sigpro'; + +const firstName = $('John'); +const lastName = $('Doe'); + +// Computed signal - automatically updates when dependencies change +const fullName = $(() => `${firstName()} ${lastName()}`); + +console.log(fullName()); // "John Doe" + +firstName('Jane'); +console.log(fullName()); // "Jane Doe" + +// Computed signals cache until dependencies change +const expensiveComputation = $(() => { + console.log('Computing...'); + return firstName().length + lastName().length; +}); + +console.log(expensiveComputation()); // "Computing..." 7 +console.log(expensiveComputation()); // 7 (cached, no log) +``` + +#### Signal with Custom Equality + +```typescript +import { $ } from 'sigpro'; + +const user = $({ id: 1, name: 'John' }); + +// Signals use Object.is comparison +user({ id: 1, name: 'John' }); // Won't trigger updates (same values, new object) +user({ id: 1, name: 'Jane' }); // Will trigger updates +``` + +**Parameters:** +- `initialValue`: Initial value or getter function for computed signal + +**Returns:** Function that acts as getter/setter with the following signature: +```typescript +type Signal = { + (): T; // Getter + (value: T | ((prev: T) => T)): void; // Setter +} +``` + +--- + +### `$$(effect)` - Effects + +Executes a function and automatically re-runs it when its dependencies change. + +#### Basic Effect + +```typescript +import { $, $$ } from 'sigpro'; + +const count = $(0); +const name = $('World'); + +// Effect runs immediately and on dependency changes +$$(() => { + console.log(`Count is: ${count()}`); // Only depends on count +}); +// Log: "Count is: 0" + +count(1); +// Log: "Count is: 1" + +name('Universe'); // No log (name is not a dependency) +``` + +#### Effect with Cleanup + +```typescript +import { $, $$ } from 'sigpro'; + +const userId = $(1); + +$$(() => { + const id = userId(); + let isSubscribed = true; + + // Simulate API subscription + const subscription = api.subscribe(id, (data) => { + if (isSubscribed) { + console.log('New data:', data); + } + }); + + // Return cleanup function + return () => { + isSubscribed = false; + subscription.unsubscribe(); + }; +}); + +userId(2); // Previous subscription cleaned up, new one created +``` + +#### Nested Effects + +```typescript +import { $, $$ } from 'sigpro'; + +const show = $(true); +const count = $(0); + +$$(() => { + if (!show()) return; + + // This effect is nested inside the conditional + // It will only be active when show() is true + $$(() => { + console.log('Count changed:', count()); + }); +}); + +show(false); // Inner effect is automatically cleaned up +count(1); // No log (inner effect not active) +show(true); // Inner effect recreated, logs "Count changed: 1" +``` + +#### Manual Effect Control + +```typescript +import { $, $$ } from 'sigpro'; + +const count = $(0); + +// Stop effect manually +const stop = $$(() => { + console.log('Effect running:', count()); +}); + +count(1); // Log: "Effect running: 1" +stop(); +count(2); // No log +``` + +**Parameters:** +- `effect`: Function to execute. Can return a cleanup function + +**Returns:** Function to stop the effect + +--- + +### `html` - Template Literal Tag + +Creates reactive DOM fragments using template literals with intelligent binding. + +#### Basic Usage + +```typescript +import { $, html } from 'sigpro'; + +const count = $(0); +const name = $('World'); + +const fragment = html` +
+

Hello ${name}

+

Count: ${count}

+ +
+`; + +document.body.appendChild(fragment); +``` + +#### Directive Reference + +##### `@event` - Event Listeners + +```typescript +import { html } from 'sigpro'; + +const handleClick = (event) => console.log('Clicked!', event); +const handleInput = (value) => console.log('Input:', value); + +html` + + + + + console.log(e.target.value)} /> + + + +` +``` + +##### `:property` - Two-way Binding + +Automatically syncs between signal and DOM element. + +```typescript +import { $, html } from 'sigpro'; + +const text = $(''); +const checked = $(false); +const selected = $('option1'); + +html` + + +

You typed: ${text}

+ + + +

Checkbox is: ${() => checked() ? 'checked' : 'unchecked'}

+ + + + + + + + + + + +` +``` + +##### `?attribute` - Boolean Attributes + +```typescript +import { $, html } from 'sigpro'; + +const isDisabled = $(true); +const isChecked = $(false); +const hasError = $(false); + +html` + + + + +
!hasError()} class="error"> + An error occurred +
+ + + +` +``` + +##### `.property` - Property Binding + +Directly binds to DOM properties, not attributes. + +```typescript +import { $, html } from 'sigpro'; + +const scrollTop = $(0); +const user = $({ name: 'John', age: 30 }); +const items = $([1, 2, 3]); + +html` + +
+ Content... +
+ + + + + + + + + + + +
+` +``` + +##### Regular Attributes + +```typescript +import { $, html } from 'sigpro'; + +const className = $('big red'); +const href = $('#section'); +const style = $('color: blue'); + +// Static attributes +html`
` + +// Dynamic attributes (non-directive) +html`
` + +// Mix of static and dynamic +html`Link` + +// Reactive attributes update when signal changes +$$(() => { + // The attribute updates automatically + console.log('Class changed:', className()); +}); +``` + +#### Conditional Rendering + +```typescript +import { $, html } from 'sigpro'; + +const show = $(true); +const user = $({ name: 'John', role: 'admin' }); + +// Using ternary +html` + ${() => show() ? html` +
Content is visible
+ ` : html` +
Content is hidden
+ `} +` + +// Using logical AND +html` + ${() => user().role === 'admin' && html` + + `} +` + +// Complex conditions +html` + ${() => { + if (!show()) return null; + if (user().role === 'admin') { + return html`
Admin view
`; + } + return html`
User view
`; + }} +` +``` + +#### List Rendering + +```typescript +import { $, html } from 'sigpro'; + +const items = $([1, 2, 3, 4, 5]); +const todos = $([ + { text: 'Learn SigPro', done: true }, + { text: 'Build an app', done: false } +]); + +// Basic list +html` + +` + +// List with keys (for efficient updates) +html` + +` + +// Nested lists +const matrix = $([[1, 2], [3, 4], [5, 6]]); + +html` + + ${() => matrix().map(row => html` + + ${() => row.map(cell => html` + + `)} + + `)} +
${cell}
+` +``` + +#### Dynamic Tag Names + +```typescript +import { $, html } from 'sigpro'; + +const tagName = $('h1'); +const level = $(1); + +html` + +
+ This will be wrapped in ${tagName} tags +
+ + + ${() => { + const Tag = `h${level()}`; + return html` + <${Tag}>Level ${level()} Heading + `; + }} +` +``` + +#### Template Composition + +```typescript +import { $, html } from 'sigpro'; + +const Header = () => html`
Header
`; +const Footer = () => html``; + +const Layout = ({ children }) => html` + ${Header()} +
+ ${children} +
+ ${Footer()} +` + +const Page = () => html` + ${Layout({ + children: html` +

Page Content

+

Some content here

+ ` + })} +` +``` + +--- + +### `$component(tag, setup, observedAttributes)` - Web Components + +Creates Custom Elements with automatic reactive properties. + +#### Basic Component + +```typescript +import { $, $component, html } from 'sigpro'; + +$component('my-counter', (props, context) => { + // props contains signals for each observed attribute + // context provides component utilities + + const increment = () => { + props.value(v => v + 1); + }; + + return html` +
+

Count: ${props.value}

+ + +
+ `; +}, ['value']); // Observed attributes +``` + +Usage: +```html + + Additional content + + + +``` + +#### Component with Complex Props + +```typescript +import { $, $component, html } from 'sigpro'; + +$component('user-profile', (props, context) => { + // Transform string attributes to appropriate types + const user = $(() => ({ + id: parseInt(props.id()), + name: props.name(), + age: parseInt(props.age()), + active: props.active() === 'true' + })); + + return html` +
+

${user().name}

+

ID: ${user().id}

+

Age: ${user().age}

+

Status: ${() => user().active ? 'Active' : 'Inactive'}

+ + +
+ `; +}, ['id', 'name', 'age', 'active']); +``` + +#### Component Lifecycle & Context + +```typescript +import { $, $component, html } from 'sigpro'; + +$component('lifecycle-demo', (props, { + select, // Query selector scoped to component + selectAll, // Query selector all scoped to component + slot, // Access slots + emit, // Dispatch custom events + host, // Reference to the host element + onMount, // Register mount callback + onUnmount, // Register unmount callback + onAttribute, // Listen to attribute changes + getAttribute, // Get raw attribute value + setAttribute, // Set raw attribute value +}) => { + + // Access slots + const defaultSlot = slot(); // Unnamed slot + const headerSlot = slot('header'); // Named slot + + // Query internal elements + const button = select('button'); + const allSpans = selectAll('span'); + + // Lifecycle hooks + onMount(() => { + console.log('Component mounted'); + // Access DOM after mount + button?.classList.add('mounted'); + }); + + onUnmount(() => { + console.log('Component unmounting'); + // Cleanup resources + }); + + // Listen to specific attribute changes + onAttribute('value', (newValue, oldValue) => { + console.log(`Value changed from ${oldValue} to ${newValue}`); + }); + + // Emit custom events + const handleClick = () => { + emit('button-click', { timestamp: Date.now() }); + emit('value-change', props.value(), { bubbles: true }); + }; + + // Access host directly + host.style.display = 'block'; + + return html` +
+ ${headerSlot} + + ${defaultSlot} +
+ `; +}, ['value']); +``` + +#### Component with Methods + +```typescript +import { $, $component, html } from 'sigpro'; + +$component('timer-widget', (props, { host }) => { + const seconds = $(0); + let intervalId; + + // Expose methods to the host element + Object.assign(host, { + start() { + if (intervalId) return; + intervalId = setInterval(() => { + seconds(s => s + 1); + }, 1000); + }, + + stop() { + clearInterval(intervalId); + intervalId = null; + }, + + reset() { + seconds(0); + }, + + get currentTime() { + return seconds(); + } + }); + + return html` +
+

${seconds} seconds

+ + + +
+ `; +}, []); +``` + +Usage: +```html + + +``` + +#### Component Inheritance + +```typescript +import { $, $component, html } from 'sigpro'; + +// Base component +$component('base-button', (props, { slot }) => { + return html` + + `; +}, ['disabled']); + +// Extended component +$component('primary-button', (props, context) => { + // Reuse base component + return html` + + ${context.slot()} + + `; +}, ['disabled']); +``` + +--- + +### `$router(routes)` - Router + +Hash-based router for SPAs with reactive integration. + +#### Basic Routing + +```typescript +import { $router, html } from 'sigpro'; + +const router = $router([ + { + path: '/', + component: () => html` +

Home Page

+ About + ` + }, + { + path: '/about', + component: () => html` +

About Page

+ Home + ` + } +]); + +document.body.appendChild(router); +``` + +#### Route Parameters + +```typescript +import { $router, html } from 'sigpro'; + +const router = $router([ + { + path: '/user/:id', + component: (params) => html` +

User Profile

+

User ID: ${params.id}

+ Edit + ` + }, + { + path: '/user/:id/posts/:postId', + component: (params) => html` +

Post ${params.postId} by User ${params.id}

+ ` + }, + { + path: /^\/product\/(?\w+)\/(?\d+)$/, + component: (params) => html` +

Product ${params.id} in ${params.category}

+ ` + } +]); +``` + +#### Nested Routes + +```typescript +import { $router, html, $ } from 'sigpro'; + +const router = $router([ + { + path: '/', + component: () => html` +

Home

+ + ` + }, + { + path: '/dashboard', + component: () => { + // Nested router + const subRouter = $router([ + { + path: '/', + component: () => html`

Dashboard Home

` + }, + { + path: '/settings', + component: () => html`

Dashboard Settings

` + }, + { + path: '/profile/:id', + component: (params) => html`

Profile ${params.id}

` + } + ]); + + return html` +
+

Dashboard

+ + ${subRouter} +
+ `; + } + } +]); +``` + +#### Route Guards + +```typescript +import { $router, html, $ } from 'sigpro'; + +const isAuthenticated = $(false); + +const requireAuth = (component) => (params) => { + if (!isAuthenticated()) { + $router.go('/login'); + return null; + } + return component(params); +}; + +const router = $router([ + { + path: '/', + component: () => html`

Public Home

` + }, + { + path: '/dashboard', + component: requireAuth((params) => html` +

Protected Dashboard

+ `) + }, + { + path: '/login', + component: () => html` +

Login

+ + ` + } +]); +``` + +#### Navigation + +```typescript +import { $router } from 'sigpro'; + +// Navigate to path +$router.go('/user/42'); + +// Navigate with replace +$router.go('/dashboard', { replace: true }); + +// Go back +$router.back(); + +// Go forward +$router.forward(); + +// Get current path +const currentPath = $router.getCurrentPath(); + +// Listen to navigation +$router.listen((path, oldPath) => { + console.log(`Navigated from ${oldPath} to ${path}`); +}); +``` + +#### Route Transitions + +```typescript +import { $router, html, $$ } from 'sigpro'; + +const router = $router([ + { + path: '/', + component: () => html`
Home
` + }, + { + path: '/about', + component: () => html`
About
` + } +]); + +// Add transitions +$$(() => { + const currentPath = router.getCurrentPath(); + const pages = document.querySelectorAll('.page'); + + pages.forEach(page => { + page.style.opacity = '0'; + page.style.transition = 'opacity 0.3s'; + + setTimeout(() => { + page.style.opacity = '1'; + }, 50); + }); +}); +``` + +--- + +## ?? Complete Examples + +### Real-time Todo Application + +```typescript +import { $, $$, html, $component } from 'sigpro'; + +// Styles +const styles = html` + +`; + +$component('todo-app', () => { + // State + const todos = $(() => { + const saved = localStorage.getItem('todos'); + return saved ? JSON.parse(saved) : []; + }); + + const newTodo = $(''); + const filter = $('all'); // 'all', 'active', 'completed' + const editingId = $(null); + const editText = $(''); + + // Save to localStorage on changes + $$(() => { + localStorage.setItem('todos', JSON.stringify(todos())); + }); + + // Filtered todos + const filteredTodos = $(() => { + const currentFilter = filter(); + const allTodos = todos(); + + switch (currentFilter) { + case 'active': + return allTodos.filter(t => !t.completed); + case 'completed': + return allTodos.filter(t => t.completed); + default: + return allTodos; + } + }); + + // Stats + const stats = $(() => { + const all = todos(); + return { + total: all.length, + completed: all.filter(t => t.completed).length, + active: all.filter(t => !t.completed).length + }; + }); + + // Actions + const addTodo = () => { + const text = newTodo().trim(); + if (!text) return; + + todos([ + ...todos(), + { + id: Date.now(), + text, + completed: false, + createdAt: new Date().toISOString() + } + ]); + newTodo(''); + }; + + const toggleTodo = (id) => { + todos(todos().map(todo => + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + )); + }; + + const deleteTodo = (id) => { + todos(todos().filter(todo => todo.id !== id)); + if (editingId() === id) { + editingId(null); + } + }; + + const startEdit = (todo) => { + editingId(todo.id); + editText(todo.text); + }; + + const saveEdit = (id) => { + const text = editText().trim(); + if (!text) { + deleteTodo(id); + } else { + todos(todos().map(todo => + todo.id === id ? { ...todo, text } : todo + )); + } + editingId(null); + }; + + const clearCompleted = () => { + todos(todos().filter(t => !t.completed)); + }; + + return html` + ${styles} +
+

?? Todo App

+ + +
+ e.key === 'Enter' && addTodo()} + /> + +
+ + +
+ + + +
+ + +
+ ${() => filteredTodos().map(todo => html` +
+ ${editingId() === todo.id ? html` + { + if (e.key === 'Enter') saveEdit(todo.id); + if (e.key === 'Escape') editingId(null); + }} + @blur=${() => saveEdit(todo.id)} + autofocus + /> + ` : html` + toggleTodo(todo.id)} + /> + startEdit(todo)}> + ${todo.text} + + + `} +
+ `)} +
+ + +
+ ${() => { + const s = stats(); + return html` + Total: ${s.total} | + Active: ${s.active} | + Completed: ${s.completed} + ${s.completed > 0 ? html` + + ` : ''} + `; + }} +
+
+ `; +}, []); +``` + +### Data Dashboard with Real-time Updates + +```typescript +import { $, $$, html, $component } from 'sigpro'; + +// Simulated WebSocket connection +class DataStream { + constructor() { + this.listeners = new Set(); + this.interval = setInterval(() => { + const data = { + timestamp: Date.now(), + value: Math.random() * 100, + category: ['A', 'B', 'C'][Math.floor(Math.random() * 3)] + }; + this.listeners.forEach(fn => fn(data)); + }, 1000); + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + destroy() { + clearInterval(this.interval); + } +} + +$component('data-dashboard', () => { + const stream = new DataStream(); + const dataPoints = $([]); + const selectedCategory = $('all'); + const timeWindow = $(60); // seconds + + // Subscribe to data stream + $$(() => { + const unsubscribe = stream.subscribe((newData) => { + dataPoints(prev => { + const updated = [...prev, newData]; + const maxAge = timeWindow() * 1000; + const cutoff = Date.now() - maxAge; + return updated.filter(d => d.timestamp > cutoff); + }); + }); + + return unsubscribe; + }); + + // Filtered data + const filteredData = $(() => { + const data = dataPoints(); + const category = selectedCategory(); + + if (category === 'all') return data; + return data.filter(d => d.category === category); + }); + + // Statistics + const statistics = $(() => { + const data = filteredData(); + if (data.length === 0) return null; + + const values = data.map(d => d.value); + return { + count: data.length, + avg: values.reduce((a, b) => a + b, 0) / values.length, + min: Math.min(...values), + max: Math.max(...values), + last: values[values.length - 1] + }; + }); + + // Cleanup on unmount + onUnmount(() => { + stream.destroy(); + }); + + return html` +
+

?? Real-time Dashboard

+ + +
+ + + + Time window: ${timeWindow}s +
+ + + ${() => { + const stats = statistics(); + if (!stats) return html`

Waiting for data...

`; + + return html` +
+
Points: ${stats.count}
+
Average: ${stats.avg.toFixed(2)}
+
Min: ${stats.min.toFixed(2)}
+
Max: ${stats.max.toFixed(2)}
+
Last: ${stats.last.toFixed(2)}
+
+ `; + }} + + +
+ ${() => filteredData().map(point => html` +
+ `)} +
+
+ `; +}, []); +``` + +## ?? Advanced Patterns + +### Custom Hooks + +```typescript +import { $, $$ } from 'sigpro'; + +// useLocalStorage hook +function useLocalStorage(key, initialValue) { + const stored = localStorage.getItem(key); + const signal = $(stored ? JSON.parse(stored) : initialValue); + + $$(() => { + localStorage.setItem(key, JSON.stringify(signal())); + }); + + return signal; +} + +// useDebounce hook +function useDebounce(signal, delay) { + const debounced = $(signal()); + let timeout; + + $$(() => { + const value = signal(); + clearTimeout(timeout); + timeout = setTimeout(() => { + debounced(value); + }, delay); + }); + + return debounced; +} + +// useFetch hook +function useFetch(url) { + const data = $(null); + const error = $(null); + const loading = $(true); + + const fetchData = async () => { + loading(true); + error(null); + try { + const response = await fetch(url()); + const json = await response.json(); + data(json); + } catch (e) { + error(e); + } finally { \ No newline at end of file diff --git a/sigpro.js b/sigpro.js new file mode 100644 index 0000000..429ebda --- /dev/null +++ b/sigpro.js @@ -0,0 +1,474 @@ +// Global state for tracking the current reactive effect +let activeEffect = null; + +// Queue for batched effect updates +const effectQueue = new Set(); +let isFlushScheduled = false; + +/** + * Flushes all pending effects in the queue + * Executes all queued jobs and clears the queue + */ +const flushEffectQueue = () => { + isFlushScheduled = false; + try { + for (const effect of effectQueue) { + effect.run(); + } + effectQueue.clear(); + } catch (error) { + console.error("SigPro Flush Error:", error); + } +}; + +/** + * Creates a reactive signal + * @param {any} initialValue - Initial value or getter function + * @returns {Function} Signal getter/setter function + */ +export const $ = (initialValue) => { + const subscribers = new Set(); + + if (typeof initialValue === "function") { + // Computed signal case + let isDirty = true; + let cachedValue; + + const computedEffect = { + dependencies: new Set(), + cleanupHandlers: new Set(), + markDirty: () => { + if (!isDirty) { + isDirty = true; + subscribers.forEach((subscriber) => { + if (subscriber.markDirty) subscriber.markDirty(); + effectQueue.add(subscriber); + }); + } + }, + run: () => { + // Clear old dependencies + computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect)); + computedEffect.dependencies.clear(); + + const previousEffect = activeEffect; + activeEffect = computedEffect; + try { + cachedValue = initialValue(); + } finally { + activeEffect = previousEffect; + isDirty = false; + } + }, + }; + + return () => { + if (activeEffect) { + subscribers.add(activeEffect); + activeEffect.dependencies.add(subscribers); + } + if (isDirty) computedEffect.run(); + return cachedValue; + }; + } + + // Regular signal case + return (...args) => { + if (args.length) { + const nextValue = typeof args[0] === "function" ? args[0](initialValue) : args[0]; + if (!Object.is(initialValue, nextValue)) { + initialValue = nextValue; + subscribers.forEach((subscriber) => { + if (subscriber.markDirty) subscriber.markDirty(); + effectQueue.add(subscriber); + }); + if (!isFlushScheduled && effectQueue.size) { + isFlushScheduled = true; + queueMicrotask(flushEffectQueue); + } + } + } + if (activeEffect) { + subscribers.add(activeEffect); + activeEffect.dependencies.add(subscribers); + } + return initialValue; + }; +}; + +/** + * 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 + */ +export const $$ = (effectFn) => { + const effect = { + dependencies: new Set(), + cleanupHandlers: new Set(), + run() { + // Run cleanup handlers + this.cleanupHandlers.forEach((handler) => handler()); + this.cleanupHandlers.clear(); + + // Clear old dependencies + this.dependencies.forEach((dependencySet) => dependencySet.delete(this)); + this.dependencies.clear(); + + const previousEffect = activeEffect; + activeEffect = this; + try { + const result = effectFn(); + if (typeof result === "function") this.cleanupFunction = result; + } finally { + activeEffect = previousEffect; + } + }, + stop() { + this.cleanupHandlers.forEach((handler) => handler()); + this.dependencies.forEach((dependencySet) => dependencySet.delete(this)); + this.cleanupFunction?.(); + }, + }; + + if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop()); + effect.run(); + return () => effect.stop(); +}; + +/** + * Tagged template literal for creating reactive HTML + * @param {string[]} strings - Template strings + * @param {...any} values - Dynamic values + * @returns {DocumentFragment} Reactive document fragment + */ +export const html = (strings, ...values) => { + const templateCache = html._templateCache ?? (html._templateCache = new WeakMap()); + + /** + * Gets a node by path from root + * @param {Node} root - Root node + * @param {number[]} path - Path indices + * @returns {Node} Target node + */ + const getNodeByPath = (root, path) => + path.reduce((node, index) => node?.childNodes?.[index], root); + + /** + * Applies reactive text content to a node + * @param {Node} node - Target node + * @param {any[]} values - Values to insert + */ + 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); + + let lastResult; + $$(() => { + let result = typeof currentValue === "function" ? currentValue() : currentValue; + if (result === lastResult) return; + lastResult = result; + + if (typeof result !== "object" && !Array.isArray(result)) { + const textNode = startMarker.nextSibling; + if (textNode !== endMarker && textNode?.nodeType === 3) { + textNode.textContent = result ?? ""; + } else { + while (startMarker.nextSibling !== endMarker) + parent.removeChild(startMarker.nextSibling); + parent.insertBefore(document.createTextNode(result ?? ""), endMarker); + } + return; + } + + // Handle arrays or objects + 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(); + }; + + // Get or create template from cache + 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); // NodeFilter.SHOW_ALL + + /** + * Gets path indices for a node + * @param {Node} node - Target node + * @returns {number[]} Path indices + */ + 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) { // Element node + 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}}")) { + // Text node + isDynamic = true; + } + + if (isDynamic) dynamicNodes.push(nodeInfo); + } + + templateCache.set(strings, (cachedTemplate = { template, dynamicNodes })); + } + + const fragment = cachedTemplate.template.content.cloneNode(true); + let valueIndex = 0; + + // Get target nodes before applyTextContent modifies the DOM + const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({ + node: getNodeByPath(fragment, nodeInfo.path), + info: nodeInfo + })); + + targets.forEach(({ node, info }) => { + if (!node) return; + + if (info.type === 1) { // Element node + info.parts.forEach((part) => { + const currentValue = values[valueIndex++]; + const attributeName = part.name; + const firstChar = attributeName[0]; + + if (firstChar === "@") { + // Event listener + node.addEventListener(attributeName.slice(1), currentValue); + } else if (firstChar === ":") { + // Two-way binding + const propertyName = attributeName.slice(1); + const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input"; + + $$(() => { + const value = typeof currentValue === "function" ? currentValue() : currentValue; + if (node[propertyName] !== value) node[propertyName] = value; + }); + + node.addEventListener(eventType, () => { + const value = eventType === "change" ? node.checked : node.value; + if (typeof currentValue === "function") currentValue(value); + }); + } else if (firstChar === "?") { + // Boolean attribute + const attrName = attributeName.slice(1); + $$(() => { + const result = typeof currentValue === "function" ? currentValue() : currentValue; + node.toggleAttribute(attrName, !!result); + }); + } else if (firstChar === ".") { + // Property binding + const propertyName = attributeName.slice(1); + $$(() => { + let result = typeof currentValue === "function" ? currentValue() : currentValue; + node[propertyName] = result; + if (result != null && typeof result !== "object" && typeof result !== "boolean") { + node.setAttribute(propertyName, result); + } + }); + } else { + // Regular attribute + if (typeof currentValue === "function") { + $$(() => node.setAttribute(attributeName, currentValue())); + } else { + node.setAttribute(attributeName, currentValue); + } + } + }); + } else if (info.type === 3) { // Text node + const placeholderCount = node.textContent.split("{{part}}").length - 1; + applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount)); + valueIndex += placeholderCount; + } + }); + + return fragment; +}; + +/** + * 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 + */ +export const $component = (tagName, setupFunction, observedAttributes = []) => { + if (customElements.get(tagName)) return; + + customElements.define( + tagName, + class extends HTMLElement { + static get observedAttributes() { + return observedAttributes; + } + + constructor() { + super(); + this._propertySignals = {}; + this.cleanupFunctions = []; + observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined))); + } + + connectedCallback() { + const frozenChildren = [...this.childNodes]; + this.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.querySelector(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, + onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn), + }; + + const result = setupFunction(this._propertySignals, context); + if (result instanceof Node) this.appendChild(result); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (this[name] !== newValue) this[name] = newValue; + } + + disconnectedCallback() { + this.cleanupFunctions.forEach((cleanupFn) => cleanupFn()); + this.cleanupFunctions = []; + } + }, + ); +}; + +/** + * Creates a router for hash-based navigation + * @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations + * @returns {HTMLDivElement} Router container element + */ +export const $router = (routes) => { + /** + * Gets current path from hash + * @returns {string} Current path + */ + const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/"; + + const currentPath = $(getCurrentPath()); + const container = document.createElement("div"); + container.style.display = "contents"; + + window.addEventListener("hashchange", () => { + const nextPath = getCurrentPath(); + if (currentPath() !== nextPath) currentPath(nextPath); + }); + + $$(() => { + const path = currentPath(); + let matchedRoute = null; + let routeParams = {}; + + for (const route of routes) { + if (route.path instanceof RegExp) { + const match = path.match(route.path); + if (match) { + matchedRoute = route; + routeParams = match.groups || { id: match[1] }; + break; + } + } else if (route.path === path) { + matchedRoute = route; + break; + } + } + + const previousEffect = activeEffect; + activeEffect = null; + + try { + const view = matchedRoute + ? matchedRoute.component(routeParams) + : html` +

404

+ `; + + container.replaceChildren( + view instanceof Node ? view : document.createTextNode(view ?? "") + ); + } finally { + activeEffect = previousEffect; + } + }); + + return container; +}; + +/** + * Navigates to a specific route + * @param {string} path - Target path + */ +$router.go = (path) => { + const targetPath = path.startsWith("/") ? path : `/${path}`; + if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath; +}; \ No newline at end of file