UNPKG

jims-web-builder

Version:

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

530 lines (487 loc) 20.5 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', 'data-on-click']; 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.nodeType === 1 ? oldNode.getAttribute('data-key') : null; const newKey = newNode.nodeType === 1 ? newNode.getAttribute('data-key') : null; 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 .filter(c => c.nodeType === 1) .map(c => [c.getAttribute('data-key'), c]) .filter(([k]) => k)); const newKeyed = new Map(newChildren .filter(c => c.nodeType === 1) .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?.nodeType === 1 && newChild?.nodeType === 1 && 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, '[^/]+)') + '$'); 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 class class JimsWebBuilder { static registry = new ComponentRegistry(); static router = new Router(); static instances = new Set(); static plugins = []; static debugMode = false; static createStore(def, moduleName) { return new Store(def, moduleName); } static css(href, lazy = false) { return { type: 'css', href, lazy }; } static js(src, attributes = {}, lazy = false) { return { type: 'js', src, attributes, lazy }; } static html(src, lazy = false) { return { type: 'html', src, lazy }; } static RenderFile(path) { return { type: 'renderFile', path }; } static use(plugin) { this.plugins.push(plugin); } static provide(key, value) { context.set(key, value); } static inject(key) { return context.get(key); } static debounce(fn, delay) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => fn.apply(this, args), delay); }; } static sanitize(html) { return sanitize(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); } 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 }); }); } processEvents(node) { if (!node || isServer) return; const eventConfig = { standardEvents: [ 'click', 'input', 'submit', 'change', 'focus', 'blur', 'mouseenter', 'mouseleave', 'keydown', 'keyup', 'touchstart', 'touchend' ], customEvents: this.config.events || [], prefixes: ['data-on-'] }; const allEvents = [...new Set([ ...eventConfig.standardEvents, ...eventConfig.customEvents ])]; allEvents.forEach(event => { node.removeEventListener(event, this._delegatedHandlers?.[event]); const handler = (e) => { for (const prefix of eventConfig.prefixes) { const selector = `[${prefix}${event}]`; let target; try { target = e.target.closest(selector); } catch (error) { if (JimsWebBuilder.debugMode) { console.warn(`Invalid selector ${selector}: ${error.message}`); } continue; } if (target) { const methodName = target.getAttribute(`${prefix}${event}`); if (this.config.methods?.[methodName]) { try { 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; } } }; this._delegatedHandlers = this._delegatedHandlers || {}; this._delegatedHandlers[event] = handler; node.addEventListener(event, handler); }); } 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; }