alepha 0.20.2 → 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 (304) hide show
  1. package/README.md +0 -1
  2. package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
  3. package/assets/swagger-ui/swagger-ui.css +1 -1
  4. package/dist/api/audits/index.browser.js +49 -0
  5. package/dist/api/audits/index.browser.js.map +1 -1
  6. package/dist/api/audits/index.js +49 -0
  7. package/dist/api/audits/index.js.map +1 -1
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts +2 -61
  10. package/dist/api/jobs/index.d.ts.map +1 -1
  11. package/dist/api/jobs/index.js.map +1 -1
  12. package/dist/api/keys/index.d.ts +4 -4
  13. package/dist/api/keys/index.js.map +1 -1
  14. package/dist/api/notifications/index.d.ts +1 -10
  15. package/dist/api/notifications/index.d.ts.map +1 -1
  16. package/dist/api/parameters/index.browser.js +37 -0
  17. package/dist/api/parameters/index.browser.js.map +1 -1
  18. package/dist/api/parameters/index.d.ts +12 -68
  19. package/dist/api/parameters/index.d.ts.map +1 -1
  20. package/dist/api/parameters/index.js +57 -4
  21. package/dist/api/parameters/index.js.map +1 -1
  22. package/dist/api/payments/index.js.map +1 -1
  23. package/dist/api/users/index.browser.js +6 -0
  24. package/dist/api/users/index.browser.js.map +1 -1
  25. package/dist/api/users/index.d.ts +148 -227
  26. package/dist/api/users/index.d.ts.map +1 -1
  27. package/dist/api/users/index.js +60 -14
  28. package/dist/api/users/index.js.map +1 -1
  29. package/dist/api/verifications/index.d.ts.map +1 -1
  30. package/dist/api/verifications/index.js +2 -1
  31. package/dist/api/verifications/index.js.map +1 -1
  32. package/dist/bucket/index.d.ts +77 -107
  33. package/dist/bucket/index.d.ts.map +1 -1
  34. package/dist/bucket/index.js +153 -5
  35. package/dist/bucket/index.js.map +1 -1
  36. package/dist/bucket/index.workerd.js +12 -2
  37. package/dist/bucket/index.workerd.js.map +1 -1
  38. package/dist/cache/core/index.d.ts +26 -0
  39. package/dist/cache/core/index.d.ts.map +1 -1
  40. package/dist/cache/core/index.js +11 -1
  41. package/dist/cache/core/index.js.map +1 -1
  42. package/dist/cache/core/index.workerd.js +11 -1
  43. package/dist/cache/core/index.workerd.js.map +1 -1
  44. package/dist/captcha/index.js.map +1 -1
  45. package/dist/cli/config/index.d.ts +7 -5
  46. package/dist/cli/config/index.d.ts.map +1 -1
  47. package/dist/cli/config/index.js +2 -3
  48. package/dist/cli/config/index.js.map +1 -1
  49. package/dist/cli/core/index.d.ts +637 -11660
  50. package/dist/cli/core/index.d.ts.map +1 -1
  51. package/dist/cli/core/index.js +707 -532
  52. package/dist/cli/core/index.js.map +1 -1
  53. package/dist/cli/devtools/index.d.ts +4 -8
  54. package/dist/cli/devtools/index.d.ts.map +1 -1
  55. package/dist/cli/devtools/index.js +20 -16
  56. package/dist/cli/devtools/index.js.map +1 -1
  57. package/dist/cli/platform/index.d.ts +51 -77
  58. package/dist/cli/platform/index.d.ts.map +1 -1
  59. package/dist/cli/platform/index.js +65 -15
  60. package/dist/cli/platform/index.js.map +1 -1
  61. package/dist/cli/vendor/index.d.ts +10 -13
  62. package/dist/cli/vendor/index.d.ts.map +1 -1
  63. package/dist/cli/vendor/index.js +30 -12
  64. package/dist/cli/vendor/index.js.map +1 -1
  65. package/dist/command/index.js +1 -1
  66. package/dist/command/index.js.map +1 -1
  67. package/dist/core/index.browser.js +27 -3
  68. package/dist/core/index.browser.js.map +1 -1
  69. package/dist/core/index.d.ts +8 -11
  70. package/dist/core/index.d.ts.map +1 -1
  71. package/dist/core/index.js +27 -3
  72. package/dist/core/index.js.map +1 -1
  73. package/dist/core/index.native.js +27 -3
  74. package/dist/core/index.native.js.map +1 -1
  75. package/dist/core/index.workerd.js +27 -3
  76. package/dist/core/index.workerd.js.map +1 -1
  77. package/dist/crypto/index.js.map +1 -1
  78. package/dist/datetime/index.d.ts +69 -10
  79. package/dist/datetime/index.d.ts.map +1 -1
  80. package/dist/datetime/index.js +135 -13
  81. package/dist/datetime/index.js.map +1 -1
  82. package/dist/email/core/index.js.map +1 -1
  83. package/dist/email/smtp/index.js +130 -16
  84. package/dist/email/smtp/index.js.map +1 -1
  85. package/dist/fake/index.js.map +1 -1
  86. package/dist/lock/core/index.d.ts +30 -2
  87. package/dist/lock/core/index.d.ts.map +1 -1
  88. package/dist/lock/core/index.js +35 -12
  89. package/dist/lock/core/index.js.map +1 -1
  90. package/dist/lock/redis/index.js.map +1 -1
  91. package/dist/logger/index.js +32 -1
  92. package/dist/logger/index.js.map +1 -1
  93. package/dist/mcp/index.d.ts +238 -31
  94. package/dist/mcp/index.d.ts.map +1 -1
  95. package/dist/mcp/index.js +198 -67
  96. package/dist/mcp/index.js.map +1 -1
  97. package/dist/orm/core/index.browser.js +2 -362
  98. package/dist/orm/core/index.browser.js.map +1 -1
  99. package/dist/orm/core/index.bun.js +18 -409
  100. package/dist/orm/core/index.bun.js.map +1 -1
  101. package/dist/orm/core/index.d.ts +41 -194
  102. package/dist/orm/core/index.d.ts.map +1 -1
  103. package/dist/orm/core/index.js +27 -422
  104. package/dist/orm/core/index.js.map +1 -1
  105. package/dist/orm/postgres/index.bun.js +17 -20
  106. package/dist/orm/postgres/index.bun.js.map +1 -1
  107. package/dist/orm/postgres/index.d.ts +1 -5
  108. package/dist/orm/postgres/index.d.ts.map +1 -1
  109. package/dist/orm/postgres/index.js +17 -20
  110. package/dist/orm/postgres/index.js.map +1 -1
  111. package/dist/react/core/index.d.ts +102 -1
  112. package/dist/react/core/index.d.ts.map +1 -1
  113. package/dist/react/core/index.js +65 -1
  114. package/dist/react/core/index.js.map +1 -1
  115. package/dist/react/form/index.d.ts +6 -0
  116. package/dist/react/form/index.d.ts.map +1 -1
  117. package/dist/react/form/index.js +7 -7
  118. package/dist/react/form/index.js.map +1 -1
  119. package/dist/react/i18n/index.d.ts +7 -1
  120. package/dist/react/i18n/index.d.ts.map +1 -1
  121. package/dist/react/i18n/index.js +6 -0
  122. package/dist/react/i18n/index.js.map +1 -1
  123. package/dist/react/intro/index.js +22 -17
  124. package/dist/react/intro/index.js.map +1 -1
  125. package/dist/react/router/index.browser.js +98 -4
  126. package/dist/react/router/index.browser.js.map +1 -1
  127. package/dist/react/router/index.d.ts +58 -5
  128. package/dist/react/router/index.d.ts.map +1 -1
  129. package/dist/react/router/index.js +122 -6
  130. package/dist/react/router/index.js.map +1 -1
  131. package/dist/react/testing/{chunk-DBEY4PJZ.js → chunk-6Ep1yQYe.js} +1 -1
  132. package/dist/react/testing/index.js +1 -1
  133. package/dist/react/testing/index.js.map +1 -1
  134. package/dist/react/ui/index.d.ts +195 -1
  135. package/dist/react/ui/index.d.ts.map +1 -1
  136. package/dist/react/ui/index.js +64 -1
  137. package/dist/react/ui/index.js.map +1 -1
  138. package/dist/react/websocket/index.js.map +1 -1
  139. package/dist/redis/index.js.map +1 -1
  140. package/dist/scheduler/index.d.ts +1 -2
  141. package/dist/scheduler/index.d.ts.map +1 -1
  142. package/dist/scheduler/index.js +1 -1
  143. package/dist/scheduler/index.js.map +1 -1
  144. package/dist/scheduler/index.workerd.js +1 -1
  145. package/dist/scheduler/index.workerd.js.map +1 -1
  146. package/dist/security/index.browser.js.map +1 -1
  147. package/dist/security/index.d.ts.map +1 -1
  148. package/dist/security/index.js +2 -2
  149. package/dist/security/index.js.map +1 -1
  150. package/dist/server/auth/index.d.ts.map +1 -1
  151. package/dist/server/auth/index.js +24 -10
  152. package/dist/server/auth/index.js.map +1 -1
  153. package/dist/server/cookies/index.js.map +1 -1
  154. package/dist/server/core/index.browser.js +10 -3
  155. package/dist/server/core/index.browser.js.map +1 -1
  156. package/dist/server/core/index.d.ts +1 -4
  157. package/dist/server/core/index.d.ts.map +1 -1
  158. package/dist/server/core/index.js +47 -9
  159. package/dist/server/core/index.js.map +1 -1
  160. package/dist/server/links/index.browser.js.map +1 -1
  161. package/dist/server/links/index.js.map +1 -1
  162. package/dist/server/metrics/index.js +19 -1
  163. package/dist/server/metrics/index.js.map +1 -1
  164. package/dist/server/rate-limit/index.js.map +1 -1
  165. package/dist/server/static/index.js.map +1 -1
  166. package/dist/server/swagger/index.d.ts.map +1 -1
  167. package/dist/server/swagger/index.js +4 -5
  168. package/dist/server/swagger/index.js.map +1 -1
  169. package/dist/sms/index.js.map +1 -1
  170. package/dist/system/index.browser.js.map +1 -1
  171. package/dist/system/index.js.map +1 -1
  172. package/dist/system/index.workerd.js.map +1 -1
  173. package/dist/topic/core/index.js.map +1 -1
  174. package/dist/websocket/index.browser.js +32 -5
  175. package/dist/websocket/index.browser.js.map +1 -1
  176. package/dist/websocket/index.d.ts +3 -1
  177. package/dist/websocket/index.d.ts.map +1 -1
  178. package/dist/websocket/index.js +42 -6
  179. package/dist/websocket/index.js.map +1 -1
  180. package/package.json +685 -274
  181. package/src/api/files/__tests__/FileController.spec.ts +1 -1
  182. package/src/api/jobs/__tests__/$job.spec.ts +5 -1
  183. package/src/api/parameters/services/ParameterProvider.ts +21 -4
  184. package/src/api/users/__tests__/SessionService.spec.ts +99 -0
  185. package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
  186. package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
  187. package/src/api/users/entities/sessions.ts +6 -0
  188. package/src/api/users/jobs/UserJobs.ts +44 -17
  189. package/src/api/users/providers/RealmProvider.ts +4 -0
  190. package/src/api/users/schemas/userQuerySchema.ts +0 -1
  191. package/src/api/users/services/SessionService.ts +27 -0
  192. package/src/api/users/services/UserService.ts +1 -5
  193. package/src/api/verifications/__tests__/CodeVerification.spec.ts +14 -0
  194. package/src/api/verifications/__tests__/LinkVerification.spec.ts +14 -0
  195. package/src/api/verifications/services/VerificationService.ts +1 -0
  196. package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
  197. package/src/bucket/index.ts +19 -2
  198. package/src/bucket/primitives/$bucket.ts +9 -1
  199. package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
  200. package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
  201. package/src/cache/core/index.ts +29 -0
  202. package/src/cache/core/primitives/$cache.ts +14 -1
  203. package/src/cli/config/defineConfig.ts +13 -15
  204. package/src/cli/core/__tests__/init.spec.ts +214 -7
  205. package/src/cli/core/commands/init.ts +12 -0
  206. package/src/cli/core/services/PackageManagerUtils.ts +23 -6
  207. package/src/cli/core/services/ProjectScaffolder.ts +315 -33
  208. package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
  209. package/src/cli/core/tasks/BuildDockerTask.ts +9 -10
  210. package/src/cli/core/tasks/BuildServerTask.ts +8 -0
  211. package/src/cli/core/templates/agentMd.ts +2 -10
  212. package/src/cli/core/templates/apiIndexTs.ts +23 -1
  213. package/src/cli/core/templates/componentsJsonTs.ts +39 -0
  214. package/src/cli/core/templates/mainCss.ts +1 -0
  215. package/src/cli/core/templates/saasAdminLayoutTsx.ts +77 -0
  216. package/src/cli/core/templates/saasAdminPagesTsx.ts +26 -0
  217. package/src/cli/core/templates/saasAuthLayoutTsx.ts +20 -0
  218. package/src/cli/core/templates/saasAuthPagesTsx.ts +62 -0
  219. package/src/cli/core/templates/saasRealmProviderTs.ts +46 -0
  220. package/src/cli/core/templates/webAppRouterTs.ts +104 -1
  221. package/src/cli/core/templates/webIndexTs.ts +23 -1
  222. package/src/cli/devtools/index.ts +12 -26
  223. package/src/cli/platform/__tests__/SecretsCommand.spec.ts +2 -0
  224. package/src/cli/platform/index.ts +15 -24
  225. package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
  226. package/src/cli/vendor/index.ts +14 -23
  227. package/src/command/providers/CliProvider.ts +1 -1
  228. package/src/core/Alepha.ts +11 -1
  229. package/src/core/helpers/ref.ts +18 -0
  230. package/src/core/index.shared.ts +1 -0
  231. package/src/core/interfaces/Service.ts +3 -1
  232. package/src/core/providers/SchemaValidator.ts +9 -1
  233. package/src/core/providers/TypeProvider.ts +2 -3
  234. package/src/datetime/REFACTORING.md +118 -0
  235. package/src/datetime/providers/DateTimeProvider.ts +203 -24
  236. package/src/lock/core/index.ts +31 -0
  237. package/src/lock/core/primitives/$lock.ts +14 -1
  238. package/src/logger/services/Logger.ts +1 -1
  239. package/src/mcp/__tests__/$resource.spec.ts +1 -1
  240. package/src/mcp/__tests__/$tool.spec.ts +1 -1
  241. package/src/mcp/__tests__/McpServerProvider.spec.ts +1 -1
  242. package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
  243. package/src/mcp/helpers/jsonrpc.ts +26 -1
  244. package/src/mcp/index.ts +10 -5
  245. package/src/mcp/interfaces/McpTypes.ts +83 -6
  246. package/src/mcp/primitives/$prompt.ts +18 -1
  247. package/src/mcp/primitives/$resource.ts +18 -1
  248. package/src/mcp/primitives/$tool.ts +83 -7
  249. package/src/mcp/providers/McpServerProvider.ts +74 -16
  250. package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
  251. package/src/orm/REFACTORING.md +330 -0
  252. package/src/orm/__tests__/$repository-tests.ts +1 -0
  253. package/src/orm/__tests__/orm-next-tests.ts +2 -67
  254. package/src/orm/__tests__/orm-next.spec.ts +0 -21
  255. package/src/orm/core/index.shared.ts +0 -2
  256. package/src/orm/core/index.ts +1 -2
  257. package/src/orm/core/primitives/$repository.ts +3 -6
  258. package/src/orm/core/primitives/$transactional.ts +11 -0
  259. package/src/orm/core/providers/drivers/DatabaseProvider.ts +0 -5
  260. package/src/orm/core/providers/drivers/NodeSqliteProvider.ts +11 -13
  261. package/src/orm/core/schemas/updateSchema.ts +1 -1
  262. package/src/orm/core/services/ModelBuilder.ts +1 -13
  263. package/src/orm/core/services/PgRelationManager.ts +4 -2
  264. package/src/orm/core/services/Repository.ts +1 -42
  265. package/src/orm/core/services/SqliteModelBuilder.ts +2 -33
  266. package/src/orm/postgres/services/PostgresModelBuilder.ts +10 -45
  267. package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
  268. package/src/react/core/hooks/useQuery.ts +153 -0
  269. package/src/react/core/index.ts +1 -0
  270. package/src/react/form/services/FormModel.ts +15 -6
  271. package/src/react/form/services/parseField.ts +8 -0
  272. package/src/react/i18n/providers/I18nProvider.ts +8 -2
  273. package/src/react/intro/components/GettingStartedAuthSlide.tsx +11 -4
  274. package/src/react/router/__tests__/$page.spec.tsx +0 -16
  275. package/src/react/router/__tests__/ReactBrowserProvider.browser.spec.ts +213 -2
  276. package/src/react/router/__tests__/ssr.spec.tsx +339 -0
  277. package/src/react/router/primitives/$page.ts +28 -4
  278. package/src/react/router/providers/ReactBrowserProvider.ts +73 -0
  279. package/src/react/router/providers/ReactBrowserRouterProvider.ts +1 -1
  280. package/src/react/router/providers/ReactPageProvider.ts +27 -9
  281. package/src/react/router/providers/ReactPreloadProvider.ts +1 -1
  282. package/src/react/router/providers/ReactServerProvider.ts +1 -0
  283. package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
  284. package/src/react/ui/index.ts +6 -0
  285. package/src/react/ui/services/SchemaControl.ts +209 -0
  286. package/src/scheduler/providers/CronProvider.ts +1 -1
  287. package/src/security/primitives/$basicAuth.ts +1 -1
  288. package/src/security/primitives/$issuer.ts +6 -3
  289. package/src/server/auth/providers/ServerAuthProvider.ts +5 -1
  290. package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
  291. package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
  292. package/src/server/core/errors/ValidationError.ts +13 -1
  293. package/src/server/core/interfaces/ServerRequest.ts +1 -0
  294. package/src/server/core/primitives/$action.ts +16 -5
  295. package/src/server/core/providers/ServerProvider.ts +1 -1
  296. package/src/server/core/providers/ServerRouterProvider.ts +28 -6
  297. package/src/server/core/services/HttpClient.ts +1 -1
  298. package/src/server/swagger/providers/ServerSwaggerProvider.ts +6 -8
  299. package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
  300. package/src/websocket/services/WebSocketClient.ts +11 -5
  301. package/src/mcp/transports/SseMcpTransport.ts +0 -182
  302. package/src/orm/core/__tests__/parseQueryString.spec.ts +0 -196
  303. package/src/orm/core/helpers/parseQueryString.ts +0 -502
  304. package/src/orm/core/primitives/$view.ts +0 -88
@@ -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)
@@ -28,9 +28,20 @@ export const reactBrowserOptions = $atom({
28
28
  name: "alepha.react.browser.options",
29
29
  schema: t.object({
30
30
  scrollRestoration: t.enum(["top", "manual"]), // TODO: must be per page?
31
+ /**
32
+ * Intercept clicks on plain `<a href="/...">` anchors and route them
33
+ * through the SPA router, so authors don't need `<Link>` everywhere
34
+ * (notably for SSR/Markdown HTML rendered as raw markup).
35
+ *
36
+ * Skips: modifier keys, non-primary mouse buttons, `target` other than
37
+ * `_self`, `download`, `data-no-router`, non-http(s) schemes, hash-only
38
+ * hrefs, external origins, and clicks already `defaultPrevented`.
39
+ */
40
+ interceptAnchorClicks: t.boolean({ default: true }),
31
41
  }),
32
42
  default: {
33
43
  scrollRestoration: "top" as const,
44
+ interceptAnchorClicks: true,
34
45
  },
35
46
  });
36
47
 
@@ -325,8 +336,70 @@ export class ReactBrowserProvider {
325
336
 
326
337
  this.render();
327
338
  });
339
+
340
+ this.attachAnchorInterceptor();
328
341
  },
329
342
  });
343
+
344
+ /**
345
+ * Attach a delegated click listener that routes plain `<a href="/...">`
346
+ * clicks through the SPA router. Returns a detach function (used in tests).
347
+ *
348
+ * Bails out on modifier keys, non-primary mouse buttons, `target`, `download`,
349
+ * `data-no-router`, hash-only/external/non-http hrefs, and already-prevented
350
+ * events. Honors the runtime `interceptAnchorClicks` flag.
351
+ */
352
+ protected attachAnchorInterceptor(): () => void {
353
+ const onClick = (ev: MouseEvent) => {
354
+ if (!this.options.interceptAnchorClicks) return;
355
+ if (ev.defaultPrevented) return;
356
+ if (ev.button !== 0) return;
357
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
358
+
359
+ const node = ev.target as Element | null;
360
+ const a = node?.closest?.("a");
361
+ if (!a) return;
362
+
363
+ if (a.hasAttribute("download")) return;
364
+ if (a.hasAttribute("data-no-router")) return;
365
+
366
+ const target = a.getAttribute("target");
367
+ if (target && target !== "_self") return;
368
+
369
+ const href = a.getAttribute("href");
370
+ if (!href) return;
371
+ if (href.startsWith("#")) return;
372
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
373
+ // absolute scheme: only intercept if it points at our own origin
374
+ let url: URL;
375
+ try {
376
+ url = new URL(href);
377
+ } catch {
378
+ return;
379
+ }
380
+ if (url.origin !== this.location.origin) return;
381
+ ev.preventDefault();
382
+ const path = url.pathname + url.search + url.hash;
383
+ this.push(this.stripBase(path)).catch((e) => this.log.error(e));
384
+ return;
385
+ }
386
+
387
+ ev.preventDefault();
388
+ const url = new URL(href, this.location.href);
389
+ const path = url.pathname + url.search + url.hash;
390
+ this.push(this.stripBase(path)).catch((e) => this.log.error(e));
391
+ };
392
+
393
+ this.document.addEventListener("click", onClick);
394
+ return () => this.document.removeEventListener("click", onClick);
395
+ }
396
+
397
+ protected stripBase(path: string): string {
398
+ if (this.base && path.startsWith(this.base)) {
399
+ return path.slice(this.base.length) || "/";
400
+ }
401
+ return path;
402
+ }
330
403
  }
331
404
 
332
405
  // ---------------------------------------------------------------------------------------------------------------------
@@ -52,7 +52,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
52
52
  previous: PreviousLayerData[] = [],
53
53
  meta = {},
54
54
  isStale: () => boolean = () => false,
55
- ): Promise<string | void> {
55
+ ): Promise<string | undefined> {
56
56
  const { pathname, search } = url;
57
57
 
58
58
  const entry: Partial<ReactRouterState> = {
@@ -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,
@@ -65,7 +65,7 @@ export class ReactPreloadProvider {
65
65
  handler: ({ response }) => {
66
66
  // Only add to HTML responses (SSR pages)
67
67
  const contentType = response.headers["content-type"];
68
- if (!contentType || !contentType.includes("text/html")) {
68
+ if (!contentType?.includes("text/html")) {
69
69
  return;
70
70
  }
71
71
 
@@ -306,6 +306,7 @@ export class ReactServerProvider {
306
306
  // Skip SSR for file-like URLs hitting the catch-all wildcard.
307
307
  // Bots and crawlers often probe paths like /hello.txt, /wp-login.php, etc.
308
308
  // Rendering a full React page for these is wasteful — return a plain 404 instead.
309
+ // biome-ignore lint/complexity/useOptionalChain: staticFilePattern is `false | RegExp`; optional chaining doesn't narrow `false`
309
310
  if (staticFilePattern && staticFilePattern.test(url.pathname)) {
310
311
  reply.status = 404;
311
312
  reply.headers["content-type"] = "text/plain";
@@ -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
  });