@hypen-space/core 0.4.46 → 0.4.81

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Enables single-file components with inline UI templates.
5
5
  *
6
- * The `hypen` tagged template preserves ${state.x} and ${item.x} bindings
6
+ * The `hypen` tagged template preserves @{state.x} and @{item.x} bindings
7
7
  * instead of interpolating them, allowing you to write:
8
8
  *
9
9
  * @example
@@ -17,7 +17,7 @@
17
17
  * })
18
18
  * .ui(hypen`
19
19
  * Column {
20
- * Text("Count: ${state.count}")
20
+ * Text("Count: @{state.count}")
21
21
  * Button { Text("+") }
22
22
  * .onClick("@actions.increment")
23
23
  * }
@@ -29,12 +29,13 @@
29
29
  * Creates a proxy that captures property access as a binding path string.
30
30
  *
31
31
  * When used in a template literal, the proxy's toString() returns the
32
- * full binding expression (e.g., "${state.user.name}").
32
+ * full binding expression (e.g., "@{state.user.name}"). This lets you
33
+ * interpolate JS variables into hypen templates without losing type safety.
33
34
  *
34
35
  * @example
35
36
  * ```typescript
36
37
  * const state = createBindingProxy('state');
37
- * `${state.user.name}` // Returns: "${state.user.name}"
38
+ * String(state.user.name) // Returns: "@{state.user.name}"
38
39
  * ```
39
40
  */
40
41
  function createBindingProxy(root: string): any {
@@ -46,7 +47,7 @@ function createBindingProxy(root: string): any {
46
47
  prop === "toString" ||
47
48
  prop === "valueOf"
48
49
  ) {
49
- return () => `\${${root}}`;
50
+ return () => `@{${root}}`;
50
51
  }
51
52
 
52
53
  // Handle other Symbol properties (e.g., Symbol.toStringTag)
@@ -56,7 +57,7 @@ function createBindingProxy(root: string): any {
56
57
 
57
58
  // Handle JSON.stringify
58
59
  if (prop === "toJSON") {
59
- return () => `\${${root}}`;
60
+ return () => `@{${root}}`;
60
61
  }
61
62
 
62
63
  // Chain to nested property: state.user -> state.user.name
@@ -87,12 +88,18 @@ function createBindingProxy(root: string): any {
87
88
  /**
88
89
  * Proxy for state bindings.
89
90
  *
90
- * Use in hypen templates to create reactive state bindings:
91
+ * In hypen templates, prefer the direct `@{state.x}` syntax. This proxy
92
+ * exists for cases where you want to interpolate JS values into templates
93
+ * — e.g., `hypen\`Text("${state.user.name}")\`` produces the same string
94
+ * as `hypen\`Text("@{state.user.name}")\``.
91
95
  *
92
96
  * @example
93
97
  * ```typescript
98
+ * // Preferred: direct syntax
99
+ * hypen`Text("Hello, @{state.user.name}")`
100
+ *
101
+ * // Also works: proxy interpolation
94
102
  * hypen`Text("Hello, ${state.user.name}")`
95
- * // Produces: Text("Hello, ${state.user.name}")
96
103
  * ```
97
104
  */
98
105
  export const state: any = createBindingProxy("state");
@@ -106,7 +113,7 @@ export const state: any = createBindingProxy("state");
106
113
  * ```typescript
107
114
  * hypen`
108
115
  * List(@state.items) {
109
- * Text("${item.name}: ${item.price}")
116
+ * Text("@{item.name}: @{item.price}")
110
117
  * }
111
118
  * `
112
119
  * ```
@@ -122,51 +129,50 @@ export const item: any = createBindingProxy("item");
122
129
  * ```typescript
123
130
  * hypen`
124
131
  * List(@state.items) {
125
- * Text("Item #${index}: ${item.name}")
132
+ * Text("Item #@{index}: @{item.name}")
126
133
  * }
127
134
  * `
128
135
  * ```
129
136
  */
130
137
  export const index: any = {
131
- [Symbol.toPrimitive]: () => "${index}",
132
- toString: () => "${index}",
133
- valueOf: () => "${index}",
134
- toJSON: () => "${index}",
138
+ [Symbol.toPrimitive]: () => "@{index}",
139
+ toString: () => "@{index}",
140
+ valueOf: () => "@{index}",
141
+ toJSON: () => "@{index}",
135
142
  };
136
143
 
137
144
  /**
138
145
  * Tagged template literal for Hypen DSL templates.
139
146
  *
140
- * Works with the `state`, `item`, and `index` binding proxies to preserve
141
- * binding syntax in the output string.
147
+ * Prefer the direct `@{state.x}` / `@{item.x}` syntax. The `state`, `item`,
148
+ * and `index` proxies are available for interpolating JS variables or
149
+ * mixing dynamic content with bindings.
142
150
  *
143
151
  * @example
144
152
  * ```typescript
145
153
  * import { hypen, state, item } from "@hypen-space/core";
146
154
  *
147
- * // Simple state binding
148
- * const t1 = hypen`Text("Count: ${state.count}")`;
149
- * // Result: 'Text("Count: ${state.count}")'
155
+ * // Preferred: direct binding syntax
156
+ * const t1 = hypen`Text("Count: @{state.count}")`;
150
157
  *
151
- * // Nested state binding
152
- * const t2 = hypen`Text("Hello, ${state.user.profile.name}")`;
153
- * // Result: 'Text("Hello, ${state.user.profile.name}")'
158
+ * // Nested paths work the same way
159
+ * const t2 = hypen`Text("Hello, @{state.user.profile.name}")`;
154
160
  *
155
161
  * // List with item binding
156
162
  * const t3 = hypen`
157
163
  * List(@state.products) {
158
- * Text("${item.name} - $${item.price}")
164
+ * Text("@{item.name} - $@{item.price}")
159
165
  * }
160
166
  * `;
161
167
  *
162
- * // Complex expressions (use regular JS interpolation for static values)
168
+ * // Interpolate JS variables alongside bindings
163
169
  * const title = "My App";
164
- * const t4 = hypen`Text("${title}: ${state.count}")`;
165
- * // Result: 'Text("My App: ${state.count}")'
170
+ * const t4 = hypen`Text("${title}: @{state.count}")`;
171
+ * // Result: 'Text("My App: @{state.count}")'
166
172
  * ```
167
173
  *
168
174
  * @param strings - Template literal string parts
169
- * @param expressions - Interpolated expressions (proxies return binding strings)
175
+ * @param expressions - Interpolated JS values or binding proxies
170
176
  * @returns The template string with bindings preserved
171
177
  */
172
178
  export function hypen(
@@ -179,7 +185,7 @@ export function hypen(
179
185
  const expr = expressions[i];
180
186
 
181
187
  // Convert expression to string
182
- // Binding proxies will return "${state.x}" via their toString()
188
+ // Binding proxies will return "@{state.x}" via their toString()
183
189
  result += String(expr);
184
190
  result += strings[i + 1]!;
185
191
  }
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * })
17
17
  * .ui(hypen`
18
18
  * Column {
19
- * Text("Count: ${state.count}")
19
+ * Text("Count: @{state.count}")
20
20
  * Button { Text("+") }
21
21
  * .onClick("@actions.increment")
22
22
  * }
@@ -37,7 +37,7 @@
37
37
  * .build();
38
38
  *
39
39
  * // component.hypen (separate file)
40
- * // Column { Text("Count: ${state.count}") ... }
40
+ * // Column { Text("Count: @{state.count}") ... }
41
41
  * ```
42
42
  */
43
43
 
@@ -95,6 +95,12 @@ export type {
95
95
  DataSourceAccessor,
96
96
  } from "./app.js";
97
97
 
98
+ // ============================================================================
99
+ // STATE PERSISTENCE
100
+ // ============================================================================
101
+
102
+ export type { StateStore } from "./persistence.js";
103
+
98
104
  // ============================================================================
99
105
  // DATA SOURCE PLUGIN SYSTEM
100
106
  // ============================================================================
@@ -0,0 +1,25 @@
1
+ /**
2
+ * State Persistence Layer
3
+ *
4
+ * Pluggable persistence interface for Hypen modules.
5
+ * Modules opt in with `.persist(adapter)` on the builder.
6
+ * The runtime handles save/restore lifecycle transparently.
7
+ */
8
+
9
+ export interface StateStore<T = unknown> {
10
+ /**
11
+ * Resolve the storage key for this module instance.
12
+ * Called on init and whenever state changes (for withKey).
13
+ * Returns null = no persistence active.
14
+ */
15
+ resolveKey(state: T, moduleName: string, sessionId: string): string | null;
16
+
17
+ /** Load persisted state. Called when key first resolves to non-null. */
18
+ load(key: string): Promise<T | null>;
19
+
20
+ /** Save state. Called on mutation (debounced by runtime). */
21
+ save(key: string, state: T): Promise<void>;
22
+
23
+ /** Delete persisted state. Called on session expire. */
24
+ delete(key: string): Promise<void>;
25
+ }
@@ -71,6 +71,8 @@ export interface SessionOptions {
71
71
  id?: string;
72
72
  /** Client metadata (platform, version, userId, etc.) */
73
73
  props?: Record<string, unknown>;
74
+ /** Optional persist/routing key for Durable Object routing (withKey) */
75
+ persistKey?: string;
74
76
  }
75
77
 
76
78
  /**
@@ -270,6 +272,7 @@ export class RemoteEngine implements Disposable {
270
272
  type: "hello",
271
273
  sessionId: this.currentSessionId ?? this.sessionOptions?.id,
272
274
  props: this.sessionOptions?.props,
275
+ persistKey: this.sessionOptions?.persistKey,
273
276
  };
274
277
 
275
278
  this.ws.send(JSON.stringify(hello));
@@ -73,6 +73,8 @@ export interface HelloMessage {
73
73
  sessionId?: string;
74
74
  /** Client metadata (platform, version, userId, etc.) */
75
75
  props?: Record<string, any>;
76
+ /** Optional persist/routing key for Durable Object routing (withKey) */
77
+ persistKey?: string;
76
78
  }
77
79
 
78
80
  /**
package/src/router.ts CHANGED
@@ -184,12 +184,19 @@ export class HypenRouter {
184
184
  window.history.pushState(null, "", url);
185
185
  }
186
186
 
187
- // Manually trigger hashchange event
188
- const hashChangeEvent = new HashChangeEvent("hashchange", {
189
- oldURL: window.location.href.replace(window.location.hash, "#" + oldPath),
190
- newURL: window.location.href,
191
- });
192
- window.dispatchEvent(hashChangeEvent);
187
+ // Manually trigger hashchange event. Wrap in try-catch because
188
+ // in test environments the global HashChangeEvent class may come
189
+ // from a different realm than the window, causing dispatchEvent to
190
+ // reject it with "parameter 1 is not of type 'Event'".
191
+ try {
192
+ const hashChangeEvent = new HashChangeEvent("hashchange", {
193
+ oldURL: window.location.href.replace(window.location.hash, "#" + oldPath),
194
+ newURL: window.location.href,
195
+ });
196
+ window.dispatchEvent(hashChangeEvent);
197
+ } catch {
198
+ // Ignore cross-realm Event dispatch errors
199
+ }
193
200
  }
194
201
  } finally {
195
202
  this.isUpdating = false;
package/src/types.ts CHANGED
@@ -36,3 +36,4 @@ export type ComponentResolver = (
36
36
  componentName: string,
37
37
  contextPath: string | null
38
38
  ) => ResolvedComponent | null;
39
+