@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.
- package/README.md +182 -11
- package/dist/src/app.js +470 -44
- package/dist/src/app.js.map +7 -5
- package/dist/src/components/builtin.js +470 -44
- package/dist/src/components/builtin.js.map +7 -5
- package/dist/src/discovery.js +559 -65
- package/dist/src/discovery.js.map +8 -6
- package/dist/src/engine.js +18 -9
- package/dist/src/engine.js.map +3 -3
- package/dist/src/index.browser.js +862 -81
- package/dist/src/index.browser.js.map +10 -6
- package/dist/src/index.js +1590 -124
- package/dist/src/index.js.map +16 -9
- package/dist/src/remote/client.js +525 -35
- package/dist/src/remote/client.js.map +7 -4
- package/dist/src/remote/index.js +1796 -35
- package/dist/src/remote/index.js.map +13 -4
- package/dist/src/router.js +55 -29
- package/dist/src/router.js.map +3 -3
- package/dist/src/state.js +57 -29
- package/dist/src/state.js.map +3 -3
- package/package.json +8 -2
- package/src/app.ts +292 -13
- package/src/discovery.ts +123 -18
- package/src/disposable.ts +281 -0
- package/src/engine.ts +29 -10
- package/src/hypen.ts +209 -0
- package/src/index.ts +147 -11
- package/src/logger.ts +338 -0
- package/src/remote/client.ts +263 -56
- package/src/remote/index.ts +25 -1
- package/src/remote/server.ts +652 -0
- package/src/remote/session.ts +256 -0
- package/src/remote/types.ts +68 -1
- package/src/result.ts +260 -0
- package/src/retry.ts +306 -0
- package/src/state.ts +103 -45
- package/wasm-browser/README.md +4 -0
- package/wasm-browser/hypen_engine_bg.wasm +0 -0
- package/wasm-browser/package.json +1 -1
- package/wasm-node/README.md +4 -0
- package/wasm-node/hypen_engine_bg.wasm +0 -0
- package/wasm-node/package.json +1 -1
- package/wasm-browser/hypen_engine_bg.js +0 -736
- 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
|
-
|
|
406
|
+
log.debug(`Registering action handler: ${actionName} for module ${definition.name}`);
|
|
215
407
|
this.engine.onAction(actionName, async (action: Action) => {
|
|
216
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
8
|
-
* 2.
|
|
9
|
-
* 3.
|
|
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
|
-
|
|
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
|
|
101
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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,
|