weifuwu 0.17.6 → 0.17.8

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
@@ -58,7 +58,7 @@ app.use('/', await tsx({ dir: './ui' }))
58
58
  serve(app.handler(), { port: 3000 })
59
59
  ```
60
60
 
61
- Your tsx pages can use it directly:
61
+ Your tsx pages can use it directly, and locale/theme switching routes (`/__lang/zh`, `/__theme/dark`) work automatically without extra routes:
62
62
 
63
63
  ```tsx
64
64
  import { Head, useCtx, useData, createStore } from 'weifuwu/react'
@@ -556,6 +556,8 @@ function Nav() {
556
556
 
557
557
  `navigate(href)` fetches the target via SSR, extracts `__weifuwu_root` content and `__WEIFUWU_PROPS`, replaces in-place, then imports the new hydration bundle. `load.ts` runs on the server for every navigation. Initial load is full SSR; subsequent navigations are client-side.
558
558
 
559
+ Language and theme switching URLs (`/__lang/:locale`, `/__theme/:theme`) are handled by `navigate()` without page reload — the preference cookie is set via a JSON request and the context is updated via `setCtx()` (see [Preferences](#preferences)).
560
+
559
561
  - `<Link prefetch>` — pre-fetches page data on hover / when entering viewport (200px margin)
560
562
  - `useNavigating()` — reactive boolean, `true` while navigation is in-flight
561
563
  - `isNavigating()` / `onNavigate(fn)` — non-hook alternatives
@@ -1242,6 +1244,56 @@ Flash messages: set via `ctx.setPref('flash', ...)` → auto-read from cookie
1242
1244
  `ctx.t()` supports dot-path nested keys: `t('tools.uppercase.title')` traverses the JSON structure.
1243
1245
  `ctx.env` exposes `WEIFUWU_PUBLIC_*` environment variables on both server and client (via `useCtx().env`).
1244
1246
 
1247
+ ### Language / Theme switching
1248
+
1249
+ The middleware automatically handles `/__lang/:locale` and `/__theme/:theme` without needing extra routes:
1250
+
1251
+ ```ts
1252
+ app.use(preferences({ dir: './locales' }))
1253
+ // ✓ /__lang/zh, /__lang/en, /__theme/dark, /__theme/light automatically work
1254
+ ```
1255
+
1256
+ **Server behavior:**
1257
+
1258
+ | Mode | Request | Response |
1259
+ |------|---------|----------|
1260
+ | Redirect | `GET /__lang/zh` (browser) | 302 with `Set-Cookie: locale=zh` back to referer |
1261
+ | JSON | `GET /__lang/zh` + `Accept: application/json` | `{ ok: true, locale: 'zh', messages: { ... } }` + `Set-Cookie` |
1262
+
1263
+ **Client-side no-refresh switching:**
1264
+
1265
+ ```tsx
1266
+ import { Link } from 'weifuwu/react'
1267
+
1268
+ <Link href="/__lang/zh">中文</Link> // Instant switch — no page reload
1269
+ <Link href="/__lang/en">English</Link> // Instant switch — no page reload
1270
+ <Link href="/__theme/dark">🌙</Link> // Instant switch — no page reload
1271
+ ```
1272
+
1273
+ When using `<Link>` (or `navigate()`), the router intercepts `__lang`/`__theme` URLs:
1274
+ 1. Fetches the endpoint with `Accept: application/json`
1275
+ 2. Updates `window.__WEIFUWU_CTX` (locale, theme, messages)
1276
+ 3. Calls `setCtx()` — React re-renders via `useSyncExternalStore`
1277
+ 4. Page content stays intact, only translations/theme update
1278
+
1279
+ Plain `<a href="/__lang/zh">` still works via traditional 302 redirect for full backward compatibility.
1280
+
1281
+ ### Options
1282
+
1283
+ ```ts
1284
+ app.use(preferences({
1285
+ dir: './locales', // translation JSON directory
1286
+ locale: {
1287
+ default: 'en', // fallback when no cookie or Accept-Language
1288
+ cookie: 'locale', // cookie name (default: 'locale')
1289
+ fromAcceptLanguage: true, // detect from Accept-Language header
1290
+ },
1291
+ theme: {
1292
+ default: 'system', // 'light' | 'dark' | 'system'
1293
+ cookie: 'theme', // cookie name (default: 'theme')
1294
+ },
1295
+ }))
1296
+
1245
1297
  ---
1246
1298
 
1247
1299
  ## Email
@@ -1290,6 +1342,7 @@ app.get('/stream', (req, ctx) => createSSEStream(events()))
1290
1342
  | Hook / Component | Description |
1291
1343
  |-----------------|-------------|
1292
1344
  | `useCtx()` | Unified context — `{ prefs, locale, theme, t, params, query, env }` (requires `preferences` middleware) |
1345
+ | `setCtx(value)` | Update context — triggers re-render in all `useCtx()` consumers |
1293
1346
  | `createStore(initial)` | Zustand-compatible shared state — `getState`, `setState`, `subscribe` |
1294
1347
  | `useData(url, opts?)` | SWR-style data fetching — cache, dedup, mutate, fallback |
1295
1348
  | `useQueryState(key, default)` | URL query param sync — `?page=1` via `useSyncExternalStore` |
@@ -1,8 +1,16 @@
1
- import type { Handler, Middleware } from './types.ts';
1
+ import type { Middleware } from './types.ts';
2
+ import { Router } from './router.ts';
2
3
  export interface AnalyticsOptions {
3
4
  excluded?: string[];
5
+ pg?: {
6
+ sql: (strings: TemplateStringsArray, ...values: any[]) => Promise<any[]>;
7
+ table: (name: string, cols: any) => any;
8
+ };
4
9
  }
5
- export declare function analytics(options?: AnalyticsOptions): {
6
- middleware: Middleware;
7
- handler: Handler;
8
- };
10
+ export interface AnalyticsModule {
11
+ middleware: () => Middleware;
12
+ router: () => Router;
13
+ migrate: () => Promise<void>;
14
+ close: () => Promise<void>;
15
+ }
16
+ export declare function analytics(options?: AnalyticsOptions): AnalyticsModule;
@@ -9,6 +9,7 @@ interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>,
9
9
  prefetch?: boolean;
10
10
  }
11
11
  export declare function Link({ href, children, onClick, prefetch, ...props }: LinkProps): import("react").DetailedReactHTMLElement<{
12
+ id?: string | undefined | undefined;
12
13
  slot?: string | undefined | undefined;
13
14
  style?: import("react").CSSProperties | undefined;
14
15
  title?: string | undefined | undefined;
@@ -34,7 +35,6 @@ export declare function Link({ href, children, onClick, prefetch, ...props }: Li
34
35
  draggable?: (boolean | "true" | "false") | undefined;
35
36
  enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
36
37
  hidden?: boolean | undefined | undefined;
37
- id?: string | undefined | undefined;
38
38
  lang?: string | undefined | undefined;
39
39
  nonce?: string | undefined | undefined;
40
40
  spellCheck?: (boolean | "true" | "false") | undefined;
package/dist/index.d.ts CHANGED
@@ -55,7 +55,7 @@ export type { OpencodeOptions, OpencodeModule, SkillDef, OpencodePermissions, Se
55
55
  export { health } from './health.ts';
56
56
  export type { HealthOptions } from './health.ts';
57
57
  export { analytics } from './analytics.ts';
58
- export type { AnalyticsOptions } from './analytics.ts';
58
+ export type { AnalyticsOptions, AnalyticsModule } from './analytics.ts';
59
59
  export { preferences } from './preferences.ts';
60
60
  export type { PrefOptions } from './preferences.ts';
61
61
  export { seo, seoMiddleware, seoTags } from './seo.ts';
package/dist/index.js CHANGED
@@ -6812,11 +6812,8 @@ var MemStore = class {
6812
6812
  }
6813
6813
  day.pv++;
6814
6814
  day.uv.add(path2);
6815
- if (mobile) {
6816
- day.mobile++;
6817
- } else {
6818
- day.desktop++;
6819
- }
6815
+ if (mobile) day.mobile++;
6816
+ else day.desktop++;
6820
6817
  let page = this.pages.get(path2);
6821
6818
  if (!page) {
6822
6819
  page = { count: 0 };
@@ -6837,11 +6834,8 @@ var MemStore = class {
6837
6834
  since.setDate(since.getDate() - days);
6838
6835
  const sinceStr = since.toISOString().slice(0, 10);
6839
6836
  const daily = [];
6840
- let totalPv = 0;
6841
- let totalMobile = 0;
6842
- let totalDesktop = 0;
6843
- const pageMap = /* @__PURE__ */ new Map();
6844
- const allUv = /* @__PURE__ */ new Set();
6837
+ let totalPv = 0, totalMobile = 0, totalDesktop = 0;
6838
+ const allUv = /* @__PURE__ */ new Set(), pageMap = /* @__PURE__ */ new Map();
6845
6839
  for (const [date, day] of this.days) {
6846
6840
  if (date < sinceStr) continue;
6847
6841
  daily.push({ date, pv: day.pv, uv: day.uv.size });
@@ -6850,56 +6844,91 @@ var MemStore = class {
6850
6844
  totalDesktop += day.desktop;
6851
6845
  for (const p of day.uv) allUv.add(p);
6852
6846
  }
6853
- for (const [path2, page] of this.pages) {
6854
- pageMap.set(path2, page.count);
6855
- }
6847
+ for (const [path2, page] of this.pages) pageMap.set(path2, page.count);
6856
6848
  const topPages = [...pageMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([path2, count]) => ({ path: path2, pv: count }));
6857
6849
  const refMap = /* @__PURE__ */ new Map();
6858
6850
  for (const [date, refs] of this.refs) {
6859
6851
  if (date < sinceStr) continue;
6860
6852
  for (const [domain, count] of refs) refMap.set(domain, (refMap.get(domain) || 0) + count);
6861
6853
  }
6862
- const referrers = [...refMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
6863
6854
  const total = totalMobile + totalDesktop || 1;
6864
6855
  return {
6865
6856
  total_pv: totalPv,
6866
6857
  total_uv: allUv.size,
6867
6858
  daily,
6868
6859
  top_pages: topPages,
6869
- referrers,
6860
+ referrers: [...refMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count })),
6870
6861
  devices: { mobile: Math.round(totalMobile / total * 1e3) / 10, desktop: Math.round(totalDesktop / total * 1e3) / 10 }
6871
6862
  };
6872
6863
  }
6873
- handler() {
6874
- return async (req) => {
6875
- const url = new URL(req.url);
6876
- const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 7, 1), 365);
6877
- const data = this.query(days);
6878
- if (url.pathname === "/__analytics/data") {
6879
- return Response.json(data);
6880
- }
6881
- return new Response(this.renderDashboard(days, data), {
6882
- headers: { "content-type": "text/html; charset=utf-8" }
6883
- });
6884
- };
6885
- }
6886
- renderDashboard(days, data) {
6887
- const { total_pv, total_uv, daily, top_pages, referrers, devices } = data;
6888
- const maxPv = Math.max(...daily.map((d) => d.pv), 1);
6889
- const bars = daily.map(
6890
- (d) => `<div class="bar-wrap"><div class="bar" style="height:${d.pv / maxPv * 100}%"></div><span class="bar-label">${d.date.slice(5)}</span></div>`
6891
- ).join("");
6892
- const rows = top_pages.map(
6893
- (p, i) => `<tr><td class="num">${i + 1}</td><td class="path">${p.path}</td><td class="num">${p.pv}</td></tr>`
6894
- ).join("");
6895
- const refRows = referrers.map(
6896
- (r) => `<tr><td>${r.domain}</td><td class="num">${r.count}</td></tr>`
6897
- ).join("");
6898
- return `<!DOCTYPE html>
6899
- <html lang="en">
6864
+ };
6865
+ async function migratePg(sql2, table) {
6866
+ const analytics2 = table("__analytics", {
6867
+ date: text("date").notNull(),
6868
+ path: text("path").notNull(),
6869
+ count: integer("count").default(0),
6870
+ mobile: integer("mobile").default(0),
6871
+ desktop: integer("desktop").default(0)
6872
+ });
6873
+ await analytics2.create();
6874
+ await analytics2.createIndex(["date", "path"], { unique: true });
6875
+ return analytics2;
6876
+ }
6877
+ async function recordPg(sql2, path2, date, mobile) {
6878
+ await sql2`
6879
+ INSERT INTO __analytics (date, path, count, mobile, desktop)
6880
+ VALUES (${date}, ${path2}, 1, ${mobile ? 1 : 0}, ${mobile ? 0 : 1})
6881
+ ON CONFLICT (date, path) DO UPDATE SET
6882
+ count = __analytics.count + 1,
6883
+ mobile = __analytics.mobile + ${mobile ? 1 : 0},
6884
+ desktop = __analytics.desktop + ${mobile ? 0 : 1}
6885
+ `;
6886
+ }
6887
+ async function queryPg(sql2, days) {
6888
+ const since = /* @__PURE__ */ new Date();
6889
+ since.setDate(since.getDate() - days);
6890
+ const sinceStr = since.toISOString().slice(0, 10);
6891
+ const daily = await sql2`
6892
+ SELECT date, SUM(count)::int as pv, COUNT(DISTINCT path)::int as uv
6893
+ FROM __analytics WHERE date >= ${sinceStr} GROUP BY date ORDER BY date
6894
+ `;
6895
+ const pageRows = await sql2`
6896
+ SELECT path, SUM(count)::int as pv
6897
+ FROM __analytics WHERE date >= ${sinceStr}
6898
+ GROUP BY path ORDER BY pv DESC LIMIT 20
6899
+ `;
6900
+ const totalRes = await sql2`
6901
+ SELECT COALESCE(SUM(count), 0)::int as total_pv,
6902
+ COALESCE(SUM(mobile), 0)::int as total_mobile,
6903
+ COALESCE(SUM(desktop), 0)::int as total_desktop
6904
+ FROM __analytics WHERE date >= ${sinceStr}
6905
+ `;
6906
+ const t = totalRes[0];
6907
+ const denom = t.total_mobile + t.total_desktop || 1;
6908
+ return {
6909
+ total_pv: t.total_pv,
6910
+ total_uv: pageRows.length,
6911
+ daily: daily.map((d) => ({ date: d.date, pv: d.pv, uv: d.uv })),
6912
+ top_pages: pageRows.map((p) => ({ path: p.path, pv: p.pv })),
6913
+ referrers: [],
6914
+ devices: { mobile: Math.round(t.total_mobile / denom * 1e3) / 10, desktop: Math.round(t.total_desktop / denom * 1e3) / 10 }
6915
+ };
6916
+ }
6917
+ function renderDashboard(days, data) {
6918
+ const { total_pv, total_uv, top_pages, referrers } = data;
6919
+ const maxPv = Math.max(...data.daily.map((d) => d.pv), 1);
6920
+ const bars = data.daily.map(
6921
+ (d) => `<div class="bar-wrap"><div class="bar" style="height:${d.pv / maxPv * 100}%"></div><span class="bar-label">${d.date.slice(5)}</span></div>`
6922
+ ).join("");
6923
+ const rows = top_pages.map(
6924
+ (p, i) => `<tr><td class="num">${i + 1}</td><td class="path">${p.path}</td><td class="num">${p.pv}</td></tr>`
6925
+ ).join("");
6926
+ const refRows = referrers.map(
6927
+ (r) => `<tr><td>${r.domain}</td><td class="num">${r.count}</td></tr>`
6928
+ ).join("");
6929
+ return `<!DOCTYPE html><html lang="en">
6900
6930
  <head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Analytics - weifuwu</title>
6901
- <style>
6902
- *,:before,:after{box-sizing:border-box;margin:0;padding:0}
6931
+ <style>*,:before,:after{box-sizing:border-box;margin:0;padding:0}
6903
6932
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8f9fa;color:#333;padding:24px;max-width:960px;margin:0 auto}
6904
6933
  h1{font-size:24px;font-weight:700;margin-bottom:24px}
6905
6934
  .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}
@@ -6924,36 +6953,58 @@ tr:hover td{background:#f8faff}
6924
6953
  <div class="cards">
6925
6954
  <div class="card"><div class="val">${total_pv}</div><div class="lbl">Page Views (${days}d)</div></div>
6926
6955
  <div class="card"><div class="val">${total_uv}</div><div class="lbl">Unique Pages</div></div>
6927
- <div class="card"><div class="val">${devices.mobile}%</div><div class="lbl">Mobile</div></div>
6928
- <div class="card"><div class="val">${devices.desktop}%</div><div class="lbl">Desktop</div></div>
6956
+ <div class="card"><div class="val">${data.devices.mobile}%</div><div class="lbl">Mobile</div></div>
6957
+ <div class="card"><div class="val">${data.devices.desktop}%</div><div class="lbl">Desktop</div></div>
6929
6958
  </div>
6930
6959
  <div class="section"><h2>Daily Page Views</h2><div class="chart">${bars}</div></div>
6931
6960
  <div class="section"><h2>Top Pages</h2>
6932
6961
  <table><thead><tr><th style="width:32px">#</th><th>Path</th><th style="width:64px">Views</th></tr></thead><tbody>${rows}</tbody></table></div>
6933
6962
  ${referrers.length ? `<div class="section"><h2>Referrers</h2><table><thead><tr><th>Domain</th><th style="width:64px">Views</th></tr></thead><tbody>${refRows}</tbody></table></div>` : ""}
6934
6963
  </body></html>`;
6935
- }
6936
- };
6964
+ }
6937
6965
  function analytics(options) {
6938
6966
  const excluded = options?.excluded ?? DEFAULT_EXCLUDED;
6939
- const store = new MemStore();
6940
- const middleware = async (req, ctx, next) => {
6941
- const url = new URL(req.url);
6942
- const path2 = url.pathname;
6943
- if (!excluded.some((e) => path2.startsWith(e))) {
6967
+ const pg = options?.pg;
6968
+ const store = pg ? null : new MemStore();
6969
+ const middleware = () => {
6970
+ const m = async (req, ctx, next) => {
6971
+ const path2 = new URL(req.url).pathname;
6972
+ if (excluded.some((e) => path2.startsWith(e))) return next(req, ctx);
6944
6973
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6945
- const ref = req.headers.get("referer") || "";
6946
- const refDomain = ref ? new URL(ref).hostname.replace(/^www\./, "") : "";
6947
6974
  const ua = req.headers.get("user-agent") || "";
6948
6975
  const mobile = /mobile|android|iphone|ipad/i.test(ua);
6949
- store.record(path2, date, refDomain, mobile);
6950
- }
6951
- return next(req, ctx);
6976
+ if (pg) {
6977
+ await recordPg(pg.sql, path2, date, mobile);
6978
+ } else {
6979
+ const ref = req.headers.get("referer") || "";
6980
+ const refDomain = ref ? new URL(ref).hostname.replace(/^www\./, "") : "";
6981
+ store.record(path2, date, refDomain, mobile);
6982
+ }
6983
+ return next(req, ctx);
6984
+ };
6985
+ return m;
6952
6986
  };
6953
- return {
6954
- middleware,
6955
- handler: store.handler()
6987
+ const handler = async (req) => {
6988
+ const url = new URL(req.url);
6989
+ const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 7, 1), 365);
6990
+ const data = pg ? await queryPg(pg.sql, days) : store.query(days);
6991
+ if (url.pathname === "/__analytics/data") return Response.json(data);
6992
+ return new Response(renderDashboard(days, data), {
6993
+ headers: { "content-type": "text/html; charset=utf-8" }
6994
+ });
6956
6995
  };
6996
+ const router = () => {
6997
+ const r = new Router();
6998
+ r.get("/__analytics/data", handler);
6999
+ r.get("/analytics", handler);
7000
+ return r;
7001
+ };
7002
+ const migrate = async () => {
7003
+ if (pg) await migratePg(pg.sql, pg.table);
7004
+ };
7005
+ const close = async () => {
7006
+ };
7007
+ return { middleware, router, migrate, close };
6957
7008
  }
6958
7009
 
6959
7010
  // preferences.ts
@@ -6983,6 +7034,30 @@ function extractCookie(req, name) {
6983
7034
  }
6984
7035
  return null;
6985
7036
  }
7037
+ function prefCookie(name, value) {
7038
+ return `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
7039
+ }
7040
+ async function handlePrefSwitch(req, value, cookieName, load) {
7041
+ const isJson = req.headers.get("accept")?.includes("application/json");
7042
+ if (isJson) {
7043
+ const result = { ok: true };
7044
+ if (cookieName === "locale" || cookieName === "lang") {
7045
+ result.locale = value;
7046
+ const messages2 = await load(value);
7047
+ if (Object.keys(messages2).length > 0) result.messages = messages2;
7048
+ } else {
7049
+ result.theme = value;
7050
+ }
7051
+ return Response.json(result, {
7052
+ headers: { "Set-Cookie": prefCookie(cookieName, value) }
7053
+ });
7054
+ }
7055
+ const referer = req.headers.get("referer") || "/";
7056
+ return new Response(null, {
7057
+ status: 302,
7058
+ headers: { Location: referer, "Set-Cookie": prefCookie(cookieName, value) }
7059
+ });
7060
+ }
6986
7061
  function preferences(options) {
6987
7062
  const dir = options.dir ? resolve10(options.dir) : void 0;
6988
7063
  const localeOpts = { ...defaults.locale, ...options.locale };
@@ -7004,6 +7079,15 @@ function preferences(options) {
7004
7079
  }
7005
7080
  }
7006
7081
  return async (req, ctx, next) => {
7082
+ const url = new URL(req.url);
7083
+ const langMatch = url.pathname.match(/^\/__lang\/(\w+)$/);
7084
+ if (langMatch && req.method === "GET") {
7085
+ return handlePrefSwitch(req, langMatch[1], localeOpts.cookie, load);
7086
+ }
7087
+ const themeMatch = url.pathname.match(/^\/__theme\/(\w+)$/);
7088
+ if (themeMatch && req.method === "GET") {
7089
+ return handlePrefSwitch(req, themeMatch[1], themeOpts.cookie, load);
7090
+ }
7007
7091
  const locale = detectLocale(req, localeOpts);
7008
7092
  const theme = detectTheme(req, themeOpts);
7009
7093
  ctx.prefs = { locale, theme };
package/dist/react.js CHANGED
@@ -140,17 +140,58 @@ function useAction(url, options) {
140
140
 
141
141
  // client-router.ts
142
142
  import { createElement, useCallback as useCallback3, useState as useState3, useEffect as useEffect2 } from "react";
143
+
144
+ // tsx-context.ts
145
+ import { useSyncExternalStore, createContext } from "react";
146
+ var _ctx = { params: {}, query: {} };
147
+ var _listeners = /* @__PURE__ */ new Set();
148
+ function setCtx(value) {
149
+ _ctx = { ..._ctx, ...value };
150
+ _listeners.forEach((fn) => fn());
151
+ }
152
+ function _buildT() {
153
+ const messages = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
154
+ if (!messages) return void 0;
155
+ return (key, params, fallback) => {
156
+ const msg = key.split(".").reduce((o, k) => o?.[k], messages);
157
+ if (msg === void 0 || msg === null) return fallback ?? key;
158
+ if (!params) return String(msg);
159
+ let result = String(msg);
160
+ for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
161
+ return result;
162
+ };
163
+ }
164
+ function useCtx() {
165
+ useSyncExternalStore(
166
+ (cb) => {
167
+ _listeners.add(cb);
168
+ return () => {
169
+ _listeners.delete(cb);
170
+ };
171
+ },
172
+ () => _ctx,
173
+ () => _ctx
174
+ );
175
+ const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
176
+ const t = data?.t ?? _ctx.t ?? _buildT();
177
+ const result = { ..._ctx, ...data };
178
+ if (t) result.t = t;
179
+ return result;
180
+ }
181
+ var TsxContext = createContext({ params: {}, query: {} });
182
+
183
+ // client-router.ts
143
184
  var _navigating = false;
144
- var _listeners = [];
185
+ var _listeners2 = [];
145
186
  function onNavigate(fn) {
146
- _listeners.push(fn);
187
+ _listeners2.push(fn);
147
188
  return () => {
148
- _listeners = _listeners.filter((l) => l !== fn);
189
+ _listeners2 = _listeners2.filter((l) => l !== fn);
149
190
  };
150
191
  }
151
192
  function setNavigating(v) {
152
193
  _navigating = v;
153
- for (const fn of _listeners) fn(v);
194
+ for (const fn of _listeners2) fn(v);
154
195
  }
155
196
  async function navigate(href) {
156
197
  if (typeof document === "undefined") return;
@@ -159,6 +200,32 @@ async function navigate(href) {
159
200
  location.href = href;
160
201
  return;
161
202
  }
203
+ const langMatch = url.pathname.match(/^\/__lang\/(\w+)$/);
204
+ const themeMatch = url.pathname.match(/^\/__theme\/(\w+)$/);
205
+ if (langMatch || themeMatch) {
206
+ try {
207
+ const res = await fetch(url.pathname, {
208
+ headers: { accept: "application/json" }
209
+ });
210
+ const data = await res.json();
211
+ const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
212
+ if (data.locale) {
213
+ ctx.locale = data.locale;
214
+ ctx.prefs = { ...ctx.prefs, locale: data.locale };
215
+ if (data.messages) window.__LOCALE_DATA__ = data.messages;
216
+ }
217
+ if (data.theme) {
218
+ ctx.theme = data.theme;
219
+ ctx.prefs = { ...ctx.prefs, theme: data.theme };
220
+ }
221
+ ;
222
+ window.__WEIFUWU_CTX = ctx;
223
+ setCtx(ctx);
224
+ } catch {
225
+ location.href = href;
226
+ }
227
+ return;
228
+ }
162
229
  const scrollPos = [window.scrollX, window.scrollY];
163
230
  setNavigating(true);
164
231
  try {
@@ -315,45 +382,6 @@ async function prefetchPage(href) {
315
382
  }
316
383
  }
317
384
 
318
- // tsx-context.ts
319
- import { useSyncExternalStore, createContext } from "react";
320
- var _ctx = { params: {}, query: {} };
321
- var _listeners2 = /* @__PURE__ */ new Set();
322
- function setCtx(value) {
323
- _ctx = { ..._ctx, ...value };
324
- _listeners2.forEach((fn) => fn());
325
- }
326
- function _buildT() {
327
- const messages = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
328
- if (!messages) return void 0;
329
- return (key, params, fallback) => {
330
- const msg = key.split(".").reduce((o, k) => o?.[k], messages);
331
- if (msg === void 0 || msg === null) return fallback ?? key;
332
- if (!params) return String(msg);
333
- let result = String(msg);
334
- for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
335
- return result;
336
- };
337
- }
338
- function useCtx() {
339
- useSyncExternalStore(
340
- (cb) => {
341
- _listeners2.add(cb);
342
- return () => {
343
- _listeners2.delete(cb);
344
- };
345
- },
346
- () => _ctx,
347
- () => _ctx
348
- );
349
- const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
350
- const t = data?.t ?? _ctx.t ?? _buildT();
351
- const result = { ..._ctx, ...data };
352
- if (t) result.t = t;
353
- return result;
354
- }
355
- var TsxContext = createContext({ params: {}, query: {} });
356
-
357
385
  // head.tsx
358
386
  import { createElement as createElement2 } from "react";
359
387
  function Head({ children }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.17.6",
3
+ "version": "0.17.8",
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",