create-fullstack-scaffold 0.4.25 → 0.5.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 (135) 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/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  5. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  6. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  7. package/template/eslint.config.js +3 -0
  8. package/template/package.json +9 -6
  9. package/template/scripts/sync-agent-hooks.mjs +189 -0
  10. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  11. package/template/src/admin/pages/ContentPage.tsx +1 -1
  12. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  13. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  14. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  15. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  16. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  17. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  18. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  19. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  20. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  21. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  22. package/template/src/admin/services/apiClient.ts +2 -3
  23. package/template/src/cli/modules/content/index.ts +3 -3
  24. package/template/src/cli/modules/dispute/index.ts +3 -3
  25. package/template/src/cli/modules/ticket/index.ts +3 -3
  26. package/template/src/cli/modules/todo/index.ts +1 -1
  27. package/template/src/cli/rpc/client.ts +3 -4
  28. package/template/src/cli/rpc/index.ts +1 -1
  29. package/template/src/client/App.tsx +3 -39
  30. package/template/src/client/AppRoutes.tsx +53 -0
  31. package/template/src/client/entry-server.tsx +75 -0
  32. package/template/src/client/main.tsx +3 -1
  33. package/template/src/client/pages/SearchPage.tsx +1 -1
  34. package/template/src/client/services/apiClient.ts +12 -4
  35. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  36. package/template/src/client/stores/entry-stores.ts +36 -0
  37. package/template/src/client/stores/notificationStore.ts +4 -4
  38. package/template/src/client/stores/todoStore.ts +2 -2
  39. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  40. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  41. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  42. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  43. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  44. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  45. package/template/src/server/app.ts +6 -4
  46. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  47. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  48. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  49. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  50. package/template/src/server/core/isr-cache.ts +6 -12
  51. package/template/src/server/core/isr-invalidation.ts +6 -12
  52. package/template/src/server/core/isr-registry.ts +104 -0
  53. package/template/src/server/core/isr-renderer.ts +75 -0
  54. package/template/src/server/db/schema/contents.ts +28 -20
  55. package/template/src/server/db/schema/disputes.ts +30 -22
  56. package/template/src/server/db/schema/notifications.ts +20 -14
  57. package/template/src/server/db/schema/orders.ts +23 -16
  58. package/template/src/server/db/schema/plugins.ts +56 -38
  59. package/template/src/server/db/schema/products.ts +24 -17
  60. package/template/src/server/db/schema/tickets.ts +44 -31
  61. package/template/src/server/db/schema/todos.ts +26 -18
  62. package/template/src/server/entries/cloudflare.ts +82 -19
  63. package/template/src/server/entries/node.ts +0 -2
  64. package/template/src/server/index.ts +0 -1
  65. package/template/src/server/isr-modules.ts +10 -0
  66. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  67. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  68. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  69. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  70. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  71. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  72. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  73. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  74. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  75. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  76. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  77. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  78. package/template/src/server/module-content/isr.ts +63 -0
  79. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  80. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  81. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  82. package/template/src/server/module-content/services/content-service.ts +23 -10
  83. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  84. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  85. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  86. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  87. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  88. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  89. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  90. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  91. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  92. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  93. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  94. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  95. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  96. package/template/src/server/module-order/services/order-service.ts +25 -13
  97. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  98. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  99. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  100. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  101. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  102. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  103. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  104. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  105. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  106. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  107. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  108. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  109. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  110. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  111. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  112. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  113. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  114. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  115. package/template/src/server/module-todos/isr.ts +41 -0
  116. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  117. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  118. package/template/src/server/route-registry.ts +0 -4
  119. package/template/src/server/rpc-merge.ts +27 -0
  120. package/template/src/server/rpc-surface.ts +117 -0
  121. package/template/src/server/rpc-type-canary.ts +80 -0
  122. package/template/src/server/test-utils/test-client.ts +7 -9
  123. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  124. package/template/src/shared/modules/content/schemas.ts +14 -0
  125. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  126. package/template/src/shared/modules/order/schemas.ts +9 -1
  127. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  128. package/template/src/shared/modules/todos/index.ts +4 -0
  129. package/template/src/shared/modules/todos/schemas.ts +14 -0
  130. package/template/src/shared/schemas/index.ts +18 -0
  131. package/template/tsup.config.ts +48 -1
  132. package/template/vitest.config.ts +19 -3
  133. package/template/vitest.setup.ts +68 -5
  134. package/template/patches/typescript+5.9.3.patch +0 -24
  135. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -42,7 +42,11 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
42
42
  return c.json({ status: 'ok', timestamp: new Date().toISOString(), db: 'not configured' })
43
43
  }
44
44
  })
45
- .post('/api/__test__/cleanup', async c => {
45
+
46
+ // 测试辅助端点:仅开发/测试环境注册。生产(含 Cloudflare 构建——tsup 将
47
+ // process.env.NODE_ENV 静态替换为 "production")不暴露这个清库端点。
48
+ if (process.env.NODE_ENV !== 'production') {
49
+ app.post('/api/__test__/cleanup', async c => {
46
50
  try {
47
51
  const { cleanupTestDatabase } = await import('./db/test-setup')
48
52
  await cleanupTestDatabase()
@@ -52,6 +56,7 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
52
56
  return c.json({ success: false as const, message: 'Failed to cleanup database' }, 500)
53
57
  }
54
58
  })
59
+ }
55
60
 
56
61
  autoRegisterRealtime(app as unknown as Parameters<typeof autoRegisterRealtime>[0])
57
62
 
@@ -100,6 +105,3 @@ export function createApp<T extends AppBindings = AppBindings>(_options: CreateA
100
105
 
101
106
  return app
102
107
  }
103
- export type AdminApiType = typeof adminApiRoutes
104
- export type ClientApiType = typeof clientApiRoutes
105
- export type AppType = ReturnType<typeof createApp>
@@ -1,117 +1,121 @@
1
1
  /**
2
- * @framework-baseline dd5cd4fe46320b03
3
- * @framework-modify
4
- * @reason 移除未使用的 vi import,修复 TypeScript strict 检查
5
- * @impact 不影响功能,仅清理代码
2
+ * @framework-baseline 68a6e13bffb67f13
6
3
  */
7
4
 
8
5
  import { describe, it, expect, beforeEach } from 'vitest'
9
- import { isISRRoute, generateCacheKey, createISRCache, type ISRCache } from '@server/core/isr-cache'
10
-
11
- describe('isISRRoute', () => {
12
- it('matches static ISR routes', () => {
13
- expect(isISRRoute('/')).toBe(true)
14
- expect(isISRRoute('/todos')).toBe(true)
15
- expect(isISRRoute('/content')).toBe(true)
16
- expect(isISRRoute('/notifications')).toBe(true)
17
- expect(isISRRoute('/websocket')).toBe(true)
18
- })
19
-
20
- it('matches dynamic content routes', () => {
21
- expect(isISRRoute('/content/123')).toBe(true)
22
- expect(isISRRoute('/content/content-456')).toBe(true)
23
- expect(isISRRoute('/content/abc-def')).toBe(true)
24
- })
25
-
26
- it('does not match non-ISR routes', () => {
27
- expect(isISRRoute('/api/todos')).toBe(false)
28
- expect(isISRRoute('/api/health')).toBe(false)
29
- expect(isISRRoute('/assets/main.js')).toBe(false)
30
- expect(isISRRoute('/admin/dashboard')).toBe(false)
31
- expect(isISRRoute('/health')).toBe(false)
32
- expect(isISRRoute('/vite.svg')).toBe(false)
33
- })
34
- })
35
-
36
- describe('generateCacheKey', () => {
37
- it('normalizes root path', () => {
38
- expect(generateCacheKey('/')).toBe('isr:/index')
39
- })
40
-
41
- it('generates keys for regular paths', () => {
42
- expect(generateCacheKey('/todos')).toBe('isr:/todos')
43
- expect(generateCacheKey('/content/123')).toBe('isr:/content/123')
44
- })
6
+ import { createISRCache } from '@server/core/isr-cache'
45
7
 
46
- it('removes trailing slash', () => {
47
- expect(generateCacheKey('/todos/')).toBe('isr:/todos')
48
- })
49
- })
50
-
51
- describe('ISRCache (memory)', () => {
52
- let cache: ISRCache
8
+ describe('ISR Cache', () => {
9
+ let cache: ReturnType<typeof createISRCache>
53
10
 
54
11
  beforeEach(() => {
55
- cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 1 })
12
+ cache = createISRCache({ maxAge: 1, staleWhileRevalidate: 2 })
56
13
  })
57
14
 
58
- it('returns miss for uncached routes', async () => {
59
- const result = await cache.lookup('/todos')
60
- expect(result.status).toBe('miss')
61
- expect(result.html).toBeNull()
15
+ describe('lookup no cache', () => {
16
+ it('should return miss for uncached path', async () => {
17
+ const result = await cache.lookup('/todos')
18
+ expect(result.status).toBe('miss')
19
+ expect(result.html).toBeNull()
20
+ })
62
21
  })
63
22
 
64
- it('stores and retrieves pages', async () => {
65
- await cache.store('/todos', '<html>todos</html>')
66
- const result = await cache.lookup('/todos')
67
- expect(result.status).toBe('fresh')
68
- expect(result.html).toBe('<html>todos</html>')
23
+ describe('store lookup fresh', () => {
24
+ it('should return fresh after store', async () => {
25
+ await cache.store('/todos', '<html>todos</html>')
26
+ const result = await cache.lookup('/todos')
27
+ expect(result.status).toBe('fresh')
28
+ expect(result.html).toBe('<html>todos</html>')
29
+ })
69
30
  })
70
31
 
71
- it('serves stale content after maxAge', async () => {
72
- cache = createISRCache({ maxAge: 0, staleWhileRevalidate: 60 })
73
-
74
- await cache.store('/todos', '<html>todos</html>')
75
-
76
- await new Promise(resolve => setTimeout(resolve, 10))
32
+ describe('store wait lookup stale', () => {
33
+ it('should return stale after maxAge', async () => {
34
+ await cache.store('/todos', '<html>todos</html>')
35
+ // Wait beyond maxAge (1s) but within staleWhileRevalidate (1+2=3s)
36
+ await new Promise(r => setTimeout(r, 1200))
37
+ const result = await cache.lookup('/todos')
38
+ expect(result.status).toBe('stale')
39
+ expect(result.html).toBe('<html>todos</html>')
40
+ })
41
+ })
77
42
 
78
- const result = await cache.lookup('/todos')
79
- expect(result.status).toBe('stale')
80
- expect(result.html).toBe('<html>todos</html>')
43
+ describe('store wait lookup miss (expired)', () => {
44
+ it('should return miss after maxAge + staleWhileRevalidate', async () => {
45
+ await cache.store('/todos', '<html>todos</html>')
46
+ // Wait beyond both maxAge and staleWhileRevalidate
47
+ await new Promise(r => setTimeout(r, 3500))
48
+ const result = await cache.lookup('/todos')
49
+ expect(result.status).toBe('miss')
50
+ expect(result.html).toBeNull()
51
+ })
81
52
  })
82
53
 
83
- it('returns miss after maxAge + staleWhileRevalidate', async () => {
84
- cache = createISRCache({ maxAge: 0, staleWhileRevalidate: 0 })
54
+ describe('purge', () => {
55
+ it('should purge specific path', async () => {
56
+ await cache.store('/todos', '<html>todos</html>')
57
+ await cache.store('/content', '<html>content</html>')
85
58
 
86
- await cache.store('/todos', '<html>todos</html>')
59
+ await cache.purge('/todos')
87
60
 
88
- await new Promise(resolve => setTimeout(resolve, 10))
61
+ expect((await cache.lookup('/todos')).status).toBe('miss')
62
+ expect((await cache.lookup('/content')).status).toBe('fresh')
63
+ })
89
64
 
90
- const result = await cache.lookup('/todos')
91
- expect(result.status).toBe('miss')
65
+ it('should not error on purge of uncached path', async () => {
66
+ await expect(cache.purge('/never-cached')).resolves.not.toThrow()
67
+ })
92
68
  })
93
69
 
94
- it('purges specific pages', async () => {
95
- await cache.store('/todos', '<html>todos</html>')
96
- await cache.store('/content', '<html>content</html>')
97
-
98
- await cache.purge('/todos')
99
-
100
- expect((await cache.lookup('/todos')).status).toBe('miss')
101
- expect((await cache.lookup('/content')).status).toBe('fresh')
70
+ describe('purgePattern', () => {
71
+ it('should purge matching pattern', async () => {
72
+ await cache.store('/content', '<html>list</html>')
73
+ await cache.store('/content/article-1', '<html>a1</html>')
74
+ await cache.store('/content/article-2', '<html>a2</html>')
75
+ await cache.store('/todos', '<html>todos</html>')
76
+
77
+ await cache.purgePattern('isr:/content/*')
78
+
79
+ // /content (exact) should still be cached
80
+ expect((await cache.lookup('/content')).status).toBe('fresh')
81
+ // /content/* should be purged
82
+ expect((await cache.lookup('/content/article-1')).status).toBe('miss')
83
+ expect((await cache.lookup('/content/article-2')).status).toBe('miss')
84
+ // /todos should be unaffected
85
+ expect((await cache.lookup('/todos')).status).toBe('fresh')
86
+ })
102
87
  })
103
88
 
104
- it('purges by pattern', async () => {
105
- await cache.store('/content', '<html>list</html>')
106
- await cache.store('/content/123', '<html>detail 123</html>')
107
- await cache.store('/content/456', '<html>detail 456</html>')
108
- await cache.store('/todos', '<html>todos</html>')
109
-
110
- await cache.purgePattern('isr:/content/*')
111
-
112
- expect((await cache.lookup('/content')).status).toBe('fresh')
113
- expect((await cache.lookup('/content/123')).status).toBe('miss')
114
- expect((await cache.lookup('/content/456')).status).toBe('miss')
115
- expect((await cache.lookup('/todos')).status).toBe('fresh')
89
+ describe('full lifecycle', () => {
90
+ it('miss store → fresh → purge → miss', async () => {
91
+ // Step 1: Initial miss
92
+ let result = await cache.lookup('/todos')
93
+ expect(result.status).toBe('miss')
94
+
95
+ // Step 2: Store
96
+ await cache.store('/todos', '<html>todos v1</html>')
97
+
98
+ // Step 3: Fresh hit
99
+ result = await cache.lookup('/todos')
100
+ expect(result.status).toBe('fresh')
101
+ expect(result.html).toBe('<html>todos v1</html>')
102
+
103
+ // Step 4: Purge
104
+ await cache.purge('/todos')
105
+
106
+ // Step 5: Miss again
107
+ result = await cache.lookup('/todos')
108
+ expect(result.status).toBe('miss')
109
+ })
110
+
111
+ it('store v1 → purge → store v2 → returns v2', async () => {
112
+ await cache.store('/todos', '<html>v1</html>')
113
+ await cache.purge('/todos')
114
+ await cache.store('/todos', '<html>v2</html>')
115
+
116
+ const result = await cache.lookup('/todos')
117
+ expect(result.status).toBe('fresh')
118
+ expect(result.html).toBe('<html>v2</html>')
119
+ })
116
120
  })
117
121
  })
@@ -15,6 +15,25 @@ import {
15
15
  } from '@server/core/isr-invalidation'
16
16
  import { createISRCache } from '@server/core/isr-cache'
17
17
 
18
+ // Side-effect: 注册 ISR 路由到全局 registry(purgeAllPages 遍历它)。
19
+ // todos/content 在部分 preset 中不存在 —— 均为可选导入
20
+ const todosIsrModule = '@server/module-todos/isr'
21
+ let hasTodosModule = false
22
+ try {
23
+ await import(/* @vite-ignore */ todosIsrModule)
24
+ hasTodosModule = true
25
+ } catch {
26
+ // preset 无 todos 模块(forum/xbrowser 等)
27
+ }
28
+ const contentIsrModule = '@server/module-content/isr'
29
+ let hasContentModule = false
30
+ try {
31
+ await import(/* @vite-ignore */ contentIsrModule)
32
+ hasContentModule = true
33
+ } catch {
34
+ // preset 无 content 模块
35
+ }
36
+
18
37
  describe('isr-invalidation', () => {
19
38
  it('returns null cache when not set', () => {
20
39
  expect(getISRCache()).toBeNull()
@@ -63,10 +82,16 @@ describe('isr-invalidation', () => {
63
82
 
64
83
  await purgeAllPages()
65
84
 
66
- expect((await cache.lookup('/')).status).toBe('miss')
67
- expect((await cache.lookup('/todos')).status).toBe('miss')
68
- expect((await cache.lookup('/content')).status).toBe('miss')
69
- expect((await cache.lookup('/content/123')).status).toBe('miss')
85
+ if (hasTodosModule) {
86
+ // '/''/todos' 路由由 todos 模块注册,仅含 todos 的 preset 可断言
87
+ expect((await cache.lookup('/')).status).toBe('miss')
88
+ expect((await cache.lookup('/todos')).status).toBe('miss')
89
+ }
90
+ if (hasContentModule) {
91
+ // content 相关页仅在 preset 含 content 模块时被注册、才可被清理
92
+ expect((await cache.lookup('/content')).status).toBe('miss')
93
+ expect((await cache.lookup('/content/123')).status).toBe('miss')
94
+ }
70
95
  })
71
96
  })
72
97
  })
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @framework-baseline 560bce7dbeafc84a
3
+ *
4
+ * @framework-modify
5
+ * @reason prettier 格式化导致内容哈希与初始基准漂移,补记说明
6
+ * @impact 仅格式,无逻辑改动
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from 'vitest'
10
+ import { createISRRegistry } from '@server/core/isr-registry'
11
+
12
+ describe('ISR Registry', () => {
13
+ let registry: ReturnType<typeof createISRRegistry>
14
+
15
+ beforeEach(() => {
16
+ registry = createISRRegistry()
17
+ })
18
+
19
+ describe('register', () => {
20
+ it('should register a single route', () => {
21
+ registry.register({
22
+ module: 'todos',
23
+ match: '/todos',
24
+ fetch: async () => ({}),
25
+ meta: () => ({ title: 'Todos', description: 'Todo list' }),
26
+ })
27
+ expect(registry.getAll()).toHaveLength(1)
28
+ })
29
+
30
+ it('should register multiple routes', () => {
31
+ registry.registerMany([
32
+ {
33
+ module: 'todos',
34
+ match: '/todos',
35
+ fetch: async () => ({}),
36
+ meta: () => ({ title: 'Todos', description: '' }),
37
+ },
38
+ {
39
+ module: 'content',
40
+ match: '/content',
41
+ fetch: async () => ({}),
42
+ meta: () => ({ title: 'Content', description: '' }),
43
+ },
44
+ ])
45
+ expect(registry.getAll()).toHaveLength(2)
46
+ })
47
+
48
+ it('should update existing entry on re-register', () => {
49
+ registry.register({
50
+ module: 'todos',
51
+ match: '/todos',
52
+ fetch: async () => ({}),
53
+ meta: () => ({ title: 'v1', description: '' }),
54
+ })
55
+ registry.register({
56
+ module: 'todos',
57
+ match: '/todos',
58
+ fetch: async () => ({}),
59
+ meta: () => ({ title: 'v2', description: '' }),
60
+ })
61
+ expect(registry.getAll()).toHaveLength(1)
62
+ expect(registry.getAll()[0].meta({}, '')).toEqual({ title: 'v2', description: '' })
63
+ })
64
+ })
65
+
66
+ describe('match — exact paths', () => {
67
+ it('should match exact path', () => {
68
+ registry.register({
69
+ module: 'todos',
70
+ match: '/todos',
71
+ fetch: async () => ({}),
72
+ meta: () => ({ title: '', description: '' }),
73
+ })
74
+ const entry = registry.match('/todos')
75
+ expect(entry).not.toBeNull()
76
+ expect(entry!.module).toBe('todos')
77
+ })
78
+
79
+ it('should not match wrong exact path', () => {
80
+ registry.register({
81
+ module: 'todos',
82
+ match: '/todos',
83
+ fetch: async () => ({}),
84
+ meta: () => ({ title: '', description: '' }),
85
+ })
86
+ expect(registry.match('/orders')).toBeNull()
87
+ })
88
+
89
+ it('should match root /', () => {
90
+ registry.register({
91
+ module: 'home',
92
+ match: '/',
93
+ fetch: async () => ({}),
94
+ meta: () => ({ title: '', description: '' }),
95
+ })
96
+ expect(registry.match('/')).not.toBeNull()
97
+ expect(registry.match('/todos')).toBeNull()
98
+ })
99
+ })
100
+
101
+ describe('match — prefix paths', () => {
102
+ it('should match prefix /content/', () => {
103
+ registry.register({
104
+ module: 'content',
105
+ match: '/content/',
106
+ fetch: async () => ({}),
107
+ meta: () => ({ title: '', description: '' }),
108
+ })
109
+ expect(registry.match('/content/content-1')).not.toBeNull()
110
+ expect(registry.match('/content/any-id')).not.toBeNull()
111
+ })
112
+
113
+ it('should not match prefix for unrelated path', () => {
114
+ registry.register({
115
+ module: 'content',
116
+ match: '/content/',
117
+ fetch: async () => ({}),
118
+ meta: () => ({ title: '', description: '' }),
119
+ })
120
+ expect(registry.match('/todos')).toBeNull()
121
+ })
122
+
123
+ it('should prefer exact match over prefix', () => {
124
+ registry.register({
125
+ module: 'content-detail',
126
+ match: '/content/',
127
+ fetch: async () => ({}),
128
+ meta: () => ({ title: 'detail', description: '' }),
129
+ })
130
+ registry.register({
131
+ module: 'content-list',
132
+ match: '/content',
133
+ fetch: async () => ({}),
134
+ meta: () => ({ title: 'list', description: '' }),
135
+ })
136
+ const exact = registry.match('/content')
137
+ const prefix = registry.match('/content/article-1')
138
+ expect(exact!.module).toBe('content-list')
139
+ expect(prefix!.module).toBe('content-detail')
140
+ })
141
+ })
142
+
143
+ describe('match — function matchers', () => {
144
+ it('should match via function', () => {
145
+ registry.register({
146
+ module: 'custom',
147
+ match: pathname => pathname.startsWith('/shop/'),
148
+ fetch: async () => ({}),
149
+ meta: () => ({ title: '', description: '' }),
150
+ })
151
+ expect(registry.match('/shop/product-1')).not.toBeNull()
152
+ expect(registry.match('/shop/')).not.toBeNull()
153
+ expect(registry.match('/other')).toBeNull()
154
+ })
155
+ })
156
+
157
+ describe('isISRRoute', () => {
158
+ it('should return true for registered routes', () => {
159
+ registry.register({
160
+ module: 'todos',
161
+ match: '/todos',
162
+ fetch: async () => ({}),
163
+ meta: () => ({ title: '', description: '' }),
164
+ })
165
+ expect(registry.isISRRoute('/todos')).toBe(true)
166
+ })
167
+
168
+ it('should return false for unregistered routes', () => {
169
+ expect(registry.isISRRoute('/unknown')).toBe(false)
170
+ })
171
+ })
172
+
173
+ describe('getExactPaths', () => {
174
+ it('should return all exact path strings', () => {
175
+ registry.registerMany([
176
+ {
177
+ module: 'a',
178
+ match: '/a',
179
+ fetch: async () => ({}),
180
+ meta: () => ({ title: '', description: '' }),
181
+ },
182
+ {
183
+ module: 'b',
184
+ match: '/b/',
185
+ fetch: async () => ({}),
186
+ meta: () => ({ title: '', description: '' }),
187
+ },
188
+ {
189
+ module: 'c',
190
+ match: '/c',
191
+ fetch: async () => ({}),
192
+ meta: () => ({ title: '', description: '' }),
193
+ },
194
+ ])
195
+ const paths = registry.getExactPaths()
196
+ expect(paths).toContain('/a')
197
+ expect(paths).toContain('/b/')
198
+ expect(paths).toContain('/c')
199
+ expect(paths).toHaveLength(3)
200
+ })
201
+ })
202
+
203
+ describe('clear', () => {
204
+ it('should remove all entries', () => {
205
+ registry.register({
206
+ module: 'a',
207
+ match: '/a',
208
+ fetch: async () => ({}),
209
+ meta: () => ({ title: '', description: '' }),
210
+ })
211
+ registry.clear()
212
+ expect(registry.getAll()).toHaveLength(0)
213
+ })
214
+ })
215
+ })
@@ -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) {