create-fullstack-scaffold 0.1.1 → 0.2.0

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.
Files changed (155) hide show
  1. package/dist/cli/index.js +1673 -696
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +16 -10
  4. package/template/.husky/pre-commit +1 -1
  5. package/template/modules.config.ts +29 -2
  6. package/template/package.json +12 -12
  7. package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
  8. package/template/playwright.config.ts +7 -1
  9. package/template/src/admin/App.tsx +2 -2
  10. package/template/src/admin/components/CaptchaModal.tsx +7 -9
  11. package/template/src/admin/layouts/Header.tsx +56 -22
  12. package/template/src/admin/layouts/Layout.tsx +14 -9
  13. package/template/src/admin/layouts/Sidebar.tsx +122 -105
  14. package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
  15. package/template/src/admin/pages/ContentPage.tsx +2 -0
  16. package/template/src/admin/pages/DashboardPage.tsx +2 -0
  17. package/template/src/admin/pages/DisputesPage.tsx +2 -0
  18. package/template/src/admin/pages/MediaTestPage.tsx +1 -1
  19. package/template/src/admin/pages/OrdersPage.tsx +2 -0
  20. package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
  21. package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
  22. package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
  23. package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
  24. package/template/src/admin/pages/TicketsPage.tsx +2 -0
  25. package/template/src/admin/pages/UsersPage.tsx +3 -2
  26. package/template/src/admin/stores/adminStore.ts +5 -0
  27. package/template/src/cli/index.ts +17 -19
  28. package/template/src/cli/modules/auth/index.ts +65 -0
  29. package/template/src/cli/modules/config/index.ts +74 -77
  30. package/template/src/cli/modules/index.ts +28 -6
  31. package/template/src/cli/modules/notification/index.ts +95 -79
  32. package/template/src/cli/modules/plugin/index.ts +111 -0
  33. package/template/src/cli/modules/todo/index.ts +99 -51
  34. package/template/src/cli/utils/auto-command.ts +7 -25
  35. package/template/src/cli/utils/index.ts +3 -1
  36. package/template/src/client/App.tsx +36 -16
  37. package/template/src/client/Layout.tsx +67 -10
  38. package/template/src/client/components/AuthButton.tsx +25 -18
  39. package/template/src/client/components/BottomTabBar.tsx +107 -0
  40. package/template/src/client/components/Navigation.tsx +162 -52
  41. package/template/src/client/components/__tests__/App.test.tsx +48 -32
  42. package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
  43. package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
  44. package/template/src/client/components/index.ts +1 -0
  45. package/template/src/client/contexts/PresetContext.tsx +10 -0
  46. package/template/src/client/main.tsx +63 -8
  47. package/template/src/client/pages/CartPage.tsx +244 -0
  48. package/template/src/client/pages/CategoriesPage.tsx +100 -0
  49. package/template/src/client/pages/ContentDetailPage.tsx +9 -14
  50. package/template/src/client/pages/ContentListPage.tsx +4 -11
  51. package/template/src/client/pages/DashboardPage.tsx +261 -0
  52. package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
  53. package/template/src/client/pages/LoginPage.tsx +127 -0
  54. package/template/src/client/pages/OrdersPage.tsx +196 -0
  55. package/template/src/client/pages/PluginDetailPage.tsx +345 -0
  56. package/template/src/client/pages/PluginsPage.tsx +223 -0
  57. package/template/src/client/pages/ProfilePage.tsx +206 -0
  58. package/template/src/client/pages/PublishPage.tsx +336 -0
  59. package/template/src/client/pages/RegisterPage.tsx +136 -0
  60. package/template/src/client/pages/SearchPage.tsx +204 -0
  61. package/template/src/client/pages/SettingsPage.tsx +220 -0
  62. package/template/src/client/pages/TopicsPage.tsx +180 -0
  63. package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
  64. package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
  65. package/template/src/client/preset-ui-config.ts +492 -0
  66. package/template/src/client/services/apiClient.ts +14 -4
  67. package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
  68. package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
  69. package/template/src/client/stores/authStore.ts +58 -5
  70. package/template/src/client/stores/chatWSStore.ts +9 -0
  71. package/template/src/client/stores/notificationStore.ts +4 -0
  72. package/template/src/client/stores/pluginStore.ts +279 -0
  73. package/template/src/client/stores/todoStore.ts +2 -6
  74. package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
  75. package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
  76. package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
  77. package/template/src/server/core/isr-cache.ts +239 -0
  78. package/template/src/server/core/isr-invalidation.ts +45 -0
  79. package/template/src/server/core/module-loader.ts +14 -7
  80. package/template/src/server/core/ssr-renderer.ts +240 -0
  81. package/template/src/server/db/init.ts +257 -10
  82. package/template/src/server/db/schema/developers.ts +20 -0
  83. package/template/src/server/db/schema/index.ts +2 -0
  84. package/template/src/server/db/schema/plugins.ts +114 -0
  85. package/template/src/server/db/test-setup.ts +91 -0
  86. package/template/src/server/entries/cloudflare.ts +79 -7
  87. package/template/src/server/entries/node.ts +48 -5
  88. package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
  89. package/template/src/server/middleware/auth.ts +8 -1
  90. package/template/src/server/middleware/captcha.ts +14 -4
  91. package/template/src/server/middleware/rate-limit.ts +7 -2
  92. package/template/src/server/module-admin/module.ts +9 -5
  93. package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
  94. package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
  95. package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
  96. package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
  97. package/template/src/server/module-admin/services/admin-service.ts +68 -11
  98. package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
  99. package/template/src/server/module-auth/index.ts +7 -0
  100. package/template/src/server/module-auth/module.ts +40 -0
  101. package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
  102. package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
  103. package/template/src/server/module-auth/services/auth-service.ts +100 -0
  104. package/template/src/server/module-content/module.ts +10 -4
  105. package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
  106. package/template/src/server/module-content/routes/topics-routes.ts +205 -0
  107. package/template/src/server/module-content/services/content-service.ts +18 -2
  108. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
  109. package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
  110. package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
  111. package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
  112. package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
  113. package/template/src/server/module-order/module.ts +15 -0
  114. package/template/src/server/module-order/routes/cart-routes.ts +103 -0
  115. package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
  116. package/template/src/server/module-order/services/order-service.ts +1 -1
  117. package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
  118. package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
  119. package/template/src/server/module-plugin/index.ts +2 -0
  120. package/template/src/server/module-plugin/module.ts +52 -0
  121. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
  122. package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
  123. package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
  124. package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
  125. package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
  126. package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
  127. package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
  128. package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
  129. package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
  130. package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
  131. package/template/src/server/route-registry.ts +16 -1
  132. package/template/src/server/test-utils/test-client.ts +1 -2
  133. package/template/src/server/utils/auth.ts +6 -0
  134. package/template/src/server/utils/json.ts +13 -0
  135. package/template/src/shared/core/module-manifest.ts +3 -6
  136. package/template/src/shared/modules/auth/index.ts +12 -0
  137. package/template/src/shared/modules/auth/schemas.ts +50 -0
  138. package/template/src/shared/modules/cart/index.ts +1 -0
  139. package/template/src/shared/modules/cart/schemas.ts +41 -0
  140. package/template/src/shared/modules/community/index.ts +1 -0
  141. package/template/src/shared/modules/community/schemas.ts +58 -0
  142. package/template/src/shared/modules/dashboard/index.ts +1 -0
  143. package/template/src/shared/modules/dashboard/schemas.ts +35 -0
  144. package/template/src/shared/modules/index.ts +41 -0
  145. package/template/src/shared/modules/order/schemas.ts +29 -0
  146. package/template/src/shared/modules/plugins/index.ts +48 -0
  147. package/template/src/shared/modules/plugins/schemas.ts +227 -0
  148. package/template/src/shared/schemas/index.ts +130 -0
  149. package/template/tests/e2e/todo.spec.ts +23 -18
  150. package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
  151. package/template/uploads/.gitkeep +0 -0
  152. package/template/vite.config.ts +2 -1
  153. package/template/vitest.setup.ts +11 -0
  154. package/template/wrangler.toml +4 -3
  155. package/template/package-lock.json +0 -14554
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @framework-baseline b820abc546e81b6c
3
+ *
4
+ * ISR (Incremental Static Regeneration) cache layer.
5
+ * Supports Cloudflare Cache API and in-memory fallback.
6
+ *
7
+ * @framework-modify
8
+ * @reason 移除未使用的 fullOptions 变量,修复 TypeScript strict 检查
9
+ * @impact 不影响功能,仅清理代码
10
+ */
11
+
12
+ export interface ISRCacheEntry {
13
+ html: string
14
+ createdAt: number
15
+ revalidateAt: number
16
+ }
17
+
18
+ export interface ISRLookupResult {
19
+ status: 'fresh' | 'stale' | 'miss'
20
+ html: string | null
21
+ entry?: ISRCacheEntry
22
+ }
23
+
24
+ export interface ISRCacheOptions {
25
+ maxAge?: number
26
+ staleWhileRevalidate?: number
27
+ }
28
+
29
+ const DEFAULT_MAX_AGE = 60
30
+ const DEFAULT_STALE_WHILE_REVALIDATE = 300
31
+
32
+ const ISR_ROUTES = ['/', '/todos', '/content', '/notifications', '/websocket']
33
+
34
+ const ISR_ROUTE_PREFIXES = ['/content/']
35
+
36
+ export function isISRRoute(pathname: string): boolean {
37
+ if (ISR_ROUTES.includes(pathname)) return true
38
+ for (const prefix of ISR_ROUTE_PREFIXES) {
39
+ if (pathname.startsWith(prefix)) return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ export function generateCacheKey(pathname: string): string {
45
+ const normalized = pathname === '/' ? '/index' : pathname.replace(/\/$/, '')
46
+ return `isr:${normalized}`
47
+ }
48
+
49
+ interface ISRCacheStore {
50
+ get(key: string): Promise<ISRCacheEntry | null>
51
+ set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void>
52
+ purge(key: string): Promise<void>
53
+ purgePattern(pattern: string): Promise<void>
54
+ }
55
+
56
+ class MemoryCacheStore implements ISRCacheStore {
57
+ private cache = new Map<string, ISRCacheEntry>()
58
+ private timer: ReturnType<typeof setInterval> | null = null
59
+
60
+ constructor() {
61
+ this.timer = setInterval(() => this.cleanup(), 60000)
62
+ if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
63
+ this.timer.unref()
64
+ }
65
+ }
66
+
67
+ private cleanup(): void {
68
+ const now = Date.now()
69
+ for (const [key, entry] of this.cache) {
70
+ if (now > entry.revalidateAt + 60000) {
71
+ this.cache.delete(key)
72
+ }
73
+ }
74
+ }
75
+
76
+ async get(key: string): Promise<ISRCacheEntry | null> {
77
+ return this.cache.get(key) ?? null
78
+ }
79
+
80
+ async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
81
+ const now = Date.now()
82
+ this.cache.set(key, {
83
+ html,
84
+ createdAt: now,
85
+ revalidateAt: now + options.maxAge * 1000,
86
+ })
87
+ }
88
+
89
+ async purge(key: string): Promise<void> {
90
+ this.cache.delete(key)
91
+ }
92
+
93
+ async purgePattern(pattern: string): Promise<void> {
94
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
95
+ for (const key of this.cache.keys()) {
96
+ if (regex.test(key)) {
97
+ this.cache.delete(key)
98
+ }
99
+ }
100
+ }
101
+
102
+ destroy(): void {
103
+ if (this.timer) {
104
+ clearInterval(this.timer)
105
+ this.timer = null
106
+ }
107
+ this.cache.clear()
108
+ }
109
+ }
110
+
111
+ class CloudflareCacheStore implements ISRCacheStore {
112
+ private cache: Cache | null = null
113
+ private origin = 'https://isr.local'
114
+
115
+ private async getCache(): Promise<Cache> {
116
+ if (!this.cache) {
117
+ this.cache = await caches.open('isr-pages')
118
+ }
119
+ return this.cache
120
+ }
121
+
122
+ private toUrl(key: string): string {
123
+ return `${this.origin}${key.replace(/^isr:/, '')}`
124
+ }
125
+
126
+ async get(key: string): Promise<ISRCacheEntry | null> {
127
+ const cache = await this.getCache()
128
+ const url = this.toUrl(key)
129
+ const response = await cache.match(url)
130
+ if (!response) return null
131
+
132
+ const html = await response.text()
133
+ const createdAt = Number(response.headers.get('x-isr-created-at') || '0')
134
+ const revalidateAt = Number(response.headers.get('x-isr-revalidate-at') || '0')
135
+
136
+ return { html, createdAt, revalidateAt }
137
+ }
138
+
139
+ async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
140
+ const cache = await this.getCache()
141
+ const url = this.toUrl(key)
142
+ const now = Date.now()
143
+
144
+ const response = new Response(html, {
145
+ headers: {
146
+ 'Content-Type': 'text/html;charset=UTF-8',
147
+ 'x-isr-created-at': String(now),
148
+ 'x-isr-revalidate-at': String(now + options.maxAge * 1000),
149
+ 'x-isr-max-age': String(options.maxAge),
150
+ 'x-isr-stale-while-revalidate': String(options.staleWhileRevalidate),
151
+ 'Cache-Control': `public, max-age=${options.maxAge}, s-maxage=${options.maxAge}, stale-while-revalidate=${options.staleWhileRevalidate}`,
152
+ },
153
+ })
154
+
155
+ await cache.put(url, response)
156
+ }
157
+
158
+ async purge(key: string): Promise<void> {
159
+ const cache = await this.getCache()
160
+ const url = this.toUrl(key)
161
+ await cache.delete(url)
162
+ }
163
+
164
+ async purgePattern(pattern: string): Promise<void> {
165
+ const cache = await this.getCache()
166
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
167
+ const allKeys: Request[] = []
168
+
169
+ for (const request of allKeys) {
170
+ const url = new URL(request.url)
171
+ const key = `isr:${url.pathname}`
172
+ if (regex.test(key)) {
173
+ await cache.delete(request)
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ export interface ISRCache {
180
+ lookup(pathname: string): Promise<ISRLookupResult>
181
+ store(pathname: string, html: string, options?: ISRCacheOptions): Promise<void>
182
+ purge(pathname: string): Promise<void>
183
+ purgePattern(pattern: string): Promise<void>
184
+ getStore(): ISRCacheStore
185
+ }
186
+
187
+ export function createISRCache(options?: ISRCacheOptions): ISRCache {
188
+ const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE
189
+ const staleWhileRevalidate = options?.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE
190
+
191
+ const isCloudflare = typeof caches !== 'undefined' && typeof caches.open === 'function'
192
+ const store: ISRCacheStore = isCloudflare ? new CloudflareCacheStore() : new MemoryCacheStore()
193
+
194
+ return {
195
+ async lookup(pathname: string): Promise<ISRLookupResult> {
196
+ const key = generateCacheKey(pathname)
197
+ const entry = await store.get(key)
198
+
199
+ if (!entry) {
200
+ return { status: 'miss', html: null }
201
+ }
202
+
203
+ const now = Date.now()
204
+ const staleTime = entry.revalidateAt + staleWhileRevalidate * 1000
205
+
206
+ if (now <= entry.revalidateAt) {
207
+ return { status: 'fresh', html: entry.html, entry }
208
+ }
209
+
210
+ if (now <= staleTime) {
211
+ return { status: 'stale', html: entry.html, entry }
212
+ }
213
+
214
+ return { status: 'miss', html: null }
215
+ },
216
+
217
+ async store(pathname: string, html: string, opts?: ISRCacheOptions): Promise<void> {
218
+ const key = generateCacheKey(pathname)
219
+ const storeOpts = {
220
+ maxAge: opts?.maxAge ?? maxAge,
221
+ staleWhileRevalidate: opts?.staleWhileRevalidate ?? staleWhileRevalidate,
222
+ }
223
+ await store.set(key, html, storeOpts)
224
+ },
225
+
226
+ async purge(pathname: string): Promise<void> {
227
+ const key = generateCacheKey(pathname)
228
+ await store.purge(key)
229
+ },
230
+
231
+ async purgePattern(pattern: string): Promise<void> {
232
+ await store.purgePattern(pattern)
233
+ },
234
+
235
+ getStore(): ISRCacheStore {
236
+ return store
237
+ },
238
+ }
239
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @framework-baseline a124c5bce28416ca
3
+ *
4
+ * @framework-modify
5
+ * @reason 自动生成的基准更新
6
+ * @impact 无功能影响
7
+ */
8
+
9
+ /**
10
+ * ISR cache invalidation utilities.
11
+ * Call these from service layer when content changes.
12
+ */
13
+
14
+ import type { ISRCache } from './isr-cache'
15
+
16
+ let _cache: ISRCache | null = null
17
+
18
+ export function setISRCache(cache: ISRCache): void {
19
+ _cache = cache
20
+ }
21
+
22
+ export function getISRCache(): ISRCache | null {
23
+ return _cache
24
+ }
25
+
26
+ export async function purgePage(pathname: string): Promise<void> {
27
+ if (!_cache) return
28
+ await _cache.purge(pathname)
29
+ }
30
+
31
+ export async function purgeContentPages(): Promise<void> {
32
+ if (!_cache) return
33
+ await _cache.purge('/content')
34
+ await _cache.purgePattern('isr:/content/*')
35
+ }
36
+
37
+ export async function purgeAllPages(): Promise<void> {
38
+ if (!_cache) return
39
+ await _cache.purge('/')
40
+ await _cache.purge('/todos')
41
+ await _cache.purge('/content')
42
+ await _cache.purge('/notifications')
43
+ await _cache.purge('/websocket')
44
+ await _cache.purgePattern('isr:/content/*')
45
+ }
@@ -169,13 +169,20 @@ export async function validateModules(): Promise<{
169
169
  const modulePath = join(MODULES_DIR, actualDir)
170
170
 
171
171
  if (manifest.routes.client) {
172
- const filePath = resolve(modulePath, manifest.routes.client.importPath + '.ts')
173
- if (!existsSync(filePath)) {
174
- errors.push({
175
- module: name,
176
- type: 'route-file',
177
- message: `client route file not found: ${manifest.routes.client.importPath}.ts`,
178
- })
172
+ const clientRoutes = Array.isArray(manifest.routes.client)
173
+ ? manifest.routes.client
174
+ : manifest.routes.client
175
+ ? [manifest.routes.client]
176
+ : []
177
+ for (const route of clientRoutes) {
178
+ const filePath = resolve(modulePath, route.importPath + '.ts')
179
+ if (!existsSync(filePath)) {
180
+ errors.push({
181
+ module: name,
182
+ type: 'route-file',
183
+ message: `client route file not found: ${route.importPath}.ts`,
184
+ })
185
+ }
179
186
  }
180
187
  }
181
188
 
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @framework-baseline cabb57f8f865d571
3
+ *
4
+ * SSR renderer for ISR. Generates HTML shells with SEO meta tags.
5
+ * Phase 1: Shell rendering with meta tags (no React renderToString).
6
+ * Phase 2 (future): Full renderToString with data-driven content.
7
+ *
8
+ * @framework-modify
9
+ * @reason 修复 RouteConfig.pattern 类型从 string 改为 RegExp,修复 TypeScript 类型错误
10
+ * @impact 不影响运行时行为,仅修复类型定义
11
+ */
12
+
13
+ export interface RouteMeta {
14
+ title: string
15
+ description: string
16
+ ogType?: string
17
+ }
18
+
19
+ export interface RouteConfig {
20
+ path: string
21
+ pattern: RegExp
22
+ meta: RouteMeta
23
+ loader?: (params: Record<string, string>) => Promise<Record<string, unknown>>
24
+ }
25
+
26
+ export interface RenderResult {
27
+ html: string
28
+ status: number
29
+ headers: Record<string, string>
30
+ }
31
+
32
+ const STATIC_ROUTES: RouteConfig[] = [
33
+ {
34
+ path: '/',
35
+ pattern: /^\/$/,
36
+ meta: {
37
+ title: 'Todo List - Biomimic App',
38
+ description: 'A full-stack application template with React and Hono',
39
+ },
40
+ },
41
+ {
42
+ path: '/todos',
43
+ pattern: /^\/todos$/,
44
+ meta: {
45
+ title: 'Todo List - Biomimic App',
46
+ description: 'Manage your todos with real-time updates',
47
+ },
48
+ },
49
+ {
50
+ path: '/notifications',
51
+ pattern: /^\/notifications$/,
52
+ meta: {
53
+ title: 'Notifications - Biomimic App',
54
+ description: 'Real-time notifications via Server-Sent Events',
55
+ },
56
+ },
57
+ {
58
+ path: '/websocket',
59
+ pattern: /^\/websocket$/,
60
+ meta: {
61
+ title: 'WebSocket Demo - Biomimic App',
62
+ description: 'Type-safe WebSocket communication demo',
63
+ },
64
+ },
65
+ {
66
+ path: '/content',
67
+ pattern: /^\/content$/,
68
+ meta: {
69
+ title: '内容中心 - Biomimic App',
70
+ description: 'Content management with categories and search',
71
+ },
72
+ },
73
+ {
74
+ path: '/content/:id',
75
+ pattern: /^\/content\/([^/]+)$/,
76
+ meta: {
77
+ title: '内容详情 - Biomimic App',
78
+ description: 'View content details',
79
+ },
80
+ loader: async params => {
81
+ try {
82
+ // Load content module dynamically (may not exist in all presets)
83
+ // Use require() to avoid TypeScript checking module existence at compile time
84
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
85
+ const contentModule = require('@server/module-content/services/content-service') as {
86
+ getContentById: (
87
+ id: string
88
+ ) => Promise<{ id: number; title: string; content?: string } | null>
89
+ }
90
+ const content = await contentModule.getContentById(params.id)
91
+ if (content) {
92
+ return {
93
+ __meta: {
94
+ title: `${content.title} - Biomimic App`,
95
+ description: content.content?.substring(0, 160) || 'View content details',
96
+ },
97
+ content,
98
+ }
99
+ }
100
+ } catch {
101
+ // DB not available, return default meta
102
+ }
103
+ return {}
104
+ },
105
+ },
106
+ ]
107
+
108
+ export function matchRoute(
109
+ pathname: string
110
+ ): { route: RouteConfig; params: Record<string, string> } | null {
111
+ for (const route of STATIC_ROUTES) {
112
+ const match = pathname.match(route.pattern)
113
+ if (match) {
114
+ const params: Record<string, string> = {}
115
+ if (route.path === '/content/:id' && match[1]) {
116
+ params.id = match[1]
117
+ }
118
+ return { route, params }
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ export async function renderPage(
125
+ pathname: string,
126
+ options?: {
127
+ assetHash?: string
128
+ data?: Record<string, unknown>
129
+ }
130
+ ): Promise<RenderResult> {
131
+ const matched = matchRoute(pathname)
132
+
133
+ if (!matched) {
134
+ return renderFallback()
135
+ }
136
+
137
+ const { route, params } = matched
138
+ let meta = { ...route.meta }
139
+ let pageData: Record<string, unknown> = {}
140
+
141
+ if (route.loader) {
142
+ try {
143
+ const loaderResult = await route.loader(params)
144
+ if (loaderResult.__meta) {
145
+ meta = { ...meta, ...(loaderResult.__meta as RouteMeta) }
146
+ delete loaderResult.__meta
147
+ }
148
+ pageData = loaderResult
149
+ } catch {
150
+ // Loader failed, use default meta
151
+ }
152
+ }
153
+
154
+ if (options?.data) {
155
+ pageData = { ...pageData, ...options.data }
156
+ }
157
+
158
+ const html = renderShell({
159
+ title: meta.title,
160
+ description: meta.description,
161
+ ogType: meta.ogType || 'website',
162
+ data: pageData,
163
+ pathname,
164
+ })
165
+
166
+ return {
167
+ html,
168
+ status: 200,
169
+ headers: {
170
+ 'Content-Type': 'text/html;charset=UTF-8',
171
+ 'X-ISR-Rendered': 'true',
172
+ },
173
+ }
174
+ }
175
+
176
+ interface ShellOptions {
177
+ title: string
178
+ description: string
179
+ ogType: string
180
+ data: Record<string, unknown>
181
+ pathname: string
182
+ }
183
+
184
+ function renderShell(options: ShellOptions): string {
185
+ const dataJson = JSON.stringify(options.data)
186
+ const escapedJson = dataJson.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
187
+
188
+ return `<!DOCTYPE html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="UTF-8" />
192
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
193
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
194
+ <title>${escapeHtml(options.title)}</title>
195
+ <meta name="description" content="${escapeHtml(options.description)}" />
196
+ <meta property="og:title" content="${escapeHtml(options.title)}" />
197
+ <meta property="og:description" content="${escapeHtml(options.description)}" />
198
+ <meta property="og:type" content="${escapeHtml(options.ogType)}" />
199
+ <meta property="og:url" content="${escapeHtml(options.pathname)}" />
200
+ <meta name="generator" content="ISR" />
201
+ </head>
202
+ <body>
203
+ <div id="root"></div>
204
+ <script>window.__SSR_DATA__ = ${escapedJson};window.__SSR_PATH__ = ${JSON.stringify(options.pathname)};</script>
205
+ <script type="module" src="/src/client/main.tsx"></script>
206
+ </body>
207
+ </html>`
208
+ }
209
+
210
+ function renderFallback(): RenderResult {
211
+ return {
212
+ html: `<!DOCTYPE html>
213
+ <html lang="en">
214
+ <head>
215
+ <meta charset="UTF-8" />
216
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
217
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
218
+ <title>Biomimic App</title>
219
+ <meta name="description" content="Biomimic App - A full-stack application template" />
220
+ </head>
221
+ <body>
222
+ <div id="root"></div>
223
+ <script type="module" src="/src/client/main.tsx"></script>
224
+ </body>
225
+ </html>`,
226
+ status: 200,
227
+ headers: {
228
+ 'Content-Type': 'text/html;charset=UTF-8',
229
+ },
230
+ }
231
+ }
232
+
233
+ function escapeHtml(str: string): string {
234
+ return str
235
+ .replace(/&/g, '&amp;')
236
+ .replace(/</g, '&lt;')
237
+ .replace(/>/g, '&gt;')
238
+ .replace(/"/g, '&quot;')
239
+ .replace(/'/g, '&#39;')
240
+ }