UNPKG

web-signature

Version:

Primitive and fast framework for rendering web interfaces

518 lines (510 loc) 20.3 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." }; let _counter = 0; class Signature { components = {}; refs = {}; libs = {}; 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); } // /** // * Returns a reference. // * @param {string} name The name of the reference. // * @return {Element | undefined} The element associated with the reference, or undefined if it does not exist. // */ // public ref(name: string): Element | undefined { // return this.refs[name]?.element; // } /** * 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 = ""; 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 (typeof fragment === "string") { template.innerHTML = fragment.trim(); next(); } else if (fragment instanceof Promise) { fragment.then((html) => { template.innerHTML = html.trim(); next(); }).catch((err) => { throw { id: "unknown-from", from: component.name, err: err }; }); } })(() => { 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 (err.id in ["unknown", "unknown-from"]) { 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."; }); } 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 = ""; try { fragment = renderer.render(); } catch (err) { if (err instanceof Error) { throw { id: "unknown-from", from: renderer.name, err: err }; } } ((next) => { if (typeof fragment === "string") { body.innerHTML = fragment.trim(); next(); } else if (fragment instanceof Promise) { try { fragment.then((html) => { body.innerHTML = html.trim(); next(); }).catch((err) => { throw { id: "unknown-from", from: renderer.name, err: err }; }); } catch (err) { console.log(1); } } })(() => { if (body.content.children.length > 1) { throw { id: "multiple-root-elements", component: com, elements: body.innerHTML }; } this.render(body.content); renderer.onRender?.(); // lifecycle hook const mountEl = body.content.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.content); 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 index () { return new Signature(); } export { Component, Library, Prop, Signature, index as default };