UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

357 lines (306 loc) 10.8 kB
/** * @fileoverview OrdoJS Runtime - Minimal runtime components */ import type { ComponentData, HydrationData, Props } from '../types/index.js'; /** * Component instance for hydrated components */ interface ComponentInstance { id: string; name: string; element: Element; props: Props; state: Record<string, any>; eventListeners: Map<string, EventListener>; update: () => void; unmount: () => void; } /** * Minimal runtime for hydration and reactivity */ export class OrdoJSRuntime { private static instance: OrdoJSRuntime; private hydratedComponents: Map<string, ComponentInstance> = new Map(); private componentConstructors: Map<string, Function> = new Map(); static getInstance(): OrdoJSRuntime { if (!OrdoJSRuntime.instance) { OrdoJSRuntime.instance = new OrdoJSRuntime(); } return OrdoJSRuntime.instance; } /** * Initialize the runtime */ init(): void { // Auto-hydrate components on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.autoHydrate()); } else { this.autoHydrate(); } } /** * Register a component constructor for hydration */ registerComponent(name: string, constructor: Function): void { this.componentConstructors.set(name, constructor); } /** * Auto-hydrate all components found in the DOM */ autoHydrate(): void { // Find all elements with hydration data const hydrationScript = document.getElementById('ordojs-hydration-data'); if (hydrationScript && hydrationScript.textContent) { try { const hydrationData: HydrationData = JSON.parse(hydrationScript.textContent); this.hydrateFromData(hydrationData); } catch (error) { console.error('Failed to parse hydration data:', error); } } // Find all components marked for hydration const componentElements = document.querySelectorAll('[data-ordojs-component]'); componentElements.forEach(element => { const componentName = element.getAttribute('data-ordojs-component'); const componentId = element.getAttribute('data-component-id'); if (componentName && componentId && !this.hydratedComponents.has(componentId)) { this.hydrateComponent(element, componentName, componentId); } }); } /** * Hydrate a component from hydration data */ hydrateFromData(hydrationData: HydrationData): void { const element = document.querySelector(`[data-component-id="${hydrationData.componentId}"]`); if (element) { this.hydrateComponent(element, hydrationData.componentName, hydrationData.componentId, hydrationData); } } /** * Hydrate a specific component */ hydrateComponent( element: Element, componentName: string, componentId: string, hydrationData?: HydrationData ): ComponentInstance | null { const constructor = this.componentConstructors.get(componentName); if (!constructor) { console.warn(`Component constructor for "${componentName}" not found`); return null; } // Extract props from element or hydration data const props = this.extractProps(element, hydrationData); // Create component instance const componentInstance = constructor(props) as ComponentInstance; componentInstance.id = componentId; componentInstance.name = componentName; componentInstance.element = element; // Initialize reactive state from hydration data if (hydrationData?.initialState) { this.initializeReactiveState(componentInstance, hydrationData.initialState); } // Attach event listeners this.attachEventListeners(element, componentInstance); // Store the hydrated component this.hydratedComponents.set(componentId, componentInstance); // Mark as hydrated element.setAttribute('data-ordojs-hydrated', 'true'); return componentInstance; } /** * Extract props from element attributes or hydration data */ private extractProps(element: Element, hydrationData?: HydrationData): Props { let props: Props = {}; // Extract from hydration data first if (hydrationData?.props) { props = { ...hydrationData.props }; } // Extract from data-props attribute const propsAttr = element.getAttribute('data-props'); if (propsAttr) { try { const parsedProps = JSON.parse(propsAttr); props = { ...props, ...parsedProps }; } catch (error) { console.warn('Failed to parse props from data-props attribute:', error); } } return props; } /** * Initialize reactive state from server data */ private initializeReactiveState(component: ComponentInstance, initialState: Record<string, any>): void { // Initialize state properties with reactive setters for (const [key, value] of Object.entries(initialState)) { if (component.state && typeof component.state === 'object') { // Set initial value component.state[key] = value; // Make it reactive if the component has an update method if (component.update && typeof component.update === 'function') { let currentValue = value; Object.defineProperty(component.state, key, { get: () => currentValue, set: (newValue) => { if (currentValue !== newValue) { currentValue = newValue; component.update(); } }, enumerable: true, configurable: true }); } } } } /** * Attach event listeners to hydrated components */ private attachEventListeners(element: Element, component: ComponentInstance): void { // Find all elements with event handlers const eventElements = element.querySelectorAll('[data-event]'); if (eventElements) { eventElements.forEach(eventElement => { const eventName = eventElement.getAttribute('data-event'); if (!eventName) return; // Create event handler const handler = (event: Event) => { // Look for handler function in component const handlerName = `handle${eventName.charAt(0).toUpperCase() + eventName.slice(1)}`; if (component && typeof (component as any)[handlerName] === 'function') { (component as any)[handlerName](event); } }; // Attach event listener eventElement.addEventListener(eventName, handler); // Store for cleanup if (!component.eventListeners) { component.eventListeners = new Map(); } component.eventListeners.set(`${eventElement.id || 'element'}-${eventName}`, handler); }); } // Handle interpolation updates this.setupInterpolationUpdates(element, component); } /** * Set up updates for interpolated content */ private setupInterpolationUpdates(element: Element, component: ComponentInstance): void { const interpolationElements = element.querySelectorAll('[data-interpolation]'); interpolationElements.forEach(interpElement => { const varName = interpElement.getAttribute('data-interpolation'); if (!varName || !component.state) return; // Update interpolation when state changes const originalUpdate = component.update; component.update = () => { // Update interpolation content if (component.state[varName] !== undefined) { interpElement.textContent = String(component.state[varName]); } // Call original update if it exists if (originalUpdate && typeof originalUpdate === 'function') { originalUpdate.call(component); } }; }); } /** * Get a hydrated component by ID */ getComponent(componentId: string): ComponentInstance | undefined { return this.hydratedComponents.get(componentId); } /** * Get all hydrated components */ getAllComponents(): ComponentInstance[] { return Array.from(this.hydratedComponents.values()); } /** * Unmount a component */ unmountComponent(componentId: string): void { const component = this.hydratedComponents.get(componentId); if (!component) return; // Remove event listeners if (component.eventListeners) { component.eventListeners.forEach((listener, key) => { const [elementId, eventName] = key.split('-'); const element = elementId === 'element' ? component.element : document.getElementById(elementId); if (element) { element.removeEventListener(eventName, listener); } }); component.eventListeners.clear(); } // Call component unmount if available if (component.unmount && typeof component.unmount === 'function') { component.unmount(); } // Remove from registry this.hydratedComponents.delete(componentId); // Remove hydration marker component.element.removeAttribute('data-ordojs-hydrated'); } /** * Unmount all components */ unmountAll(): void { const componentIds = Array.from(this.hydratedComponents.keys()); componentIds.forEach(id => this.unmountComponent(id)); } } /** * Hydrator class for mounting to pre-rendered HTML */ export class OrdoJSHydrator { private runtime: OrdoJSRuntime; constructor() { this.runtime = OrdoJSRuntime.getInstance(); } /** * Hydrate a specific component */ hydrateComponent(element: Element, componentData: ComponentData): ComponentInstance | null { const componentName = element.getAttribute('data-ordojs-component'); const componentId = element.getAttribute('data-component-id'); if (!componentName || !componentId) { console.warn('Element missing required hydration attributes'); return null; } return this.runtime.hydrateComponent(element, componentName, componentId, { componentName, componentId, props: componentData.props, initialState: componentData.state || {}, version: '1.0' }); } /** * Attach event listeners to hydrated components */ attachEventListeners(element: Element, handlers: Record<string, Function>): void { Object.entries(handlers).forEach(([eventName, handler]) => { const eventElements = element.querySelectorAll(`[data-event="${eventName}"]`); eventElements.forEach(eventElement => { eventElement.addEventListener(eventName, handler as EventListener); }); }); } /** * Initialize reactive state */ initializeReactiveState(component: ComponentInstance): void { // This is handled by the runtime's initializeReactiveState method // This method is kept for API compatibility } }