weifuwu 0.17.7 → 0.17.9

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
@@ -1153,19 +1155,19 @@ In-memory page view tracking with a built-in dashboard. Zero extra dependencies.
1153
1155
  ```ts
1154
1156
  import { analytics } from 'weifuwu'
1155
1157
 
1156
- app.use(analytics()) // mounts middleware + /analytics + /__analytics/data
1158
+ app.use(analytics()) // mounts middleware + /__analytics + /__analytics/data
1157
1159
  ```
1158
1160
 
1159
1161
  | Endpoint | Description |
1160
1162
  |----------|-------------|
1161
- | `GET /analytics` | Dashboard — PV trend, top pages, referrers, device breakdown |
1163
+ | `GET /__analytics` | Dashboard — PV trend, top pages, referrers, device breakdown |
1162
1164
  | `GET /__analytics/data?days=7` | Raw JSON data for custom dashboards |
1163
1165
 
1164
- Excluded paths (not recorded): `/__analytics/*`, `__wfw/*`, `/static/*`, `/analytics`.
1166
+ Excluded paths (not recorded): `/__analytics/*`, `/__wfw/*`, `/static/*`.
1165
1167
 
1166
1168
  ### Dashboard
1167
1169
 
1168
- The built-in `/analytics` page renders a server-generated HTML dashboard with:
1170
+ The built-in `/__analytics` page renders a server-generated HTML dashboard with:
1169
1171
 
1170
1172
  - **Summary cards** — total PV, unique pages, mobile/desktop ratio
1171
1173
  - **Bar chart** — daily page views for the selected period
@@ -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,11 +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[];
4
5
  pg?: {
5
6
  sql: (strings: TemplateStringsArray, ...values: any[]) => Promise<any[]>;
7
+ table: (name: string, cols: any) => any;
6
8
  };
7
9
  }
8
- export declare function analytics(options?: AnalyticsOptions): {
9
- middleware: Middleware;
10
- handler: Handler;
11
- };
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
@@ -579,7 +579,8 @@ import chokidar from "chokidar";
579
579
 
580
580
  // tsx-context.ts
581
581
  import { useSyncExternalStore, createContext } from "react";
582
- var _ctx = { params: {}, query: {} };
582
+ var fallbackT = (key, _params, fallback) => fallback ?? key;
583
+ var _ctx = { params: {}, query: {}, parsed: {}, prefs: {}, env: {}, t: fallbackT, user: {} };
583
584
  var _listeners = /* @__PURE__ */ new Set();
584
585
  function setCtx(value) {
585
586
  _ctx = { ..._ctx, ...value };
@@ -587,7 +588,7 @@ function setCtx(value) {
587
588
  }
588
589
  function _buildT() {
589
590
  const messages2 = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
590
- if (!messages2) return void 0;
591
+ if (!messages2) return fallbackT;
591
592
  return (key, params, fallback) => {
592
593
  const msg = key.split(".").reduce((o, k) => o?.[k], messages2);
593
594
  if (msg === void 0 || msg === null) return fallback ?? key;
@@ -610,11 +611,9 @@ function useCtx() {
610
611
  );
611
612
  const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
612
613
  const t = data?.t ?? _ctx.t ?? _buildT();
613
- const result = { ..._ctx, ...data };
614
- if (t) result.t = t;
615
- return result;
614
+ return { ..._ctx, ...data, t };
616
615
  }
617
- var TsxContext = createContext({ params: {}, query: {} });
616
+ var TsxContext = createContext({ params: {}, query: {}, parsed: {}, prefs: {}, env: {}, t: fallbackT, user: {} });
618
617
 
619
618
  // tsx-instance.ts
620
619
  var liveReloadClients = /* @__PURE__ */ new Set();
@@ -904,8 +903,6 @@ var TsxInstance = class {
904
903
  user: ctx.user,
905
904
  parsed: ctx.parsed,
906
905
  prefs: ctx.prefs,
907
- locale: ctx.locale,
908
- theme: ctx.theme,
909
906
  t: ctx.t,
910
907
  env: ctx.env
911
908
  });
@@ -1075,8 +1072,6 @@ ${src}`;
1075
1072
  user: ctx.user,
1076
1073
  parsed: ctx.parsed,
1077
1074
  prefs: ctx.prefs,
1078
- locale: ctx.locale,
1079
- theme: ctx.theme,
1080
1075
  t: ctx.t,
1081
1076
  env: ctx.env
1082
1077
  });
@@ -1345,7 +1340,7 @@ function streamResponse(reactStream, opts) {
1345
1340
  function buildHeadPayload(opts) {
1346
1341
  const { ctx, base, compiledTailwindCss } = opts;
1347
1342
  let result = "";
1348
- if (ctx.theme) {
1343
+ if (ctx.prefs?.theme) {
1349
1344
  result += `<script>!function(){var t=(document.cookie.match(/(?:^|;\\s*)theme=([^;]+)/)||[])[1]||'system';if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.setAttribute('data-theme',t)}()</script>
1350
1345
  `;
1351
1346
  }
@@ -1362,11 +1357,9 @@ function buildHeadPayload(opts) {
1362
1357
  params: ctx.params,
1363
1358
  query: ctx.query,
1364
1359
  user: ctx.user,
1365
- parsed: ctx.parsed
1360
+ parsed: ctx.parsed,
1361
+ prefs: ctx.prefs
1366
1362
  };
1367
- if (ctx.prefs) ctxData.prefs = ctx.prefs;
1368
- if (ctx.locale) ctxData.locale = ctx.locale;
1369
- if (ctx.theme) ctxData.theme = ctx.theme;
1370
1363
  const publicEnv = {};
1371
1364
  for (const key of Object.keys(process.env)) {
1372
1365
  if (key.startsWith("WEIFUWU_PUBLIC_")) {
@@ -6799,7 +6792,7 @@ function health(options) {
6799
6792
  }
6800
6793
 
6801
6794
  // analytics.ts
6802
- var DEFAULT_EXCLUDED = ["/__analytics", "/__wfw", "/static", "/analytics"];
6795
+ var DEFAULT_EXCLUDED = ["/__analytics", "/__wfw", "/static"];
6803
6796
  var MemStore = class {
6804
6797
  days = /* @__PURE__ */ new Map();
6805
6798
  pages = /* @__PURE__ */ new Map();
@@ -6812,11 +6805,8 @@ var MemStore = class {
6812
6805
  }
6813
6806
  day.pv++;
6814
6807
  day.uv.add(path2);
6815
- if (mobile) {
6816
- day.mobile++;
6817
- } else {
6818
- day.desktop++;
6819
- }
6808
+ if (mobile) day.mobile++;
6809
+ else day.desktop++;
6820
6810
  let page = this.pages.get(path2);
6821
6811
  if (!page) {
6822
6812
  page = { count: 0 };
@@ -6837,11 +6827,8 @@ var MemStore = class {
6837
6827
  since.setDate(since.getDate() - days);
6838
6828
  const sinceStr = since.toISOString().slice(0, 10);
6839
6829
  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();
6830
+ let totalPv = 0, totalMobile = 0, totalDesktop = 0;
6831
+ const allUv = /* @__PURE__ */ new Set(), pageMap = /* @__PURE__ */ new Map();
6845
6832
  for (const [date, day] of this.days) {
6846
6833
  if (date < sinceStr) continue;
6847
6834
  daily.push({ date, pv: day.pv, uv: day.uv.size });
@@ -6850,64 +6837,80 @@ var MemStore = class {
6850
6837
  totalDesktop += day.desktop;
6851
6838
  for (const p of day.uv) allUv.add(p);
6852
6839
  }
6853
- for (const [path2, page] of this.pages) {
6854
- pageMap.set(path2, page.count);
6855
- }
6840
+ for (const [path2, page] of this.pages) pageMap.set(path2, page.count);
6856
6841
  const topPages = [...pageMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([path2, count]) => ({ path: path2, pv: count }));
6857
6842
  const refMap = /* @__PURE__ */ new Map();
6858
6843
  for (const [date, refs] of this.refs) {
6859
6844
  if (date < sinceStr) continue;
6860
6845
  for (const [domain, count] of refs) refMap.set(domain, (refMap.get(domain) || 0) + count);
6861
6846
  }
6862
- const referrers = [...refMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
6863
6847
  const total = totalMobile + totalDesktop || 1;
6864
6848
  return {
6865
6849
  total_pv: totalPv,
6866
6850
  total_uv: allUv.size,
6867
6851
  daily,
6868
6852
  top_pages: topPages,
6869
- referrers,
6853
+ referrers: [...refMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count })),
6870
6854
  devices: { mobile: Math.round(totalMobile / total * 1e3) / 10, desktop: Math.round(totalDesktop / total * 1e3) / 10 }
6871
6855
  };
6872
6856
  }
6873
6857
  };
6858
+ async function migratePg(sql2, table) {
6859
+ const analytics2 = table("__analytics", {
6860
+ date: text("date").notNull(),
6861
+ path: text("path").notNull(),
6862
+ count: integer("count").default(0),
6863
+ mobile: integer("mobile").default(0),
6864
+ desktop: integer("desktop").default(0)
6865
+ });
6866
+ await analytics2.create();
6867
+ await analytics2.createIndex(["date", "path"], { unique: true });
6868
+ return analytics2;
6869
+ }
6870
+ async function recordPg(sql2, path2, date, mobile) {
6871
+ await sql2`
6872
+ INSERT INTO __analytics (date, path, count, mobile, desktop)
6873
+ VALUES (${date}, ${path2}, 1, ${mobile ? 1 : 0}, ${mobile ? 0 : 1})
6874
+ ON CONFLICT (date, path) DO UPDATE SET
6875
+ count = __analytics.count + 1,
6876
+ mobile = __analytics.mobile + ${mobile ? 1 : 0},
6877
+ desktop = __analytics.desktop + ${mobile ? 0 : 1}
6878
+ `;
6879
+ }
6874
6880
  async function queryPg(sql2, days) {
6875
6881
  const since = /* @__PURE__ */ new Date();
6876
6882
  since.setDate(since.getDate() - days);
6883
+ const sinceStr = since.toISOString().slice(0, 10);
6877
6884
  const daily = await sql2`
6878
- SELECT date, SUM(count) as pv, COUNT(DISTINCT path) as uv
6879
- FROM __analytics WHERE date >= ${since.toISOString().slice(0, 10)}
6880
- GROUP BY date ORDER BY date
6885
+ SELECT date, SUM(count)::int as pv, COUNT(DISTINCT path)::int as uv
6886
+ FROM __analytics WHERE date >= ${sinceStr} GROUP BY date ORDER BY date
6881
6887
  `;
6882
6888
  const pageRows = await sql2`
6883
- SELECT path, SUM(count) as pv
6884
- FROM __analytics WHERE date >= ${since.toISOString().slice(0, 10)}
6889
+ SELECT path, SUM(count)::int as pv
6890
+ FROM __analytics WHERE date >= ${sinceStr}
6885
6891
  GROUP BY path ORDER BY pv DESC LIMIT 20
6886
6892
  `;
6887
6893
  const totalRes = await sql2`
6888
- SELECT COALESCE(SUM(count), 0) as total_pv,
6889
- COALESCE(SUM(mobile), 0) as total_mobile,
6890
- COALESCE(SUM(desktop), 0) as total_desktop
6891
- FROM __analytics WHERE date >= ${since.toISOString().slice(0, 10)}
6894
+ SELECT COALESCE(SUM(count), 0)::int as total_pv,
6895
+ COALESCE(SUM(mobile), 0)::int as total_mobile,
6896
+ COALESCE(SUM(desktop), 0)::int as total_desktop
6897
+ FROM __analytics WHERE date >= ${sinceStr}
6892
6898
  `;
6893
- const total = totalRes[0];
6894
- const totalMobileDesktop = total.total_mobile + total.total_desktop || 1;
6899
+ const t = totalRes[0];
6900
+ const denom = t.total_mobile + t.total_desktop || 1;
6895
6901
  return {
6896
- total_pv: total.total_pv,
6902
+ total_pv: t.total_pv,
6897
6903
  total_uv: pageRows.length,
6898
- daily: daily.map((d) => ({ date: d.date, pv: Number(d.pv), uv: Number(d.uv) })),
6899
- top_pages: pageRows.map((p) => ({ path: p.path, pv: Number(p.pv) })),
6904
+ daily: daily.map((d) => ({ date: d.date, pv: d.pv, uv: d.uv })),
6905
+ top_pages: pageRows.map((p) => ({ path: p.path, pv: p.pv })),
6900
6906
  referrers: [],
6901
- devices: {
6902
- mobile: Math.round(total.total_mobile / totalMobileDesktop * 1e3) / 10,
6903
- desktop: Math.round(total.total_desktop / totalMobileDesktop * 1e3) / 10
6904
- }
6907
+ devices: { mobile: Math.round(t.total_mobile / denom * 1e3) / 10, desktop: Math.round(t.total_desktop / denom * 1e3) / 10 }
6905
6908
  };
6906
6909
  }
6907
6910
  function renderDashboard(days, data) {
6908
- const { total_pv, total_uv, daily, top_pages, referrers } = data;
6909
- const maxPv = Math.max(...daily.map((d) => d.pv), 1);
6910
- const bars = daily.map(
6911
+ const { total_pv, total_uv, top_pages, referrers } = data;
6912
+ const maxPv = Math.max(...data.daily.map((d) => d.pv), 1);
6913
+ const bars = data.daily.map(
6911
6914
  (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>`
6912
6915
  ).join("");
6913
6916
  const rows = top_pages.map(
@@ -6916,11 +6919,9 @@ function renderDashboard(days, data) {
6916
6919
  const refRows = referrers.map(
6917
6920
  (r) => `<tr><td>${r.domain}</td><td class="num">${r.count}</td></tr>`
6918
6921
  ).join("");
6919
- return `<!DOCTYPE html>
6920
- <html lang="en">
6922
+ return `<!DOCTYPE html><html lang="en">
6921
6923
  <head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Analytics - weifuwu</title>
6922
- <style>
6923
- *,:before,:after{box-sizing:border-box;margin:0;padding:0}
6924
+ <style>*,:before,:after{box-sizing:border-box;margin:0;padding:0}
6924
6925
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8f9fa;color:#333;padding:24px;max-width:960px;margin:0 auto}
6925
6926
  h1{font-size:24px;font-weight:700;margin-bottom:24px}
6926
6927
  .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}
@@ -6958,28 +6959,23 @@ function analytics(options) {
6958
6959
  const excluded = options?.excluded ?? DEFAULT_EXCLUDED;
6959
6960
  const pg = options?.pg;
6960
6961
  const store = pg ? null : new MemStore();
6961
- const middleware = async (req, ctx, next) => {
6962
- const url = new URL(req.url);
6963
- const path2 = url.pathname;
6964
- if (excluded.some((e) => path2.startsWith(e))) return next(req, ctx);
6965
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6966
- const ref = req.headers.get("referer") || "";
6967
- const ua = req.headers.get("user-agent") || "";
6968
- const mobile = /mobile|android|iphone|ipad/i.test(ua);
6969
- if (pg) {
6970
- await pg.sql`
6971
- INSERT INTO __analytics (date, path, count, mobile, desktop)
6972
- VALUES (${date}, ${path2}, 1, ${mobile ? 1 : 0}, ${mobile ? 0 : 1})
6973
- ON CONFLICT (date, path) DO UPDATE SET
6974
- count = __analytics.count + 1,
6975
- mobile = __analytics.mobile + ${mobile ? 1 : 0},
6976
- desktop = __analytics.desktop + ${mobile ? 0 : 1}
6977
- `;
6978
- } else {
6979
- const refDomain = ref ? new URL(ref).hostname.replace(/^www\./, "") : "";
6980
- store.record(path2, date, refDomain, mobile);
6981
- }
6982
- return next(req, ctx);
6962
+ const middleware = () => {
6963
+ const m = async (req, ctx, next) => {
6964
+ const path2 = new URL(req.url).pathname;
6965
+ if (excluded.some((e) => path2.startsWith(e))) return next(req, ctx);
6966
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6967
+ const ua = req.headers.get("user-agent") || "";
6968
+ const mobile = /mobile|android|iphone|ipad/i.test(ua);
6969
+ if (pg) {
6970
+ await recordPg(pg.sql, path2, date, mobile);
6971
+ } else {
6972
+ const ref = req.headers.get("referer") || "";
6973
+ const refDomain = ref ? new URL(ref).hostname.replace(/^www\./, "") : "";
6974
+ store.record(path2, date, refDomain, mobile);
6975
+ }
6976
+ return next(req, ctx);
6977
+ };
6978
+ return m;
6983
6979
  };
6984
6980
  const handler = async (req) => {
6985
6981
  const url = new URL(req.url);
@@ -6990,7 +6986,18 @@ function analytics(options) {
6990
6986
  headers: { "content-type": "text/html; charset=utf-8" }
6991
6987
  });
6992
6988
  };
6993
- return { middleware, handler };
6989
+ const router = () => {
6990
+ const r = new Router();
6991
+ r.get("/__analytics/data", handler);
6992
+ r.get("/__analytics", handler);
6993
+ return r;
6994
+ };
6995
+ const migrate = async () => {
6996
+ if (pg) await migratePg(pg.sql, pg.table);
6997
+ };
6998
+ const close = async () => {
6999
+ };
7000
+ return { middleware, router, migrate, close };
6994
7001
  }
6995
7002
 
6996
7003
  // preferences.ts
@@ -7020,6 +7027,30 @@ function extractCookie(req, name) {
7020
7027
  }
7021
7028
  return null;
7022
7029
  }
7030
+ function prefCookie(name, value) {
7031
+ return `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
7032
+ }
7033
+ async function handlePrefSwitch(req, value, cookieName, load) {
7034
+ const isJson = req.headers.get("accept")?.includes("application/json");
7035
+ if (isJson) {
7036
+ const result = { ok: true };
7037
+ if (cookieName === "locale" || cookieName === "lang") {
7038
+ result.locale = value;
7039
+ const messages2 = await load(value);
7040
+ if (Object.keys(messages2).length > 0) result.messages = messages2;
7041
+ } else {
7042
+ result.theme = value;
7043
+ }
7044
+ return Response.json(result, {
7045
+ headers: { "Set-Cookie": prefCookie(cookieName, value) }
7046
+ });
7047
+ }
7048
+ const referer = req.headers.get("referer") || "/";
7049
+ return new Response(null, {
7050
+ status: 302,
7051
+ headers: { Location: referer, "Set-Cookie": prefCookie(cookieName, value) }
7052
+ });
7053
+ }
7023
7054
  function preferences(options) {
7024
7055
  const dir = options.dir ? resolve10(options.dir) : void 0;
7025
7056
  const localeOpts = { ...defaults.locale, ...options.locale };
@@ -7041,11 +7072,18 @@ function preferences(options) {
7041
7072
  }
7042
7073
  }
7043
7074
  return async (req, ctx, next) => {
7075
+ const url = new URL(req.url);
7076
+ const langMatch = url.pathname.match(/^\/__lang\/(\w+)$/);
7077
+ if (langMatch && req.method === "GET") {
7078
+ return handlePrefSwitch(req, langMatch[1], localeOpts.cookie, load);
7079
+ }
7080
+ const themeMatch = url.pathname.match(/^\/__theme\/(\w+)$/);
7081
+ if (themeMatch && req.method === "GET") {
7082
+ return handlePrefSwitch(req, themeMatch[1], themeOpts.cookie, load);
7083
+ }
7044
7084
  const locale = detectLocale(req, localeOpts);
7045
7085
  const theme = detectTheme(req, themeOpts);
7046
7086
  ctx.prefs = { locale, theme };
7047
- ctx.locale = locale;
7048
- ctx.theme = theme;
7049
7087
  if (dir) {
7050
7088
  const msgs = await load(locale);
7051
7089
  ctx.t = (key, params, fallback) => translate(msgs, key, params, fallback);
package/dist/react.js CHANGED
@@ -140,17 +140,57 @@ 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 fallbackT = (key, _params, fallback) => fallback ?? key;
147
+ var _ctx = { params: {}, query: {}, parsed: {}, prefs: {}, env: {}, t: fallbackT, user: {} };
148
+ var _listeners = /* @__PURE__ */ new Set();
149
+ function setCtx(value) {
150
+ _ctx = { ..._ctx, ...value };
151
+ _listeners.forEach((fn) => fn());
152
+ }
153
+ function _buildT() {
154
+ const messages = typeof window !== "undefined" ? window.__LOCALE_DATA__ : globalThis.__LOCALE_DATA__;
155
+ if (!messages) return fallbackT;
156
+ return (key, params, fallback) => {
157
+ const msg = key.split(".").reduce((o, k) => o?.[k], messages);
158
+ if (msg === void 0 || msg === null) return fallback ?? key;
159
+ if (!params) return String(msg);
160
+ let result = String(msg);
161
+ for (const [k, v] of Object.entries(params)) result = result.replace(`{${k}}`, v);
162
+ return result;
163
+ };
164
+ }
165
+ function useCtx() {
166
+ useSyncExternalStore(
167
+ (cb) => {
168
+ _listeners.add(cb);
169
+ return () => {
170
+ _listeners.delete(cb);
171
+ };
172
+ },
173
+ () => _ctx,
174
+ () => _ctx
175
+ );
176
+ const data = typeof window !== "undefined" ? window.__WEIFUWU_CTX : null;
177
+ const t = data?.t ?? _ctx.t ?? _buildT();
178
+ return { ..._ctx, ...data, t };
179
+ }
180
+ var TsxContext = createContext({ params: {}, query: {}, parsed: {}, prefs: {}, env: {}, t: fallbackT, user: {} });
181
+
182
+ // client-router.ts
143
183
  var _navigating = false;
144
- var _listeners = [];
184
+ var _listeners2 = [];
145
185
  function onNavigate(fn) {
146
- _listeners.push(fn);
186
+ _listeners2.push(fn);
147
187
  return () => {
148
- _listeners = _listeners.filter((l) => l !== fn);
188
+ _listeners2 = _listeners2.filter((l) => l !== fn);
149
189
  };
150
190
  }
151
191
  function setNavigating(v) {
152
192
  _navigating = v;
153
- for (const fn of _listeners) fn(v);
193
+ for (const fn of _listeners2) fn(v);
154
194
  }
155
195
  async function navigate(href) {
156
196
  if (typeof document === "undefined") return;
@@ -159,6 +199,30 @@ async function navigate(href) {
159
199
  location.href = href;
160
200
  return;
161
201
  }
202
+ const langMatch = url.pathname.match(/^\/__lang\/(\w+)$/);
203
+ const themeMatch = url.pathname.match(/^\/__theme\/(\w+)$/);
204
+ if (langMatch || themeMatch) {
205
+ try {
206
+ const res = await fetch(url.pathname, {
207
+ headers: { accept: "application/json" }
208
+ });
209
+ const data = await res.json();
210
+ const ctx = { ...window.__WEIFUWU_CTX || {}, params: {}, query: {} };
211
+ if (data.locale) {
212
+ ctx.prefs = { ...ctx.prefs, locale: data.locale };
213
+ if (data.messages) window.__LOCALE_DATA__ = data.messages;
214
+ }
215
+ if (data.theme) {
216
+ ctx.prefs = { ...ctx.prefs, theme: data.theme };
217
+ }
218
+ ;
219
+ window.__WEIFUWU_CTX = ctx;
220
+ setCtx(ctx);
221
+ } catch {
222
+ location.href = href;
223
+ }
224
+ return;
225
+ }
162
226
  const scrollPos = [window.scrollX, window.scrollY];
163
227
  setNavigating(true);
164
228
  try {
@@ -315,45 +379,6 @@ async function prefetchPage(href) {
315
379
  }
316
380
  }
317
381
 
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
382
  // head.tsx
358
383
  import { createElement as createElement2 } from "react";
359
384
  function Head({ children }) {
@@ -1,13 +1,13 @@
1
1
  export interface CtxValue {
2
2
  params: Record<string, string>;
3
3
  query: Record<string, string>;
4
- user?: unknown;
5
- parsed?: Record<string, unknown>;
6
- prefs?: Record<string, string>;
7
- locale?: string;
8
- theme?: string;
9
- t?: (key: string, params?: Record<string, string>, fallback?: string) => string;
10
- env?: Record<string, string>;
4
+ user: {
5
+ id?: string;
6
+ };
7
+ parsed: Record<string, unknown>;
8
+ prefs: Record<string, string>;
9
+ t: (key: string, params?: Record<string, string>, fallback?: string) => string;
10
+ env: Record<string, string>;
11
11
  }
12
12
  export declare function setCtx(value: Partial<CtxValue>): void;
13
13
  export declare function useCtx(): CtxValue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
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",