@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
357 lines (306 loc) • 10.8 kB
text/typescript
/**
* @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
}
}