weifuwu 0.18.18 → 0.19.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 CHANGED
@@ -15,8 +15,7 @@ serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
15
15
  ```
16
16
 
17
17
  ```ts
18
- import { serve, Router, preferences } from 'weifuwu'
19
- import { ssr, layout, liveReload } from 'weifuwu/ssr'
18
+ import { serve, Router, preferences, ssr, layout, liveReload } from 'weifuwu'
20
19
  const app = new Router()
21
20
  app.use(preferences({ dir: './locales' }))
22
21
  app.use(layout('./layouts/root.tsx'))
@@ -568,12 +567,12 @@ app.use(requestId({ header: 'X-Request-Id', generator: () => crypto.randomUUID()
568
567
 
569
568
  ---
570
569
 
571
- ## React SSR (weifuwu/ssr)
570
+ ## React SSR (weifuwu)
572
571
 
573
- Import from `'weifuwu/ssr'`:
572
+ Import from `'weifuwu'` (no separate ssr entry):
574
573
 
575
574
  ```ts
576
- import { ssr, layout, liveReload, errorBoundary, notFound, tailwind } from 'weifuwu/ssr'
575
+ import { ssr, layout, liveReload, errorBoundary, notFound, tailwind } from 'weifuwu'
577
576
  ```
578
577
 
579
578
  ### ssr(path) [β]
@@ -588,7 +587,8 @@ app.get('/about', ssr('./pages/about.tsx'))
588
587
  - Reads `ctx.layoutStack` (set by `layout()` middleware) and wraps the component from outer to inner
589
588
  - Injects hydration script pointing to the auto-generated client bundle at `/__ssr/[hash].js`
590
589
  - Serializes middleware-injected `ctx` data to `window.__WEIFUWU_CTX` for client-side hydration
591
- - Dev mode: injects live reload WebSocket script
590
+ - **Dev mode:** uses `createRoot` instead of `hydrateRoot` — all hooks (`useState`, `useEffect`) work correctly; SSR content is still streamed for fast first paint
591
+ - **Prod mode:** uses `hydrateRoot` for full SSR hydration
592
592
 
593
593
  ### layout(path) [β]
594
594
 
@@ -610,7 +610,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
610
610
 
611
611
  ### liveReload(opts) [β]
612
612
 
613
- Returns a `Router` that registers a WebSocket endpoint at `/__weifuwu/livereload` and starts a file watcher on the given directories. When a `.tsx` file changes, it clears the compile cache and broadcasts a reload to all connected browsers.
613
+ Returns a `Router` that registers a WebSocket endpoint at `/__weifuwu/livereload` and starts a file watcher on the given directories. When a `.tsx` file changes, it compiles a hot-update bundle and broadcasts it to all connected browsers.
614
614
 
615
615
  ```ts
616
616
  if (process.env.NODE_ENV !== 'production') {
@@ -618,6 +618,21 @@ if (process.env.NODE_ENV !== 'production') {
618
618
  }
619
619
  ```
620
620
 
621
+ **How HMR works:**
622
+
623
+ 1. File change detected → compiles the entry `page.tsx` (bundles all dependencies) with esbuild
624
+ 2. Browser receives WS message → `import()` the hot bundle
625
+ 3. Hot bundle calls `window.__WFW_REFRESH(newComponent)` → updates a stable proxy component
626
+ 4. Proxy reference unchanged → React reuses fiber tree → `useState` values preserved
627
+ 5. Sets `ctx.tick` to trigger re-render without page navigation
628
+
629
+ **Key properties:**
630
+
631
+ - **State preservation** — input fields, scroll position, and other `useState`-driven state survive edits
632
+ - **Entry hash isolation** — each `ssr()` route has its own entry hash; WS messages carry the hash so only matching tabs update
633
+ - **Lazy hydration cache** — hydration bundle is only rebuilt on actual page load, not on every file change
634
+ - **Compilation fallback** — if esbuild encounters a syntax error, `location.reload()` is called to show the error
635
+
621
636
  Mount without a path — the internal `/__weifuwu/livereload` route is invisible to the user. The `ssr()` function automatically injects the client-side WS script in dev mode.
622
637
 
623
638
  | Option | Type | Default | Description |
@@ -652,15 +667,15 @@ Returns a catch-all handler for 404 pages. Typically registered last:
652
667
  app.all('/*', notFound('./not-found.tsx'))
653
668
  ```
654
669
 
655
- ### tailwind(path) [α]
670
+ ### tailwind(cssPath) [α]
656
671
 
657
- Compiles Tailwind CSS v4 via `@tailwindcss/postcss` and serves it at `/__wfw/style.css`. In dev mode, watches the CSS file for changes.
672
+ Compiles Tailwind CSS v4 via `@tailwindcss/postcss` and serves it at `/__wfw/style.css`. The file is compiled once and cached; on `app.css` changes in dev mode, the style is pushed to connected browsers via liveReload's WebSocket.
658
673
 
659
674
  ```ts
660
- app.use(tailwind('./app.css'))
675
+ app.use(tailwind('./ui'))
661
676
  ```
662
677
 
663
- When `tailwind()` middleware is detected, `ssr()` automatically injects `<link rel="stylesheet" href="/__wfw/style.css" />` into the HTML `<head>`.
678
+ The `dir` argument is the directory containing `app.css`. When `tailwind()` middleware is detected, `ssr()` automatically injects `<link rel="stylesheet" href="/__wfw/style.css" />` into the HTML `<head>`.
664
679
 
665
680
  ### seo [β] + seoMiddleware [α]
666
681
 
@@ -893,14 +908,14 @@ function Toast() {
893
908
  Auto-detected when `NODE_ENV !== 'production'`. File watching + live reload via `liveReload()`:
894
909
 
895
910
  ```ts
896
- import { liveReload } from 'weifuwu/ssr'
911
+ import { liveReload } from 'weifuwu'
897
912
 
898
913
  if (process.env.NODE_ENV !== 'production') {
899
914
  app.use(liveReload({ dirs: ['./pages', './layouts'] }))
900
915
  }
901
916
  ```
902
917
 
903
- When a `.tsx` file changes, `ssr()` clears its compile cache and the browser auto-refreshes. No process restart needed.
918
+ When a `.tsx` file changes, the browser hot-updates without refreshing `useState` values are preserved. See `liveReload` section for details.
904
919
 
905
920
  Tailwind v4 auto-compile via `tailwind()` middleware:
906
921
 
@@ -0,0 +1,481 @@
1
+ // ui/page.tsx
2
+ import { useState as useState6 } from "react";
3
+
4
+ // ../../use-websocket.ts
5
+ import { useEffect, useRef, useCallback, useState } from "react";
6
+ var RECONNECT_DELAY = 3e3;
7
+ var MAX_RETRIES = 10;
8
+ function resolveUrl(url) {
9
+ return typeof url === "function" ? url() : url;
10
+ }
11
+ function useWebsocket(url, options) {
12
+ const { onMessage, reconnect: reconnectOpt = true, protocols, enabled = true } = options ?? {};
13
+ const [lastMessage, setLastMessage] = useState(null);
14
+ const [readyState, setReadyState] = useState(WebSocket.CLOSED);
15
+ const wsRef = useRef(null);
16
+ const retryRef = useRef(0);
17
+ const timerRef = useRef(void 0);
18
+ const mountedRef = useRef(true);
19
+ const shouldReconnectRef = useRef(true);
20
+ const urlRef = useRef(url);
21
+ const optsRef = useRef({ onMessage, reconnectOpt, protocols });
22
+ urlRef.current = url;
23
+ optsRef.current = { onMessage, reconnectOpt, protocols };
24
+ const cleanup = useCallback(() => {
25
+ clearTimeout(timerRef.current);
26
+ wsRef.current?.close();
27
+ wsRef.current = null;
28
+ }, []);
29
+ const connect = useCallback(() => {
30
+ if (!mountedRef.current || !enabled) return;
31
+ const resolved = resolveUrl(urlRef.current);
32
+ if (!resolved) return;
33
+ wsRef.current?.close();
34
+ const ws = new WebSocket(resolved, optsRef.current.protocols);
35
+ wsRef.current = ws;
36
+ setReadyState(WebSocket.CONNECTING);
37
+ ws.addEventListener("open", () => {
38
+ if (!mountedRef.current) return;
39
+ retryRef.current = 0;
40
+ setReadyState(WebSocket.OPEN);
41
+ });
42
+ ws.addEventListener("message", (e) => {
43
+ if (!mountedRef.current) return;
44
+ const data = typeof e.data === "string" ? e.data : String(e.data);
45
+ setLastMessage(data);
46
+ optsRef.current.onMessage?.(data);
47
+ });
48
+ ws.addEventListener("close", () => {
49
+ if (!mountedRef.current) return;
50
+ setReadyState(WebSocket.CLOSED);
51
+ const ro = optsRef.current.reconnectOpt;
52
+ if (ro && shouldReconnectRef.current && mountedRef.current) {
53
+ const maxRetries = typeof ro === "object" ? ro.maxRetries ?? MAX_RETRIES : MAX_RETRIES;
54
+ const delay = typeof ro === "object" ? ro.delay ?? RECONNECT_DELAY : RECONNECT_DELAY;
55
+ if (retryRef.current < maxRetries) {
56
+ retryRef.current++;
57
+ timerRef.current = setTimeout(() => connect(), delay);
58
+ }
59
+ }
60
+ });
61
+ }, [enabled]);
62
+ useEffect(() => {
63
+ mountedRef.current = true;
64
+ shouldReconnectRef.current = true;
65
+ if (enabled) connect();
66
+ return () => {
67
+ mountedRef.current = false;
68
+ cleanup();
69
+ };
70
+ }, [enabled, connect, cleanup]);
71
+ const send = useCallback((data) => {
72
+ wsRef.current?.send(data);
73
+ }, []);
74
+ const close = useCallback(() => {
75
+ shouldReconnectRef.current = false;
76
+ cleanup();
77
+ setReadyState(WebSocket.CLOSED);
78
+ }, [cleanup]);
79
+ const reconnectFn = useCallback(() => {
80
+ retryRef.current = 0;
81
+ shouldReconnectRef.current = true;
82
+ cleanup();
83
+ connect();
84
+ }, [cleanup, connect]);
85
+ return { send, close, readyState, lastMessage, reconnect: reconnectFn };
86
+ }
87
+
88
+ // ../../use-action.ts
89
+ import { useState as useState2, useCallback as useCallback2, useRef as useRef2 } from "react";
90
+
91
+ // ../../client-router.ts
92
+ import { createElement, useCallback as useCallback3, useState as useState3, useEffect as useEffect2 } from "react";
93
+
94
+ // ../../client-pref.ts
95
+ var interceptors = [];
96
+ function addInterceptor(fn) {
97
+ interceptors.push(fn);
98
+ }
99
+ async function runInterceptors(url) {
100
+ for (const fn of interceptors) {
101
+ if (await fn(url)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ // ../../client-router.ts
107
+ var _navigating = false;
108
+ var _listeners = [];
109
+ function setNavigating(v) {
110
+ _navigating = v;
111
+ for (const fn of _listeners) fn(v);
112
+ }
113
+ async function navigate(href) {
114
+ if (typeof document === "undefined") return;
115
+ const url = new URL(href, location.origin);
116
+ if (url.origin !== location.origin) {
117
+ location.href = href;
118
+ return;
119
+ }
120
+ if (await runInterceptors(url)) return;
121
+ const scrollPos = [window.scrollX, window.scrollY];
122
+ setNavigating(true);
123
+ try {
124
+ const html = await fetch(url.pathname + url.search, {
125
+ headers: { accept: "text/html" }
126
+ }).then((r) => r.text());
127
+ const doc = new DOMParser().parseFromString(html, "text/html");
128
+ const rootEl = doc.getElementById("__weifuwu_root");
129
+ if (!rootEl) {
130
+ location.href = href;
131
+ return;
132
+ }
133
+ const newHtml = rootEl.innerHTML;
134
+ const propsMatch = html.match(/window\.__WEIFUWU_PROPS=(.+?)<\/script>/);
135
+ if (!propsMatch) {
136
+ location.href = href;
137
+ return;
138
+ }
139
+ const bundleMatch = html.match(/src="(\/__wfw\/client\/[^"]+\.js)"/);
140
+ const bundleUrl = bundleMatch ? bundleMatch[1] : null;
141
+ applyHead(html);
142
+ const currentRoot = document.getElementById("__weifuwu_root");
143
+ if (!currentRoot) {
144
+ location.href = href;
145
+ return;
146
+ }
147
+ ;
148
+ window.__WEIFUWU_PROPS = JSON.parse(propsMatch[1]);
149
+ history.pushState(null, "", url.pathname + url.search);
150
+ currentRoot.innerHTML = newHtml;
151
+ const ctxMatch = html.match(/window\.__WEIFUWU_CTX=(.+?)<\/script>/);
152
+ if (ctxMatch) {
153
+ try {
154
+ window.__WEIFUWU_CTX = JSON.parse(ctxMatch[1]);
155
+ } catch {
156
+ }
157
+ }
158
+ const localeMatch = html.match(/window\.__LOCALE_DATA__=(.+?)<\/script>/);
159
+ if (localeMatch) {
160
+ try {
161
+ window.__LOCALE_DATA__ = JSON.parse(localeMatch[1]);
162
+ } catch {
163
+ }
164
+ }
165
+ if (bundleUrl) {
166
+ try {
167
+ await import(
168
+ /* @vite-ignore */
169
+ `${bundleUrl}`
170
+ );
171
+ } catch (e) {
172
+ console.error("[weifuwu/router] hydration failed:", e);
173
+ location.href = href;
174
+ }
175
+ }
176
+ window.scrollTo(scrollPos[0], scrollPos[1]);
177
+ } finally {
178
+ setNavigating(false);
179
+ }
180
+ }
181
+ function applyHead(html) {
182
+ const match = html.match(/<template id="__wfw_head">([\s\S]*?)<\/template>/);
183
+ if (!match) return;
184
+ const headHtml = match[1];
185
+ const titleMatch = headHtml.match(/<title>([^<]*)<\/title>/);
186
+ if (titleMatch) document.title = titleMatch[1];
187
+ const doc = new DOMParser().parseFromString(headHtml, "text/html");
188
+ const newMeta = doc.querySelectorAll("meta");
189
+ const existing = document.querySelectorAll("head meta");
190
+ const newNames = new Set(Array.from(newMeta).map((m) => m.getAttribute("name") || m.getAttribute("property") || ""));
191
+ for (const el of existing) {
192
+ const key = el.getAttribute("name") || el.getAttribute("property") || "";
193
+ if (!newNames.has(key)) el.remove();
194
+ }
195
+ for (const el of newMeta) {
196
+ const key = el.getAttribute("name") || el.getAttribute("property") || "";
197
+ let existingEl = null;
198
+ if (key) {
199
+ for (const m of document.head.querySelectorAll("meta")) {
200
+ if (m.getAttribute("name") === key || m.getAttribute("property") === key) {
201
+ existingEl = m;
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ if (existingEl) {
207
+ for (const attr of el.attributes) existingEl.setAttribute(attr.name, attr.value);
208
+ } else {
209
+ document.head.appendChild(el.cloneNode());
210
+ }
211
+ }
212
+ const newLink = doc.querySelector('link[rel="canonical"]');
213
+ const existingLink = document.querySelector('link[rel="canonical"]');
214
+ if (newLink) {
215
+ if (existingLink) existingLink.setAttribute("href", newLink.getAttribute("href") || "");
216
+ else document.head.appendChild(newLink.cloneNode());
217
+ } else if (existingLink) {
218
+ existingLink.remove();
219
+ }
220
+ }
221
+
222
+ // ../../tsx-context.ts
223
+ import { useSyncExternalStore, createContext } from "react";
224
+ var DEFAULT_CTX = { params: {}, query: {}, parsed: {}, prefs: {}, loaderData: {}, env: {}, user: {} };
225
+ var KEY = "__WEIFUWU_CTX_STORE";
226
+ function getStore() {
227
+ if (typeof globalThis !== "undefined" && globalThis[KEY]) {
228
+ return globalThis[KEY];
229
+ }
230
+ const s = {
231
+ _ctx: DEFAULT_CTX,
232
+ _snapshot: { params: DEFAULT_CTX.params, query: DEFAULT_CTX.query, user: DEFAULT_CTX.user, parsed: DEFAULT_CTX.parsed, prefs: DEFAULT_CTX.prefs, env: DEFAULT_CTX.env },
233
+ _listeners: /* @__PURE__ */ new Set(),
234
+ _alsGetStore: null
235
+ };
236
+ if (typeof globalThis !== "undefined") {
237
+ globalThis[KEY] = s;
238
+ }
239
+ return s;
240
+ }
241
+ var store = getStore();
242
+ var subscribe = (cb) => {
243
+ store._listeners.add(cb);
244
+ return () => {
245
+ store._listeners.delete(cb);
246
+ };
247
+ };
248
+ var getSnapshot = () => store._snapshot;
249
+ function setCtx(value) {
250
+ store._ctx = { ...store._ctx, ...value };
251
+ store._snapshot = { params: store._ctx.params, query: store._ctx.query, user: store._ctx.user, parsed: store._ctx.parsed, prefs: store._ctx.prefs, env: store._ctx.env };
252
+ store._listeners.forEach((fn) => fn());
253
+ }
254
+ function useCtx() {
255
+ if (typeof window !== "undefined") {
256
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot);
257
+ return { ...snapshot, ...window.__WEIFUWU_CTX };
258
+ }
259
+ const alsStore = store._alsGetStore?.();
260
+ return alsStore ?? store._ctx;
261
+ }
262
+ function useLoaderData() {
263
+ const ctx = useCtx();
264
+ return ctx.loaderData;
265
+ }
266
+ var TsxContext = createContext(DEFAULT_CTX);
267
+
268
+ // ../../head.tsx
269
+ import { createElement as createElement2 } from "react";
270
+
271
+ // ../../client-state.ts
272
+ import { useSyncExternalStore as useSyncExternalStore2, useCallback as useCallback4, useEffect as useEffect3, useRef as useRef3, useState as useState4 } from "react";
273
+
274
+ // ../../client-locale.ts
275
+ function buildT() {
276
+ const messages = globalThis.__LOCALE_DATA__ || (typeof window !== "undefined" ? window.__LOCALE_DATA__ : null);
277
+ if (!messages) return (key, _p, fb) => fb ?? key;
278
+ return (key, params, fallback) => {
279
+ const msg = key.split(".").reduce((o, k) => o?.[k], messages);
280
+ if (msg === void 0 || msg === null) return fallback ?? key;
281
+ if (!params) return String(msg);
282
+ let result = String(msg);
283
+ for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
284
+ return result;
285
+ };
286
+ }
287
+ addInterceptor(async (url) => {
288
+ const m = url.pathname.match(/^\/__lang\/([\w-]+)$/);
289
+ if (!m) return false;
290
+ try {
291
+ const res = await fetch(url.pathname, {
292
+ headers: { accept: "application/json" }
293
+ });
294
+ const data = await res.json();
295
+ const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
296
+ ctx.prefs = { ...ctx.prefs, locale: data.locale };
297
+ if (data.messages) window.__LOCALE_DATA__ = data.messages;
298
+ window.__WEIFUWU_CTX = ctx;
299
+ setCtx(ctx);
300
+ } catch {
301
+ location.href = url.href;
302
+ }
303
+ return true;
304
+ });
305
+ function useLocale() {
306
+ const ctx = useCtx();
307
+ return {
308
+ locale: ctx.prefs.locale,
309
+ setLocale: (locale) => navigate("/__lang/" + locale),
310
+ t: buildT()
311
+ };
312
+ }
313
+
314
+ // ../../client-theme.ts
315
+ import { useEffect as useEffect4 } from "react";
316
+ function resolveTheme(theme) {
317
+ if (theme === "system") {
318
+ if (typeof window === "undefined") return "light";
319
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
320
+ }
321
+ return theme;
322
+ }
323
+ var _mqListener = null;
324
+ function applyTheme(theme) {
325
+ if (typeof document === "undefined") return;
326
+ const resolved = resolveTheme(theme);
327
+ document.documentElement.dataset.theme = resolved;
328
+ if (theme === "system") {
329
+ if (!_mqListener) {
330
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
331
+ mq.addEventListener("change", (e) => {
332
+ if (window.__WEIFUWU_CTX?.prefs?.theme === "system") {
333
+ document.documentElement.dataset.theme = e.matches ? "dark" : "light";
334
+ }
335
+ });
336
+ _mqListener = mq;
337
+ }
338
+ }
339
+ }
340
+ addInterceptor(async (url) => {
341
+ const m = url.pathname.match(/^\/__theme\/([\w-]+)$/);
342
+ if (!m) return false;
343
+ try {
344
+ const res = await fetch(url.pathname, {
345
+ headers: { accept: "application/json" }
346
+ });
347
+ const data = await res.json();
348
+ const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
349
+ ctx.prefs = { ...ctx.prefs, theme: data.theme };
350
+ window.__WEIFUWU_CTX = ctx;
351
+ applyTheme(data.theme);
352
+ setCtx(ctx);
353
+ } catch {
354
+ location.href = url.href;
355
+ }
356
+ return true;
357
+ });
358
+ function useTheme() {
359
+ const ctx = useCtx();
360
+ const theme = ctx.prefs.theme ?? "system";
361
+ useEffect4(() => {
362
+ applyTheme(theme);
363
+ }, [theme]);
364
+ return {
365
+ theme,
366
+ resolvedTheme: resolveTheme(theme),
367
+ setTheme: (t) => navigate("/__theme/" + t)
368
+ };
369
+ }
370
+
371
+ // ../../use-flash-message.ts
372
+ import { useState as useState5 } from "react";
373
+
374
+ // ui/components/Greeting.tsx
375
+ import { jsxs } from "react/jsx-runtime";
376
+ function Greeting({ name }) {
377
+ return /* @__PURE__ */ jsxs("span", { className: "text-red-500 font-bold", children: [
378
+ name,
379
+ "!"
380
+ ] });
381
+ }
382
+
383
+ // ui/page.tsx
384
+ import { jsx, jsxs as jsxs2 } from "react/jsx-runtime";
385
+ function Home() {
386
+ const [input, setInput] = useState6("");
387
+ const { send, lastMessage, readyState } = useWebsocket("/ws/echo");
388
+ const { locale, t, setLocale } = useLocale();
389
+ const { theme, resolvedTheme, setTheme } = useTheme();
390
+ const ld = useLoaderData();
391
+ return /* @__PURE__ */ jsxs2("div", { className: "min-h-screen bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100", children: [
392
+ /* @__PURE__ */ jsx("header", { className: "border-b dark:border-gray-800", children: /* @__PURE__ */ jsxs2("div", { className: "max-w-5xl mx-auto flex items-center justify-between h-14 px-4", children: [
393
+ /* @__PURE__ */ jsx("span", { className: "font-bold text-lg", children: "weifuwu" }),
394
+ /* @__PURE__ */ jsxs2("nav", { className: "hidden sm:flex gap-6 text-sm", children: [
395
+ /* @__PURE__ */ jsx("span", { className: "hover:text-blue-600 transition cursor-pointer", children: t("nav.home") }),
396
+ /* @__PURE__ */ jsx("span", { className: "hover:text-blue-600 transition cursor-pointer", children: t("nav.docs") }),
397
+ /* @__PURE__ */ jsx("span", { className: "hover:text-blue-600 transition cursor-pointer", children: t("nav.api") })
398
+ ] }),
399
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 text-sm", children: [
400
+ /* @__PURE__ */ jsx(
401
+ "button",
402
+ {
403
+ onClick: () => setLocale(locale === "en" ? "zh-CN" : "en"),
404
+ className: "px-2 py-1 rounded border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition",
405
+ children: locale === "en" ? "\u4E2D\u6587" : "EN"
406
+ }
407
+ ),
408
+ /* @__PURE__ */ jsx(
409
+ "button",
410
+ {
411
+ onClick: () => setTheme(resolvedTheme === "light" ? "dark" : "light"),
412
+ className: "px-2 py-1 rounded border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition",
413
+ children: resolvedTheme === "light" ? "\u{1F319}" : "\u2600\uFE0F"
414
+ }
415
+ )
416
+ ] })
417
+ ] }) }),
418
+ /* @__PURE__ */ jsxs2("section", { className: "text-center py-20 px-4", children: [
419
+ /* @__PURE__ */ jsx("h1", { className: "text-5xl font-bold mb-4", children: t("hero.title") }),
420
+ /* @__PURE__ */ jsx("p", { className: "text-xl text-gray-500 dark:text-gray-400 mb-8", children: t("hero.subtitle") }),
421
+ /* @__PURE__ */ jsx(Greeting, { name: "Weifuwu" }),
422
+ /* @__PURE__ */ jsxs2("div", { className: "flex justify-center gap-4 mt-8", children: [
423
+ /* @__PURE__ */ jsx("a", { href: "#", className: "px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-medium", children: t("cta.start") }),
424
+ /* @__PURE__ */ jsx("a", { href: "#", className: "px-6 py-2.5 border dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition font-medium", children: t("cta.learn") })
425
+ ] })
426
+ ] }),
427
+ ld.features && /* @__PURE__ */ jsx("section", { className: "max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6 px-4 pb-20", children: ld.features.map((f, i) => /* @__PURE__ */ jsxs2("div", { className: "p-6 rounded-xl border dark:border-gray-800 bg-gray-50 dark:bg-gray-900", children: [
428
+ /* @__PURE__ */ jsx("h3", { className: "font-semibold mb-2", children: f.title }),
429
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500 dark:text-gray-400", children: f.desc })
430
+ ] }, i)) }),
431
+ /* @__PURE__ */ jsx("section", { className: "max-w-xl mx-auto px-4 pb-20", children: /* @__PURE__ */ jsxs2("div", { className: "border dark:border-gray-800 rounded-xl p-6 bg-gray-50 dark:bg-gray-900 space-y-4", children: [
432
+ /* @__PURE__ */ jsx("h2", { className: "font-semibold", children: t("demo.title") }),
433
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500 dark:text-gray-400", children: readyState === 1 ? t("ws.connected") : readyState === 0 ? t("ws.connecting") : t("ws.disconnected") }),
434
+ /* @__PURE__ */ jsxs2("div", { className: "flex gap-2", children: [
435
+ /* @__PURE__ */ jsx(
436
+ "input",
437
+ {
438
+ value: input,
439
+ onChange: (e) => setInput(e.target.value),
440
+ onKeyDown: (e) => {
441
+ if (e.key === "Enter") {
442
+ send(input);
443
+ setInput("");
444
+ }
445
+ },
446
+ placeholder: t("demo.placeholder"),
447
+ className: "flex-1 border dark:border-gray-700 rounded px-3 py-2 text-sm bg-white dark:bg-gray-950 outline-none focus:border-blue-500 transition"
448
+ }
449
+ ),
450
+ /* @__PURE__ */ jsx(
451
+ "button",
452
+ {
453
+ onClick: () => {
454
+ send(input);
455
+ setInput("");
456
+ },
457
+ className: "px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 transition font-medium",
458
+ children: t("demo.send")
459
+ }
460
+ )
461
+ ] }),
462
+ lastMessage && /* @__PURE__ */ jsxs2("p", { className: "text-sm text-gray-600 dark:text-gray-400", children: [
463
+ /* @__PURE__ */ jsxs2("span", { className: "font-medium", children: [
464
+ t("demo.echo"),
465
+ ":"
466
+ ] }),
467
+ " ",
468
+ lastMessage
469
+ ] })
470
+ ] }) }),
471
+ /* @__PURE__ */ jsxs2("footer", { className: "border-t dark:border-gray-800 py-8 text-center text-sm text-gray-500", children: [
472
+ "\xA9 2026 MyApp \xB7 ",
473
+ t("footer.privacy"),
474
+ " \xB7 ",
475
+ t("footer.terms")
476
+ ] })
477
+ ] });
478
+ }
479
+ export {
480
+ Home as default
481
+ };
@@ -0,0 +1,14 @@
1
+ // ui/layout.tsx
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ function RootLayout({ children }) {
4
+ return /* @__PURE__ */ jsxs("html", { lang: "en", children: [
5
+ /* @__PURE__ */ jsxs("head", { children: [
6
+ /* @__PURE__ */ jsx("meta", { charSet: "utf-8" }),
7
+ /* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" })
8
+ ] }),
9
+ /* @__PURE__ */ jsx("body", { children: /* @__PURE__ */ jsx("main", { children }) })
10
+ ] });
11
+ }
12
+ export {
13
+ RootLayout as default
14
+ };
@@ -0,0 +1,23 @@
1
+ import { join } from 'node:path'
2
+ import { Router, ssr, layout, tailwind, preferences } from '../../index.ts'
3
+
4
+ const _ui = join(import.meta.dirname, 'ui')
5
+ const _loc = join(import.meta.dirname, 'locales')
6
+
7
+ export const app = new Router()
8
+ app.use(tailwind(_ui))
9
+ app.use(preferences({ dir: _loc, locale: { default: 'en' }, theme: { default: 'system' } }))
10
+ app.use(async (req, ctx, next) => {
11
+ ctx.loaderData = {
12
+ features: [
13
+ { title: 'SSR + HMR', desc: 'State-preserving hot reload' },
14
+ { title: 'i18n', desc: 'Built-in internationalization' },
15
+ { title: 'Theme', desc: 'Light/dark mode toggle' },
16
+ ],
17
+ }
18
+ return next(req, ctx)
19
+ })
20
+ app.use(layout(join(_ui, 'layout.tsx')))
21
+ app.get('/', ssr(join(_ui, 'page.tsx')))
22
+ app.get('/api/ping', () => Response.json({ pong: true, time: new Date().toISOString() }))
23
+ app.ws('/ws/echo', { message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
@@ -0,0 +1,12 @@
1
+ import { join } from 'node:path'
2
+ import { loadEnv, serve, liveReload } from '../../index.ts'
3
+ import { app } from './app.ts'
4
+
5
+ loadEnv()
6
+ if (process.env.NODE_ENV !== 'production') {
7
+ app.use(liveReload({ dirs: [join(import.meta.dirname, 'ui')] }))
8
+ }
9
+ const port = Number(process.env.PORT) || 3000
10
+ const server = serve(app.handler(), { port, websocket: app.websocketHandler() })
11
+ await server.ready
12
+ console.log(`Listening on http://localhost:${server.port}`)
@@ -0,0 +1,8 @@
1
+ {
2
+ "nav": { "home": "Home", "docs": "Docs", "api": "API" },
3
+ "hero": { "title": "Build Faster", "subtitle": "A modern web framework for Node.js" },
4
+ "cta": { "start": "Get Started", "learn": "Learn More" },
5
+ "demo": { "title": "WebSocket Demo", "placeholder": "Type a message...", "send": "Send", "echo": "Echo" },
6
+ "ws": { "connected": "Connected", "connecting": "Connecting...", "disconnected": "Disconnected" },
7
+ "footer": { "privacy": "Privacy", "terms": "Terms" }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "nav": { "home": "首页", "docs": "文档", "api": "API" },
3
+ "hero": { "title": "更快地构建", "subtitle": "一个现代的 Node.js Web 框架" },
4
+ "cta": { "start": "开始使用", "learn": "了解更多" },
5
+ "demo": { "title": "WebSocket 演示", "placeholder": "输入消息...", "send": "发送", "echo": "回声" },
6
+ "ws": { "connected": "已连接", "connecting": "连接中...", "disconnected": "未连接" },
7
+ "footer": { "privacy": "隐私", "terms": "条款" }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "nav": { "home": "首頁", "docs": "文件", "api": "API" },
3
+ "hero": { "title": "更快構建", "subtitle": "一個現代的 Node.js Web 框架" },
4
+ "cta": { "start": "開始使用", "learn": "了解更多" },
5
+ "demo": { "title": "WebSocket 演示", "placeholder": "輸入訊息...", "send": "發送", "echo": "回聲" },
6
+ "ws": { "connected": "已連線", "connecting": "連線中...", "disconnected": "未連線" },
7
+ "footer": { "privacy": "隱私", "terms": "條款" }
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "nav": { "home": "首页", "docs": "文档", "api": "API" },
3
+ "hero": { "title": "更快地构建", "subtitle": "一个现代的 Node.js Web 框架" },
4
+ "cta": { "start": "开始使用", "learn": "了解更多" },
5
+ "demo": { "title": "WebSocket 演示", "placeholder": "输入消息...", "send": "发送", "echo": "回声" },
6
+ "ws": { "connected": "已连接", "connecting": "连接中...", "disconnected": "未连接" },
7
+ "footer": { "privacy": "隐私", "terms": "条款" }
8
+ }