web-signature
Version:
Primitive and fast framework for rendering web interfaces
423 lines (422 loc) • 18.2 kB
JavaScript
import Ref from "./Ref.js";
import Errors from "./Errors.js";
let _counter = 0;
export default class Signature {
components = {};
refs = {};
libs = {};
bank = new Map();
constructor() {
}
/**
* Adds a component to the signature.
* @param {ComponentConstructor} component The component to add.
* @param {string} name Optional name for the component. If not provided, uses the component's name property.
*/
add(component, name) {
const key = typeof name === "string" ? name : component.name;
if (this.components[key]) {
console.warn(new Error(`Component with name ${key} already exists.`));
}
this.components[key] = component;
}
/**
* Registers a library in the signature.
* @import {Library} from "./Library.js";
* @param {Library} library The library to register.
* @param {string[]} exclude Optional array of component names to exclude from the library registration.
*/
register(library, ...exclude) {
if (this.libs[library.name]) {
console.warn(new Error(`Library with name ${library.name} already exists.`));
}
const components = library.list().filter(com => !(com.name in exclude));
this.libs[library.name] = {
name: library.name,
version: library.version,
author: library.author,
components: components.map(com => com.name),
dependencies: library.libs
};
for (const com of components) {
this.add(com.component, `${library.name}-${com.name}`);
}
}
/**
* Returns a library.
* @param {string} name The name of the library.
* @return {LibMeta}
*/
lib(name) {
return this.libs[name];
}
/**
* Returns a formatted object of all libraries in the signature.
* @return {Record<string, ResolvedLib>} A object of formatted libraries with their components and dependencies.
*/
libraries() {
const formatKey = (lib) => {
let key = lib.name;
if (lib.version)
key += `@${lib.version}`;
if (lib.author)
key += `#${lib.author}`;
return key;
};
const resolve = (libs, visited = new Set()) => {
const result = {};
for (const [_, lib] of Object.entries(libs)) {
const key = formatKey(lib);
if (visited.has(key))
continue;
visited.add(key);
result[key] = {
components: lib.components,
dependencies: resolve(lib.dependencies, visited)
};
}
return result;
};
return resolve(this.libs);
}
/**
* Contacts the Component.onContact method through its reference.
* @param {string} name The name of the reference.
* @param {...any[]} props The properties to pass to the component's onContact method.
*/
contactWith(name, ...props) {
const ref = this.refs[name];
if (!ref) {
throw new Error(`Ref with name ${name} does not exist.`);
}
const instance = ref.instance;
return instance.onContact?.(...props); // lifecycle hook
}
/**
* Updates the reference.
* @param {string} name The name of the reference to update.
*/
updateRef(name) {
const ref = this.refs[name];
if (!ref) {
throw new Error(`Ref with name ${name} does not exist.`);
}
const component = ref.instance;
let fragment = {
strings: Object.assign([], { raw: [] }),
values: []
};
try {
fragment = component.render();
}
catch (err) {
if (err instanceof Error) {
throw { id: "unknown-from", from: component.name, err: err };
}
}
const template = document.createElement("template");
((next) => {
if (fragment instanceof Promise) {
fragment.then((html) => {
template.content.appendChild(this.templateToElement(html, component.name));
next();
}).catch((err) => {
throw { id: "unknown-from", from: component.name, err: err };
});
}
else if (typeof fragment === "object") {
template.content.appendChild(this.templateToElement(fragment, component.name));
next();
}
})(() => {
if (template.content.children.length !== 1) {
throw new Error(`Component '${component.name}' must render a single root element.`);
}
const newElement = template.content.firstElementChild;
this.render(template.content);
component.onRender?.(); // lifecycle hook
ref.element.replaceWith(newElement);
ref.element = newElement;
component.onMount?.(newElement); // lifecycle hook
});
}
/**
* Starts rendering in the specified area.
* @param {string} selector The selector of the element where the signature should be rendered.
* @param {() => void} [callback] Optional callback that will be called after rendering is complete.
*/
contact(selector, callback) {
const hunter = new Promise((_r, reject) => {
try {
const mainFrame = document.querySelector(selector);
if (!mainFrame) {
reject({ id: "element-not-found", selector: selector });
return;
}
const secondaryFrame = document.createElement("div");
secondaryFrame.innerHTML = mainFrame.innerHTML;
this.render(secondaryFrame);
mainFrame.replaceChildren(...Array.from(secondaryFrame.childNodes));
if (callback) {
callback();
}
}
catch (err) {
if (err instanceof Error) {
if (err instanceof RangeError && err.message.includes("stack")) {
reject({ id: "stack-overflow", err: err });
}
else
reject({ id: "unknown", err: err });
}
else
reject(err);
}
});
// Handle errors
hunter.catch((err) => {
let message = Errors[err.id];
Object.keys(err).filter(key => !(key in ["id", "err"])).forEach((key) => {
message = message.replace(new RegExp(`#${key}`, "gm"), String(err[key]));
});
if (window.SIGNATURE?.DEV_MODE)
console.log(err); // dev
if (err.id in ["unknown", "unknown-from", "render-async-failed"]) {
console.error(`[${err.id}] ${message}`, err.err);
}
else
console.error(`[${err.id}] ${message}`);
throw "Page rendering was interrupted by Signature due to the above error.";
});
}
templateToString(template) {
let body = "";
for (let i = 0; i < template.strings.length; i++) {
body += template.strings[i];
if (i < template.values.length) {
body += `<!--si-mark-${i}-->`;
}
}
return body;
}
fillTemplate(template, markup) {
let body;
if (this.bank.has(template.strings.join("@@"))) {
body = this.bank.get(template.strings.join("@@"))?.cloneNode(true);
}
else {
body = document.createElement("template");
body.innerHTML = markup;
this.bank.set(template.strings.join("@@"), body.cloneNode(true));
}
// Processing si-mark comments
(() => {
let walker = document.createTreeWalker(body.content, NodeFilter.SHOW_COMMENT);
let node;
let marks = [];
while ((node = walker.nextNode())) {
if (/si-mark-\d+/gm.test(node.nodeValue ?? "")) {
marks.push(node);
}
}
for (const node of marks) {
const value = template.values[Number((node.nodeValue ?? "").match(/si-mark-(\d+)/m)[1])];
if (typeof value === "object" && value.type === "unsafeHTML") {
let obj = document.createElement("div");
obj.innerHTML = value.value;
while (obj.firstChild) {
node.parentNode?.insertBefore(obj.firstChild, node);
}
node.remove();
}
else {
node.replaceWith(document.createTextNode(String(value)));
}
}
})();
// Processing si-mark attributes
(() => {
let walker = document.createTreeWalker(body.content, NodeFilter.SHOW_ELEMENT);
let node;
while ((node = walker.nextNode())) {
for (const attr of Array.from(node.attributes)) {
if (/<!--si-mark-\d+-->/gm.test(attr.value)) {
const match = attr.value.match(/si-mark-(\d+)/m);
if (match) {
const value = template.values[Number(match[1])];
node.setAttribute(attr.name, String(value));
}
}
}
}
})();
return body;
}
templateToElement(template, component) {
const markup = this.templateToString(template);
const body = this.fillTemplate(template, markup);
if (body.content.children.length !== 1) {
throw { id: "multiple-root-elements", elements: body.innerHTML, component };
}
return body.content.firstElementChild;
}
render(frame) {
for (const com of Object.keys(this.components)) {
const component = this.components[com];
// Find all elements with the component name in the frame
for (const el of Array.from(frame.querySelectorAll(com)).concat(Array.from(frame.querySelectorAll(`[si-component="${com}"]`)))) {
const renderer = new component();
renderer.onInit?.(); // lifecycle hook
if (el instanceof HTMLElement) {
// Fill the renderer's content
renderer.content = el.innerHTML.trim();
// Parse properties
for (const prop of Object.keys(renderer.props)) {
const attr = el.getAttribute(prop);
if (attr === null) {
if (renderer.props[prop].required) {
throw { id: "prop-is-required", component: com, prop: prop };
}
renderer.data[prop] = null;
}
else if (attr === "") {
if (renderer.props[prop].required) {
throw { id: "prop-is-required", component: com, prop: prop };
}
if (renderer.props[prop].isValid(attr)) {
renderer.data[prop] = null;
}
}
else {
let val;
// Determine the type of the property and convert the attribute value accordingly
switch (renderer.props[prop].type) {
case "boolean":
val = Boolean(attr);
break;
case "number":
val = Number(attr);
break;
case "string":
val = String(attr);
break;
case "array":
try {
val = JSON.parse(attr);
}
catch (e) {
throw {
id: "invalid-value-for-property",
component: com,
prop: prop,
value: attr,
attr: attr
};
}
break;
default:
if (renderer.props[prop].required) {
throw {
id: "unsupported-type-for-property",
component: com,
prop: prop,
type: renderer.props[prop].type
};
}
break;
}
if (val !== undefined) {
if (renderer.props[prop].isValid(val)) {
if (renderer.props[prop].validate) {
if (!renderer.props[prop].validate(val)) {
throw {
id: "invalid-value-for-property",
component: com,
prop: prop,
value: val,
attr: attr
};
}
}
renderer.data[prop] = val;
renderer.onPropParsed?.(renderer.props[prop], val); // lifecycle hook
}
else {
throw {
id: "invalid-value-for-property",
component: com,
prop: prop,
value: val,
attr: attr
};
}
}
}
}
renderer.onPropsParsed?.(); // lifecycle hook
}
// Create a template for rendering
const body = document.createElement("template");
let fragment = {
strings: Object.assign([], { raw: [] }),
values: []
};
try {
fragment = renderer.render();
}
catch (err) {
if (err instanceof Error) {
throw { id: "unknown-from", from: renderer.name, err: err };
}
}
((next) => {
if (fragment instanceof Promise) {
try {
fragment.then((html) => {
body.appendChild(this.templateToElement(html, com));
next();
}).catch((err) => {
throw { id: "unknown-from", from: renderer.name, err: err };
});
}
catch (err) {
throw { id: "render-async-failed", component: com, err: err };
}
}
else if (typeof fragment === "object") {
body.appendChild(this.templateToElement(fragment, com));
next();
}
})(() => {
this.render(body.content);
renderer.onRender?.(); // lifecycle hook
const mountEl = body.firstElementChild;
// Processing ref
if (el.hasAttribute("ref") || renderer.options.generateRefIfNotSpecified) {
let refName = el.getAttribute("ref");
if (refName === null) {
refName = "";
}
_counter++;
// If the ref name is empty, generate a unique name
if (refName === "") {
refName = `r${_counter}${Math.random().toString(36).substring(2, 15)}${_counter}`;
}
if (this.refs[refName]) {
throw { id: "ref-collision", ref: refName, component: com };
}
this.refs[refName] = new Ref(renderer, mountEl);
mountEl.setAttribute("ref", refName);
renderer.ref = {
id: refName,
contact: (...props) => this.contactWith(refName, ...props),
update: () => this.updateRef(refName)
};
}
el.replaceWith(body.firstElementChild);
renderer.onMount?.(mountEl); // lifecycle hook
});
}
}
}
}