refactor: replace Jinja templates with static frontend pages

This commit is contained in:
2026-06-16 03:35:41 +00:00
parent 2874680ffa
commit 593dece92b
39 changed files with 3532 additions and 2801 deletions
+317
View File
@@ -0,0 +1,317 @@
/**
* Hoover — vdom.js
*
* Virtual DOM: h() factory, vnode creation, diffing, patching.
* Maintains _vnodeDom WeakMap for vnode ↔ DOM element resolution.
*
* Critical fixes vs. reactive-dom.js:
* - _vnodeDom updated after EVERY vnode→dom assignment
* - Keyed diff with proper element reordering
* - Unkeyed diff with anchor tracking
* - Proper unmountTree for cleanup (fires registered onUnmount hooks)
*/
// Exported so render.js can access it
export const _vnodeDom = new WeakMap();
// Lifecycle hooks registry (component.js populates this)
export const _mountFn = { fn: null };
export const _unmountFn = { fn: null };
export function setMountFn(fn) { _mountFn.fn = fn; }
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
/**
* Build a VNode. Three forms:
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
* h(ComponentFn, { prop: 1 }, child1, child2) — component (fn called)
* h('#text', 'some text') — text node
*/
export function h(tag, props, ...children) {
if (typeof tag === 'function') {
const base = typeof props === 'object' && props !== null ? props : {};
if (!base.children && children.length)
base.children = flatten(children);
return tag(base);
}
if (tag === '#text')
return { tag: '#text', text: String(props) };
if (tag === '#comp') {
return { tag: '#comp', props: props || {}, ch: flatten(children) };
}
return { tag, props: props || {}, ch: flatten(children) };
}
/** Flatten nested arrays / primitives → VNode array. */
function flatten(arr) {
const out = [];
for (const c of arr.flat(Infinity)) {
if (c == null || typeof c === 'boolean') continue;
out.push(
typeof c === 'string' || typeof c === 'number'
? { tag: '#text', text: String(c) }
: c,
);
}
return out;
}
/**
* Look up the real DOM element for a VNode via _vnodeDom.
*/
export function getDom(vnode) {
return vnode ? _vnodeDom.get(vnode) : null;
}
/**
* Create a real DOM element (or subtree) from a VNode.
* Also registers _vnodeDom mapping for the created element and all descendants.
*/
export function createDom(vnode) {
if (!vnode) return document.createTextNode('');
if (vnode.tag === '#text') {
const tn = document.createTextNode(vnode.text || '');
_vnodeDom.set(vnode, tn);
return tn;
}
const el = document.createElement(vnode.tag);
applyProps(el, vnode.props);
_vnodeDom.set(vnode, el);
for (const c of vnode.ch || []) {
el.appendChild(createDom(c));
}
return el;
}
/** Apply every prop on an element (initial mount). */
export function applyProps(el, props) {
for (const [k, v] of Object.entries(props)) setProp(el, k, v);
}
/** Set a single prop (or event) on an element. */
export function setProp(el, key, value) {
if (key === 'key' || key === 'ref') return;
if (key === 'html') { el.innerHTML = String(value); return; }
if (key === 'innerHTML') { el.innerHTML = String(value); return; }
if (key === 'textContent') { el.textContent = String(value); return; }
if (key.startsWith('on:')) {
const ev = key.slice(3);
const map = el._evMap || {};
if (map[ev]) el.removeEventListener(ev, map[ev]);
if (typeof value === 'function') {
el.addEventListener(ev, value);
map[ev] = value;
} else delete map[ev];
el._evMap = map;
return;
}
if (key === 'class' && typeof value === 'object' && value !== null) {
el.className = Object.keys(value).filter(k => value[k]).join(' ');
return;
}
if (key === 'style' && typeof value === 'object' && value !== null) {
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
return;
}
const tag = el.tagName.toLowerCase();
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) {
el.value = value == null ? '' : String(value); return;
}
if (key === 'checked' && tag === 'input') { el.checked = !!value; return; }
if (key === 'disabled') { el.disabled = !!value; return; }
if (key === 'selected' && tag === 'option') { el.selected = !!value; return; }
if (value == null || value === false || value === undefined)
el.removeAttribute(key);
else
el.setAttribute(key, value === true ? '' : String(value));
}
/** Remove a single prop from an element. */
export function unsetProp(el, key) {
if (key === 'key' || key === 'ref') return;
if (key.startsWith('on:')) {
const ev = key.slice(3);
const map = el._evMap || {};
if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; }
el._evMap = map;
return;
}
const tag = el.tagName.toLowerCase();
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return;
if (key === 'checked' && tag === 'input') { el.checked = false; return; }
if (key === 'disabled') { el.disabled = false; return; }
if (key === 'selected' && tag === 'option') { el.selected = false; return; }
el.removeAttribute(key);
}
/** Diff two props objects and patch the element in place. */
export function patchProps(el, oldP = {}, newP = {}) {
for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) {
const hasOld = k in oldP, hasNew = k in newP;
if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue;
if (hasNew) setProp(el, k, newP[k]);
else unsetProp(el, k);
}
}
/** Recursively clean up event listeners and child nodes. */
export function sweepDom(el) {
if (_unmountFn.fn) _unmountFn.fn(el);
for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]);
while (el.firstChild) {
const child = el.firstChild;
if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child);
el.removeChild(child);
}
}
/**
* Patch children of a parent element.
* Dispatches to keyed or unkeyed patching based on whether any vnode has a key.
*/
export function patchChildren(parent, oldCh, newCh) {
const hasKeys = (ch) => ch.some(v => v?.props?.key != null);
if (hasKeys(newCh) && hasKeys(oldCh))
patchKeyed(parent, oldCh, newCh);
else
patchUnkeyed(parent, oldCh, newCh);
}
/**
* Unkeyed (index-based) children diff.
*
* Fix: _vnodeDom updated after EVERY vnode→dom assignment.
*/
export function patchUnkeyed(parent, oldCh, newCh) {
const maxLen = Math.max(oldCh.length, newCh.length);
let lastDom = null;
for (let i = 0; i < maxLen; i++) {
const oldV = oldCh[i], newV = newCh[i];
if (!newV && oldV) {
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
}
continue;
}
if (newV && !oldV) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
lastDom = d;
continue;
}
patchNode(parent, oldV, newV, null);
lastDom = getDom(newV);
}
}
/**
* Keyed children diff — preserves order, reuses DOM by key.
*
* Fix: proper element reordering using lastDom anchor tracking.
*/
export function patchKeyed(parent, oldCh, newCh) {
const oldMap = new Map(
oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v])
);
const toRemove = new Set(oldMap.keys());
let lastDom = null;
for (const newV of newCh) {
const key = newV.props?.key;
toRemove.delete(key);
const oldV = oldMap.get(key);
if (oldV) {
patchNode(parent, oldV, newV, null);
const d = getDom(newV);
if (d) {
if (lastDom && d !== lastDom.nextSibling) {
parent.insertBefore(d, lastDom.nextSibling || null);
}
lastDom = d;
}
} else {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
lastDom = d;
}
}
for (const key of toRemove) {
const oldV = oldMap.get(key);
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
}
}
}
/**
* Patch one VNode against another inside parent.
*
* - no old → create + insert
* - no new → sweep + remove
* - tag match → patchProps + patchChildren
* - tag mismatch → replace
*
* Fix: _vnodeDom always set to the correct dom after patch.
*/
export function patchNode(parent, oldV, newV, anchor) {
if (!oldV && !newV) return;
if (!oldV) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, anchor || null);
return;
}
if (!newV) {
const d = getDom(oldV);
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
}
return;
}
const dom = getDom(oldV);
if (!dom || !dom.parentNode) {
const d = createDom(newV);
_vnodeDom.set(newV, d);
parent.insertBefore(d, anchor || null);
return;
}
// Tag changed → full replace
if (oldV.tag !== newV.tag) {
if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
dom.parentNode.replaceChild(nd, dom);
return;
}
// Text node — fast path
if (oldV.tag === '#text') {
if (oldV.text !== newV.text) dom.nodeValue = newV.text;
_vnodeDom.set(newV, dom);
return;
}
// Element: patch in place
patchProps(dom, oldV.props || {}, newV.props || {});
patchChildren(dom, oldV.ch || [], newV.ch || []);
_vnodeDom.set(newV, dom);
}