alepha 0.20.3 → 0.20.4

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 (217) hide show
  1. package/dist/api/audits/index.d.ts.map +1 -1
  2. package/dist/api/files/index.d.ts.map +1 -1
  3. package/dist/api/jobs/index.d.ts +14 -14
  4. package/dist/api/jobs/index.d.ts.map +1 -1
  5. package/dist/api/keys/index.d.ts +4 -4
  6. package/dist/api/organizations/index.d.ts.map +1 -1
  7. package/dist/api/parameters/index.d.ts +8 -3
  8. package/dist/api/parameters/index.d.ts.map +1 -1
  9. package/dist/api/parameters/index.js +20 -4
  10. package/dist/api/parameters/index.js.map +1 -1
  11. package/dist/api/payments/index.d.ts.map +1 -1
  12. package/dist/api/users/index.browser.js +6 -0
  13. package/dist/api/users/index.browser.js.map +1 -1
  14. package/dist/api/users/index.d.ts +5037 -139
  15. package/dist/api/users/index.d.ts.map +1 -1
  16. package/dist/api/users/index.js +58 -10
  17. package/dist/api/users/index.js.map +1 -1
  18. package/dist/bucket/index.d.ts +77 -107
  19. package/dist/bucket/index.d.ts.map +1 -1
  20. package/dist/bucket/index.js +148 -4
  21. package/dist/bucket/index.js.map +1 -1
  22. package/dist/bucket/index.workerd.js +7 -1
  23. package/dist/bucket/index.workerd.js.map +1 -1
  24. package/dist/cache/core/index.d.ts +26 -0
  25. package/dist/cache/core/index.d.ts.map +1 -1
  26. package/dist/cache/core/index.js +11 -1
  27. package/dist/cache/core/index.js.map +1 -1
  28. package/dist/cache/core/index.workerd.js +11 -1
  29. package/dist/cache/core/index.workerd.js.map +1 -1
  30. package/dist/cli/config/index.d.ts +7 -5
  31. package/dist/cli/config/index.d.ts.map +1 -1
  32. package/dist/cli/config/index.js +2 -3
  33. package/dist/cli/config/index.js.map +1 -1
  34. package/dist/cli/core/index.d.ts +420 -13
  35. package/dist/cli/core/index.d.ts.map +1 -1
  36. package/dist/cli/core/index.js +22 -511
  37. package/dist/cli/core/index.js.map +1 -1
  38. package/dist/cli/devtools/index.d.ts +4 -8
  39. package/dist/cli/devtools/index.d.ts.map +1 -1
  40. package/dist/cli/devtools/index.js +13 -15
  41. package/dist/cli/devtools/index.js.map +1 -1
  42. package/dist/cli/platform/index.d.ts +10 -13
  43. package/dist/cli/platform/index.d.ts.map +1 -1
  44. package/dist/cli/platform/index.js +18 -15
  45. package/dist/cli/platform/index.js.map +1 -1
  46. package/dist/cli/vendor/index.d.ts +10 -13
  47. package/dist/cli/vendor/index.d.ts.map +1 -1
  48. package/dist/cli/vendor/index.js +16 -13
  49. package/dist/cli/vendor/index.js.map +1 -1
  50. package/dist/core/index.browser.js +27 -3
  51. package/dist/core/index.browser.js.map +1 -1
  52. package/dist/core/index.d.ts +6 -3
  53. package/dist/core/index.d.ts.map +1 -1
  54. package/dist/core/index.js +27 -3
  55. package/dist/core/index.js.map +1 -1
  56. package/dist/core/index.native.js +27 -3
  57. package/dist/core/index.native.js.map +1 -1
  58. package/dist/core/index.workerd.js +27 -3
  59. package/dist/core/index.workerd.js.map +1 -1
  60. package/dist/datetime/index.d.ts +69 -10
  61. package/dist/datetime/index.d.ts.map +1 -1
  62. package/dist/datetime/index.js +135 -13
  63. package/dist/datetime/index.js.map +1 -1
  64. package/dist/email/smtp/index.js +10636 -2
  65. package/dist/email/smtp/index.js.map +1 -1
  66. package/dist/fake/index.d.ts +8085 -4
  67. package/dist/fake/index.d.ts.map +1 -1
  68. package/dist/fake/index.js +33554 -3
  69. package/dist/fake/index.js.map +1 -1
  70. package/dist/lock/core/index.d.ts +30 -2
  71. package/dist/lock/core/index.d.ts.map +1 -1
  72. package/dist/lock/core/index.js +35 -12
  73. package/dist/lock/core/index.js.map +1 -1
  74. package/dist/mcp/index.d.ts +238 -31
  75. package/dist/mcp/index.d.ts.map +1 -1
  76. package/dist/mcp/index.js +198 -71
  77. package/dist/mcp/index.js.map +1 -1
  78. package/dist/orm/core/index.browser.js +1 -1
  79. package/dist/orm/core/index.browser.js.map +1 -1
  80. package/dist/orm/core/index.bun.js +4 -3
  81. package/dist/orm/core/index.bun.js.map +1 -1
  82. package/dist/orm/core/index.d.ts +4877 -9
  83. package/dist/orm/core/index.d.ts.map +1 -1
  84. package/dist/orm/core/index.js +4 -3
  85. package/dist/orm/core/index.js.map +1 -1
  86. package/dist/orm/postgres/index.d.ts +608 -1
  87. package/dist/orm/postgres/index.d.ts.map +1 -1
  88. package/dist/react/core/index.d.ts +102 -1
  89. package/dist/react/core/index.d.ts.map +1 -1
  90. package/dist/react/core/index.js +65 -1
  91. package/dist/react/core/index.js.map +1 -1
  92. package/dist/react/form/index.d.ts +6 -0
  93. package/dist/react/form/index.d.ts.map +1 -1
  94. package/dist/react/form/index.js +7 -7
  95. package/dist/react/form/index.js.map +1 -1
  96. package/dist/react/i18n/index.d.ts +7 -1
  97. package/dist/react/i18n/index.d.ts.map +1 -1
  98. package/dist/react/i18n/index.js +6 -0
  99. package/dist/react/i18n/index.js.map +1 -1
  100. package/dist/react/router/index.browser.js +20 -2
  101. package/dist/react/router/index.browser.js.map +1 -1
  102. package/dist/react/router/index.d.ts +36 -4
  103. package/dist/react/router/index.d.ts.map +1 -1
  104. package/dist/react/router/index.js +20 -2
  105. package/dist/react/router/index.js.map +1 -1
  106. package/dist/react/testing/chunk-6Ep1yQYe.js +16 -0
  107. package/dist/react/testing/index.d.ts +411 -1
  108. package/dist/react/testing/index.d.ts.map +1 -1
  109. package/dist/react/testing/index.js +12293 -13
  110. package/dist/react/testing/index.js.map +1 -1
  111. package/dist/react/ui/index.d.ts +195 -1
  112. package/dist/react/ui/index.d.ts.map +1 -1
  113. package/dist/react/ui/index.js +61 -1
  114. package/dist/react/ui/index.js.map +1 -1
  115. package/dist/scheduler/index.d.ts +84 -3
  116. package/dist/scheduler/index.d.ts.map +1 -1
  117. package/dist/scheduler/index.js +390 -1
  118. package/dist/scheduler/index.js.map +1 -1
  119. package/dist/scheduler/index.workerd.js +390 -1
  120. package/dist/scheduler/index.workerd.js.map +1 -1
  121. package/dist/security/index.d.ts +325 -2
  122. package/dist/security/index.d.ts.map +1 -1
  123. package/dist/security/index.js +1361 -2
  124. package/dist/security/index.js.map +1 -1
  125. package/dist/server/auth/index.d.ts +1054 -1
  126. package/dist/server/auth/index.d.ts.map +1 -1
  127. package/dist/server/auth/index.js +1223 -1
  128. package/dist/server/auth/index.js.map +1 -1
  129. package/dist/server/core/index.browser.js +10 -3
  130. package/dist/server/core/index.browser.js.map +1 -1
  131. package/dist/server/core/index.d.ts.map +1 -1
  132. package/dist/server/core/index.js +28 -5
  133. package/dist/server/core/index.js.map +1 -1
  134. package/dist/server/metrics/index.d.ts +514 -1
  135. package/dist/server/metrics/index.d.ts.map +1 -1
  136. package/dist/server/metrics/index.js +4374 -4
  137. package/dist/server/metrics/index.js.map +1 -1
  138. package/dist/server/swagger/index.d.ts.map +1 -1
  139. package/dist/server/swagger/index.js +3 -4
  140. package/dist/server/swagger/index.js.map +1 -1
  141. package/dist/websocket/index.browser.js +11 -5
  142. package/dist/websocket/index.browser.js.map +1 -1
  143. package/dist/websocket/index.d.ts +3 -1
  144. package/dist/websocket/index.d.ts.map +1 -1
  145. package/dist/websocket/index.js +21 -6
  146. package/dist/websocket/index.js.map +1 -1
  147. package/package.json +671 -263
  148. package/src/api/parameters/services/ParameterProvider.ts +21 -4
  149. package/src/api/users/__tests__/SessionService.spec.ts +99 -0
  150. package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
  151. package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
  152. package/src/api/users/entities/sessions.ts +6 -0
  153. package/src/api/users/jobs/UserJobs.ts +44 -17
  154. package/src/api/users/providers/RealmProvider.ts +4 -0
  155. package/src/api/users/services/SessionService.ts +27 -0
  156. package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
  157. package/src/bucket/index.ts +19 -2
  158. package/src/bucket/primitives/$bucket.ts +9 -1
  159. package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
  160. package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
  161. package/src/cache/core/index.ts +29 -0
  162. package/src/cache/core/primitives/$cache.ts +14 -1
  163. package/src/cli/config/defineConfig.ts +13 -15
  164. package/src/cli/core/__tests__/init.spec.ts +6 -7
  165. package/src/cli/core/services/ProjectScaffolder.ts +18 -14
  166. package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
  167. package/src/cli/core/templates/agentMd.ts +2 -10
  168. package/src/cli/core/templates/saasAdminLayoutTsx.ts +3 -3
  169. package/src/cli/devtools/index.ts +12 -26
  170. package/src/cli/platform/index.ts +15 -24
  171. package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
  172. package/src/cli/vendor/index.ts +14 -23
  173. package/src/core/Alepha.ts +11 -1
  174. package/src/core/helpers/ref.ts +18 -0
  175. package/src/core/index.shared.ts +1 -0
  176. package/src/core/providers/SchemaValidator.ts +9 -1
  177. package/src/core/providers/TypeProvider.ts +1 -2
  178. package/src/datetime/REFACTORING.md +118 -0
  179. package/src/datetime/providers/DateTimeProvider.ts +203 -24
  180. package/src/lock/core/index.ts +31 -0
  181. package/src/lock/core/primitives/$lock.ts +14 -1
  182. package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
  183. package/src/mcp/helpers/jsonrpc.ts +26 -1
  184. package/src/mcp/index.ts +10 -5
  185. package/src/mcp/interfaces/McpTypes.ts +83 -6
  186. package/src/mcp/primitives/$prompt.ts +18 -1
  187. package/src/mcp/primitives/$resource.ts +18 -1
  188. package/src/mcp/primitives/$tool.ts +83 -7
  189. package/src/mcp/providers/McpServerProvider.ts +74 -16
  190. package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
  191. package/src/orm/REFACTORING.md +330 -0
  192. package/src/orm/core/primitives/$transactional.ts +11 -0
  193. package/src/orm/core/schemas/updateSchema.ts +1 -1
  194. package/src/orm/core/services/PgRelationManager.ts +4 -2
  195. package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
  196. package/src/react/core/hooks/useQuery.ts +153 -0
  197. package/src/react/core/index.ts +1 -0
  198. package/src/react/form/services/FormModel.ts +15 -6
  199. package/src/react/form/services/parseField.ts +8 -0
  200. package/src/react/i18n/providers/I18nProvider.ts +8 -2
  201. package/src/react/router/__tests__/$page.spec.tsx +0 -16
  202. package/src/react/router/__tests__/ssr.spec.tsx +339 -0
  203. package/src/react/router/primitives/$page.ts +28 -4
  204. package/src/react/router/providers/ReactPageProvider.ts +27 -9
  205. package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
  206. package/src/react/ui/index.ts +6 -0
  207. package/src/react/ui/services/SchemaControl.ts +209 -0
  208. package/src/security/primitives/$issuer.ts +6 -3
  209. package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
  210. package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
  211. package/src/server/core/errors/ValidationError.ts +13 -1
  212. package/src/server/core/primitives/$action.ts +16 -5
  213. package/src/server/core/providers/ServerRouterProvider.ts +26 -4
  214. package/src/server/swagger/providers/ServerSwaggerProvider.ts +5 -7
  215. package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
  216. package/src/websocket/services/WebSocketClient.ts +11 -5
  217. package/src/mcp/transports/SseMcpTransport.ts +0 -182
@@ -45,6 +45,12 @@ export interface FieldMeta {
45
45
  constraints: FieldConstraints;
46
46
  testId?: string;
47
47
  schema: TSchema;
48
+ /**
49
+ * Raw `$control` value from the schema, untyped here. The UI layer
50
+ * (`alepha/react/ui`) provides the strict {@link SchemaControl} type and
51
+ * a `resolveSchemaControl` helper to evaluate the function form.
52
+ */
53
+ control?: unknown;
48
54
  }
49
55
 
50
56
  export interface ParseFieldOptions {
@@ -77,6 +83,7 @@ export const parseField = (
77
83
  pattern?: string;
78
84
  properties?: unknown;
79
85
  items?: { properties?: unknown };
86
+ $control?: unknown;
80
87
  };
81
88
 
82
89
  const label =
@@ -132,6 +139,7 @@ export const parseField = (
132
139
  | string
133
140
  | undefined,
134
141
  schema: input.schema,
142
+ control: schema.$control,
135
143
  };
136
144
  };
137
145
 
@@ -230,15 +230,21 @@ export class I18nProvider<
230
230
  return value;
231
231
  };
232
232
 
233
+ /**
234
+ * Look up `key` in the registered dictionaries. The `(string & {})` arm
235
+ * keeps autocomplete for the typed dictionary keys while allowing shared
236
+ * library components to pass arbitrary string keys (with a `default`
237
+ * fallback) without casting to `as never`.
238
+ */
233
239
  public readonly tr = (
234
- key: keyof ServiceDictionary<S>[K],
240
+ key: keyof ServiceDictionary<S>[K] | (string & {}),
235
241
  options: {
236
242
  args?: string[];
237
243
  default?: string;
238
244
  } = {},
239
245
  ) => {
240
246
  const translation = this.translate(key as string, options.args || []);
241
- if (translation === key && options.default) {
247
+ if (translation === (key as string) && options.default) {
242
248
  return options.default;
243
249
  }
244
250
  return translation;
@@ -419,22 +419,6 @@ describe("$page primitive tests", () => {
419
419
  expect(await app.staticPage.fetch().then((it) => it.html)).toBe(html);
420
420
  });
421
421
 
422
- test("$page - client-side only rendering", async ({ expect }) => {
423
- class App {
424
- clientOnly = $page({
425
- path: "/client",
426
- client: true,
427
- component: () => "Client only",
428
- });
429
- }
430
-
431
- const app = alepha.inject(App);
432
- await alepha.start();
433
-
434
- const clientRendered = await app.clientOnly.fetch();
435
- expect(clientRendered.html).toBe("");
436
- });
437
-
438
422
  test("$page - server response handler", async ({ expect }) => {
439
423
  const mockHandler = vi.fn();
440
424
 
@@ -0,0 +1,339 @@
1
+ import { Alepha } from "alepha";
2
+ import { beforeEach, describe, test } from "vitest";
3
+ import { $page, NestedView } from "../index.ts";
4
+
5
+ describe("$page ssr option", () => {
6
+ let alepha: Alepha;
7
+
8
+ beforeEach(() => {
9
+ alepha = Alepha.create();
10
+ });
11
+
12
+ test("default (no ssr option) renders server-side", async ({ expect }) => {
13
+ class App {
14
+ home = $page({
15
+ path: "/",
16
+ component: () => "Home content",
17
+ });
18
+ }
19
+
20
+ const app = alepha.inject(App);
21
+ await alepha.start();
22
+
23
+ const rendered = await app.home.render();
24
+ expect(rendered.html).toBe("Home content");
25
+ });
26
+
27
+ test("ssr: true renders server-side", async ({ expect }) => {
28
+ class App {
29
+ home = $page({
30
+ path: "/",
31
+ ssr: true,
32
+ component: () => "Home content",
33
+ });
34
+ }
35
+
36
+ const app = alepha.inject(App);
37
+ await alepha.start();
38
+
39
+ const rendered = await app.home.render();
40
+ expect(rendered.html).toBe("Home content");
41
+ });
42
+
43
+ test("ssr: false skips server rendering on a leaf page", async ({
44
+ expect,
45
+ }) => {
46
+ class App {
47
+ home = $page({
48
+ path: "/",
49
+ ssr: false,
50
+ component: () => "Home content",
51
+ });
52
+ }
53
+
54
+ const app = alepha.inject(App);
55
+ await alepha.start();
56
+
57
+ const rendered = await app.home.render();
58
+ expect(rendered.html).toBe("");
59
+ });
60
+
61
+ test("ssr: false still runs the loader server-side", async ({ expect }) => {
62
+ let loaderCalls = 0;
63
+
64
+ class App {
65
+ home = $page({
66
+ path: "/",
67
+ ssr: false,
68
+ loader: () => {
69
+ loaderCalls += 1;
70
+ return { msg: "loaded" };
71
+ },
72
+ component: ({ msg }: { msg: string }) => msg,
73
+ });
74
+ }
75
+
76
+ const app = alepha.inject(App);
77
+ await alepha.start();
78
+
79
+ const rendered = await app.home.render();
80
+ expect(rendered.html).toBe("");
81
+ expect(loaderCalls).toBe(1);
82
+ });
83
+
84
+ test("parent ssr: false cascades to child without override", async ({
85
+ expect,
86
+ }) => {
87
+ class App {
88
+ parent = $page({
89
+ path: "/parent",
90
+ ssr: false,
91
+ component: () => (
92
+ <>
93
+ Parent
94
+ <NestedView />
95
+ </>
96
+ ),
97
+ });
98
+
99
+ child = $page({
100
+ path: "/child",
101
+ parent: this.parent,
102
+ component: () => "Child",
103
+ });
104
+ }
105
+
106
+ const app = alepha.inject(App);
107
+ await alepha.start();
108
+
109
+ const rendered = await app.child.render();
110
+ expect(rendered.html).toBe("");
111
+ });
112
+
113
+ test("child ssr: true overrides parent ssr: false (both render)", async ({
114
+ expect,
115
+ }) => {
116
+ class App {
117
+ parent = $page({
118
+ path: "/parent",
119
+ ssr: false,
120
+ component: () => (
121
+ <>
122
+ Parent
123
+ <NestedView />
124
+ </>
125
+ ),
126
+ });
127
+
128
+ child = $page({
129
+ path: "/child",
130
+ parent: this.parent,
131
+ ssr: true,
132
+ component: () => "Child",
133
+ });
134
+ }
135
+
136
+ const app = alepha.inject(App);
137
+ await alepha.start();
138
+
139
+ const rendered = await app.child.render();
140
+ expect(rendered.html).toBe("Parent<!-- -->Child");
141
+ });
142
+
143
+ test("child ssr: false overrides parent ssr: true (skip everything)", async ({
144
+ expect,
145
+ }) => {
146
+ class App {
147
+ parent = $page({
148
+ path: "/parent",
149
+ ssr: true,
150
+ component: () => (
151
+ <>
152
+ Parent
153
+ <NestedView />
154
+ </>
155
+ ),
156
+ });
157
+
158
+ child = $page({
159
+ path: "/child",
160
+ parent: this.parent,
161
+ ssr: false,
162
+ component: () => "Child",
163
+ });
164
+ }
165
+
166
+ const app = alepha.inject(App);
167
+ await alepha.start();
168
+
169
+ const rendered = await app.child.render();
170
+ expect(rendered.html).toBe("");
171
+ });
172
+
173
+ test("siblings: one inherits parent ssr: false, the other overrides", async ({
174
+ expect,
175
+ }) => {
176
+ class App {
177
+ parent = $page({
178
+ path: "/parent",
179
+ ssr: false,
180
+ component: () => (
181
+ <>
182
+ Parent
183
+ <NestedView />
184
+ </>
185
+ ),
186
+ });
187
+
188
+ home = $page({
189
+ path: "/home",
190
+ parent: this.parent,
191
+ ssr: true,
192
+ component: () => "Home",
193
+ });
194
+
195
+ about = $page({
196
+ path: "/about",
197
+ parent: this.parent,
198
+ component: () => "About",
199
+ });
200
+ }
201
+
202
+ const app = alepha.inject(App);
203
+ await alepha.start();
204
+
205
+ const home = await app.home.render();
206
+ expect(home.html).toBe("Parent<!-- -->Home");
207
+
208
+ const about = await app.about.render();
209
+ expect(about.html).toBe("");
210
+ });
211
+
212
+ test("3-level chain: nearest explicit ssr wins (leaf decides)", async ({
213
+ expect,
214
+ }) => {
215
+ class App {
216
+ grand = $page({
217
+ path: "/grand",
218
+ ssr: false,
219
+ component: () => (
220
+ <>
221
+ Grand
222
+ <NestedView />
223
+ </>
224
+ ),
225
+ });
226
+
227
+ // no ssr → inherits from grand → false (default for descendants)
228
+ mid = $page({
229
+ path: "/mid",
230
+ parent: this.grand,
231
+ component: () => (
232
+ <>
233
+ Mid
234
+ <NestedView />
235
+ </>
236
+ ),
237
+ });
238
+
239
+ // explicit ssr: true → overrides inherited false
240
+ leaf = $page({
241
+ path: "/leaf",
242
+ parent: this.mid,
243
+ ssr: true,
244
+ component: () => "Leaf",
245
+ });
246
+ }
247
+
248
+ const app = alepha.inject(App);
249
+ await alepha.start();
250
+
251
+ const rendered = await app.leaf.render();
252
+ expect(rendered.html).toBe("Grand<!-- -->Mid<!-- -->Leaf");
253
+ });
254
+
255
+ test("3-level chain: middle ssr: true does not affect leaf without explicit value", async ({
256
+ expect,
257
+ }) => {
258
+ class App {
259
+ grand = $page({
260
+ path: "/grand",
261
+ ssr: false,
262
+ component: () => (
263
+ <>
264
+ Grand
265
+ <NestedView />
266
+ </>
267
+ ),
268
+ });
269
+
270
+ // explicit true overrides grand's false for itself + descendants default
271
+ mid = $page({
272
+ path: "/mid",
273
+ parent: this.grand,
274
+ ssr: true,
275
+ component: () => (
276
+ <>
277
+ Mid
278
+ <NestedView />
279
+ </>
280
+ ),
281
+ });
282
+
283
+ // no ssr → walks up: mid.ssr === true → leaf renders
284
+ leaf = $page({
285
+ path: "/leaf",
286
+ parent: this.mid,
287
+ component: () => "Leaf",
288
+ });
289
+ }
290
+
291
+ const app = alepha.inject(App);
292
+ await alepha.start();
293
+
294
+ const rendered = await app.leaf.render();
295
+ expect(rendered.html).toBe("Grand<!-- -->Mid<!-- -->Leaf");
296
+ });
297
+
298
+ test("parent ssr: false: loaders still run for parent and child", async ({
299
+ expect,
300
+ }) => {
301
+ let parentLoaderCalls = 0;
302
+ let childLoaderCalls = 0;
303
+
304
+ class App {
305
+ parent = $page({
306
+ path: "/parent",
307
+ ssr: false,
308
+ loader: () => {
309
+ parentLoaderCalls += 1;
310
+ return { fromParent: "p" };
311
+ },
312
+ component: () => (
313
+ <>
314
+ Parent
315
+ <NestedView />
316
+ </>
317
+ ),
318
+ });
319
+
320
+ child = $page({
321
+ path: "/child",
322
+ parent: this.parent,
323
+ loader: ({ fromParent }) => {
324
+ childLoaderCalls += 1;
325
+ return { fromChild: `${fromParent}-c` };
326
+ },
327
+ component: ({ fromChild }: { fromChild: string }) => fromChild,
328
+ });
329
+ }
330
+
331
+ const app = alepha.inject(App);
332
+ await alepha.start();
333
+
334
+ const rendered = await app.child.render();
335
+ expect(rendered.html).toBe("");
336
+ expect(parentLoaderCalls).toBe(1);
337
+ expect(childLoaderCalls).toBe(1);
338
+ });
339
+ });
@@ -10,7 +10,6 @@ import {
10
10
  type TSchema,
11
11
  } from "alepha";
12
12
  import { $cache } from "alepha/cache";
13
- import type { ClientOnlyProps } from "alepha/react";
14
13
  import type { Head } from "alepha/react/head";
15
14
  import type { ServerRequest } from "alepha/server";
16
15
  import type { FC, ReactNode } from "react";
@@ -297,10 +296,35 @@ export interface PagePrimitiveOptions<
297
296
  };
298
297
 
299
298
  /**
300
- * If true, force the page to be rendered only on the client-side (browser).
301
- * It uses the `<ClientOnly/>` component to render the page.
299
+ * Enable or disable server-side rendering for this page.
300
+ *
301
+ * - `true` (default): the page component is rendered on the server and
302
+ * hydrated on the client.
303
+ * - `false`: the loader still runs on the server (so data is preloaded and
304
+ * serialized for hydration), but the component is rendered only on the
305
+ * client. The server emits no HTML for this page.
306
+ *
307
+ * **Decided at the leaf, inherited as default by descendants.**
308
+ *
309
+ * The effective value is determined by the matched leaf page: walk up the
310
+ * parent chain and use the nearest explicit `ssr` value. Setting
311
+ * `ssr: false` on a parent therefore acts as the default for its children;
312
+ * a child can override with `ssr: true`.
313
+ *
314
+ * Skipping rendering while keeping the loader is the recommended strategy
315
+ * for CPU-constrained server environments (e.g. Cloudflare Workers) and
316
+ * heavy admin/dashboard views where SSR provides little SEO value.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * root = $page({ ssr: false }); // default for children
321
+ * home = $page({ parent: root, ssr: true }); // overrides → SSR
322
+ * about = $page({ parent: root }); // inherits → no SSR
323
+ * ```
324
+ *
325
+ * @default true
302
326
  */
303
- client?: boolean | ClientOnlyProps;
327
+ ssr?: boolean;
304
328
 
305
329
  /**
306
330
  * Called before the server response is sent to the client. (server only)
@@ -470,6 +470,14 @@ export class ReactPageProvider {
470
470
  }
471
471
  }
472
472
 
473
+ // If the matched leaf opts out of SSR (own value or inherited from
474
+ // parents), wrap the root layer in ClientOnly so the server emits no
475
+ // HTML for the route chain. Loaders have already run above.
476
+ if (state.layers.length > 0 && !this.isSSR(route)) {
477
+ const rootLayer = state.layers[0];
478
+ rootLayer.element = createElement(ClientOnly, {}, rootLayer.element);
479
+ }
480
+
473
481
  return { state };
474
482
  }
475
483
 
@@ -598,14 +606,6 @@ export class ReactPageProvider {
598
606
  ): ReactNode {
599
607
  view ??= this.renderEmptyView();
600
608
 
601
- const element = page.client
602
- ? createElement(
603
- ClientOnly,
604
- typeof page.client === "object" ? page.client : {},
605
- view,
606
- )
607
- : view;
608
-
609
609
  return createElement(
610
610
  RouterLayerContext.Provider,
611
611
  {
@@ -616,10 +616,28 @@ export class ReactPageProvider {
616
616
  this.getErrorHandler(page) ?? ((error) => this.renderError(error)),
617
617
  },
618
618
  },
619
- element,
619
+ view,
620
620
  );
621
621
  }
622
622
 
623
+ /**
624
+ * Resolve the effective `ssr` value for a route by walking up the parent
625
+ * chain. Returns the nearest explicit `ssr` value, defaulting to `true`.
626
+ *
627
+ * The decision is made at the leaf: a parent's `ssr` only acts as a default
628
+ * for descendants that did not set their own value.
629
+ */
630
+ public isSSR(route: PageRoute): boolean {
631
+ let current: PageRoute | undefined = route;
632
+ while (current) {
633
+ if (typeof current.ssr === "boolean") {
634
+ return current.ssr;
635
+ }
636
+ current = current.parent;
637
+ }
638
+ return true;
639
+ }
640
+
623
641
  protected map(
624
642
  pages: Array<PagePrimitive>,
625
643
  target: PagePrimitive,
@@ -0,0 +1,36 @@
1
+ import { $atom, type Static, t } from "alepha";
2
+
3
+ /**
4
+ * Available themes the user can pick from. Apps populate this atom on boot
5
+ * (e.g. `alepha.store.set(uiThemeListAtom, MY_THEMES)`); UI consumers like
6
+ * `<ButtonTheme/>` read it to render a picker. The selected theme id is
7
+ * persisted separately in `uiAtom.theme`.
8
+ *
9
+ * Defaults to a single `"default"` entry so the registry stays usable when
10
+ * an app doesn't declare its own list.
11
+ */
12
+ export const uiThemeListAtom = $atom({
13
+ name: "alepha.react.ui.themes",
14
+ schema: t.array(
15
+ t.object({
16
+ /** Stable id stored in `uiAtom.theme`. Mapped to a CSS class on `<html>`. */
17
+ id: t.string(),
18
+ /** Human-readable label shown in the picker. */
19
+ label: t.string(),
20
+ /**
21
+ * Optional 4-color preview swatch in 2×2 order (TL, TR, BL, BR). Any
22
+ * CSS-valid color string.
23
+ */
24
+ swatch: t.optional(t.array(t.string(), { minItems: 4, maxItems: 4 })),
25
+ /**
26
+ * Optional stylesheet URL (typically Google Fonts) loaded lazily when
27
+ * the theme is selected.
28
+ */
29
+ fontHref: t.optional(t.string()),
30
+ }),
31
+ ),
32
+ default: [{ id: "default", label: "Default" }],
33
+ });
34
+
35
+ export type UiThemeList = Static<typeof uiThemeListAtom.schema>;
36
+ export type UiTheme = UiThemeList[number];
@@ -1,14 +1,18 @@
1
1
  import { $module } from "alepha";
2
2
  import type { UiState } from "./atoms/uiAtom.ts";
3
+ import type { UiThemeList } from "./atoms/uiThemeListAtom.ts";
4
+ import { uiThemeListAtom } from "./atoms/uiThemeListAtom.ts";
3
5
  import { UiPersistence } from "./services/UiPersistence.ts";
4
6
 
5
7
  // ---------------------------------------------------------------------------------------------------------------------
6
8
 
7
9
  export * from "./atoms/uiAtom.ts";
10
+ export * from "./atoms/uiThemeListAtom.ts";
8
11
  export * from "./components/ColorScheme.tsx";
9
12
  export * from "./hooks/useColorMode.ts";
10
13
  export * from "./hooks/useSidebarState.ts";
11
14
  export * from "./hooks/useTheme.ts";
15
+ export * from "./services/SchemaControl.ts";
12
16
  export * from "./services/UiPersistence.ts";
13
17
 
14
18
  // ---------------------------------------------------------------------------------------------------------------------
@@ -16,6 +20,7 @@ export * from "./services/UiPersistence.ts";
16
20
  declare module "alepha" {
17
21
  export interface State {
18
22
  "alepha.react.ui": UiState;
23
+ "alepha.react.ui.themes": UiThemeList;
19
24
  }
20
25
  }
21
26
 
@@ -31,5 +36,6 @@ declare module "alepha" {
31
36
  */
32
37
  export const AlephaReactUi = $module({
33
38
  name: "alepha.react.ui",
39
+ atoms: [uiThemeListAtom],
34
40
  services: [UiPersistence],
35
41
  });