weifuwu 0.27.5 → 0.27.6
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 +217 -4
- package/dist/cli.js +241 -36
- package/dist/index.d.ts +9 -0
- package/dist/index.js +290 -30
- package/dist/ssr/assets.d.ts +20 -0
- package/dist/ssr/compile.d.ts +21 -0
- package/dist/ssr/css.d.ts +16 -0
- package/dist/ssr/html.d.ts +53 -0
- package/dist/ssr/layout.d.ts +2 -0
- package/dist/ssr/view.d.ts +36 -0
- package/dist/types.d.ts +6 -0
- package/package.json +6 -1
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><script>...</script></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-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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 ${
|
|
114
|
-
console.log(` cd ${
|
|
115
|
-
if (
|
|
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>
|
|
124
|
-
npx weifuwu init <name> --
|
|
125
|
-
npx weifuwu
|
|
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: {
|
|
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(
|
|
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((
|
|
296
|
+
return new Promise((resolve8) => {
|
|
297
297
|
const timer = setTimeout(() => {
|
|
298
298
|
server.closeAllConnections();
|
|
299
|
-
|
|
299
|
+
resolve8();
|
|
300
300
|
}, timeoutMs);
|
|
301
301
|
server.on("close", () => {
|
|
302
302
|
clearTimeout(timer);
|
|
303
|
-
|
|
303
|
+
resolve8();
|
|
304
304
|
});
|
|
305
305
|
});
|
|
306
306
|
}
|
|
@@ -345,7 +345,7 @@ function createHub(opts) {
|
|
|
345
345
|
}
|
|
346
346
|
});
|
|
347
347
|
}
|
|
348
|
-
function
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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
|
|
2004
|
-
|
|
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((
|
|
2034
|
+
return new Promise((resolve8, reject) => {
|
|
2035
2035
|
const timer = setTimeout(() => {
|
|
2036
|
-
const idx = this.resolveQueue.indexOf(
|
|
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
|
-
|
|
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((
|
|
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(() =>
|
|
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
|
|
3017
|
+
const raw2 = result[0], score = parseInt(result[1], 10);
|
|
3018
3018
|
if (score > now) {
|
|
3019
|
-
await redis2.zadd(jobKey, score,
|
|
3019
|
+
await redis2.zadd(jobKey, score, raw2);
|
|
3020
3020
|
break;
|
|
3021
3021
|
}
|
|
3022
3022
|
let job;
|
|
3023
3023
|
try {
|
|
3024
|
-
job = JSON.parse(
|
|
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
|
|
3075
|
-
return
|
|
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
|
|
3085
|
-
return
|
|
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
|
|
3095
|
-
for (const entry of
|
|
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
|
|
3115
|
-
for (const entry of
|
|
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
|
|
3365
|
+
const raw2 = getCookies(req)[name] ?? null;
|
|
3366
3366
|
const referer = req.headers.get("referer") || "/";
|
|
3367
3367
|
let value = void 0;
|
|
3368
|
-
if (
|
|
3368
|
+
if (raw2) {
|
|
3369
3369
|
try {
|
|
3370
|
-
value = JSON.parse(decodeURIComponent(
|
|
3370
|
+
value = JSON.parse(decodeURIComponent(raw2));
|
|
3371
3371
|
} catch {
|
|
3372
|
-
value =
|
|
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 (
|
|
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
|
+
"&": "&",
|
|
3441
|
+
"<": "<",
|
|
3442
|
+
">": ">",
|
|
3443
|
+
'"': """,
|
|
3444
|
+
"'": "'"
|
|
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><script>alert("xss")</script></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,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.
|
|
4
|
+
"version": "0.27.6",
|
|
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
|
},
|