@tschk/moonshine-react 0.3.1
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/README.md +20 -0
- package/package.json +42 -0
- package/src/client.tsx +158 -0
- package/src/index.ts +30 -0
- package/src/islands.tsx +113 -0
- package/src/renderer.tsx +147 -0
- package/src/serialize.ts +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# `@tschk/moonshine-react`
|
|
2
|
+
|
|
3
|
+
React renderer, islands, hydration, and signal hooks for Moonshine.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createApp, useSignal, createSignal } from "@tschk/moonshine-react";
|
|
7
|
+
import { island, hydrateIslands } from "@tschk/moonshine-react";
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
|
|
12
|
+
- `reactRenderer` implements the `Renderer` contract for SSR and streaming.
|
|
13
|
+
- `island` / `hydrateIslands` for partial hydration.
|
|
14
|
+
- `createApp`, `useSignal`, `useResource`, `createIslandSignal`.
|
|
15
|
+
- `serializeIslandProps` for server island props.
|
|
16
|
+
|
|
17
|
+
Peer dependencies: `react` and `react-dom` >= 18.
|
|
18
|
+
|
|
19
|
+
See [docs/MANIFEST.md](../../docs/MANIFEST.md) and
|
|
20
|
+
[DESIGN.md](../../DESIGN.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tschk/moonshine-react",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "React renderer, islands, and signal hooks for Moonshine",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"format": "bunx prettier --check .",
|
|
14
|
+
"lint": "bunx oxlint .",
|
|
15
|
+
"typecheck": "bunx tsc --noEmit",
|
|
16
|
+
"test": "bun test"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@tschk/moonshine": "^0.3.1",
|
|
20
|
+
"@tschk/moonshine-framework": "^0.3.1",
|
|
21
|
+
"react": "^19.1.0",
|
|
22
|
+
"react-dom": "^19.1.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "1.3.14",
|
|
26
|
+
"@types/react": "^19.1.0",
|
|
27
|
+
"@types/react-dom": "^19.1.0",
|
|
28
|
+
"typescript": "7.0.2"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"keywords": [
|
|
36
|
+
"moonshine",
|
|
37
|
+
"react",
|
|
38
|
+
"renderer",
|
|
39
|
+
"islands",
|
|
40
|
+
"signals"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createElement,
|
|
3
|
+
useSyncExternalStore,
|
|
4
|
+
type ComponentType,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
8
|
+
import {
|
|
9
|
+
batch,
|
|
10
|
+
createMemo,
|
|
11
|
+
createSignal,
|
|
12
|
+
createStore,
|
|
13
|
+
getStoreRoot,
|
|
14
|
+
untrack,
|
|
15
|
+
type Memo,
|
|
16
|
+
type Signal,
|
|
17
|
+
type StoreSetter,
|
|
18
|
+
} from "@tschk/moonshine";
|
|
19
|
+
import {
|
|
20
|
+
createResource,
|
|
21
|
+
type CreateResourceOptions,
|
|
22
|
+
type Resource,
|
|
23
|
+
type ResourceStatus,
|
|
24
|
+
} from "@tschk/moonshine";
|
|
25
|
+
|
|
26
|
+
export type MoonshineAppOptions = {
|
|
27
|
+
root: ComponentType;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type MoonshineApp = {
|
|
32
|
+
mount: (container: string | Element) => void;
|
|
33
|
+
unmount: () => void;
|
|
34
|
+
readonly mounted: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createMoonshineApp(options: MoonshineAppOptions): MoonshineApp {
|
|
38
|
+
let reactRoot: Root | null = null;
|
|
39
|
+
let host: Element | null = null;
|
|
40
|
+
let mounted = false;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
get mounted() {
|
|
44
|
+
return mounted;
|
|
45
|
+
},
|
|
46
|
+
mount(container) {
|
|
47
|
+
if (mounted) {
|
|
48
|
+
throw new Error("createMoonshineApp: already mounted");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const el =
|
|
52
|
+
typeof container === "string"
|
|
53
|
+
? document.querySelector(container)
|
|
54
|
+
: container;
|
|
55
|
+
if (!el) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`createMoonshineApp: mount target not found: ${String(container)}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
host = el;
|
|
62
|
+
if (options.className) {
|
|
63
|
+
el.classList.add(...options.className.split(/\s+/).filter(Boolean));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reactRoot = createRoot(el);
|
|
67
|
+
reactRoot.render(<options.root />);
|
|
68
|
+
mounted = true;
|
|
69
|
+
},
|
|
70
|
+
unmount() {
|
|
71
|
+
if (!mounted || !reactRoot) return;
|
|
72
|
+
reactRoot.unmount();
|
|
73
|
+
reactRoot = null;
|
|
74
|
+
if (host && options.className) {
|
|
75
|
+
for (const cls of options.className.split(/\s+/).filter(Boolean)) {
|
|
76
|
+
host.classList.remove(cls);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
host = null;
|
|
80
|
+
mounted = false;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const createApp = createMoonshineApp;
|
|
86
|
+
|
|
87
|
+
type Subscribable<T> = (() => T) & {
|
|
88
|
+
subscribe: (listener: () => void) => () => void;
|
|
89
|
+
peek?: () => T;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function useSignal<T>(
|
|
93
|
+
source: Subscribable<T>,
|
|
94
|
+
getServerSnapshot?: () => T,
|
|
95
|
+
): T {
|
|
96
|
+
const subscribe = (onStoreChange: () => void) =>
|
|
97
|
+
source.subscribe(onStoreChange);
|
|
98
|
+
const getSnapshot = () => source();
|
|
99
|
+
const server =
|
|
100
|
+
getServerSnapshot ??
|
|
101
|
+
(() =>
|
|
102
|
+
typeof source.peek === "function"
|
|
103
|
+
? (source.peek as () => T)()
|
|
104
|
+
: source());
|
|
105
|
+
return useSyncExternalStore(subscribe, getSnapshot, server);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function useStore<T extends object>(store: T): T {
|
|
109
|
+
const node = getStoreRoot(store as object);
|
|
110
|
+
if (!node) {
|
|
111
|
+
throw new Error("useStore: expected a createStore() proxy");
|
|
112
|
+
}
|
|
113
|
+
useSyncExternalStore(
|
|
114
|
+
node.subscribe,
|
|
115
|
+
() => store,
|
|
116
|
+
() => store,
|
|
117
|
+
);
|
|
118
|
+
return store;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useResource<T>(resource: Resource<T>): {
|
|
122
|
+
data: T | undefined;
|
|
123
|
+
loading: boolean;
|
|
124
|
+
error: Error | undefined;
|
|
125
|
+
status: ReturnType<Resource<T>["status"]>;
|
|
126
|
+
refetch: () => Promise<T | undefined>;
|
|
127
|
+
} {
|
|
128
|
+
const data = useSignal(resource);
|
|
129
|
+
const loading = useSignal(resource.loading);
|
|
130
|
+
const error = useSignal(resource.error);
|
|
131
|
+
const status = useSignal(resource.status);
|
|
132
|
+
return { data, loading, error, status, refetch: resource.refetch };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createIslandSignal<T>(initial: T): Signal<T> {
|
|
136
|
+
return createSignal(initial);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderToNode(root: ComponentType): ReactNode {
|
|
140
|
+
return createElement(root);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
batch,
|
|
145
|
+
createMemo,
|
|
146
|
+
createResource,
|
|
147
|
+
createSignal,
|
|
148
|
+
createStore,
|
|
149
|
+
untrack,
|
|
150
|
+
};
|
|
151
|
+
export type {
|
|
152
|
+
CreateResourceOptions,
|
|
153
|
+
Memo,
|
|
154
|
+
Resource,
|
|
155
|
+
ResourceStatus,
|
|
156
|
+
Signal,
|
|
157
|
+
StoreSetter,
|
|
158
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { reactRenderer } from "./renderer";
|
|
2
|
+
export type { RenderContext, Renderer } from "@tschk/moonshine-framework";
|
|
3
|
+
export {
|
|
4
|
+
batch,
|
|
5
|
+
createApp,
|
|
6
|
+
createIslandSignal,
|
|
7
|
+
createMemo,
|
|
8
|
+
createMoonshineApp,
|
|
9
|
+
createResource,
|
|
10
|
+
createSignal,
|
|
11
|
+
createStore,
|
|
12
|
+
renderToNode,
|
|
13
|
+
untrack,
|
|
14
|
+
useResource,
|
|
15
|
+
useSignal,
|
|
16
|
+
useStore,
|
|
17
|
+
} from "./client";
|
|
18
|
+
export type {
|
|
19
|
+
CreateResourceOptions,
|
|
20
|
+
Memo,
|
|
21
|
+
MoonshineApp,
|
|
22
|
+
MoonshineAppOptions,
|
|
23
|
+
Resource,
|
|
24
|
+
ResourceStatus,
|
|
25
|
+
Signal,
|
|
26
|
+
StoreSetter,
|
|
27
|
+
} from "./client";
|
|
28
|
+
export { island, hydrateIslands } from "./islands";
|
|
29
|
+
export type { IslandEntry, IslandRegistry } from "./islands";
|
|
30
|
+
export { serializeIslandProps } from "./serialize";
|
package/src/islands.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
Fragment,
|
|
5
|
+
use,
|
|
6
|
+
useContext,
|
|
7
|
+
type ComponentType,
|
|
8
|
+
type FunctionComponent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { serializeIslandProps } from "./serialize";
|
|
12
|
+
|
|
13
|
+
export type IslandEntry = {
|
|
14
|
+
id: string;
|
|
15
|
+
specifier: string;
|
|
16
|
+
props: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type IslandRegistry = {
|
|
20
|
+
entries: Map<string, IslandEntry>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const IslandContext = createContext<IslandRegistry | null>(null);
|
|
24
|
+
|
|
25
|
+
const ISLAND_IMPORT_RE = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
26
|
+
|
|
27
|
+
function extractSpecifier(loader: () => Promise<unknown>): string {
|
|
28
|
+
const source = loader.toString();
|
|
29
|
+
const match = source.match(ISLAND_IMPORT_RE);
|
|
30
|
+
return match ? match[1]! : source;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hashString(input: string): string {
|
|
34
|
+
let hash = 2166136261;
|
|
35
|
+
for (let i = 0; i < input.length; i++) {
|
|
36
|
+
hash ^= input.charCodeAt(i);
|
|
37
|
+
hash +=
|
|
38
|
+
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
39
|
+
hash >>>= 0;
|
|
40
|
+
}
|
|
41
|
+
return hash.toString(36);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getIslandId(loader: () => Promise<unknown>): string {
|
|
45
|
+
return `moonshine-island-${hashString(extractSpecifier(loader))}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const IslandProvider: FunctionComponent<{
|
|
49
|
+
registry: IslandRegistry;
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
}> = ({ registry, children }) => {
|
|
52
|
+
return createElement(IslandContext.Provider, { value: registry }, children);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function island<P>(
|
|
56
|
+
loader: () => Promise<ComponentType<P> | { default: ComponentType<P> }>,
|
|
57
|
+
): FunctionComponent<P> {
|
|
58
|
+
const id = getIslandId(loader);
|
|
59
|
+
const specifier = extractSpecifier(loader);
|
|
60
|
+
const IslandComponent: FunctionComponent<P> = (props) => {
|
|
61
|
+
const registry = useContext(IslandContext);
|
|
62
|
+
const mod = use(loader());
|
|
63
|
+
const Component = ((mod as { default?: ComponentType<P> }).default ??
|
|
64
|
+
mod) as FunctionComponent<P>;
|
|
65
|
+
const children = createElement(Component as any, props as any);
|
|
66
|
+
if (registry) {
|
|
67
|
+
registry.entries.set(id, {
|
|
68
|
+
id,
|
|
69
|
+
specifier,
|
|
70
|
+
props: (props ?? {}) as Record<string, unknown>,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return createElement(
|
|
74
|
+
Fragment,
|
|
75
|
+
null,
|
|
76
|
+
createElement("div", { "data-moonshine-island": id }, children),
|
|
77
|
+
createElement("script", {
|
|
78
|
+
type: "application/json",
|
|
79
|
+
"data-moonshine-island-props": id,
|
|
80
|
+
dangerouslySetInnerHTML: {
|
|
81
|
+
__html: serializeIslandProps(props),
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
return IslandComponent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function hydrateIslands(): void {
|
|
90
|
+
if (typeof document === "undefined") return;
|
|
91
|
+
const modulesEl = document.querySelector(
|
|
92
|
+
"script[data-moonshine-island-modules]",
|
|
93
|
+
);
|
|
94
|
+
const modules = modulesEl ? JSON.parse(modulesEl.textContent ?? "{}") : {};
|
|
95
|
+
const markers = document.querySelectorAll("[data-moonshine-island]");
|
|
96
|
+
for (const marker of Array.from(markers)) {
|
|
97
|
+
const id = marker.getAttribute("data-moonshine-island");
|
|
98
|
+
if (!id) continue;
|
|
99
|
+
const propsEl = document.querySelector(
|
|
100
|
+
`script[data-moonshine-island-props="${id}"]`,
|
|
101
|
+
);
|
|
102
|
+
const props = propsEl ? JSON.parse(propsEl.textContent ?? "{}") : {};
|
|
103
|
+
const specifier = modules[id];
|
|
104
|
+
if (!specifier) continue;
|
|
105
|
+
import(specifier).then(async (mod) => {
|
|
106
|
+
const { hydrateRoot } = await import("react-dom/client");
|
|
107
|
+
const { createElement } = await import("react");
|
|
108
|
+
const Component = ((mod as { default?: ComponentType<unknown> })
|
|
109
|
+
.default ?? mod) as FunctionComponent<unknown>;
|
|
110
|
+
hydrateRoot(marker, createElement(Component as any, props as any));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/renderer.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createElement,
|
|
3
|
+
type ComponentType,
|
|
4
|
+
type FunctionComponent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
8
|
+
import type { RenderContext, Renderer } from "@tschk/moonshine-framework";
|
|
9
|
+
import { IslandProvider, type IslandRegistry } from "./islands";
|
|
10
|
+
import { escapeHtml, serializeIslandProps } from "./serialize";
|
|
11
|
+
|
|
12
|
+
async function renderToStringAsync(
|
|
13
|
+
element: ReactNode,
|
|
14
|
+
signal?: AbortSignal,
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
const stream = await renderToReadableStream(element, { signal });
|
|
17
|
+
await stream.allReady;
|
|
18
|
+
const res = new Response(stream);
|
|
19
|
+
return res.text();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type PageProps = {
|
|
23
|
+
params: Record<string, string>;
|
|
24
|
+
data: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type LayoutProps = PageProps & {
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
async function loadComponent(file: string): Promise<ComponentType<unknown>> {
|
|
32
|
+
const mod = await import(file);
|
|
33
|
+
return (mod.default ?? mod) as ComponentType<unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function buildBody(
|
|
37
|
+
context: RenderContext,
|
|
38
|
+
registry: IslandRegistry,
|
|
39
|
+
): Promise<ReactNode> {
|
|
40
|
+
const { route, params, data } = context;
|
|
41
|
+
const Page = (await loadComponent(
|
|
42
|
+
route.file,
|
|
43
|
+
)) as FunctionComponent<PageProps>;
|
|
44
|
+
const pageProps = { params, data };
|
|
45
|
+
let body = createElement(Page, pageProps);
|
|
46
|
+
|
|
47
|
+
if (route.layouts) {
|
|
48
|
+
for (let i = route.layouts.length - 1; i >= 0; i--) {
|
|
49
|
+
const Layout = (await loadComponent(
|
|
50
|
+
route.layouts[i]!,
|
|
51
|
+
)) as FunctionComponent<LayoutProps>;
|
|
52
|
+
body = createElement(Layout, { ...pageProps, children: body });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return createElement(IslandProvider, { registry }, body);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildIslandHeadAndTail(
|
|
60
|
+
registry: IslandRegistry,
|
|
61
|
+
clientEntries: string[],
|
|
62
|
+
isIslandMode: boolean,
|
|
63
|
+
): { head: string; tail: string } {
|
|
64
|
+
const preloads = new Set<string>();
|
|
65
|
+
const modules: Record<string, string> = {};
|
|
66
|
+
|
|
67
|
+
for (const entry of registry.entries.values()) {
|
|
68
|
+
preloads.add(escapeHtml(entry.specifier));
|
|
69
|
+
modules[entry.id] = entry.specifier;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const headParts: string[] = [];
|
|
73
|
+
for (const href of preloads) {
|
|
74
|
+
headParts.push(`<link rel="modulepreload" href="${href}">`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tailParts: string[] = [];
|
|
78
|
+
if (isIslandMode && registry.entries.size > 0) {
|
|
79
|
+
for (const entry of clientEntries) {
|
|
80
|
+
preloads.add(escapeHtml(entry));
|
|
81
|
+
headParts.push(`<link rel="modulepreload" href="${escapeHtml(entry)}">`);
|
|
82
|
+
}
|
|
83
|
+
tailParts.push(
|
|
84
|
+
`<script type="application/json" data-moonshine-island-modules>${serializeIslandProps(modules)}</script>`,
|
|
85
|
+
);
|
|
86
|
+
for (const entry of clientEntries) {
|
|
87
|
+
tailParts.push(
|
|
88
|
+
`<script type="module" src="${escapeHtml(entry)}"></script>`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { head: headParts.join(""), tail: tailParts.join("") };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function renderPage(context: RenderContext): Promise<string> {
|
|
97
|
+
const registry: IslandRegistry = { entries: new Map() };
|
|
98
|
+
const body = await buildBody(context, registry);
|
|
99
|
+
const bodyMarkup = await renderToStringAsync(body, context.signal);
|
|
100
|
+
const isIslandMode = context.route.mode === "island";
|
|
101
|
+
const { head, tail } = buildIslandHeadAndTail(
|
|
102
|
+
registry,
|
|
103
|
+
context.route.clientEntries,
|
|
104
|
+
isIslandMode,
|
|
105
|
+
);
|
|
106
|
+
return `<!DOCTYPE html><html><head>${head}</head><body>${bodyMarkup}${tail}</body></html>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderSpaShell(context: RenderContext): string {
|
|
110
|
+
const clientEntries = context.route.clientEntries;
|
|
111
|
+
const head: string[] = [];
|
|
112
|
+
const tail: string[] = [];
|
|
113
|
+
for (const entry of clientEntries) {
|
|
114
|
+
head.push(`<link rel="modulepreload" href="${escapeHtml(entry)}">`);
|
|
115
|
+
tail.push(`<script type="module" src="${escapeHtml(entry)}"></script>`);
|
|
116
|
+
}
|
|
117
|
+
return `<!DOCTYPE html><html><head>${head.join("")}</head><body><div id="moonshine-app" data-moonshine-app="${escapeHtml(context.route.id)}"></div>${tail.join("")}</body></html>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const reactRenderer: Renderer = {
|
|
121
|
+
name: "react",
|
|
122
|
+
async prerender(context) {
|
|
123
|
+
if (context.route.mode === "api") {
|
|
124
|
+
return serializeIslandProps(context.data);
|
|
125
|
+
}
|
|
126
|
+
if (context.route.mode === "spa") {
|
|
127
|
+
return renderSpaShell(context);
|
|
128
|
+
}
|
|
129
|
+
return renderPage(context);
|
|
130
|
+
},
|
|
131
|
+
async render(context) {
|
|
132
|
+
if (context.route.mode === "api") {
|
|
133
|
+
return new Response(serializeIslandProps(context.data), {
|
|
134
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (context.route.mode === "spa") {
|
|
138
|
+
return new Response(renderSpaShell(context), {
|
|
139
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const html = await renderPage(context);
|
|
143
|
+
return new Response(html, {
|
|
144
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
};
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const HTML_ESCAPE = /[<>&\u2028\u2029]/g;
|
|
2
|
+
|
|
3
|
+
function escapeHtmlChar(char: string): string {
|
|
4
|
+
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function escapeHtmlValue(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/&/g, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """)
|
|
13
|
+
.replace(/'/g, "'");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function serializeIslandProps(value: unknown): string {
|
|
17
|
+
const seen = new WeakSet<object>();
|
|
18
|
+
function replacer(this: object, _key: string, v: unknown): unknown {
|
|
19
|
+
if (v === undefined) return undefined;
|
|
20
|
+
const type = typeof v;
|
|
21
|
+
if (type === "function" || type === "symbol" || type === "bigint") {
|
|
22
|
+
throw new TypeError(`cannot serialize ${type}`);
|
|
23
|
+
}
|
|
24
|
+
if (v !== null && (typeof v === "object" || Array.isArray(v))) {
|
|
25
|
+
if (seen.has(v)) throw new TypeError("cyclic reference");
|
|
26
|
+
seen.add(v);
|
|
27
|
+
}
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
const raw = JSON.stringify(value, replacer);
|
|
31
|
+
if (raw === undefined) return "null";
|
|
32
|
+
return raw.replace(HTML_ESCAPE, escapeHtmlChar);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function escapeHtml(value: string): string {
|
|
36
|
+
return escapeHtmlValue(value);
|
|
37
|
+
}
|