2025-03-27 17:16:11 +00:00
|
|
|
// 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 = {};
|
2025-03-27 17:27:49 +00:00
|
|
|
for (const key in options.state) {
|
2025-03-27 17:16:11 +00:00
|
|
|
this.state[key] = signal(options.state[key]);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.methods = options || {};
|
|
|
|
this.lifecycle = {
|
|
|
|
mounted: options.mounted || (() => {}),
|
|
|
|
unmounted: options.unmounted || (() => {})
|
|
|
|
};
|
|
|
|
|
2025-03-27 17:27:49 +00:00
|
|
|
for (const key in this.methods) {
|
|
|
|
if (typeof this.methods[key] === "function") {
|
2025-03-27 17:16:11 +00:00
|
|
|
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);
|
|
|
|
|
2025-03-27 17:27:49 +00:00
|
|
|
for (const key in this.state) {
|
2025-03-27 17:16:11 +00:00
|
|
|
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) {
|
2025-03-27 17:27:49 +00:00
|
|
|
for (const attr of node.attributes) {
|
2025-03-27 17:16:11 +00:00
|
|
|
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;
|
|
|
|
|
2025-03-27 17:27:49 +00:00
|
|
|
case 'each': {
|
|
|
|
const parent = this.root.querySelector(binding.parentSelector);
|
2025-03-27 17:16:11 +00:00
|
|
|
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;
|
2025-03-27 17:27:49 +00:00
|
|
|
}
|
2025-03-27 17:16:11 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
this.lifecycle.unmounted.call(this);
|
|
|
|
this.bindings.forEach(binding => binding.forEach(b => b.unsubscribe?.()));
|
|
|
|
this.root.innerHTML = this.template.innerHTML;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export default Mite;
|