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 { static #ERRORS = { INVALID_IDENTIFIER: 'INVALID_IDENTIFIER', INVALID_ENTITY_TYPE: 'INVALID_ENTITY_TYPE', INVALID_ENTITY: 'INVALID_ENTITY', INVALID_ROOT: 'INVALID_ROOT', INVALID_COMPONENT_TYPE: 'INVALID_COMPONENT_TYPE', INVALID_PLUGIN_URL: 'INVALID_PLUGIN_URL', INVALID_PLUGIN_TYPE: 'INVALID_PLUGIN_TYPE', INVALID_COMMAND: 'INVALID_COMMAND', COMMAND_NOT_FOUND: 'COMMAND_NOT_FOUND', PLUGIN_NOT_FOUND: 'PLUGIN_NOT_FOUND', REGISTERED_PLUGIN_MISSING_EXEC: 'REGISTERED_PLUGIN_MISSING_EXEC', }; // class static globals static #instances = new Map(); 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()], ]); /** * Constructs a new instance of the Cetra framework. * @class * @classdesc The Cetra framework provides a reactive approach to DOM manipulation and component management. * @param {Map} settings - A map object containing the framework settings. */ constructor(options = new Map()) { super(); 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 (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); } 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); } 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`] = function (entities) { if (!Array.isArray(entities)) throw new TypeError(Cetra.#ERRORS.INVALID_ENTITY_TYPE); 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( Cetra.#ERRORS.INVALID_IDENTIFIER, 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(Cetra.#ERRORS.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(Cetra.#ERRORS.INVALID_ENTITY); } if (!this.#isValidEntity(entityType, fn)) { Registry.unregister(uniqueId, this.#secretKey); throw new TypeError(Cetra.#ERRORS.INVALID_ENTITY); } if (Cetra.#isValidPlugin(fn)) { 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 Cetra.#isValidPlugin(fn); case 'Command': return typeof fn === 'string'; case 'Type': return ( typeof fn === 'function' && fn.constructor.name === '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. * @returns {Cetra} The Cetra instance with the specified ID. */ static getInstance(id) { return Cetra.#instances.get(id); } /** * Initializes the Cetra framework by setting up the mutation observer and processing existing components. * @memberof Cetra * @instance * @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')); if (typeof this.#root == 'string') { this.#root = document.querySelector(this.#root); if (!this.#root) { Logger.error(Cetra.#ERRORS.INVALID_ROOT); this.#root = document.body; } } this.#registerDefaults(); this.#readComponents(); this.#initObserver(); this.#root.setAttribute( `data-${this.#internals.get('settings').get('selector')}-id`, this.#identifier ); Cetra.#instances.set(this.#identifier, this); this.emit('onAfterInit', this); } /** * Registers default properties, transformer or anything related to the functionality * @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(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 (typeof identifier != 'string') { Logger.error( Cetra.#ERRORS.INVALID_IDENTIFIER, identifier, typeof identifier ); return undefined; } if (!this.#internals.get('plugins').has(identifier)) { Logger.error(Cetra.#ERRORS.PLUGIN_NOT_FOUND, 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); }); } /** * Checks if a plugin is a valid Cetra plugin. * @private * @param {Function} klass - The plugin to be checked. * @returns {boolean} - Returns true if the plugin is a valid Cetra plugin, otherwise false. */ static #isValidPlugin(klass) { let isInstance = false, isClass = false, isFunction = false; if ( klass instanceof CetraPlugin || klass.constructor.name != 'Function' ) { isInstance = true; } else if (typeof klass === 'function') { isFunction = true; try { isInstance = new klass() instanceof CetraPlugin; } catch (error) { return false; } if (!isInstance) { isClass = Object.prototype.isPrototypeOf.call( CetraPlugin.prototype, klass.prototype ); } } return isInstance || isClass || isFunction; } /** * 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 (!Cetra.#isValidPlugin(klass)) { if (klass.plugin) { Registry.unregister(uniqueId, this.#secretKey); return this.#initPlugin(klass.plugin); } if (typeof klass == 'string' && this.#isUrl(klass)) return klass; throw new TypeError(Cetra.#ERRORS.INVALID_PLUGIN_TYPE); } if (typeof klass === 'function') { if (!klass.name) { klass = new klass(uid); // simple function } else { klass = new klass(uid); // a class with its own name } } if (typeof klass.register === 'function') return klass.register(uid); // what else could it be? if (typeof klass !== 'object') throw new TypeError(Cetra.#ERRORS.INVALID_PLUGIN_TYPE); if ( klass.url && (typeof klass.url !== 'string' || !this.#isUrl(klass.url)) ) throw new TypeError(Cetra.#ERRORS.INVALID_PLUGIN_URL); if (klass.plugin) { return this.#initPlugin(klass.plugin); } return klass; } catch (err) { Logger.error(err); Registry.unregister(uid, this.#secretKey); return undefined; } } /** * checks if the given url is valid * @param {String} url the url to check * @returns {Boolean} true if the url is valid, false otherwise */ #isUrl(url) { if (typeof url != 'string') return false; try { // if there is no error this should be a valid url const urlObj = new URL(url, window.location.href); return this.#internals.get('settings').get('enforceSecureUrls') ? urlObj.protocol === 'https:' : true; } catch (err) { // any error is a bad url return false; } } /** * Reads and initializes components based on the defined component selector. * @memberof Cetra * @instance * @method * @param {NodeList} nodes - The NodeList of DOM nodes to process. * @param {boolean} [recursive=true] - Indicates whether to recursively process child nodes. */ #readComponents() { let comps = this.#queryElements('', { _parent: this.#root, }); if (!comps || comps.length == 0) return Logger.log('no components found (yet)'); for (const [index, comp] of [...comps.entries()]) { if ( comp.matches( `[data-${this.#internals .get('settings') .get('selector')}-id]:not(template)` ) ) continue; this.#initComponent(comp); } } /** * 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'); 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) ) ); } /** * Finds and returns the first element that matches the specified selector. * If no selector is provided, it uses the selectors generated from the attributes. * * @param {string} attributes - The attributes used to generate the selectors. * @param {Object} options - Additional options for querying the element. * @param {Element} options._parent - The parent element to search within. Defaults to the document. * @param {string} options.selector - The specific selector to use for querying the element. * * @returns {Element|null} The first element that matches the selector, or null if no match is found. */ #queryElement(attributes = '', options = {}) { return (options._parent || document).querySelector( options.selector || this.#selectors(attributes) ); } /** * Retrieves the value of the specified option from the framework's settings. * @memberof Cetra * @instance * @method * @param {string} option - The name of the option to retrieve. * @returns {*} The value of the specified option. */ getOption(key) { if (typeof key != 'string') throw new Error('given key is not a string!'); return this.#internals .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') ) ?? 'BaseComponent'; 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) { 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; } /** * 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.#addEntity('Plugin', identifier, plugin); // this.addPlugin(identifier, plugin); } } registerCommands(commands, identifier) { if (!Array.isArray(commands)) throw new TypeError(Cetra.#ERRORS.INVALID_COMMANDS_TYPE); for (const command of commands) { if (typeof command != 'string') { Logger.error(Cetra.#ERRORS.INVALID_COMMAND, command); continue; } this.#addEntity('Command', command, identifier); } } /** * 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( componentId, update = { command: 'none', args: [], evt: new CustomEvent('empty') } ) { if (Array.isArray(update.args)) update.args = []; const comp = this.#internals.get('compIndex').get(componentId); if (!comp) { throw new Error(`component ${componentId} not found`); } if (!this.#internals.get('commands').has(update.command)) { Logger.error(Cetra.#ERRORS.COMMAND_NOT_FOUND, update.command); return; } const command = this.#internals.get('commands').get(update.command); if (!command) return Logger.error( Cetra.#ERRORS.COMMAND_NOT_FOUND, update.command, update ); if (typeof command !== 'string') { if (typeof command.constructor.name !== 'Function') { return Logger.error( Cetra.#ERRORS.INVALID_COMMAND, update.command ); } return command(comp, update); } const plugin = this.#getPlugin(command); if (!plugin) return Logger.error( Cetra.#ERRORS.PLUGIN_NOT_FOUND, update.command, update, plugin ); if (!plugin.exec) return Logger.error( Cetra.#ERRORS.REGISTERED_PLUGIN_MISSING_EXEC, update.command, update, plugin ); plugin.exec(comp, update); } } export default Cetra;