weifuwu 0.30.2 → 0.31.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 +120 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +348 -7
- package/dist/react/client.d.ts +39 -0
- package/dist/react/client.js +300 -0
- package/dist/react/context.d.ts +2 -0
- package/dist/react/error-boundary.d.ts +31 -0
- package/dist/react/hooks.d.ts +6 -0
- package/dist/react/index.d.ts +28 -0
- package/dist/react/index.js +342 -0
- package/dist/react/navigation.d.ts +63 -0
- package/dist/react/navigation.js +154 -0
- package/dist/react/render.d.ts +8 -0
- package/dist/react/route-utils.d.ts +31 -0
- package/dist/react/types.d.ts +46 -0
- package/package.json +31 -2
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// src/react/index.ts
|
|
2
|
+
import { Fragment as Fragment2 } from "react";
|
|
3
|
+
|
|
4
|
+
// src/react/render.ts
|
|
5
|
+
import {
|
|
6
|
+
createElement,
|
|
7
|
+
Fragment
|
|
8
|
+
} from "react";
|
|
9
|
+
import { renderToString, renderToReadableStream } from "react-dom/server";
|
|
10
|
+
|
|
11
|
+
// src/react/context.ts
|
|
12
|
+
import { createContext } from "react";
|
|
13
|
+
var CTX_KEY = /* @__PURE__ */ Symbol.for("weifuwu.react.ServerDataContext");
|
|
14
|
+
function getServerDataContext() {
|
|
15
|
+
const globalStore = globalThis;
|
|
16
|
+
if (globalStore[CTX_KEY]) return globalStore[CTX_KEY];
|
|
17
|
+
const ctx = createContext({});
|
|
18
|
+
ctx.displayName = "ServerData";
|
|
19
|
+
globalStore[CTX_KEY] = ctx;
|
|
20
|
+
return ctx;
|
|
21
|
+
}
|
|
22
|
+
var ServerDataContext = getServerDataContext();
|
|
23
|
+
|
|
24
|
+
// src/react/render.ts
|
|
25
|
+
var encoder = new TextEncoder();
|
|
26
|
+
var decoder = new TextDecoder();
|
|
27
|
+
function escapeAttr(s) {
|
|
28
|
+
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
29
|
+
}
|
|
30
|
+
function buildHeadTags(head) {
|
|
31
|
+
if (!head) return "";
|
|
32
|
+
const tags = [];
|
|
33
|
+
if (head.title) tags.push(`<title>${escapeAttr(head.title)}</title>`);
|
|
34
|
+
if (head.meta) {
|
|
35
|
+
for (const [name, content] of Object.entries(head.meta)) {
|
|
36
|
+
tags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(content)}">`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (head.links) {
|
|
40
|
+
for (const link of head.links) {
|
|
41
|
+
const attrs = Object.entries(link).map(([k, v]) => `${k}="${escapeAttr(v)}"`).join(" ");
|
|
42
|
+
tags.push(`<link ${attrs}>`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (head.scripts) {
|
|
46
|
+
for (const script of head.scripts) {
|
|
47
|
+
const entries = Object.entries(script).filter(([, v]) => v !== void 0);
|
|
48
|
+
const attrs = entries.map(([k, v]) => `${k}="${escapeAttr(v)}"`).join(" ");
|
|
49
|
+
tags.push(`<script ${attrs}></script>`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return tags.join("");
|
|
53
|
+
}
|
|
54
|
+
function injectHeadIntoHtml(html, headTags) {
|
|
55
|
+
if (!headTags) return html;
|
|
56
|
+
let result = html;
|
|
57
|
+
if (headTags.includes("<title>")) {
|
|
58
|
+
const titleMatch = headTags.match(/<title>[^<]*<\/title>/);
|
|
59
|
+
if (titleMatch) {
|
|
60
|
+
result = result.replace(/<title>[^<]*<\/title>/, titleMatch[0]);
|
|
61
|
+
headTags = headTags.replace(titleMatch[0], "");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (headTags) {
|
|
65
|
+
result = result.replace("</head>", headTags + "</head>");
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
function injectHeadIntoStream(sourceStream, headTags) {
|
|
70
|
+
if (!headTags) return sourceStream;
|
|
71
|
+
let titleTag = "";
|
|
72
|
+
let rest = headTags;
|
|
73
|
+
const titleMatch = headTags.match(/<title>[^<]*<\/title>/);
|
|
74
|
+
if (titleMatch) {
|
|
75
|
+
titleTag = titleMatch[0];
|
|
76
|
+
rest = headTags.replace(titleMatch[0], "");
|
|
77
|
+
}
|
|
78
|
+
let titleInjected = !titleTag;
|
|
79
|
+
let headInjected = !rest;
|
|
80
|
+
return new ReadableStream({
|
|
81
|
+
async start(controller) {
|
|
82
|
+
const reader = sourceStream.getReader();
|
|
83
|
+
let buffer = "";
|
|
84
|
+
try {
|
|
85
|
+
while (true) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
buffer += decoder.decode(value, { stream: true });
|
|
89
|
+
if (!titleInjected) {
|
|
90
|
+
const idx = buffer.indexOf("<title>");
|
|
91
|
+
const endIdx = buffer.indexOf("</title>", idx);
|
|
92
|
+
if (idx !== -1 && endIdx !== -1) {
|
|
93
|
+
controller.enqueue(
|
|
94
|
+
encoder.encode(buffer.slice(0, idx) + titleTag + buffer.slice(endIdx + 8))
|
|
95
|
+
);
|
|
96
|
+
buffer = "";
|
|
97
|
+
titleInjected = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!headInjected) {
|
|
102
|
+
const idx = buffer.indexOf("</head>");
|
|
103
|
+
if (idx !== -1) {
|
|
104
|
+
controller.enqueue(
|
|
105
|
+
encoder.encode(buffer.slice(0, idx) + rest + buffer.slice(idx))
|
|
106
|
+
);
|
|
107
|
+
buffer = "";
|
|
108
|
+
headInjected = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (titleInjected && headInjected && buffer) {
|
|
113
|
+
controller.enqueue(encoder.encode(buffer));
|
|
114
|
+
buffer = "";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (buffer) controller.enqueue(encoder.encode(buffer));
|
|
118
|
+
controller.close();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
controller.error(err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function wrapElement(element, layouts, data) {
|
|
126
|
+
let wrapped = element;
|
|
127
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
128
|
+
wrapped = createElement(layouts[i], null, wrapped);
|
|
129
|
+
}
|
|
130
|
+
const json = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
131
|
+
const dataScript = createElement("script", {
|
|
132
|
+
id: "__WEIFUWU_DATA__",
|
|
133
|
+
type: "application/json",
|
|
134
|
+
dangerouslySetInnerHTML: { __html: json }
|
|
135
|
+
});
|
|
136
|
+
return createElement(
|
|
137
|
+
ServerDataContext.Provider,
|
|
138
|
+
{ value: data },
|
|
139
|
+
createElement(Fragment, null, wrapped, dataScript)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
function render(element, layouts, options = {}) {
|
|
143
|
+
const data = options.data ?? {};
|
|
144
|
+
const wrapped = wrapElement(element, layouts, data);
|
|
145
|
+
let html = renderToString(wrapped);
|
|
146
|
+
const headTags = buildHeadTags(options.head);
|
|
147
|
+
html = injectHeadIntoHtml(html, headTags);
|
|
148
|
+
return new Response("<!DOCTYPE html>\n" + html, {
|
|
149
|
+
status: options.status ?? 200,
|
|
150
|
+
headers: {
|
|
151
|
+
"content-type": "text/html; charset=utf-8",
|
|
152
|
+
...options.headers
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async function renderStream(element, layouts, options = {}) {
|
|
157
|
+
const data = options.data ?? {};
|
|
158
|
+
const wrapped = wrapElement(element, layouts, data);
|
|
159
|
+
const headTags = buildHeadTags(options.head);
|
|
160
|
+
let stream = await renderToReadableStream(wrapped);
|
|
161
|
+
stream = injectHeadIntoStream(stream, headTags);
|
|
162
|
+
const doctype = encoder.encode("<!DOCTYPE html>\n");
|
|
163
|
+
const combined = new ReadableStream({
|
|
164
|
+
async start(controller) {
|
|
165
|
+
controller.enqueue(doctype);
|
|
166
|
+
try {
|
|
167
|
+
const reader = stream.getReader();
|
|
168
|
+
while (true) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (done) {
|
|
171
|
+
controller.close();
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
controller.enqueue(value);
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
controller.error(err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return new Response(combined, {
|
|
182
|
+
status: options.status ?? 200,
|
|
183
|
+
headers: {
|
|
184
|
+
"content-type": "text/html; charset=utf-8",
|
|
185
|
+
"transfer-encoding": "chunked",
|
|
186
|
+
...options.headers
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/react/hooks.ts
|
|
192
|
+
import { useContext } from "react";
|
|
193
|
+
function useServerData() {
|
|
194
|
+
return useContext(ServerDataContext);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/react/navigation.ts
|
|
198
|
+
import {
|
|
199
|
+
createElement as createElement2,
|
|
200
|
+
createContext as createContext2,
|
|
201
|
+
useContext as useContext2
|
|
202
|
+
} from "react";
|
|
203
|
+
|
|
204
|
+
// src/react/error-boundary.ts
|
|
205
|
+
import { Component } from "react";
|
|
206
|
+
var ErrorBoundary = class extends Component {
|
|
207
|
+
state = { error: null };
|
|
208
|
+
static getDerivedStateFromError(error) {
|
|
209
|
+
return { error };
|
|
210
|
+
}
|
|
211
|
+
componentDidCatch(error, info) {
|
|
212
|
+
this.props.onError?.(error, info);
|
|
213
|
+
}
|
|
214
|
+
render() {
|
|
215
|
+
if (this.state.error) {
|
|
216
|
+
return this.props.fallback;
|
|
217
|
+
}
|
|
218
|
+
return this.props.children;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// src/react/navigation.ts
|
|
223
|
+
var RouterContext = createContext2(null);
|
|
224
|
+
RouterContext.displayName = "ClientRouter";
|
|
225
|
+
function useParams() {
|
|
226
|
+
return useContext2(RouterContext)?.params ?? {};
|
|
227
|
+
}
|
|
228
|
+
function useNavigate() {
|
|
229
|
+
const ctx = useContext2(RouterContext);
|
|
230
|
+
if (!ctx) throw new Error("useNavigate() must be used within a client router");
|
|
231
|
+
return ctx.navigate;
|
|
232
|
+
}
|
|
233
|
+
function useNavigation() {
|
|
234
|
+
const ctx = useContext2(RouterContext);
|
|
235
|
+
return { state: ctx?.state ?? "idle" };
|
|
236
|
+
}
|
|
237
|
+
function Link({ href, children, ...props }) {
|
|
238
|
+
const router = useContext2(RouterContext);
|
|
239
|
+
const isExternal = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
|
|
240
|
+
if (!router || isExternal || props.target !== void 0) {
|
|
241
|
+
return createElement2("a", { href, ...props }, children);
|
|
242
|
+
}
|
|
243
|
+
const handleClick = (e) => {
|
|
244
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
|
|
245
|
+
if (e.button !== 0) return;
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
router.navigate(href);
|
|
248
|
+
};
|
|
249
|
+
return createElement2("a", { href, onClick: handleClick, ...props }, children);
|
|
250
|
+
}
|
|
251
|
+
function useRevalidate() {
|
|
252
|
+
const ctx = useContext2(RouterContext);
|
|
253
|
+
if (!ctx) throw new Error("useRevalidate() must be used within a client router");
|
|
254
|
+
return ctx.revalidate;
|
|
255
|
+
}
|
|
256
|
+
function Form({ method = "post", action, children, ...props }) {
|
|
257
|
+
const router = useContext2(RouterContext);
|
|
258
|
+
if (!router) {
|
|
259
|
+
return createElement2("form", { method, action, ...props }, children);
|
|
260
|
+
}
|
|
261
|
+
const handleSubmit = async (e) => {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
const form = e.target;
|
|
264
|
+
const formData = new FormData(form);
|
|
265
|
+
const url = action || window.location.pathname;
|
|
266
|
+
const fetchMethod = method === "get" ? "GET" : method.toUpperCase();
|
|
267
|
+
try {
|
|
268
|
+
const res = await fetch(url, {
|
|
269
|
+
method: fetchMethod,
|
|
270
|
+
body: fetchMethod === "GET" ? void 0 : formData,
|
|
271
|
+
redirect: "manual"
|
|
272
|
+
});
|
|
273
|
+
if (res.status >= 300 && res.status < 400) {
|
|
274
|
+
const loc = res.headers.get("Location");
|
|
275
|
+
if (loc) {
|
|
276
|
+
router.navigate(loc);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
await router.revalidate();
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error("[weifuwu/react] Form submit failed:", err);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
return createElement2(
|
|
286
|
+
"form",
|
|
287
|
+
{ method, action, ...props, onSubmit: handleSubmit },
|
|
288
|
+
children
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/react/index.ts
|
|
293
|
+
var LAYOUTS_KEY = /* @__PURE__ */ Symbol.for("weifuwu:react:layouts");
|
|
294
|
+
var SETUP_KEY = /* @__PURE__ */ Symbol.for("weifuwu:react:setup");
|
|
295
|
+
function bag(ctx) {
|
|
296
|
+
return ctx;
|
|
297
|
+
}
|
|
298
|
+
function isDataRequest(req) {
|
|
299
|
+
try {
|
|
300
|
+
return new URL(req.url).searchParams.has("_data");
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function react(opts = {}) {
|
|
306
|
+
const layout = opts.layout ?? Fragment2;
|
|
307
|
+
const mw = (req, ctx, next) => {
|
|
308
|
+
const b = bag(ctx);
|
|
309
|
+
const layouts = b[LAYOUTS_KEY] ?? [];
|
|
310
|
+
b[LAYOUTS_KEY] = layouts;
|
|
311
|
+
layouts.push(layout);
|
|
312
|
+
if (!b[SETUP_KEY]) {
|
|
313
|
+
b[SETUP_KEY] = true;
|
|
314
|
+
ctx.render = (element, renderOpts) => {
|
|
315
|
+
if (renderOpts?.data && isDataRequest(req)) {
|
|
316
|
+
return Promise.resolve(Response.json(renderOpts.data));
|
|
317
|
+
}
|
|
318
|
+
return Promise.resolve(render(element, layouts, renderOpts));
|
|
319
|
+
};
|
|
320
|
+
ctx.renderStream = (element, renderOpts) => {
|
|
321
|
+
if (renderOpts?.data && isDataRequest(req)) {
|
|
322
|
+
return Promise.resolve(Response.json(renderOpts.data));
|
|
323
|
+
}
|
|
324
|
+
return renderStream(element, layouts, renderOpts);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return next(req, ctx);
|
|
328
|
+
};
|
|
329
|
+
return mw;
|
|
330
|
+
}
|
|
331
|
+
export {
|
|
332
|
+
ErrorBoundary,
|
|
333
|
+
Form,
|
|
334
|
+
Link,
|
|
335
|
+
ServerDataContext,
|
|
336
|
+
react,
|
|
337
|
+
useNavigate,
|
|
338
|
+
useNavigation,
|
|
339
|
+
useParams,
|
|
340
|
+
useRevalidate,
|
|
341
|
+
useServerData
|
|
342
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared navigation primitives — usable on both server and client.
|
|
3
|
+
*
|
|
4
|
+
* - Link: renders <a> on server (no RouterContext), SPA <a> on client
|
|
5
|
+
* - useParams / useNavigate: client hooks (throw friendly error on server)
|
|
6
|
+
*/
|
|
7
|
+
import { type ReactNode, type ReactElement } from 'react';
|
|
8
|
+
export type NavigationState = 'idle' | 'loading';
|
|
9
|
+
export interface RouterContextValue {
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
navigate: (url: string) => Promise<void>;
|
|
12
|
+
revalidate: () => Promise<void>;
|
|
13
|
+
state: NavigationState;
|
|
14
|
+
}
|
|
15
|
+
export declare const RouterContext: import("react").Context<RouterContextValue | null>;
|
|
16
|
+
/** Get current route params. Returns {} when no router context (SSR / outside router). */
|
|
17
|
+
export declare function useParams(): Record<string, string>;
|
|
18
|
+
/** Programmatic navigation. Throws if used outside a client router. */
|
|
19
|
+
export declare function useNavigate(): (url: string) => Promise<void>;
|
|
20
|
+
/** Navigation state — 'idle' or 'loading'. Use for progress bars / disabled states. */
|
|
21
|
+
export declare function useNavigation(): {
|
|
22
|
+
state: NavigationState;
|
|
23
|
+
};
|
|
24
|
+
export interface LinkProps {
|
|
25
|
+
href: string;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Navigation link.
|
|
31
|
+
*
|
|
32
|
+
* When inside a client router context: intercepts clicks for SPA navigation.
|
|
33
|
+
* When outside (SSR / plain React): renders a plain <a> tag.
|
|
34
|
+
*
|
|
35
|
+
* Both cases produce identical DOM output — safe for hydration.
|
|
36
|
+
*/
|
|
37
|
+
export declare function Link({ href, children, ...props }: LinkProps): ReactElement;
|
|
38
|
+
export interface FormProps {
|
|
39
|
+
method?: 'get' | 'post' | 'put' | 'delete' | 'patch';
|
|
40
|
+
action?: string;
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/** Revalidate the current route's data. */
|
|
45
|
+
export declare function useRevalidate(): () => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* SPA form — submits via fetch instead of full page reload.
|
|
48
|
+
*
|
|
49
|
+
* On submit:
|
|
50
|
+
* 1. Serializes form data via FormData
|
|
51
|
+
* 2. fetch(action, { method, body })
|
|
52
|
+
* 3. On redirect (3xx): SPA-navigates to the Location header
|
|
53
|
+
* 4. On success: revalidates the current route's loader
|
|
54
|
+
*
|
|
55
|
+
* On the server (no RouterContext): renders a plain <form>.
|
|
56
|
+
*/
|
|
57
|
+
export declare function Form({ method, action, children, ...props }: FormProps): ReactElement;
|
|
58
|
+
export declare function matchPath(pathname: string, pattern: string): Record<string, string> | null;
|
|
59
|
+
export { useServerData } from './hooks.ts';
|
|
60
|
+
export { ServerDataContext } from './context.ts';
|
|
61
|
+
export { ErrorBoundary } from './error-boundary.ts';
|
|
62
|
+
export type { ErrorBoundaryProps } from './error-boundary.ts';
|
|
63
|
+
export { defineRoute } from './route-utils.ts';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// src/react/navigation.ts
|
|
2
|
+
import {
|
|
3
|
+
createElement,
|
|
4
|
+
createContext as createContext2,
|
|
5
|
+
useContext as useContext2
|
|
6
|
+
} from "react";
|
|
7
|
+
|
|
8
|
+
// src/react/hooks.ts
|
|
9
|
+
import { useContext } from "react";
|
|
10
|
+
|
|
11
|
+
// src/react/context.ts
|
|
12
|
+
import { createContext } from "react";
|
|
13
|
+
var CTX_KEY = /* @__PURE__ */ Symbol.for("weifuwu.react.ServerDataContext");
|
|
14
|
+
function getServerDataContext() {
|
|
15
|
+
const globalStore = globalThis;
|
|
16
|
+
if (globalStore[CTX_KEY]) return globalStore[CTX_KEY];
|
|
17
|
+
const ctx = createContext({});
|
|
18
|
+
ctx.displayName = "ServerData";
|
|
19
|
+
globalStore[CTX_KEY] = ctx;
|
|
20
|
+
return ctx;
|
|
21
|
+
}
|
|
22
|
+
var ServerDataContext = getServerDataContext();
|
|
23
|
+
|
|
24
|
+
// src/react/hooks.ts
|
|
25
|
+
function useServerData() {
|
|
26
|
+
return useContext(ServerDataContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/react/error-boundary.ts
|
|
30
|
+
import { Component } from "react";
|
|
31
|
+
var ErrorBoundary = class extends Component {
|
|
32
|
+
state = { error: null };
|
|
33
|
+
static getDerivedStateFromError(error) {
|
|
34
|
+
return { error };
|
|
35
|
+
}
|
|
36
|
+
componentDidCatch(error, info) {
|
|
37
|
+
this.props.onError?.(error, info);
|
|
38
|
+
}
|
|
39
|
+
render() {
|
|
40
|
+
if (this.state.error) {
|
|
41
|
+
return this.props.fallback;
|
|
42
|
+
}
|
|
43
|
+
return this.props.children;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/react/route-utils.ts
|
|
48
|
+
function defineRoute(config) {
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/react/navigation.ts
|
|
53
|
+
var RouterContext = createContext2(null);
|
|
54
|
+
RouterContext.displayName = "ClientRouter";
|
|
55
|
+
function useParams() {
|
|
56
|
+
return useContext2(RouterContext)?.params ?? {};
|
|
57
|
+
}
|
|
58
|
+
function useNavigate() {
|
|
59
|
+
const ctx = useContext2(RouterContext);
|
|
60
|
+
if (!ctx) throw new Error("useNavigate() must be used within a client router");
|
|
61
|
+
return ctx.navigate;
|
|
62
|
+
}
|
|
63
|
+
function useNavigation() {
|
|
64
|
+
const ctx = useContext2(RouterContext);
|
|
65
|
+
return { state: ctx?.state ?? "idle" };
|
|
66
|
+
}
|
|
67
|
+
function Link({ href, children, ...props }) {
|
|
68
|
+
const router = useContext2(RouterContext);
|
|
69
|
+
const isExternal = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
|
|
70
|
+
if (!router || isExternal || props.target !== void 0) {
|
|
71
|
+
return createElement("a", { href, ...props }, children);
|
|
72
|
+
}
|
|
73
|
+
const handleClick = (e) => {
|
|
74
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
|
|
75
|
+
if (e.button !== 0) return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
router.navigate(href);
|
|
78
|
+
};
|
|
79
|
+
return createElement("a", { href, onClick: handleClick, ...props }, children);
|
|
80
|
+
}
|
|
81
|
+
function useRevalidate() {
|
|
82
|
+
const ctx = useContext2(RouterContext);
|
|
83
|
+
if (!ctx) throw new Error("useRevalidate() must be used within a client router");
|
|
84
|
+
return ctx.revalidate;
|
|
85
|
+
}
|
|
86
|
+
function Form({ method = "post", action, children, ...props }) {
|
|
87
|
+
const router = useContext2(RouterContext);
|
|
88
|
+
if (!router) {
|
|
89
|
+
return createElement("form", { method, action, ...props }, children);
|
|
90
|
+
}
|
|
91
|
+
const handleSubmit = async (e) => {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
const form = e.target;
|
|
94
|
+
const formData = new FormData(form);
|
|
95
|
+
const url = action || window.location.pathname;
|
|
96
|
+
const fetchMethod = method === "get" ? "GET" : method.toUpperCase();
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
method: fetchMethod,
|
|
100
|
+
body: fetchMethod === "GET" ? void 0 : formData,
|
|
101
|
+
redirect: "manual"
|
|
102
|
+
});
|
|
103
|
+
if (res.status >= 300 && res.status < 400) {
|
|
104
|
+
const loc = res.headers.get("Location");
|
|
105
|
+
if (loc) {
|
|
106
|
+
router.navigate(loc);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await router.revalidate();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error("[weifuwu/react] Form submit failed:", err);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return createElement(
|
|
116
|
+
"form",
|
|
117
|
+
{ method, action, ...props, onSubmit: handleSubmit },
|
|
118
|
+
children
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
function matchPath(pathname, pattern) {
|
|
122
|
+
const patParts = pattern.split("/").filter(Boolean);
|
|
123
|
+
const pathParts = pathname.split("/").filter(Boolean);
|
|
124
|
+
const hasWildcard = patParts[patParts.length - 1] === "*";
|
|
125
|
+
if (!hasWildcard && patParts.length !== pathParts.length) return null;
|
|
126
|
+
if (hasWildcard && pathParts.length < patParts.length - 1) return null;
|
|
127
|
+
const params = {};
|
|
128
|
+
for (let i = 0; i < patParts.length; i++) {
|
|
129
|
+
if (patParts[i] === "*") {
|
|
130
|
+
params["*"] = pathParts.slice(i).join("/");
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (patParts[i].startsWith(":")) {
|
|
134
|
+
params[patParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
|
|
135
|
+
} else if (patParts[i] !== pathParts[i]) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return params;
|
|
140
|
+
}
|
|
141
|
+
export {
|
|
142
|
+
ErrorBoundary,
|
|
143
|
+
Form,
|
|
144
|
+
Link,
|
|
145
|
+
RouterContext,
|
|
146
|
+
ServerDataContext,
|
|
147
|
+
defineRoute,
|
|
148
|
+
matchPath,
|
|
149
|
+
useNavigate,
|
|
150
|
+
useNavigation,
|
|
151
|
+
useParams,
|
|
152
|
+
useRevalidate,
|
|
153
|
+
useServerData
|
|
154
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactElement, type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
import type { RenderOptions } from './types.ts';
|
|
3
|
+
export declare function render(element: ReactElement, layouts: ComponentType<{
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
}>[], options?: RenderOptions): Response;
|
|
6
|
+
export declare function renderStream(element: ReactElement, layouts: ComponentType<{
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}>[], options?: RenderOptions): Promise<Response>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Type-safe route definition.
|
|
4
|
+
*
|
|
5
|
+
* Captures the loader return type as a phantom `$data` field.
|
|
6
|
+
* Use with `useServerData<typeof route.$data>()` for auto-complete.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const userRoute = defineRoute({
|
|
11
|
+
* path: '/users/:id',
|
|
12
|
+
* component: UserPage,
|
|
13
|
+
* loader: (params) => fetch(`/users/${params.id}?_data`).then(r => r.json()),
|
|
14
|
+
* })
|
|
15
|
+
* // userRoute.$data = { user: { id: number; name: string; ... } }
|
|
16
|
+
*
|
|
17
|
+
* // In the component:
|
|
18
|
+
* const { user } = useServerData<typeof userRoute.$data>()
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function defineRoute<T extends Record<string, unknown> = Record<string, unknown>>(config: {
|
|
22
|
+
path: string;
|
|
23
|
+
component: ComponentType;
|
|
24
|
+
loader: (params: Record<string, string>) => Promise<T>;
|
|
25
|
+
}): {
|
|
26
|
+
path: string;
|
|
27
|
+
component: ComponentType;
|
|
28
|
+
loader: (params: Record<string, string>) => Promise<T>;
|
|
29
|
+
/** Phantom type — use with `typeof route.$data` for `useServerData`. */
|
|
30
|
+
$data: T;
|
|
31
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactElement, ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { Context, Middleware } from '../types.ts';
|
|
3
|
+
declare module '../types.ts' {
|
|
4
|
+
interface Context {
|
|
5
|
+
/** Server-render a React element to HTML (renderToString). */
|
|
6
|
+
render(element: ReactElement, opts?: RenderOptions): Promise<Response>;
|
|
7
|
+
/** Streaming server-render a React element to HTML (renderToReadableStream). */
|
|
8
|
+
renderStream(element: ReactElement, opts?: RenderOptions): Promise<Response>;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export interface HeadOptions {
|
|
12
|
+
/** Page title — injected as <title>. */
|
|
13
|
+
title?: string;
|
|
14
|
+
/** Meta tags: name → content. */
|
|
15
|
+
meta?: Record<string, string>;
|
|
16
|
+
/** Link tags (stylesheet, canonical, etc). */
|
|
17
|
+
links?: Array<{
|
|
18
|
+
rel: string;
|
|
19
|
+
href: string;
|
|
20
|
+
[key: string]: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** Additional <script> tags in <head>. */
|
|
23
|
+
scripts?: Array<{
|
|
24
|
+
src?: string;
|
|
25
|
+
[key: string]: string | undefined;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export interface RenderOptions {
|
|
29
|
+
/** Data serialized to client — available via useServerData(). */
|
|
30
|
+
data?: Record<string, unknown>;
|
|
31
|
+
/** Head tags to inject into <head>. Supported in render() and renderStream(). */
|
|
32
|
+
head?: HeadOptions;
|
|
33
|
+
status?: number;
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
export interface ReactOptions {
|
|
37
|
+
/** Layout component wrapping rendered content. Multiple react() calls via mount accumulate layouts from inner → outer. */
|
|
38
|
+
layout?: ComponentType<{
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
export interface ReactInjected {
|
|
43
|
+
render: (element: ReactElement, opts?: RenderOptions) => Promise<Response>;
|
|
44
|
+
renderStream: (element: ReactElement, opts?: RenderOptions) => Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
export type ReactMiddleware = Middleware<Context, Context & ReactInjected>;
|
package/package.json
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weifuwu",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.31.1",
|
|
5
5
|
"description": "Web-standard HTTP microframework for Node.js — (req, ctx) => Response",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"default": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./react": {
|
|
12
|
+
"types": "./dist/react/index.d.ts",
|
|
13
|
+
"default": "./dist/react/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./react/client": {
|
|
16
|
+
"types": "./dist/react/client.d.ts",
|
|
17
|
+
"default": "./dist/react/client.js"
|
|
18
|
+
},
|
|
19
|
+
"./react/navigation": {
|
|
20
|
+
"types": "./dist/react/navigation.d.ts",
|
|
21
|
+
"default": "./dist/react/navigation.js"
|
|
10
22
|
}
|
|
11
23
|
},
|
|
12
24
|
"files": [
|
|
@@ -29,11 +41,28 @@
|
|
|
29
41
|
"postgres": "^3.4.9",
|
|
30
42
|
"ws": "^8"
|
|
31
43
|
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": "19",
|
|
46
|
+
"react-dom": "19"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"react": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"react-dom": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
32
56
|
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^10.0.1",
|
|
33
58
|
"@types/node": "^25",
|
|
59
|
+
"@types/react": "^19.2.17",
|
|
60
|
+
"@types/react-dom": "^19.2.3",
|
|
34
61
|
"@types/ws": "^8",
|
|
35
|
-
"
|
|
62
|
+
"esbuild": "^0.28.1",
|
|
36
63
|
"globals": "^17.7.0",
|
|
64
|
+
"react": "^19.2.7",
|
|
65
|
+
"react-dom": "^19.2.7",
|
|
37
66
|
"typescript-eslint": "^8.62.1"
|
|
38
67
|
},
|
|
39
68
|
"sideEffects": false
|