create-fullstack-scaffold 0.4.25 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/cli/index.js +432 -15
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +10 -13
  4. package/template/drizzle.config.ts +10 -4
  5. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  6. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  7. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  8. package/template/eslint.config.js +3 -0
  9. package/template/package.json +9 -6
  10. package/template/scripts/sync-agent-hooks.mjs +189 -0
  11. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  12. package/template/src/admin/pages/ContentPage.tsx +1 -1
  13. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  14. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  15. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  16. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  17. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  18. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  19. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  20. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  21. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  22. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  23. package/template/src/admin/services/apiClient.ts +2 -3
  24. package/template/src/cli/modules/content/index.ts +3 -3
  25. package/template/src/cli/modules/dispute/index.ts +3 -3
  26. package/template/src/cli/modules/ticket/index.ts +3 -3
  27. package/template/src/cli/modules/todo/index.ts +1 -1
  28. package/template/src/cli/rpc/client.ts +3 -4
  29. package/template/src/cli/rpc/index.ts +1 -1
  30. package/template/src/client/App.tsx +3 -39
  31. package/template/src/client/AppRoutes.tsx +53 -0
  32. package/template/src/client/entry-server.tsx +75 -0
  33. package/template/src/client/main.tsx +3 -1
  34. package/template/src/client/pages/SearchPage.tsx +1 -1
  35. package/template/src/client/services/apiClient.ts +12 -4
  36. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  37. package/template/src/client/stores/entry-stores.ts +36 -0
  38. package/template/src/client/stores/notificationStore.ts +4 -4
  39. package/template/src/client/stores/todoStore.ts +2 -2
  40. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  41. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  42. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  43. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  44. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  45. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  46. package/template/src/server/app.ts +6 -4
  47. package/template/src/server/core/__tests__/isr-cache-cf.test.ts +96 -0
  48. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  49. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  50. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  51. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  52. package/template/src/server/core/isr-cache.ts +61 -18
  53. package/template/src/server/core/isr-invalidation.ts +6 -12
  54. package/template/src/server/core/isr-registry.ts +104 -0
  55. package/template/src/server/core/isr-renderer.ts +75 -0
  56. package/template/src/server/db/schema/contents.ts +28 -20
  57. package/template/src/server/db/schema/disputes.ts +30 -22
  58. package/template/src/server/db/schema/notifications.ts +20 -14
  59. package/template/src/server/db/schema/orders.ts +23 -16
  60. package/template/src/server/db/schema/plugins.ts +56 -38
  61. package/template/src/server/db/schema/products.ts +24 -17
  62. package/template/src/server/db/schema/tickets.ts +44 -31
  63. package/template/src/server/db/schema/todos.ts +26 -18
  64. package/template/src/server/entries/cloudflare.ts +97 -21
  65. package/template/src/server/entries/node.ts +0 -2
  66. package/template/src/server/index.ts +0 -1
  67. package/template/src/server/isr-modules.ts +10 -0
  68. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  69. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  70. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  71. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  72. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  73. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  74. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  75. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  76. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  77. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  78. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  79. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  80. package/template/src/server/module-content/isr.ts +63 -0
  81. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  82. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  83. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  84. package/template/src/server/module-content/services/content-service.ts +23 -10
  85. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  86. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  87. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  88. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  89. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  90. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  91. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  92. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  93. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  94. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  95. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  96. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  97. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  98. package/template/src/server/module-order/services/order-service.ts +25 -13
  99. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  100. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  101. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  102. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  103. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  104. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  105. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  106. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  107. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  108. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  109. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  110. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  111. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  112. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  113. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  114. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  115. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  116. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  117. package/template/src/server/module-todos/isr.ts +41 -0
  118. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  119. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  120. package/template/src/server/route-registry.ts +0 -4
  121. package/template/src/server/rpc-merge.ts +27 -0
  122. package/template/src/server/rpc-surface.ts +117 -0
  123. package/template/src/server/rpc-type-canary.ts +80 -0
  124. package/template/src/server/test-utils/test-client.ts +7 -9
  125. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  126. package/template/src/shared/modules/content/schemas.ts +14 -0
  127. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  128. package/template/src/shared/modules/order/schemas.ts +9 -1
  129. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  130. package/template/src/shared/modules/todos/index.ts +4 -0
  131. package/template/src/shared/modules/todos/schemas.ts +14 -0
  132. package/template/src/shared/schemas/index.ts +18 -0
  133. package/template/tsup.config.ts +48 -1
  134. package/template/vitest.config.ts +19 -3
  135. package/template/vitest.setup.ts +68 -5
  136. package/template/patches/typescript+5.9.3.patch +0 -24
  137. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @framework-baseline b75ca79fd266283d
3
+ *
4
+ * @framework-modify
5
+ * @reason 断言对齐实现:generator meta 值为 ISR-SSG(isr-renderer.ts:65)
6
+ * @impact 仅测试断言,无运行时影响
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import { renderISRPage, escapeHtml } from '@server/core/isr-renderer'
11
+
12
+ describe('ISR Renderer', () => {
13
+ describe('escapeHtml', () => {
14
+ it('should escape & < > "', () => {
15
+ expect(escapeHtml('a&b<c>d"e')).toBe('a&amp;b&lt;c&gt;d&quot;e')
16
+ })
17
+
18
+ it('should escape empty string', () => {
19
+ expect(escapeHtml('')).toBe('')
20
+ })
21
+
22
+ it('should escape multiple occurrences', () => {
23
+ expect(escapeHtml('<<>>')).toBe('&lt;&lt;&gt;&gt;')
24
+ })
25
+
26
+ it('should not alter plain text', () => {
27
+ expect(escapeHtml('hello world')).toBe('hello world')
28
+ })
29
+ })
30
+
31
+ describe('renderISRPage — with template', () => {
32
+ const template = `<!DOCTYPE html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="UTF-8" />
36
+ <title>Original Title</title>
37
+ <meta name="description" content="original desc" />
38
+ <meta property="og:title" content="original" />
39
+ <meta property="og:description" content="original" />
40
+ </head>
41
+ <body>
42
+ <div id="root"></div>
43
+ </body>
44
+ </html>`
45
+
46
+ it('should inject title', () => {
47
+ const html = renderISRPage({
48
+ template,
49
+ meta: { title: 'New Title', description: 'new desc' },
50
+ })
51
+ expect(html).toContain('<title>New Title</title>')
52
+ expect(html).not.toContain('Original Title')
53
+ })
54
+
55
+ it('should inject meta description', () => {
56
+ const html = renderISRPage({
57
+ template,
58
+ meta: { title: 'T', description: 'New Description' },
59
+ })
60
+ expect(html).toContain('name="description" content="New Description"')
61
+ expect(html).not.toContain('content="original desc"')
62
+ })
63
+
64
+ it('should inject og:title and og:description', () => {
65
+ const html = renderISRPage({
66
+ template,
67
+ meta: { title: 'OG Title', description: 'OG Desc' },
68
+ })
69
+ expect(html).toContain('property="og:title" content="OG Title"')
70
+ expect(html).toContain('property="og:description" content="OG Desc"')
71
+ })
72
+
73
+ it('should inject generator=ISR', () => {
74
+ const html = renderISRPage({
75
+ template,
76
+ meta: { title: 'T', description: 'D' },
77
+ })
78
+ expect(html).toContain('name="generator" content="ISR-SSG"')
79
+ })
80
+
81
+ it('should keep root div empty (meta-only)', () => {
82
+ const html = renderISRPage({
83
+ template,
84
+ meta: { title: 'T', description: 'D' },
85
+ })
86
+ expect(html).toContain('<div id="root"></div>')
87
+ })
88
+
89
+ it('should escape HTML in meta values', () => {
90
+ const html = renderISRPage({
91
+ template,
92
+ meta: { title: '<script>alert(1)</script>', description: '<b>bold</b>' },
93
+ })
94
+ expect(html).not.toContain('<script>alert(1)</script>')
95
+ expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
96
+ })
97
+ })
98
+
99
+ describe('renderISRPage — no template (fallback)', () => {
100
+ it('should generate standalone HTML', () => {
101
+ const html = renderISRPage({
102
+ template: null,
103
+ meta: { title: 'Standalone', description: 'Fallback mode' },
104
+ })
105
+ expect(html).toContain('<!DOCTYPE html>')
106
+ expect(html).toContain('<title>Standalone</title>')
107
+ expect(html).toContain('<div id="root"></div>')
108
+ })
109
+
110
+ it('should include all meta tags in fallback', () => {
111
+ const html = renderISRPage({
112
+ template: null,
113
+ meta: { title: 'T', description: 'D' },
114
+ })
115
+ expect(html).toContain('name="description"')
116
+ expect(html).toContain('property="og:title"')
117
+ expect(html).toContain('property="og:description"')
118
+ expect(html).toContain('name="generator" content="ISR-SSG"')
119
+ })
120
+ })
121
+ })
@@ -5,10 +5,12 @@
5
5
  * Supports Cloudflare Cache API and in-memory fallback.
6
6
  *
7
7
  * @framework-modify
8
- * @reason 移除未使用的 fullOptions 变量,修复 TypeScript strict 检查
9
- * @impact 不影响功能,仅清理代码
8
+ * @reason 模块化改造:移除硬编码路由,路由判断委托给 isrRegistry
9
+ * @impact isr-invalidation 也需要同步改造
10
10
  */
11
11
 
12
+ import { isrRegistry } from './isr-registry'
13
+
12
14
  export interface ISRCacheEntry {
13
15
  html: string
14
16
  createdAt: number
@@ -29,16 +31,8 @@ export interface ISRCacheOptions {
29
31
  const DEFAULT_MAX_AGE = 60
30
32
  const DEFAULT_STALE_WHILE_REVALIDATE = 300
31
33
 
32
- const ISR_ROUTES = ['/', '/todos', '/content', '/notifications', '/websocket']
33
-
34
- const ISR_ROUTE_PREFIXES = ['/content/']
35
-
36
34
  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
35
+ return isrRegistry.isISRRoute(pathname)
42
36
  }
43
37
 
44
38
  export function generateCacheKey(pathname: string): string {
@@ -110,7 +104,7 @@ class MemoryCacheStore implements ISRCacheStore {
110
104
 
111
105
  class CloudflareCacheStore implements ISRCacheStore {
112
106
  private cache: Cache | null = null
113
- private origin = 'https://isr.local'
107
+ private origin = 'https://isr-v7.local'
114
108
 
115
109
  private async getCache(): Promise<Cache> {
116
110
  if (!this.cache) {
@@ -136,6 +130,33 @@ class CloudflareCacheStore implements ISRCacheStore {
136
130
  return { html, createdAt, revalidateAt }
137
131
  }
138
132
 
133
+ // Cache API 没有 list():用专用索引键记录所有已缓存 pathname(JSON 数组),
134
+ // purgePattern 据此清剿。跨 isolate 各自记账,最终一致(各自清理各自的键,
135
+ // 键相同即覆盖,无重复副作用)。
136
+ private static readonly INDEX_KEY = 'isr:__index__'
137
+
138
+ private async readIndex(): Promise<string[]> {
139
+ const cache = await this.getCache()
140
+ const res = await cache.match(this.toUrl(CloudflareCacheStore.INDEX_KEY))
141
+ if (!res) return []
142
+ try {
143
+ const parsed = JSON.parse(await res.text())
144
+ return Array.isArray(parsed) ? parsed : []
145
+ } catch {
146
+ return []
147
+ }
148
+ }
149
+
150
+ private async writeIndex(keys: string[]): Promise<void> {
151
+ const cache = await this.getCache()
152
+ // 索引不必常驻新鲜——purge 类操作低频,短 TTL 控制体积
153
+ const body = JSON.stringify([...new Set(keys)])
154
+ await cache.put(
155
+ this.toUrl(CloudflareCacheStore.INDEX_KEY),
156
+ new Response(body, { headers: { 'Content-Type': 'application/json' } })
157
+ )
158
+ }
159
+
139
160
  async set(key: string, html: string, options: Required<ISRCacheOptions>): Promise<void> {
140
161
  const cache = await this.getCache()
141
162
  const url = this.toUrl(key)
@@ -153,26 +174,48 @@ class CloudflareCacheStore implements ISRCacheStore {
153
174
  })
154
175
 
155
176
  await cache.put(url, response)
177
+
178
+ const index = await this.readIndex()
179
+ if (!index.includes(key)) {
180
+ index.push(key)
181
+ await this.writeIndex(index)
182
+ }
156
183
  }
157
184
 
158
185
  async purge(key: string): Promise<void> {
159
186
  const cache = await this.getCache()
160
187
  const url = this.toUrl(key)
161
188
  await cache.delete(url)
189
+
190
+ const index = await this.readIndex()
191
+ if (index.includes(key)) {
192
+ await this.writeIndex(index.filter(k => k !== key))
193
+ }
162
194
  }
163
195
 
164
196
  async purgePattern(pattern: string): Promise<void> {
197
+ // 此前为永远不执行的死代码(allKeys 恒空)——CF 上内容更新后
198
+ // 陈旧详情页会一直服务到自然过期。现按索引清单真清剿。
199
+ const regex = new RegExp('^' + this.escapeForPattern(pattern).replace(/\*/g, '.*') + '$')
200
+ const index = await this.readIndex()
165
201
  const cache = await this.getCache()
166
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
167
- const allKeys: Request[] = []
202
+ const survivors: string[] = []
168
203
 
169
- for (const request of allKeys) {
170
- const url = new URL(request.url)
171
- const key = `isr:${url.pathname}`
204
+ for (const key of index) {
172
205
  if (regex.test(key)) {
173
- await cache.delete(request)
206
+ await cache.delete(this.toUrl(key))
207
+ } else {
208
+ survivors.push(key)
174
209
  }
175
210
  }
211
+ if (survivors.length !== index.length) {
212
+ await this.writeIndex(survivors)
213
+ }
214
+ }
215
+
216
+ private escapeForPattern(pattern: string): string {
217
+ // 转义除 * 外的正则元字符,防 'content/1+2' 之类键名误匹配
218
+ return pattern.replace(/[.+?^${}()|[\]]/g, '\\$&')
176
219
  }
177
220
  }
178
221
 
@@ -2,16 +2,12 @@
2
2
  * @framework-baseline a124c5bce28416ca
3
3
  *
4
4
  * @framework-modify
5
- * @reason 自动生成的基准更新
6
- * @impact 无功能影响
7
- */
8
-
9
- /**
10
- * ISR cache invalidation utilities.
11
- * Call these from service layer when content changes.
5
+ * @reason 模块化改造:purgeAllPages 改为遍历注册表
6
+ * @impact 不再硬编码路由列表
12
7
  */
13
8
 
14
9
  import type { ISRCache } from './isr-cache'
10
+ import { isrRegistry } from './isr-registry'
15
11
 
16
12
  let _cache: ISRCache | null = null
17
13
 
@@ -36,10 +32,8 @@ export async function purgeContentPages(): Promise<void> {
36
32
 
37
33
  export async function purgeAllPages(): Promise<void> {
38
34
  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')
35
+ for (const path of isrRegistry.getExactPaths()) {
36
+ await _cache.purge(path)
37
+ }
44
38
  await _cache.purgePattern('isr:/content/*')
45
39
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @framework-baseline faa47d8c6e71847d
3
+ *
4
+ *
5
+ * @framework-modify
6
+ * @reason prettier 格式化与注释结构整理(无逻辑改动)
7
+ * @impact 框架文件维护性修改,行为见测试
8
+ */
9
+
10
+ /**
11
+ * ISR Route Registry — modules register their ISR routes here.
12
+ * Full SSG mode: fetches data for meta tags AND pre-populates stores for React SSR.
13
+ * The CF entry and ISR cache use this to discover which routes need ISR.
14
+ */
15
+
16
+ export type ISRMatcher = string | ((pathname: string) => boolean)
17
+
18
+ export interface ISRRouterContext {
19
+ /** D1 database binding (Cloudflare) or null (Node.js / test) */
20
+ db?: unknown
21
+ /** Raw env object (for module-specific needs) */
22
+ env?: unknown
23
+ }
24
+
25
+ export interface ISRRouteEntry {
26
+ /** Module name that owns this route */
27
+ module: string
28
+ /**
29
+ * Path matcher: exact string or function.
30
+ * String starting with '/' and ending with '/' = prefix match.
31
+ * String starting with '/' = exact match.
32
+ * Function = custom matcher.
33
+ */
34
+ match: ISRMatcher
35
+ /** Fetch data needed for SSR rendering and meta tags */
36
+ fetch: (pathname: string, ctx: ISRRouterContext) => Promise<unknown>
37
+ /** Generate meta tags from fetched data */
38
+ meta: (data: unknown, pathname: string) => { title: string; description: string }
39
+ /** Max age in seconds (optional, per-route override) */
40
+ maxAge?: number
41
+ }
42
+
43
+ class ISRRegistry {
44
+ private entries: ISRRouteEntry[] = []
45
+
46
+ register(entry: ISRRouteEntry): void {
47
+ const idx = this.entries.findIndex(e => e.module === entry.module && e.match === entry.match)
48
+ if (idx >= 0) {
49
+ this.entries[idx] = entry
50
+ } else {
51
+ this.entries.push(entry)
52
+ }
53
+ }
54
+
55
+ registerMany(entries: ISRRouteEntry[]): void {
56
+ for (const entry of entries) {
57
+ this.register(entry)
58
+ }
59
+ }
60
+
61
+ match(pathname: string): ISRRouteEntry | null {
62
+ for (const entry of this.entries) {
63
+ if (typeof entry.match === 'string' && !entry.match.endsWith('/')) {
64
+ if (pathname === entry.match) return entry
65
+ }
66
+ }
67
+ for (const entry of this.entries) {
68
+ if (typeof entry.match === 'string' && entry.match.endsWith('/') && entry.match !== '/') {
69
+ if (pathname.startsWith(entry.match)) return entry
70
+ }
71
+ }
72
+ for (const entry of this.entries) {
73
+ if (typeof entry.match === 'function') {
74
+ if (entry.match(pathname)) return entry
75
+ }
76
+ }
77
+ for (const entry of this.entries) {
78
+ if (entry.match === '/' && pathname === '/') return entry
79
+ }
80
+ return null
81
+ }
82
+
83
+ isISRRoute(pathname: string): boolean {
84
+ return this.match(pathname) !== null
85
+ }
86
+
87
+ getExactPaths(): string[] {
88
+ return this.entries.filter(e => typeof e.match === 'string').map(e => e.match as string)
89
+ }
90
+
91
+ getAll(): ISRRouteEntry[] {
92
+ return [...this.entries]
93
+ }
94
+
95
+ clear(): void {
96
+ this.entries = []
97
+ }
98
+ }
99
+
100
+ export const isrRegistry = new ISRRegistry()
101
+
102
+ export function createISRRegistry(): ISRRegistry {
103
+ return new ISRRegistry()
104
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @framework-baseline cec621fae360ba2b
3
+ *
4
+ *
5
+ * @framework-modify
6
+ * @reason prettier 格式化与注释结构整理;body 改为可选字段
7
+ * @impact 框架文件维护性修改,行为见测试
8
+ */
9
+
10
+ /**
11
+ * ISR HTML renderer — injects SSR body content AND meta tags into HTML template.
12
+ * Full SSG mode: combines React SSR output with SEO meta tags.
13
+ */
14
+
15
+ export function escapeHtml(str: string): string {
16
+ return str
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ }
22
+
23
+ export interface ISRPageMeta {
24
+ title: string
25
+ description: string
26
+ }
27
+
28
+ export interface ISRRenderOptions {
29
+ /** Pre-built HTML template (from ASSETS or readFileSync) */
30
+ template: string | null
31
+ /** SSR body content (React renderToString output); omitted in meta-only mode */
32
+ body?: string
33
+ /** Meta tags */
34
+ meta: ISRPageMeta
35
+ }
36
+
37
+ /**
38
+ * Inject ISR content (SSR body + meta tags) into an HTML template.
39
+ * If template is null, generates a minimal standalone HTML.
40
+ */
41
+ export function renderISRPage(opts: ISRRenderOptions): string {
42
+ const { template, body = '', meta } = opts
43
+ const safeTitle = escapeHtml(meta.title)
44
+ const safeDesc = escapeHtml(meta.description)
45
+
46
+ if (template) {
47
+ let html = template
48
+ html = html.replace(/<title>[^<]*<\/title>/, `<title>${safeTitle}</title>`)
49
+ html = html.replace(/<meta\s+name="description"[^>]*>/, '')
50
+ html = html.replace(/<meta\s+property="og:title"[^>]*>/, '')
51
+ html = html.replace(/<meta\s+property="og:description"[^>]*>/, '')
52
+ html = html.replace(
53
+ '</head>',
54
+ ` <meta name="description" content="${safeDesc}" />\n <meta property="og:title" content="${safeTitle}" />\n <meta property="og:description" content="${safeDesc}" />\n <meta name="generator" content="ISR-SSG" />\n </head>`
55
+ )
56
+ html = html.replace('<div id="root"></div>', `<div id="root">${body}</div>`)
57
+ return html
58
+ }
59
+
60
+ return `<!DOCTYPE html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="UTF-8" />
64
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
65
+ <title>${safeTitle}</title>
66
+ <meta name="description" content="${safeDesc}" />
67
+ <meta property="og:title" content="${safeTitle}" />
68
+ <meta property="og:description" content="${safeDesc}" />
69
+ <meta name="generator" content="ISR-SSG" />
70
+ </head>
71
+ <body>
72
+ <div id="root">${body}</div>
73
+ </body>
74
+ </html>`
75
+ }
@@ -1,4 +1,4 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
2
  import { sql } from 'drizzle-orm'
3
3
 
4
4
  export const contentStatuses = ['draft', 'published', 'archived'] as const
@@ -7,25 +7,33 @@ export type ContentStatus = (typeof contentStatuses)[number]
7
7
  export const contentCategories = ['article', 'announcement', 'tutorial', 'news', 'policy'] as const
8
8
  export type ContentCategory = (typeof contentCategories)[number]
9
9
 
10
- export const contents = sqliteTable('contents', {
11
- id: integer('id').primaryKey({ autoIncrement: true }),
12
- title: text('title').notNull(),
13
- body: text('body').notNull(),
14
- excerpt: text('excerpt'),
15
- category: text('category', { enum: contentCategories }).notNull(),
16
- tags: text('tags'),
17
- status: text('status', { enum: contentStatuses }).notNull().default('draft'),
18
- author: text('author').notNull(),
19
- viewCount: integer('view_count').notNull().default(0),
20
- likeCount: integer('like_count').notNull().default(0),
21
- publishedAt: integer('published_at', { mode: 'timestamp' }),
22
- createdAt: integer('created_at', { mode: 'timestamp' })
23
- .notNull()
24
- .default(sql`(unixepoch() * 1000)`),
25
- updatedAt: integer('updated_at', { mode: 'timestamp' })
26
- .notNull()
27
- .default(sql`(unixepoch() * 1000)`),
28
- })
10
+ export const contents = sqliteTable(
11
+ 'contents',
12
+ {
13
+ id: integer('id').primaryKey({ autoIncrement: true }),
14
+ title: text('title').notNull(),
15
+ body: text('body').notNull(),
16
+ excerpt: text('excerpt'),
17
+ category: text('category', { enum: contentCategories }).notNull(),
18
+ tags: text('tags'),
19
+ status: text('status', { enum: contentStatuses }).notNull().default('draft'),
20
+ author: text('author').notNull(),
21
+ viewCount: integer('view_count').notNull().default(0),
22
+ likeCount: integer('like_count').notNull().default(0),
23
+ publishedAt: integer('published_at', { mode: 'timestamp' }),
24
+ createdAt: integer('created_at', { mode: 'timestamp' })
25
+ .notNull()
26
+ .default(sql`(unixepoch() * 1000)`),
27
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
28
+ .notNull()
29
+ .default(sql`(unixepoch() * 1000)`),
30
+ },
31
+ table => ({
32
+ statusIdx: index('contents_status_idx').on(table.status),
33
+ categoryIdx: index('contents_category_idx').on(table.category),
34
+ createdAtIdx: index('contents_created_at_idx').on(table.createdAt),
35
+ })
36
+ )
29
37
 
30
38
  export type ContentTable = typeof contents.$inferSelect
31
39
  export type NewContent = typeof contents.$inferInsert
@@ -1,4 +1,4 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
2
  import { sql } from 'drizzle-orm'
3
3
 
4
4
  export const disputeTypes = [
@@ -13,27 +13,35 @@ export type DisputeType = (typeof disputeTypes)[number]
13
13
  export const disputeStatuses = ['pending', 'investigating', 'resolved', 'rejected'] as const
14
14
  export type DisputeStatus = (typeof disputeStatuses)[number]
15
15
 
16
- export const disputes = sqliteTable('disputes', {
17
- id: integer('id').primaryKey({ autoIncrement: true }),
18
- disputeNo: text('dispute_no').notNull(),
19
- orderId: text('order_id').notNull(),
20
- orderNo: text('order_no').notNull(),
21
- customerName: text('customer_name').notNull(),
22
- customerEmail: text('customer_email').notNull(),
23
- type: text('type', { enum: disputeTypes }).notNull(),
24
- status: text('status', { enum: disputeStatuses }).notNull().default('pending'),
25
- description: text('description').notNull(),
26
- resolution: text('resolution'),
27
- amount: integer('amount').notNull(),
28
- resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
29
- resolvedBy: text('resolved_by'),
30
- createdAt: integer('created_at', { mode: 'timestamp' })
31
- .notNull()
32
- .default(sql`(unixepoch() * 1000)`),
33
- updatedAt: integer('updated_at', { mode: 'timestamp' })
34
- .notNull()
35
- .default(sql`(unixepoch() * 1000)`),
36
- })
16
+ export const disputes = sqliteTable(
17
+ 'disputes',
18
+ {
19
+ id: integer('id').primaryKey({ autoIncrement: true }),
20
+ disputeNo: text('dispute_no').notNull(),
21
+ orderId: text('order_id').notNull(),
22
+ orderNo: text('order_no').notNull(),
23
+ customerName: text('customer_name').notNull(),
24
+ customerEmail: text('customer_email').notNull(),
25
+ type: text('type', { enum: disputeTypes }).notNull(),
26
+ status: text('status', { enum: disputeStatuses }).notNull().default('pending'),
27
+ description: text('description').notNull(),
28
+ resolution: text('resolution'),
29
+ amount: integer('amount').notNull(),
30
+ resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
31
+ resolvedBy: text('resolved_by'),
32
+ createdAt: integer('created_at', { mode: 'timestamp' })
33
+ .notNull()
34
+ .default(sql`(unixepoch() * 1000)`),
35
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
36
+ .notNull()
37
+ .default(sql`(unixepoch() * 1000)`),
38
+ },
39
+ table => ({
40
+ statusIdx: index('disputes_status_idx').on(table.status),
41
+ orderIdIdx: index('disputes_order_id_idx').on(table.orderId),
42
+ createdAtIdx: index('disputes_created_at_idx').on(table.createdAt),
43
+ })
44
+ )
37
45
 
38
46
  export type DisputeTable = typeof disputes.$inferSelect
39
47
  export type NewDispute = typeof disputes.$inferInsert
@@ -1,16 +1,22 @@
1
- import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
2
- import { sql } from 'drizzle-orm';
1
+ import { sqliteTable, integer, text, index } from 'drizzle-orm/sqlite-core'
2
+ import { sql } from 'drizzle-orm'
3
3
 
4
- export const notifications = sqliteTable('notifications', {
5
- id: text('id').primaryKey(),
6
- type: text('type').notNull(),
7
- title: text('title').notNull(),
8
- message: text('message').notNull(),
9
- read: integer('read', { mode: 'boolean' }).notNull().default(false),
10
- createdAt: integer('created_at', { mode: 'timestamp' })
11
- .notNull()
12
- .default(sql`(unixepoch() * 1000)`),
13
- });
4
+ export const notifications = sqliteTable(
5
+ 'notifications',
6
+ {
7
+ id: text('id').primaryKey(),
8
+ type: text('type').notNull(),
9
+ title: text('title').notNull(),
10
+ message: text('message').notNull(),
11
+ read: integer('read', { mode: 'boolean' }).notNull().default(false),
12
+ createdAt: integer('created_at', { mode: 'timestamp' })
13
+ .notNull()
14
+ .default(sql`(unixepoch() * 1000)`),
15
+ },
16
+ table => ({
17
+ createdAtIdx: index('notifications_created_at_idx').on(table.createdAt),
18
+ })
19
+ )
14
20
 
15
- export type NotificationTable = typeof notifications.$inferSelect;
16
- export type NewNotification = typeof notifications.$inferInsert;
21
+ export type NotificationTable = typeof notifications.$inferSelect
22
+ export type NewNotification = typeof notifications.$inferInsert