@claudelaw/taichu 0.6.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 (93) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/docker-compose.yml +42 -0
  6. package/docs/ROADMAP.md +101 -0
  7. package/docs/api/README.md +102 -0
  8. package/docs/architecture/001-zero-dependency-core.md +61 -0
  9. package/docs/architecture/002-structured-content-model.md +70 -0
  10. package/docs/architecture/003-hook-based-extension.md +82 -0
  11. package/docs/architecture/004-api-first-architecture.md +122 -0
  12. package/docs/architecture/README.md +24 -0
  13. package/docs/logo.svg +40 -0
  14. package/docs/research/ai-era-cms-user-research.md +247 -0
  15. package/docs/zh/README.md +81 -0
  16. package/docs/zh/guides/deploy.md +75 -0
  17. package/docs/zh/guides/mcp.md +84 -0
  18. package/docs/zh/guides/promotion.md +51 -0
  19. package/marketplace.json +78 -0
  20. package/package.json +60 -0
  21. package/packages/core/src/auth.js +158 -0
  22. package/packages/core/src/content-type.js +244 -0
  23. package/packages/core/src/core.test.js +406 -0
  24. package/packages/core/src/errors.js +60 -0
  25. package/packages/core/src/hooks.js +104 -0
  26. package/packages/core/src/index.js +16 -0
  27. package/packages/core/src/server.test.js +149 -0
  28. package/packages/core/src/sm-crypto.js +31 -0
  29. package/packages/core/src/sqlite-store.js +354 -0
  30. package/packages/core/src/store.js +174 -0
  31. package/packages/core/src/tokenizer.js +89 -0
  32. package/packages/core/src/vector-index.js +131 -0
  33. package/packages/llm-providers/src/index.js +181 -0
  34. package/packages/mcp/src/index.js +355 -0
  35. package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
  36. package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
  37. package/packages/server/public/admin/index.html +28 -0
  38. package/packages/server/public/aurora/style.css +1173 -0
  39. package/packages/server/public/favicon.svg +46 -0
  40. package/packages/server/public/theme/index.html +288 -0
  41. package/packages/server/public/theme/style.css +133 -0
  42. package/packages/server/public/theme-minimal/index.html +223 -0
  43. package/packages/server/public/theme-minimal/style.css +109 -0
  44. package/packages/server/public/ws-test.html +106 -0
  45. package/packages/server/src/activitypub.js +228 -0
  46. package/packages/server/src/audit.js +104 -0
  47. package/packages/server/src/auth-provider.js +76 -0
  48. package/packages/server/src/body-parser.js +52 -0
  49. package/packages/server/src/bootstrap.js +272 -0
  50. package/packages/server/src/collab.js +154 -0
  51. package/packages/server/src/config.js +136 -0
  52. package/packages/server/src/context.js +86 -0
  53. package/packages/server/src/email.js +317 -0
  54. package/packages/server/src/index.js +195 -0
  55. package/packages/server/src/logger.js +78 -0
  56. package/packages/server/src/media-store.js +213 -0
  57. package/packages/server/src/middleware/auth.js +203 -0
  58. package/packages/server/src/middleware/cors.js +15 -0
  59. package/packages/server/src/middleware/error-handler.js +49 -0
  60. package/packages/server/src/middleware/rate-limit.js +118 -0
  61. package/packages/server/src/multipart.js +150 -0
  62. package/packages/server/src/notify.js +126 -0
  63. package/packages/server/src/pipeline.js +206 -0
  64. package/packages/server/src/plugin-installer.js +139 -0
  65. package/packages/server/src/plugin-manager.js +165 -0
  66. package/packages/server/src/relationships.js +217 -0
  67. package/packages/server/src/revisions.js +114 -0
  68. package/packages/server/src/router.js +194 -0
  69. package/packages/server/src/routes/activitypub.js +140 -0
  70. package/packages/server/src/routes/api.js +363 -0
  71. package/packages/server/src/routes/audit.js +222 -0
  72. package/packages/server/src/routes/auth.js +205 -0
  73. package/packages/server/src/routes/collab.js +90 -0
  74. package/packages/server/src/routes/export.js +77 -0
  75. package/packages/server/src/routes/graphql.js +344 -0
  76. package/packages/server/src/routes/media.js +169 -0
  77. package/packages/server/src/routes/plugin-marketplace.js +171 -0
  78. package/packages/server/src/routes/relationships.js +133 -0
  79. package/packages/server/src/routes/rss.js +92 -0
  80. package/packages/server/src/routes/sso.js +211 -0
  81. package/packages/server/src/routes/theme.js +119 -0
  82. package/packages/server/src/routes/webhook.js +94 -0
  83. package/packages/server/src/routes/wechat.js +115 -0
  84. package/packages/server/src/routes/workflow.js +157 -0
  85. package/packages/server/src/scheduler.js +96 -0
  86. package/packages/server/src/search.js +100 -0
  87. package/packages/server/src/server.test.js +295 -0
  88. package/packages/server/src/sso-analytics.js +78 -0
  89. package/packages/server/src/static.js +70 -0
  90. package/packages/server/src/theme-engine.js +119 -0
  91. package/packages/server/src/webhook.js +192 -0
  92. package/packages/server/src/websocket.js +308 -0
  93. package/scripts/cli.js +90 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Taichu Server Integration Tests
3
+ *
4
+ * Tests the full HTTP server stack: startup, REST API, auth, middleware.
5
+ * Uses Node.js built-in test runner + http module (zero dependencies).
6
+ */
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import http from 'node:http';
10
+ import { spawn } from 'node:child_process';
11
+ import { join } from 'node:path';
12
+
13
+ // ════════════════════════════════════════════════════════════
14
+ // Helpers
15
+ // ════════════════════════════════════════════════════════════
16
+
17
+ const BASE = 'http://localhost:3121';
18
+
19
+ function request(method, path, opts = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const url = new URL(path, BASE);
22
+ const options = {
23
+ method,
24
+ hostname: url.hostname,
25
+ port: url.port,
26
+ path: url.pathname + url.search,
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ ...opts.headers,
30
+ },
31
+ };
32
+
33
+ const req = http.request(options, (res) => {
34
+ let body = '';
35
+ res.on('data', (chunk) => (body += chunk));
36
+ res.on('end', () => {
37
+ try {
38
+ resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body || '{}') });
39
+ } catch {
40
+ resolve({ status: res.statusCode, headers: res.headers, body });
41
+ }
42
+ });
43
+ });
44
+ req.on('error', reject);
45
+ if (opts.body) req.write(JSON.stringify(opts.body));
46
+ req.end();
47
+ });
48
+ }
49
+
50
+ function pollUntilReady(maxRetries = 30) {
51
+ return new Promise((resolve, reject) => {
52
+ let attempts = 0;
53
+ const check = () => {
54
+ attempts++;
55
+ http.get(`${BASE}/api/health`, (res) => {
56
+ if (res.statusCode === 200) return resolve(true);
57
+ if (attempts >= maxRetries) return reject(new Error('Server did not become ready'));
58
+ setTimeout(check, 200);
59
+ }).on('error', () => {
60
+ if (attempts >= maxRetries) return reject(new Error('Server did not start'));
61
+ setTimeout(check, 200);
62
+ });
63
+ };
64
+ check();
65
+ });
66
+ }
67
+
68
+ // ════════════════════════════════════════════════════════════
69
+ // Server Lifecycle
70
+ // ════════════════════════════════════════════════════════════
71
+
72
+ let serverProcess;
73
+ let authToken;
74
+ let apiKey;
75
+
76
+ before(async () => {
77
+ // Start server on alternate port for testing
78
+ const serverPath = join(import.meta.dirname, '..', '..', 'server', 'src', 'index.js');
79
+ serverProcess = spawn('node', [serverPath], {
80
+ env: {
81
+ ...process.env,
82
+ TAICHU_PORT: '3121',
83
+ TAICHU_STORAGE: 'memory',
84
+ TAICHU_JWT_SECRET: 'test-secret-key-for-integration-tests',
85
+ TAICHU_PUBLIC_READ: '0',
86
+ NODE_ENV: 'test',
87
+ },
88
+ stdio: 'pipe',
89
+ });
90
+
91
+ await pollUntilReady();
92
+ });
93
+
94
+ after(() => {
95
+ if (serverProcess) {
96
+ serverProcess.kill('SIGTERM');
97
+ }
98
+ });
99
+
100
+ // ════════════════════════════════════════════════════════════
101
+ // Health & System
102
+ // ════════════════════════════════════════════════════════════
103
+
104
+ describe('Health & System', () => {
105
+ it('GET /api/health returns 200', async () => {
106
+ const res = await request('GET', '/api/health');
107
+ assert.equal(res.status, 200);
108
+ });
109
+
110
+ it('GET /api/health returns status ok', async () => {
111
+ const res = await request('GET', '/api/health');
112
+ assert.equal(res.body.status, 'ok');
113
+ });
114
+
115
+ it('GET / returns frontend HTML', async () => {
116
+ const res = await request('GET', '/');
117
+ assert.equal(res.status, 200);
118
+ assert.ok(typeof res.body === 'string');
119
+ });
120
+ });
121
+
122
+ // ════════════════════════════════════════════════════════════
123
+ // Authentication
124
+ // ════════════════════════════════════════════════════════════
125
+
126
+ describe('Authentication', () => {
127
+ it('POST /api/auth/login with wrong credentials returns 401', async () => {
128
+ const res = await request('POST', '/api/auth/login', {
129
+ body: { username: 'admin', password: 'wrongpassword' },
130
+ });
131
+ assert.equal(res.status, 401);
132
+ });
133
+
134
+ it('POST /api/auth/register creates a user and returns token', async () => {
135
+ const res = await request('POST', '/api/auth/register', {
136
+ body: { username: 'testuser', password: 'TestPass123!', email: 'test@taichu.dev' },
137
+ });
138
+ assert.equal(res.status, 200);
139
+ assert.ok(res.body.token);
140
+ authToken = res.body.token;
141
+ });
142
+
143
+ it('GET /api/auth/me with valid token returns user info', async () => {
144
+ const res = await request('GET', '/api/auth/me', {
145
+ headers: { Authorization: `Bearer ${authToken}` },
146
+ });
147
+ assert.equal(res.status, 200);
148
+ assert.ok(res.body.user || res.body.username);
149
+ });
150
+
151
+ it('POST /api/auth/apikeys creates an API key', async () => {
152
+ const res = await request('POST', '/api/auth/apikeys', {
153
+ headers: { Authorization: `Bearer ${authToken}` },
154
+ body: { name: 'Test Agent Key', scopes: ['read', 'write'] },
155
+ });
156
+ assert.equal(res.status, 200);
157
+ assert.ok(res.body.key);
158
+ apiKey = res.body.key;
159
+ });
160
+
161
+ it('GET /api/auth/apikeys lists user API keys', async () => {
162
+ const res = await request('GET', '/api/auth/apikeys', {
163
+ headers: { Authorization: `Bearer ${authToken}` },
164
+ });
165
+ assert.equal(res.status, 200);
166
+ });
167
+ });
168
+
169
+ // ════════════════════════════════════════════════════════════
170
+ // Content CRUD (REST API)
171
+ // ════════════════════════════════════════════════════════════
172
+
173
+ describe('Content CRUD', () => {
174
+ let articleId;
175
+
176
+ it('GET /api/content/article returns empty list initially', async () => {
177
+ const res = await request('GET', '/api/content/article', {
178
+ headers: { Authorization: `Bearer ${authToken}` },
179
+ });
180
+ assert.equal(res.status, 200);
181
+ assert.ok(Array.isArray(res.body.items || res.body));
182
+ });
183
+
184
+ it('POST /api/content/article creates an article', async () => {
185
+ const res = await request('POST', '/api/content/article', {
186
+ headers: { Authorization: `Bearer ${authToken}` },
187
+ body: {
188
+ title: 'Hello Taichu',
189
+ slug: 'hello-taichu',
190
+ body: 'This is a test article created by integration tests.',
191
+ status: 'draft',
192
+ },
193
+ });
194
+ assert.equal(res.status, 201);
195
+ assert.ok(res.body.id || res.body._id);
196
+ articleId = res.body.id || res.body._id;
197
+ });
198
+
199
+ it('GET /api/content/article/:id returns the article', async () => {
200
+ const res = await request('GET', `/api/content/article/${articleId}`, {
201
+ headers: { Authorization: `Bearer ${authToken}` },
202
+ });
203
+ assert.equal(res.status, 200);
204
+ });
205
+
206
+ it('PATCH /api/content/article/:id updates the article', async () => {
207
+ const res = await request('PATCH', `/api/content/article/${articleId}`, {
208
+ headers: { Authorization: `Bearer ${authToken}` },
209
+ body: { title: 'Hello Taichu (Updated)', status: 'published' },
210
+ });
211
+ assert.equal(res.status, 200);
212
+ });
213
+
214
+ it('DELETE /api/content/article/:id deletes the article', async () => {
215
+ const res = await request('DELETE', `/api/content/article/${articleId}`, {
216
+ headers: { Authorization: `Bearer ${authToken}` },
217
+ });
218
+ assert.ok(res.status === 200 || res.status === 204);
219
+ });
220
+
221
+ it('GET deleted article returns 404', async () => {
222
+ const res = await request('GET', `/api/content/article/${articleId}`, {
223
+ headers: { Authorization: `Bearer ${authToken}` },
224
+ });
225
+ assert.equal(res.status, 404);
226
+ });
227
+ });
228
+
229
+ // ════════════════════════════════════════════════════════════
230
+ // Content Types
231
+ // ════════════════════════════════════════════════════════════
232
+
233
+ describe('Content Types', () => {
234
+ it('GET /api/content-types lists all types', async () => {
235
+ const res = await request('GET', '/api/content-types', {
236
+ headers: { Authorization: `Bearer ${authToken}` },
237
+ });
238
+ assert.equal(res.status, 200);
239
+ });
240
+ });
241
+
242
+ // ════════════════════════════════════════════════════════════
243
+ // Auth Enforcement
244
+ // ════════════════════════════════════════════════════════════
245
+
246
+ describe('Auth Enforcement', () => {
247
+ it('GET /api/content/article without token returns 401', async () => {
248
+ const res = await request('GET', '/api/content/article');
249
+ assert.equal(res.status, 401);
250
+ });
251
+
252
+ it('GET /api/content/article with invalid token returns 401', async () => {
253
+ const res = await request('GET', '/api/content/article', {
254
+ headers: { Authorization: 'Bearer invalid-token-here' },
255
+ });
256
+ assert.equal(res.status, 401);
257
+ });
258
+ });
259
+
260
+ // ════════════════════════════════════════════════════════════
261
+ // CORS & Error Handling
262
+ // ════════════════════════════════════════════════════════════
263
+
264
+ describe('CORS & Errors', () => {
265
+ it('OPTIONS request returns CORS headers', async () => {
266
+ // Use http.request directly to check headers without body parsing
267
+ const res = await new Promise((resolve, reject) => {
268
+ const req = http.request(`${BASE}/api/health`, { method: 'OPTIONS' }, (res) => {
269
+ resolve({ status: res.statusCode, headers: res.headers });
270
+ });
271
+ req.on('error', reject);
272
+ req.end();
273
+ });
274
+ assert.ok(res.status >= 200 && res.status < 300);
275
+ });
276
+
277
+ it('GET /api/nonexistent returns 404', async () => {
278
+ const res = await request('GET', '/api/nonexistent', {
279
+ headers: { Authorization: `Bearer ${authToken}` },
280
+ });
281
+ assert.equal(res.status, 404);
282
+ });
283
+
284
+ it('POST /api/content/article with empty body returns 400', async () => {
285
+ const res = await request('POST', '/api/content/article', {
286
+ headers: {
287
+ Authorization: `Bearer ${authToken}`,
288
+ 'Content-Type': 'application/json',
289
+ },
290
+ body: {},
291
+ });
292
+ // May return 400 or 201 depending on validation
293
+ assert.ok(res.status >= 200 && res.status < 500);
294
+ });
295
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * SSO + Analytics — 企业认证 & 统计集成(P1-10, P1-04)
3
+ *
4
+ * SSO: LDAP / OIDC / SAML 协议适配
5
+ * Analytics: 百度统计 / Google Analytics / Umami
6
+ */
7
+
8
+ import { createLogger } from './logger.js';
9
+
10
+ const log = createLogger('sso');
11
+
12
+ // ── SSO Providers ──────────────────────────────────────────
13
+
14
+ /**
15
+ * Simple OIDC SSO integration.
16
+ * Requires `openid-client` npm package for production use.
17
+ * This is a stub that documents the integration pattern.
18
+ *
19
+ * Flow:
20
+ * 1. GET /api/sso/:provider → redirect to IdP login
21
+ * 2. GET /api/sso/:provider/callback → exchange code for tokens, create/link user
22
+ */
23
+ export async function handleOIDC(provider, config) {
24
+ return {
25
+ provider,
26
+ authorizeUrl: config.authorizeUrl,
27
+ enabled: !!config.clientId
28
+ };
29
+ }
30
+
31
+ export function getSSOProviders() {
32
+ const providers = [];
33
+ if (process.env.TAICHU_SSO_OIDC_CLIENT_ID) {
34
+ providers.push({
35
+ name: 'oidc',
36
+ label: 'OIDC / OAuth 2.0',
37
+ enabled: true,
38
+ loginUrl: '/api/sso/oidc'
39
+ });
40
+ }
41
+ if (process.env.TAICHU_SSO_LDAP_URL) {
42
+ providers.push({
43
+ name: 'ldap',
44
+ label: 'LDAP / Active Directory',
45
+ enabled: true
46
+ });
47
+ }
48
+ return providers;
49
+ }
50
+
51
+ // ── Analytics Provider ─────────────────────────────────────
52
+
53
+ const ANALYTICS = {
54
+ baidu: {
55
+ name: 'baidu',
56
+ label: '百度统计',
57
+ script: (id) => `<script>var _hmt=_hmt||[];(function(){var hm=document.createElement("script");hm.src="https://hm.baidu.com/hm.js?${id}";var s=document.getElementsByTagName("script")[0];s.parentNode.insertBefore(hm,s)})();</script>`
58
+ },
59
+ google: {
60
+ name: 'google',
61
+ label: 'Google Analytics',
62
+ script: (id) => `<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${id}')</script>`
63
+ },
64
+ umami: {
65
+ name: 'umami',
66
+ label: 'Umami',
67
+ script: (id, websiteId) => `<script async defer data-website-id="${websiteId || id}" src="${id}"></script>`
68
+ }
69
+ };
70
+
71
+ export function getAnalyticsScript(provider, id) {
72
+ const p = ANALYTICS[provider];
73
+ return p ? p.script(id, process.env.TAICHU_ANALYTICS_WEBSITE_ID) : '';
74
+ }
75
+
76
+ export function getAnalyticsProviders() {
77
+ return Object.values(ANALYTICS).map(p => ({ name: p.name, label: p.label }));
78
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Static file server — 提供管理后台和公共前端静态文件
3
+ */
4
+
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { join, extname } from 'node:path';
8
+
9
+ const MIME_TYPES = {
10
+ '.html': 'text/html; charset=utf-8',
11
+ '.css': 'text/css; charset=utf-8',
12
+ '.js': 'application/javascript; charset=utf-8',
13
+ '.json': 'application/json; charset=utf-8',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.svg': 'image/svg+xml',
19
+ '.ico': 'image/x-icon',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2'
22
+ };
23
+
24
+ export async function serveStatic(ctx, publicDir, urlPath) {
25
+ try {
26
+ // Normalize path: /admin => /admin/index.html
27
+ let filePath = urlPath;
28
+ if (filePath === '/' || filePath.endsWith('/')) {
29
+ filePath += 'index.html';
30
+ }
31
+
32
+ const fullPath = join(publicDir, filePath);
33
+
34
+ // Security: prevent directory traversal
35
+ if (!fullPath.startsWith(publicDir)) {
36
+ ctx.res.writeHead(403);
37
+ ctx.res.end('Forbidden');
38
+ return true;
39
+ }
40
+
41
+ if (!existsSync(fullPath)) {
42
+ return false;
43
+ }
44
+
45
+ const ext = extname(fullPath).toLowerCase();
46
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
47
+
48
+ const content = await readFile(fullPath);
49
+
50
+ const headers = { 'Content-Type': contentType };
51
+
52
+ // Cache control: long cache for hashed assets, short for HTML
53
+ if (ext === '.html') {
54
+ headers['Cache-Control'] = 'no-cache';
55
+ } else if (ext === '.js' || ext === '.css' || ext === '.woff' || ext === '.woff2') {
56
+ headers['Cache-Control'] = 'public, max-age=31536000, immutable';
57
+ } else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'].includes(ext)) {
58
+ headers['Cache-Control'] = 'public, max-age=86400';
59
+ }
60
+
61
+ ctx.res.writeHead(200, headers);
62
+ ctx.res.end(content);
63
+ return true;
64
+ } catch (err) {
65
+ if (err.code === 'ENOENT') return false;
66
+ ctx.res.writeHead(500);
67
+ ctx.res.end('Internal Server Error');
68
+ return true;
69
+ }
70
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Theme Engine — 前端主题渲染
3
+ *
4
+ * Taichu 的 Headless CMS → 前端主题桥接层。
5
+ *
6
+ * 架构:
7
+ * / → 渲染默认主题(index.html with injected config)
8
+ * /post/slug → 渲染文章详情
9
+ * /page/slug → 渲染页面
10
+ * /category/slug → 渲染分类列表
11
+ * /api/* → 透传 CMS API(同源,无 CORS 问题)
12
+ *
13
+ * 主题文件:
14
+ * 默认主题:packages/server/public/theme/index.html
15
+ * 自定义主题:.taichu/themes/{theme-name}/index.html
16
+ *
17
+ * 主题配置:
18
+ * GET /api/site-settings → { theme: { primaryColor, fontFamily, ... } }
19
+ * 主题 HTML 通过内嵌 <script>window.__TAICHU__ = {...}</script> 获取配置
20
+ */
21
+
22
+ import { readFileSync, existsSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { getStore } from './context.js';
25
+
26
+ const THEME_DIR = join(process.cwd(), '.taichu', 'themes');
27
+ const DEFAULT_THEME = join(import.meta.dirname || join(process.cwd(), 'packages', 'server', 'src'), '..', 'public', 'theme', 'index.html');
28
+
29
+ /**
30
+ * Render the frontend theme for a given path.
31
+ * @param {import('./context.js').Context} ctx
32
+ */
33
+ export async function renderTheme(ctx) {
34
+ const { pathname } = ctx.url;
35
+
36
+ // Get site config
37
+ let siteConfig = {};
38
+ try {
39
+ const store = getStore();
40
+ const docs = await store.list({ type: 'site_settings', limit: 1 });
41
+ if (docs[0]) siteConfig = docs[0].data;
42
+ } catch {}
43
+
44
+ // Determine which theme to use
45
+ const themeName = siteConfig.theme?.activeTheme || 'default';
46
+ const themeFile = themeName === 'default'
47
+ ? DEFAULT_THEME
48
+ : join(THEME_DIR, themeName, 'index.html');
49
+
50
+ // Config to inject into the theme
51
+ const config = {
52
+ apiBase: '/api',
53
+ site: {
54
+ name: siteConfig.siteName || 'Taichu CMS',
55
+ description: siteConfig.siteDescription || '',
56
+ icp: siteConfig.icpNumber || '',
57
+ gongan: siteConfig.gonganNumber || '',
58
+ analytics: siteConfig.analyticsId || '',
59
+ language: siteConfig.language || 'zh-CN',
60
+ timezone: siteConfig.timezone || 'Asia/Shanghai'
61
+ },
62
+ theme: siteConfig.theme || {},
63
+ seo: {
64
+ title: siteConfig.seoTitle || siteConfig.siteName || '',
65
+ description: siteConfig.seoDescription || siteConfig.siteDescription || '',
66
+ keywords: siteConfig.seoKeywords || []
67
+ }
68
+ };
69
+
70
+ try {
71
+ let html = readFileSync(themeFile, 'utf-8');
72
+
73
+ // Inject config before </head>
74
+ const configScript = `<script>window.__TAICHU__ = ${JSON.stringify(config)};</script>`;
75
+ html = html.replace('</head>', `${configScript}\n</head>`);
76
+
77
+ ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
78
+ ctx.res.end(html);
79
+ } catch (err) {
80
+ // Theme not found → fallback to default
81
+ if (themeName !== 'default') {
82
+ try {
83
+ let html = readFileSync(DEFAULT_THEME, 'utf-8');
84
+ const configScript = `<script>window.__TAICHU__ = ${JSON.stringify(config)};</script>`;
85
+ html = html.replace('</head>', `${configScript}\n</head>`);
86
+ ctx.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
87
+ ctx.res.end(html);
88
+ return;
89
+ } catch {}
90
+ }
91
+ ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
92
+ ctx.res.end(JSON.stringify({ error: 'Theme not found' }));
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Serve a static theme asset (CSS, JS, images).
98
+ */
99
+ export function serveThemeAsset(ctx, filePath) {
100
+ const themeName = 'default'; // Can be extended for custom themes
101
+ const base = themeName === 'default'
102
+ ? join(import.meta.dirname || join(process.cwd(), 'packages', 'server', 'src'), '..', 'public', 'theme')
103
+ : join(THEME_DIR, themeName);
104
+
105
+ const fullPath = join(base, filePath);
106
+ try {
107
+ const content = readFileSync(fullPath);
108
+ const ext = filePath.split('.').pop();
109
+ const mime = {
110
+ css: 'text/css', js: 'application/javascript', png: 'image/png',
111
+ jpg: 'image/jpeg', svg: 'image/svg+xml', woff2: 'font/woff2', json: 'application/json'
112
+ }[ext] || 'application/octet-stream';
113
+ ctx.res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=3600' });
114
+ ctx.res.end(content);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }