UNPKG

eleva

Version:

A minimalist and lightweight, pure vanilla JavaScript frontend runtime framework.

607 lines (561 loc) 24.7 kB
/*! Eleva Props Plugin v1.0.0-rc.2 | MIT License | https://elevajs.com */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ElevaPropsPlugin = {})); })(this, (function (exports) { 'use strict'; function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } /** * @class 🔒 TemplateEngine * @classdesc A secure template engine that handles interpolation and dynamic attribute parsing. * Provides a safe way to evaluate expressions in templates while preventing XSS attacks. * All methods are static and can be called directly on the class. * * @example * const template = "Hello, {{name}}!"; * const data = { name: "World" }; * const result = TemplateEngine.parse(template, data); // Returns: "Hello, World!" */ class TemplateEngine { /** * Parses a template string, replacing expressions with their evaluated values. * Expressions are evaluated in the provided data context. * * @public * @static * @param {string} template - The template string to parse. * @param {Record<string, unknown>} data - The data context for evaluating expressions. * @returns {string} The parsed template with expressions replaced by their values. * @example * const result = TemplateEngine.parse("{{user.name}} is {{user.age}} years old", { * user: { name: "John", age: 30 } * }); // Returns: "John is 30 years old" */ static parse(template, data) { if (typeof template !== "string") return template; return template.replace(this.expressionPattern, (_, expression) => this.evaluate(expression, data)); } /** * Evaluates an expression in the context of the provided data object. * Note: This does not provide a true sandbox and evaluated expressions may access global scope. * The use of the `with` statement is necessary for expression evaluation but has security implications. * Expressions should be carefully validated before evaluation. * * @public * @static * @param {string} expression - The expression to evaluate. * @param {Record<string, unknown>} data - The data context for evaluation. * @returns {unknown} The result of the evaluation, or an empty string if evaluation fails. * @example * const result = TemplateEngine.evaluate("user.name", { user: { name: "John" } }); // Returns: "John" * const age = TemplateEngine.evaluate("user.age", { user: { age: 30 } }); // Returns: 30 */ static evaluate(expression, data) { if (typeof expression !== "string") return expression; try { return new Function("data", `with(data) { return ${expression}; }`)(data); } catch (_unused) { return ""; } } } /** * @private {RegExp} Regular expression for matching template expressions in the format {{ expression }} * @type {RegExp} */ TemplateEngine.expressionPattern = /\{\{\s*(.*?)\s*\}\}/g; /** * @class 🎯 PropsPlugin * @classdesc A plugin that extends Eleva's props data handling to support any type of data structure * with automatic type detection, parsing, and reactive prop updates. This plugin enables seamless * passing of complex data types from parent to child components without manual parsing. * * Core Features: * - Automatic type detection and parsing (strings, numbers, booleans, objects, arrays, dates, etc.) * - Support for complex data structures including nested objects and arrays * - Reactive props that automatically update when parent data changes * - Comprehensive error handling with custom error callbacks * - Simple configuration with minimal setup required * * @example * // Install the plugin * const app = new Eleva("myApp"); * app.use(PropsPlugin, { * enableAutoParsing: true, * enableReactivity: true, * onError: (error, value) => { * console.error('Props parsing error:', error, value); * } * }); * * // Use complex props in components * app.component("UserCard", { * template: (ctx) => ` * <div class="user-info-container" * :user='${JSON.stringify(ctx.user.value)}' * :permissions='${JSON.stringify(ctx.permissions.value)}' * :settings='${JSON.stringify(ctx.settings.value)}'> * </div> * `, * children: { * '.user-info-container': 'UserInfo' * } * }); * * app.component("UserInfo", { * setup({ props }) { * return { * user: props.user, // Automatically parsed object * permissions: props.permissions, // Automatically parsed array * settings: props.settings // Automatically parsed object * }; * } * }); */ const PropsPlugin = { /** * Unique identifier for the plugin * @type {string} */ name: "props", /** * Plugin version * @type {string} */ version: "1.0.0-rc.2", /** * Plugin description * @type {string} */ description: "Advanced props data handling for complex data structures with automatic type detection and reactivity", /** * Installs the plugin into the Eleva instance * * @param {Object} eleva - The Eleva instance * @param {Object} options - Plugin configuration options * @param {boolean} [options.enableAutoParsing=true] - Enable automatic type detection and parsing * @param {boolean} [options.enableReactivity=true] - Enable reactive prop updates using Eleva's signal system * @param {Function} [options.onError=null] - Error handler function called when parsing fails * * @example * // Basic installation * app.use(PropsPlugin); * * // Installation with custom options * app.use(PropsPlugin, { * enableAutoParsing: true, * enableReactivity: false, * onError: (error, value) => { * console.error('Props parsing error:', error, value); * } * }); */ install(eleva, options = {}) { const { enableAutoParsing = true, enableReactivity = true, onError = null } = options; /** * Detects the type of a given value * @private * @param {any} value - The value to detect type for * @returns {string} The detected type ('string', 'number', 'boolean', 'object', 'array', 'date', 'map', 'set', 'function', 'null', 'undefined', 'unknown') * * @example * detectType("hello") // → "string" * detectType(42) // → "number" * detectType(true) // → "boolean" * detectType([1, 2, 3]) // → "array" * detectType({}) // → "object" * detectType(new Date()) // → "date" * detectType(null) // → "null" */ const detectType = value => { if (value === null) return "null"; if (value === undefined) return "undefined"; if (typeof value === "boolean") return "boolean"; if (typeof value === "number") return "number"; if (typeof value === "string") return "string"; if (typeof value === "function") return "function"; if (value instanceof Date) return "date"; if (value instanceof Map) return "map"; if (value instanceof Set) return "set"; if (Array.isArray(value)) return "array"; if (typeof value === "object") return "object"; return "unknown"; }; /** * Parses a prop value with automatic type detection * @private * @param {any} value - The value to parse * @returns {any} The parsed value with appropriate type * * @description * This function automatically detects and parses different data types from string values: * - Special strings: "true" → true, "false" → false, "null" → null, "undefined" → undefined * - JSON objects/arrays: '{"key": "value"}' → {key: "value"}, '[1, 2, 3]' → [1, 2, 3] * - Boolean-like strings: "1" → true, "0" → false, "" → true * - Numeric strings: "42" → 42, "3.14" → 3.14 * - Date strings: "2023-01-01T00:00:00.000Z" → Date object * - Other strings: returned as-is * * @example * parsePropValue("true") // → true * parsePropValue("42") // → 42 * parsePropValue('{"key": "val"}') // → {key: "val"} * parsePropValue('[1, 2, 3]') // → [1, 2, 3] * parsePropValue("hello") // → "hello" */ const parsePropValue = value => { try { // Handle non-string values - return as-is if (typeof value !== "string") { return value; } // Handle special string patterns first if (value === "true") return true; if (value === "false") return false; if (value === "null") return null; if (value === "undefined") return undefined; // Try to parse as JSON (for objects and arrays) // This handles complex data structures like objects and arrays if (value.startsWith("{") || value.startsWith("[")) { try { return JSON.parse(value); } catch (e) { // Not valid JSON, throw error to trigger error handler throw new Error(`Invalid JSON: ${value}`); } } // Handle boolean-like strings (including "1" and "0") // These are common in HTML attributes and should be treated as booleans if (value === "1") return true; if (value === "0") return false; if (value === "") return true; // Empty string is truthy in HTML attributes // Handle numeric strings (after boolean check to avoid conflicts) // This ensures "0" is treated as boolean false, not number 0 if (!isNaN(value) && value !== "" && !isNaN(parseFloat(value))) { return Number(value); } // Handle date strings (ISO format) // Recognizes standard ISO date format and converts to Date object if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) { const date = new Date(value); if (!isNaN(date.getTime())) { return date; } } // Return as string if no other parsing applies // This is the fallback for regular text strings return value; } catch (error) { // Call error handler if provided if (onError) { onError(error, value); } // Fallback to original value to prevent breaking the application return value; } }; /** * Enhanced props extraction with automatic type detection * @private * @param {HTMLElement} element - The DOM element to extract props from * @returns {Object} Object containing parsed props with appropriate types * * @description * Extracts props from DOM element attributes that start with ":" and automatically * parses them to their appropriate types. Removes the attributes from the element * after extraction. * * @example * // HTML: <div :name="John" :age="30" :active="true" :data='{"key": "value"}'></div> * const props = extractProps(element); * // Result: { name: "John", age: 30, active: true, data: {key: "value"} } */ const extractProps = element => { const props = {}; const attrs = element.attributes; // Iterate through attributes in reverse order to handle removal correctly for (let i = attrs.length - 1; i >= 0; i--) { const attr = attrs[i]; // Only process attributes that start with ":" (prop attributes) if (attr.name.startsWith(":")) { const propName = attr.name.slice(1); // Remove the ":" prefix // Parse the value if auto-parsing is enabled, otherwise use as-is const parsedValue = enableAutoParsing ? parsePropValue(attr.value) : attr.value; props[propName] = parsedValue; // Remove the attribute from the DOM element after extraction element.removeAttribute(attr.name); } } return props; }; /** * Creates reactive props using Eleva's signal system * @private * @param {Object} props - The props object to make reactive * @returns {Object} Object containing reactive props (Eleva signals) * * @description * Converts regular prop values into Eleva signals for reactive updates. * If a value is already a signal, it's passed through unchanged. * * @example * const props = { name: "John", age: 30, active: true }; * const reactiveProps = createReactiveProps(props); * // Result: { * // name: Signal("John"), * // age: Signal(30), * // active: Signal(true) * // } */ const createReactiveProps = props => { const reactiveProps = {}; // Convert each prop value to a reactive signal Object.entries(props).forEach(([key, value]) => { // Check if value is already a signal (has 'value' and 'watch' properties) if (value && typeof value === "object" && "value" in value && "watch" in value) { // Value is already a signal, use it as-is reactiveProps[key] = value; } else { // Create new signal for the prop value to make it reactive reactiveProps[key] = new eleva.signal(value); } }); return reactiveProps; }; // Override Eleva's internal _extractProps method with our enhanced version eleva._extractProps = extractProps; // Override Eleva's mount method to apply enhanced prop handling const originalMount = eleva.mount; eleva.mount = async (container, compName, props = {}) => { // Create reactive props if reactivity is enabled const enhancedProps = enableReactivity ? createReactiveProps(props) : props; // Call the original mount method with enhanced props return await originalMount.call(eleva, container, compName, enhancedProps); }; // Override Eleva's _mountComponents method to enable signal reference passing const originalMountComponents = eleva._mountComponents; // Cache to store parent contexts by container element const parentContextCache = new WeakMap(); // Store child instances that need signal linking const pendingSignalLinks = new Set(); eleva._mountComponents = async (container, children, childInstances) => { for (const [selector, component] of Object.entries(children)) { if (!selector) continue; for (const el of container.querySelectorAll(selector)) { if (!(el instanceof HTMLElement)) continue; // Extract props from DOM attributes const extractedProps = eleva._extractProps(el); // Get parent context to check for signal references let enhancedProps = extractedProps; // Try to find parent context by looking up the DOM tree let parentContext = parentContextCache.get(container); if (!parentContext) { let currentElement = container; while (currentElement && !parentContext) { if (currentElement._eleva_instance && currentElement._eleva_instance.data) { parentContext = currentElement._eleva_instance.data; // Cache the parent context for future use parentContextCache.set(container, parentContext); break; } currentElement = currentElement.parentElement; } } if (enableReactivity && parentContext) { const signalProps = {}; // Check each extracted prop to see if there's a matching signal in parent context Object.keys(extractedProps).forEach(propName => { if (parentContext[propName] && parentContext[propName] instanceof eleva.signal) { // Found a signal in parent context with the same name as the prop // Pass the signal reference instead of creating a new one signalProps[propName] = parentContext[propName]; } }); // Merge signal props with regular props (signal props take precedence) enhancedProps = _extends({}, extractedProps, signalProps); } // Create reactive props for non-signal props only let finalProps = enhancedProps; if (enableReactivity) { // Only create reactive props for values that aren't already signals const nonSignalProps = {}; Object.entries(enhancedProps).forEach(([key, value]) => { if (!(value && typeof value === "object" && "value" in value && "watch" in value)) { // This is not a signal, create a reactive prop for it nonSignalProps[key] = value; } }); // Create reactive props only for non-signal values const reactiveNonSignalProps = createReactiveProps(nonSignalProps); // Merge signal props with reactive non-signal props finalProps = _extends({}, reactiveNonSignalProps, enhancedProps); } /** @type {MountResult} */ const instance = await eleva.mount(el, component, finalProps); if (instance && !childInstances.includes(instance)) { childInstances.push(instance); // If we have extracted props but no parent context yet, mark for later signal linking if (enableReactivity && Object.keys(extractedProps).length > 0 && !parentContext) { pendingSignalLinks.add({ instance, extractedProps, container, component }); } } } } // After mounting all children, try to link signals for pending instances if (enableReactivity && pendingSignalLinks.size > 0) { for (const pending of pendingSignalLinks) { const { instance, extractedProps, container, component } = pending; // Try to find parent context again let parentContext = parentContextCache.get(container); if (!parentContext) { let currentElement = container; while (currentElement && !parentContext) { if (currentElement._eleva_instance && currentElement._eleva_instance.data) { parentContext = currentElement._eleva_instance.data; parentContextCache.set(container, parentContext); break; } currentElement = currentElement.parentElement; } } if (parentContext) { const signalProps = {}; // Check each extracted prop to see if there's a matching signal in parent context Object.keys(extractedProps).forEach(propName => { if (parentContext[propName] && parentContext[propName] instanceof eleva.signal) { signalProps[propName] = parentContext[propName]; } }); // Update the child instance's data with signal references if (Object.keys(signalProps).length > 0) { Object.assign(instance.data, signalProps); // Set up signal watchers for the newly linked signals Object.keys(signalProps).forEach(propName => { const signal = signalProps[propName]; if (signal && typeof signal.watch === "function") { signal.watch(newValue => { // Trigger a re-render of the child component when the signal changes const childComponent = eleva._components.get(component) || component; if (childComponent && childComponent.template) { const templateResult = typeof childComponent.template === "function" ? childComponent.template(instance.data) : childComponent.template; const newHtml = TemplateEngine.parse(templateResult, instance.data); eleva.renderer.patchDOM(instance.container, newHtml); } }); } }); // Initial re-render to show the correct signal values const childComponent = eleva._components.get(component) || component; if (childComponent && childComponent.template) { const templateResult = typeof childComponent.template === "function" ? childComponent.template(instance.data) : childComponent.template; const newHtml = TemplateEngine.parse(templateResult, instance.data); eleva.renderer.patchDOM(instance.container, newHtml); } } // Remove from pending list pendingSignalLinks.delete(pending); } } } }; /** * Expose utility methods on the Eleva instance * @namespace eleva.props */ eleva.props = { /** * Parse a single value with automatic type detection * @param {any} value - The value to parse * @returns {any} The parsed value with appropriate type * * @example * app.props.parse("42") // → 42 * app.props.parse("true") // → true * app.props.parse('{"key": "val"}') // → {key: "val"} */ parse: value => { // Return value as-is if auto parsing is disabled if (!enableAutoParsing) { return value; } // Use our enhanced parsing function return parsePropValue(value); }, /** * Detect the type of a value * @param {any} value - The value to detect type for * @returns {string} The detected type * * @example * app.props.detectType("hello") // → "string" * app.props.detectType(42) // → "number" * app.props.detectType([1, 2, 3]) // → "array" */ detectType }; // Store original methods for uninstall eleva._originalExtractProps = eleva._extractProps; eleva._originalMount = originalMount; eleva._originalMountComponents = originalMountComponents; }, /** * Uninstalls the plugin from the Eleva instance * * @param {Object} eleva - The Eleva instance * * @description * Restores the original Eleva methods and removes all plugin-specific * functionality. This method should be called when the plugin is no * longer needed. * * @example * // Uninstall the plugin * PropsPlugin.uninstall(app); */ uninstall(eleva) { // Restore original _extractProps method if (eleva._originalExtractProps) { eleva._extractProps = eleva._originalExtractProps; delete eleva._originalExtractProps; } // Restore original mount method if (eleva._originalMount) { eleva.mount = eleva._originalMount; delete eleva._originalMount; } // Restore original _mountComponents method if (eleva._originalMountComponents) { eleva._mountComponents = eleva._originalMountComponents; delete eleva._originalMountComponents; } // Remove plugin utility methods if (eleva.props) { delete eleva.props; } } }; exports.PropsPlugin = PropsPlugin; })); //# sourceMappingURL=props.umd.js.map