@hypen-space/web 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hypen.ts ADDED
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Hypen - High-Level API for Web Applications
3
+ *
4
+ * Simple API for rendering Hypen applications (like ReactDOM.render)
5
+ */
6
+
7
+ import {
8
+ BrowserEngine as Engine,
9
+ HypenModuleInstance,
10
+ HypenRouter,
11
+ HypenGlobalContext,
12
+ componentLoader,
13
+ Router,
14
+ Route,
15
+ Link,
16
+ type RouterContext,
17
+ type HypenModuleDefinition,
18
+ } from "@hypen-space/core";
19
+ import { DOMRenderer } from "./dom/renderer.js";
20
+ import type { DebugConfig } from "./dom/debug.js";
21
+
22
+ export interface HypenConfig {
23
+ /** Base directory for components (default: "./src/components") */
24
+ componentsDir?: string;
25
+ /** Enable debug logging */
26
+ debug?: boolean;
27
+ /** Custom WASM path */
28
+ wasmPath?: string;
29
+ /** Enable re-render heatmap debugging */
30
+ debugHeatmap?: boolean;
31
+ /** Heatmap increment per re-render (default: 5%) */
32
+ heatmapIncrement?: number;
33
+ /** Heatmap fade out duration in ms (default: 2000) */
34
+ heatmapFadeOut?: number;
35
+ }
36
+
37
+ export class Hypen {
38
+ private engine: Engine | null = null;
39
+ private renderer: DOMRenderer | null = null;
40
+ private moduleInstance: HypenModuleInstance<any> | null = null;
41
+ private container: HTMLElement | null = null;
42
+ private config: HypenConfig;
43
+ private router: HypenRouter;
44
+ private globalContext: HypenGlobalContext;
45
+ private moduleInstances = new Map<string, HypenModuleInstance<any>>();
46
+
47
+ constructor(config: HypenConfig = {}) {
48
+ this.config = {
49
+ componentsDir: "./src/components",
50
+ debug: false,
51
+ ...config,
52
+ };
53
+
54
+ // Initialize router and global context
55
+ this.router = new HypenRouter();
56
+ this.globalContext = new HypenGlobalContext();
57
+
58
+ // Register built-in components
59
+ componentLoader.register("Router", Router, "");
60
+ componentLoader.register("Route", Route, "");
61
+ componentLoader.register("Link", Link, "");
62
+
63
+ // Store router and hypen instance in global context for access in components
64
+ (this.globalContext as any).__router = this.router;
65
+ (this.globalContext as any).__hypenEngine = this;
66
+ }
67
+
68
+ /**
69
+ * Initialize the Hypen runtime
70
+ * Must be called before render()
71
+ */
72
+ async init(): Promise<void> {
73
+ if (this.config.debug) {
74
+ console.log("[Hypen] Initializing...");
75
+ }
76
+
77
+ // Initialize engine
78
+ this.engine = new Engine();
79
+ await this.engine.init(
80
+ this.config.wasmPath ? { wasmPath: this.config.wasmPath } : undefined
81
+ );
82
+
83
+ if (this.config.debug) {
84
+ console.log("[Hypen] Engine initialized");
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Load all components from the components directory
90
+ */
91
+ async loadComponents(componentsDir?: string): Promise<void> {
92
+ const dir = componentsDir || this.config.componentsDir!;
93
+
94
+ if (this.config.debug) {
95
+ console.log(`[Hypen] Loading components from ${dir}...`);
96
+ }
97
+
98
+ await componentLoader.loadFromComponentsDir(dir);
99
+
100
+ if (this.config.debug) {
101
+ console.log(
102
+ `[Hypen] Loaded ${componentLoader.getNames().length} components`
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Render a component to a DOM container
109
+ *
110
+ * @param componentName - Name of the component to render (e.g., "HomePage")
111
+ * @param containerSelector - CSS selector or HTMLElement for the mount point
112
+ */
113
+ async render(
114
+ componentName: string,
115
+ containerSelector: string | HTMLElement
116
+ ): Promise<void> {
117
+ if (!this.engine) {
118
+ throw new Error("[Hypen] Engine not initialized. Call init() first.");
119
+ }
120
+
121
+ // Get the container element
122
+ if (typeof containerSelector === "string") {
123
+ const element = document.querySelector(containerSelector);
124
+ if (!element) {
125
+ throw new Error(`[Hypen] Container not found: ${containerSelector}`);
126
+ }
127
+ this.container = element as HTMLElement;
128
+ } else {
129
+ this.container = containerSelector;
130
+ }
131
+
132
+ // Get the component definition
133
+ const component = componentLoader.get(componentName);
134
+ if (!component) {
135
+ throw new Error(
136
+ `[Hypen] Component "${componentName}" not found. Available: ${componentLoader.getNames().join(", ")}`
137
+ );
138
+ }
139
+
140
+ if (this.config.debug) {
141
+ console.log(`[Hypen] Rendering ${componentName} to`, this.container);
142
+ }
143
+
144
+ // Create renderer with debug config
145
+ this.renderer = new DOMRenderer(this.container, this.engine, {
146
+ enabled: this.config.debugHeatmap || false,
147
+ showHeatmap: this.config.debugHeatmap || false,
148
+ heatmapIncrement: this.config.heatmapIncrement || 5,
149
+ fadeOutDuration: this.config.heatmapFadeOut || 2000,
150
+ maxOpacity: 0.8,
151
+ });
152
+
153
+ // Set render callback
154
+ this.engine.setRenderCallback((patches) => {
155
+ if (this.config.debug) {
156
+ console.log(`[Hypen] Applying ${patches.length} patches`);
157
+ }
158
+ this.renderer!.applyPatches(patches);
159
+ });
160
+
161
+ // Create router context
162
+ const routerContext: RouterContext = {
163
+ root: this.router,
164
+ current: this.router,
165
+ };
166
+
167
+ // Set context on renderer for component composition
168
+ this.renderer.setContext(routerContext, this.globalContext);
169
+
170
+ // Extract module ID from component name or .id() applicator
171
+ const moduleId = this.extractModuleId(componentName, component.template);
172
+
173
+ // Create module instance with router and global context
174
+ this.moduleInstance = new HypenModuleInstance(
175
+ this.engine,
176
+ component.module,
177
+ routerContext,
178
+ this.globalContext
179
+ );
180
+
181
+ // Register module in global context
182
+ this.globalContext.registerModule(moduleId, this.moduleInstance);
183
+ this.moduleInstances.set(moduleId, this.moduleInstance);
184
+
185
+ // Connect module state changes to renderer
186
+ this.moduleInstance.onStateChange(() => {
187
+ const mergedState = this.getMergedState();
188
+ if (this.config.debug) {
189
+ console.log(`[Hypen] State changed, merged state:`, mergedState);
190
+ }
191
+ this.renderer!.updateState(mergedState);
192
+ });
193
+
194
+ // Set up component resolver for dynamic component composition
195
+ this.setupComponentResolver();
196
+
197
+ // Create module instances for ALL components that have state
198
+ this.createNestedModuleInstances();
199
+
200
+ // Render the UI template
201
+ this.engine.renderSource(component.template);
202
+
203
+ // Update renderer with initial state
204
+ this.renderer!.updateState(this.getMergedState());
205
+
206
+ if (this.config.debug) {
207
+ console.log(`[Hypen] ${componentName} rendered successfully`);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Unmount and cleanup
213
+ */
214
+ async unmount(): Promise<void> {
215
+ if (this.config.debug) {
216
+ console.log("[Hypen] Unmounting...");
217
+ }
218
+
219
+ if (this.moduleInstance) {
220
+ await this.moduleInstance.destroy();
221
+ this.moduleInstance = null;
222
+ }
223
+
224
+ if (this.container) {
225
+ this.container.innerHTML = "";
226
+ this.container = null;
227
+ }
228
+
229
+ this.renderer = null;
230
+
231
+ if (this.config.debug) {
232
+ console.log("[Hypen] Unmounted");
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Get the current module instance
238
+ */
239
+ getModuleInstance(): HypenModuleInstance<any> | null {
240
+ return this.moduleInstance;
241
+ }
242
+
243
+ /**
244
+ * Get the current state
245
+ */
246
+ getState(): any {
247
+ return this.moduleInstance?.getState() ?? null;
248
+ }
249
+
250
+ /**
251
+ * Get the router instance
252
+ */
253
+ getRouter(): HypenRouter {
254
+ return this.router;
255
+ }
256
+
257
+ /**
258
+ * Get the global context
259
+ */
260
+ getGlobalContext(): HypenGlobalContext {
261
+ return this.globalContext;
262
+ }
263
+
264
+ /**
265
+ * Enable or disable debug heatmap mode
266
+ */
267
+ setDebugHeatmap(enabled: boolean): void {
268
+ if (this.renderer) {
269
+ this.renderer.setDebugConfig({ enabled, showHeatmap: enabled });
270
+ if (this.config.debug) {
271
+ console.log(`[Hypen] Debug heatmap ${enabled ? "enabled" : "disabled"}`);
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Reset debug tracking for all elements
278
+ */
279
+ resetDebugTracking(): void {
280
+ if (this.renderer) {
281
+ this.renderer.resetDebugTracking();
282
+ if (this.config.debug) {
283
+ console.log(`[Hypen] Debug tracking reset`);
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Get debug statistics
290
+ */
291
+ getDebugStats(): {
292
+ totalRerenders: number;
293
+ elementCount: number;
294
+ avgRerenders: number;
295
+ } | null {
296
+ return this.renderer?.getDebugStats() || null;
297
+ }
298
+
299
+ /**
300
+ * Render a lazy route component into a specific route element
301
+ * This is called by the Router when a route becomes active
302
+ */
303
+ async renderLazyRoute(
304
+ routePath: string,
305
+ componentName: string,
306
+ routeElement: HTMLElement
307
+ ): Promise<void> {
308
+ if (!this.engine || !this.renderer) {
309
+ throw new Error("Engine not initialized");
310
+ }
311
+
312
+ // Get the component from the loader
313
+ const component = componentLoader.get(componentName);
314
+ if (!component) {
315
+ throw new Error(`Component ${componentName} not found`);
316
+ }
317
+
318
+ // Get the component template
319
+ const template = component.template;
320
+ if (!template) {
321
+ throw new Error(`Component ${componentName} has no template`);
322
+ }
323
+
324
+ // Create module instance for this component if it has state
325
+ if (component.module && !this.moduleInstances.has(componentName)) {
326
+ const moduleId = this.extractModuleId(componentName, template);
327
+
328
+ const routerContext: RouterContext = {
329
+ root: this.router,
330
+ current: this.router,
331
+ };
332
+
333
+ const moduleInstance = new HypenModuleInstance(
334
+ this.engine,
335
+ component.module,
336
+ routerContext,
337
+ this.globalContext
338
+ );
339
+
340
+ this.globalContext.registerModule(moduleId, moduleInstance);
341
+ this.moduleInstances.set(componentName, moduleInstance);
342
+
343
+ // Listen to state changes
344
+ moduleInstance.onStateChange(() => {
345
+ const mergedState = this.getMergedState();
346
+ if (this.config.debug) {
347
+ console.log(
348
+ `[Hypen] Lazy component ${componentName} state changed:`,
349
+ mergedState
350
+ );
351
+ }
352
+ this.renderer!.updateState(mergedState);
353
+ });
354
+ }
355
+
356
+ // Wait a tick for module instance to initialize and fetch data
357
+ await new Promise((resolve) => setTimeout(resolve, 50));
358
+
359
+ // Get the route element's node ID from the renderer
360
+ const routeNodeId = routeElement.dataset.hypenId;
361
+ if (!routeNodeId) {
362
+ throw new Error(`Route element is missing data-hypen-id attribute`);
363
+ }
364
+
365
+ // Get the current merged state
366
+ const mergedState = this.getMergedState();
367
+
368
+ // Render into the Route element
369
+ this.engine.renderInto(template, routeNodeId, mergedState);
370
+
371
+ // Ensure freshly created text nodes are interpolated with current state
372
+ this.renderer!.updateState(mergedState);
373
+ }
374
+
375
+ /**
376
+ * Extract module ID from component name or .id() applicator
377
+ */
378
+ private extractModuleId(componentName: string, template: string): string {
379
+ // Look for .id("CustomName") or .id('CustomName') in the template
380
+ const idMatch = template.match(/\.id\(["']([^"']+)["']\)/);
381
+ const matchedId = idMatch?.[1];
382
+ if (matchedId) {
383
+ return matchedId;
384
+ }
385
+
386
+ // Default to component name
387
+ return componentName;
388
+ }
389
+
390
+ /**
391
+ * Create module instances for all components that have state
392
+ */
393
+ private createNestedModuleInstances(): void {
394
+ const routerContext: RouterContext = {
395
+ root: this.router,
396
+ current: this.router,
397
+ };
398
+
399
+ // Create built-in Router module instance
400
+ if (Router && !this.moduleInstances.has("Router")) {
401
+ const routerInstance = new HypenModuleInstance(
402
+ this.engine!,
403
+ Router,
404
+ routerContext,
405
+ this.globalContext
406
+ );
407
+ this.globalContext.registerModule("Router", routerInstance);
408
+ this.moduleInstances.set("Router", routerInstance);
409
+ routerInstance.onStateChange(() => {
410
+ const mergedState = this.getMergedState();
411
+ if (this.config.debug) {
412
+ console.log(`[Hypen] Router state changed:`, mergedState);
413
+ }
414
+ this.renderer!.updateState(mergedState);
415
+ });
416
+ }
417
+
418
+ // Get all registered components
419
+ const componentNames = componentLoader.getNames();
420
+
421
+ for (const name of componentNames) {
422
+ // Skip if already created
423
+ if (this.moduleInstances.has(name)) {
424
+ continue;
425
+ }
426
+
427
+ const comp = componentLoader.get(name);
428
+ if (!comp || !comp.module) {
429
+ continue;
430
+ }
431
+
432
+ // Create module instance
433
+ if (this.config.debug) {
434
+ console.log(`[Hypen] Creating nested module instance for: ${name}`);
435
+ }
436
+
437
+ const moduleInstance = new HypenModuleInstance(
438
+ this.engine!,
439
+ comp.module,
440
+ routerContext,
441
+ this.globalContext
442
+ );
443
+
444
+ this.globalContext.registerModule(name, moduleInstance);
445
+ this.moduleInstances.set(name, moduleInstance);
446
+
447
+ // Connect state changes to renderer
448
+ moduleInstance.onStateChange(() => {
449
+ const mergedState = this.getMergedState();
450
+ if (this.config.debug) {
451
+ console.log(
452
+ `[Hypen] Nested component ${name} state changed:`,
453
+ mergedState
454
+ );
455
+ }
456
+ this.renderer!.updateState(mergedState);
457
+ });
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Get merged state from all module instances
463
+ */
464
+ private getMergedState(): Record<string, any> {
465
+ const merged: Record<string, any> = {};
466
+
467
+ // Include main module state
468
+ if (this.moduleInstance) {
469
+ const mainState = this.moduleInstance.getState();
470
+ Object.assign(merged, mainState);
471
+ }
472
+
473
+ // Include all nested component states
474
+ for (const [name, instance] of this.moduleInstances.entries()) {
475
+ const nestedState = instance.getState();
476
+ Object.assign(merged, nestedState);
477
+ }
478
+
479
+ return merged;
480
+ }
481
+
482
+ /**
483
+ * Set up component resolver for the engine
484
+ */
485
+ private setupComponentResolver(): void {
486
+ if (!this.engine) return;
487
+
488
+ // List of built-in DOM elements that should NOT be resolved
489
+ const builtInElements = new Set([
490
+ "Column",
491
+ "Row",
492
+ "Text",
493
+ "Button",
494
+ "Image",
495
+ "Input",
496
+ "Container",
497
+ "Box",
498
+ "Center",
499
+ "List",
500
+ "Canvas",
501
+ "Spacer",
502
+ "Divider",
503
+ "ScrollView",
504
+ ]);
505
+
506
+ this.engine.setComponentResolver(
507
+ (componentName: string, contextPath: string | null) => {
508
+ // Don't try to resolve built-in DOM elements
509
+ if (builtInElements.has(componentName)) {
510
+ return null;
511
+ }
512
+
513
+ if (this.config.debug) {
514
+ console.log(
515
+ `[Hypen] Resolving component: ${componentName} (context: ${contextPath})`
516
+ );
517
+ }
518
+
519
+ // Check if component is registered
520
+ const componentDef = componentLoader.get(componentName);
521
+ if (!componentDef) {
522
+ if (this.config.debug) {
523
+ console.log(`[Hypen] Component not found: ${componentName}`);
524
+ }
525
+ return null;
526
+ }
527
+
528
+ // Router is passthrough, Route is lazy
529
+ const isPassthrough = componentName === "Router";
530
+ const isLazy = componentName === "Route";
531
+
532
+ const resolved = {
533
+ source: componentDef.template,
534
+ path: componentDef.path || componentName,
535
+ passthrough: isPassthrough,
536
+ lazy: isLazy,
537
+ };
538
+
539
+ if (this.config.debug) {
540
+ const flags = [];
541
+ if (isPassthrough) flags.push("passthrough");
542
+ if (isLazy) flags.push("lazy");
543
+ const flagStr = flags.length > 0 ? ` (${flags.join(", ")})` : "";
544
+ console.log(
545
+ `[Hypen] Resolved ${componentName} -> ${resolved.path}${flagStr}`
546
+ );
547
+ }
548
+
549
+ return resolved;
550
+ }
551
+ );
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Quick render function (like ReactDOM.render)
557
+ *
558
+ * @example
559
+ * ```typescript
560
+ * import { render } from "@hypen-space/web";
561
+ *
562
+ * await render("HomePage", "#app");
563
+ * ```
564
+ */
565
+ export async function render(
566
+ componentName: string,
567
+ containerSelector: string | HTMLElement,
568
+ config?: HypenConfig
569
+ ): Promise<Hypen> {
570
+ const hypen = new Hypen(config);
571
+ await hypen.init();
572
+ await hypen.loadComponents();
573
+ await hypen.render(componentName, containerSelector);
574
+ return hypen;
575
+ }
576
+
577
+ /**
578
+ * Render with explicit component loading
579
+ *
580
+ * @example
581
+ * ```typescript
582
+ * import { renderWithComponents } from "@hypen-space/web";
583
+ * import HomePage from "./components/HomePage/component";
584
+ * import homePageTemplate from "./components/HomePage/component.hypen";
585
+ *
586
+ * await renderWithComponents(
587
+ * { HomePage: { module: HomePage, template: homePageTemplate } },
588
+ * "HomePage",
589
+ * "#app"
590
+ * );
591
+ * ```
592
+ */
593
+ export async function renderWithComponents(
594
+ components: Record<string, { module: HypenModuleDefinition; template: string }>,
595
+ componentName: string,
596
+ containerSelector: string | HTMLElement,
597
+ config?: HypenConfig
598
+ ): Promise<Hypen> {
599
+ const hypen = new Hypen(config);
600
+ await hypen.init();
601
+
602
+ // Register components manually
603
+ for (const [name, { module, template }] of Object.entries(components)) {
604
+ let processedTemplate = template;
605
+
606
+ // If template starts with "module ComponentName {", extract just the children
607
+ const moduleMatch = template.match(/^\s*module\s+\w+\s*\{([\s\S]*)\}\s*$/);
608
+ if (moduleMatch && moduleMatch[1]) {
609
+ processedTemplate = moduleMatch[1].trim();
610
+ }
611
+
612
+ componentLoader.register(name, module, processedTemplate);
613
+ }
614
+
615
+ await hypen.render(componentName, containerSelector);
616
+ return hypen;
617
+ }
package/src/index.ts CHANGED
@@ -45,6 +45,13 @@ export { RerenderTracker, type DebugConfig, defaultDebugConfig } from "./dom/deb
45
45
  export { CanvasRenderer } from "./canvas/renderer.js";
46
46
  export { canvasHandler, canvasApplicators } from "./dom/canvas/index.js";
47
47
 
48
+ // ============================================================================
49
+ // HIGH-LEVEL API
50
+ // ============================================================================
51
+
52
+ export { Hypen, render, renderWithComponents } from "./hypen.js";
53
+ export type { HypenConfig } from "./hypen.js";
54
+
48
55
  // Re-export core types that web users commonly need
49
56
  export type {
50
57
  Patch,
@@ -53,4 +60,4 @@ export type {
53
60
  RouterContext,
54
61
  HypenModuleInstance,
55
62
  HypenGlobalContext,
56
- } from "@hypen/core";
63
+ } from "@hypen-space/core";