weifuwu 0.17.19 → 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/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';
5
+ export { Link, useNavigate, navigate, useNavigating, addInterceptor } from './client-router.ts';
6
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,63 +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());
164
- }
165
- function _buildT() {
166
- const messages = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
167
- if (!messages) return fallbackT;
168
- return (key, params, fallback) => {
169
- const msg = key.split(".").reduce((o, k) => o?.[k], messages);
170
- if (msg === void 0 || msg === null) return fallback ?? key;
171
- if (!params) return String(msg);
172
- let result = String(msg);
173
- for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
174
- return result;
175
- };
144
+ // client-pref.ts
145
+ var interceptors = [];
146
+ function addInterceptor(fn) {
147
+ interceptors.push(fn);
176
148
  }
177
- function _readCtx() {
178
- const alsStore = _alsGetStore?.();
179
- const base = alsStore ?? _ctx;
180
- const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
181
- return { ...base, ...data, t: _buildT() };
182
- }
183
- function useCtx() {
184
- useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
185
- return _readCtx();
149
+ async function runInterceptors(url) {
150
+ for (const fn of interceptors) {
151
+ if (await fn(url)) return true;
152
+ }
153
+ return false;
186
154
  }
187
- var TsxContext = createContext(DEFAULT_CTX);
188
155
 
189
156
  // client-router.ts
190
157
  var _navigating = false;
191
- var _listeners2 = [];
158
+ var _listeners = [];
192
159
  function onNavigate(fn) {
193
- _listeners2.push(fn);
160
+ _listeners.push(fn);
194
161
  return () => {
195
- _listeners2 = _listeners2.filter((l) => l !== fn);
162
+ _listeners = _listeners.filter((l) => l !== fn);
196
163
  };
197
164
  }
198
165
  function setNavigating(v) {
199
166
  _navigating = v;
200
- for (const fn of _listeners2) fn(v);
167
+ for (const fn of _listeners) fn(v);
201
168
  }
202
169
  async function navigate(href) {
203
170
  if (typeof document === "undefined") return;
@@ -206,30 +173,7 @@ async function navigate(href) {
206
173
  location.href = href;
207
174
  return;
208
175
  }
209
- const langMatch = url.pathname.match(/^\/__lang\/(\w+)$/);
210
- const themeMatch = url.pathname.match(/^\/__theme\/(\w+)$/);
211
- if (langMatch || themeMatch) {
212
- try {
213
- const res = await fetch(url.pathname, {
214
- headers: { accept: "application/json" }
215
- });
216
- const data = await res.json();
217
- const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
218
- if (data.locale) {
219
- ctx.prefs = { ...ctx.prefs, locale: data.locale };
220
- if (data.messages) window.__LOCALE_DATA__ = data.messages;
221
- }
222
- if (data.theme) {
223
- ctx.prefs = { ...ctx.prefs, theme: data.theme };
224
- }
225
- ;
226
- window.__WEIFUWU_CTX = ctx;
227
- setCtx(ctx);
228
- } catch {
229
- location.href = href;
230
- }
231
- return;
232
- }
176
+ if (await runInterceptors(url)) return;
233
177
  const scrollPos = [window.scrollX, window.scrollY];
234
178
  setNavigating(true);
235
179
  try {
@@ -386,6 +330,51 @@ async function prefetchPage(href) {
386
330
  }
387
331
  }
388
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
+
389
378
  // head.tsx
390
379
  import { createElement as createElement2 } from "react";
391
380
  function Head({ children }) {
@@ -524,18 +513,101 @@ function useQueryState(key, defaultValue = "") {
524
513
  }, [key, defaultValue]);
525
514
  return [value, setValue];
526
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
+ }
527
595
  export {
528
596
  Head,
529
597
  Link,
530
598
  TsxContext,
599
+ addInterceptor,
600
+ applyTheme,
531
601
  createStore,
532
602
  navigate,
533
603
  setCtx,
534
604
  useAction,
535
605
  useCtx,
536
606
  useData,
607
+ useLocale,
537
608
  useNavigate,
538
609
  useNavigating,
539
610
  useQueryState,
611
+ useTheme,
540
612
  useWebsocket
541
613
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.17.19",
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",