diff --git a/lib/core/Cetra.js b/lib/core/Cetra.js index a9e2efb..81c06c2 100644 --- a/lib/core/Cetra.js +++ b/lib/core/Cetra.js @@ -1,17 +1,44 @@ import Emitter from './Emitter'; import Logger from './Logger'; +import Registry from './Registry'; +import CetraPlugin from '../plugins/CetraPlugin'; +import BaseComponent from './BaseComponent'; + class Cetra extends Emitter { // class static globals static #instances = new Map(); - static #uidIndex = new Set(); + static #entityTypes = { + plugins: 'Plugin', + commands: 'Command', + types: 'Type', + }; // prevents us from defining several `registerSomething` methods static #defaultSettings = new Map([ ['selector', 'ctra'], ['root', document.body], + [ + 'observeOptions', + { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: false, + characterData: true, + characterDataOldValue: false, + }, + ], + ['types', new Map()], + ['plugins', new Map()], + ['enforceSecureUrls', true], + ['noSandboxing', false], + ['varSeparator', ':'], + ['defaultPlugins', new Map()], ]); #identifier; + #secretKey; #root; #internals = new Map([ + ['compIndex', new Map()], ['settings', new Map()], ]); @@ -23,13 +50,21 @@ class Cetra extends Emitter { */ constructor(options = new Map()) { super(); - this.#identifier = this.uid(); + this.#secretKey = Math.random().toString(36).substring(2, 8); + this.#identifier = Registry.register({ + key: this.#secretKey, + obj: this, + parent: '$root', + }); + + this.#initEntityTypes(); // generic entity specific methods registration if (!(options instanceof Map) && typeof options == 'object') { options = new Map(Object.entries(options)); } - for (const key of Cetra.#defaultSettings.keys()) { + for (let key of Cetra.#defaultSettings.keys()) { + if (Object.keys(Cetra.#entityTypes).includes(key)) continue; let value = options.get(key); if (typeof value == 'undefined') { value = Cetra.#defaultSettings.get(key); @@ -37,6 +72,16 @@ class Cetra extends Emitter { this.#internals.get('settings').set(key, value); } + if (options.get('plugins') instanceof Map) { + this.#registerPlugins([...options.get('plugins')]); + } + + if (this.#internals.get('settings').get('defaultPlugins').size > 0) { + this.#registerPlugins([ + ...this.#internals.get('settings').get('defaultPlugins'), + ]); + } + for (const [key, cb] of options) { if (!key.startsWith('on') || typeof cb != 'function') continue; this.on(key, cb); @@ -45,6 +90,154 @@ class Cetra extends Emitter { this.#init(); } + /** + * Registers entity types. + */ + #initEntityTypes() { + for (const key of Object.keys(Cetra.#entityTypes)) { + const entityType = Cetra.#entityTypes[key]; + this.#internals.set(key, new Map()); + if (typeof this[`add${entityType}`] === 'undefined') { + this[`add${entityType}`] = (identifier, fn) => + this.#addEntity(entityType, identifier, fn); + } + + if (typeof this[`add${entityType}s`] === 'undefined') { + this[`add${entityType}s`] = (entities) => { + if (!Array.isArray(entities)) { + if (typeof entities != 'string') + throw new TypeError( + `${entityType} is not of type array or string` + ); + + throw new TypeError( + `${entityType} is not of type array` + ); + } + + entities.forEach(({ identifier, _obj }) => { + this[`register${entityType}`](identifier, _obj); + }); + }; + } + + if (typeof this[`remove${entityType}`] === 'undefined') { + this[`remove${entityType}`] = (identifier) => + this.#removeEntity(entityType, identifier); + } + + if (typeof this[`get${entityType}`] === 'undefined') { + this[`get${entityType}`] = (identifier) => + this.#getEntity(entityType, identifier); + } + } + } + + /** + * Registers an entity with the specified entity type and identifier. + * + * @param {string} entityType - The type of the entity. + * @param {string} identifier - The identifier of the entity. + * @param {Function} fn - The constructor function for the entity. + * @throws {TypeError} If the entity type is invalid. + */ + #addEntity(entityType, identifier, fn) { + if (typeof identifier != 'string') { + Logger.error(`invalid identifier for ${entityType} ${identifier}`); + return false; + } + // get entityKey by its value + const entityKey = Object.keys(Cetra.#entityTypes).find((key) => { + return Cetra.#entityTypes[key] == entityType; + }); + if (!entityKey) { + Logger.error(`invalid entity type ${entityType}`); + return false; + } + + let uniqueId = Registry.register({ + obj: fn, + key: this.#secretKey, + parent: this.#identifier, + }); + try { + if (typeof fn === 'undefined') { + Registry.unregister(uniqueId, this.#secretKey); + throw new TypeError( + `invalid entity constructor for ${entityType} ${identifier}` + ); + } + + if (!this.#isValidEntity(entityType, fn)) { + Registry.unregister(uniqueId, this.#secretKey); + throw new TypeError( + `invalid entity constructor for ${entityType} ${identifier}` + ); + } + + if (typeof fn == 'function') { + if (!(fn instanceof CetraPlugin)) { + if (fn.name) { + Registry.unregister(uniqueId, this.#secretKey); + fn = new fn(uniqueId); + uniqueId = Registry.register({ + obj: fn, + key: this.#secretKey, + parent: this.#identifier, + }); + } + } + + if (typeof fn.register === 'function') { + fn = fn.register(uniqueId); + } + } + this.#internals.get(entityKey).set(identifier, fn); + + return true; + } catch (err) { + Logger.error(err); + Registry.unregister(uniqueId); + return false; + } + } + + #isValidEntity(entityType, fn) { + if (typeof fn === 'undefined') return false; + switch (entityType) { + case 'Plugin': + return fn instanceof CetraPlugin || typeof fn === 'function'; + case 'Command': + case 'Type': + return typeof fn === 'function'; + default: + return false; + } + } + + /** + * Unregisters an entity of the specified type and identifier. + * @param {string} entityType - The type of the entity. + * @param {string} identifier - The identifier of the entity. + * @returns {boolean|undefined} - Returns `true` if the entity was successfully unregistered, `false` if the entity was not found, or `undefined` if the entity type does not exist. + */ + #removeEntity(entityType, identifier) { + if (this.#internals.get(entityType).has(identifier)) + return this.#internals.get(entityType).delete(identifier); + + return undefined; + } + + /** + * Retrieves an entity of the specified type and identifier. + * @param {string} entityType - The type of the entity. + * @param {string} identifier - The identifier of the entity. + * @returns {any} The retrieved entity, or undefined if not found. + */ + #getEntity(entityType, identifier) { + return this.#internals.get(entityType).get(identifier) ?? undefined; + } + /** * Retrieves an instance of Cetra by its ID. * @param {string} id - The ID of the Cetra instance to retrieve. @@ -61,6 +254,9 @@ class Cetra extends Emitter { * @method */ #init() { + if (!MutationObserver || typeof MutationObserver == 'undefined') + throw new Error('Please update your browser.'); + this.#root = this.#internals.get('settings').get('root') || document.querySelector(this.#internals.get('settings').get('root')); @@ -75,6 +271,8 @@ class Cetra extends Emitter { } this.#registerDefaults(); + this.#readComponents(); + this.#initObserver(); this.#root.setAttribute( `data-${this.#internals.get('settings').get('selector')}-id`, @@ -90,6 +288,219 @@ class Cetra extends Emitter { * @returns {void} */ #registerDefaults() { + this.addType('string', (val) => { + return String(val); + }); + this.addType('number', (val) => { + return Number(val); + }); + this.addType('int', (val) => { + return isNaN(val) ? 0 : parseInt(val, 10); + }); + this.addType('integer', (val) => { + return isNaN(val) ? 0 : parseInt(val, 10); + }); + this.addType('float', (val) => { + return isNaN(val) ? 0 : parseFloat(val); + }); + this.addType('bool', (str) => { + return ['true', 'yes', 'on', '+', '1'].includes(str.toLowerCase()); + }); + this.addType('boolean', (str) => { + return ['true', 'yes', 'on', '+', '1'].includes(str.toLowerCase()); + }); + this.addType('json', (val) => { + let data; + try { + data = JSON.parse(val); + } catch (err) { + //Logger.error(err); + return val; + } + + return data; + }); + } + + /** + * Initializes the mutation observer to listen for changes in the DOM and triggers the appropriate actions. + * @memberof Cetra + * @instance + * @method + * @private + */ + #initObserver() { + try { + this.observer = new MutationObserver( + this.handleMutation.bind(this) + ); + this.observer.observe( + this.#root, + this.#internals.get('settings').get('observeOptions') + ); + } catch (err) { + Logger.error('an error occured with the observer!', err); + } + } + + /** + * Retrieves a plugin by its identifier. + * @note even though there is `getEntity` this method is required for the plugin loading process + * + * @param {string} identifier - The identifier of the plugin. + * @returns {Promise|Function|Object|undefined} - The plugin object or a promise that resolves to the plugin object. Returns undefined if the plugin is not found or if the identifier is not a string. + */ + getPlugin(identifier) { + if (!(identifier instanceof String)) { + Logger.error('Plugin identifier is not a string'); + return undefined; + } + + if (!this.#internals.get('plugins').has(identifier)) return undefined; + + const plugin = this.#internals.get('plugins').get(identifier); + + if (!plugin) return undefined; + + if (typeof plugin.url == 'string') { + return this.#loadPlugin(plugin.url, identifier); // returns a promise + } + + if (!['function', 'object'].includes(typeof plugin)) return undefined; + + return plugin; + } + + /** + * requests a plugin from the given url and returns a promise which is resolved after the plugin is loaded. + * @param {String} url the url to load + * @returns {Promise} the promise of the plugin loading process + */ + #loadPlugin(url, identifier) { + if (!this.#internals.get('plugins').has(identifier)) + return Promise.reject( + new Error(`plugin ${identifier} is not registered`) + ); + const _framework = this; + return new Promise((resolve, reject) => { + fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error( + `Cetra: Plugin ${url} could not be loaded` + ); + } + return response.text(); + }) + .then((text) => { + if (this.#internals.get('settings').get('noSandboxing')) { + const pluginScriptTag = + document.createElement('script'); + pluginScriptTag.textContent = text; + + document.body.appendChild(pluginScriptTag); + return resolve(); + } else if (this.#internals.get('plugins').get('sandbox')) { + return this.#internals + .get('plugins') + .get('sandbox') + .execute(text, (integrityId) => { + if ( + !_framework.#identifier + .get('plugins') + .has(identifier) + ) + return reject( + new Error( + `plugin ${identifier} is not registered` + ) + ); + + const pluginData = _framework.#internals + .get('plugins') + .get(identifier); + _framework.#internals + .get('plugins') + .set(identifier, { + ...pluginData, + integrityId, + }); + resolve( + _framework.#internals + .get('plugins') + .get(identifier) + ); + }); + } else { + return reject( + new Error(`plugin ${identifier} is not registered`) + ); + } + }) + .catch(reject); + }); + } + + /** + * Initializes a plugin by registering it with the Cetra registry. + * + * @param {Function|Object} klass - The plugin class or object to be initialized. + * @returns {Function|Object} - The initialized plugin. + * @throws {TypeError} - If the plugin is null, not of a valid type, or has an invalid URL. + */ + #initPlugin(klass) { + const uid = Registry.register({ + key: this.#secretKey, + obj: klass, + parent: this.#identifier, + }); + + try { + if (!['function', 'object'].includes(typeof klass)) + throw new TypeError( + 'plugin is not of type function or object or instance of a CetraPlugin (child) class' + ); + + if (klass instanceof CetraPlugin) { + if (typeof klass.register === 'function') { + return klass.register(uid); + } + return klass; + } + + if (typeof klass === 'function') { + if (!klass.name) { + klass = new klass(uid); // simple function + } else { + klass = new klass(uid); // a class with its own name + } + } + + // what else could it be? + if (typeof klass !== 'object') + throw new TypeError( + 'plugin is not of a valid plugin type (`CetraPlugin` or `object` or `function` or `url`)' + ); + + if ( + klass.url && + (typeof klass.url !== 'string' || !this.#isUrl(klass.url)) + ) + throw new TypeError( + 'plugin url is not of type string and/or no valid url' + ); + + if (klass.plugin) { + Logger.info('registering plugin 5', klass); + return this.#initPlugin(klass.plugin); + } + + return klass; + } catch (err) { + Logger.error(err); + Registry.unregister(uid, this.#secretKey); + return undefined; + } } /** @@ -112,29 +523,87 @@ class Cetra extends Emitter { } /** - * Generates a (internally)unique identifier. - * @param {number} [numGroups=1] - The number of groups to generate. - * @param {number} [groupLength=6] - The length of each group. + * Reads and initializes components based on the defined component selector. * @memberof Cetra + * @instance * @method - * @returns {string} The unique identifier. + * @param {NodeList} nodes - The NodeList of DOM nodes to process. + * @param {boolean} [recursive=true] - Indicates whether to recursively process child nodes. */ - uid(numGroups = 1, groupLength = 6) { - const usedIds = new Set(Cetra.#uidIndex.values()); + #readComponents() { + let comps = this.queryElements('', { _parent: this.#root }); + if (!comps || comps.length == 0) + return Logger.log('no components found (yet)'); - let uid = undefined; + for (const [index, comp] of [...comps.entries()]) { + if ( + comp.matches( + `[data-${this.#internals + .get('settings') + .get('selector')}-id]` + ) + ) { + Logger.info('skipped element', comp); + continue; + } - while (!uid || usedIds.has(uid)) { - uid = Array.from(Array(numGroups), () => - Math.random() - .toString(36) - .substring(2, groupLength + 2) - ).join(''); + this.#initComponent(comp); } + } - Cetra.#uidIndex.add(uid); + /** + * Generates a comma-separated string of selectors based on the component selector defined in the framework's settings. + * @memberof Cetra + * @instance + * @method + * @param {string | string[]} [suffixes=[]] - The suffixes to append to the component selector. + * @returns {string} The generated selector string. + */ + #selectors(suffixes = []) { + if (!Array.isArray(suffixes)) { + if (typeof suffixes != 'string') + throw new Error('suffixes is not of type array or string'); - return uid; + suffixes = [suffixes]; + } + const selector = this.#internals.get('settings').get('selector'); + + if (suffixes.length == 0) suffixes = ['']; + + return [...suffixes] + .map((suffix) => { + const selectorString = [`${selector}`, suffix || null] + .filter((v) => !!v) + .join('-'); + return [ + `[data-${selectorString}]`, + `.${selectorString}`, + `[${selectorString}]`, + ].join(','); + }) + .join(','); + } + + /** + * Queries the DOM for elements that match the given selector. + * @memberof Cetra + * @instance + * @method + * @param {string} selector - The selector string to query the DOM with. + * @returns {NodeList} A NodeList of matching DOM elements. + */ + queryElements(attributes = '', options = {}) { + return Array.from( + (options._parent || document).querySelectorAll( + options.selector || this.#selectors(attributes) + ) + ); + } + + queryElement(attributes = '', options = {}) { + return (options._parent || document).querySelector( + options.selector || this.#selectors(attributes) + ); } /** @@ -152,6 +621,282 @@ class Cetra extends Emitter { .get('settings') .get(key.toString().toLowerCase()); } + + /** + * Initializes a component with the specified name and options. + * @memberof Cetra + * @instance + * @method + * @param {string} name - The name of the component to initialize. + * @param {object} options - The options for the component. + * @returns {boolean} Returns true if the component was successfully initialized, otherwise false. + */ + #initComponent(comp) { + if (!comp.matches(this.#selectors())) return null; + if ( + comp.getAttribute( + `data-${this.#internals.get('settings').get('selector')}-id` + ) + ) + return null; + + const compType = + comp.getAttribute( + `data-${this.#internals.get('settings').get('selector')}` + ) ?? + comp.getAttribute(this.#internals.get('settings').get('selector')); + + if (typeof compType == 'undefined' || !compType) { + Logger.error(`no component type given`); + return null; + } + + let compData = new Map([ + [ + 'id', + Registry.register({ + key: this.#secretKey, + parent: this.#identifier, + type: compType, + obj: comp, + }), + ], + ['cetraId', this.#identifier], + ]); + + // add id to the component + comp.setAttribute(this.#dataIdAttribute(), compData.get('id')); + + compData.set( + 'selector', + `[${this.#dataIdAttribute()}="${compData.get('id')}"]` + ); + + if (typeof compType != 'undefined' && compType) { + compData.set('type', compType); + } + + compData.set('variables', this.#initVariables(comp)); + + for (const [_id, plugin] of this.#internals.get('plugins')) { + if (!plugin || typeof plugin.initComponent !== 'function') continue; + plugin.initComponent(comp, compData); + } + + const compInstance = new BaseComponent(compData); + this.#internals + .get('compIndex') + .set(compInstance.get('id'), compInstance); + this.emit('onAfterInitComponent', compInstance); + } + + #dataIdAttribute() { + return `data-${this.#internals.get('settings').get('selector')}-id`; + } + + /** + * Initializes variables with the specified data from the component. + * @memberof Cetra + * @instance + * @method + * @param {HTMLElement} component - The component element containing the variables and their initial values. + * @returns {Map} The Map object containing the initialized variables. + */ + #initVariables(component) { + const variables = new Map(); + + // Find elements within the component that have the 'data-ctra-var' attribute + const varElements = component.querySelectorAll(this.#selectors('var')); + + varElements.forEach((element) => { + const varData = element.getAttribute('data-ctra-var'); + const [varName, varType, varDefault] = varData.split( + this.#internals.get('settings').get('varSeparator') + ); + + // Check if the variable is already set + if (variables.has(varName)) return; + + let defaultValue = this.convertValue(varType, varDefault); // typeConversion[varType](varDefault); // Initialize with the default value from the attribute + + // Check if the textContent of the element exists and differs from the given default value + if ( + typeof varDefault == 'undefined' && + element.textContent.trim() !== '' + ) { + defaultValue = this.convertValue( + varType, + element.textContent.trim() + ); // Convert the parsed value based on the specified type + } + + // Set the variable with the determined initial value + variables.set( + varName, + new Map([ + ['default', defaultValue], + ['type', varType], + ]) + ); + }); + + return variables; + } + + /** + * takes the value and converts it based on the given type definition + * @memberof Cetra + * @instance + * @method + * @param {string} type - the variable's type + * @param {mixed} value - the value we need to parse + * @returns {mixed} the converted (otherwise the same) value + */ + convertValue(type, value) { + console.log('bane', this.#internals.get('types'), type, value); + const convertFn = this.#internals.get('types').get(type ?? 'string'); + if (!convertFn) { + Logger.warn(`No conversion function found for type "${type}".`); + return value; + } + return convertFn(value); + } + + /** Observer related */ + + /** + * handler for mutation events + * @note when MutationObserver registers a mutation within the root element + * @memberof Cetra + * @instance + * @method + * @param {Array} mutations - the list of registered mutations + * @param {MutationObserver} _observer - the observer which registered the mutation(s) + */ + handleMutation(mutations, _observer) { + if (_observer != this) return; + + console.log('ITS HAPPENING', mutations, _observer); + } + + /** + * iterates over a list of plugins and registers them within the framework + * @memberof Cetra + * @instance + * @method + * @private + * @param {Array} plugins - The plugins to register. + */ + #registerPlugins(plugins) { + if (!Array.isArray(plugins)) throw new Error('plugins is not an array'); + for (const [identifier, plugin] of plugins) { + this.addPlugin(identifier, plugin); + } + } + + /** + * Registers a plugin with the specified name and options. + * @memberof Cetra + * @instance + * @method + * @param {string} name - The name of the plugin. + * @param {object} options - The options for the plugin. + */ + addPlugin(identifier, plugin) { + const uniqueId = Registry.register({ + key: this.#secretKey, + obj: plugin, + parent: this.#identifier, + }); + if (!identifier) { + Logger.error('Plugin identifier is required'); + Registry.unregister(uniqueId, this.#secretKey); + return; + } + + if (this.#internals.get('plugins').has(identifier)) { + Logger.log(`Overwrite plugin ${identifier}`); + Registry.unregister(uniqueId, this.#secretKey); + Registry.register({ + key: this.#secretKey, + obj: plugin, + parent: this.#identifier, + }); + } + + if (!plugin) { + Logger.error( + `Plugin ${identifier} is not an url or a plugin instance or class` + ); + Registry.unregister(uniqueId, this.#secretKey); + return; + } + + if (typeof plugin === 'string') { + if (!this.#isUrl(plugin)) { + Logger.error(`Plugin ${identifier} has an invalid url: ${url}`); + Registry.unregister(uniqueId, this.#secretKey); + return; + } + Logger.info(`Plugin ${identifier} is loaded from ${plugin}`); + this.#internals.get('plugins').set(identifier, { + plugin, + key: uniqueId, + parent: this.#identifier, + }); + return; + } + + if (!['function', 'object'].includes(typeof plugin)) { + Logger.error('Plugin is not of type function or object'); + Registry.unregister(uniqueId, this.#secretKey); + return null; + } + + const pluginInstance = this.#initPlugin(plugin); + Registry.unregister(uniqueId, this.#secretKey); + if (!pluginInstance) + throw new TypeError('plugin is not of a valid plugin type'); + + Registry.register({ + key: this.#secretKey, + obj: pluginInstance, + parent: this.#identifier, + }); + + this.#internals.get('plugins').set(identifier, pluginInstance); + } + + /** + * Executes an action on a component. + * + * @param {string} action - The action to be executed. + * @param {string} varName - The name of the variable to be updated. + * @param {string} componentId - The ID of the component. + * @param {any} e - The event object. + * @throws {Error} If the component with the given ID is not found. + */ + exec(action, varName, componentId, e) { + const comp = this.#internals.get('compIndex').get(componentId); + + if (!comp) { + throw new Error(`component ${componentId} not found`); + } + + if (this.#internals.get('plugins').get('utils')[action]) { + const value = comp.getValue(varName); + comp.update([ + { + old: value, + new: this.#internals + .get('plugins') + .get('utils') + [action](comp.getValue(varName)), + varName, + }, + ]); + } + } } export default Cetra;