UNPKG

jims-web-builder

Version:

A reactive, declarative, slot-driven component builder for the modern web — no build tools required.

610 lines (563 loc) 23.3 kB
// Global store for state management const globalStore = { modules: new Map() }; // Resource cache const resourceCache = new Map(); // Global context for dependency injection const context = new Map(); // Expression cache for performance const expressionCache = new Map(); // SSR detection const isServer = typeof window === 'undefined'; // Improved expression parser with caching function safeEvalExpression(expr, context) { if (expressionCache.has(expr)) { return expressionCache.get(expr)(context); } try { const fn = new Function('ctx', `with (ctx) { return ${expr}; }`); expressionCache.set(expr, fn); return fn(context); } catch (e) { console.warn(`Expression "${expr}" failed: ${e.message}`); return `Error: Invalid expression "${expr}"`; } } // Retry fetch with exponential backoff async function retryFetch(url, retries = 3, delay = 1000) { for (let i = 0; i < retries; i++) { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); } catch (e) { if (i === retries - 1) { console.error(`Failed to fetch ${url} after ${retries} attempts: ${e.message}`); throw e; } await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i))); } } } // Sanitize HTML function sanitize(html) { const allowedTags = ['div', 'span', 'p', 'h1', 'h2', 'h3', 'a', 'button', 'input', 'ul', 'li', 'form']; const allowedAttrs = ['id', 'class', 'href', 'type', 'value', 'style', 'data-key']; let clean = html; allowedTags.forEach(tag => { clean = clean.replace(new RegExp(`</?${tag}(?:\\s+[^>]+)?>`, 'gi'), match => { if (!match.includes('=')) return match; return match.replace(/([a-zA-Z-]+)="[^"]*"/g, (attr, name) => allowedAttrs.includes(name) ? attr : ''); }); }); return clean.replace(/<[a-zA-Z-]+[^>]*>/g, match => allowedTags.some(tag => match.startsWith(`<${tag}`)) ? match : ''); } // Create virtual DOM function createVdom(html) { const parser = isServer ? require('node-html-parser').parse : new DOMParser(); return isServer ? parser(html) : parser.parseFromString(html, 'text/html').body.firstChild; } // Optimized diff and patch with key-based reconciliation function diffAndPatch(oldNode, newNode, parent, component) { if (!oldNode && newNode) { parent.appendChild(newNode); component.processEvents(newNode); } else if (oldNode && !newNode) { oldNode.remove(); } else if (oldNode.nodeType !== newNode.nodeType || oldNode.tagName !== newNode.tagName) { parent.replaceChild(newNode, oldNode); component.processEvents(newNode); } else if (oldNode.nodeType === 3 && newNode.nodeType === 3) { if (oldNode.textContent !== newNode.textContent) oldNode.textContent = newNode.textContent; } else { const oldKey = oldNode.getAttribute('data-key'); const newKey = newNode.getAttribute('data-key'); if (oldKey && newKey && oldKey !== newKey) { parent.replaceChild(newNode, oldNode); component.processEvents(newNode); return; } const oldAttrs = oldNode.attributes || []; const newAttrs = newNode.attributes || []; for (let attr of oldAttrs) if (!newAttrs[attr.name]) oldNode.removeAttribute(attr.name); for (let attr of newAttrs) if (oldNode.getAttribute(attr.name) !== attr.value) oldNode.setAttribute(attr.name, attr.value); const oldChildren = Array.from(oldNode.childNodes); const newChildren = Array.from(newNode.childNodes); const max = Math.max(oldChildren.length, newChildren.length); const oldKeyed = new Map(oldChildren.map(c => [c.getAttribute?.('data-key'), c]).filter(([k]) => k)); const newKeyed = new Map(newChildren.map(c => [c.getAttribute?.('data-key'), c]).filter(([k]) => k)); for (let i = 0; i < max; i++) { const oldChild = oldChildren[i]; const newChild = newChildren[i]; if (oldChild?.getAttribute('data-key') && newChild?.getAttribute('data-key')) { if (oldKeyed.has(newChild.getAttribute('data-key'))) { diffAndPatch(oldKeyed.get(newChild.getAttribute('data-key')), newChild, oldNode, component); } else { oldNode.insertBefore(newChild, oldChild); component.processEvents(newChild); } } else { diffAndPatch(oldChild, newChild, oldNode, component); } } } } // ComponentRegistry class class ComponentRegistry { constructor() { this.components = new Map(); } define(id, config) { this.components.set(id, config); } async lazyDefine(id, src) { try { const response = await retryFetch(src); const config = JSON.parse(response); this.define(id, config); } catch (e) { console.error(`Failed to load component ${id}: ${e.message}`); throw e; } } get(id) { return this.components.get(id); } } // Router class class Router { constructor(options = {}) { this.routes = []; this.container = options.container || document.body; if (!isServer) { window.addEventListener('popstate', () => this.navigate()); } } add(path, componentId, options = {}) { this.routes.push({ path, componentId, ...options }); } go(path) { if (!isServer) { history.pushState({}, '', path); this.navigate(); } } navigate() { const path = isServer ? this.currentPath : window.location.pathname; const route = this.routes.find(r => { const regex = new RegExp('^' + r.path.replace(/:[^/]+/g, '([^/]+)') + '$'); return regex.test(path); }); if (!route) { console.warn(`No route found for ${path}`); return; } if (route.guard && !route.guard({ params: this.getParams(route.path, path) })) { console.warn(`Navigation to ${path} blocked by guard`); return; } const config = JimsWebBuilder.registry.get(route.componentId); if (!config) { console.warn(`Component ${route.componentId} not found`); return; } const component = new JimsWebBuilder({ id: route.componentId }); component.init(config, { params: this.getParams(route.path, path) }); this.container.innerHTML = ''; this.container.appendChild(component.element); } getParams(routePath, currentPath) { const regex = new RegExp('^' + routePath.replace(/:[^/]+/g, ' Crocodile[^/]+)') + '$'); return (regex.exec(currentPath) || []).slice(1); } } // Store class with memoized computed properties class Store { constructor(def, moduleName = 'default') { this.state = new Proxy(def.state || {}, { set: (obj, prop, value) => { obj[prop] = value; this.notify(prop); return true; } }); this.actions = def.actions || {}; this.computed = {}; this.computedCache = new Map(); for (let key in def.computed) { Object.defineProperty(this.computed, key, { get: () => { if (!this.computedCache.has(key)) { this.computedCache.set(key, def.computed[key]({ state: this.state })); } return this.computedCache.get(key); } }); } globalStore.modules.set(moduleName, this); } notify(prop) { this.computedCache.clear(); // Invalidate computed cache on state change JimsWebBuilder.instances.forEach(c => c.queueRender()); } } /** * JimsWebBuilder: A lightweight framework for building web applications. * @class */ class JimsWebBuilder { static registry = new ComponentRegistry(); static router = new Router(); static instances = new Set(); static plugins = []; static debugMode = false; /** * Creates a new store for state management. * @param {Object} def - Store definition with state, actions, and computed propertiesastie. * @param {string} [moduleName='default'] - Name of the store module. * @returns {Store} The created store instance. */ static createStore(def, moduleName) { return new Store(def, moduleName); } /** * Registers a CSS resource. * @param {string} href - URL of the CSS file. * @param {boolean} [lazy=false] - Whether to load lazily. */ static css(href, lazy = false) { return { type: 'css', href, lazy }; } /** * Registers a JavaScript resource. * @param {string} src - URL of the JS file. * @param {Object} [attributes={}] - Script attributes. * @param {boolean} [lazy=false] - Whether to load lazily. */ static js(src, attributes = {}, lazy = false) { return { type: 'js', src, attributes, lazy }; } /** * Registers an HTML resource. * @param {string} src - URL of the HTML file. * @param {boolean} [lazy=false] - Whether to load lazily. */ static html(src, lazy = false) { return { type: 'html', src, lazy }; } /** * Registers a render file. * @param {string} path - Path to the render file. */ static RenderFile(path) { return { type: 'renderFile', path }; } /** * Registers a plugin. * @param {Function} plugin - Plugin function that receives component and config. */ static use(plugin) { this.plugins.push(plugin); } /** * Provides a value to the dependency injection context. * @param {string} key - Context key. * @param {*} value - Value to provide. */ static provide(key, value) { context.set(key, value); } /** * Injects a value from the dependency injection context. * @param {string} key - Context key. * @returns {*} The injected value. */ static inject(key) { return context.get(key); } /** * Debounces a function. * @param {Function} fn - Function to debounce. * @param {number} delay - Debounce delay in ms. * @returns {Function} Debounced function. */ static debounce(fn, delay) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => fn.apply(this, args), delay); }; } /** * Sanitizes HTML to prevent XSS. * @param {string} html - HTML string to sanitize. * @returns {string} Sanitized HTML. */ static sanitize(html) { return sanitize(html); } /** * Renders a component to a string for SSR. * @param {string} id - Component ID. * @param {Object} config - Component configuration. * @param {Object} [props={}] - Component props. * @returns {Promise<string>} Rendered HTML. */ static async renderToString(id, config, props = {}) { const component = new JimsWebBuilder({ id }); await component.init(config, props, true); return component.element.outerHTML || component.element.innerHTML; } constructor(element) { this.element = isServer ? { innerHTML: '', setAttribute: () => {}, getAttribute: () => null } : element; this.id = element.id || element; this.state = {}; this.localStore = null; this.props = {}; this.pendingUpdates = new Set(); JimsWebBuilder.instances.add(this); } /** * Initializes the component. * @param {Object} config - Component configuration. * @param {Object} [props={}] - Component props. * @param {boolean} [isSSR=false] - Whether rendering on the server. */ async init(config, props = {}, isSSR = false) { this.config = config || JimsWebBuilder.registry.get(this.id); if (!this.config) { this.renderError(`No config for component ${this.id}`); return; } JimsWebBuilder.plugins.forEach(plugin => plugin(this, this.config)); this.state = new Proxy(this.config.state || {}, { set: (obj, prop, value) => { obj[prop] = value; this.pendingUpdates.add(prop); this.queueRender(); return true; } }); this.props = props; this.localStore = this.config.store ? new Store(this.config.store, `${this.id}-store`) : null; if (this.config.sfc) { try { const sfc = await retryFetch(this.config.sfc); const templateMatch = sfc.match(/<template>([\s\S]*?)<\/template>/); const scriptMatch = sfc.match(/<script type="application\/json">([\s\S]*?)<\/script>/); const styleMatch = sfc.match(/<style>([\s\S]*?)<\/style>/); if (templateMatch) this.config.render = () => templateMatch[1]; if (scriptMatch) Object.assign(this.config, JSON.parse(scriptMatch[1])); if (styleMatch) { const style = document.createElement('style'); style.textContent = styleMatch[1].replace(/:scope/g, `.jwb-scope-${this.id}`); document.head.appendChild(style); } } catch (e) { this.renderError(`Failed to load SFC: ${e.message}`); return; } } if (this.config.links) await this.loadResources(this.config.links, isSSR); if (this.config.beforeLoad) this.config.beforeLoad.call(this); if (!isSSR && this.config.onMount) { this.element.addEventListener('DOMContentLoaded', () => this.config.onMount.call(this)); } if (!isSSR && this.config.onUpdate) { const observer = new MutationObserver(() => { this.config.onUpdate.call(this, this.props); this.queueRender(); }); observer.observe(this.element, { attributes: true }); } await this.render(this.config, isSSR); if (this.config.afterLoad) this.config.afterLoad.call(this); } async loadResources(links, isSSR) { await Promise.all(links.map(async link => { if (link.lazy && isSSR) return; try { if (link.type === 'css' && !resourceCache.has(link.href)) { const content = isSSR ? await retryFetch(link.href) : ''; resourceCache.set(link.href, content); if (!isSSR) { const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = link.href; document.head.appendChild(style); } } else if (link.type === 'js' && !resourceCache.has(link.src)) { const content = isSSR ? await retryFetch(link.src) : ''; resourceCache.set(link.src, content); if (!isSSR) { const script = document.createElement('script'); script.src = link.src; for (let attr in link.attributes) script.setAttribute(attr, link.attributes[attr]); document.head.appendChild(script); } } else if (link.type === 'html' && !resourceCache.has(link.src)) { resourceCache.set(link.src, await retryFetch(link.src)); } } catch (e) { this.renderError(`Failed to load resource ${link.href || link.src}: ${e.message}`); } })); } async render(config, isSSR = false) { try { if (config.beforeRender) config.beforeRender.call(this); let html = ''; if (typeof config.render === 'function') { html = config.render({ state: this.state, methods: config.methods, computed: this.localStore?.computed, props: this.props, inject: JimsWebBuilder.inject }); } else if (config.render?.type === 'renderFile') { if (!resourceCache.has(config.render.path)) { resourceCache.set(config.render.path, await retryFetch(config.render.path)); } html = resourceCache.get(config.render.path); } else { html = config.render || ''; } html = this.processTemplate(html); const newVdom = createVdom(sanitize(html)); if (isSSR) { this.element.innerHTML = newVdom.outerHTML || newVdom.innerHTML; } else { diffAndPatch(this.element.firstChild, newVdom, this.element, this); } if (config.afterRender) config.afterRender.call(this); } catch (e) { this.renderError(`Render failed: ${e.message}`); } } renderError(message) { const html = this.config.errorBoundary ? this.config.errorBoundary(message) : ` <div style="border: 2px solid red; padding: 10px;"> <h3>Error</h3> <p>${message}</p> </div> `; if (!isServer) { this.element.innerHTML = sanitize(html); } if (JimsWebBuilder.debugMode) { console.error(`Component ${this.id}: ${message}`, new Error().stack); } } processTemplate(html) { return html.replace(/{{([^}]+)}}/g, (match, expr) => { return safeEvalExpression(expr.trim(), { state: this.state, modules: globalStore.modules, props: this.props }); }); } /** * Enhanced event processing with: * - Proper CSS selectors * - Event delegation for performance * - Better error handling * - Support for both @ and data-on- prefixes */ processEvents(node) { if (!node || isServer) return; // Configuration for event handling const eventConfig = { // Standard DOM events standardEvents: [ 'click', 'input', 'submit', 'change', 'focus', 'blur', 'mouseenter', 'mouseleave', 'keydown', 'keyup', 'touchstart', 'touchend' ], // Custom events from component config customEvents: this.config.events || [], // Prefix options (supports both @event and data-on-event) prefixes: ['@', 'data-on-'] }; // Combine all events to process const allEvents = [...new Set([ ...eventConfig.standardEvents, ...eventConfig.customEvents ])]; // Create a single delegated event listener per event type allEvents.forEach(event => { // Remove existing listener to avoid duplicates node.removeEventListener(event, this._delegatedHandlers?.[event]); // Create new delegated handler const handler = (e) => { // Check all possible attribute prefixes for (const prefix of eventConfig.prefixes) { const selector = `[${prefix}${event}]`; const target = e.target.closest(selector); if (target) { const methodName = target.getAttribute(`${prefix}${event}`); if (this.config.methods?.[methodName]) { try { // Apply method with component as context this.config.methods[methodName].call(this, e); if (JimsWebBuilder.debugMode) { console.debug( `[${this.id}] ${event} -> ${methodName}`, { target, event: e } ); } } catch (error) { console.error( `[${this.id}] Error in ${methodName}:`, error ); } } else if (JimsWebBuilder.debugMode) { console.warn( `[${this.id}] Missing method: ${methodName}` ); } break; // Stop checking other prefixes once found } } }; // Store reference for removal later this._delegatedHandlers = this._delegatedHandlers || {}; this._delegatedHandlers[event] = handler; // Add event listener node.addEventListener(event, handler); }); } /** * Programmatically triggers a custom event on the component's root element. * @param {string} eventName - Name of the custom event to trigger. * @param {Object} [detail={}] - Data to pass with the custom event. */ triggerCustomEvent(eventName, detail = {}) { if (isServer) return; const customEvent = new CustomEvent(eventName, { detail, bubbles: true, cancelable: true }); this.element.dispatchEvent(customEvent); if (JimsWebBuilder.debugMode) { console.log(`Custom event ${eventName} triggered on component ${this.id}`, detail); } } queueRender = JimsWebBuilder.debounce(() => { if (!isServer) { requestAnimationFrame(() => this.render(this.config)); } else { this.render(this.config); } }, 16); destroy() { if (this.config.onDestroy) this.config.onDestroy.call(this); JimsWebBuilder.instances.delete(this); } } // Debug tools if (JimsWebBuilder.debugMode) { window._JWB_DEVTOOLS_ = { getComponent(id) { return Array.from(JimsWebBuilder.instances).find(c => c.id === id); }, getStore(moduleName) { return globalStore.modules.get(moduleName); }, logState(moduleName, key) { const store = globalStore.modules.get(moduleName); if (store) { console.log(`${moduleName}.${key}:`, store.state[key]); } }, registry: JimsWebBuilder.registry, router: JimsWebBuilder.router, triggerCustomEvent(id, eventName, detail) { const component = Array.from(JimsWebBuilder.instances).find(c => c.id === id); if (component) component.triggerCustomEvent(eventName, detail); } }; } // Module exports for npm if (typeof module !== 'undefined' && module.exports) { module.exports = JimsWebBuilder; module.exports.default = JimsWebBuilder; // For ES modules }