@hypen-space/core 0.2.12 → 0.3.0

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.
Files changed (45) hide show
  1. package/README.md +182 -11
  2. package/dist/src/app.js +470 -44
  3. package/dist/src/app.js.map +7 -5
  4. package/dist/src/components/builtin.js +470 -44
  5. package/dist/src/components/builtin.js.map +7 -5
  6. package/dist/src/discovery.js +559 -65
  7. package/dist/src/discovery.js.map +8 -6
  8. package/dist/src/engine.js +18 -9
  9. package/dist/src/engine.js.map +3 -3
  10. package/dist/src/index.browser.js +862 -81
  11. package/dist/src/index.browser.js.map +10 -6
  12. package/dist/src/index.js +1590 -124
  13. package/dist/src/index.js.map +16 -9
  14. package/dist/src/remote/client.js +525 -35
  15. package/dist/src/remote/client.js.map +7 -4
  16. package/dist/src/remote/index.js +1796 -35
  17. package/dist/src/remote/index.js.map +13 -4
  18. package/dist/src/router.js +55 -29
  19. package/dist/src/router.js.map +3 -3
  20. package/dist/src/state.js +57 -29
  21. package/dist/src/state.js.map +3 -3
  22. package/package.json +8 -2
  23. package/src/app.ts +292 -13
  24. package/src/discovery.ts +123 -18
  25. package/src/disposable.ts +281 -0
  26. package/src/engine.ts +29 -10
  27. package/src/hypen.ts +209 -0
  28. package/src/index.ts +147 -11
  29. package/src/logger.ts +338 -0
  30. package/src/remote/client.ts +263 -56
  31. package/src/remote/index.ts +25 -1
  32. package/src/remote/server.ts +652 -0
  33. package/src/remote/session.ts +256 -0
  34. package/src/remote/types.ts +68 -1
  35. package/src/result.ts +260 -0
  36. package/src/retry.ts +306 -0
  37. package/src/state.ts +103 -45
  38. package/wasm-browser/README.md +4 -0
  39. package/wasm-browser/hypen_engine_bg.wasm +0 -0
  40. package/wasm-browser/package.json +1 -1
  41. package/wasm-node/README.md +4 -0
  42. package/wasm-node/hypen_engine_bg.wasm +0 -0
  43. package/wasm-node/package.json +1 -1
  44. package/wasm-browser/hypen_engine_bg.js +0 -736
  45. package/wasm-node/hypen_engine_bg.js +0 -736
package/src/app.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import type { Action } from "./engine.js";
7
+ import type { Session } from "./remote/types.js";
8
+ import { type Result, Ok, Err, fromPromise, ActionError, HypenError } from "./result.js";
7
9
 
8
10
  // Interface for engine compatibility (works with both engine.js and engine.browser.js)
9
11
  export interface IEngine {
@@ -15,6 +17,9 @@ export interface IEngine {
15
17
  import { createObservableState, type StateChange, getStateSnapshot } from "./state.js";
16
18
  import type { HypenRouter } from "./router.js";
17
19
  import type { HypenGlobalContext, ModuleReference } from "./context.js";
20
+ import { createLogger } from "./logger.js";
21
+
22
+ const log = createLogger("ModuleInstance");
18
23
 
19
24
  export type RouterContext = {
20
25
  root: HypenRouter;
@@ -62,6 +67,77 @@ export interface ActionHandlerContext<T> {
62
67
  */
63
68
  export type ActionHandler<T> = (ctx: ActionHandlerContext<T>) => void | Promise<void>;
64
69
 
70
+ /**
71
+ * Context passed to onDisconnect handler
72
+ */
73
+ export interface DisconnectContext<T> {
74
+ /** Current state snapshot (ready to serialize and save) */
75
+ state: T;
76
+ /** Session information */
77
+ session: Session;
78
+ }
79
+
80
+ /**
81
+ * Context passed to onReconnect handler
82
+ */
83
+ export interface ReconnectContext<T> {
84
+ /** Session information */
85
+ session: Session;
86
+ /** Call this with saved state to restore it */
87
+ restore: (savedState: T) => void;
88
+ }
89
+
90
+ /**
91
+ * Context passed to onExpire handler
92
+ */
93
+ export interface ExpireContext {
94
+ /** Session information */
95
+ session: Session;
96
+ }
97
+
98
+ /**
99
+ * Handler called when client disconnects (session still alive for TTL)
100
+ */
101
+ export type DisconnectHandler<T> = (ctx: DisconnectContext<T>) => void | Promise<void>;
102
+
103
+ /**
104
+ * Handler called when client reconnects with existing session
105
+ */
106
+ export type ReconnectHandler<T> = (ctx: ReconnectContext<T>) => void | Promise<void>;
107
+
108
+ /**
109
+ * Handler called when session TTL expires (client never reconnected)
110
+ */
111
+ export type ExpireHandler = (ctx: ExpireContext) => void | Promise<void>;
112
+
113
+ /**
114
+ * Context passed to onError handler
115
+ */
116
+ export interface ErrorContext<T> {
117
+ /** The error that occurred */
118
+ error: HypenError;
119
+ /** Current state (for inspection, not mutation during error handling) */
120
+ state: T;
121
+ /** The action name if error occurred in an action handler */
122
+ actionName?: string;
123
+ /** The lifecycle phase if error occurred in a lifecycle handler */
124
+ lifecycle?: "created" | "destroyed" | "disconnect" | "reconnect" | "expire";
125
+ }
126
+
127
+ /**
128
+ * Error handler return type - controls error propagation
129
+ */
130
+ export type ErrorHandlerResult =
131
+ | void // Continue with default behavior (log + emit)
132
+ | { handled: true } // Error was handled, skip default behavior
133
+ | { retry: true } // Retry the operation (only for actions)
134
+ | { rethrow: true }; // Re-throw the error
135
+
136
+ /**
137
+ * Handler called when an error occurs in the module
138
+ */
139
+ export type ErrorHandler<T> = (ctx: ErrorContext<T>) => ErrorHandlerResult | Promise<ErrorHandlerResult>;
140
+
65
141
  export interface HypenModuleDefinition<T = unknown> {
66
142
  name?: string;
67
143
  actions: string[];
@@ -69,10 +145,23 @@ export interface HypenModuleDefinition<T = unknown> {
69
145
  persist?: boolean;
70
146
  version?: number;
71
147
  initialState: T;
148
+ /**
149
+ * Inline UI template for single-file components.
150
+ * Set via the `.ui(hypen`...`)` method.
151
+ */
152
+ template?: string;
72
153
  handlers: {
73
154
  onCreated?: LifecycleHandler<T>;
74
155
  onAction: Map<string, ActionHandler<T>>;
75
156
  onDestroyed?: LifecycleHandler<T>;
157
+ /** Called when client disconnects (session persists for TTL) */
158
+ onDisconnect?: DisconnectHandler<T>;
159
+ /** Called when client reconnects with existing session */
160
+ onReconnect?: ReconnectHandler<T>;
161
+ /** Called when session TTL expires */
162
+ onExpire?: ExpireHandler;
163
+ /** Called when any error occurs in the module */
164
+ onError?: ErrorHandler<T>;
76
165
  };
77
166
  }
78
167
 
@@ -90,6 +179,11 @@ export class HypenAppBuilder<T> {
90
179
  private createdHandler?: LifecycleHandler<T>;
91
180
  private actionHandlers: Map<string, ActionHandler<T>> = new Map();
92
181
  private destroyedHandler?: LifecycleHandler<T>;
182
+ private disconnectHandler?: DisconnectHandler<T>;
183
+ private reconnectHandler?: ReconnectHandler<T>;
184
+ private expireHandler?: ExpireHandler;
185
+ private errorHandler?: ErrorHandler<T>;
186
+ private template?: string;
93
187
 
94
188
  constructor(
95
189
  initialState: T,
@@ -123,6 +217,99 @@ export class HypenAppBuilder<T> {
123
217
  return this;
124
218
  }
125
219
 
220
+ /**
221
+ * Register a handler for client disconnection
222
+ * Called when client disconnects; session persists for TTL
223
+ * Use this to save state for later restoration
224
+ */
225
+ onDisconnect(fn: DisconnectHandler<T>): this {
226
+ this.disconnectHandler = fn;
227
+ return this;
228
+ }
229
+
230
+ /**
231
+ * Register a handler for session reconnection
232
+ * Called when client reconnects with an existing session ID
233
+ * Use restore() to hydrate state from saved data
234
+ */
235
+ onReconnect(fn: ReconnectHandler<T>): this {
236
+ this.reconnectHandler = fn;
237
+ return this;
238
+ }
239
+
240
+ /**
241
+ * Register a handler for session expiration
242
+ * Called when TTL expires and client never reconnected
243
+ * Use this to clean up stored state
244
+ */
245
+ onExpire(fn: ExpireHandler): this {
246
+ this.expireHandler = fn;
247
+ return this;
248
+ }
249
+
250
+ /**
251
+ * Register an error handler for the module
252
+ * Called when any error occurs in action handlers or lifecycle hooks
253
+ *
254
+ * @example
255
+ * ```typescript
256
+ * app
257
+ * .defineState({ count: 0 })
258
+ * .onAction("increment", ({ state }) => {
259
+ * if (state.count > 100) throw new Error("Count too high");
260
+ * state.count += 1;
261
+ * })
262
+ * .onError(({ error, actionName, state }) => {
263
+ * console.error(`Error in ${actionName}:`, error.message);
264
+ * // Optionally recover
265
+ * if (error.message.includes("too high")) {
266
+ * return { handled: true }; // Don't propagate
267
+ * }
268
+ * // Or retry
269
+ * // return { retry: true };
270
+ * })
271
+ * .build();
272
+ * ```
273
+ *
274
+ * @param fn - Error handler function
275
+ * @returns Builder for chaining
276
+ */
277
+ onError(fn: ErrorHandler<T>): this {
278
+ this.errorHandler = fn;
279
+ return this;
280
+ }
281
+
282
+ /**
283
+ * Define the component's UI template inline (single-file component).
284
+ *
285
+ * Use with the `hypen` tagged template literal and binding proxies:
286
+ *
287
+ * @example
288
+ * ```typescript
289
+ * import { app, hypen, state } from "@hypen-space/core";
290
+ *
291
+ * export default app
292
+ * .defineState({ count: 0 })
293
+ * .onAction("increment", ({ state }) => {
294
+ * state.count += 1;
295
+ * })
296
+ * .ui(hypen`
297
+ * Column {
298
+ * Text("Count: ${state.count}")
299
+ * Button { Text("+") }
300
+ * .onClick("@actions.increment")
301
+ * }
302
+ * `);
303
+ * ```
304
+ *
305
+ * @param template - The Hypen DSL template string
306
+ * @returns The built module definition (calls build() internally)
307
+ */
308
+ ui(template: string): HypenModuleDefinition<T> {
309
+ this.template = template;
310
+ return this.build();
311
+ }
312
+
126
313
  /**
127
314
  * Build the module definition
128
315
  */
@@ -139,10 +326,15 @@ export class HypenAppBuilder<T> {
139
326
  persist: this.options.persist,
140
327
  version: this.options.version,
141
328
  initialState: this.initialState,
329
+ template: this.template,
142
330
  handlers: {
143
331
  onCreated: this.createdHandler,
144
332
  onAction: this.actionHandlers,
145
333
  onDestroyed: this.destroyedHandler,
334
+ onDisconnect: this.disconnectHandler,
335
+ onReconnect: this.reconnectHandler,
336
+ onExpire: this.expireHandler,
337
+ onError: this.errorHandler,
146
338
  },
147
339
  };
148
340
  }
@@ -211,9 +403,9 @@ export class HypenModuleInstance<T extends object = any> {
211
403
 
212
404
  // Register action handlers with flexible parameter support
213
405
  for (const [actionName, handler] of definition.handlers.onAction) {
214
- console.log(`[ModuleInstance] Registering action handler: ${actionName} for module ${definition.name}`);
406
+ log.debug(`Registering action handler: ${actionName} for module ${definition.name}`);
215
407
  this.engine.onAction(actionName, async (action: Action) => {
216
- console.log(`[ModuleInstance] Action handler fired: ${actionName}`, action);
408
+ log.debug(`Action handler fired: ${actionName}`, action);
217
409
 
218
410
  const actionCtx: ActionContext = {
219
411
  name: action.name,
@@ -229,17 +421,21 @@ export class HypenModuleInstance<T extends object = any> {
229
421
  ? this.createGlobalContextAPI()
230
422
  : undefined;
231
423
 
232
- try {
233
- await handler({
234
- action: actionCtx,
235
- state: this.state,
236
- next,
237
- context: context!,
238
- });
239
- console.log(`[ModuleInstance] Action handler completed: ${actionName}`);
240
- } catch (error) {
241
- console.error(`[ModuleInstance] Action handler error for ${actionName}:`, error);
242
- throw error; // Re-throw to allow callers to handle the error
424
+ // Use Result type for error handling
425
+ const result = await this.executeAction(actionName, handler, {
426
+ action: actionCtx,
427
+ state: this.state,
428
+ next,
429
+ context: context!,
430
+ });
431
+
432
+ if (!result.ok) {
433
+ const shouldRethrow = await this.handleError(result.error, { actionName });
434
+ if (shouldRethrow) {
435
+ throw result.error;
436
+ }
437
+ } else {
438
+ log.debug(`Action handler completed: ${actionName}`);
243
439
  }
244
440
  });
245
441
  }
@@ -278,6 +474,89 @@ export class HypenModuleInstance<T extends object = any> {
278
474
  return api;
279
475
  }
280
476
 
477
+ /**
478
+ * Execute an action handler with Result-based error handling
479
+ * Handles both synchronous throws and async rejections
480
+ */
481
+ private async executeAction(
482
+ actionName: string,
483
+ handler: ActionHandler<T>,
484
+ ctx: ActionHandlerContext<T>
485
+ ): Promise<Result<void, ActionError>> {
486
+ try {
487
+ // Wrap in try-catch to handle synchronous throws
488
+ const result = handler(ctx);
489
+ // Await in case handler returns a promise
490
+ await result;
491
+ return Ok(undefined);
492
+ } catch (e) {
493
+ return Err(new ActionError(actionName, e));
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Handle an error with module-level error handler support
499
+ * Falls back to default behavior (emit + log) if no handler or handler doesn't suppress
500
+ * @returns true if the error should be rethrown
501
+ */
502
+ private async handleError(
503
+ error: HypenError,
504
+ context: { actionName?: string; lifecycle?: ErrorContext<T>["lifecycle"] }
505
+ ): Promise<boolean> {
506
+ const errorCtx: ErrorContext<T> = {
507
+ error,
508
+ state: this.state,
509
+ actionName: context.actionName,
510
+ lifecycle: context.lifecycle,
511
+ };
512
+
513
+ // Call module-level error handler if defined
514
+ if (this.definition.handlers.onError) {
515
+ try {
516
+ const result = await this.definition.handlers.onError(errorCtx);
517
+
518
+ // Check if error was handled
519
+ if (result && typeof result === "object") {
520
+ if ("handled" in result && result.handled) {
521
+ // Error was handled, skip default behavior
522
+ return false;
523
+ }
524
+ if ("rethrow" in result && result.rethrow) {
525
+ // Signal caller to rethrow
526
+ return true;
527
+ }
528
+ // Note: 'retry' would need to be handled at the action execution level
529
+ // For now, we just continue with default behavior
530
+ }
531
+ } catch (handlerError) {
532
+ // Error in error handler - log and continue with default behavior
533
+ log.error("Error in onError handler:", handlerError);
534
+ }
535
+ }
536
+
537
+ // Default behavior: emit to global context and log
538
+ if (this.globalContext) {
539
+ const eventContext = context.actionName
540
+ ? `action:${context.actionName}`
541
+ : context.lifecycle
542
+ ? `lifecycle:${context.lifecycle}`
543
+ : "unknown";
544
+
545
+ this.globalContext.emit("error", {
546
+ message: error.message,
547
+ error,
548
+ context: eventContext,
549
+ });
550
+ }
551
+
552
+ log.error(
553
+ `${context.actionName ? `Action "${context.actionName}"` : `Lifecycle "${context.lifecycle}"`} error:`,
554
+ error
555
+ );
556
+
557
+ return false;
558
+ }
559
+
281
560
  /**
282
561
  * Call the onCreated handler
283
562
  */
package/src/discovery.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * Automatically discovers Hypen components from the filesystem.
5
5
  *
6
6
  * Supports multiple conventions:
7
- * 1. Folder-based: ComponentName/component.ts + ComponentName/component.hypen
8
- * 2. Sibling files: ComponentName.ts + ComponentName.hypen
9
- * 3. Index-based: ComponentName/index.ts + ComponentName/index.hypen
7
+ * 1. Single-file: ComponentName.ts with .ui(hypen`...`) inline template
8
+ * 2. Folder-based: ComponentName/component.ts + ComponentName/component.hypen
9
+ * 3. Sibling files: ComponentName.ts + ComponentName.hypen
10
+ * 4. Index-based: ComponentName/index.ts + ComponentName/index.hypen
10
11
  */
11
12
 
12
13
  import { existsSync, readdirSync, readFileSync, watch } from "fs";
@@ -15,18 +16,29 @@ import type { HypenModuleDefinition } from "./app.js";
15
16
 
16
17
  export interface DiscoveredComponent {
17
18
  name: string;
18
- hypenPath: string;
19
+ /** Path to .hypen file (null for single-file components with inline templates) */
20
+ hypenPath: string | null;
21
+ /** Path to .ts module file */
19
22
  modulePath: string | null;
23
+ /** The UI template (from .hypen file or inline) */
20
24
  template: string;
25
+ /** Whether this component has a module (state/actions) */
21
26
  hasModule: boolean;
27
+ /** Whether this is a single-file component with inline template */
28
+ isSingleFile: boolean;
22
29
  }
23
30
 
24
31
  export interface DiscoveryOptions {
25
32
  /**
26
33
  * Which naming patterns to look for
27
- * Default: ["folder", "sibling", "index"]
34
+ * Default: ["single-file", "folder", "sibling", "index"]
35
+ *
36
+ * - single-file: ComponentName.ts with .ui(hypen`...`) inline template
37
+ * - folder: ComponentName/component.ts + ComponentName/component.hypen
38
+ * - sibling: ComponentName.ts + ComponentName.hypen
39
+ * - index: ComponentName/index.ts + ComponentName/index.hypen
28
40
  */
29
- patterns?: ("folder" | "sibling" | "index")[];
41
+ patterns?: ("single-file" | "folder" | "sibling" | "index")[];
30
42
 
31
43
  /**
32
44
  * Recursively scan subdirectories
@@ -81,7 +93,7 @@ export async function discoverComponents(
81
93
  options: DiscoveryOptions = {}
82
94
  ): Promise<DiscoveredComponent[]> {
83
95
  const {
84
- patterns = ["folder", "sibling", "index"],
96
+ patterns = ["single-file", "folder", "sibling", "index"],
85
97
  recursive = false,
86
98
  debug = false,
87
99
  } = options;
@@ -97,8 +109,8 @@ export async function discoverComponents(
97
109
  log("Scanning directory:", resolvedDir);
98
110
  log("Patterns:", patterns);
99
111
 
100
- // Helper to add a component if not already seen
101
- const addComponent = (
112
+ // Helper to add a two-file component (.hypen + .ts)
113
+ const addTwoFileComponent = (
102
114
  name: string,
103
115
  hypenPath: string,
104
116
  modulePath: string | null
@@ -118,9 +130,34 @@ export async function discoverComponents(
118
130
  modulePath,
119
131
  template,
120
132
  hasModule: modulePath !== null,
133
+ isSingleFile: false,
121
134
  });
122
135
 
123
- log(`Found: ${name} (${modulePath ? "with module" : "stateless"})`);
136
+ log(`Found: ${name} (two-file, ${modulePath ? "with module" : "stateless"})`);
137
+ };
138
+
139
+ // Helper to add a single-file component (.ts with inline template)
140
+ const addSingleFileComponent = (
141
+ name: string,
142
+ modulePath: string,
143
+ template: string
144
+ ) => {
145
+ if (seen.has(name)) {
146
+ log(`Skipping duplicate: ${name}`);
147
+ return;
148
+ }
149
+
150
+ seen.add(name);
151
+ components.push({
152
+ name,
153
+ hypenPath: null,
154
+ modulePath,
155
+ template,
156
+ hasModule: true,
157
+ isSingleFile: true,
158
+ });
159
+
160
+ log(`Found: ${name} (single-file with inline template)`);
124
161
  };
125
162
 
126
163
  // Scan directory for folder-based components
@@ -140,7 +177,7 @@ export async function discoverComponents(
140
177
  const hypenPath = join(folderPath, "component.hypen");
141
178
  if (existsSync(hypenPath)) {
142
179
  const modulePath = join(folderPath, "component.ts");
143
- addComponent(
180
+ addTwoFileComponent(
144
181
  componentName,
145
182
  hypenPath,
146
183
  existsSync(modulePath) ? modulePath : null
@@ -154,7 +191,7 @@ export async function discoverComponents(
154
191
  const hypenPath = join(folderPath, "index.hypen");
155
192
  if (existsSync(hypenPath)) {
156
193
  const modulePath = join(folderPath, "index.ts");
157
- addComponent(
194
+ addTwoFileComponent(
158
195
  componentName,
159
196
  hypenPath,
160
197
  existsSync(modulePath) ? modulePath : null
@@ -170,7 +207,7 @@ export async function discoverComponents(
170
207
  }
171
208
  };
172
209
 
173
- // Scan for sibling file components
210
+ // Scan for sibling file components (.ts + .hypen pairs)
174
211
  const scanForSiblingComponents = (dir: string) => {
175
212
  if (!existsSync(dir)) return;
176
213
 
@@ -193,7 +230,7 @@ export async function discoverComponents(
193
230
  if (baseName === "component" || baseName === "index") continue;
194
231
 
195
232
  const modulePath = join(dir, `${baseName}.ts`);
196
- addComponent(
233
+ addTwoFileComponent(
197
234
  baseName,
198
235
  hypenPath,
199
236
  existsSync(modulePath) ? modulePath : null
@@ -201,6 +238,56 @@ export async function discoverComponents(
201
238
  }
202
239
  };
203
240
 
241
+ // Scan for single-file components (.ts files with inline templates)
242
+ const scanForSingleFileComponents = async (dir: string) => {
243
+ if (!existsSync(dir)) return;
244
+
245
+ const entries = readdirSync(dir, { withFileTypes: true });
246
+
247
+ for (const entry of entries) {
248
+ if (entry.isDirectory()) {
249
+ if (recursive) {
250
+ await scanForSingleFileComponents(join(dir, entry.name));
251
+ }
252
+ continue;
253
+ }
254
+
255
+ // Only look at .ts files
256
+ if (!entry.name.endsWith(".ts")) continue;
257
+
258
+ // Skip files that are clearly not components
259
+ if (entry.name.startsWith('.') || entry.name.includes('.test.') || entry.name.includes('.spec.')) continue;
260
+
261
+ // Skip component.ts and index.ts (handled by folder patterns)
262
+ const baseName = basename(entry.name, ".ts");
263
+ if (baseName === "component" || baseName === "index") continue;
264
+
265
+ // Skip if there's a matching .hypen file (handled by sibling pattern)
266
+ const hypenPath = join(dir, `${baseName}.hypen`);
267
+ if (existsSync(hypenPath)) continue;
268
+
269
+ // Check if this looks like a single-file component by looking for .ui( pattern
270
+ const modulePath = join(dir, entry.name);
271
+ const content = readFileSync(modulePath, "utf-8");
272
+
273
+ // Quick heuristic: look for .ui( or .ui(hypen pattern
274
+ if (content.includes(".ui(") || content.includes(".ui(hypen")) {
275
+ // Try to import and check for template
276
+ try {
277
+ const moduleExport = await import(modulePath);
278
+ const module = moduleExport.default as HypenModuleDefinition<any>;
279
+
280
+ if (module && typeof module === "object" && module.template) {
281
+ addSingleFileComponent(baseName, modulePath, module.template);
282
+ }
283
+ } catch (e) {
284
+ // Failed to import, skip this file
285
+ log(`Failed to import potential single-file component: ${entry.name}`, e);
286
+ }
287
+ }
288
+ }
289
+ };
290
+
204
291
  // Run scans based on patterns
205
292
  if (patterns.includes("folder") || patterns.includes("index")) {
206
293
  scanForFolderComponents(resolvedDir);
@@ -210,6 +297,10 @@ export async function discoverComponents(
210
297
  scanForSiblingComponents(resolvedDir);
211
298
  }
212
299
 
300
+ if (patterns.includes("single-file")) {
301
+ await scanForSingleFileComponents(resolvedDir);
302
+ }
303
+
213
304
  log(`Discovered ${components.length} components`);
214
305
 
215
306
  return components;
@@ -244,11 +335,17 @@ export async function loadDiscoveredComponents(
244
335
 
245
336
  for (const component of components) {
246
337
  let module: HypenModuleDefinition<any>;
338
+ let template = component.template;
247
339
 
248
340
  if (component.modulePath) {
249
341
  // Import the TypeScript module
250
342
  const moduleExport = await import(component.modulePath);
251
343
  module = moduleExport.default as HypenModuleDefinition<any>;
344
+
345
+ // For single-file components, template is already in the module
346
+ if (component.isSingleFile && module.template) {
347
+ template = module.template;
348
+ }
252
349
  } else {
253
350
  // Create a stateless module
254
351
  module = app.defineState({}).build();
@@ -257,7 +354,7 @@ export async function loadDiscoveredComponents(
257
354
  loaded.set(component.name, {
258
355
  name: component.name,
259
356
  module,
260
- template: component.template,
357
+ template,
261
358
  });
262
359
  }
263
360
 
@@ -396,14 +493,22 @@ export async function generateComponentsCode(
396
493
  code += `\nimport { app } from "@hypen-space/core";\n\n`;
397
494
 
398
495
  for (const component of components) {
399
- const templateJson = JSON.stringify(component.template);
400
-
401
- if (component.hasModule) {
496
+ if (component.isSingleFile) {
497
+ // Single-file component: template is in the module itself
498
+ code += `export const ${component.name} = {
499
+ module: ${component.name}Module,
500
+ template: ${component.name}Module.template,
501
+ };\n\n`;
502
+ } else if (component.hasModule) {
503
+ // Two-file component with module
504
+ const templateJson = JSON.stringify(component.template);
402
505
  code += `export const ${component.name} = {
403
506
  module: ${component.name}Module,
404
507
  template: ${templateJson},
405
508
  };\n\n`;
406
509
  } else {
510
+ // Stateless two-file component
511
+ const templateJson = JSON.stringify(component.template);
407
512
  code += `const ${component.name}Module = app.defineState({}).build();
408
513
  export const ${component.name} = {
409
514
  module: ${component.name}Module,