weifuwu 0.33.3 → 0.33.5

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
@@ -39,25 +39,24 @@ serve(app, { port: 3000 })
39
39
  ### Frontend
40
40
 
41
41
  ```tsx
42
- import { signal, Show, For, createApp, router, RouteView } from 'weifuwu/client'
42
+ import { signal, createApp, api, auth, ws, router, RouteView, LoginForm } from 'weifuwu/client'
43
43
  import type { WfuiContext } from 'weifuwu/client'
44
44
 
45
- function HomePage(_props: {}, ctx: WfuiContext) {
45
+ function AppShell(_props: {}, ctx: WfuiContext) {
46
+ if (!ctx.isAuthenticated) return <LoginForm />
46
47
  return (
47
48
  <div>
48
- <h1>Hello weifuwu</h1>
49
- <p>当前路径: {ctx.route.path}</p>
49
+ <nav><a onClick={() => ctx.app.navigate('/chat')}>聊天</a></nav>
50
+ <main><RouteView /></main>
50
51
  </div>
51
52
  )
52
53
  }
53
54
 
54
55
  const app = createApp()
55
- app.use(router({
56
- routes: [
57
- { path: '/', component: HomePage, title: '首页' },
58
- { path: '/chat/:id', component: ChatPage, title: '聊天' },
59
- ],
60
- }))
56
+ app.use(api()) // ← ctx.api.get/post
57
+ app.use(auth()) // ← ctx.user / ctx.login / ctx.logout
58
+ app.use(ws()) // ctx.ws.send / onMessage
59
+ app.use(router({ routes })) // ctx.route / 路由
61
60
  app.mount('#root', AppShell)
62
61
  ```
63
62
 
@@ -67,7 +66,7 @@ app.mount('#root', AppShell)
67
66
  ```
68
67
 
69
68
  ```js
70
- // build.mjs
69
+ // build.mjs — 可选,也可用 ctx.ui.js() 服务端动态编译
71
70
  esbuild.build({
72
71
  entryPoints: ['src/main.tsx'],
73
72
  jsx: 'automatic',
@@ -76,6 +75,11 @@ esbuild.build({
76
75
  })
77
76
  ```
78
77
 
78
+ 或用服务端动态编译(无需独立构建脚本):
79
+ ```ts
80
+ app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
81
+ ```
82
+
79
83
  ### Environment Variables
80
84
 
81
85
  | Variable | Default | Used by |
@@ -127,6 +131,7 @@ esbuild.build({
127
131
  | `redis()` | Redis client (`ctx.redis`) |
128
132
  | `queue()` | Job queue + cron |
129
133
  | `createHub()` | WebSocket pub/sub |
134
+ | `ui()` | SSR + SPA rendering (`ctx.ui.html`, `ctx.ui.js`, `ctx.ui.css`) |
130
135
 
131
136
  ### Frontend (weifuwu/client)
132
137
 
@@ -140,6 +145,11 @@ esbuild.build({
140
145
  | `<RouteView>` | Route outlet |
141
146
  | `createApp()` | App instance with middleware chain |
142
147
  | `router()` | Hash/history router with params + query |
148
+ | `api()` | HTTP client (`ctx.api.get/post`) |
149
+ | `auth()` | Auth state (`ctx.user/login/logout`) |
150
+ | `ws()` | WebSocket (`ctx.ws.send/onMessage`) |
151
+ | `LoginForm` | Login/register form component |
152
+ | `Chat` | Real-time messaging component |
143
153
  | `domMount()` | Direct DOM mounting |
144
154
 
145
155
  ### Utilities
@@ -218,6 +228,106 @@ function AppShell(_, ctx) {
218
228
  ctx.route.path // "/chat/123"
219
229
  ctx.route.params // { id: "123" }
220
230
  ctx.route.query // { tab: "settings" }
231
+
232
+ ### Middleware: api / auth / ws
233
+
234
+ ```tsx
235
+ import { api, auth, ws } from 'weifuwu/client'
236
+
237
+ app.use(api()) // ctx.api.get/post/put/patch/delete
238
+ app.use(auth()) // ctx.user / ctx.login / ctx.logout / ctx.register
239
+ app.use(ws()) // ctx.ws.send / onMessage / join / leave
240
+ ```
241
+
242
+ `api()` creates a fetch client with automatic token injection.
243
+ `auth()` persists sessions to localStorage, validates tokens on startup.
244
+ `ws()` manages WebSocket connections with auto-reconnect.
245
+
246
+ ### Pre-built Components
247
+
248
+ ```tsx
249
+ import { LoginForm, Chat } from 'weifuwu/client'
250
+
251
+ // Login / Register form
252
+ function LoginPage(_, ctx) {
253
+ if (ctx.isAuthenticated) return ctx.app.navigate('/')
254
+ return <LoginForm />
255
+ }
256
+
257
+ // Real-time chat
258
+ function ChatPage(_, ctx) {
259
+ return <Chat conversationId="123" />
260
+ }
261
+ ```
262
+
263
+ ### SSR & SPA (ctx.ui.html / ctx.ui.js / ctx.ui.css)
264
+
265
+ ```ts
266
+ import { ui } from 'weifuwu'
267
+
268
+ app.use(ui())
269
+
270
+ // SSR page — ctx.ui.html`` returns complete HTML Response
271
+ app.get('/blog/:slug', async (req, ctx) => ctx.ui.html`
272
+ <!DOCTYPE html>
273
+ <html>
274
+ <head>
275
+ <title>${post.title}</title>
276
+ <link rel="stylesheet" href="/static/style.css">
277
+ </head>
278
+ <body>
279
+ <div id="root">
280
+ <h1>${post.title}</h1>
281
+ <div>${ctx.ui.html.unsafe(post.body)}</div>
282
+ <div data-hydrate="like"></div>
283
+ </div>
284
+ <script>window.__WFUI_PROPS__=${ctx.ui.html.unsafe(JSON.stringify({ post }))}</script>
285
+ <script src="/static/app.js"></script>
286
+ </body>
287
+ </html>
288
+ `)
289
+
290
+ // Dynamic JS compilation — ctx.ui.js() compiles TSX on demand
291
+ app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
292
+
293
+ // Dynamic CSS serving — ctx.ui.css() reads and serves CSS
294
+ app.get('/static/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
295
+
296
+ // Client hydrates interactive sections, skips SSR content
297
+ const app = createApp()
298
+ app.use(api())
299
+
300
+ const root = document.getElementById('root')
301
+ if (root && root.children.length > 0) {
302
+ // SSR page — hydrate only interactive areas
303
+ app.hydrate('[data-hydrate="like"]', LikeButton)
304
+ } else {
305
+ // SPA page — full mount
306
+ app.mount('#root', AppShell)
307
+ }
308
+ ```
309
+
310
+ ### wrap() — Third-party Library Integration
311
+
312
+ ```tsx
313
+ import { wrap, effect } from 'weifuwu/client'
314
+ import * as echarts from 'echarts'
315
+
316
+ // wrap(tagName, setup) creates a component:
317
+ // - Creates a <div> container
318
+ // - Calls setup(el, props, ctx) when element enters the document
319
+ // - Runs cleanup when element is removed
320
+ const PieChart = wrap('div', (el, props: { data: any[] }, ctx) => {
321
+ const chart = echarts.init(el)
322
+ chart.setOption({ series: [{ type: 'pie', data: props.data }] })
323
+ effect(() => chart.setOption({ series: [{ type: 'pie', data: props.data }] }))
324
+ return () => chart.dispose()
325
+ })
326
+
327
+ // Use in JSX like any component
328
+ <Dashboard>
329
+ <PieChart data={salesData} />
330
+ </Dashboard>
221
331
  ```
222
332
 
223
333
  ### Show / For
@@ -746,20 +856,30 @@ src/
746
856
  ├── queue/ ← Job queue + cron
747
857
  ├── graphql.ts ← GraphQL
748
858
  ├── hub.ts ← WebSocket hub
749
- ├── client/ Frontend framework (signal, JSX, router)
750
- ├── index.ts
751
- │ ├── signal.ts
752
- │ ├── jsx-runtime.ts
753
- │ ├── app.ts
754
- │ ├── router.ts
755
- └── types.ts
756
- └── test/ 281 tests
859
+ ├── ui/ ctx.ui.html / ctx.ui.js / ctx.ui.css
860
+ ├── client/ ← Frontend framework
861
+ │ ├── index.ts ← Entry (exports signal, wrap, createApp, ...)
862
+ │ ├── signal.ts ← Signal / effect / computed
863
+ │ ├── jsx-runtime.ts ← JSX → DOM / Show / For / wrap / onMount
864
+ │ ├── app.ts ← createApp / hydrate / middleware chain
865
+ ├── router.ts ← Route matching / RouteView
866
+ │ ├── types.ts WfuiContext / RouteDef
867
+ │ ├── middleware/
868
+ │ │ ├── api.ts ← HTTP client
869
+ │ │ ├── auth.ts ← Login / logout / token
870
+ │ │ └── ws.ts ← WebSocket
871
+ │ └── components/
872
+ │ ├── LoginForm.ts ← Login / register form
873
+ │ └── Chat.ts ← Real-time messaging
874
+ └── test/ ← Tests
757
875
 
758
876
  apps/demo/ ← Full-stack demo
759
- ├── src/main.tsx
760
- ├── public/index.html
761
- ├── tsconfig.json
762
- └── scripts/build.mjs
877
+ ├── src/main.tsx ← SPA + SSR hydrate demo pages
878
+ ├── server.ts ← weifuwu server
879
+ ├── public/
880
+ │ ├── index.html ← HTML skeleton with placeholders
881
+ │ └── style.css ← Demo styles
882
+ └── tsconfig.json
763
883
 
764
884
  docker-compose.yml ← postgres (pgvector) + redis
765
885
  ```
@@ -21,4 +21,16 @@ export declare function createApp(): {
21
21
  ctx: WfuiContext;
22
22
  use: (mw: AppMiddleware) => any;
23
23
  mount: (rootSelector: string, RootComponent: Component) => Promise<void>;
24
+ /**
25
+ * Hydrate 一个已由 SSR 渲染的区域。
26
+ * 不清除目标容器内的内容,只附加组件输出。
27
+ *
28
+ * ```ts
29
+ * const app = createApp()
30
+ * app.use(api())
31
+ * app.use(auth())
32
+ * app.hydrate('#comments', CommentSection, { postId: '123' })
33
+ * ```
34
+ */
35
+ hydrate: (selector: string, Component: Component, props?: Record<string, unknown>) => void;
24
36
  };
@@ -15,7 +15,7 @@
15
15
  */
16
16
  export { signal, computed, effect, isSignal } from './signal.ts';
17
17
  export type { Signal } from './signal.ts';
18
- export { jsx, jsxs, jsxDEV, Fragment, Show, For, domMount } from './jsx-runtime.ts';
18
+ export { jsx, jsxs, jsxDEV, Fragment, Show, For, domMount, wrap } from './jsx-runtime.ts';
19
19
  export type { Component } from './jsx-runtime.ts';
20
20
  export type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
21
21
  export { createApp } from './app.ts';
@@ -51,6 +51,60 @@ var currentCtx = null;
51
51
  function setCtx(ctx) {
52
52
  currentCtx = ctx;
53
53
  }
54
+ var _entries = /* @__PURE__ */ new Map();
55
+ function _ensure(el) {
56
+ let entry = _entries.get(el);
57
+ if (entry) return entry;
58
+ entry = {
59
+ mounted: document.contains(el),
60
+ observer: null,
61
+ mountFns: [],
62
+ disposeFns: []
63
+ };
64
+ _entries.set(el, entry);
65
+ const obs = new MutationObserver(() => {
66
+ const now = document.contains(el);
67
+ if (now && !entry.mounted) {
68
+ entry.mounted = true;
69
+ const fns = entry.mountFns.slice();
70
+ entry.mountFns = [];
71
+ for (const fn of fns) {
72
+ const dispose = fn();
73
+ if (typeof dispose === "function") {
74
+ entry.disposeFns.push(dispose);
75
+ }
76
+ }
77
+ } else if (!now && entry.mounted) {
78
+ entry.mounted = false;
79
+ for (const fn of entry.disposeFns) fn();
80
+ entry.disposeFns = [];
81
+ entry.mountFns = [];
82
+ obs.disconnect();
83
+ _entries.delete(el);
84
+ }
85
+ });
86
+ obs.observe(document.body, { childList: true, subtree: true });
87
+ entry.observer = obs;
88
+ return entry;
89
+ }
90
+ function onMount(el, fn) {
91
+ const entry = _ensure(el);
92
+ if (entry.mounted) {
93
+ const dispose = fn();
94
+ if (typeof dispose === "function") {
95
+ entry.disposeFns.push(dispose);
96
+ }
97
+ } else {
98
+ entry.mountFns.push(fn);
99
+ }
100
+ }
101
+ function wrap(tagName, setup) {
102
+ return (props, ctx) => {
103
+ const el = document.createElement(tagName);
104
+ onMount(el, () => setup(el, props, ctx));
105
+ return el;
106
+ };
107
+ }
54
108
  function setProp(el, key, value) {
55
109
  if (key === "class" || key === "className") {
56
110
  if (isSignal(value)) {
@@ -223,6 +277,18 @@ function createApp() {
223
277
  const app = jsx(RootComponent, {});
224
278
  domMount(rootSelector, app);
225
279
  setCtx(null);
280
+ },
281
+ hydrate(selector, Component, props) {
282
+ const root = document.querySelector(selector);
283
+ if (!root) {
284
+ console.warn(`hydrate target not found: ${selector}`);
285
+ return;
286
+ }
287
+ const mergedProps = props ?? window.__WFUI_PROPS__ ?? {};
288
+ setCtx(ctx);
289
+ const vnode = jsx(Component, mergedProps);
290
+ root.appendChild(vnode);
291
+ setCtx(null);
226
292
  }
227
293
  };
228
294
  }
@@ -690,5 +756,6 @@ export {
690
756
  jsxs,
691
757
  router,
692
758
  signal,
759
+ wrap,
693
760
  ws
694
761
  };
@@ -20,6 +20,33 @@ declare global {
20
20
  export declare function setCtx(ctx: WfuiContext | null): void;
21
21
  export declare function getCtx(): WfuiContext | null;
22
22
  export type Component<P = {}> = (props: P, ctx: WfuiContext) => Node;
23
+ /**
24
+ * 包装第三方库为组件 — 元素挂载后执行 setup,返回函数在元素移除时自动清理。
25
+ *
26
+ * 自动创建容器元素 + 自动管理生命周期。
27
+ * 返回标准的 (props, ctx) => Node 组件。
28
+ *
29
+ * ```tsx
30
+ * const PieChart = wrap('div', (el, props: { data: any }, ctx) => {
31
+ * el.style.cssText = 'width:100%;height:300px'
32
+ * const chart = echarts.init(el)
33
+ * chart.setOption(props.data)
34
+ * effect(() => chart.setOption(props.data))
35
+ * return () => chart.dispose()
36
+ * })
37
+ *
38
+ * const CanvasChart = wrap('canvas', (el, props, ctx) => {
39
+ * const chart = new Chart(el, { type: 'line', data: props.data })
40
+ * return () => chart.destroy()
41
+ * })
42
+ *
43
+ * // 在 JSX 中使用
44
+ * function Dashboard() {
45
+ * return <div><PieChart data={salesData} /><CanvasChart data={trendData} /></div>
46
+ * }
47
+ * ```
48
+ */
49
+ export declare function wrap<P = {}>(tagName: string, setup: (el: HTMLElement, props: P, ctx: WfuiContext) => (() => void) | void): Component<P>;
23
50
  export declare function jsx(type: string | Component, props: Record<string, unknown> | null, ...children: unknown[]): Node;
24
51
  export declare function jsxs(type: string | Component, props: Record<string, unknown> | null, ...children: unknown[]): Node;
25
52
  export declare function jsxDEV(type: string | Component, props: Record<string, unknown> | null, _key: string | null, _isStatic: boolean, _source: {
@@ -51,6 +51,60 @@ var currentCtx = null;
51
51
  function setCtx(ctx) {
52
52
  currentCtx = ctx;
53
53
  }
54
+ var _entries = /* @__PURE__ */ new Map();
55
+ function _ensure(el) {
56
+ let entry = _entries.get(el);
57
+ if (entry) return entry;
58
+ entry = {
59
+ mounted: document.contains(el),
60
+ observer: null,
61
+ mountFns: [],
62
+ disposeFns: []
63
+ };
64
+ _entries.set(el, entry);
65
+ const obs = new MutationObserver(() => {
66
+ const now = document.contains(el);
67
+ if (now && !entry.mounted) {
68
+ entry.mounted = true;
69
+ const fns = entry.mountFns.slice();
70
+ entry.mountFns = [];
71
+ for (const fn of fns) {
72
+ const dispose = fn();
73
+ if (typeof dispose === "function") {
74
+ entry.disposeFns.push(dispose);
75
+ }
76
+ }
77
+ } else if (!now && entry.mounted) {
78
+ entry.mounted = false;
79
+ for (const fn of entry.disposeFns) fn();
80
+ entry.disposeFns = [];
81
+ entry.mountFns = [];
82
+ obs.disconnect();
83
+ _entries.delete(el);
84
+ }
85
+ });
86
+ obs.observe(document.body, { childList: true, subtree: true });
87
+ entry.observer = obs;
88
+ return entry;
89
+ }
90
+ function onMount(el, fn) {
91
+ const entry = _ensure(el);
92
+ if (entry.mounted) {
93
+ const dispose = fn();
94
+ if (typeof dispose === "function") {
95
+ entry.disposeFns.push(dispose);
96
+ }
97
+ } else {
98
+ entry.mountFns.push(fn);
99
+ }
100
+ }
101
+ function wrap(tagName, setup) {
102
+ return (props, ctx) => {
103
+ const el = document.createElement(tagName);
104
+ onMount(el, () => setup(el, props, ctx));
105
+ return el;
106
+ };
107
+ }
54
108
  function setProp(el, key, value) {
55
109
  if (key === "class" || key === "className") {
56
110
  if (isSignal(value)) {
@@ -223,6 +277,18 @@ function createApp() {
223
277
  const app = jsx(RootComponent, {});
224
278
  domMount(rootSelector, app);
225
279
  setCtx(null);
280
+ },
281
+ hydrate(selector, Component, props) {
282
+ const root = document.querySelector(selector);
283
+ if (!root) {
284
+ console.warn(`hydrate target not found: ${selector}`);
285
+ return;
286
+ }
287
+ const mergedProps = props ?? window.__WFUI_PROPS__ ?? {};
288
+ setCtx(ctx);
289
+ const vnode = jsx(Component, mergedProps);
290
+ root.appendChild(vnode);
291
+ setCtx(null);
226
292
  }
227
293
  };
228
294
  }
@@ -690,5 +756,6 @@ export {
690
756
  jsxs,
691
757
  router,
692
758
  signal,
759
+ wrap,
693
760
  ws
694
761
  };
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export { compress } from './middleware/compress.ts';
23
23
  export type { CompressOptions } from './middleware/compress.ts';
24
24
  export { helmet } from './middleware/helmet.ts';
25
25
  export type { HelmetOptions } from './middleware/helmet.ts';
26
+ export { ui } from './ui/index.ts';
26
27
  export { graphql } from './graphql.ts';
27
28
  export type { GraphQLOptions, GraphQLHandler } from './graphql.ts';
28
29
  export { postgres, MIGRATIONS_TABLE } from './postgres/index.ts';
@@ -47,6 +48,4 @@ export type { CMSAPI, CMSOptions, Content, ContentStatus, ContentType, Tag, TagW
47
48
  export { kb, KB } from './kb/index.ts';
48
49
  export type { KBAPI, KBOptions, Document, Chunk, SearchResult, ImportOptions, SearchOptions, } from './kb/types.ts';
49
50
  export type { BaseAPI, BaseDef, BaseOptions, TableSchema, FieldSchema, FieldType, ColumnMap, CreateBaseInput, UpdateBaseInput, QueryOptions, } from './base/types.ts';
50
- export { ui } from './ui/index.ts';
51
- export type { UiOptions, UiRenderOptions } from './ui/index.ts';
52
51
  export { requireRole } from './user/types.ts';
package/dist/index.js CHANGED
@@ -225,14 +225,14 @@ function serve(router, options) {
225
225
  if (!server.listening) return;
226
226
  server.close();
227
227
  server.closeIdleConnections();
228
- return new Promise((resolve3) => {
228
+ return new Promise((resolve4) => {
229
229
  const timer = setTimeout(() => {
230
230
  server.closeAllConnections();
231
- resolve3();
231
+ resolve4();
232
232
  }, timeoutMs);
233
233
  server.on("close", () => {
234
234
  clearTimeout(timer);
235
- resolve3();
235
+ resolve4();
236
236
  });
237
237
  });
238
238
  }
@@ -1384,6 +1384,90 @@ var DEFAULTS = {
1384
1384
  permissionsPolicy: "camera=(),display-capture=(),fullscreen=(),geolocation=(),microphone=()"
1385
1385
  };
1386
1386
 
1387
+ // src/ui/index.ts
1388
+ import { build } from "esbuild";
1389
+ import { readFile as readFile2 } from "node:fs/promises";
1390
+ import { resolve as resolve3 } from "node:path";
1391
+ var HtmlSafe = class {
1392
+ constructor(value) {
1393
+ this.value = value;
1394
+ }
1395
+ value;
1396
+ toString() {
1397
+ return this.value;
1398
+ }
1399
+ };
1400
+ function escape(s) {
1401
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1402
+ }
1403
+ function stringify(v) {
1404
+ if (v == null || v === false || v === true) return "";
1405
+ if (Array.isArray(v)) return v.map(stringify).join("");
1406
+ if (v instanceof HtmlSafe) return v.value;
1407
+ return escape(String(v));
1408
+ }
1409
+ function unsafe(s) {
1410
+ return new HtmlSafe(s);
1411
+ }
1412
+ var jsCache = /* @__PURE__ */ new Map();
1413
+ var cssCache = /* @__PURE__ */ new Map();
1414
+ function ui() {
1415
+ return async (_req, ctx, next) => {
1416
+ function htmlTag(strings, ...values) {
1417
+ let body = "";
1418
+ for (let i = 0; i < strings.length; i++) {
1419
+ body += strings[i];
1420
+ if (i < values.length) body += stringify(values[i]);
1421
+ }
1422
+ return new Response(body, {
1423
+ headers: { "Content-Type": "text/html; charset=utf-8" }
1424
+ });
1425
+ }
1426
+ ctx.ui = {
1427
+ html: Object.assign(htmlTag, { unsafe }),
1428
+ async js(entryPath) {
1429
+ const absPath = resolve3(entryPath);
1430
+ const cached = jsCache.get(absPath);
1431
+ if (cached) {
1432
+ return new Response(cached.code, {
1433
+ headers: { "Content-Type": "application/javascript" }
1434
+ });
1435
+ }
1436
+ const result = await build({
1437
+ entryPoints: [absPath],
1438
+ bundle: true,
1439
+ format: "esm",
1440
+ platform: "browser",
1441
+ jsx: "automatic",
1442
+ jsxImportSource: "weifuwu/client",
1443
+ write: false
1444
+ });
1445
+ const code = result.outputFiles[0].text;
1446
+ jsCache.set(absPath, { code });
1447
+ return new Response(code, {
1448
+ headers: { "Content-Type": "application/javascript" }
1449
+ });
1450
+ },
1451
+ async css(entryPath) {
1452
+ const absPath = resolve3(entryPath);
1453
+ const stat = await import("node:fs").then((fs) => fs.promises.stat(absPath));
1454
+ const cached = cssCache.get(absPath);
1455
+ if (cached && cached.mtime === stat.mtimeMs) {
1456
+ return new Response(cached.code, {
1457
+ headers: { "Content-Type": "text/css; charset=utf-8" }
1458
+ });
1459
+ }
1460
+ const code = await readFile2(absPath, "utf-8");
1461
+ cssCache.set(absPath, { code, mtime: stat.mtimeMs });
1462
+ return new Response(code, {
1463
+ headers: { "Content-Type": "text/css; charset=utf-8" }
1464
+ });
1465
+ }
1466
+ };
1467
+ return next(_req, ctx);
1468
+ };
1469
+ }
1470
+
1387
1471
  // src/graphql.ts
1388
1472
  import {
1389
1473
  buildSchema,
@@ -4316,51 +4400,6 @@ function kb(opts) {
4316
4400
  mw.__meta = { injects: ["kb"], depends: ["sql"] };
4317
4401
  return mw;
4318
4402
  }
4319
-
4320
- // src/ui/index.ts
4321
- function defaultTemplate(title, script, propsJson) {
4322
- return `<!DOCTYPE html>
4323
- <html lang="zh-CN">
4324
- <head>
4325
- <meta charset="UTF-8">
4326
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
4327
- <title>${escapeHtml(title)}</title>
4328
- </head>
4329
- <body>
4330
- <div id="root"></div>
4331
- ${propsJson ? `<script>window.__WFUI_PROPS__=${propsJson}</script>` : ""}
4332
- <script src="${escapeHtml(script)}"></script>
4333
- </body>
4334
- </html>`;
4335
- }
4336
- function escapeHtml(s) {
4337
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4338
- }
4339
- function ui(opts = {}) {
4340
- const defaultTitle = opts.title ?? "weifuwu";
4341
- const defaultScript = opts.script ?? "/static/app.js";
4342
- const template = opts.template;
4343
- return async (req, ctx, next) => {
4344
- ctx.ui = {
4345
- html(renderOpts = {}) {
4346
- const title = renderOpts.title ?? defaultTitle;
4347
- const script = renderOpts.script ?? defaultScript;
4348
- let propsJson = "";
4349
- if (renderOpts.props) {
4350
- try {
4351
- propsJson = JSON.stringify(renderOpts.props);
4352
- } catch {
4353
- }
4354
- }
4355
- const body = template ? template.replace(/\{\{title\}\}/g, escapeHtml(title)).replace(/\{\{script\}\}/g, escapeHtml(script)).replace(/\{\{props\}\}/g, propsJson) : defaultTemplate(title, script, propsJson);
4356
- return new Response(body, {
4357
- headers: { "Content-Type": "text/html; charset=utf-8" }
4358
- });
4359
- }
4360
- };
4361
- return next(req, ctx);
4362
- };
4363
- }
4364
4403
  export {
4365
4404
  Base,
4366
4405
  CMS,
@@ -1,37 +1,46 @@
1
1
  /**
2
- * ui 中间件 — 注入 ctx.ui.html()
2
+ * ui 中间件 — 注入 ctx.ui.html / ctx.ui.js / ctx.ui.css
3
3
  *
4
- * 返回 SPA HTML shell,加载前端 client bundle
4
+ * ctx.ui.html 是 tagged template,返回完整 HTML Response
5
+ * ctx.ui.js 编译 TSX 入口,返回 JS bundle Response。
6
+ * ctx.ui.css 读取/编译 CSS 入口,返回 CSS Response。
5
7
  *
6
8
  * ```ts
7
- * import { ui, serveStatic } from 'weifuwu'
9
+ * import { ui } from 'weifuwu'
8
10
  *
9
- * app.use(ui({ title: 'My App', script: '/static/app.js' }))
10
- * app.get('/static/*', serveStatic('./dist/client'))
11
- * app.get('*', async (req, ctx) => ctx.ui.html())
11
+ * app.use(ui())
12
+ *
13
+ * app.get('/blog/:slug', async (req, ctx) => ctx.ui.html`
14
+ * <!DOCTYPE html>
15
+ * <html>
16
+ * <head><title>${post.title}</title></head>
17
+ * <body>
18
+ * <div id="root"><article>...</article></div>
19
+ * <script src="/static/app.js"></script>
20
+ * </body>
21
+ * </html>
22
+ * `)
23
+ *
24
+ * app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
25
+ * app.get('/static/style.css', async (req, ctx) => ctx.ui.css('./public/style.css'))
12
26
  * ```
13
27
  */
14
28
  import type { Middleware } from '../types.ts';
15
29
  declare module '../types.ts' {
16
30
  interface Context {
17
31
  ui: {
18
- /** 返回 SPA HTML 页面 */
19
- html: (opts?: UiRenderOptions) => Response;
32
+ /** Tagged template HTML Response */
33
+ html: UiHtmlTag;
34
+ /** 编译 TSX → JS bundle Response */
35
+ js: (entryPath: string) => Promise<Response>;
36
+ /** 读取/编译 CSS → CSS Response */
37
+ css: (entryPath: string) => Promise<Response>;
20
38
  };
21
39
  }
22
40
  }
23
- export interface UiOptions {
24
- /** 页面标题,默认 'weifuwu' */
25
- title?: string;
26
- /** Client bundle JS 路径,默认 '/static/app.js' */
27
- script?: string;
28
- /** 自定义 HTML 模板,覆盖默认 */
29
- template?: string;
30
- }
31
- export interface UiRenderOptions {
32
- title?: string;
33
- script?: string;
34
- /** 内嵌到页面的初始数据(通过 window.__WFUI_PROPS__ 访问) */
35
- props?: Record<string, unknown>;
41
+ interface UiHtmlTag {
42
+ (strings: TemplateStringsArray, ...values: unknown[]): Response;
43
+ unsafe: (s: string) => string;
36
44
  }
37
- export declare function ui(opts?: UiOptions): Middleware;
45
+ export declare function ui(): Middleware;
46
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.33.3",
4
+ "version": "0.33.5",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {