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
JavaScript
// 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;
}