weifuwu 0.16.6 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,6 @@ description: Web-standard HTTP framework for Node.js — (req, ctx) => Response
14
14
  - [React SSR (tsx)](#react-ssr-tsx)
15
15
  - [PostgreSQL](#postgresql)
16
16
  - [Auth](#auth)
17
- - [Security](#security)
18
17
  - [WebSocket & Real-time](#websocket--real-time)
19
18
  - [AI](#ai)
20
19
  - [Data Layer](#data-layer)
@@ -26,7 +25,7 @@ description: Web-standard HTTP framework for Node.js — (req, ctx) => Response
26
25
  - [Opencode](#opencode)
27
26
  - [Deploy](#deploy)
28
27
  - [Health check](#health-check)
29
- - [Internationalization](#internationalization)
28
+ - [Preferences](#preferences)
30
29
  - [Email](#email)
31
30
  - [Server-Sent Events](#server-sent-events)
32
31
  - [Utility functions](#utility-functions)
@@ -44,17 +43,42 @@ import { serve } from 'weifuwu'
44
43
  serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
45
44
  ```
46
45
 
47
- ### weifuwu init
46
+ ### Full-stack SSR in one file
47
+
48
+ ```ts
49
+ import { serve, Router, tsx, preferences } from 'weifuwu'
50
+
51
+ const app = new Router()
52
+ app.use(preferences({
53
+ dir: './locales', // i18n (0 extra deps)
54
+ theme: {}, // dark mode (0 extra deps)
55
+ }))
56
+ app.use('/', await tsx({ dir: './ui' }))
57
+
58
+ serve(app.handler(), { port: 3000 })
59
+ ```
60
+
61
+ Your tsx pages can use it directly:
62
+
63
+ ```tsx
64
+ import { Head, useCtx, useData, createStore } from 'weifuwu/react'
48
65
 
49
- Generate a full project with React SSR, WebSocket, Tailwind CSS, and graceful shutdown:
66
+ export default function Page() {
67
+ const { t, theme } = useCtx() // i18n + theme
68
+ const { data } = useData('/api/list') // data fetching
69
+ return <h1>{t('hello')} / {theme}</h1>
70
+ }
71
+ ```
72
+
73
+ **Zero extra dependencies** — no zustand, react-query, next-intl, next-themes, react-hot-toast needed.
74
+
75
+ ### weifuwu init
50
76
 
51
77
  ```bash
52
78
  npx weifuwu init my-app
53
79
  cd my-app && npm install && npm run dev
54
80
  ```
55
81
 
56
- Creates `app.ts` with `tsx()`, `router.ws()`, `/api/ping` API route, and `ui/pages/layout.tsx` + `page.tsx`.
57
-
58
82
  ---
59
83
 
60
84
  ## serve() — HTTP server
@@ -183,10 +207,11 @@ app.get('/admin', middleware, handler) // route-level
183
207
  | `compress(options?)` | Brotli / Gzip / Deflate compression |
184
208
  | `validate(schemas)` | Zod validation (body, query, params) |
185
209
  | `upload(options?)` | Multipart file upload |
186
- | `i18n(options)` | Internationalization `ctx.t()`, locale detection |
210
+ | `preferences(options?)` | Locale + theme detection, `ctx.t()`, `ctx.prefs`, `ctx.setPref()` |
187
211
  | `seoMiddleware(options?)` | `X-Robots-Tag` header — string or path-based function |
188
212
  | `helmet(options?)` | Security headers — CSP, HSTS, X-Frame-Options, etc. |
189
213
  | `requestId(options?)` | `X-Request-ID` header + `ctx.requestId` |
214
+ | `health(options?)` | `GET /health` endpoint with custom checks |
190
215
 
191
216
  ### auth
192
217
 
@@ -332,6 +357,8 @@ app.use(helmet({
332
357
  }))
333
358
  ```
334
359
 
360
+ 13 security headers by default: `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Strict-Transport-Security`, `Content-Security-Policy`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`, `Cross-Origin-Embedder-Policy`, `X-DNS-Prefetch-Control`, `X-Download-Options`, `X-Permitted-Cross-Domain-Policies`.
361
+
335
362
  ### requestId
336
363
 
337
364
  ```ts
@@ -341,14 +368,11 @@ app.use(requestId())
341
368
  // Sets X-Request-ID header on responses, available as ctx.requestId
342
369
  ```
343
370
 
344
- 13 security headers set by default with `helmet()`: `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Strict-Transport-Security`, `Content-Security-Policy`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Openner-Policy`, `Cross-Origin-Resource-Policy`, `Cross-Origin-Embedder-Policy`, `X-DNS-Prefetch-Control`, `X-Download-Options`, `X-Permitted-Cross-Domain-Policies`.
345
-
346
371
  ---
347
372
 
348
373
  ## React SSR (tsx)
349
374
 
350
375
  ```ts
351
- import { serve, Router } from 'weifuwu'
352
376
  import { serve, Router, tsx } from 'weifuwu'
353
377
 
354
378
  const app = new Router()
@@ -378,12 +402,25 @@ ui/
378
402
 
379
403
  ### page.tsx — page component
380
404
 
405
+ Components receive `{ params, query }` from routing and can use hooks for
406
+ context, data fetching, state, URL sync, and meta tags (see [exports table](#react-exports-weifuwureact)):
407
+
381
408
  ```tsx
382
- export default function Page({ params, query }: {
383
- params: { slug: string }
384
- query: Record<string, string>
385
- }) {
386
- return <article><h1>{params.slug}</h1></article>
409
+ import { Head, useCtx, useData, createStore, useQueryState } from 'weifuwu/react'
410
+
411
+ const useFilters = createStore({ category: '' })
412
+
413
+ export default function Page({ params, query }: { params: { slug: string }; query: Record<string, string> }) {
414
+ const { t, locale, theme } = useCtx() // i18n + theme + prefs
415
+ const [page, setPage] = useQueryState('page', '1') // URL sync
416
+ const { data, loading, mutate } = useData(`/api/posts?page=${page}`) // data fetching
417
+
418
+ return (
419
+ <>
420
+ <Head><title>{t('page.title')}</title></Head>
421
+ {loading ? <Skeleton /> : data.posts.map(p => <Card key={p.id} />)}
422
+ </>
423
+ )
387
424
  }
388
425
  ```
389
426
 
@@ -422,6 +459,8 @@ export default function RootLayout({ children, req, ctx }: {
422
459
 
423
460
  **Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`.
424
461
 
462
+ Page components access preferences, i18n, and theme via `useCtx()` — see [Preferences](#preferences).
463
+
425
464
  ### route.ts — API (co-located with page)
426
465
 
427
466
  ```ts
@@ -446,6 +485,17 @@ const apiUrl = process.env.WEIFUWU_PUBLIC_API_URL
446
485
 
447
486
  The hydration bundle also injects `self.process = { env: {} }` as a safety net so any `process.env.*` reference in bundled dependencies won't throw.
448
487
 
488
+ ### Streaming SSR
489
+
490
+ HTML is streamed via `TransformStream` — the browser starts rendering before the full page is ready. `<head>` content (theme blocking script, locale data, CSS) is injected at the `</head>` boundary and sent immediately.
491
+
492
+ ### Persistent layout
493
+
494
+ The client hydration bundle creates a persistent `App` root that wraps all pages. Client-side navigation via `<Link>` (or `navigate()`) replaces the page component in-place instead of unmounting and re-hydrating. This means:
495
+ - `TsxContext.Provider` stays alive across navigations
496
+ - `createStore()` state persists (no cache-busting on bundle imports)
497
+ - Faster navigation — React only re-renders the page component
498
+
449
499
  ### Client-side hooks
450
500
 
451
501
  #### useWebsocket — auto-reconnecting WebSocket
@@ -490,13 +540,14 @@ Auto-serializes JSON, auto-reads `_csrf` cookie and sends as `X-CSRF-Token`. Ret
490
540
  ### Client-side navigation
491
541
 
492
542
  ```tsx
493
- import { Link, useNavigate } from 'weifuwu/react'
543
+ import { Link, useNavigate, useNavigating } from 'weifuwu/react'
494
544
 
495
545
  function Nav() {
496
546
  const navigate = useNavigate()
547
+ const loading = useNavigating()
497
548
  return (
498
- <nav>
499
- <Link href="/about">About</Link>
549
+ <nav className={loading ? 'opacity-50' : ''}>
550
+ <Link href="/about" prefetch>About</Link>
500
551
  <button onClick={() => navigate('/contact')}>Contact</button>
501
552
  </nav>
502
553
  )
@@ -505,6 +556,126 @@ function Nav() {
505
556
 
506
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.
507
558
 
559
+ - `<Link prefetch>` — pre-fetches page data on hover / when entering viewport (200px margin)
560
+ - `useNavigating()` — reactive boolean, `true` while navigation is in-flight
561
+ - `isNavigating()` / `onNavigate(fn)` — non-hook alternatives
562
+ - Scroll position is saved before navigation and restored after the new page renders
563
+
564
+ ### Head — per-page meta tags
565
+
566
+ ```tsx
567
+ import { Head } from 'weifuwu/react'
568
+
569
+ export default function Page() {
570
+ return (
571
+ <>
572
+ <Head>
573
+ <title>My Page - App</title>
574
+ <meta name="description" content="Page description" />
575
+ <meta property="og:title" content="My Page" />
576
+ </Head>
577
+ <h1>Content</h1>
578
+ </>
579
+ )
580
+ }
581
+ ```
582
+
583
+ During SSR, the `<Head>` content is extracted from the body and merged into `<head>`. On client-side navigation via `<Link>`, title and meta tags are updated automatically.
584
+
585
+ ### Flash messages
586
+
587
+ ```ts
588
+ // Server: set a flash message before redirect
589
+ router.post('/post', (req, ctx) => {
590
+ return ctx.setPref('flash', JSON.stringify({
591
+ type: 'success', message: 'Published!'
592
+ })) // → 302 with Set-Cookie: flash=...
593
+ })
594
+ ```
595
+
596
+ ```tsx
597
+ // Client: display flash from preferences
598
+ function Toast() {
599
+ const { prefs } = useCtx()
600
+ const flash = prefs?.flash ? JSON.parse(prefs.flash) : null
601
+ if (!flash) return null
602
+ return <div className={`toast ${flash.type}`}>{flash.message}</div>
603
+ }
604
+ ```
605
+
606
+ Flash is read once from the cookie, then automatically cleared on the response. After page refresh the flash is gone.
607
+
608
+ ### Client-side state management
609
+
610
+ #### createStore — shared state (replaces Zustand)
611
+
612
+ ```tsx
613
+ import { createStore } from 'weifuwu/react'
614
+
615
+ const useStore = createStore({ count: 0, items: [] as string[] })
616
+
617
+ function Counter() {
618
+ const count = useStore(s => s.count) // selector
619
+ const { setState, getState } = useStore() // full state + API
620
+ return <button onClick={() => setState({ count: count + 1 })}>{count}</button>
621
+ }
622
+
623
+ function List() {
624
+ const items = useStore(s => s.items)
625
+ return items.map(i => <div>{i}</div>)
626
+ }
627
+
628
+ // Outside components:
629
+ useStore.getState()
630
+ useStore.setState({ count: 1 })
631
+ useStore.subscribe(() => {})
632
+ ```
633
+
634
+ Uses `useSyncExternalStore` internally. No context provider needed. State persists across client-side navigations (no cache-busting on bundle imports).
635
+
636
+ #### useData — data fetching (replaces React Query / SWR)
637
+
638
+ ```tsx
639
+ import { useData } from 'weifuwu/react'
640
+
641
+ // Client-only fetch (shows loading skeleton on first load)
642
+ function PostList() {
643
+ const { data, error, loading, mutate } = useData('/api/posts')
644
+ if (loading) return <Skeleton />
645
+ return <div>{data.posts.map(p => <PostCard key={p.id} post={p} />)}</div>
646
+ }
647
+
648
+ // With SSR fallback — data from load.ts, client takes over after hydration
649
+ function PostList({ load }: { load: { posts: Post[] } }) {
650
+ const { data, mutate } = useData('/api/posts', { fallback: load })
651
+ return <div>{data.posts.map(p => <PostCard key={p.id} post={p} />)}</div>
652
+ }
653
+ ```
654
+
655
+ In-memory cache with 60s TTL, concurrent request dedup. `mutate(data)` for optimistic updates, `mutate()` for revalidation.
656
+
657
+ #### useQueryState — URL query params
658
+
659
+ ```tsx
660
+ import { useQueryState } from 'weifuwu/react'
661
+
662
+ function SearchPage() {
663
+ const [q, setQ] = useQueryState('q', '')
664
+ const [page, setPage] = useQueryState('page', '1')
665
+ const { data } = useData(`/api/search?q=${q}&page=${page}`)
666
+
667
+ return (
668
+ <>
669
+ <input value={q} onChange={e => { setQ(e.target.value); setPage('1') }} />
670
+ <Results items={data?.items} />
671
+ <Pagination page={Number(page)} onChange={setPage} />
672
+ </>
673
+ )
674
+ }
675
+ ```
676
+
677
+ Synced with `window.location.search` via `useSyncExternalStore`. Back/forward navigation updates the state. Changes use `history.replaceState` and dispatch a synthetic `popstate` for reactivity.
678
+
508
679
  ### Development mode
509
680
 
510
681
  Auto-detected when `NODE_ENV !== 'production'`. File watching (`chokidar`), single-file recompilation, WebSocket live reload (`/__weifuwu/livereload`), Tailwind CSS v4 auto-compilation.
@@ -990,18 +1161,31 @@ app.use(health({
990
1161
 
991
1162
  ---
992
1163
 
993
- ## Internationalization
1164
+ ## Preferences
994
1165
 
995
1166
  ```ts
996
- import { i18n } from 'weifuwu'
1167
+ import { preferences } from 'weifuwu'
997
1168
 
998
- app.use(i18n({ dir: './locales', defaultLocale: 'en' }))
1169
+ app.use(preferences({
1170
+ dir: './locales', // translation directory (optional)
1171
+ locale: { default: 'en' }, // locale detection
1172
+ theme: { default: 'system' }, // 'light' | 'dark' | 'system'
1173
+ }))
999
1174
 
1000
1175
  // In handlers: ctx.t('greeting') → "Hello"
1001
- // In layout: ctx.locale → "en"
1176
+ // ctx.locale → "en"
1177
+ // ctx.theme → "light"
1178
+ // ctx.prefs → { locale: 'en', theme: 'light' }
1179
+ // ctx.setPref('locale', 'zh') → 302 + cookie
1180
+ // ctx.setPref('flash', '{"type":"success","message":"Done"}') → flash message
1181
+
1182
+ // In tsx components:
1183
+ const { t, locale, theme } = useCtx()
1002
1184
  ```
1003
1185
 
1004
- Locale detection: `Accept-Language` header browser preference. Falls back to `defaultLocale`.
1186
+ Locale detection priority: cookie → `Accept-Language` → default.
1187
+ Theme detection: cookie → default (`'system'`).
1188
+ Flash messages: set via `ctx.setPref('flash', ...)` → auto-read from cookie → cleared after rendering.
1005
1189
 
1006
1190
  ---
1007
1191
 
@@ -1045,13 +1229,22 @@ app.get('/stream', (req, ctx) => createSSEStream(events()))
1045
1229
  | `formatSSE(event, data)` | Format SSE event string |
1046
1230
  | `formatSSEData(data)` | Format SSE data string |
1047
1231
  | `runWorkflow(options)` | DAG execution engine as AI SDK Tool |
1048
- | `useWebsocket(url, opts?)` | React auto-reconnecting WebSocket hook |
1049
- | `useAction(url, opts?)` | React async form submission hook |
1050
- | `navigate(href)` | Client-side page navigation |
1051
- | `useNavigate()` | React hook returning navigate function |
1052
- | `csrf(options?)` | CSRF protection middleware |
1053
- | `seoTags(config)` | Generate `<title>`, `<meta>`, OG, Twitter Card tags |
1054
- | `createHub(options?)` | WebSocket channel hub |
1232
+
1233
+ ### React exports (`weifuwu/react`)
1234
+
1235
+ | Hook / Component | Description |
1236
+ |-----------------|-------------|
1237
+ | `useCtx()` | Unified context `{ prefs, locale, theme, t, params, query }` (requires `preferences` middleware) |
1238
+ | `createStore(initial)` | Zustand-compatible shared state — `getState`, `setState`, `subscribe` |
1239
+ | `useData(url, opts?)` | SWR-style data fetching — cache, dedup, mutate, fallback |
1240
+ | `useQueryState(key, default)` | URL query param sync — `?page=1` via `useSyncExternalStore` |
1241
+ | `useAction(url, opts?)` | Async form submission — `{ submit, data, error, pending }` |
1242
+ | `useWebsocket(url, opts?)` | Auto-reconnecting WebSocket — `{ send, lastMessage, readyState }` |
1243
+ | `useNavigate()` | Client-side navigation callback |
1244
+ | `useNavigating()` | Reactive loading state during navigation |
1245
+ | `navigate(href)` | Client-side page navigation (imperative) |
1246
+ | `Link` | `<Link href prefetch>` — prefetch on hover/visible |
1247
+ | `Head` | `<Head>` — per-page `<title>` / `<meta>` merged into `<head>` |
1055
1248
 
1056
1249
  ### AI SDK re-exports
1057
1250
 
@@ -1,10 +1,19 @@
1
+ export declare function isNavigating(): boolean;
2
+ export declare function onNavigate(fn: (v: boolean) => void): () => void;
1
3
  export declare function navigate(href: string): Promise<void>;
2
4
  export declare function useNavigate(): (href: string) => Promise<void>;
5
+ export declare function useNavigating(): boolean;
3
6
  interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
4
7
  href: string;
5
8
  children: React.ReactNode;
9
+ prefetch?: boolean;
6
10
  }
7
- export declare function Link({ href, children, onClick, ...props }: LinkProps): import("react").DetailedReactHTMLElement<{
11
+ export declare function Link({ href, children, onClick, prefetch, ...props }: LinkProps): import("react").DetailedReactHTMLElement<{
12
+ slot?: string | undefined | undefined;
13
+ style?: import("react").CSSProperties | undefined;
14
+ title?: string | undefined | undefined;
15
+ dir?: string | undefined | undefined;
16
+ property?: string | undefined | undefined;
8
17
  download?: any;
9
18
  hrefLang?: string | undefined | undefined;
10
19
  media?: string | undefined | undefined;
@@ -22,18 +31,14 @@ export declare function Link({ href, children, onClick, ...props }: LinkProps):
22
31
  className?: string | undefined | undefined;
23
32
  contentEditable?: (boolean | "true" | "false") | "inherit" | "plaintext-only" | undefined;
24
33
  contextMenu?: string | undefined | undefined;
25
- dir?: string | undefined | undefined;
26
34
  draggable?: (boolean | "true" | "false") | undefined;
27
35
  enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
28
36
  hidden?: boolean | undefined | undefined;
29
37
  id?: string | undefined | undefined;
30
38
  lang?: string | undefined | undefined;
31
39
  nonce?: string | undefined | undefined;
32
- slot?: string | undefined | undefined;
33
40
  spellCheck?: (boolean | "true" | "false") | undefined;
34
- style?: import("react").CSSProperties | undefined;
35
41
  tabIndex?: number | undefined | undefined;
36
- title?: string | undefined | undefined;
37
42
  translate?: "yes" | "no" | undefined | undefined;
38
43
  radioGroup?: string | undefined | undefined;
39
44
  role?: import("react").AriaRole | undefined;
@@ -42,7 +47,6 @@ export declare function Link({ href, children, onClick, ...props }: LinkProps):
42
47
  datatype?: string | undefined | undefined;
43
48
  inlist?: any;
44
49
  prefix?: string | undefined | undefined;
45
- property?: string | undefined | undefined;
46
50
  rel?: string | undefined | undefined;
47
51
  resource?: string | undefined | undefined;
48
52
  rev?: string | undefined | undefined;
@@ -230,7 +234,7 @@ export declare function Link({ href, children, onClick, ...props }: LinkProps):
230
234
  onDropCapture?: import("react").DragEventHandler<HTMLAnchorElement> | undefined;
231
235
  onMouseDown?: import("react").MouseEventHandler<HTMLAnchorElement> | undefined;
232
236
  onMouseDownCapture?: import("react").MouseEventHandler<HTMLAnchorElement> | undefined;
233
- onMouseEnter?: import("react").MouseEventHandler<HTMLAnchorElement> | undefined;
237
+ onMouseEnter: import("react").MouseEventHandler<HTMLAnchorElement>;
234
238
  onMouseLeave?: import("react").MouseEventHandler<HTMLAnchorElement> | undefined;
235
239
  onMouseMove?: import("react").MouseEventHandler<HTMLAnchorElement> | undefined;
236
240
  onMouseMoveCapture?: import("react").MouseEventHandler<HTMLAnchorElement> | undefined;
@@ -0,0 +1,22 @@
1
+ type SetPartial<T> = Partial<T> | ((prev: T) => Partial<T>);
2
+ export interface StoreApi<T> {
3
+ (): T;
4
+ <S>(selector: (state: T) => S): S;
5
+ getState: () => T;
6
+ setState: (partial: SetPartial<T>) => void;
7
+ subscribe: (listener: () => void) => () => void;
8
+ }
9
+ export declare function createStore<T extends Record<string, unknown>>(initial: T): StoreApi<T>;
10
+ interface UseDataResult<T> {
11
+ data: T | undefined;
12
+ error: Error | undefined;
13
+ loading: boolean;
14
+ mutate: (data?: T) => Promise<void>;
15
+ }
16
+ interface UseDataOptions<T> {
17
+ fallback?: T;
18
+ ttl?: number;
19
+ }
20
+ export declare function useData<T = unknown>(url: string | null, options?: UseDataOptions<T>): UseDataResult<T>;
21
+ export declare function useQueryState(key: string, defaultValue?: string): [string, (val: string | ((prev: string) => string)) => void];
22
+ export {};
package/dist/head.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function Head({ children }: {
3
+ children: ReactNode;
4
+ }): import("react").DetailedReactHTMLElement<{
5
+ id: string;
6
+ }, HTMLElement>;
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, useTsx } from './tsx.ts';
7
+ export { tsx, TsxContext, useCtx } 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';
@@ -54,8 +54,8 @@ export { opencode } from './opencode/index.ts';
54
54
  export type { OpencodeOptions, OpencodeModule, SkillDef, OpencodePermissions, Session as OpencodeSession, } from './opencode/types.ts';
55
55
  export { health } from './health.ts';
56
56
  export type { HealthOptions } from './health.ts';
57
- export { i18n } from './i18n.ts';
58
- export type { I18nOptions } from './i18n.ts';
57
+ export { preferences } from './preferences.ts';
58
+ export type { PrefOptions } from './preferences.ts';
59
59
  export { seo, seoMiddleware, seoTags } from './seo.ts';
60
60
  export type { SeoOptions, RobotsRule, SitemapUrl, SitemapConfig, SeoHeadersConfig, SeoTagsConfig, } from './seo.ts';
61
61
  export { mailer } from './mailer.ts';