vite-plugin-vanjs
Version:
An async first mini meta-framework for VanJS powered by Vite
283 lines (256 loc) • 8.63 kB
JavaScript
import van from "vanjs-core";
import { unwrap } from "../router/unwrap.mjs";
import { getTagKey } from "../meta/helpers.mjs";
/**
* @param {Element} element
* @param {string} key
* @param {boolean | string | number | null | undefined} value
*/
export const setAttribute = (element, key, value) => {
if (value == null || value === false || value === "" || value === undefined) {
element.removeAttribute(key);
} else {
const attr = value === true ? "" : String(value);
element.setAttribute(key, attr);
}
};
/**
* Sets or removes an attribute with the specified or inferred namespace on an element.
*
* @param {string|null} ns - The namespace URI (e.g., 'http://www.w3.org/2000/svg') or null to infer from element.
* @param {Element} element - The DOM element to modify.
* @param {string} key - The attribute name (e.g., 'stroke-width', 'xlink:href').
* @param {string|boolean|null|undefined} value - The attribute value; falsy values remove the attribute.
*/
export const setAttributeNS = (ns, element, key, value) => {
// Infer namespace from element if ns is null
const elementNS = ns || element.namespaceURI ||
/* istanbul ignore next - this is a required fallback */ null;
// Map attributes to specific namespaces
const attrNamespaces = {
"xlink:": "http://www.w3.org/1999/xlink", // XLink attributes (e.g., xlink:href)
"xml:": "http://www.w3.org/XML/1998/namespace", // XML attributes (e.g., xml:lang)
"xsi:": "http://www.w3.org/2001/XMLSchema-instance", // XML Schema Instance (e.g., xsi:schemaLocation)
};
// Determine attribute namespace
let attrNS = elementNS;
for (const [prefix, uri] of Object.entries(attrNamespaces)) {
if (key.startsWith(prefix)) {
attrNS = uri;
break;
}
}
if (value == null || value === false || value === "" || value === undefined) {
// Remove attribute
try {
// istanbul ignore else - case may not be covered by happy-dom?
if (attrNS && attrNS !== "null") {
// Strip prefix (e.g., xlink:href -> href)
element.removeAttributeNS(attrNS, key.replace(/^[^:]+:/, ""));
} else {
// istanbul ignore next - case may not be covered by happy-dom?
element.removeAttribute(key);
// istanbul ignore next - case may not be covered by happy-dom?
element.removeAttribute(key.replace(/^[^:]+:/, ""));
}
} catch (_e) {
// Silent fail: attribute may not exist
}
} else {
// Set attribute
const attr = value === true ? key.replace(/^[^:]+:/, "") : String(value);
try {
element.setAttributeNS(attrNS, key, attr);
} catch (_e) {
// Fallback to non-namespaced set
// istanbul ignore next - case may not be covered by happy-dom?
element.setAttribute(key, attr);
}
}
};
/**
* @param {import("csstype").Properties | string} style
* @returns {string}
*/
export const styleToString = (style) => {
return typeof style === "string"
? style
: typeof style === "object"
? Object.entries(style).reduce((acc, [key, value]) =>
acc +
key
.split(/(?=[A-Z])/)
.join("-")
.toLowerCase() +
":" +
// allow state values in style object
(typeof value === "object" && "val" in value ? value.val : value) +
";", "")
: /* istanbul ignore next */ "";
};
/** @type {(el1: HTMLElement, el2: HTMLElement | HTMLElement[], deep?: boolean) => boolean} */
export function elementsMatch(el1, el2, deep) {
// Quick initial checks before recursing
// istanbul ignore else
if (
!(el2 instanceof HTMLElement) ||
el1.tagName !== el2.tagName ||
el1.id !== el2.id ||
el1.className !== el2.className
) {
return false;
}
const childNodes1 = Array.from(el1.childNodes);
const childNodes2 = Array.from(el2.childNodes);
// istanbul ignore else
if (childNodes1.length !== childNodes2.length) {
return false;
}
// Only recurse if necessary (has childNodes with data-hk)
const hasHydratedChildren = childNodes1.some((child) =>
child instanceof HTMLElement && (child.hasAttribute("data-hk") ||
child.querySelector("[data-hk]"))
);
// istanbul ignore next
if (!hasHydratedChildren) {
return true; // If no hydrated children, elements match based on initial checks
}
// Only recurse if opted in
// istanbul ignore next
return deep
? childNodes1.every((child, idx) => elementsMatch(child, childNodes2[idx]))
: true;
}
function createHydrationContext() {
/** @type {WeakMap<Element, Element>} */
const parentCache = new WeakMap();
/** @type {(element: HTMLElement, root: HTMLElement) => HTMLElement | null} */
function getParent(element, root) {
const cacheKey = element;
// istanbul ignore if - must be connected to a read DOM
if (parentCache.has(cacheKey)) {
const cached = parentCache.get(cacheKey);
// Verify the cached parent is still valid for this root
if (cached && cached.isConnected && root.contains(cached)) {
return cached;
}
// If not valid, remove from cache
parentCache.delete(cacheKey);
}
const chain = [];
let current = element;
while (current !== root && current) {
chain.push(current);
current = current.parentElement;
}
const parent = chain.slice(-1)[0];
// istanbul ignore else
if (parent) {
parentCache.set(cacheKey, parent);
}
return parent;
}
/** @type {(oldDom: HTMLElement, newDom: HTMLElement | HTMLElement[]) => HTMLElement} */
function diffAndHydrate(oldDom, newDom) {
if (!oldDom || !newDom) return;
// SPA mode
// istanbul ignore else
if (!oldDom.children.length && !elementsMatch(oldDom, newDom)) {
return oldDom.replaceChildren(...unwrap(newDom).children);
}
// istanbul ignore else
if (newDom instanceof Array) {
oldDom.replaceChildren(...unwrap(newDom).children);
return;
}
// SSR Mode
/** @type {Set<HTMLElement>} */
const oldSet = new Set();
/** @type {Set<HTMLElement>} */
const newSet = new Set();
const processElements = (root, set) => {
const elements = root.querySelectorAll("[data-hk]");
let lastParent = null;
elements.forEach((el) => {
const parent = getParent(el, root);
if (parent && parent !== lastParent) {
set.add(parent);
lastParent = parent;
}
});
};
processElements(oldDom, oldSet);
processElements(newDom, newSet);
// istanbul ignore else
if (newSet.size > 0) {
const newArray = Array.from(newSet);
oldSet.forEach((el) => {
const match = newArray.find((m) => elementsMatch(m, el));
// istanbul ignore else
if (match) {
el.replaceWith(match);
}
});
}
}
return { diffAndHydrate };
}
/**
* @param {Element} target the root element
* @param {Element | Element[] | Promise<Element | Element[]>} content the element(s) to hydrate
*/
export const hydrate = (target, content) => {
if (content instanceof Promise) {
content.then((res) => {
hydrate(target, res);
});
return target;
}
if (typeof content === "function") {
van.derive(() => {
hydrate(target, content());
});
return target;
}
const wrapper = unwrap(content);
const currentChildren = Array.from(target.children);
const newChildren = Array.from(wrapper.children);
if (target.tagName.toLowerCase() === "head") {
// Keep current tags on first hydration
if (!target.hasAttribute("data-h")) {
target.setAttribute("data-h", "");
return target;
}
// Replace all tags uniformly - styles/scripts are handled via imports
newChildren.forEach((newChild) => {
const key = getTagKey(newChild);
const existing = currentChildren.find((child) =>
getTagKey(child) === key
);
if (existing) {
if (existing.outerHTML !== newChild.outerHTML) {
existing.replaceWith(newChild);
}
} else {
target.appendChild(newChild);
}
});
} else {
if (!target.hasAttribute("data-h")) {
const { diffAndHydrate } = createHydrationContext();
diffAndHydrate(target, content);
target.setAttribute("data-h", "");
} else {
// target.replaceChildren(...newChildren);
const parsed = [];
for (const child of newChildren) {
parsed.push(
child instanceof Element
? child
: /* istanbul ignore next */ String(child),
);
}
target.replaceChildren(...parsed);
}
}
};