alabjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/dist/adapters/cloudflare.d.ts +31 -0
  2. package/dist/adapters/cloudflare.d.ts.map +1 -0
  3. package/dist/adapters/cloudflare.js +30 -0
  4. package/dist/adapters/cloudflare.js.map +1 -0
  5. package/dist/adapters/deno.d.ts +22 -0
  6. package/dist/adapters/deno.d.ts.map +1 -0
  7. package/dist/adapters/deno.js +21 -0
  8. package/dist/adapters/deno.js.map +1 -0
  9. package/dist/adapters/web.d.ts +47 -0
  10. package/dist/adapters/web.d.ts.map +1 -0
  11. package/dist/adapters/web.js +212 -0
  12. package/dist/adapters/web.js.map +1 -0
  13. package/dist/cli.d.ts +11 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +61 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/client/hooks.d.ts +119 -0
  18. package/dist/client/hooks.d.ts.map +1 -0
  19. package/dist/client/hooks.js +220 -0
  20. package/dist/client/hooks.js.map +1 -0
  21. package/dist/client/hooks.test.d.ts +2 -0
  22. package/dist/client/hooks.test.d.ts.map +1 -0
  23. package/dist/client/hooks.test.js +45 -0
  24. package/dist/client/hooks.test.js.map +1 -0
  25. package/dist/client/index.d.ts +6 -0
  26. package/dist/client/index.d.ts.map +1 -0
  27. package/dist/client/index.js +4 -0
  28. package/dist/client/index.js.map +1 -0
  29. package/dist/client/offline.d.ts +52 -0
  30. package/dist/client/offline.d.ts.map +1 -0
  31. package/dist/client/offline.js +90 -0
  32. package/dist/client/offline.js.map +1 -0
  33. package/dist/client/provider.d.ts +12 -0
  34. package/dist/client/provider.d.ts.map +1 -0
  35. package/dist/client/provider.js +10 -0
  36. package/dist/client/provider.js.map +1 -0
  37. package/dist/commands/build.d.ts +18 -0
  38. package/dist/commands/build.d.ts.map +1 -0
  39. package/dist/commands/build.js +173 -0
  40. package/dist/commands/build.js.map +1 -0
  41. package/dist/commands/dev.d.ts +8 -0
  42. package/dist/commands/dev.d.ts.map +1 -0
  43. package/dist/commands/dev.js +447 -0
  44. package/dist/commands/dev.js.map +1 -0
  45. package/dist/commands/info.d.ts +6 -0
  46. package/dist/commands/info.d.ts.map +1 -0
  47. package/dist/commands/info.js +92 -0
  48. package/dist/commands/info.js.map +1 -0
  49. package/dist/commands/ssg.d.ts +8 -0
  50. package/dist/commands/ssg.d.ts.map +1 -0
  51. package/dist/commands/ssg.js +124 -0
  52. package/dist/commands/ssg.js.map +1 -0
  53. package/dist/commands/start.d.ts +7 -0
  54. package/dist/commands/start.d.ts.map +1 -0
  55. package/dist/commands/start.js +26 -0
  56. package/dist/commands/start.js.map +1 -0
  57. package/dist/commands/test.d.ts +24 -0
  58. package/dist/commands/test.d.ts.map +1 -0
  59. package/dist/commands/test.js +87 -0
  60. package/dist/commands/test.js.map +1 -0
  61. package/dist/components/ErrorBoundary.d.ts +38 -0
  62. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  63. package/dist/components/ErrorBoundary.js +46 -0
  64. package/dist/components/ErrorBoundary.js.map +1 -0
  65. package/dist/components/Font.d.ts +57 -0
  66. package/dist/components/Font.d.ts.map +1 -0
  67. package/dist/components/Font.js +33 -0
  68. package/dist/components/Font.js.map +1 -0
  69. package/dist/components/Image.d.ts +74 -0
  70. package/dist/components/Image.d.ts.map +1 -0
  71. package/dist/components/Image.js +85 -0
  72. package/dist/components/Image.js.map +1 -0
  73. package/dist/components/Link.d.ts +23 -0
  74. package/dist/components/Link.d.ts.map +1 -0
  75. package/dist/components/Link.js +48 -0
  76. package/dist/components/Link.js.map +1 -0
  77. package/dist/components/Script.d.ts +37 -0
  78. package/dist/components/Script.d.ts.map +1 -0
  79. package/dist/components/Script.js +70 -0
  80. package/dist/components/Script.js.map +1 -0
  81. package/dist/components/index.d.ts +10 -0
  82. package/dist/components/index.d.ts.map +1 -0
  83. package/dist/components/index.js +6 -0
  84. package/dist/components/index.js.map +1 -0
  85. package/dist/i18n/i18n.test.d.ts +2 -0
  86. package/dist/i18n/i18n.test.d.ts.map +1 -0
  87. package/dist/i18n/i18n.test.js +132 -0
  88. package/dist/i18n/i18n.test.js.map +1 -0
  89. package/dist/i18n/index.d.ts +135 -0
  90. package/dist/i18n/index.d.ts.map +1 -0
  91. package/dist/i18n/index.js +189 -0
  92. package/dist/i18n/index.js.map +1 -0
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +3 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/router/code-router.d.ts +204 -0
  98. package/dist/router/code-router.d.ts.map +1 -0
  99. package/dist/router/code-router.js +258 -0
  100. package/dist/router/code-router.js.map +1 -0
  101. package/dist/router/code-router.test.d.ts +2 -0
  102. package/dist/router/code-router.test.d.ts.map +1 -0
  103. package/dist/router/code-router.test.js +128 -0
  104. package/dist/router/code-router.test.js.map +1 -0
  105. package/dist/router/index.d.ts +4 -0
  106. package/dist/router/index.d.ts.map +1 -0
  107. package/dist/router/index.js +2 -0
  108. package/dist/router/index.js.map +1 -0
  109. package/dist/router/manifest.d.ts +12 -0
  110. package/dist/router/manifest.d.ts.map +1 -0
  111. package/dist/router/manifest.js +2 -0
  112. package/dist/router/manifest.js.map +1 -0
  113. package/dist/server/app.d.ts +13 -0
  114. package/dist/server/app.d.ts.map +1 -0
  115. package/dist/server/app.js +407 -0
  116. package/dist/server/app.js.map +1 -0
  117. package/dist/server/cache.d.ts +99 -0
  118. package/dist/server/cache.d.ts.map +1 -0
  119. package/dist/server/cache.js +161 -0
  120. package/dist/server/cache.js.map +1 -0
  121. package/dist/server/cache.test.d.ts +2 -0
  122. package/dist/server/cache.test.d.ts.map +1 -0
  123. package/dist/server/cache.test.js +150 -0
  124. package/dist/server/cache.test.js.map +1 -0
  125. package/dist/server/csrf.d.ts +28 -0
  126. package/dist/server/csrf.d.ts.map +1 -0
  127. package/dist/server/csrf.js +66 -0
  128. package/dist/server/csrf.js.map +1 -0
  129. package/dist/server/csrf.test.d.ts +2 -0
  130. package/dist/server/csrf.test.d.ts.map +1 -0
  131. package/dist/server/csrf.test.js +154 -0
  132. package/dist/server/csrf.test.js.map +1 -0
  133. package/dist/server/image.d.ts +18 -0
  134. package/dist/server/image.d.ts.map +1 -0
  135. package/dist/server/image.js +97 -0
  136. package/dist/server/image.js.map +1 -0
  137. package/dist/server/index.d.ts +57 -0
  138. package/dist/server/index.d.ts.map +1 -0
  139. package/dist/server/index.js +58 -0
  140. package/dist/server/index.js.map +1 -0
  141. package/dist/server/middleware.d.ts +53 -0
  142. package/dist/server/middleware.d.ts.map +1 -0
  143. package/dist/server/middleware.js +80 -0
  144. package/dist/server/middleware.js.map +1 -0
  145. package/dist/server/middleware.test.d.ts +2 -0
  146. package/dist/server/middleware.test.d.ts.map +1 -0
  147. package/dist/server/middleware.test.js +125 -0
  148. package/dist/server/middleware.test.js.map +1 -0
  149. package/dist/server/revalidate.d.ts +49 -0
  150. package/dist/server/revalidate.d.ts.map +1 -0
  151. package/dist/server/revalidate.js +62 -0
  152. package/dist/server/revalidate.js.map +1 -0
  153. package/dist/server/revalidate.test.d.ts +2 -0
  154. package/dist/server/revalidate.test.d.ts.map +1 -0
  155. package/dist/server/revalidate.test.js +93 -0
  156. package/dist/server/revalidate.test.js.map +1 -0
  157. package/dist/server/server-fn.test.d.ts +2 -0
  158. package/dist/server/server-fn.test.d.ts.map +1 -0
  159. package/dist/server/server-fn.test.js +105 -0
  160. package/dist/server/server-fn.test.js.map +1 -0
  161. package/dist/server/sitemap.d.ts +9 -0
  162. package/dist/server/sitemap.d.ts.map +1 -0
  163. package/dist/server/sitemap.js +26 -0
  164. package/dist/server/sitemap.js.map +1 -0
  165. package/dist/server/sitemap.test.d.ts +2 -0
  166. package/dist/server/sitemap.test.d.ts.map +1 -0
  167. package/dist/server/sitemap.test.js +61 -0
  168. package/dist/server/sitemap.test.js.map +1 -0
  169. package/dist/server/sse.d.ts +59 -0
  170. package/dist/server/sse.d.ts.map +1 -0
  171. package/dist/server/sse.js +91 -0
  172. package/dist/server/sse.js.map +1 -0
  173. package/dist/server/sse.test.d.ts +2 -0
  174. package/dist/server/sse.test.d.ts.map +1 -0
  175. package/dist/server/sse.test.js +68 -0
  176. package/dist/server/sse.test.js.map +1 -0
  177. package/dist/signals/index.d.ts +101 -0
  178. package/dist/signals/index.d.ts.map +1 -0
  179. package/dist/signals/index.js +149 -0
  180. package/dist/signals/index.js.map +1 -0
  181. package/dist/signals/signals.test.d.ts +2 -0
  182. package/dist/signals/signals.test.d.ts.map +1 -0
  183. package/dist/signals/signals.test.js +146 -0
  184. package/dist/signals/signals.test.js.map +1 -0
  185. package/dist/ssr/html.d.ts +27 -0
  186. package/dist/ssr/html.d.ts.map +1 -0
  187. package/dist/ssr/html.js +107 -0
  188. package/dist/ssr/html.js.map +1 -0
  189. package/dist/ssr/html.test.d.ts +2 -0
  190. package/dist/ssr/html.test.d.ts.map +1 -0
  191. package/dist/ssr/html.test.js +178 -0
  192. package/dist/ssr/html.test.js.map +1 -0
  193. package/dist/ssr/render.d.ts +46 -0
  194. package/dist/ssr/render.d.ts.map +1 -0
  195. package/dist/ssr/render.js +87 -0
  196. package/dist/ssr/render.js.map +1 -0
  197. package/dist/ssr/router-dev.d.ts +60 -0
  198. package/dist/ssr/router-dev.d.ts.map +1 -0
  199. package/dist/ssr/router-dev.js +205 -0
  200. package/dist/ssr/router-dev.js.map +1 -0
  201. package/dist/ssr/router-dev.test.d.ts +2 -0
  202. package/dist/ssr/router-dev.test.d.ts.map +1 -0
  203. package/dist/ssr/router-dev.test.js +189 -0
  204. package/dist/ssr/router-dev.test.js.map +1 -0
  205. package/dist/test/index.d.ts +93 -0
  206. package/dist/test/index.d.ts.map +1 -0
  207. package/dist/test/index.js +146 -0
  208. package/dist/test/index.js.map +1 -0
  209. package/dist/types/index.d.ts +117 -0
  210. package/dist/types/index.d.ts.map +1 -0
  211. package/dist/types/index.js +2 -0
  212. package/dist/types/index.js.map +1 -0
  213. package/dist/types/napi.d.ts +15 -0
  214. package/dist/types/napi.d.ts.map +1 -0
  215. package/dist/types/napi.js +2 -0
  216. package/dist/types/napi.js.map +1 -0
  217. package/package.json +107 -0
  218. package/src/adapters/cloudflare.ts +30 -0
  219. package/src/adapters/deno.ts +21 -0
  220. package/src/adapters/web.ts +259 -0
  221. package/src/cli.ts +68 -0
  222. package/src/client/hooks.test.ts +54 -0
  223. package/src/client/hooks.ts +329 -0
  224. package/src/client/index.ts +5 -0
  225. package/src/client/offline-sw.ts +191 -0
  226. package/src/client/offline.ts +114 -0
  227. package/src/client/provider.tsx +14 -0
  228. package/src/commands/build.ts +201 -0
  229. package/src/commands/dev.ts +509 -0
  230. package/src/commands/info.ts +111 -0
  231. package/src/commands/ssg.ts +177 -0
  232. package/src/commands/start.ts +32 -0
  233. package/src/commands/test.ts +102 -0
  234. package/src/components/ErrorBoundary.tsx +73 -0
  235. package/src/components/Font.tsx +100 -0
  236. package/src/components/Image.tsx +141 -0
  237. package/src/components/Link.tsx +64 -0
  238. package/src/components/Script.tsx +97 -0
  239. package/src/components/index.ts +9 -0
  240. package/src/i18n/i18n.test.tsx +169 -0
  241. package/src/i18n/index.tsx +256 -0
  242. package/src/index.ts +10 -0
  243. package/src/router/code-router.test.ts +146 -0
  244. package/src/router/code-router.tsx +459 -0
  245. package/src/router/index.ts +18 -0
  246. package/src/router/manifest.ts +13 -0
  247. package/src/server/app.ts +466 -0
  248. package/src/server/cache.test.ts +192 -0
  249. package/src/server/cache.ts +195 -0
  250. package/src/server/csrf.test.ts +199 -0
  251. package/src/server/csrf.ts +80 -0
  252. package/src/server/image.ts +112 -0
  253. package/src/server/index.ts +144 -0
  254. package/src/server/middleware.test.ts +151 -0
  255. package/src/server/middleware.ts +95 -0
  256. package/src/server/revalidate.test.ts +106 -0
  257. package/src/server/revalidate.ts +75 -0
  258. package/src/server/server-fn.test.ts +127 -0
  259. package/src/server/sitemap.test.ts +68 -0
  260. package/src/server/sitemap.ts +30 -0
  261. package/src/server/sse.test.ts +81 -0
  262. package/src/server/sse.ts +110 -0
  263. package/src/signals/index.ts +177 -0
  264. package/src/signals/signals.test.ts +164 -0
  265. package/src/ssr/html.test.ts +200 -0
  266. package/src/ssr/html.ts +140 -0
  267. package/src/ssr/render.ts +144 -0
  268. package/src/ssr/router-dev.test.ts +230 -0
  269. package/src/ssr/router-dev.ts +229 -0
  270. package/src/test/index.ts +206 -0
  271. package/src/types/compiler.d.ts +25 -0
  272. package/src/types/index.ts +147 -0
  273. package/src/types/napi.ts +20 -0
  274. package/src/types/plugins.d.ts +3 -0
  275. package/tsconfig.json +11 -0
  276. package/tsconfig.tsbuildinfo +1 -0
  277. package/vitest.config.ts +32 -0
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { signal, computed } from "./index.js";
3
+
4
+ // Note: useSignal, useSignalValue, useSignalSetter are React hooks that require
5
+ // a React component context. We test the non-hook primitives here (signal, computed)
6
+ // which contain the core reactivity logic (~70% of the module's surface area).
7
+
8
+ describe("signal", () => {
9
+ it("initialises with the given value", () => {
10
+ const s = signal(42);
11
+ expect(s.get()).toBe(42);
12
+ });
13
+
14
+ it("updates value via set()", () => {
15
+ const s = signal(0);
16
+ s.set(10);
17
+ expect(s.get()).toBe(10);
18
+ });
19
+
20
+ it("supports functional updates", () => {
21
+ const s = signal(5);
22
+ s.set((prev) => prev + 3);
23
+ expect(s.get()).toBe(8);
24
+ });
25
+
26
+ it("getSnapshot returns the current value", () => {
27
+ const s = signal("hello");
28
+ expect(s.getSnapshot()).toBe("hello");
29
+ s.set("world");
30
+ expect(s.getSnapshot()).toBe("world");
31
+ });
32
+
33
+ it("notifies subscribers on change", () => {
34
+ const s = signal(0);
35
+ let notified = false;
36
+ s.subscribe(() => {
37
+ notified = true;
38
+ });
39
+ s.set(1);
40
+ expect(notified).toBe(true);
41
+ });
42
+
43
+ it("does NOT notify when value is unchanged (Object.is)", () => {
44
+ const s = signal(42);
45
+ let callCount = 0;
46
+ s.subscribe(() => {
47
+ callCount++;
48
+ });
49
+ s.set(42); // same value
50
+ expect(callCount).toBe(0);
51
+ });
52
+
53
+ it("does NOT notify when functional update returns same value", () => {
54
+ const s = signal(42);
55
+ let callCount = 0;
56
+ s.subscribe(() => {
57
+ callCount++;
58
+ });
59
+ s.set((v) => v); // identity — same value
60
+ expect(callCount).toBe(0);
61
+ });
62
+
63
+ it("unsubscribe stops notifications", () => {
64
+ const s = signal(0);
65
+ let callCount = 0;
66
+ const unsub = s.subscribe(() => {
67
+ callCount++;
68
+ });
69
+ s.set(1);
70
+ expect(callCount).toBe(1);
71
+ unsub();
72
+ s.set(2);
73
+ expect(callCount).toBe(1); // still 1, not notified
74
+ });
75
+
76
+ it("supports multiple subscribers", () => {
77
+ const s = signal(0);
78
+ let a = 0;
79
+ let b = 0;
80
+ s.subscribe(() => { a++; });
81
+ s.subscribe(() => { b++; });
82
+ s.set(1);
83
+ expect(a).toBe(1);
84
+ expect(b).toBe(1);
85
+ });
86
+
87
+ it("handles null and undefined values", () => {
88
+ const s = signal<string | null>(null);
89
+ expect(s.get()).toBe(null);
90
+ s.set("hello");
91
+ expect(s.get()).toBe("hello");
92
+ s.set(null);
93
+ expect(s.get()).toBe(null);
94
+ });
95
+
96
+ it("handles object values", () => {
97
+ const s = signal({ name: "Ada" });
98
+ expect(s.get()).toEqual({ name: "Ada" });
99
+ const newObj = { name: "Bob" };
100
+ s.set(newObj);
101
+ expect(s.get()).toBe(newObj);
102
+ });
103
+ });
104
+
105
+ describe("computed", () => {
106
+ it("derives value from source signals", () => {
107
+ const a = signal(2);
108
+ const b = signal(3);
109
+ const sum = computed([a, b], ([x, y]) => (x as number) + (y as number));
110
+ expect(sum.get()).toBe(5);
111
+ });
112
+
113
+ it("recomputes when a source changes", () => {
114
+ const a = signal(2);
115
+ const b = signal(3);
116
+ const sum = computed([a, b], ([x, y]) => (x as number) + (y as number));
117
+ a.set(10);
118
+ expect(sum.get()).toBe(13);
119
+ });
120
+
121
+ it("notifies subscribers when derived value changes", () => {
122
+ const count = signal(0);
123
+ const doubled = computed([count], ([c]) => (c as number) * 2);
124
+ let notified = false;
125
+ doubled.subscribe(() => {
126
+ notified = true;
127
+ });
128
+ count.set(5);
129
+ expect(notified).toBe(true);
130
+ expect(doubled.get()).toBe(10);
131
+ });
132
+
133
+ it("does NOT notify when derived value is unchanged", () => {
134
+ const a = signal(3);
135
+ const isPositive = computed([a], ([v]) => (v as number) > 0);
136
+ expect(isPositive.get()).toBe(true);
137
+ let callCount = 0;
138
+ isPositive.subscribe(() => {
139
+ callCount++;
140
+ });
141
+ a.set(5); // still positive → derived value stays `true`
142
+ expect(callCount).toBe(0);
143
+ });
144
+
145
+ it("getSnapshot returns current derived value", () => {
146
+ const s = signal("hello");
147
+ const upper = computed([s], ([v]) => (v as string).toUpperCase());
148
+ expect(upper.getSnapshot()).toBe("HELLO");
149
+ });
150
+
151
+ it("supports single source signal", () => {
152
+ const name = signal("Ada");
153
+ const greeting = computed([name], ([n]) => `Hello, ${n as string}!`);
154
+ expect(greeting.get()).toBe("Hello, Ada!");
155
+ name.set("Bob");
156
+ expect(greeting.get()).toBe("Hello, Bob!");
157
+ });
158
+
159
+ it("does not expose set method", () => {
160
+ const s = signal(1);
161
+ const c = computed([s], ([v]) => v);
162
+ expect("set" in c).toBe(false);
163
+ });
164
+ });
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { htmlShellBefore, htmlShellAfter } from "./html.js";
3
+
4
+ describe("htmlShellBefore", () => {
5
+ const baseOpts = {
6
+ metadata: {},
7
+ paramsJson: "{}",
8
+ searchParamsJson: "{}",
9
+ routeFile: "app/page.tsx",
10
+ ssr: false,
11
+ };
12
+
13
+ it("produces valid HTML doctype and structure", () => {
14
+ const html = htmlShellBefore(baseOpts);
15
+ expect(html).toContain("<!doctype html>");
16
+ expect(html).toContain('<html lang="en">');
17
+ expect(html).toContain('<meta charset="UTF-8" />');
18
+ expect(html).toContain('<div id="alabjs-root">');
19
+ });
20
+
21
+ it("includes alabjs-route meta tag", () => {
22
+ const html = htmlShellBefore(baseOpts);
23
+ expect(html).toContain('<meta name="alabjs-route" content="app/page.tsx" />');
24
+ });
25
+
26
+ it("includes alabjs-ssr meta tag set to false", () => {
27
+ const html = htmlShellBefore(baseOpts);
28
+ expect(html).toContain('<meta name="alabjs-ssr" content="false" />');
29
+ });
30
+
31
+ it("includes alabjs-ssr meta tag set to true", () => {
32
+ const html = htmlShellBefore({ ...baseOpts, ssr: true });
33
+ expect(html).toContain('<meta name="alabjs-ssr" content="true" />');
34
+ });
35
+
36
+ it("embeds params JSON", () => {
37
+ const html = htmlShellBefore({
38
+ ...baseOpts,
39
+ paramsJson: '{"id":"42"}',
40
+ });
41
+ expect(html).toContain('<meta name="alabjs-params" content="{&quot;id&quot;:&quot;42&quot;}" />');
42
+ });
43
+
44
+ it("includes title tag when metadata has title", () => {
45
+ const html = htmlShellBefore({
46
+ ...baseOpts,
47
+ metadata: { title: "My Page" },
48
+ });
49
+ expect(html).toContain("<title>My Page</title>");
50
+ });
51
+
52
+ it("escapes HTML in title", () => {
53
+ const html = htmlShellBefore({
54
+ ...baseOpts,
55
+ metadata: { title: "<script>alert(1)</script>" },
56
+ });
57
+ expect(html).not.toContain("<script>alert(1)</script>");
58
+ expect(html).toContain("&lt;script&gt;");
59
+ });
60
+
61
+ it("includes description meta tag", () => {
62
+ const html = htmlShellBefore({
63
+ ...baseOpts,
64
+ metadata: { description: "A test page" },
65
+ });
66
+ expect(html).toContain('<meta name="description" content="A test page" />');
67
+ });
68
+
69
+ it("includes canonical link", () => {
70
+ const html = htmlShellBefore({
71
+ ...baseOpts,
72
+ metadata: { canonical: "https://example.com/page" },
73
+ });
74
+ expect(html).toContain('<link rel="canonical" href="https://example.com/page" />');
75
+ });
76
+
77
+ it("includes robots meta tag", () => {
78
+ const html = htmlShellBefore({
79
+ ...baseOpts,
80
+ metadata: { robots: "noindex, nofollow" },
81
+ });
82
+ expect(html).toContain('<meta name="robots" content="noindex, nofollow" />');
83
+ });
84
+
85
+ it("includes theme-color meta tag", () => {
86
+ const html = htmlShellBefore({
87
+ ...baseOpts,
88
+ metadata: { themeColor: "#ff6600" },
89
+ });
90
+ expect(html).toContain('<meta name="theme-color" content="#ff6600" />');
91
+ });
92
+
93
+ it("includes Open Graph tags", () => {
94
+ const html = htmlShellBefore({
95
+ ...baseOpts,
96
+ metadata: {
97
+ og: {
98
+ title: "OG Title",
99
+ description: "OG Desc",
100
+ image: "https://example.com/og.png",
101
+ url: "https://example.com",
102
+ type: "website",
103
+ siteName: "Example",
104
+ },
105
+ },
106
+ });
107
+ expect(html).toContain('<meta property="og:title" content="OG Title" />');
108
+ expect(html).toContain('<meta property="og:description" content="OG Desc" />');
109
+ expect(html).toContain('<meta property="og:image" content="https://example.com/og.png" />');
110
+ expect(html).toContain('<meta property="og:url" content="https://example.com" />');
111
+ expect(html).toContain('<meta property="og:type" content="website" />');
112
+ expect(html).toContain('<meta property="og:site_name" content="Example" />');
113
+ });
114
+
115
+ it("includes Twitter Card tags", () => {
116
+ const html = htmlShellBefore({
117
+ ...baseOpts,
118
+ metadata: {
119
+ twitter: {
120
+ card: "summary_large_image",
121
+ title: "TW Title",
122
+ description: "TW Desc",
123
+ image: "https://example.com/tw.png",
124
+ creator: "@alabjs",
125
+ },
126
+ },
127
+ });
128
+ expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />');
129
+ expect(html).toContain('<meta name="twitter:title" content="TW Title" />');
130
+ expect(html).toContain('<meta name="twitter:creator" content="@alabjs" />');
131
+ });
132
+
133
+ it("includes extra meta tags", () => {
134
+ const html = htmlShellBefore({
135
+ ...baseOpts,
136
+ metadata: {
137
+ extra: [
138
+ { name: "author", content: "AlabJS Team" },
139
+ { property: "article:author", content: "https://example.com" },
140
+ ],
141
+ },
142
+ });
143
+ expect(html).toContain('name="author"');
144
+ expect(html).toContain('content="AlabJS Team"');
145
+ expect(html).toContain('property="article:author"');
146
+ });
147
+
148
+ it("includes layouts meta tag when provided", () => {
149
+ const html = htmlShellBefore({
150
+ ...baseOpts,
151
+ layoutsJson: '["app/layout.tsx"]',
152
+ });
153
+ expect(html).toContain('name="alabjs-layouts"');
154
+ });
155
+
156
+ it("includes loading meta tag when provided", () => {
157
+ const html = htmlShellBefore({
158
+ ...baseOpts,
159
+ loadingFile: "app/loading.tsx",
160
+ });
161
+ expect(html).toContain('<meta name="alabjs-loading" content="app/loading.tsx" />');
162
+ });
163
+
164
+ it("includes headExtra content", () => {
165
+ const html = htmlShellBefore({
166
+ ...baseOpts,
167
+ headExtra: '<meta name="csrf-token" content="abc123" />',
168
+ });
169
+ expect(html).toContain('<meta name="csrf-token" content="abc123" />');
170
+ });
171
+
172
+ it("includes globals.css link", () => {
173
+ const html = htmlShellBefore(baseOpts);
174
+ expect(html).toContain('<link rel="stylesheet" href="/app/globals.css" />');
175
+ });
176
+ });
177
+
178
+ describe("htmlShellAfter", () => {
179
+ it("closes the alabjs-root div and body/html", () => {
180
+ const html = htmlShellAfter({});
181
+ expect(html).toContain("</div>");
182
+ expect(html).toContain("</body>");
183
+ expect(html).toContain("</html>");
184
+ });
185
+
186
+ it("includes the client script tag", () => {
187
+ const html = htmlShellAfter({});
188
+ expect(html).toContain('<script type="module" src="/@alabjs/client">');
189
+ });
190
+
191
+ it("includes nonce when provided", () => {
192
+ const html = htmlShellAfter({ nonce: "abc123" });
193
+ expect(html).toContain('nonce="abc123"');
194
+ });
195
+
196
+ it("escapes quotes in nonce", () => {
197
+ const html = htmlShellAfter({ nonce: 'test"nonce' });
198
+ expect(html).toContain("&quot;");
199
+ });
200
+ });
@@ -0,0 +1,140 @@
1
+ import type { PageMetadata } from "../types/index.js";
2
+
3
+ export interface HtmlShellOptions {
4
+ metadata: PageMetadata;
5
+ /** Serialised params JSON string to embed in the page for client hydration. */
6
+ paramsJson: string;
7
+ /** Serialised search-params JSON string. */
8
+ searchParamsJson: string;
9
+ /** Relative path to the page module (e.g. `app/users/[id]/page.tsx`). */
10
+ routeFile: string;
11
+ /** Whether SSR is enabled for this route. */
12
+ ssr: boolean;
13
+ /** JSON array of layout file paths (relative to cwd), outermost first. */
14
+ layoutsJson?: string | undefined;
15
+ /** Relative path to the nearest loading.tsx (for client-side Suspense fallback). */
16
+ loadingFile?: string | undefined;
17
+ /** Extra content injected into <head> (used by Vite to insert HMR scripts). */
18
+ headExtra?: string | undefined;
19
+ /** Nonce for CSP inline scripts (optional). */
20
+ nonce?: string | undefined;
21
+ }
22
+
23
+ /** Build the opening HTML fragment — everything up to and including `<div id="alabjs-root">`. */
24
+ export function htmlShellBefore(opts: HtmlShellOptions): string {
25
+ const {
26
+ metadata,
27
+ paramsJson,
28
+ searchParamsJson,
29
+ routeFile,
30
+ ssr,
31
+ layoutsJson,
32
+ loadingFile,
33
+ headExtra = "",
34
+ } = opts;
35
+
36
+ const titleTag = metadata.title
37
+ ? `<title>${escHtml(metadata.title)}</title>`
38
+ : "";
39
+
40
+ const descTag = metadata.description
41
+ ? `<meta name="description" content="${escAttr(metadata.description)}" />`
42
+ : "";
43
+
44
+ const canonicalTag = metadata.canonical
45
+ ? `<link rel="canonical" href="${escAttr(metadata.canonical)}" />`
46
+ : "";
47
+
48
+ const robotsTag = metadata.robots
49
+ ? `<meta name="robots" content="${escAttr(metadata.robots)}" />`
50
+ : "";
51
+
52
+ const themeColorTag = metadata.themeColor
53
+ ? `<meta name="theme-color" content="${escAttr(metadata.themeColor)}" />`
54
+ : "";
55
+
56
+ const ogTags = metadata.og ? buildOgTags(metadata.og) : "";
57
+ const twitterTags = metadata.twitter ? buildTwitterTags(metadata.twitter) : "";
58
+ const extraTags = metadata.extra ? buildExtraTags(metadata.extra) : "";
59
+
60
+ return `<!doctype html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="UTF-8" />
64
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
65
+ ${titleTag}
66
+ ${descTag}
67
+ ${canonicalTag}
68
+ ${robotsTag}
69
+ ${themeColorTag}
70
+ ${ogTags}
71
+ ${twitterTags}
72
+ ${extraTags}
73
+ <meta name="alabjs-route" content="${escAttr(routeFile)}" />
74
+ <meta name="alabjs-ssr" content="${ssr ? "true" : "false"}" />
75
+ <meta name="alabjs-params" content="${escAttr(paramsJson)}" />
76
+ <meta name="alabjs-search-params" content="${escAttr(searchParamsJson)}" />
77
+ ${layoutsJson ? `<meta name="alabjs-layouts" content="${escAttr(layoutsJson)}" />` : ""}
78
+ ${loadingFile ? `<meta name="alabjs-loading" content="${escAttr(loadingFile)}" />` : ""}
79
+ <link rel="stylesheet" href="/app/globals.css" />
80
+ ${headExtra}
81
+ </head>
82
+ <body>
83
+ <div id="alabjs-root">`;
84
+ }
85
+
86
+ /** Build the closing HTML fragment — everything after the SSR content. */
87
+ export function htmlShellAfter(opts: { nonce?: string | undefined }): string {
88
+ const nonceAttr = opts.nonce ? ` nonce="${escAttr(opts.nonce)}"` : "";
89
+ return `</div>
90
+ <script type="module" src="/@alabjs/client"${nonceAttr}></script>
91
+ </body>
92
+ </html>`;
93
+ }
94
+
95
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
96
+
97
+ function escHtml(s: string): string {
98
+ return s
99
+ .replace(/&/g, "&amp;")
100
+ .replace(/</g, "&lt;")
101
+ .replace(/>/g, "&gt;");
102
+ }
103
+
104
+ function escAttr(s: string): string {
105
+ return s
106
+ .replace(/&/g, "&amp;")
107
+ .replace(/"/g, "&quot;");
108
+ }
109
+
110
+ function buildOgTags(og: NonNullable<PageMetadata["og"]>): string {
111
+ const tags: string[] = [];
112
+ if (og.title) tags.push(`<meta property="og:title" content="${escAttr(og.title)}" />`);
113
+ if (og.description) tags.push(`<meta property="og:description" content="${escAttr(og.description)}" />`);
114
+ if (og.image) tags.push(`<meta property="og:image" content="${escAttr(og.image)}" />`);
115
+ if (og.url) tags.push(`<meta property="og:url" content="${escAttr(og.url)}" />`);
116
+ if (og.type) tags.push(`<meta property="og:type" content="${escAttr(og.type)}" />`);
117
+ if (og.siteName) tags.push(`<meta property="og:site_name" content="${escAttr(og.siteName)}" />`);
118
+ return tags.join("\n ");
119
+ }
120
+
121
+ function buildTwitterTags(tw: NonNullable<PageMetadata["twitter"]>): string {
122
+ const tags: string[] = [];
123
+ if (tw.card) tags.push(`<meta name="twitter:card" content="${escAttr(tw.card)}" />`);
124
+ if (tw.title) tags.push(`<meta name="twitter:title" content="${escAttr(tw.title)}" />`);
125
+ if (tw.description) tags.push(`<meta name="twitter:description" content="${escAttr(tw.description)}" />`);
126
+ if (tw.image) tags.push(`<meta name="twitter:image" content="${escAttr(tw.image)}" />`);
127
+ if (tw.creator) tags.push(`<meta name="twitter:creator" content="${escAttr(tw.creator)}" />`);
128
+ return tags.join("\n ");
129
+ }
130
+
131
+ function buildExtraTags(extra: NonNullable<PageMetadata["extra"]>): string {
132
+ return extra
133
+ .map((attrs) => {
134
+ const attrStr = Object.entries(attrs)
135
+ .map(([k, v]) => `${k}="${escAttr(v)}"`)
136
+ .join(" ");
137
+ return `<meta ${attrStr} />`;
138
+ })
139
+ .join("\n ");
140
+ }
@@ -0,0 +1,144 @@
1
+ import { createElement, type ComponentType } from "react";
2
+ import { renderToPipeableStream } from "react-dom/server";
3
+ import { Writable } from "node:stream";
4
+ import type { ServerResponse } from "node:http";
5
+ import { htmlShellBefore, htmlShellAfter, type HtmlShellOptions } from "./html.js";
6
+ import type { PageMetadata } from "../types/index.js";
7
+
8
+ export interface RenderOptions {
9
+ /** The React page component. */
10
+ Page: ComponentType<{ params: Record<string, string>; searchParams: Record<string, string> }>;
11
+ /** Layout components to wrap the page, ordered outermost → innermost. */
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ layouts?: ComponentType<any>[];
14
+ /** Parsed route params (e.g. `{ id: "123" }`). */
15
+ params: Record<string, string>;
16
+ /** Parsed query params. */
17
+ searchParams: Record<string, string>;
18
+ /** Metadata exported by the page module (`export const metadata = ...`). */
19
+ metadata?: PageMetadata;
20
+ /** Relative path to the page module, embedded in the HTML for client hydration. */
21
+ routeFile: string;
22
+ /** JSON array of layout file paths for client-side hydration. */
23
+ layoutsJson?: string;
24
+ /** Relative path to nearest loading.tsx, for client Suspense fallback. */
25
+ loadingFile?: string | undefined;
26
+ /** Whether SSR is enabled for this route. */
27
+ ssr: boolean;
28
+ /** Extra HTML to inject into <head> (Vite injects its HMR tags here in dev). */
29
+ headExtra?: string;
30
+ /** CSP nonce (optional). */
31
+ nonce?: string;
32
+ }
33
+
34
+ /**
35
+ * Render a page component to a streaming HTTP response using React 19's
36
+ * `renderToPipeableStream`. The HTML shell is split at the `<div id="alab-root">`
37
+ * boundary: the opening fragment is flushed before React begins streaming, and
38
+ * the closing fragment is appended when the stream finishes.
39
+ */
40
+ export function renderToResponse(res: ServerResponse, opts: RenderOptions): void {
41
+ const {
42
+ Page,
43
+ layouts = [],
44
+ params,
45
+ searchParams,
46
+ metadata = {},
47
+ routeFile,
48
+ layoutsJson,
49
+ loadingFile,
50
+ ssr,
51
+ headExtra,
52
+ nonce,
53
+ } = opts;
54
+
55
+ const shellOpts: HtmlShellOptions = {
56
+ metadata,
57
+ paramsJson: JSON.stringify(params),
58
+ searchParamsJson: JSON.stringify(searchParams),
59
+ routeFile,
60
+ layoutsJson,
61
+ loadingFile,
62
+ ssr,
63
+ headExtra,
64
+ nonce,
65
+ };
66
+
67
+ const before = htmlShellBefore(shellOpts);
68
+ const after = htmlShellAfter({ nonce });
69
+
70
+ // Build element tree: Page wrapped by layouts outermost→innermost
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ const pageEl = createElement(Page, { params, searchParams }) as any;
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ const rootEl: any = layouts.length
75
+ ? layouts.reduceRight((child, Layout) => createElement(Layout, {}, child), pageEl)
76
+ : pageEl;
77
+
78
+ let didError = false;
79
+ let headersSent = false;
80
+
81
+ const { pipe, abort } = renderToPipeableStream(
82
+ rootEl,
83
+ {
84
+ onShellReady() {
85
+ if (headersSent) return;
86
+ headersSent = true;
87
+
88
+ res.statusCode = didError ? 500 : 200;
89
+ res.setHeader("content-type", "text/html; charset=utf-8");
90
+ res.write(before);
91
+
92
+ // Wrap the Node.js response in a Writable that appends the closing
93
+ // shell fragment once React finishes streaming.
94
+ const writable = new Writable({
95
+ write(chunk: Buffer, _enc, cb) {
96
+ res.write(chunk, cb);
97
+ },
98
+ final(cb) {
99
+ res.write(after);
100
+ res.end();
101
+ cb();
102
+ },
103
+ });
104
+
105
+ pipe(writable);
106
+ },
107
+
108
+ onShellError(err) {
109
+ // React couldn't render even the shell boundary — send a plain error page.
110
+ if (!headersSent) {
111
+ headersSent = true;
112
+ res.statusCode = 500;
113
+ res.setHeader("content-type", "text/plain; charset=utf-8");
114
+ const msg = err instanceof Error ? err.message : String(err);
115
+ res.end(`[alabjs] SSR shell error in ${routeFile}: ${msg}`);
116
+ }
117
+ console.error(`[alabjs] SSR shell error in ${routeFile}:`, err);
118
+ },
119
+
120
+ onError(err) {
121
+ didError = true;
122
+ console.error(`[alabjs] SSR component error in ${routeFile}:`, err);
123
+ },
124
+ },
125
+ );
126
+
127
+ // Abort the stream if the client disconnects early.
128
+ res.on("close", () => {
129
+ if (!res.writableEnded) abort();
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Render a page component to a full HTML string.
135
+ * Used in dev mode when streaming is not needed (faster iteration).
136
+ */
137
+ export async function renderToString(
138
+ Page: ComponentType<{ params: Record<string, string>; searchParams: Record<string, string> }>,
139
+ params: Record<string, string>,
140
+ searchParams: Record<string, string>,
141
+ ): Promise<string> {
142
+ const { renderToString: reactRenderToString } = await import("react-dom/server");
143
+ return reactRenderToString(createElement(Page, { params, searchParams }));
144
+ }