weifuwu 0.27.5 → 0.27.7

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
@@ -63,6 +63,137 @@ app.use(rateLimit({ window: 60 }))
63
63
 
64
64
  ---
65
65
 
66
+ ## Full-stack SSR
67
+
68
+ Server-rendered HTML with zero frontend build tools. Uses `html()` tagged templates
69
+ for safe HTML rendering, HTMX for dynamic interactions, and Alpine.js for client-side state.
70
+
71
+ ```ts
72
+ import { Router, serve, html, raw, layout, view, cssContext, cssRouter, assetRouter } from 'weifuwu'
73
+
74
+ const app = new Router()
75
+
76
+ // Middleware
77
+ app.use(theme())
78
+ app.use(i18n({ dir: './locales' }))
79
+ app.use(cssContext('./ui')) // compile globals.css → ctx.css
80
+
81
+ // Layout (wraps all pages)
82
+ app.use(layout('./ui/app/layout.ts'))
83
+
84
+ // Static assets (HTMX, Alpine — served locally, no CDN)
85
+ app.use(assetRouter())
86
+
87
+ // CSS serving
88
+ app.use('/', cssRouter('./ui'))
89
+
90
+ // Page
91
+ app.get('/', view('./ui/app/page.ts'))
92
+
93
+ // HTMX fragment handler
94
+ app.get('/users/table', async (req, ctx) => {
95
+ const users = await ctx.sql`SELECT * FROM users`
96
+ return html`${users.map((u) => html`<div>${u.name}</div>`)}`
97
+ })
98
+
99
+ // API
100
+ app.get('/api/ping', () => Response.json({ pong: true }))
101
+
102
+ serve(app.handler(), { port: 3000 })
103
+ ```
104
+
105
+ ### html() — Tagged template HTML
106
+
107
+ Safe HTML rendering with automatic escaping. Zero dependencies — uses JavaScript
108
+ tagged template literals.
109
+
110
+ ```ts
111
+ import { html, raw } from 'weifuwu'
112
+
113
+ // Auto-escaped
114
+ html`<h1>${userInput}</h1>`
115
+ // `<h1>&lt;script&gt;...&lt;/script&gt;</h1>`
116
+
117
+ // raw() bypasses escaping (for trusted HTML)
118
+ html`<div>${raw(body)}</div>`
119
+
120
+ // Arrays (from map)
121
+ html`<ul>
122
+ ${items.map((i) => html`<li>${i}</li>`)}
123
+ </ul>`
124
+
125
+ // Conditionals
126
+ html`${isAdmin && html`<button>Admin</button>`}`
127
+
128
+ // Nested html() is safe from double-escaping
129
+ html`<div>${html`<span>nested</span>`}</div>`
130
+ ```
131
+
132
+ ### layout() — Layout middleware
133
+
134
+ Wraps page HTML in a layout template. Multiple layouts nest naturally.
135
+
136
+ ```ts
137
+ import { html, raw } from 'weifuwu'
138
+
139
+ // ui/app/layout.ts
140
+ export default function (body: string, ctx: any) {
141
+ return html`<!DOCTYPE html>
142
+ <html>
143
+ <head>
144
+ <meta charset="utf-8" />
145
+ <script src="/__wfw/js/htmx.min.js"></script>
146
+ <script defer src="/__wfw/js/alpine.min.js"></script>
147
+ </head>
148
+ <body class="min-h-screen bg-white dark:bg-gray-950">
149
+ ${raw(body)}
150
+ <!-- ← use raw() for page content -->
151
+ </body>
152
+ </html>`
153
+ }
154
+ ```
155
+
156
+ ### view() — Page handler factory
157
+
158
+ Loads a `.ts` file and calls its default export to produce an HTML Response.
159
+
160
+ ```ts
161
+ // app.ts
162
+ app.get('/', view('./ui/app/page.ts'))
163
+
164
+ // ui/app/page.ts
165
+ export default function (ctx: any) {
166
+ return html`<h1 class="text-3xl font-bold">${ctx.i18n?.t('title')}</h1>`
167
+ }
168
+ ```
169
+
170
+ ### CSS pipeline (Tailwind v4)
171
+
172
+ Compiles `globals.css` via `@tailwindcss/postcss`. Cached and served with content hash.
173
+
174
+ ```css
175
+ /* ui/app/globals.css */
176
+ @import 'tailwindcss';
177
+ @custom-variant dark (&:is(.dark *));
178
+ ```
179
+
180
+ ```ts
181
+ app.use(cssContext('./ui')) // compile → ctx.css.url
182
+ app.use(cssRouter('./ui')) // serve /__wfw/style/:hash.css
183
+ ```
184
+
185
+ ### Local assets (no CDN)
186
+
187
+ HTMX and Alpine.js are npm dependencies, served from the weifuwu server.
188
+ No external network requests.
189
+
190
+ ```ts
191
+ app.use(assetRouter()) // serve /__wfw/js/htmx.min.js, alpine.min.js
192
+ // In layout: ${assetScripts()}
193
+ ```
194
+
195
+ ---
196
+
66
197
  ## Public API
67
198
 
68
199
  ### serve
@@ -405,6 +536,57 @@ app.use(i18n({ dir: './locales', defaultLocale: 'en' }))
405
536
 
406
537
  Options: `dir`, `defaultLocale`, `cookie`, `param`, `header`
407
538
 
539
+ ### SSR utilities
540
+
541
+ #### html()
542
+
543
+ Tagged template literal for safe HTML. See [Full-stack SSR](#full-stack-ssr).
544
+
545
+ ```ts
546
+ import { html, raw } from 'weifuwu'
547
+
548
+ html`<h1>${title}</h1>` // auto-escaped
549
+ html`<div>${raw(html)}</div>` // unescaped
550
+ ```
551
+
552
+ #### layout()
553
+
554
+ Middleware that wraps page content in a layout template.
555
+
556
+ ```ts
557
+ import { layout } from 'weifuwu'
558
+ app.use(layout('./ui/app/layout.ts'))
559
+ ```
560
+
561
+ #### view()
562
+
563
+ Handler factory that loads a `.ts` file as a page.
564
+
565
+ ```ts
566
+ import { view } from 'weifuwu'
567
+ app.get('/', view('./ui/app/page.ts'))
568
+ ```
569
+
570
+ #### cssContext() / cssRouter()
571
+
572
+ Tailwind v4 CSS compilation and serving.
573
+
574
+ ```ts
575
+ import { cssContext, cssRouter } from 'weifuwu'
576
+ app.use(cssContext('./ui')) // compile → ctx.css
577
+ app.use(cssRouter('./ui')) // serve /__wfw/style/:hash.css
578
+ ```
579
+
580
+ #### assetRouter() / assetScripts()
581
+
582
+ Serve HTMX and Alpine.js from node_modules (no CDN).
583
+
584
+ ```ts
585
+ import { assetRouter, assetScripts } from 'weifuwu'
586
+ app.use(assetRouter())
587
+ // In layout: ${assetScripts()}
588
+ ```
589
+
408
590
  ### Standalone utilities
409
591
 
410
592
  #### SSE
@@ -484,17 +666,42 @@ throw new HttpError('Not found', 404) // caught by serve(), returns 404
484
666
  ## CLI
485
667
 
486
668
  ```bash
487
- npx weifuwu init my-api
488
- cd my-api
489
- npm run dev
669
+ npx weifuwu init my-app # Full-stack project (SSR + Tailwind + HTMX + Alpine)
670
+ npx weifuwu init my-app --minimal # Minimal API-only project
671
+ npx weifuwu version # Print version
490
672
  ```
491
673
 
674
+ ### Full-stack template (`init`)
675
+
676
+ Generates a complete project with SSR, Tailwind CSS compilation, HTMX + Alpine served
677
+ locally, theme switching, internationalization, and a demo home page.
678
+
679
+ ```
680
+ my-app/
681
+ index.ts — server entry
682
+ app.ts — Router setup
683
+ ui/
684
+ app/
685
+ globals.css — Tailwind v4
686
+ layout.ts — root layout (HTMX + Alpine + theme script)
687
+ page.ts — home page (theme/i18n demo)
688
+ locales/
689
+ en.json
690
+ zh-CN.json
691
+ package.json
692
+ tsconfig.json — with @/ path alias
693
+ ```
694
+
695
+ ### Minimal template (`init --minimal`)
696
+
492
697
  Creates a minimal API project with `app.ts`, `index.ts`, and TypeScript config.
493
698
 
494
699
  ---
495
700
 
496
701
  ## Dependencies
497
702
 
703
+ ### Backend
704
+
498
705
  - `postgres` — PostgreSQL client
499
706
  - `ioredis` — Redis client
500
707
  - `ai`, `@ai-sdk/openai` — AI SDK
@@ -502,4 +709,10 @@ Creates a minimal API project with `app.ts`, `index.ts`, and TypeScript config.
502
709
  - `ws` — WebSocket
503
710
  - `zod` — Schema validation
504
711
 
505
- Zero build tools. Zero frontend framework dependencies.
712
+ ### Frontend (served locally, no CDN)
713
+
714
+ - `tailwindcss`, `@tailwindcss/postcss`, `postcss` — Tailwind v4 CSS compilation
715
+ - `htmx.org` — HTML-over-the-wire dynamic interactions
716
+ - `alpinejs` — Lightweight client interactivity
717
+
718
+ Zero build tools. Zero frontend framework compilation.
package/dist/cli.js CHANGED
@@ -28,6 +28,13 @@ async function cmdInit(name, opts) {
28
28
  process.exit(1);
29
29
  }
30
30
  const pkg = await readPkg();
31
+ if (opts.minimal) {
32
+ await generateMinimal(targetDir, name, pkg.version, opts.skipInstall);
33
+ } else {
34
+ await generateFull(targetDir, name, pkg.version, opts.skipInstall);
35
+ }
36
+ }
37
+ async function generateMinimal(targetDir, name, version, skipInstall) {
31
38
  await mkdir(targetDir, { recursive: true });
32
39
  await writeFile(
33
40
  join(targetDir, "app.ts"),
@@ -53,24 +60,208 @@ async function cmdInit(name, opts) {
53
60
  ``
54
61
  ].join("\n")
55
62
  );
63
+ await writePackageJson(targetDir, name, version, {});
64
+ await writeCommonFiles(targetDir);
65
+ await finishInit(targetDir, skipInstall);
66
+ }
67
+ async function generateFull(targetDir, name, version, skipInstall) {
68
+ await mkdir(targetDir, { recursive: true });
69
+ await mkdir(join(targetDir, "ui", "app"), { recursive: true });
70
+ await mkdir(join(targetDir, "ui", "lib"), { recursive: true });
71
+ await mkdir(join(targetDir, "locales"), { recursive: true });
72
+ await writeFile(
73
+ join(targetDir, "app.ts"),
74
+ [
75
+ `import { Router, layout, view, theme, i18n, cssContext, cssRouter, assetRouter } from 'weifuwu'`,
76
+ ``,
77
+ `export const app = new Router()`,
78
+ ``,
79
+ `// Middleware`,
80
+ `app.use(theme())`,
81
+ `app.use(i18n({ dir: './locales' }))`,
82
+ `app.use(cssContext('./ui'))`,
83
+ ``,
84
+ `// Layout \u2014 wraps all pages`,
85
+ `app.use(layout('./ui/app/layout.ts'))`,
86
+ ``,
87
+ `// Static assets (HTMX, Alpine)`,
88
+ `app.use('/', assetRouter())`,
89
+ ``,
90
+ `// CSS serving`,
91
+ `app.use('/', cssRouter('./ui'))`,
92
+ ``,
93
+ `// Pages`,
94
+ `app.get('/', view('./ui/app/page.ts'))`,
95
+ ``,
96
+ `// API route`,
97
+ `app.get('/api/ping', () => Response.json({ pong: true, time: new Date().toISOString() }))`,
98
+ ``
99
+ ].join("\n")
100
+ );
101
+ await writeFile(
102
+ join(targetDir, "index.ts"),
103
+ [
104
+ `import { loadEnv, serve } from 'weifuwu'`,
105
+ `import { app } from './app.ts'`,
106
+ ``,
107
+ `loadEnv()`,
108
+ `const port = Number(process.env.PORT) || 3000`,
109
+ `serve(app.handler(), { port })`,
110
+ ``
111
+ ].join("\n")
112
+ );
56
113
  await writeFile(
57
- join(targetDir, "package.json"),
114
+ join(targetDir, "ui", "app", "globals.css"),
115
+ [`@import "tailwindcss";`, `@custom-variant dark (&:is(.dark *));`, ``].join("\n")
116
+ );
117
+ await writeFile(
118
+ join(targetDir, "ui", "app", "layout.ts"),
119
+ [
120
+ `import { html, raw, assetScripts } from 'weifuwu'`,
121
+ ``,
122
+ `export default function(body: string, ctx: any) {`,
123
+ ` // Theme: resolve before paint to prevent flash`,
124
+ ` const themeScript = raw(\`<script>`,
125
+ `!function(){`,
126
+ `var t=(document.cookie.match(/(?:^|;\\s*)theme=([^;]+)/)||[])[1]||'system';`,
127
+ `if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';`,
128
+ `document.documentElement.classList.toggle('dark',t==='dark');`,
129
+ `}()`,
130
+ `</script>\`)`,
131
+ ``,
132
+ ` // i18n: set lang attribute`,
133
+ ` const lang = ctx.i18n?.locale || 'en'`,
134
+ ``,
135
+ ` // CSS: include compiled stylesheet`,
136
+ ` const cssLink = ctx.css?.url`,
137
+ ` ? raw(\`<link rel="stylesheet" href="\${ctx.css.url}">\`)`,
138
+ ` : ''`,
139
+ ``,
140
+ ` return html\`<!DOCTYPE html>`,
141
+ `<html lang="\${lang}">`,
142
+ `<head>`,
143
+ ` <meta charset="utf-8" />`,
144
+ ` <meta name="viewport" content="width=device-width, initial-scale=1" />`,
145
+ ` \${themeScript}`,
146
+ ` \${assetScripts()}`,
147
+ ` \${cssLink}`,
148
+ `</head>`,
149
+ `<body class="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">`,
150
+ ` \${raw(body)}`,
151
+ `</body>`,
152
+ `</html>\``,
153
+ `}`,
154
+ ``
155
+ ].join("\n")
156
+ );
157
+ await writeFile(
158
+ join(targetDir, "ui", "app", "page.ts"),
159
+ [
160
+ `import { html } from 'weifuwu'`,
161
+ ``,
162
+ `export default function(ctx: any) {`,
163
+ ` const t = ctx.i18n?.t || ((k: string) => k)`,
164
+ ` const theme = ctx.theme?.value || 'system'`,
165
+ ` const locale = ctx.i18n?.locale || 'en'`,
166
+ ``,
167
+ ` return html\`<div x-data="{ open: false }" class="min-h-screen">`,
168
+ ` <!-- Navbar -->`,
169
+ ` <nav class="border-b border-gray-200 dark:border-gray-800">`,
170
+ ` <div class="max-w-5xl mx-auto flex items-center justify-between h-14 px-4">`,
171
+ ` <span class="font-bold text-lg">weifuwu</span>`,
172
+ ` <div class="flex items-center gap-3 text-sm">`,
173
+ ` <!-- Locale toggle -->`,
174
+ ` <a href="/__lang/\${locale === 'en' ? 'zh-CN' : 'en'}"`,
175
+ ` class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600`,
176
+ ` hover:bg-gray-100 dark:hover:bg-gray-800 transition">`,
177
+ ` \${locale === 'en' ? '\u4E2D\u6587' : 'EN'}`,
178
+ ` </a>`,
179
+ ` <!-- Theme toggle -->`,
180
+ ` <a href="/__theme/\${theme === 'dark' ? 'light' : 'dark'}"`,
181
+ ` class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600`,
182
+ ` hover:bg-gray-100 dark:hover:bg-gray-800 transition">`,
183
+ ` \${theme === 'dark' ? '\u2600\uFE0F' : '\u{1F319}'}`,
184
+ ` </a>`,
185
+ ` </div>`,
186
+ ` </div>`,
187
+ ` </nav>`,
188
+ ``,
189
+ ` <!-- Hero -->`,
190
+ ` <section class="max-w-3xl mx-auto px-4 py-16 text-center">`,
191
+ ` <h1 class="text-4xl font-bold tracking-tight mb-3">\${t('hero.title')}</h1>`,
192
+ ` <p class="text-gray-500 dark:text-gray-400 text-lg mb-8">`,
193
+ ` Pure Node.js, no build step`,
194
+ ` </p>`,
195
+ ``,
196
+ ` <div class="flex justify-center gap-3">`,
197
+ ` <button @click="open = !open"`,
198
+ ` class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white`,
199
+ ` hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300">`,
200
+ ` \${t('hero.cta')}`,
201
+ ` </button>`,
202
+ ` <a href="/docs"`,
203
+ ` class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium`,
204
+ ` hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800">`,
205
+ ` \${t('hero.docs')}`,
206
+ ` </a>`,
207
+ ` </div>`,
208
+ ``,
209
+ ` <!-- Alpine demo: click to reveal -->`,
210
+ ` <div x-show="open" x-cloak`,
211
+ ` class="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-sm text-left">`,
212
+ ` \${t('demo.alpine')}`,
213
+ ` </div>`,
214
+ ` </section>`,
215
+ ` </div>\``,
216
+ `}`,
217
+ ``
218
+ ].join("\n")
219
+ );
220
+ await writeFile(
221
+ join(targetDir, "ui", "lib", "utils.ts"),
222
+ [
223
+ `/**`,
224
+ ` * cn() \u2014 Merge class names, handling conditional and array inputs.`,
225
+ ` * Lightweight alternative to clsx + tailwind-merge.`,
226
+ ` */`,
227
+ `export function cn(...classes: (string | false | null | undefined)[]): string {`,
228
+ ` return classes.filter(Boolean).join(' ')`,
229
+ `}`,
230
+ ``
231
+ ].join("\n")
232
+ );
233
+ await writeFile(
234
+ join(targetDir, "locales", "en.json"),
58
235
  JSON.stringify(
59
236
  {
60
- name,
61
- type: "module",
62
- scripts: {
63
- dev: "node --watch index.ts",
64
- start: "node index.ts"
65
- },
66
- dependencies: {
67
- weifuwu: `^${pkg.version}`
68
- }
237
+ "hero.title": "Build APIs & UI, Zero Build Step",
238
+ "hero.cta": "Try Alpine",
239
+ "hero.docs": "Documentation",
240
+ "demo.alpine": "This is Alpine.js in action \u2014 click-toggled content, zero JavaScript written."
241
+ },
242
+ null,
243
+ 2
244
+ ) + "\n"
245
+ );
246
+ await writeFile(
247
+ join(targetDir, "locales", "zh-CN.json"),
248
+ JSON.stringify(
249
+ {
250
+ "hero.title": "\u96F6\u7F16\u8BD1\u6784\u5EFA API \u548C UI",
251
+ "hero.cta": "\u4F53\u9A8C Alpine",
252
+ "hero.docs": "\u6587\u6863",
253
+ "demo.alpine": "\u8FD9\u662F Alpine.js \u7684\u6F14\u793A\u2014\u2014\u70B9\u51FB\u5207\u6362\u5185\u5BB9\uFF0C\u4E0D\u9700\u8981\u5199 JavaScript\u3002"
69
254
  },
70
255
  null,
71
256
  2
72
257
  ) + "\n"
73
258
  );
259
+ await writePackageJson(targetDir, name, version, {
260
+ dependencies: {
261
+ weifuwu: `^${version}`
262
+ },
263
+ devDependencies: {}
264
+ });
74
265
  await writeFile(
75
266
  join(targetDir, "tsconfig.json"),
76
267
  JSON.stringify(
@@ -82,37 +273,45 @@ async function cmdInit(name, opts) {
82
273
  strict: true,
83
274
  skipLibCheck: true,
84
275
  noEmit: true,
85
- allowImportingTsExtensions: true
276
+ allowImportingTsExtensions: true,
277
+ paths: {
278
+ "@/*": ["./ui/*"]
279
+ }
86
280
  },
87
- include: ["*.ts"]
281
+ include: ["*.ts", "ui/**/*.ts"]
88
282
  },
89
283
  null,
90
284
  2
91
285
  ) + "\n"
92
286
  );
93
- await writeFile(join(targetDir, ".gitignore"), "node_modules\n.env\n");
287
+ await writeCommonFiles(targetDir);
288
+ await finishInit(targetDir, skipInstall);
289
+ }
290
+ async function writePackageJson(targetDir, name, version, extra) {
291
+ const pkg = {
292
+ name,
293
+ type: "module",
294
+ scripts: {
295
+ dev: "node --watch index.ts",
296
+ start: "node index.ts"
297
+ },
298
+ ...extra
299
+ };
300
+ await writeFile(join(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
301
+ }
302
+ async function writeCommonFiles(targetDir) {
303
+ await writeFile(join(targetDir, ".gitignore"), "node_modules\n.env\n.weifuwu\n");
94
304
  await writeFile(join(targetDir, ".env"), "PORT=3000\n");
95
- await writeFile(
96
- join(targetDir, "AGENTS.md"),
97
- [
98
- "# Project Guide for AI Agents",
99
- "",
100
- "Before making any changes to this project, first read the weifuwu framework documentation:",
101
- "",
102
- "./node_modules/weifuwu/README.md",
103
- "",
104
- "This file contains the framework API reference, conventions, and patterns used throughout the codebase.",
105
- ""
106
- ].join("\n")
107
- );
108
- if (!opts.skipInstall) {
305
+ }
306
+ async function finishInit(targetDir, skipInstall) {
307
+ if (!skipInstall) {
109
308
  console.log("\nInstalling dependencies...");
110
309
  execSync("npm install", { cwd: targetDir, stdio: "inherit" });
111
310
  }
112
311
  console.log(`
113
- \u2705 Created ${name}/`);
114
- console.log(` cd ${name}`);
115
- if (opts.skipInstall) console.log(` npm install`);
312
+ \u2705 Created ${targetDir.split("/").pop()}/`);
313
+ console.log(` cd ${targetDir.split("/").pop()}`);
314
+ if (skipInstall) console.log(` npm install`);
116
315
  console.log(` npm run dev`);
117
316
  }
118
317
  var cmd = process.argv[2];
@@ -120,25 +319,31 @@ var HELP = `
120
319
  weifuwu \u2014 Web-standard HTTP microframework for Node.js
121
320
 
122
321
  Usage:
123
- npx weifuwu init <name> Create a new project
124
- npx weifuwu init <name> --skip-install Create project, skip npm install
125
- npx weifuwu version Print version
322
+ npx weifuwu init <name> Create a new project (SSR + shadcn UI)
323
+ npx weifuwu init <name> --minimal Create a minimal API-only project
324
+ npx weifuwu init <name> --skip-install Skip npm install
325
+ npx weifuwu version Print version
126
326
  `;
127
327
  if (cmd === "version" || cmd === "-v" || cmd === "--version") {
128
328
  cmdVersion().catch(console.error);
129
329
  } else if (cmd === "init") {
130
330
  const { values, positionals } = parseArgs({
131
331
  args: process.argv.slice(3),
132
- options: { "skip-install": { type: "boolean" } },
332
+ options: {
333
+ "skip-install": { type: "boolean" },
334
+ minimal: { type: "boolean" }
335
+ },
133
336
  strict: false,
134
337
  allowPositionals: true
135
338
  });
136
339
  const name = positionals[0];
137
340
  if (!name) {
138
- console.error("Usage: npx weifuwu init <name> [--skip-install]");
341
+ console.error("Usage: npx weifuwu init <name> [--skip-install] [--minimal]");
139
342
  process.exit(1);
140
343
  }
141
- cmdInit(name, { skipInstall: !!values["skip-install"] }).catch(console.error);
344
+ cmdInit(name, { skipInstall: !!values["skip-install"], minimal: !!values["minimal"] }).catch(
345
+ console.error
346
+ );
142
347
  } else {
143
348
  console.log(HELP);
144
349
  }
package/dist/index.d.ts CHANGED
@@ -57,3 +57,12 @@ export { flash } from './middleware/flash.ts';
57
57
  export type { FlashOptions, FlashInjected, FlashModule } from './middleware/flash.ts';
58
58
  export { csrf } from './middleware/csrf.ts';
59
59
  export type { CsrfOptions, CsrfInjected, CsrfModule } from './middleware/csrf.ts';
60
+ export { html, raw } from './ssr/html.ts';
61
+ export type { RawString } from './ssr/html.ts';
62
+ export { layout } from './ssr/layout.ts';
63
+ export { view } from './ssr/view.ts';
64
+ export type { ViewOptions } from './ssr/view.ts';
65
+ export { loadModule, clearModuleCache } from './ssr/compile.ts';
66
+ export { cssContext, cssRouter, clearCSSCache } from './ssr/css.ts';
67
+ export type { CssAsset } from './ssr/css.ts';
68
+ export { assetRouter, assetScripts } from './ssr/assets.ts';
package/dist/index.js CHANGED
@@ -293,14 +293,14 @@ function serve(handler, options) {
293
293
  if (!server.listening) return;
294
294
  server.close();
295
295
  server.closeIdleConnections();
296
- return new Promise((resolve4) => {
296
+ return new Promise((resolve8) => {
297
297
  const timer = setTimeout(() => {
298
298
  server.closeAllConnections();
299
- resolve4();
299
+ resolve8();
300
300
  }, timeoutMs);
301
301
  server.on("close", () => {
302
302
  clearTimeout(timer);
303
- resolve4();
303
+ resolve8();
304
304
  });
305
305
  });
306
306
  }
@@ -345,7 +345,7 @@ function createHub(opts) {
345
345
  }
346
346
  });
347
347
  }
348
- function join3(key, ws) {
348
+ function join4(key, ws) {
349
349
  if (!channels.has(key)) {
350
350
  channels.set(key, /* @__PURE__ */ new Set());
351
351
  redisSub?.subscribe(`${prefix}${key}`);
@@ -407,7 +407,7 @@ function createHub(opts) {
407
407
  redisPub = void 0;
408
408
  redisSub = null;
409
409
  }
410
- return { join: join3, leave, broadcast, close };
410
+ return { join: join4, leave, broadcast, close };
411
411
  }
412
412
 
413
413
  // core/router.ts
@@ -1960,7 +1960,7 @@ var TestWSRequest = class {
1960
1960
  const baseUrl = await this.app._ensureServer();
1961
1961
  const wsUrl = baseUrl.replace(/^http/, "ws") + this.path;
1962
1962
  const ws = new WSWebSocket(wsUrl, { handshakeTimeout: this._timeout });
1963
- return new Promise((resolve4, reject) => {
1963
+ return new Promise((resolve8, reject) => {
1964
1964
  const timer = setTimeout(() => {
1965
1965
  reject(new Error(`WebSocket connection timed out after ${this._timeout}ms`));
1966
1966
  ws.close();
@@ -1969,7 +1969,7 @@ var TestWSRequest = class {
1969
1969
  clearTimeout(timer);
1970
1970
  const conn = new TestWSConnection(ws, this._timeout);
1971
1971
  this.app._trackConnection(conn);
1972
- resolve4(conn);
1972
+ resolve8(conn);
1973
1973
  });
1974
1974
  ws.on("error", (err) => {
1975
1975
  clearTimeout(timer);
@@ -2000,8 +2000,8 @@ var TestWSConnection = class {
2000
2000
  ws.on("message", (data) => {
2001
2001
  const str = data.toString();
2002
2002
  if (this.resolveQueue.length > 0) {
2003
- const resolve4 = this.resolveQueue.shift();
2004
- resolve4(str);
2003
+ const resolve8 = this.resolveQueue.shift();
2004
+ resolve8(str);
2005
2005
  } else {
2006
2006
  this.messageQueue.push(str);
2007
2007
  }
@@ -2031,15 +2031,15 @@ var TestWSConnection = class {
2031
2031
  if (this._closed) {
2032
2032
  throw new Error("WebSocket connection closed");
2033
2033
  }
2034
- return new Promise((resolve4, reject) => {
2034
+ return new Promise((resolve8, reject) => {
2035
2035
  const timer = setTimeout(() => {
2036
- const idx = this.resolveQueue.indexOf(resolve4);
2036
+ const idx = this.resolveQueue.indexOf(resolve8);
2037
2037
  if (idx !== -1) this.resolveQueue.splice(idx, 1);
2038
2038
  reject(new Error(`WebSocket receive timed out after ${timeout ?? this._timeout}ms`));
2039
2039
  }, timeout ?? this._timeout);
2040
2040
  this.resolveQueue.push((msg) => {
2041
2041
  clearTimeout(timer);
2042
- resolve4(msg);
2042
+ resolve8(msg);
2043
2043
  });
2044
2044
  });
2045
2045
  }
@@ -2053,12 +2053,12 @@ var TestWSConnection = class {
2053
2053
  * Useful for verifying that something did NOT happen.
2054
2054
  */
2055
2055
  async expectSilent(ms) {
2056
- return new Promise((resolve4, reject) => {
2056
+ return new Promise((resolve8, reject) => {
2057
2057
  if (this.messageQueue.length > 0) {
2058
2058
  reject(new Error(`Expected silence but got message: ${this.messageQueue[0].slice(0, 100)}`));
2059
2059
  return;
2060
2060
  }
2061
- const timer = setTimeout(() => resolve4(), ms);
2061
+ const timer = setTimeout(() => resolve8(), ms);
2062
2062
  const origPush = this.resolveQueue.push.bind(this.resolveQueue);
2063
2063
  this.resolveQueue.push = (_fn) => {
2064
2064
  clearTimeout(timer);
@@ -3014,14 +3014,14 @@ function createRedisQueue(opts) {
3014
3014
  while (running && inflight < MAX_CONCURRENT) {
3015
3015
  const result = await redis2.zpopmin(jobKey);
3016
3016
  if (result.length < 2) break;
3017
- const raw = result[0], score = parseInt(result[1], 10);
3017
+ const raw2 = result[0], score = parseInt(result[1], 10);
3018
3018
  if (score > now) {
3019
- await redis2.zadd(jobKey, score, raw);
3019
+ await redis2.zadd(jobKey, score, raw2);
3020
3020
  break;
3021
3021
  }
3022
3022
  let job;
3023
3023
  try {
3024
- job = JSON.parse(raw);
3024
+ job = JSON.parse(raw2);
3025
3025
  } catch {
3026
3026
  continue;
3027
3027
  }
@@ -3071,8 +3071,8 @@ function createRedisQueue(opts) {
3071
3071
  redis2.disconnect();
3072
3072
  };
3073
3073
  mw.jobs = async function jobs(limit) {
3074
- const raw = await redis2.zrevrange(jobKey, 0, (limit ?? 50) - 1);
3075
- return raw.map((r) => {
3074
+ const raw2 = await redis2.zrevrange(jobKey, 0, (limit ?? 50) - 1);
3075
+ return raw2.map((r) => {
3076
3076
  try {
3077
3077
  return JSON.parse(r);
3078
3078
  } catch {
@@ -3081,8 +3081,8 @@ function createRedisQueue(opts) {
3081
3081
  }).filter(Boolean);
3082
3082
  };
3083
3083
  mw.failedJobs = async function failedJobs(limit) {
3084
- const raw = await redis2.lrange(failedKey, 0, (limit ?? 50) - 1);
3085
- return raw.map((r) => {
3084
+ const raw2 = await redis2.lrange(failedKey, 0, (limit ?? 50) - 1);
3085
+ return raw2.map((r) => {
3086
3086
  try {
3087
3087
  return JSON.parse(r);
3088
3088
  } catch {
@@ -3091,8 +3091,8 @@ function createRedisQueue(opts) {
3091
3091
  }).filter(Boolean);
3092
3092
  };
3093
3093
  mw.retryFailed = async function retryFailed(jobId) {
3094
- const raw = await redis2.lrange(failedKey, 0, -1);
3095
- for (const entry of raw) {
3094
+ const raw2 = await redis2.lrange(failedKey, 0, -1);
3095
+ for (const entry of raw2) {
3096
3096
  try {
3097
3097
  const job = JSON.parse(entry);
3098
3098
  if (job.id === jobId) {
@@ -3111,8 +3111,8 @@ function createRedisQueue(opts) {
3111
3111
  };
3112
3112
  mw.retryAllFailed = async function retryAllFailed(type) {
3113
3113
  let count = 0;
3114
- const raw = await redis2.lrange(failedKey, 0, -1);
3115
- for (const entry of raw) {
3114
+ const raw2 = await redis2.lrange(failedKey, 0, -1);
3115
+ for (const entry of raw2) {
3116
3116
  try {
3117
3117
  const job = JSON.parse(entry);
3118
3118
  if (type && job.type !== type) continue;
@@ -3362,14 +3362,14 @@ function makeSetFlash(name, location) {
3362
3362
  function flash(options) {
3363
3363
  const name = options?.name ?? "flash";
3364
3364
  const mw = async (req, ctx, next) => {
3365
- const raw = getCookies(req)[name] ?? null;
3365
+ const raw2 = getCookies(req)[name] ?? null;
3366
3366
  const referer = req.headers.get("referer") || "/";
3367
3367
  let value = void 0;
3368
- if (raw) {
3368
+ if (raw2) {
3369
3369
  try {
3370
- value = JSON.parse(decodeURIComponent(raw));
3370
+ value = JSON.parse(decodeURIComponent(raw2));
3371
3371
  } catch {
3372
- value = raw;
3372
+ value = raw2;
3373
3373
  }
3374
3374
  }
3375
3375
  ctx.flash = {
@@ -3377,7 +3377,7 @@ function flash(options) {
3377
3377
  set: makeSetFlash(name, referer)
3378
3378
  };
3379
3379
  const res = await next(req, ctx);
3380
- if (raw) {
3380
+ if (raw2) {
3381
3381
  const headers = new Headers(res.headers);
3382
3382
  headers.append("Set-Cookie", `${name}=; Path=/; Max-Age=0`);
3383
3383
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
@@ -3434,6 +3434,255 @@ function csrf(options) {
3434
3434
  mw.__meta = { injects: ["csrf"], depends: [] };
3435
3435
  return mw;
3436
3436
  }
3437
+
3438
+ // ssr/html.ts
3439
+ var ESCAPE_MAP = {
3440
+ "&": "&amp;",
3441
+ "<": "&lt;",
3442
+ ">": "&gt;",
3443
+ '"': "&quot;",
3444
+ "'": "&#39;"
3445
+ };
3446
+ function escapeHtml(s) {
3447
+ const str = String(s);
3448
+ if (!/[&<>"']/.test(str)) return str;
3449
+ return str.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] || c);
3450
+ }
3451
+ function raw(s) {
3452
+ return {
3453
+ __brand: "RawString",
3454
+ value: s,
3455
+ toString() {
3456
+ return this.value;
3457
+ }
3458
+ };
3459
+ }
3460
+ function isRaw(v) {
3461
+ return typeof v === "object" && v !== null && "__brand" in v && v.__brand === "RawString";
3462
+ }
3463
+ function html(strings, ...values) {
3464
+ let result = "";
3465
+ for (let i = 0; i < strings.length; i++) {
3466
+ result += strings[i];
3467
+ if (i < values.length) {
3468
+ result += stringify(values[i]);
3469
+ }
3470
+ }
3471
+ return raw(result);
3472
+ }
3473
+ function stringify(v) {
3474
+ if (v === null || v === void 0 || v === false) return "";
3475
+ if (isRaw(v)) return v.value;
3476
+ if (Array.isArray(v)) {
3477
+ let out = "";
3478
+ for (let i = 0; i < v.length; i++) {
3479
+ out += stringify(v[i]);
3480
+ }
3481
+ return out;
3482
+ }
3483
+ if (typeof v === "number") return String(v);
3484
+ return escapeHtml(v);
3485
+ }
3486
+
3487
+ // ssr/layout.ts
3488
+ import { resolve as resolve4, isAbsolute } from "node:path";
3489
+ function layout(path) {
3490
+ const absPath = isAbsolute(path) ? path : resolve4(process.cwd(), path);
3491
+ let modPromise = null;
3492
+ async function getRenderFn() {
3493
+ if (!modPromise) {
3494
+ modPromise = import(absPath).catch((err) => {
3495
+ modPromise = null;
3496
+ throw new Error(
3497
+ `[layout] Failed to load layout module "${path}": ${err instanceof Error ? err.message : String(err)}`
3498
+ );
3499
+ });
3500
+ }
3501
+ const mod = await modPromise;
3502
+ const renderFn = mod.default;
3503
+ if (typeof renderFn !== "function") {
3504
+ throw new Error(
3505
+ `[layout] Layout module "${path}" must export a default function, got ${typeof renderFn}`
3506
+ );
3507
+ }
3508
+ return renderFn;
3509
+ }
3510
+ const mw = async (req, ctx, next) => {
3511
+ const renderFn = await getRenderFn();
3512
+ const response = await next(req, ctx);
3513
+ const ct = response.headers.get("content-type") ?? "";
3514
+ if (!ct.includes("text/html")) return response;
3515
+ const body = await response.text();
3516
+ const wrapped = await renderFn(body, ctx);
3517
+ return new Response(wrapped, {
3518
+ status: response.status,
3519
+ headers: { "content-type": "text/html; charset=utf-8" }
3520
+ });
3521
+ };
3522
+ mw.__meta = { injects: [], depends: [] };
3523
+ return mw;
3524
+ }
3525
+
3526
+ // ssr/compile.ts
3527
+ import { resolve as resolve5, isAbsolute as isAbsolute2 } from "node:path";
3528
+ var moduleCache = /* @__PURE__ */ new Map();
3529
+ var loading = /* @__PURE__ */ new Map();
3530
+ async function loadModule(path) {
3531
+ const absPath = isAbsolute2(path) ? path : resolve5(process.cwd(), path);
3532
+ const cached = moduleCache.get(absPath);
3533
+ if (cached) return cached;
3534
+ const inFlight = loading.get(absPath);
3535
+ if (inFlight) return inFlight;
3536
+ const promise = import(absPath).then((mod) => {
3537
+ loading.delete(absPath);
3538
+ moduleCache.set(absPath, Promise.resolve(mod));
3539
+ return mod;
3540
+ }).catch((err) => {
3541
+ loading.delete(absPath);
3542
+ moduleCache.delete(absPath);
3543
+ throw new Error(
3544
+ `[compile] Failed to load module "${path}": ${err instanceof Error ? err.message : String(err)}`
3545
+ );
3546
+ });
3547
+ loading.set(absPath, promise);
3548
+ return promise;
3549
+ }
3550
+ function clearModuleCache(path) {
3551
+ if (path) {
3552
+ const absPath = isAbsolute2(path) ? path : resolve5(process.cwd(), path);
3553
+ moduleCache.delete(absPath);
3554
+ loading.delete(absPath);
3555
+ } else {
3556
+ moduleCache.clear();
3557
+ loading.clear();
3558
+ }
3559
+ }
3560
+
3561
+ // ssr/view.ts
3562
+ function isRawString(v) {
3563
+ return typeof v === "object" && v !== null && "__brand" in v && v.__brand === "RawString";
3564
+ }
3565
+ function isResponse(v) {
3566
+ return v instanceof Response;
3567
+ }
3568
+ function view(path, options) {
3569
+ return async (req, ctx) => {
3570
+ let mod;
3571
+ if (options?.module) {
3572
+ mod = options.module;
3573
+ } else {
3574
+ mod = await loadModule(path);
3575
+ }
3576
+ const renderFn = mod.default;
3577
+ if (typeof renderFn !== "function") {
3578
+ throw new Error(
3579
+ `[view] Module "${path}" must export a default function, got ${typeof renderFn}`
3580
+ );
3581
+ }
3582
+ const result = renderFn.length >= 1 ? await renderFn(ctx) : await renderFn();
3583
+ if (isResponse(result)) return result;
3584
+ const body = isRawString(result) ? result.value : String(result);
3585
+ return new Response(body, {
3586
+ status: 200,
3587
+ headers: { "content-type": "text/html; charset=utf-8" }
3588
+ });
3589
+ };
3590
+ }
3591
+
3592
+ // ssr/css.ts
3593
+ import { createHash } from "node:crypto";
3594
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
3595
+ import { join as join3, resolve as resolve6 } from "node:path";
3596
+ import tailwindPlugin from "@tailwindcss/postcss";
3597
+ import postcss from "postcss";
3598
+ var cssCache = /* @__PURE__ */ new Map();
3599
+ async function compileCSS(cssPath, sourceDir) {
3600
+ if (!existsSync(cssPath)) {
3601
+ return { css: "", hash: "empty", url: "" };
3602
+ }
3603
+ const raw2 = readFileSync2(cssPath, "utf-8");
3604
+ const src = `@source "${sourceDir}";
3605
+ ${raw2}`;
3606
+ const result = await postcss([tailwindPlugin()]).process(src, { from: cssPath });
3607
+ const hash = createHash("md5").update(result.css).digest("hex").slice(0, 8);
3608
+ const asset = { css: result.css, hash, url: `/__wfw/style/${hash}.css` };
3609
+ cssCache.set(cssPath, asset);
3610
+ return asset;
3611
+ }
3612
+ function cssContext(dir) {
3613
+ const appDir = resolve6(dir, "app");
3614
+ const cssPath = join3(appDir, "globals.css");
3615
+ let cached = null;
3616
+ return async (req, ctx, next) => {
3617
+ if (!cached) cached = compileCSS(cssPath, appDir);
3618
+ const asset = await cached;
3619
+ if (asset.css) ctx.css = asset;
3620
+ return next(req, ctx);
3621
+ };
3622
+ }
3623
+ function cssRouter(dir) {
3624
+ const router = new Router();
3625
+ router.get("/__wfw/style/:hash.css", async () => {
3626
+ const cssPath = join3(resolve6(dir, "app"), "globals.css");
3627
+ const asset = cssCache.get(cssPath);
3628
+ if (!asset) return new Response("", { status: 404 });
3629
+ return new Response(asset.css, {
3630
+ headers: { "content-type": "text/css; charset=utf-8" }
3631
+ });
3632
+ });
3633
+ return router;
3634
+ }
3635
+ function clearCSSCache() {
3636
+ cssCache.clear();
3637
+ }
3638
+
3639
+ // ssr/assets.ts
3640
+ import { readFileSync as readFileSync3 } from "node:fs";
3641
+ import { resolve as resolve7 } from "node:path";
3642
+ function resolvePackage(name, file) {
3643
+ return resolve7(process.cwd(), "node_modules", name, file);
3644
+ }
3645
+ var HTMX_PATH = resolvePackage("htmx.org", "dist/htmx.min.js");
3646
+ var ALPINE_PATH = resolvePackage("alpinejs", "dist/cdn.min.js");
3647
+ var htmxContent = null;
3648
+ var alpineContent = null;
3649
+ function loadAsset(path) {
3650
+ try {
3651
+ return readFileSync3(path, "utf-8");
3652
+ } catch {
3653
+ return null;
3654
+ }
3655
+ }
3656
+ function assetRouter() {
3657
+ const router = new Router();
3658
+ router.get("/__wfw/js/htmx.min.js", () => {
3659
+ if (!htmxContent) htmxContent = loadAsset(HTMX_PATH);
3660
+ if (!htmxContent) return new Response("HTMX not found", { status: 404 });
3661
+ return new Response(htmxContent, {
3662
+ headers: {
3663
+ "content-type": "application/javascript; charset=utf-8",
3664
+ "cache-control": "public, max-age=31536000, immutable"
3665
+ }
3666
+ });
3667
+ });
3668
+ router.get("/__wfw/js/alpine.min.js", () => {
3669
+ if (!alpineContent) alpineContent = loadAsset(ALPINE_PATH);
3670
+ if (!alpineContent) return new Response("Alpine not found", { status: 404 });
3671
+ return new Response(alpineContent, {
3672
+ headers: {
3673
+ "content-type": "application/javascript; charset=utf-8",
3674
+ "cache-control": "public, max-age=31536000, immutable"
3675
+ }
3676
+ });
3677
+ });
3678
+ return router;
3679
+ }
3680
+ function assetScripts() {
3681
+ return raw(`
3682
+ <script src="/__wfw/js/htmx.min.js"></script>
3683
+ <script defer src="/__wfw/js/alpine.min.js"></script>
3684
+ `);
3685
+ }
3437
3686
  export {
3438
3687
  DEFAULT_MAX_BODY,
3439
3688
  HttpError,
@@ -3443,6 +3692,10 @@ export {
3443
3692
  TestRequest,
3444
3693
  aiProvider,
3445
3694
  aiStream,
3695
+ assetRouter,
3696
+ assetScripts,
3697
+ clearCSSCache,
3698
+ clearModuleCache,
3446
3699
  compress,
3447
3700
  cors,
3448
3701
  createHub,
@@ -3451,6 +3704,8 @@ export {
3451
3704
  createTestDb,
3452
3705
  createTestServer,
3453
3706
  csrf,
3707
+ cssContext,
3708
+ cssRouter,
3454
3709
  currentTrace,
3455
3710
  currentTraceId,
3456
3711
  deleteCookie,
@@ -3467,16 +3722,20 @@ export {
3467
3722
  graphql,
3468
3723
  health,
3469
3724
  helmet,
3725
+ html,
3470
3726
  i18n,
3471
3727
  isBundled,
3472
3728
  isDev,
3473
3729
  isProd,
3730
+ layout,
3474
3731
  loadEnv,
3732
+ loadModule,
3475
3733
  logger,
3476
3734
  openai,
3477
3735
  postgres,
3478
3736
  queue,
3479
3737
  rateLimit,
3738
+ raw,
3480
3739
  redis,
3481
3740
  requestId,
3482
3741
  runWithTrace,
@@ -3493,5 +3752,6 @@ export {
3493
3752
  traceElapsed,
3494
3753
  upload,
3495
3754
  validate,
3755
+ view,
3496
3756
  withTestDb
3497
3757
  };
@@ -0,0 +1,20 @@
1
+ import { Router } from '../core/router.ts';
2
+ import { type RawString } from './html.ts';
3
+ /**
4
+ * Create a Router that serves HTMX and Alpine.js at `/__wfw/js/`.
5
+ *
6
+ * ```ts
7
+ * app.use('/', assetRouter())
8
+ * ```
9
+ */
10
+ export declare function assetRouter(): Router;
11
+ /**
12
+ * Generate `<script>` tags for HTMX and Alpine, pointing to local paths.
13
+ *
14
+ * ```ts
15
+ * <head>
16
+ * ${assetScripts()}
17
+ * </head>
18
+ * ```
19
+ */
20
+ export declare function assetScripts(): RawString;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Load a `.ts` module with caching.
3
+ *
4
+ * - Modules are loaded once and cached indefinitely.
5
+ * - Concurrent calls for the same path share a single load promise.
6
+ * - Cache can be cleared for a specific path or entirely.
7
+ *
8
+ * @param path - Absolute or relative path to a `.ts` module.
9
+ * @returns The module exports.
10
+ */
11
+ export declare function loadModule<T = Record<string, unknown>>(path: string): Promise<T>;
12
+ /**
13
+ * Clear the module cache for a specific path or entirely.
14
+ *
15
+ * @param path - If provided, only clear this module. If omitted, clear all.
16
+ */
17
+ export declare function clearModuleCache(path?: string): void;
18
+ /**
19
+ * Get the number of cached modules (for testing/debugging).
20
+ */
21
+ export declare function cachedModuleCount(): number;
@@ -0,0 +1,16 @@
1
+ import { Router } from '../core/router.ts';
2
+ import type { Middleware } from '../types.ts';
3
+ export interface CssAsset {
4
+ css: string;
5
+ hash: string;
6
+ url: string;
7
+ }
8
+ declare module '../types.ts' {
9
+ interface Context {
10
+ css?: CssAsset;
11
+ }
12
+ }
13
+ export declare function compileCSS(cssPath: string, sourceDir: string): Promise<CssAsset>;
14
+ export declare function cssContext(dir: string): Middleware;
15
+ export declare function cssRouter(dir: string): Router;
16
+ export declare function clearCSSCache(): void;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * html — Tagged template literal for safe HTML rendering.
3
+ *
4
+ * Auto-escapes all interpolated values. Use {@link raw} to bypass escaping.
5
+ *
6
+ * ```ts
7
+ * import { html, raw } from 'weifuwu'
8
+ *
9
+ * const name = '<script>alert("xss")</script>'
10
+ * html`<h1>${name}</h1>`
11
+ * // → "<h1>&lt;script&gt;alert("xss")&lt;/script&gt;</h1>"
12
+ *
13
+ * html`<div>${raw('<b>safe</b>')}</div>`
14
+ * // → "<div><b>safe</b></div>"
15
+ * ```
16
+ */
17
+ /**
18
+ * Opaque marker returned by {@link raw} and {@link html}.
19
+ *
20
+ * Carries pre-escaped HTML that won't be double-escaped when nested
21
+ * in another {@link html} template. Has a {@link toString} for use
22
+ * in regular string contexts.
23
+ */
24
+ export interface RawString {
25
+ __brand: 'RawString';
26
+ value: string;
27
+ toString(): string;
28
+ }
29
+ /**
30
+ * Mark a string as pre-escaped HTML. The value will NOT be escaped
31
+ * when interpolated into an {@link html} template.
32
+ *
33
+ * ```ts
34
+ * html`<div>${raw(safeContent)}</div>`
35
+ * ```
36
+ */
37
+ export declare function raw(s: string): RawString;
38
+ /**
39
+ * Render a safe HTML string from a tagged template literal.
40
+ *
41
+ * - String values are HTML-escaped
42
+ * - {@link raw} values are inserted unescaped
43
+ * - Arrays are joined (supports `Array.map(() => html\`...\`)`)
44
+ * - `null` and `false` render as empty string
45
+ * - Numbers render as-is (they cannot contain HTML special chars)
46
+ *
47
+ * ```ts
48
+ * html`<h1>${title}</h1>`
49
+ * html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`
50
+ * html`${isAdmin && html`<button>Admin</button>`}`
51
+ * ```
52
+ */
53
+ export declare function html(strings: TemplateStringsArray, ...values: unknown[]): RawString;
@@ -0,0 +1,2 @@
1
+ import type { Middleware } from '../types.ts';
2
+ export declare function layout(path: string): Middleware;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * view — Handler factory that renders a page template.
3
+ *
4
+ * Loads a `.ts` module and calls its **default export** function to
5
+ * produce an HTML response. The function can return:
6
+ * - A {@link RawString} (from `html()` or `raw()`)
7
+ * - A plain `string`
8
+ * - A `Response` (for redirects, custom status codes, etc.)
9
+ *
10
+ * ```ts
11
+ * // ui/app/page.ts
12
+ * export default function() {
13
+ * return html`<h1>Hello</h1>`
14
+ * }
15
+ *
16
+ * // Or with ctx access:
17
+ * export default function(ctx: Context) {
18
+ * return html`<h1>${ctx.params.slug}</h1>`
19
+ * }
20
+ *
21
+ * // Or for custom status:
22
+ * export default function(ctx) {
23
+ * if (!ctx.params.id) return new Response('Not Found', { status: 404 })
24
+ * return html`<h1>${ctx.params.id}</h1>`
25
+ * }
26
+ * ```
27
+ *
28
+ * @param path - Absolute or relative path to a `.ts` module.
29
+ * @returns A {@link Handler} that renders the page.
30
+ */
31
+ import type { Handler } from '../types.ts';
32
+ export interface ViewOptions {
33
+ /** Pre-loaded module (for production pre-build) */
34
+ module?: unknown;
35
+ }
36
+ export declare function view(path: string, options?: ViewOptions): Handler;
package/dist/types.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface Context {
9
9
  params: Record<string, string>;
10
10
  query: Record<string, string>;
11
11
  mountPath?: string;
12
+ /**
13
+ * Server-side data loaded for the current page.
14
+ * Set by middleware before a page handler renders.
15
+ * Available in templates via `ctx.loaderData`.
16
+ */
17
+ loaderData?: Record<string, unknown>;
12
18
  [key: string]: unknown;
13
19
  }
14
20
  export type Handler<T extends Context = Context> = (req: Request, ctx: T) => Response | Promise<Response>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.27.5",
4
+ "version": "0.27.7",
5
5
  "description": "Web-standard HTTP microframework for Node.js — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": "./dist/index.js"
@@ -28,10 +28,15 @@
28
28
  "dependencies": {
29
29
  "@ai-sdk/openai": "^3.0.66",
30
30
  "@graphql-tools/schema": "^10",
31
+ "@tailwindcss/postcss": "^4",
31
32
  "ai": "^6",
33
+ "alpinejs": "^3",
32
34
  "graphql": "^16",
35
+ "htmx.org": "^2",
33
36
  "ioredis": "^5.11.0",
37
+ "postcss": "^8",
34
38
  "postgres": "^3.4.9",
39
+ "tailwindcss": "^4",
35
40
  "ws": "^8",
36
41
  "zod": "^4.4.3"
37
42
  },