@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
710 lines (625 loc) • 18.1 kB
text/typescript
/**
* @fileoverview OrdoJS Component System - Modern component architecture
* @author OrdoJS Framework Team
*/
import type { Props } from '../types/index.js';
import { signal, type EffectCleanup, type Signal } from './reactivity.js';
/**
* Component lifecycle phases
*/
export enum ComponentLifecycle {
CREATED = 'created',
MOUNTED = 'mounted',
UPDATED = 'updated',
UNMOUNTED = 'unmounted',
ERROR = 'error'
}
/**
* Component context for dependency injection
*/
export interface ComponentContext {
/** Parent component */
parent?: ComponentInstance;
/** Injected dependencies */
injected: Map<string | symbol, any>;
/** Provided dependencies */
provided: Map<string | symbol, any>;
/** Component registry */
registry: ComponentRegistry;
}
/**
* Component definition interface
*/
export interface ComponentDefinition<P extends Props = Props, S = any> {
/** Component name */
name: string;
/** Props validation schema */
props?: PropSchema<P>;
/** Setup function */
setup?: (props: P, context: SetupContext) => S | Promise<S>;
/** Render function */
render?: (state: S, props: P) => VNode | string;
/** Lifecycle hooks */
lifecycle?: Partial<LifecycleHooks>;
/** Component styles */
styles?: string | (() => string);
/** Component metadata */
meta?: ComponentMeta;
}
/**
* Component metadata
*/
export interface ComponentMeta {
/** Component version */
version?: string;
/** Component description */
description?: string;
/** Component tags */
tags?: string[];
/** Is component async */
async?: boolean;
/** Component dependencies */
dependencies?: string[];
}
/**
* Props validation schema
*/
export interface PropSchema<P = any> {
[key: string]: PropDefinition<any>;
}
/**
* Prop definition
*/
export interface PropDefinition<T = any> {
/** Prop type */
type: PropType<T>;
/** Is required */
required?: boolean;
/** Default value */
default?: T | (() => T);
/** Validation function */
validator?: (value: T) => boolean;
/** Prop description */
description?: string;
}
/**
* Prop types
*/
export type PropType<T> =
| 'string'
| 'number'
| 'boolean'
| 'object'
| 'array'
| 'function'
| ((value: any) => value is T);
/**
* Setup context
*/
export interface SetupContext {
/** Component props */
props: Signal<Props>;
/** Emit events */
emit: (event: string, payload?: any) => void;
/** Expose public API */
expose: (api: Record<string, any>) => void;
/** Access parent context */
parent?: ComponentInstance;
/** Inject dependency */
inject: <T>(key: string | symbol, defaultValue?: T) => T;
/** Provide dependency */
provide: <T>(key: string | symbol, value: T) => void;
/** Component slots */
slots: Record<string, () => VNode[]>;
}
/**
* Lifecycle hooks
*/
export interface LifecycleHooks {
/** Before component creation */
beforeCreate?: () => void;
/** After component creation */
created?: () => void;
/** Before component mount */
beforeMount?: () => void;
/** After component mount */
mounted?: () => void;
/** Before component update */
beforeUpdate?: () => void;
/** After component update */
updated?: () => void;
/** Before component unmount */
beforeUnmount?: () => void;
/** After component unmount */
unmounted?: () => void;
/** Error handler */
errorCaptured?: (error: Error, instance: ComponentInstance) => boolean | void;
}
/**
* Virtual DOM node
*/
export interface VNode {
/** Node type */
type: string | ComponentDefinition;
/** Node props */
props?: Props;
/** Child nodes */
children?: VNode[];
/** Node key */
key?: string | number;
/** Node ref */
ref?: (el: Element | ComponentInstance | null) => void;
/** Raw HTML */
innerHTML?: string;
}
/**
* Component instance
*/
export interface ComponentInstance<P extends Props = Props, S = any> {
/** Component ID */
id: string;
/** Component definition */
definition: ComponentDefinition<P, S>;
/** Component props */
props: Signal<P>;
/** Component state */
state: S;
/** Component context */
context: ComponentContext;
/** Current lifecycle phase */
lifecycle: Signal<ComponentLifecycle>;
/** DOM element */
element: Element | null;
/** Child components */
children: ComponentInstance[];
/** Event emitter */
emit: (event: string, payload?: any) => void;
/** Exposed API */
exposed: Record<string, any>;
/** Cleanup functions */
cleanups: EffectCleanup[];
/** Mount component */
mount: (target: Element) => Promise<void>;
/** Unmount component */
unmount: () => Promise<void>;
/** Update component */
update: (newProps?: Partial<P>) => Promise<void>;
/** Force re-render */
forceUpdate: () => Promise<void>;
}
/**
* Component registry
*/
export interface ComponentRegistry {
/** Register component */
register: (definition: ComponentDefinition) => void;
/** Get component */
get: (name: string) => ComponentDefinition | undefined;
/** Unregister component */
unregister: (name: string) => boolean;
/** List all components */
list: () => ComponentDefinition[];
}
/**
* Component factory options
*/
export interface ComponentFactoryOptions {
/** Global component registry */
registry?: ComponentRegistry;
/** Global error handler */
errorHandler?: (error: Error, instance: ComponentInstance) => void;
/** Development mode */
devMode?: boolean;
}
/**
* Component registry implementation
*/
class ComponentRegistryImpl implements ComponentRegistry {
private components = new Map<string, ComponentDefinition>();
register(definition: ComponentDefinition): void {
if (this.components.has(definition.name)) {
console.warn(`Component "${definition.name}" is already registered`);
}
this.components.set(definition.name, definition);
}
get(name: string): ComponentDefinition | undefined {
return this.components.get(name);
}
unregister(name: string): boolean {
return this.components.delete(name);
}
list(): ComponentDefinition[] {
return Array.from(this.components.values());
}
}
/**
* Component instance implementation
*/
class ComponentInstanceImpl<P extends Props = Props, S = any> implements ComponentInstance<P, S> {
public id: string;
public props: Signal<P>;
public state: S;
public context: ComponentContext;
public lifecycle: Signal<ComponentLifecycle>;
public element: Element | null = null;
public children: ComponentInstance[] = [];
public exposed: Record<string, any> = {};
public cleanups: EffectCleanup[] = [];
private eventListeners = new Map<string, Set<(payload: any) => void>>();
constructor(
public definition: ComponentDefinition<P, S>,
initialProps: P,
parentContext?: ComponentContext
) {
this.id = generateComponentId();
this.props = signal(initialProps);
this.lifecycle = signal(ComponentLifecycle.CREATED);
this.context = {
parent: parentContext?.parent,
injected: new Map(parentContext?.injected),
provided: new Map(),
registry: parentContext?.registry || new ComponentRegistryImpl()
};
this.state = {} as S;
this.initializeComponent();
}
emit = (event: string, payload?: any): void => {
const listeners = this.eventListeners.get(event);
if (listeners) {
for (const listener of listeners) {
try {
listener(payload);
} catch (error) {
console.error(`Error in event listener for "${event}":`, error);
}
}
}
// Bubble to parent
if (this.context.parent) {
this.context.parent.emit(`child:${event}`, { source: this, payload });
}
};
async mount(target: Element): Promise<void> {
if (this.element) {
throw new Error('Component is already mounted');
}
try {
this.runLifecycleHook('beforeMount');
// Create element
this.element = await this.render();
target.appendChild(this.element);
this.lifecycle.set(ComponentLifecycle.MOUNTED);
this.runLifecycleHook('mounted');
} catch (error) {
this.lifecycle.set(ComponentLifecycle.ERROR);
this.handleError(error as Error);
}
}
async unmount(): Promise<void> {
if (!this.element) {
return;
}
try {
this.runLifecycleHook('beforeUnmount');
// Unmount children
for (const child of this.children) {
await child.unmount();
}
// Remove element
if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
this.element = null;
// Cleanup effects
for (const cleanup of this.cleanups) {
cleanup();
}
this.cleanups = [];
this.lifecycle.set(ComponentLifecycle.UNMOUNTED);
this.runLifecycleHook('unmounted');
} catch (error) {
this.handleError(error as Error);
}
}
async update(newProps?: Partial<P>): Promise<void> {
if (newProps) {
this.props.update(current => ({ ...current, ...newProps }));
}
try {
this.runLifecycleHook('beforeUpdate');
if (this.element) {
const newElement = await this.render();
if (this.element.parentNode) {
this.element.parentNode.replaceChild(newElement, this.element);
}
this.element = newElement;
}
this.lifecycle.set(ComponentLifecycle.UPDATED);
this.runLifecycleHook('updated');
} catch (error) {
this.handleError(error as Error);
}
}
async forceUpdate(): Promise<void> {
await this.update();
}
private async initializeComponent(): Promise<void> {
try {
this.runLifecycleHook('beforeCreate');
// Validate props
if (this.definition.props) {
this.validateProps(this.props.value, this.definition.props);
}
// Setup component
if (this.definition.setup) {
const setupContext = this.createSetupContext();
const result = await this.definition.setup(this.props.value, setupContext);
if (result) {
this.state = result;
}
}
this.runLifecycleHook('created');
} catch (error) {
this.handleError(error as Error);
}
}
private createSetupContext(): SetupContext {
return {
props: this.props,
emit: this.emit,
expose: (api: Record<string, any>) => {
Object.assign(this.exposed, api);
},
parent: this.context.parent,
inject: <T>(key: string | symbol, defaultValue?: T): T => {
return this.context.injected.get(key) ?? defaultValue;
},
provide: <T>(key: string | symbol, value: T) => {
this.context.provided.set(key, value);
// Propagate to children
for (const child of this.children) {
child.context.injected.set(key, value);
}
},
slots: {} // TODO: Implement slots
};
}
private async render(): Promise<Element> {
if (this.definition.render) {
const vnode = this.definition.render(this.state, this.props.value);
return this.renderVNode(vnode);
}
// Default render
const div = document.createElement('div');
div.className = `ordojs-component ordojs-${this.definition.name}`;
div.textContent = `Component: ${this.definition.name}`;
return div;
}
private renderVNode(vnode: VNode | string): Element {
if (typeof vnode === 'string') {
const div = document.createElement('div');
div.textContent = vnode;
return div;
}
if (typeof vnode.type === 'string') {
// HTML element
const element = document.createElement(vnode.type);
// Set props as attributes
if (vnode.props) {
for (const [key, value] of Object.entries(vnode.props)) {
if (key.startsWith('on') && typeof value === 'function') {
// Event listener
const eventName = key.slice(2).toLowerCase();
element.addEventListener(eventName, value);
} else if (key === 'className' || key === 'class') {
element.className = String(value);
} else if (key === 'style' && typeof value === 'object') {
Object.assign(element.style, value);
} else {
element.setAttribute(key, String(value));
}
}
}
// Render children
if (vnode.children) {
for (const child of vnode.children) {
element.appendChild(this.renderVNode(child));
}
}
// Set innerHTML if provided
if (vnode.innerHTML) {
element.innerHTML = vnode.innerHTML;
}
return element;
} else {
// Component
const childInstance = createComponent(vnode.type, vnode.props || {}, this.context);
this.children.push(childInstance);
const childElement = document.createElement('div');
childInstance.mount(childElement).then(() => {
// Component mounted
});
return childElement;
}
}
private validateProps(props: P, schema: PropSchema<P>): void {
for (const [key, definition] of Object.entries(schema)) {
const value = props[key as keyof P];
// Check required
if (definition.required && (value === undefined || value === null)) {
throw new Error(`Required prop "${key}" is missing`);
}
// Check type
if (value !== undefined && !this.validatePropType(value, definition.type)) {
throw new Error(`Prop "${key}" has invalid type`);
}
// Custom validator
if (value !== undefined && definition.validator && !definition.validator(value)) {
throw new Error(`Prop "${key}" failed validation`);
}
}
}
private validatePropType(value: any, type: PropType<any>): boolean {
if (typeof type === 'function') {
return type(value);
}
switch (type) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number';
case 'boolean':
return typeof value === 'boolean';
case 'object':
return typeof value === 'object' && value !== null;
case 'array':
return Array.isArray(value);
case 'function':
return typeof value === 'function';
default:
return true;
}
}
private runLifecycleHook(hook: keyof LifecycleHooks): void {
if (hook === 'errorCaptured') {
return; // errorCaptured is handled separately
}
const hookFn = this.definition.lifecycle?.[hook] as (() => void) | undefined;
if (hookFn) {
try {
hookFn.call(this);
} catch (error) {
this.handleError(error as Error);
}
}
}
private handleError(error: Error): void {
console.error(`Error in component "${this.definition.name}":`, error);
// Try error boundary
if (this.definition.lifecycle?.errorCaptured) {
const handled = this.definition.lifecycle.errorCaptured(error, this as any);
if (handled) return;
}
// Bubble to parent
if (this.context.parent?.definition.lifecycle?.errorCaptured) {
const handled = this.context.parent.definition.lifecycle.errorCaptured(error, this as any);
if (handled) return;
}
// Global error handler would be called here
throw error;
}
}
/**
* Generate unique component ID
*/
function generateComponentId(): string {
return `ordojs-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Create component instance
*/
export function createComponent<P extends Props = Props, S = any>(
definition: ComponentDefinition<P, S>,
props: P,
context?: ComponentContext
): ComponentInstance<P, S> {
return new ComponentInstanceImpl(definition, props, context);
}
/**
* Define a component
*/
export function defineComponent<P extends Props = Props, S = any>(
definition: ComponentDefinition<P, S>
): ComponentDefinition<P, S> {
return definition;
}
/**
* Create virtual DOM node
*/
export function h(
type: string | ComponentDefinition,
props?: Props,
...children: (VNode | string)[]
): VNode {
return {
type,
props,
children: children.flat().map(child =>
typeof child === 'string' ? { type: 'text', props: { textContent: child } } as VNode : child
)
};
}
/**
* Fragment component for multiple root nodes
*/
export const Fragment = defineComponent({
name: 'Fragment',
render: (_, props) => {
return h('div', { style: { display: 'contents' } }, ...(props.children || []));
}
});
/**
* Component factory
*/
export class ComponentFactory {
private registry: ComponentRegistry;
private options: ComponentFactoryOptions;
constructor(options: ComponentFactoryOptions = {}) {
this.registry = options.registry || new ComponentRegistryImpl();
this.options = {
devMode: false,
...options
};
}
/**
* Register component
*/
register(definition: ComponentDefinition): void {
this.registry.register(definition);
}
/**
* Create component instance
*/
create<P extends Props = Props>(
nameOrDefinition: string | ComponentDefinition<P>,
props: P
): ComponentInstance<P> {
const definition = typeof nameOrDefinition === 'string'
? this.registry.get(nameOrDefinition)
: nameOrDefinition;
if (!definition) {
throw new Error(`Component "${nameOrDefinition}" not found`);
}
const context: ComponentContext = {
injected: new Map(),
provided: new Map(),
registry: this.registry
};
return createComponent(definition as ComponentDefinition<P>, props, context);
}
/**
* Get registry
*/
getRegistry(): ComponentRegistry {
return this.registry;
}
}
/**
* Default component factory
*/
export const componentFactory = new ComponentFactory();
/**
* Global component registration
*/
export function registerComponent(definition: ComponentDefinition): void {
componentFactory.register(definition);
}
/**
* Create component from name
*/
export function createComponentByName<P extends Props = Props>(
name: string,
props: P
): ComponentInstance<P> {
return componentFactory.create(name, props);
}