@okzgn/estructura
Version:
A lightweight dependency-free JavaScript framework that lets you assign functions to be automatically attached to custom or extended data types, based on one or multiple arguments.
465 lines (436 loc) • 26.1 kB
JavaScript
/**
* Estructura v1.19.0
* A lightweight dependency-free JavaScript framework that lets you assign functions to be automatically attached to custom or extended data types, based on one or multiple arguments.
* 2025 (c) OKZGN
* @license MIT
*/
/**
* Describes the result of a type analysis operation.
* The result is an array of strings, with the most specific type at the end.
* This array also acts as a hash map for O(1) lookups.
* @typedef {string[]} EstructuraTypeResult
*/
/**
* The main dispatcher function of an Estructura instance. It analyzes the argument types
* and returns a new object containing the methods from all matching function definitions.
* @typedef {function(...*): object} EstructuraDispatcher
*/
/**
* The internal state object for a single sandboxed instance of Estructura.
* @typedef {object} EstructuraInstance
* @property {string} name - The name of the instance for logging.
* @property {object} messages - A queue for pending console messages to prevent flooding.
* @property {object} fns - The registry for function dispatching.
* @property {object} subtypes - The registry for subtype definitions.
*/
/**
* The main public interface of an Estructura instance.
* @typedef {EstructuraDispatcher & {
* type: function(*): EstructuraTypeResult,
* fn: function(object|Function): EstructuraPublicInterface,
* subtype: function(object|string): EstructuraPublicInterface,
* instance: function(string): EstructuraPublicInterface
* }} EstructuraPublicInterface
*/
// This typedef is not directly used in the code but helps document the shape of resolved nodes.
/**
* A conceptual wrapper for a resolved function node.
* @typedef {object} ResolvedNode
* @property {object|Function} fns - The actual function or object of methods at this node.
* @property {string} type - The specific type that matched to find this node.
*/
let messages = {};
const instances = {};
const typeof_str_value = typeof '';
const typeof_obj_value = typeof {};
const typeof_fn_value = typeof function(){};
const typeof_undef_value = typeof undefined;
const get_primitive_type_fn = Object.prototype.toString;
const verify_own_property_fn = Object.prototype.hasOwnProperty;
const primitive_types_map = { 'undefined': 'Undefined', 'null': 'Null', 'function': 'Function', 'string': 'String', 'bigint': 'BigInt', 'symbol': 'Symbol', 'object': 'Object', 'boolean': 'Boolean' };
// Check for setInterval once at startup for performance.
const setinterval_is_on = typeof setInterval === typeof_fn_value;
const predefined_subtypes = {
'object-constructors': function(){ return { 'Object': function(input){ return get_primitive_type_fn.call(input).slice(8, -1); } } },
'browser-dom': function(){
if(!predefined_subtypes['browser-dom'].cache){
predefined_subtypes['browser-dom'].cache = {
'HTMLDocument': 'Document', 'HTMLCollection': 'Nodes', 'HTMLAllCollection': 'Nodes', 'NodeList': 'Nodes', 'HTMLHtmlElement': 'Node', 'HTMLHeadElement': 'Node', 'HTMLTitleElement': 'Node', 'HTMLBaseElement': 'Node', 'HTMLLinkElement': 'Node', 'HTMLMetaElement': 'Node', 'HTMLStyleElement': 'Node', 'HTMLScriptElement': 'Node', 'HTMLModElement': 'Node', 'HTMLBodyElement': 'Node', 'HTMLHeadingElement': 'Node', 'HTMLDivElement': 'Node', 'HTMLElement': 'Node', 'HTMLParagraphElement': 'Node', 'HTMLAnchorElement': 'Node', 'HTMLSpanElement': 'Node', 'HTMLBRElement': 'Node', 'HTMLHRElement': 'Node', 'HTMLPreElement': 'Node', 'HTMLQuoteElement': 'Node', 'HTMLOListElement': 'Node', 'HTMLUListElement': 'Node', 'HTMLLIElement': 'Node', 'HTMLDListElement': 'Node', 'HTMLMenuElement': 'Node', 'HTMLImageElement': 'Node', 'HTMLIFrameElement': 'Node', 'HTMLEmbedElement': 'Node', 'HTMLObjectElement': 'Node', 'HTMLParamElement': 'Node', 'HTMLVideoElement': 'Node', 'HTMLAudioElement': 'Node', 'HTMLSourceElement': 'Node', 'HTMLTrackElement': 'Node', 'HTMLCanvasElement': 'Node', 'HTMLMapElement': 'Node', 'HTMLAreaElement': 'Node', 'HTMLPictureElement': 'Node', 'HTMLTableElement': 'Node', 'HTMLTableCaptionElement': 'Node', 'HTMLTableColElement': 'Node', 'HTMLTableSectionElement': 'Node', 'HTMLTableRowElement': 'Node', 'HTMLTableCellElement': 'Node', 'HTMLFormElement': 'Node', 'HTMLLabelElement': 'Node', 'HTMLInputElement': 'Node', 'HTMLButtonElement': 'Node', 'HTMLSelectElement': 'Node', 'HTMLDataListElement': 'Node', 'HTMLOptGroupElement': 'Node', 'HTMLOptionElement': 'Node', 'HTMLTextAreaElement': 'Node', 'HTMLOutputElement': 'Node', 'HTMLProgressElement': 'Node', 'HTMLMeterElement': 'Node', 'HTMLFieldSetElement': 'Node', 'HTMLLegendElement': 'Node', 'HTMLKeygenElement': 'Node', 'HTMLDetailsElement': 'Node', 'HTMLDialogElement': 'Node', 'HTMLSummaryElement': 'Node', 'HTMLSlotElement': 'Node', 'HTMLTemplateElement': 'Node', 'HTMLMarqueeElement': 'Node', 'HTMLFrameSetElement': 'Node', 'HTMLFrameElement': 'Node', 'HTMLDirectoryElement': 'Node', 'HTMLFontElement': 'Node', 'SVGSVGElement': 'Node', 'SVGElement': 'Node', 'MathMLMathElement': 'Node', 'MathMLElement': 'Node', 'HTMLUnknownElement': 'Node', 'Text': 'Node', 'Comment': 'Node', 'DocumentFragment': 'Node', 'Attr': 'Node',
// For any input identified as a 'Node', create a more specific hierarchical subtype.
// e.g., A <div> element becomes 'Node.DIV'.
'Node': function(input, subtype){ return subtype + '.' + input.tagName; },
'Window': 'Browser', 'Navigator': 'Browser', 'Screen': 'Browser', 'Location': 'Browser', 'History': 'Browser',
// Solution for some browser incompatibilities that return 'Function' instead of 'Object' for DOM nodes and collections.
'Function': function(input){ return typeof input.nodeType === "number" || typeof input.item === "function" ? 'Object' : null; }
};
}
return predefined_subtypes['browser-dom'].cache;
}
};
const incorrect_fns_and_subtypes_names = {
// Object/Function prototype properties
'hasOwnProperty': true, 'toString': true, 'valueOf': true, 'constructor': true, 'isPrototypeOf': true, 'propertyIsEnumerable': true, 'toLocaleString': true, 'name': true, 'arguments': true, 'caller': true, 'apply': true, 'bind': true, 'call': true, '__defineGetter__': true, '__defineSetter__': true, '__lookupGetter__': true, '__lookupSetter__': true, '__proto__': true, 'prototype': true, 'length': true,
// JavaScript reserved keywords
'await': true, 'break': true, 'case': true, 'catch': true, 'class': true, 'const': true, 'continue': true, 'debugger': true, 'default': true, 'delete': true, 'do': true, 'else': true, 'enum': true, 'export': true, 'extends': true, 'false': true, 'finally': true, 'for': true, 'function': true, 'if': true, 'implements': true, 'import': true, 'in': true, 'instanceof': true, 'interface': true, 'let': true, 'new': true, 'null': true, 'package': true, 'private': true, 'protected': true, 'public': true, 'return': true, 'static': true, 'super': true, 'switch': true, 'this': true, 'throw': true, 'true': true, 'try': true, 'typeof': true, 'var': true, 'void': true, 'while': true, 'with': true, 'yield': true,
// Internal framework properties
'fns': true, 'subtypes': true, 'fn': true, 'subtype': true, 'Default': true,
// Special methods
'toJSON': true
};
/**
* Centralized message handler. Prepends the instance name to messages.
* In browser-like environments, it queues identical warnings to prevent
* console flooding, delivering them periodically.
* @private
* @this {{name: string, messages?: object}} The context object, usually an Estructura instance.
* @param {'info'|'warn'|'error'} message_type The type of message to display.
* @param {string} message The message content.
*/
function message(message_type, message){
message = 'Estructura (' + (this.name || 'Default') + '): ' + message;
if(typeof console !== typeof_undef_value && console[message_type]){
if(this.messages && setinterval_is_on){
this.messages[message] = message_type;
return;
}
console[message_type](message);
}
else if(message_type === 'error'){ throw new Error(message); }
}
/**
* Checks if a property name is valid for registration.
* @private
* @this EstructuraInstance
* @param {object} object The object containing the property.
* @param {string} property The name of the property to check.
* @returns {boolean} Returns `true` if the name is valid.
*/
function is_correct_object_property_name(object, property){
let object_type = type.call(this, object);
let it_is = true;
if(object_type['Array'] && incorrect_fns_and_subtypes_names[object[property]]){
property = object[property];
it_is = false;
}
else if(object_type['Object'] && (!verify_own_property_fn.call(object, property) || incorrect_fns_and_subtypes_names[property])){ it_is = false; }
if(!it_is){ message.call(this, 'warn', 'Name "' + property + '" is a reserved word and cannot be used.'); }
return it_is;
}
/**
* A simplified, ES3-compatible version of `Object.assign`.
* @private
* @param {object} destination_object The object to receive the properties.
* @param {object} source_object The object from which to copy properties.
*/
function simple_object_extend(destination_object, source_object){
for(let field in source_object){
if(!verify_own_property_fn.call(source_object, field)){ continue; }
destination_object[field] = source_object[field];
}
}
/**
* Attaches methods from a resolved node to the result object. Executes hybrid node functions called 'handlers'.
* @private
* @this EstructuraInstance
* @param {object} result_object The object to which methods will be attached.
* @param {object|Function} resolved_node The final function-node from the 'fns' tree.
* @param {string} resolved_node_type The type string that led to this node.
* @param {IArguments} main_fn_args The original arguments from the main dispatcher call.
* @param {boolean} [should_return_result] If true, ensures the function returns the result object.
* @returns {object|null}
*/
function attach_resolved_methods(result_object, resolved_node, resolved_node_type, main_fn_args, should_return_result){
let actual_fns = resolved_node;
if(typeof actual_fns === typeof_fn_value){
try {
// A hybrid node called 'handler' can return a new object of functions to use.
// If it returns a falsy value, we fall back to the original node.
// If it returns a non-object, we also fall back to protect against errors.
actual_fns = actual_fns.apply(result_object, main_fn_args) || resolved_node;
if(typeof actual_fns === typeof_str_value){ actual_fns = resolved_node; }
}
catch(error){ message.call(this, 'error', 'Type handler function "' + resolved_node_type + '" error: ' + String(error)); }
}
// If it is a string, the function stops to protect against string iteration. Another data types don't cause issues, including falsy values.
if(typeof actual_fns === typeof_str_value){ return (should_return_result ? result_object : null); }
for(let fn_name in actual_fns){
if(!verify_own_property_fn.call(actual_fns, fn_name) || typeof actual_fns[fn_name] !== typeof_fn_value){ continue; }
// The collisions order is strictly inherited from the inverted iteration order in main_dispatcher (while(type_iterator--)),
// ensuring the most general type is processed last and overwrites more specific matches.
if(result_object[fn_name]){ message.call(this, 'warn', 'Conflict for "' + fn_name + '". A definition from type "' + resolved_node_type + '" is overwriting another same name method or handler. This occurs when an input matches multiple types.'); }
result_object[fn_name] = adjust_method(this, fn_name, actual_fns, main_fn_args);
}
return (should_return_result ? result_object : null);
};
/**
* Creates a wrapper for a user-defined method to provide a consistent argument structure and error handling.
* @private
* @param {EstructuraInstance} instance_context The current Estructura instance for error logging.
* @param {string} method_name The name of the method for error logging.
* @param {object} methods The object containing the method function.
* @param {IArguments} main_fn_args The original arguments from the main dispatcher call.
* @returns {Function} The wrapped method.
*/
function adjust_method(instance_context, method_name, methods, main_fn_args){
// This factory is for compatibility with arrow functions and to provide a unified calling interface.
return function(){
// The original dispatcher arguments are consistently passed as an array, becoming the first argument for the user's method.
// e.g., _e(a, b).method(c) -> user's method receives ([a, b], c)
let method_args = [Array.prototype.slice.call(main_fn_args)];
for(let i = 0; i < arguments.length; i++){ method_args.push(arguments[i]); }
try { return methods[method_name].apply(this, method_args); }
catch(error){ message.call(instance_context, 'error', 'Method "' + method_name + '" error: ' + String(error)); }
return this;
};
}
/**
* Safely executes a user-provided subtype definition function.
* @private
* @this EstructuraInstance
* @param {string} subtype_name The name of the subtype for error reporting.
* @param {Function} subtype_definition_fn The user's subtype function.
* @param {*} input The input value passed to the function.
* @param {string} primitive_type The base primitive type of the input.
* @param {object} matched_subtypes The map of already detected subtypes.
* @returns {*|false} The result of the subtype function, or `false` if an error occurs.
*/
function subtype_definition_execution(subtype_name, subtype_definition_fn, input, primitive_type, matched_subtypes){
try { return subtype_definition_fn(input, primitive_type, function(){ return simple_object_extend([], matched_subtypes); }); }
catch(error){ message.call(this, 'error', 'Subtype definition "' + subtype_name + '" function error: ' + String(error)); }
return false;
}
/**
* Recursively discovers all applicable subtypes for a given input.
* @private
* @this EstructuraInstance
* @param {*} input The value being analyzed.
* @param {string[]|object} matched_subtypes The accumulating list of detected types. It starts with the base primitive type.
* @returns {string[]} The final list of all detected types.
*/
function subtypes_recognition(input, matched_subtypes){
// The last-added type is the one we are expanding now.
let primitive_type = matched_subtypes[matched_subtypes.length - 1];
let subtypes = this.subtypes[primitive_type];
let iterator = subtypes.length, subtype_found, subtype_definition;
while(iterator--){
subtype_definition = subtypes[iterator];
if(subtype_found = (subtype_definition.value || subtype_definition_execution.call(this, subtype_definition.name, subtype_definition.fn, input, primitive_type, matched_subtypes))){
if(typeof subtype_found !== typeof_str_value){
if(subtype_found !== true){ message.call(this, 'warn', 'Subtype definition "' + subtype_definition.name + '" should return a string or `true`. The definition name was used as the subtype name.'); }
subtype_found = subtype_definition.name;
}
if(!matched_subtypes[subtype_found]){
matched_subtypes.push(subtype_found);
matched_subtypes[subtype_found] = true;
// If the newly found subtype has its own children, recurse.
if(this.subtypes[subtype_found]){
subtypes_recognition.call(this, input, matched_subtypes);
}
}
}
}
return matched_subtypes;
};
/**
* The core type detection engine for an instance. It analyzes an input and returns
* an array of all its detected types, from most to least specific.
* @private
* @this EstructuraInstance
* @param {*} input The value to be type-checked.
* @returns {EstructuraTypeResult}
*/
function type(input){
// Get base primitive type using a map for performance. Special case for NaN.
let primitive_type = [(input == null ? primitive_types_map[String(input)] : (primitive_types_map[typeof input] || (isNaN(input) ? 'NaN' : 'Number')))];
// Also use the type array as a hash map for O(1) lookups.
primitive_type[primitive_type[0]] = true;
// If there are registered subtypes for this primitive, start the recognition process.
return (!this.subtypes[primitive_type[0]] ? primitive_type : subtypes_recognition.call(this, input, primitive_type));
};
/**
* Replaces a node in the dispatch tree, preserving the properties of the original node
* by merging them onto the new one. This handles converting plain objects to hybrid
* function-objects called 'handlers' and merging properties between them.
* @private
* @param {object} target_object The parent object containing the node to be replaced (e.g., the 'fns' registry).
* @param {string} property_name The key of the node to be replaced.
* @param {function|object} new_function_or_object The new function or object that will replace the existing node.
*/
function merge_type_fns_node(target_object, property_name, new_function_or_object){
let previous_object_or_fn_copy = {};
// Note: The extend operation is safe; it will do nothing if target_object[property_name] is not an object.
simple_object_extend(previous_object_or_fn_copy, target_object[property_name]);
if(!target_object[property_name] || typeof target_object[property_name] === typeof_obj_value){ target_object[property_name] = new_function_or_object; }
simple_object_extend(target_object[property_name], previous_object_or_fn_copy);
}
/**
* Registers functions into the 'fns' dispatch tree for an instance.
* @private
* @this EstructuraInstance
* @param {object|function} new_type_fns An object representing the function tree to merge, or a single function to act as a node.
* @param {object} [existent_type_fns] Internal use for recursion.
* @returns {undefined}
*/
function fn(new_type_fns, existent_type_fns){
if(typeof new_type_fns === typeof_fn_value){
merge_type_fns_node(this, 'fns', new_type_fns);
return this;
}
existent_type_fns = existent_type_fns || this.fns;
for(let type_name in new_type_fns){
if(!is_correct_object_property_name.call(this, new_type_fns, type_name)){ continue; }
let new_field = new_type_fns[type_name];
let new_field_type = type.call(this, new_field);
switch(new_field_type[0]){
case 'Function':
merge_type_fns_node(existent_type_fns, type_name, new_field);
break;
case 'Object':
let existent_field_type = type.call(this, existent_type_fns[type_name]);
if(!existent_field_type['Object'] && !existent_field_type['Function']){
existent_type_fns[type_name] = {};
}
fn.call(this, new_field, existent_type_fns[type_name]);
break;
default: message.call(this, 'warn', 'Invalid definition for "fn.' + type_name + '". Only Function or Object are allowed.');
}
}
};
/**
* Registers subtype definitions for an instance.
* @private
* @this EstructuraInstance
* @param {object|string} subtype_definitions An object with definitions or a predefined set name.
* @param {Array} [parent_subtype] Internal use for recursion.
* @param {boolean} [should_not_create_nested_definitions] If true, prevents registering functions or values during structural initialization of nested subtypes.
* @returns {undefined}
*/
function subtype(subtype_definitions, parent_subtype, should_not_create_nested_definitions){
if(typeof subtype_definitions === typeof_str_value && typeof predefined_subtypes[subtype_definitions] === typeof_fn_value){
return subtype.call(this, predefined_subtypes[subtype_definitions](), parent_subtype);
}
let current_subtypes = parent_subtype || this.subtypes;
for(let definition_name in subtype_definitions){
if(!is_correct_object_property_name.call(this, subtype_definitions, definition_name)){ continue; }
let subtype_definition_type = type.call(this, subtype_definitions[definition_name]);
let existent_definition_type = type.call(this, current_subtypes[definition_name]);
if(!parent_subtype && !existent_definition_type['Array']){ current_subtypes[definition_name] = []; }
let existent_subtypes_reference = (!parent_subtype ? current_subtypes[definition_name] : current_subtypes);
switch(subtype_definition_type[0]){
case 'Object':
// Put subtype definitions in the first level of definitions to create container for another subtypes.
subtype.call(this, subtype_definitions[definition_name]);
// This puts subtype definitions into the subtype definition that is infered from.
subtype.call(this, subtype_definitions[definition_name], current_subtypes[definition_name]);
break;
case 'Function':
if(!should_not_create_nested_definitions){
existent_subtypes_reference.push({ name: definition_name, fn: subtype_definitions[definition_name] });
}
break;
case 'Array': case 'String':
let inline_subtype_definitions = typeof subtype_definitions[definition_name] === typeof_str_value ? [subtype_definitions[definition_name]] : subtype_definitions[definition_name];
let inline_subtype_definitions_iterator = inline_subtype_definitions.length;
while(inline_subtype_definitions_iterator--){
if(!inline_subtype_definitions[inline_subtype_definitions_iterator] || typeof inline_subtype_definitions[inline_subtype_definitions_iterator] !== typeof_str_value){
message.call(this, 'warn', 'Subtype definition "' + definition_name + '" contains an invalid value which has been ignored.');
continue;
}
if(!is_correct_object_property_name.call(this, inline_subtype_definitions, inline_subtype_definitions_iterator)){ continue; }
if(!should_not_create_nested_definitions){
existent_subtypes_reference.push({ name: definition_name, value: inline_subtype_definitions[inline_subtype_definitions_iterator] });
}
}
break;
default: message.call(this, 'warn', 'Invalid definition for "subtype.' + definition_name + '". Only Object, Function, Array, or String are allowed.');
}
}
};
/**
* The main dispatcher function of an instance. It resolves and executes functions based on argument types.
* This optimized version delays method attachment until the final argument is processed.
* @private
* @this EstructuraInstance
* @param {...*} args - The sequence of arguments to be dispatched.
* @returns {object} A new wrapper object with the resolved methods.
*/
function main_dispatcher(){
let deep_length = arguments.length - 1;
let types_start = [this.fns];
let result_object = {};
// The dispatching algorithm works by iteratively filtering a list of possible function nodes.
// It walks down the 'fns' tree, collecting potential next-level nodes at each step.
for(let args_iterator = 0; args_iterator < arguments.length; args_iterator++){
// If no potential paths remain, break early.
if(!types_start.length){ break; }
let found_object = [];
let result_iterator = types_start.length;
let arg_types_iterator = type.call(this, arguments[args_iterator]);
let type_iterator;
while(result_iterator--){
type_iterator = arg_types_iterator.length;
while(type_iterator--){
let current_node_fns = types_start[result_iterator];
if(current_node_fns && (current_node_fns = current_node_fns[arg_types_iterator[type_iterator]])){
// Optimization: Only attach methods for the final set of resolved nodes.
// For intermediate arguments, just collect the next possible nodes.
if(args_iterator !== deep_length){ found_object.push(current_node_fns); }
else { attach_resolved_methods.call(this, result_object, current_node_fns, arg_types_iterator[type_iterator], arguments); }
}
}
}
types_start = found_object;
}
// Finally, attach methods from the root 'this.fns' (the 'Any' type).
return attach_resolved_methods.call(this, result_object, this.fns, 'Any', arguments, true);
};
/**
* Creates a sandboxed instance of Estructura.
* @private
* @param {string} name The name for the new instance.
* @returns {EstructuraPublicInterface} A new, isolated Estructura instance.
*/
function create_instance(name){
messages[name] = {};
/** @type {EstructuraInstance} */
const instance = { name: name, messages: messages[name], fns: {}, subtypes: {} };
/** @type {EstructuraPublicInterface} */
const public_interface = function(){ return main_dispatcher.apply(instance, arguments); };
public_interface.type = function(input){ return type.call(instance, input); };
public_interface.fn = function(new_fns){ return fn.call(instance, new_fns); };
public_interface.subtype = function(new_subtypes){ return subtype.call(instance, new_subtypes); };
public_interface.instance = function(name){ return get_instance(name); };
subtype.call(instance, 'object-constructors');
return public_interface;
}
/**
* Retrieves or creates a named, sandboxed instance of Estructura.
* @private
* @param {string} name The name of the instance.
* @returns {EstructuraPublicInterface|undefined} The requested instance.
*/
function get_instance(name){
if(typeof name !== typeof_str_value){ return; }
if(incorrect_fns_and_subtypes_names[name]){
message.call({ name: name }, 'error', 'Name "' + name + '" is a reserved word. Using default instance instead.');
return get_instance('');
}
if(!instances[name]){ instances[name] = create_instance(name); }
return instances[name];
}
// Periodically dispatch all queued info, warn, and error messages.
if(setinterval_is_on){
setInterval(function(){
for(let instance_name in messages){
for(let message_content in messages[instance_name]){
if(verify_own_property_fn.call(messages[instance_name], message_content)){
console[messages[instance_name][message_content]](message_content);
delete messages[instance_name][message_content];
}
}
}
}, 1000);
}
/** @type {EstructuraPublicInterface} */
const _e = get_instance('');
export default _e;