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.
- package/dist/adapters/cloudflare.d.ts +31 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +30 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +22 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +21 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/web.d.ts +47 -0
- package/dist/adapters/web.d.ts.map +1 -0
- package/dist/adapters/web.js +212 -0
- package/dist/adapters/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/hooks.d.ts +119 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +220 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/hooks.test.d.ts +2 -0
- package/dist/client/hooks.test.d.ts.map +1 -0
- package/dist/client/hooks.test.js +45 -0
- package/dist/client/hooks.test.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/offline.d.ts +52 -0
- package/dist/client/offline.d.ts.map +1 -0
- package/dist/client/offline.js +90 -0
- package/dist/client/offline.js.map +1 -0
- package/dist/client/provider.d.ts +12 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +10 -0
- package/dist/client/provider.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +173 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +447 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/info.d.ts +6 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/ssg.d.ts +8 -0
- package/dist/commands/ssg.d.ts.map +1 -0
- package/dist/commands/ssg.js +124 -0
- package/dist/commands/ssg.js.map +1 -0
- package/dist/commands/start.d.ts +7 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +26 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/test.d.ts +24 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +87 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +38 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +46 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/Font.d.ts +57 -0
- package/dist/components/Font.d.ts.map +1 -0
- package/dist/components/Font.js +33 -0
- package/dist/components/Font.js.map +1 -0
- package/dist/components/Image.d.ts +74 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +85 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Link.d.ts +23 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +48 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Script.d.ts +37 -0
- package/dist/components/Script.d.ts.map +1 -0
- package/dist/components/Script.js +70 -0
- package/dist/components/Script.js.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +132 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +135 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/router/code-router.d.ts +204 -0
- package/dist/router/code-router.d.ts.map +1 -0
- package/dist/router/code-router.js +258 -0
- package/dist/router/code-router.js.map +1 -0
- package/dist/router/code-router.test.d.ts +2 -0
- package/dist/router/code-router.test.d.ts.map +1 -0
- package/dist/router/code-router.test.js +128 -0
- package/dist/router/code-router.test.js.map +1 -0
- package/dist/router/index.d.ts +4 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +2 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +2 -0
- package/dist/router/manifest.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +407 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cache.d.ts +99 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +161 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/cache.test.d.ts +2 -0
- package/dist/server/cache.test.d.ts.map +1 -0
- package/dist/server/cache.test.js +150 -0
- package/dist/server/cache.test.js.map +1 -0
- package/dist/server/csrf.d.ts +28 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/csrf.js +66 -0
- package/dist/server/csrf.js.map +1 -0
- package/dist/server/csrf.test.d.ts +2 -0
- package/dist/server/csrf.test.d.ts.map +1 -0
- package/dist/server/csrf.test.js +154 -0
- package/dist/server/csrf.test.js.map +1 -0
- package/dist/server/image.d.ts +18 -0
- package/dist/server/image.d.ts.map +1 -0
- package/dist/server/image.js +97 -0
- package/dist/server/image.js.map +1 -0
- package/dist/server/index.d.ts +57 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +58 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware.d.ts +53 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +80 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/middleware.test.d.ts +2 -0
- package/dist/server/middleware.test.d.ts.map +1 -0
- package/dist/server/middleware.test.js +125 -0
- package/dist/server/middleware.test.js.map +1 -0
- package/dist/server/revalidate.d.ts +49 -0
- package/dist/server/revalidate.d.ts.map +1 -0
- package/dist/server/revalidate.js +62 -0
- package/dist/server/revalidate.js.map +1 -0
- package/dist/server/revalidate.test.d.ts +2 -0
- package/dist/server/revalidate.test.d.ts.map +1 -0
- package/dist/server/revalidate.test.js +93 -0
- package/dist/server/revalidate.test.js.map +1 -0
- package/dist/server/server-fn.test.d.ts +2 -0
- package/dist/server/server-fn.test.d.ts.map +1 -0
- package/dist/server/server-fn.test.js +105 -0
- package/dist/server/server-fn.test.js.map +1 -0
- package/dist/server/sitemap.d.ts +9 -0
- package/dist/server/sitemap.d.ts.map +1 -0
- package/dist/server/sitemap.js +26 -0
- package/dist/server/sitemap.js.map +1 -0
- package/dist/server/sitemap.test.d.ts +2 -0
- package/dist/server/sitemap.test.d.ts.map +1 -0
- package/dist/server/sitemap.test.js +61 -0
- package/dist/server/sitemap.test.js.map +1 -0
- package/dist/server/sse.d.ts +59 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +91 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/sse.test.d.ts +2 -0
- package/dist/server/sse.test.d.ts.map +1 -0
- package/dist/server/sse.test.js +68 -0
- package/dist/server/sse.test.js.map +1 -0
- package/dist/signals/index.d.ts +101 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +149 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/signals.test.d.ts +2 -0
- package/dist/signals/signals.test.d.ts.map +1 -0
- package/dist/signals/signals.test.js +146 -0
- package/dist/signals/signals.test.js.map +1 -0
- package/dist/ssr/html.d.ts +27 -0
- package/dist/ssr/html.d.ts.map +1 -0
- package/dist/ssr/html.js +107 -0
- package/dist/ssr/html.js.map +1 -0
- package/dist/ssr/html.test.d.ts +2 -0
- package/dist/ssr/html.test.d.ts.map +1 -0
- package/dist/ssr/html.test.js +178 -0
- package/dist/ssr/html.test.js.map +1 -0
- package/dist/ssr/render.d.ts +46 -0
- package/dist/ssr/render.d.ts.map +1 -0
- package/dist/ssr/render.js +87 -0
- package/dist/ssr/render.js.map +1 -0
- package/dist/ssr/router-dev.d.ts +60 -0
- package/dist/ssr/router-dev.d.ts.map +1 -0
- package/dist/ssr/router-dev.js +205 -0
- package/dist/ssr/router-dev.js.map +1 -0
- package/dist/ssr/router-dev.test.d.ts +2 -0
- package/dist/ssr/router-dev.test.d.ts.map +1 -0
- package/dist/ssr/router-dev.test.js +189 -0
- package/dist/ssr/router-dev.test.js.map +1 -0
- package/dist/test/index.d.ts +93 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +146 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/napi.d.ts +15 -0
- package/dist/types/napi.d.ts.map +1 -0
- package/dist/types/napi.js +2 -0
- package/dist/types/napi.js.map +1 -0
- package/package.json +107 -0
- package/src/adapters/cloudflare.ts +30 -0
- package/src/adapters/deno.ts +21 -0
- package/src/adapters/web.ts +259 -0
- package/src/cli.ts +68 -0
- package/src/client/hooks.test.ts +54 -0
- package/src/client/hooks.ts +329 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline-sw.ts +191 -0
- package/src/client/offline.ts +114 -0
- package/src/client/provider.tsx +14 -0
- package/src/commands/build.ts +201 -0
- package/src/commands/dev.ts +509 -0
- package/src/commands/info.ts +111 -0
- package/src/commands/ssg.ts +177 -0
- package/src/commands/start.ts +32 -0
- package/src/commands/test.ts +102 -0
- package/src/components/ErrorBoundary.tsx +73 -0
- package/src/components/Font.tsx +100 -0
- package/src/components/Image.tsx +141 -0
- package/src/components/Link.tsx +64 -0
- package/src/components/Script.tsx +97 -0
- package/src/components/index.ts +9 -0
- package/src/i18n/i18n.test.tsx +169 -0
- package/src/i18n/index.tsx +256 -0
- package/src/index.ts +10 -0
- package/src/router/code-router.test.ts +146 -0
- package/src/router/code-router.tsx +459 -0
- package/src/router/index.ts +18 -0
- package/src/router/manifest.ts +13 -0
- package/src/server/app.ts +466 -0
- package/src/server/cache.test.ts +192 -0
- package/src/server/cache.ts +195 -0
- package/src/server/csrf.test.ts +199 -0
- package/src/server/csrf.ts +80 -0
- package/src/server/image.ts +112 -0
- package/src/server/index.ts +144 -0
- package/src/server/middleware.test.ts +151 -0
- package/src/server/middleware.ts +95 -0
- package/src/server/revalidate.test.ts +106 -0
- package/src/server/revalidate.ts +75 -0
- package/src/server/server-fn.test.ts +127 -0
- package/src/server/sitemap.test.ts +68 -0
- package/src/server/sitemap.ts +30 -0
- package/src/server/sse.test.ts +81 -0
- package/src/server/sse.ts +110 -0
- package/src/signals/index.ts +177 -0
- package/src/signals/signals.test.ts +164 -0
- package/src/ssr/html.test.ts +200 -0
- package/src/ssr/html.ts +140 -0
- package/src/ssr/render.ts +144 -0
- package/src/ssr/router-dev.test.ts +230 -0
- package/src/ssr/router-dev.ts +229 -0
- package/src/test/index.ts +206 -0
- package/src/types/compiler.d.ts +25 -0
- package/src/types/index.ts +147 -0
- package/src/types/napi.ts +20 -0
- package/src/types/plugins.d.ts +3 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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="{"id":"42"}" />');
|
|
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("<script>");
|
|
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(""");
|
|
199
|
+
});
|
|
200
|
+
});
|
package/src/ssr/html.ts
ADDED
|
@@ -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, "&")
|
|
100
|
+
.replace(/</g, "<")
|
|
101
|
+
.replace(/>/g, ">");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function escAttr(s: string): string {
|
|
105
|
+
return s
|
|
106
|
+
.replace(/&/g, "&")
|
|
107
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|