From bafe02e7ede47f0bb60876022de4e1809dccc52b Mon Sep 17 00:00:00 2001 From: Geoff Doty Date: Thu, 27 Mar 2025 12:16:11 -0500 Subject: [PATCH] initial commit --- src/Mite.js | 237 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 src/Mite.js diff --git a/src/Mite.js b/src/Mite.js new file mode 100644 index 0000000..acb00f6 --- /dev/null +++ b/src/Mite.js @@ -0,0 +1,237 @@ +// Signal implementation +function signal(value) { + const subscribers = new Set(); + return { + get value() { + return value; + }, + set value(newValue) { + value = newValue; + subscribers.forEach(fn => fn()); + }, + subscribe(fn) { + subscribers.add(fn); + return () => subscribers.delete(fn); + } + }; +} + +export class Mite { + constructor(selector, options) { + this.root = document.querySelector(selector); + if (!this.root) throw new Error(`Element ${selector} not found`); + + this.state = {}; + for (let key in options.state) { + this.state[key] = signal(options.state[key]); + } + + this.methods = options || {}; + this.lifecycle = { + mounted: options.mounted || (() => {}), + unmounted: options.unmounted || (() => {}) + }; + + for (let key in this.methods) { + if (typeof this.methods[key] === 'function') { + this.methods[key] = this.methods[key].bind(this); + } + } + + this.template = this.root.cloneNode(true); + this.bindings = new Map(); + + this.render(); + this.lifecycle.mounted.call(this); + } + + render() { + this.bindings.forEach(binding => binding.forEach(b => b.unsubscribe?.())); + this.bindings.clear(); + + const fragment = this.template.cloneNode(true); + this.root.innerHTML = ''; + this.root.appendChild(fragment); + + this.scanAndBind(this.root); + + for (let key in this.state) { + this.update(key); + } + } + + scanAndBind(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('{')) { + const matches = node.textContent.match(/{(\w+)}/g); + if (matches) { + matches.forEach(token => { + const key = token.slice(1, -1); + if (this.state[key]) { + if (!this.bindings.has(key)) this.bindings.set(key, []); + const updateFn = () => this.update(key); + const unsubscribe = this.state[key].subscribe(updateFn); + this.bindings.get(key).push({ node, type: 'text', original: node.textContent, unsubscribe }); + } + }); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + for (let attr of node.attributes) { + const matches = attr.value.match(/{(\w+)}/g); + if (matches) { + if (attr.name.startsWith('@')) { + const eventName = attr.name.slice(1); + const methodName = attr.value.slice(1, -1); + if (this.methods[methodName]) { + node.addEventListener(eventName, this.methods[methodName]); + } + } else { + matches.forEach(token => { + const key = token.slice(1, -1); + if (this.state[key]) { + if (!this.bindings.has(key)) this.bindings.set(key, []); + const updateFn = () => this.update(key); + const unsubscribe = this.state[key].subscribe(updateFn); + this.bindings.get(key).push({ node, type: 'attr', attrName: attr.name, original: attr.value, unsubscribe }); + } + }); + } + } + } + + const rif = node.getAttribute('r-if'); + if (rif && rif.match(/{(\w+)}$/)) { + const key = rif.slice(1, -1); + if (this.state[key]) { + if (!this.bindings.has(key)) this.bindings.set(key, []); + const updateFn = () => this.update(key); + const unsubscribe = this.state[key].subscribe(updateFn); + this.bindings.get(key).push({ node, type: 'if', unsubscribe }); + } + } + + const reach = node.getAttribute('r-each'); + if (reach) { + const match = reach.match(/{(.+?)}/); + if (!match) { + console.error(`Invalid r-each syntax: ${reach}`); + continue; + } + const content = match[1]; + + const parts = content.split(' in '); + if (parts.length !== 2) { + console.error(`Invalid r-each split on " in ": ${content}, parts: ${parts}`); + continue; + } + let [names, listName] = parts; + + names = names.trim(); + listName = listName.trim(); + const nameParts = names.split(',').map(part => part.trim()); + const itemName = nameParts[0]; + const indexName = nameParts.length > 1 ? nameParts[1] : undefined; + + if (this.state[listName]) { + if (!this.bindings.has(listName)) this.bindings.set(listName, []); + const updateFn = () => this.update(listName); + const unsubscribe = this.state[listName].subscribe(updateFn); + this.bindings.get(listName).push({ + node, + type: 'each', + itemName, + indexName, + unsubscribe, + originalReach: reach, + parentSelector: node.parentNode.tagName.toLowerCase() + }); + } else { + console.error(`State key not found: ${listName}`); + } + } + } + } + } + + update(key) { + const bindings = this.bindings.get(key) || []; + bindings.forEach(binding => { + // Remove the parentNode check for 'each' bindings since we use parentSelector + if (!binding.node) return; + + switch (binding.type) { + case 'text': + if (!binding.node.parentNode) return; + binding.node.textContent = binding.original.replace(/{(\w+)}/g, (_, k) => { + return this.state[k] ? this.state[k].value : `{${k}}`; + }); + break; + + case 'attr': + if (!binding.node.parentNode) return; + binding.node.setAttribute(binding.attrName, binding.original.replace(/{(\w+)}/g, (_, k) => { + return this.state[k] ? this.state[k].value : `{${k}}`; + })); + break; + + case 'if': + if (!binding.node.parentNode) return; + binding.node.style.display = this.state[key].value ? '' : 'none'; + break; + + case 'each': + let parent = this.root.querySelector(binding.parentSelector); + if (!parent) { + console.error(`Parent node (${binding.parentSelector}) not found for r-each="${binding.originalReach}"`); + return; + } + const template = this.template.querySelector(`[r-each="${binding.originalReach}"]`); + if (!template) { + console.error(`Template not found for r-each="${binding.originalReach}"`); + return; + } + const items = this.state[key].value || []; + + parent.innerHTML = ''; + + items.forEach((item, index) => { + const clone = template.cloneNode(true); + clone.removeAttribute('r-each'); + const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT); + let textNode; + while ((textNode = walker.nextNode())) { + if (textNode.textContent.includes('{')) { + textNode.textContent = textNode.textContent.replace(/{(\w+)}/g, (_, k) => { + if (k === binding.itemName) return item; + if (k === binding.indexName) return index; + return this.state[k] ? this.state[k].value : `{${k}}`; + }); + } + } + const eventElements = clone.querySelectorAll('[\\@click]'); + eventElements.forEach(el => { + const clickAttr = el.getAttribute('@click'); + if (clickAttr && clickAttr.match(/{(\w+),\s*(\w+)}/)) { + const [, methodName, param] = clickAttr.match(/{(\w+),\s*(\w+)}/); + if (this.methods[methodName] && param === binding.indexName) { + el.addEventListener('click', (e) => this.methods[methodName](e, index)); + } + } + }); + parent.appendChild(clone); + }); + break; + } + }); + } + + destroy() { + this.lifecycle.unmounted.call(this); + this.bindings.forEach(binding => binding.forEach(b => b.unsubscribe?.())); + this.root.innerHTML = this.template.innerHTML; + } +} + +export default Mite;