UNPKG

web-signature

Version:

Primitive and fast framework for rendering web interfaces

423 lines (422 loc) 18.2 kB
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 }); } } } }