UNPKG

web-signature

Version:

Primitive and fast framework for rendering web interfaces

595 lines (586 loc) 23.4 kB
class Ref { element; instance; constructor(instance, element) { this.instance = instance; this.element = element; } } const errorMessages = { "element-not-found": "Element not found for selector: #selector", "prop-is-required": "Property '#prop' in component '#component' is required but not provided.", "unsupported-type-for-property": "Unsupported type for property '#prop' in component '#component': #type", "invalid-value-for-property": "Invalid value for property '#prop' in component '#component': #value (value: #attr)", "multiple-root-elements": "Component '#component' must render a single root element. \n\t#elements", "ref-collision": "Ref collision detected for ref '#ref' in component '#component'.", "unknown": "An unknown error occurred.", "unknown-from": "An unknown error occurred in component '#from'.", "stack-overflow": "Stack Overflow detected: possible recursive component rendering.", "render-async-failed": "Error during asynchronous rendering of the component #component.", }; let _counter = 0; 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 = errorMessages[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 }); } } } } class Component { content; options = { generateRefIfNotSpecified: false, }; ref; props = {}; data = {}; onInit() { } ; onRender() { } ; onMount(el) { } ; onContact(...props) { } ; onPropsParsed() { } ; onPropParsed(prop, value) { } ; } class Prop { type; required = true; validate; constructor(type, required = true, validate) { this.type = type; this.required = required; if (validate) { this.validate = validate; } else { this.validate = () => true; } } isValid(value) { switch (this.type) { case "boolean": return typeof value === "boolean"; case "number": return typeof value === "number" && !isNaN(value); case "string": return typeof value === "string"; case "array": return Array.isArray(value); case "null": return value === null; default: return false; } } } class Library { name; version; author; libs = {}; components = {}; /** * @param {string} name The name of the library. * @param {string} [author] Optional author of the library. * @param {string} [version] Optional version of the library. */ constructor(name, author, version) { this.name = name; // optional properties this.author = author; this.version = version; } /** * Registers a component in the library. * @param {ComponentConstructor} component The component to register. * @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. * @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 in ${this.name}.`)); } 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}`); } } /** * Retrieves a component by its name. * @param {string} name The name of the component to retrieve. * @return {ComponentConstructor | undefined} The component associated with the name, or undefined if it does not exist. */ get(name) { return this.components[name]; } /** * Returns a library. * @param {string} name The name of the library. * @return {LibMeta} */ lib(name) { return this.libs[name]; } /** * Lists all registered components in the library. * @return {Array<{ component: ComponentConstructor, name: string }>} An array of objects containing component constructors and their names. */ list() { return Object.entries(this.components).map(([name, component]) => ({ component, name })); } } function html(strings, ...values) { return { strings, values }; } function unsafeHTML(value) { return { type: "unsafeHTML", value: value }; } function index () { return new Signature(); } export { Component, Library, Prop, Signature, index as default, html, unsafeHTML };