mancha
Version:
Javscript HTML rendering engine
608 lines • 30.2 kB
JavaScript
import { safeAnchorEl, safeAreaEl } from "safevalues/dom";
import { appendChild, attributeNameToCamelCase, cloneAttribute, ellipsize, firstElementChild, getAttribute, getAttributeOrDataset, hasProperty, insertBefore, isRelativePath, nodeToString, removeAttribute, removeAttributeOrDataset, removeChild, replaceChildren, replaceWith, safeSetAttribute, setAttribute, setAttributeOrDataset, setProperty, traverse, } from "./dome.js";
import { Iterator } from "./iterator.js";
/** @internal */
export var RendererPlugins;
(function (RendererPlugins) {
RendererPlugins.resolveIncludes = async function (node, params) {
const elem = node;
// Early exit: node must be an <include> element.
const tagName = elem.tagName?.toLowerCase();
if (!["include", "link"].includes(tagName))
return;
if (tagName === "link" && getAttribute(elem, "rel") !== "subresource")
return;
this.log("include directive found in:\n", nodeToString(node, 128));
this.log("include params:", params);
// Early exit: <include> tags must have a src attribute.
const attrName = elem.tagName.toLocaleLowerCase() === "link" ? "href" : "src";
const src = getAttribute(elem, attrName);
if (!src)
throw new Error(`"${attrName}" attribute missing from ${nodeToString(node, 128)}.`);
// All attributes are cloned to the first child of the include tag, except for these.
const skipAttrs = [];
if (tagName === "include")
skipAttrs.push("src");
if (tagName === "link")
skipAttrs.push("rel", "href");
// The included file will replace this tag, and all elements will be fully preprocessed.
const handler = (fragment) => {
// Add whatever attributes the include tag had to the first child.
const child = firstElementChild(fragment);
for (const attr of Array.from(elem.attributes)) {
if (child && !skipAttrs.includes(attr.name))
cloneAttribute(elem, child, attr.name);
}
// Replace the include tag with the contents of the included file.
replaceWith(node, ...fragment.childNodes);
};
// Compute the subparameters being passed down to the included file.
const subparameters = {
...params,
rootDocument: false,
maxdepth: (params?.maxdepth ?? 100) - 1,
};
if (subparameters.maxdepth === 0)
throw new Error("Maximum recursion depth reached.");
// Case 1: Absolute remote path.
if (src.includes("://") || src.startsWith("//")) {
this.log("Including remote file from absolute path:", src);
await this.preprocessRemote(src, subparameters).then(handler);
// Case 2: Relative remote path.
}
else if (params?.dirpath?.includes("://") || params?.dirpath?.startsWith("//")) {
const relpath = src.startsWith("/") ? src : `${params.dirpath}/${src}`;
this.log("Including remote file from relative path:", relpath);
await this.preprocessRemote(relpath, subparameters).then(handler);
// Case 3: Local absolute path.
}
else if (src.charAt(0) === "/") {
this.log("Including local file from absolute path:", src);
await this.preprocessLocal(src, subparameters).then(handler);
// Case 4: Local relative path.
}
else {
const relpath = params?.dirpath && params?.dirpath !== "." ? `${params?.dirpath}/${src}` : src;
this.log("Including local file from relative path:", relpath);
await this.preprocessLocal(relpath, subparameters).then(handler);
}
};
RendererPlugins.rebaseRelativePaths = function (node, params) {
const elem = node;
const tagName = elem.tagName?.toLowerCase();
// Early exit: if there is no dirpath, we cannot rebase relative paths.
if (!params?.dirpath)
return;
// We have to retrieve the attribute, because the node property is always an absolute path.
const src = getAttribute(elem, "src");
const href = getAttribute(elem, "href");
const render = getAttributeOrDataset(elem, "render", ":");
// Early exit: if there is no element attribute to rebase, we can skip this step.
const pathref = src || href || render;
if (!pathref || !isRelativePath(pathref))
return;
const relpath = `${params.dirpath}/${pathref}`;
this.log("Rebasing relative path as:", relpath);
if (render) {
setAttributeOrDataset(elem, "render", relpath, ":");
}
else if (hasProperty(elem, "attribs")) {
safeSetAttribute(elem, src ? "src" : "href", relpath);
}
else if (tagName === "img") {
elem.src = relpath;
}
else if (tagName === "a") {
safeAnchorEl.setHref(elem, relpath);
}
else if (tagName === "source") {
elem.src = relpath;
}
else if (tagName === "audio") {
elem.src = relpath;
}
else if (tagName === "video") {
elem.src = relpath;
}
else if (tagName === "track") {
elem.src = relpath;
}
else if (tagName === "input") {
elem.src = relpath;
}
else if (tagName === "area") {
safeAreaEl.setHref(elem, relpath);
}
else {
this.log("Unable to rebase relative path for element:", tagName);
}
};
RendererPlugins.registerCustomElements = function (node, params) {
const elem = node;
const tagName = elem.tagName?.toLowerCase();
const customTagName = (getAttribute(elem, "is") || getAttribute(elem, "alt"))?.toLowerCase();
if (["template", "div"].includes(tagName) && customTagName) {
if (tagName === "div" && getAttribute(elem, "role") !== "template")
return;
if (!this._customElements.has(customTagName)) {
this.log(`Registering custom element: ${customTagName}\n`, nodeToString(elem, 128));
// Preprocess template content so paths are rebased BEFORE registration.
// This must happen synchronously to avoid a race condition where resolveCustomElements
// clones the template before paths are rebased.
const content = elem.content || elem;
const contentNodes = Array.from(traverse(content));
// Skip the first node (the content/template root itself).
for (let i = 1; i < contentNodes.length; i++) {
RendererPlugins.rebaseRelativePaths.call(this, contentNodes[i], params);
}
// Now register the template with rebased paths.
this._customElements.set(customTagName, elem);
// Remove the original node from the DOM.
if (elem?.parentNode) {
removeChild(elem.parentNode, elem);
}
}
}
};
RendererPlugins.resolveCustomElements = function (node, _params) {
const elem = node;
const tagName = elem.tagName?.toLowerCase();
let cusName = tagName;
if (cusName === "div")
cusName = getAttribute(elem, "role")?.toLowerCase() || cusName;
if (cusName && this._customElements.has(cusName)) {
this.log(`Processing custom element: ${cusName}\n`, nodeToString(elem, 128));
const template = this._customElements.get(cusName);
const clone = (template.content || template).cloneNode(true);
// Add whatever attributes the custom element tag had to the first child.
const child = firstElementChild(clone);
if (child) {
for (const attr of Array.from(elem.attributes)) {
if (tagName !== "div" || attr.name !== "role")
cloneAttribute(elem, child, attr.name);
}
}
// If there's a <slot> element, replace it with the contents of the custom element.
const iter = new Iterator(traverse(clone));
const slot = iter.find((x) => x.tagName?.toLowerCase() === "slot");
if (slot)
replaceWith(slot, ...elem.childNodes);
// Replace the custom element tag with the contents of the template.
replaceWith(node, ...clone.childNodes);
}
};
RendererPlugins.resolveTextNodeExpressions = function (node, _params) {
const content = node.nodeValue || "";
if (node.nodeType !== 3 || !content?.trim())
return;
this.log(`Processing node content value:\n`, ellipsize(content, 128));
// Identify all the expressions found in the content.
const matcher = new RegExp(/{{ ([^}]+) }}/gm);
const expressions = Array.from(content.matchAll(matcher)).map((match) => match[1]);
// To update the node, we re-evaluate all of the expressions since that's much simpler than
// caching results.
return this.effect(function () {
let updatedContent = content;
for (const expr of expressions) {
const result = this.eval(expr, { $elem: node });
updatedContent = updatedContent.replace(`{{ ${expr} }}`, String(result));
}
node.nodeValue = updatedContent;
});
};
RendererPlugins.resolveDataAttribute = async function (node, params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const dataAttr = getAttributeOrDataset(elem, "data", ":");
if (dataAttr) {
this.log(":data attribute found in:\n", nodeToString(node, 128));
// Remove the attribute from the node.
removeAttributeOrDataset(elem, "data", ":");
let subrenderer;
// Check if the element already has a renderer attached.
if (hasProperty(node, "renderer")) {
subrenderer = node.renderer;
this.log("Reusing existing subrenderer for node:", nodeToString(node, 64));
}
else {
subrenderer = this.subrenderer();
// Attach the subrenderer to the node as a property, using setProperty.
setProperty(node, "renderer", subrenderer);
this.log("Created and attached new subrenderer for node:", nodeToString(node, 64));
}
// Evaluate the expression.
const result = subrenderer.eval(dataAttr, { $elem: node });
// Await any promises in the result object.
await Promise.all(Object.entries(result).map(([k, v]) => {
// Special handling for query parameters: they must be set via the public set() method
// to trigger the key handlers that update the URL. We set them on the root renderer
// to ensure the handler is found and to respect the global nature of URL state.
if (k.startsWith("$$")) {
const root = subrenderer._store.get("$rootRenderer");
return (root || subrenderer).set(k, v);
}
// Use the store's set method to update the value and avoid modifying ancestor values.
return subrenderer._store.set(k, v);
}));
// Optimization: if we are reusing the current renderer, we don't need to re-mount
// or skip children. The current traversal will handle them.
if (subrenderer === this)
return;
// Skip all the children of the current node, if it's a subrenderer.
for (const child of traverse(node, this._skipNodes)) {
this._skipNodes.add(child);
}
// Mount the current node with the subrenderer.
await subrenderer.mount(node, params);
}
};
RendererPlugins.resolveClassAttribute = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const classAttr = getAttributeOrDataset(elem, "class", ":");
if (classAttr) {
this.log(":class attribute found in:\n", nodeToString(node, 128));
// Remove the attribute from the node.
removeAttributeOrDataset(elem, "class", ":");
// Store the original class attribute, if any.
const originalClass = getAttribute(elem, "class") || "";
// Compute the function's result.
return this.effect(function () {
const result = this.eval(classAttr, { $elem: node });
safeSetAttribute(elem, "class", (result ? `${originalClass} ${result}` : originalClass).trim());
});
}
};
RendererPlugins.resolveTextAttributes = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const textAttr = getAttributeOrDataset(elem, "text", ":");
if (textAttr) {
this.log(":text attribute found in:\n", nodeToString(node, 128));
// Remove the attribute from the node.
removeAttributeOrDataset(elem, "text", ":");
// Compute the function's result and track dependencies.
const setTextContent = (content) => this.textContent(node, content);
return this.effect(function () {
setTextContent(this.eval(textAttr, { $elem: node }));
});
}
};
RendererPlugins.resolveHtmlAttribute = async function (node, params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const htmlAttr = getAttributeOrDataset(elem, "html", ":");
if (htmlAttr) {
this.log(":html attribute found in:\n", nodeToString(node, 128));
// Remove the attribute from the node.
removeAttributeOrDataset(elem, "html", ":");
// Compute the function's result and track dependencies.
return this.effect(function () {
const result = this.eval(htmlAttr, { $elem: node });
return new Promise((resolve) => {
(async () => {
const fragment = await this.preprocessString(result, params);
await this.renderNode(fragment);
replaceChildren(elem, fragment);
resolve();
})();
});
});
}
};
RendererPlugins.resolveEventAttributes = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
for (const attr of Array.from(elem.attributes || [])) {
if (attr.name.startsWith(":on:") || attr.name.startsWith("data-on-")) {
let eventName = "";
let modifiers = [];
if (attr.name.startsWith(":on:")) {
[eventName, ...modifiers] = attr.name.substring(":on:".length).split(".");
}
else if (attr.name.startsWith("data-on-")) {
[eventName, ...modifiers] = attr.name.substring("data-on-".length).split(".");
}
if (!eventName)
throw new Error(`Invalid event attribute: ${attr.name}`);
this.log(attr.name, "attribute found in:\n", nodeToString(node, 128));
// Remove the processed attributes from node.
removeAttributeOrDataset(elem, attr.name);
// Special case: disable the annoying, default page reload behavior for form elements.
const preventDefault = eventName === "submit" && elem.tagName.toUpperCase() === "FORM";
node.addEventListener?.(eventName, (event) => {
if (modifiers.includes("prevent") || preventDefault) {
event.preventDefault();
}
return this.eval(attr.value, { $elem: node, $event: event });
});
}
}
};
RendererPlugins.resolveForAttribute = async function (node, params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const forAttr = getAttributeOrDataset(elem, "for", ":")?.trim();
if (forAttr) {
this.log(":for attribute found in:\n", nodeToString(node, 128));
// Remove the processed attributes from node.
removeAttributeOrDataset(elem, "for", ":");
// Ensure the node and its children are not processed by subsequent steps.
for (const child of traverse(node, this._skipNodes)) {
this._skipNodes.add(child);
}
// Save the original node style to restore it later.
// NOTE: This is a hack because Chrome is sometimes displaying the contents of <template>.
const originalStyle = getAttribute(elem, "style") || "";
setAttribute(elem, "style", "display: none;");
// Place the template node into a template element.
const parent = node.parentNode;
if (!parent)
return; // Should already be skipped but for safety
const template = this.createElement("template", node.ownerDocument);
insertBefore(parent, template, node);
removeChild(parent, node);
if ("content" in template) {
appendChild(template.content, node);
}
else {
appendChild(template, node);
}
this.log(":for template:\n", nodeToString(template, 128));
// Tokenize the input by splitting it based on the format "{key} in {expression}".
const tokens = forAttr.split(" in ", 2);
if (tokens.length !== 2) {
throw new Error(`Invalid :for format: \`${forAttr}\`. Expected "{key} in {expression}".`);
}
// Keep track of all the child nodes added.
const children = [];
// Compute the container expression and track dependencies.
const [loopKey, itemsExpr] = tokens;
await this.effect(function () {
const items = this.eval(itemsExpr, { $elem: node });
this.log(":for list items:", items);
// Remove all the previously added children, if any.
children.splice(0, children.length).forEach((child) => {
// Only remove if child is still part of this parent (may have been moved by :if).
if (child.parentNode === parent) {
removeChild(parent, child);
}
this._skipNodes.delete(child);
});
// Validate that the expression returns a list of items.
if (!Array.isArray(items)) {
console.error(`Expression did not yield a list: \`${itemsExpr}\` => \`${items}\``);
return Promise.resolve();
}
// Loop through the container items.
const awaiters = [];
for (const item of items) {
// Create a subrenderer that will hold the loop item and all node descendants.
const subrenderer = this.subrenderer();
// NOTE: Using the store object directly to avoid modifying ancestor values.
subrenderer._store.set(loopKey, item);
// Create a new HTML element for each item and add them to parent node.
const copy = node.cloneNode(true);
// Restore the original style of the node.
setAttribute(copy, "style", originalStyle);
// Also add the new element to the store.
children.push(copy);
// Since the element will be handled by a subrenderer, skip it in parent renderer.
this._skipNodes.add(copy);
// Render the element using the subrenderer.
awaiters.push(subrenderer.mount(copy, params));
this.log("Rendered list child:\n", nodeToString(copy, 128));
}
// Insert the new children into the parent container.
const reference = template.nextSibling;
for (const child of children) {
insertBefore(parent, child, reference);
}
return Promise.all(awaiters);
});
}
};
RendererPlugins.resolveBindAttribute = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const bindExpr = getAttributeOrDataset(elem, "bind", ":");
if (bindExpr) {
this.log(":bind attribute found in:\n", nodeToString(node, 128));
// The change events we listen for can be overriden by user.
const defaultEvents = ["change", "input"];
const updateEvents = getAttribute(elem, ":bind:on")?.split(",") ||
elem.dataset?.bindOn?.split(",") ||
defaultEvents;
// Remove the processed attributes from node.
removeAttributeOrDataset(elem, "bind", ":");
removeAttribute(elem, ":bind:on");
removeAttribute(elem, "data-bind-on");
// If the element is of type checkbox, we bind to the "checked" property.
const prop = getAttribute(elem, "type") === "checkbox" ? "checked" : "value";
// If the bound expression is a simple variable and does not exist, create it.
if (!bindExpr.includes(".") && !this.has(bindExpr)) {
// NOTE: Setting the value to an empty string instead of undefined or null.
this.set(bindExpr, "");
}
// Watch for updates in the store and bind our property ==> node value.
this.effect(function () {
const result = this.eval(bindExpr, { $elem: node });
if (prop === "checked")
elem.checked = !!result;
else
elem.value = result;
});
// Bind node value ==> our property.
const nodeExpr = `${bindExpr} = $elem.${prop}`;
// Watch for updates in the node's value.
for (const event of updateEvents) {
node.addEventListener(event, () => this.eval(nodeExpr, { $elem: node }));
}
}
};
RendererPlugins.resolveIfAttribute = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const ifExpr = getAttributeOrDataset(elem, "if", ":");
if (ifExpr) {
this.log(":if attribute found in:\n", nodeToString(node, 128));
// Remove the processed attributes from node.
removeAttributeOrDataset(elem, "if", ":");
// Create a placeholder to replace the element when hidden.
const placeholder = this.createComment(" :if placeholder ", node.ownerDocument);
// Compute the function's result and track dependencies.
this.effect(function () {
const result = this.eval(ifExpr, { $elem: node });
if (result) {
// Toggle ON: element should be visible.
if (!elem.parentNode && placeholder.parentNode) {
replaceWith(placeholder, elem);
}
}
else {
// Toggle OFF: element should be hidden.
if (elem.parentNode) {
replaceWith(elem, placeholder);
}
}
});
}
};
RendererPlugins.resolveShowAttribute = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const showExpr = getAttributeOrDataset(elem, "show", ":");
if (showExpr) {
this.log(":show attribute found in:\n", nodeToString(node, 128));
// Remove the processed attributes from node.
removeAttributeOrDataset(elem, "show", ":");
// TODO: Instead of using element display, insert a dummy <template> to track position of
// child, then replace it with the original child when needed.
// Store the original display value to reset it later if needed.
const display = elem.style?.display === "none"
? ""
: (elem.style?.display ??
getAttribute(elem, "style")
?.split(";")
?.find((x) => x.split(":")[0] === "display")
?.split(":")
?.at(1)
?.trim());
// Compute the function's result and track dependencies.
this.effect(function () {
const result = this.eval(showExpr, { $elem: node });
// If the result is false, set the node's display to none.
if (elem.style)
elem.style.display = result ? display : "none";
else
safeSetAttribute(elem, "style", `display: ${result ? display : "none"};`);
});
}
};
RendererPlugins.resolveCustomAttribute = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
for (const attr of Array.from(elem.attributes || [])) {
const prefix1 = ":attr:";
const prefix2 = "data-attr-";
if (attr.name.startsWith(prefix1) || attr.name.startsWith(prefix2)) {
this.log(attr.name, "attribute found in:\n", nodeToString(node, 128));
// Remove the processed attributes from node.
removeAttribute(elem, attr.name);
const attrName = (attr.name.split(prefix1, 2).at(-1) || "").split(prefix2, 2).at(-1) ?? "";
this.effect(function () {
const attrValue = this.eval(attr.value, { $elem: node });
setAttribute(elem, attrName, attrValue);
});
}
}
};
RendererPlugins.resolveCustomProperty = function (node, _params) {
if (this._skipNodes.has(node))
return;
const elem = node;
for (const attr of Array.from(elem.attributes || [])) {
const prefix1 = ":prop:";
const prefix2 = "data-prop-";
if (attr.name.startsWith(prefix1) || attr.name.startsWith(prefix2)) {
this.log(attr.name, "property found in:\n", nodeToString(node, 128));
// Remove the processed attributes from node.
removeAttribute(elem, attr.name);
const attrName = (attr.name.split(prefix1, 2).at(-1) || "").split(prefix2, 2).at(-1) ?? "";
const propName = attributeNameToCamelCase(attrName);
this.effect(function () {
const propValue = this.eval(attr.value, { $elem: node });
setProperty(elem, propName, propValue);
});
}
}
};
RendererPlugins.stripTypes = (node, _params) => {
const elem = node;
// Remove :types and data-types attributes
if (getAttribute(elem, ":types")) {
removeAttribute(elem, ":types");
}
if (getAttribute(elem, "data-types")) {
removeAttribute(elem, "data-types");
}
};
/**
* Rendering plugin: creates a subrenderer, mounts it, then imports module from
* :render/data-render and calls the default export.
* Path resolution is handled by rebaseRelativePaths during preprocessing.
*/
RendererPlugins.resolveRenderAttribute = async function (node, params) {
if (this._skipNodes.has(node))
return;
const elem = node;
const src = getAttributeOrDataset(elem, "render", ":");
if (!src)
return;
this.log(`:render attribute found: ${src}`);
// Remove attribute before mounting to prevent re-processing.
removeAttributeOrDataset(elem, "render", ":");
let subrenderer;
// Check if the element already has a renderer attached.
if (hasProperty(node, "renderer")) {
subrenderer = node.renderer;
this.log("Reusing existing subrenderer for :render:", nodeToString(node, 64));
}
else {
// Create a subrenderer to process this node and its descendants.
subrenderer = this.subrenderer();
setProperty(elem, "renderer", subrenderer);
}
// Mount subrenderer - this processes all descendants.
await subrenderer.mount(node, params);
// Skip this node and descendants in parent renderer.
// We do this AFTER mount to allow recursive mounting (subrenderer === this) to process children first.
for (const child of traverse(node, this._skipNodes)) {
this._skipNodes.add(child);
}
// Subrenderer is done, descendants are ready - execute init.
try {
const module = await import(/* webpackIgnore: true */ src);
if (typeof module.default === "function") {
await module.default(elem, subrenderer);
}
else if (module.default !== undefined) {
console.warn(`Module ${src} default export is not a function`);
}
}
catch (err) {
console.error(`Failed to execute render init from ${src}:`, err);
}
};
})(RendererPlugins || (RendererPlugins = {}));
//# sourceMappingURL=plugins.js.map