weifuwu 0.17.18 → 0.17.21

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
@@ -322,8 +322,13 @@ app.use(preferences({ dir: './locales', locale: { default: 'en' }, theme: { defa
322
322
  | `theme.cookie` | `'theme'` | Cookie name |
323
323
 
324
324
  ```tsx
325
- // Client-side no-refresh switching with <Link>
326
- <Link href="/__lang/zh">中文</Link>
325
+ // Client-side no-refresh switching import enables it automatically
326
+ import { useLocale, useTheme } from 'weifuwu/react'
327
+
328
+ <Link href="/__lang/zh">中文</Link> // <Link> handles it via interceptor
329
+ <button onClick={() => setLocale('en')}>EN</button> // or programmatic
330
+ const { theme, resolvedTheme, setTheme } = useTheme()
331
+ // resolvedTheme resolves 'system' → 'dark'|'light' based on prefers-color-scheme
327
332
  ```
328
333
 
329
334
  ### queue [B]
@@ -467,12 +472,15 @@ const navigate = useNavigate() // programmatic: navigate('/contact
467
472
  const loading = useNavigating() // reactive loading state
468
473
  ```
469
474
 
470
- `navigate()` fetches SSR, extracts `__weifuwu_root`, replaces in-place. `load.ts` runs on server each nav. `/__lang/:locale` and `/__theme/:theme` intercepted for no-refresh switching.
475
+ `navigate()` fetches SSR, extracts `__weifuwu_root`, replaces in-place. `load.ts` runs on server each nav.
476
+
477
+ **Preference URLs** (`/__lang/`, `/__theme/`) are intercepted by modular interceptors registered via `addInterceptor()` — no page reload needed. Importing `useLocale` or `useTheme` registers the interceptor automatically.
471
478
 
472
479
  ### Client-side hooks
473
480
 
474
481
  ```tsx
475
482
  import { useWebsocket, useAction, useData, useQueryState, createStore, Head, setCtx } from 'weifuwu/react'
483
+ import { useLocale, useTheme, applyTheme, addInterceptor } from 'weifuwu/react'
476
484
 
477
485
  // WebSocket — auto-reconnecting
478
486
  const { send, lastMessage, readyState } = useWebsocket('/ws/chat', { onMessage: (d) => console.log(d), reconnect: { maxRetries: 10, delay: 3000 } })
@@ -499,6 +507,58 @@ const count = useStore(s => s.count)
499
507
  setCtx({ locale: 'en', prefs: { locale: 'en' } })
500
508
  ```
501
509
 
510
+ ### Locale & Theme
511
+
512
+ ```tsx
513
+ import { useLocale } from 'weifuwu/react'
514
+ function LangSwitch() {
515
+ const { locale, setLocale, t } = useLocale()
516
+ return <button onClick={() => setLocale('zh-CN')}>{t('switch_lang')}</button>
517
+ }
518
+ ```
519
+
520
+ | Return | Description |
521
+ |--------|-------------|
522
+ | `locale` | Current locale string (from `ctx.prefs.locale`) |
523
+ | `setLocale(locale)` | Switch locale (calls `navigate('/__lang/' + locale)`) |
524
+ | `t` | Translation function (same as `useCtx().t`) |
525
+
526
+ ```tsx
527
+ import { useTheme } from 'weifuwu/react'
528
+ function ThemeToggle() {
529
+ const { theme, resolvedTheme, setTheme } = useTheme()
530
+ return (
531
+ <>
532
+ <span>Current: {resolvedTheme}</span> {/* 'dark' | 'light' — never 'system' */}
533
+ <select value={theme} onChange={e => setTheme(e.target.value)}>
534
+ <option value="light">☀ Light</option>
535
+ <option value="dark">🌙 Dark</option>
536
+ <option value="system">💻 System</option>
537
+ </select>
538
+ </>
539
+ )
540
+ }
541
+ ```
542
+
543
+ | Return | Description |
544
+ |--------|-------------|
545
+ | `theme` | Raw preference (`'light'` \| `'dark'` \| `'system'`) |
546
+ | `resolvedTheme` | Resolved value (`'light'` \| `'dark'`) — `'system'` → matchMedia |
547
+ | `setTheme(theme)` | Switch theme (calls `navigate('/__theme/' + theme)`) |
548
+
549
+ **`applyTheme(theme)`** — DOM-only theme application. Sets `data-theme` on `<html>`, registers `matchMedia` listener for `'system'`. Used by the interceptor; exported for custom scenarios.
550
+
551
+ **`addInterceptor(fn)`** — Register a URL interceptor. Interceptors run before SPA navigation; if one returns `true`, `navigate()` skips the fetch-and-swap.
552
+
553
+ ```ts
554
+ import { addInterceptor } from 'weifuwu/react'
555
+ addInterceptor(async (url) => {
556
+ if (!url.pathname.startsWith('/__custom/')) return false
557
+ // handle without page reload
558
+ return true
559
+ })
560
+ ```
561
+
502
562
  ### Flash messages
503
563
 
504
564
  ```ts
@@ -0,0 +1,5 @@
1
+ export declare function useLocale(): {
2
+ locale: string;
3
+ setLocale: (locale: string) => Promise<void>;
4
+ t: (key: string, params?: Record<string, string>, fallback?: string) => string;
5
+ };
@@ -0,0 +1,3 @@
1
+ export type UrlInterceptor = (url: URL) => boolean | Promise<boolean>;
2
+ export declare function addInterceptor(fn: UrlInterceptor): void;
3
+ export declare function runInterceptors(url: URL): Promise<boolean>;
@@ -1,3 +1,4 @@
1
+ export { addInterceptor } from './client-pref.ts';
1
2
  export declare function isNavigating(): boolean;
2
3
  export declare function onNavigate(fn: (v: boolean) => void): () => void;
3
4
  export declare function navigate(href: string): Promise<void>;
@@ -297,4 +298,3 @@ export declare function Link({ href, children, onClick, prefetch, ...props }: Li
297
298
  href: string;
298
299
  onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
299
300
  }, HTMLElement>;
300
- export {};
@@ -0,0 +1,7 @@
1
+ declare function applyTheme(theme: string): void;
2
+ export declare function useTheme(): {
3
+ theme: string;
4
+ resolvedTheme: string;
5
+ setTheme: (t: string) => Promise<void>;
6
+ };
7
+ export { applyTheme };
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { serve, createTestServer } from './serve.ts';
4
4
  export type { ServeOptions, Server } from './serve.ts';
5
5
  export { Router } from './router.ts';
6
6
  export type { WebSocketHandler } from './router.ts';
7
- export { tsx, TsxContext, useCtx, setCtx, getCtx } from './tsx.ts';
7
+ export { tsx, TsxContext, useCtx, setCtx } from './tsx.ts';
8
8
  export type { TsxOptions } from './tsx.ts';
9
9
  export { auth, cors, logger } from './middleware.ts';
10
10
  export type { AuthOptions, CORSOptions, LoggerOptions } from './middleware.ts';
package/dist/index.js CHANGED
@@ -598,15 +598,10 @@ function setCtx(value) {
598
598
  _snapshot = { params: _ctx.params, query: _ctx.query, user: _ctx.user, parsed: _ctx.parsed, prefs: _ctx.prefs, env: _ctx.env };
599
599
  _listeners.forEach((fn) => fn());
600
600
  }
601
- var _cachedT = null;
602
601
  function _buildT() {
603
- if (_cachedT) return _cachedT;
604
602
  const messages2 = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
605
- if (!messages2) {
606
- _cachedT = fallbackT;
607
- return fallbackT;
608
- }
609
- _cachedT = (key, params, fallback) => {
603
+ if (!messages2) return fallbackT;
604
+ return (key, params, fallback) => {
610
605
  const msg = key.split(".").reduce((o, k) => o?.[k], messages2);
611
606
  if (msg === void 0 || msg === null) return fallback ?? key;
612
607
  if (!params) return String(msg);
@@ -614,22 +609,17 @@ function _buildT() {
614
609
  for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
615
610
  return result;
616
611
  };
617
- return _cachedT;
618
612
  }
619
613
  function _readCtx() {
620
614
  const alsStore = _alsGetStore?.();
621
615
  const base = alsStore ?? _ctx;
622
616
  const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
623
- const t = typeof base.t === "function" && base.t !== fallbackT ? base.t : _buildT();
624
- return { ...base, ...data, t };
617
+ return { ...base, ...data, t: _buildT() };
625
618
  }
626
619
  function useCtx() {
627
620
  useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
628
621
  return _readCtx();
629
622
  }
630
- function getCtx() {
631
- return _readCtx();
632
- }
633
623
  var TsxContext = createContext(DEFAULT_CTX);
634
624
 
635
625
  // tsx-instance.ts
@@ -8635,7 +8625,6 @@ export {
8635
8625
  generateObject,
8636
8626
  generateText2 as generateText,
8637
8627
  getCookies,
8638
- getCtx,
8639
8628
  graphql,
8640
8629
  health,
8641
8630
  helmet,
package/dist/react.d.ts CHANGED
@@ -2,8 +2,10 @@ export { useWebsocket } from './use-websocket.ts';
2
2
  export type { UseWebsocketOptions, UseWebsocketReturn } from './use-websocket.ts';
3
3
  export { useAction } from './use-action.ts';
4
4
  export type { UseActionOptions, UseActionReturn } from './use-action.ts';
5
- export { Link, useNavigate, navigate, useNavigating } from './client-router.ts';
6
- export { TsxContext, useCtx, setCtx, getCtx } from './tsx-context.ts';
5
+ export { Link, useNavigate, navigate, useNavigating, addInterceptor } from './client-router.ts';
6
+ export { TsxContext, useCtx, setCtx } from './tsx-context.ts';
7
7
  export { Head } from './head.tsx';
8
8
  export { createStore, useData, useQueryState } from './client-state.ts';
9
9
  export type { StoreApi } from './client-state.ts';
10
+ export { useLocale } from './client-locale.ts';
11
+ export { useTheme, applyTheme } from './client-theme.ts';
package/dist/react.js CHANGED
@@ -141,73 +141,30 @@ function useAction(url, options) {
141
141
  // client-router.ts
142
142
  import { createElement, useCallback as useCallback3, useState as useState3, useEffect as useEffect2 } from "react";
143
143
 
144
- // tsx-context.ts
145
- import { useSyncExternalStore, createContext } from "react";
146
- var fallbackT = (key, _params, fallback) => fallback ?? key;
147
- var DEFAULT_CTX = { params: {}, query: {}, parsed: {}, prefs: {}, env: {}, t: fallbackT, user: {} };
148
- var _ctx = DEFAULT_CTX;
149
- var _snapshot = { params: _ctx.params, query: _ctx.query, user: _ctx.user, parsed: _ctx.parsed, prefs: _ctx.prefs, env: _ctx.env };
150
- var _listeners = /* @__PURE__ */ new Set();
151
- var subscribe = (cb) => {
152
- _listeners.add(cb);
153
- return () => {
154
- _listeners.delete(cb);
155
- };
156
- };
157
- var getSnapshot = () => _snapshot;
158
- var getServerSnapshot = getSnapshot;
159
- var _alsGetStore = null;
160
- function setCtx(value) {
161
- _ctx = { ..._ctx, ...value };
162
- _snapshot = { params: _ctx.params, query: _ctx.query, user: _ctx.user, parsed: _ctx.parsed, prefs: _ctx.prefs, env: _ctx.env };
163
- _listeners.forEach((fn) => fn());
144
+ // client-pref.ts
145
+ var interceptors = [];
146
+ function addInterceptor(fn) {
147
+ interceptors.push(fn);
164
148
  }
165
- var _cachedT = null;
166
- function _buildT() {
167
- if (_cachedT) return _cachedT;
168
- const messages = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
169
- if (!messages) {
170
- _cachedT = fallbackT;
171
- return fallbackT;
149
+ async function runInterceptors(url) {
150
+ for (const fn of interceptors) {
151
+ if (await fn(url)) return true;
172
152
  }
173
- _cachedT = (key, params, fallback) => {
174
- const msg = key.split(".").reduce((o, k) => o?.[k], messages);
175
- if (msg === void 0 || msg === null) return fallback ?? key;
176
- if (!params) return String(msg);
177
- let result = String(msg);
178
- for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
179
- return result;
180
- };
181
- return _cachedT;
182
- }
183
- function _readCtx() {
184
- const alsStore = _alsGetStore?.();
185
- const base = alsStore ?? _ctx;
186
- const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
187
- const t = typeof base.t === "function" && base.t !== fallbackT ? base.t : _buildT();
188
- return { ...base, ...data, t };
153
+ return false;
189
154
  }
190
- function useCtx() {
191
- useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
192
- return _readCtx();
193
- }
194
- function getCtx() {
195
- return _readCtx();
196
- }
197
- var TsxContext = createContext(DEFAULT_CTX);
198
155
 
199
156
  // client-router.ts
200
157
  var _navigating = false;
201
- var _listeners2 = [];
158
+ var _listeners = [];
202
159
  function onNavigate(fn) {
203
- _listeners2.push(fn);
160
+ _listeners.push(fn);
204
161
  return () => {
205
- _listeners2 = _listeners2.filter((l) => l !== fn);
162
+ _listeners = _listeners.filter((l) => l !== fn);
206
163
  };
207
164
  }
208
165
  function setNavigating(v) {
209
166
  _navigating = v;
210
- for (const fn of _listeners2) fn(v);
167
+ for (const fn of _listeners) fn(v);
211
168
  }
212
169
  async function navigate(href) {
213
170
  if (typeof document === "undefined") return;
@@ -216,30 +173,7 @@ async function navigate(href) {
216
173
  location.href = href;
217
174
  return;
218
175
  }
219
- const langMatch = url.pathname.match(/^\/__lang\/(\w+)$/);
220
- const themeMatch = url.pathname.match(/^\/__theme\/(\w+)$/);
221
- if (langMatch || themeMatch) {
222
- try {
223
- const res = await fetch(url.pathname, {
224
- headers: { accept: "application/json" }
225
- });
226
- const data = await res.json();
227
- const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
228
- if (data.locale) {
229
- ctx.prefs = { ...ctx.prefs, locale: data.locale };
230
- if (data.messages) window.__LOCALE_DATA__ = data.messages;
231
- }
232
- if (data.theme) {
233
- ctx.prefs = { ...ctx.prefs, theme: data.theme };
234
- }
235
- ;
236
- window.__WEIFUWU_CTX = ctx;
237
- setCtx(ctx);
238
- } catch {
239
- location.href = href;
240
- }
241
- return;
242
- }
176
+ if (await runInterceptors(url)) return;
243
177
  const scrollPos = [window.scrollX, window.scrollY];
244
178
  setNavigating(true);
245
179
  try {
@@ -396,6 +330,51 @@ async function prefetchPage(href) {
396
330
  }
397
331
  }
398
332
 
333
+ // tsx-context.ts
334
+ import { useSyncExternalStore, createContext } from "react";
335
+ var fallbackT = (key, _params, fallback) => fallback ?? key;
336
+ var DEFAULT_CTX = { params: {}, query: {}, parsed: {}, prefs: {}, env: {}, t: fallbackT, user: {} };
337
+ var _ctx = DEFAULT_CTX;
338
+ var _snapshot = { params: _ctx.params, query: _ctx.query, user: _ctx.user, parsed: _ctx.parsed, prefs: _ctx.prefs, env: _ctx.env };
339
+ var _listeners2 = /* @__PURE__ */ new Set();
340
+ var subscribe = (cb) => {
341
+ _listeners2.add(cb);
342
+ return () => {
343
+ _listeners2.delete(cb);
344
+ };
345
+ };
346
+ var getSnapshot = () => _snapshot;
347
+ var getServerSnapshot = getSnapshot;
348
+ var _alsGetStore = null;
349
+ function setCtx(value) {
350
+ _ctx = { ..._ctx, ...value };
351
+ _snapshot = { params: _ctx.params, query: _ctx.query, user: _ctx.user, parsed: _ctx.parsed, prefs: _ctx.prefs, env: _ctx.env };
352
+ _listeners2.forEach((fn) => fn());
353
+ }
354
+ function _buildT() {
355
+ const messages = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
356
+ if (!messages) return fallbackT;
357
+ return (key, params, fallback) => {
358
+ const msg = key.split(".").reduce((o, k) => o?.[k], messages);
359
+ if (msg === void 0 || msg === null) return fallback ?? key;
360
+ if (!params) return String(msg);
361
+ let result = String(msg);
362
+ for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
363
+ return result;
364
+ };
365
+ }
366
+ function _readCtx() {
367
+ const alsStore = _alsGetStore?.();
368
+ const base = alsStore ?? _ctx;
369
+ const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
370
+ return { ...base, ...data, t: _buildT() };
371
+ }
372
+ function useCtx() {
373
+ useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
374
+ return _readCtx();
375
+ }
376
+ var TsxContext = createContext(DEFAULT_CTX);
377
+
399
378
  // head.tsx
400
379
  import { createElement as createElement2 } from "react";
401
380
  function Head({ children }) {
@@ -534,19 +513,101 @@ function useQueryState(key, defaultValue = "") {
534
513
  }, [key, defaultValue]);
535
514
  return [value, setValue];
536
515
  }
516
+
517
+ // client-locale.ts
518
+ addInterceptor(async (url) => {
519
+ const m = url.pathname.match(/^\/__lang\/(\w+)$/);
520
+ if (!m) return false;
521
+ try {
522
+ const res = await fetch(url.pathname, {
523
+ headers: { accept: "application/json" }
524
+ });
525
+ const data = await res.json();
526
+ const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
527
+ ctx.prefs = { ...ctx.prefs, locale: data.locale };
528
+ if (data.messages) window.__LOCALE_DATA__ = data.messages;
529
+ window.__WEIFUWU_CTX = ctx;
530
+ setCtx(ctx);
531
+ } catch {
532
+ location.href = url.href;
533
+ }
534
+ return true;
535
+ });
536
+ function useLocale() {
537
+ const ctx = useCtx();
538
+ return {
539
+ locale: ctx.prefs.locale,
540
+ setLocale: (locale) => navigate("/__lang/" + locale),
541
+ t: ctx.t
542
+ };
543
+ }
544
+
545
+ // client-theme.ts
546
+ function resolveTheme(theme) {
547
+ if (theme === "system") {
548
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
549
+ }
550
+ return theme;
551
+ }
552
+ var _mqListener = null;
553
+ function applyTheme(theme) {
554
+ const resolved = resolveTheme(theme);
555
+ document.documentElement.dataset.theme = resolved;
556
+ if (theme === "system") {
557
+ if (!_mqListener) {
558
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
559
+ mq.addEventListener("change", (e) => {
560
+ if (window.__WEIFUWU_CTX?.prefs?.theme === "system") {
561
+ document.documentElement.dataset.theme = e.matches ? "dark" : "light";
562
+ }
563
+ });
564
+ _mqListener = mq;
565
+ }
566
+ }
567
+ }
568
+ addInterceptor(async (url) => {
569
+ const m = url.pathname.match(/^\/__theme\/(\w+)$/);
570
+ if (!m) return false;
571
+ try {
572
+ const res = await fetch(url.pathname, {
573
+ headers: { accept: "application/json" }
574
+ });
575
+ const data = await res.json();
576
+ const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
577
+ ctx.prefs = { ...ctx.prefs, theme: data.theme };
578
+ window.__WEIFUWU_CTX = ctx;
579
+ applyTheme(data.theme);
580
+ setCtx(ctx);
581
+ } catch {
582
+ location.href = url.href;
583
+ }
584
+ return true;
585
+ });
586
+ function useTheme() {
587
+ const ctx = useCtx();
588
+ const theme = ctx.prefs.theme ?? "system";
589
+ return {
590
+ theme,
591
+ resolvedTheme: resolveTheme(theme),
592
+ setTheme: (t) => navigate("/__theme/" + t)
593
+ };
594
+ }
537
595
  export {
538
596
  Head,
539
597
  Link,
540
598
  TsxContext,
599
+ addInterceptor,
600
+ applyTheme,
541
601
  createStore,
542
- getCtx,
543
602
  navigate,
544
603
  setCtx,
545
604
  useAction,
546
605
  useCtx,
547
606
  useData,
607
+ useLocale,
548
608
  useNavigate,
549
609
  useNavigating,
550
610
  useQueryState,
611
+ useTheme,
551
612
  useWebsocket
552
613
  };
@@ -12,14 +12,5 @@ export interface CtxValue {
12
12
  /** @internal Injected by tsx-instance.ts for async-safe context isolation */
13
13
  export declare function __registerAls(getStore: () => CtxValue | undefined): void;
14
14
  export declare function setCtx(value: Partial<CtxValue>): void;
15
- /**
16
- * React hook — returns the current request context.
17
- * Must be called from a React component or custom hook.
18
- */
19
15
  export declare function useCtx(): CtxValue;
20
- /**
21
- * Plain accessor — returns the current request context without calling any React hooks.
22
- * Safe to call from utility functions, route handlers, middleware, load functions, etc.
23
- */
24
- export declare function getCtx(): CtxValue;
25
16
  export declare const TsxContext: import("react").Context<CtxValue>;
@@ -1,6 +1,6 @@
1
1
  import { Router } from './router.ts';
2
- import { TsxContext, useCtx, setCtx, getCtx } from './tsx-context.ts';
3
- export { TsxContext, useCtx, setCtx, getCtx };
2
+ import { TsxContext, useCtx, setCtx } from './tsx-context.ts';
3
+ export { TsxContext, useCtx, setCtx };
4
4
  export interface TsxOptions {
5
5
  dir: string;
6
6
  }
package/dist/tsx.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { TsxContext, useCtx, setCtx, getCtx } from './tsx-instance.ts';
1
+ import { TsxContext, useCtx, setCtx } from './tsx-instance.ts';
2
2
  import type { TsxOptions } from './tsx-instance.ts';
3
3
  import type { Router } from './router.ts';
4
- export { TsxContext, useCtx, setCtx, getCtx };
4
+ export { TsxContext, useCtx, setCtx };
5
5
  export type { TsxOptions };
6
6
  export declare function tsx(options: TsxOptions): Promise<Router & {
7
7
  stop: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.17.18",
3
+ "version": "0.17.21",
4
4
  "description": "Web-standard HTTP framework for Node.js — (req, ctx) => Response",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",