@code2rich/jpage 1.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 (143) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +56 -0
  4. package/.github/workflows/ci.yml +43 -0
  5. package/CLAUDE.md +280 -0
  6. package/Dockerfile +44 -0
  7. package/LICENSE +21 -0
  8. package/README.md +433 -0
  9. package/README_EN.md +399 -0
  10. package/bin/args.js +64 -0
  11. package/bin/client.js +93 -0
  12. package/bin/commands/_shared.js +54 -0
  13. package/bin/commands/cat.js +23 -0
  14. package/bin/commands/ls.js +44 -0
  15. package/bin/commands/mv.js +20 -0
  16. package/bin/commands/rm.js +22 -0
  17. package/bin/commands/skills.js +70 -0
  18. package/bin/commands/star.js +23 -0
  19. package/bin/commands/tags.js +97 -0
  20. package/bin/commands/upload.js +84 -0
  21. package/bin/commands/url.js +25 -0
  22. package/bin/commands/whoami.js +29 -0
  23. package/bin/config.js +85 -0
  24. package/bin/jpage.js +168 -0
  25. package/build.js +112 -0
  26. package/docker-compose.yml +26 -0
  27. package/docs/api.md +438 -0
  28. package/docs/design/005-custom-modal.md +296 -0
  29. package/docs/design/013-file-version-history.md +324 -0
  30. package/docs/design/billing-system.md +600 -0
  31. package/docs/design/db-index-and-healthcheck.md +176 -0
  32. package/docs/design/loading-states.md +209 -0
  33. package/docs/virtual-hosting-feasibility.md +453 -0
  34. package/eslint.config.mjs +172 -0
  35. package/lib/auth-state.js +15 -0
  36. package/lib/categories.js +20 -0
  37. package/lib/crypto.js +85 -0
  38. package/lib/csp.js +66 -0
  39. package/lib/db.js +53 -0
  40. package/lib/dispatch.js +103 -0
  41. package/lib/fts.js +81 -0
  42. package/lib/middleware/auth.js +114 -0
  43. package/lib/middleware/files.js +42 -0
  44. package/lib/paths.js +9 -0
  45. package/lib/render-cache.js +48 -0
  46. package/lib/render.js +157 -0
  47. package/lib/templates.js +149 -0
  48. package/lib/util.js +66 -0
  49. package/lib/view-counts.js +59 -0
  50. package/lib/zip.js +192 -0
  51. package/logger.js +16 -0
  52. package/mailer.js +34 -0
  53. package/mcp/constants.js +16 -0
  54. package/mcp/resources.js +74 -0
  55. package/mcp/server.js +43 -0
  56. package/mcp/tools-categories.js +56 -0
  57. package/mcp/tools-content-templates.js +59 -0
  58. package/mcp/tools-files.js +245 -0
  59. package/mcp/tools-tags.js +41 -0
  60. package/mcp/tools-versions.js +57 -0
  61. package/mcp/transport.js +183 -0
  62. package/mcp/util.js +63 -0
  63. package/mcp-server.js +20 -0
  64. package/migrations/001_init_schema.js +25 -0
  65. package/migrations/002_add_share_key.js +33 -0
  66. package/migrations/003_add_roles_and_tokens.js +28 -0
  67. package/migrations/004_add_version_history.js +32 -0
  68. package/migrations/005_tags_starred_categories.js +49 -0
  69. package/migrations/006_zip_bundle.js +17 -0
  70. package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
  71. package/migrations/008_add_fts5.js +6 -0
  72. package/migrations/009_add_link_visits.js +20 -0
  73. package/migrations/010_add_templates_system.js +34 -0
  74. package/migrations/011_content_templates.js +233 -0
  75. package/migrations/012_add_email_and_verification.js +35 -0
  76. package/migrations/013_add_token_encrypted.js +14 -0
  77. package/migrations.js +65 -0
  78. package/package.json +63 -0
  79. package/public/css/style.css +2915 -0
  80. package/public/index.html +855 -0
  81. package/public/js/api.js +22 -0
  82. package/public/js/app.js +94 -0
  83. package/public/js/components/dialog.js +106 -0
  84. package/public/js/components/toast.js +13 -0
  85. package/public/js/pages/content-templates.js +330 -0
  86. package/public/js/pages/home.js +1903 -0
  87. package/public/js/pages/landing.js +158 -0
  88. package/public/js/pages/login.js +175 -0
  89. package/public/js/pages/preview.js +713 -0
  90. package/public/js/theme.js +44 -0
  91. package/public/js/utils.js +67 -0
  92. package/routes/admin.js +136 -0
  93. package/routes/auth.js +365 -0
  94. package/routes/categories.js +90 -0
  95. package/routes/content-templates.js +215 -0
  96. package/routes/files/_shared.js +112 -0
  97. package/routes/files/associations.js +94 -0
  98. package/routes/files/crud.js +139 -0
  99. package/routes/files/detail-serve.js +178 -0
  100. package/routes/files/index.js +38 -0
  101. package/routes/files/list.js +200 -0
  102. package/routes/files/overwrite.js +114 -0
  103. package/routes/files/upload.js +204 -0
  104. package/routes/files/versions.js +166 -0
  105. package/routes/files.js +16 -0
  106. package/routes/skills.js +93 -0
  107. package/routes/tags.js +65 -0
  108. package/routes/tokens.js +110 -0
  109. package/routes/users.js +120 -0
  110. package/server.js +372 -0
  111. package/skills/jpage-content-template/SKILL.md +98 -0
  112. package/skills/jpage-upload/SKILL.md +247 -0
  113. package/skills-registry.js +135 -0
  114. package/templates/academic.html +41 -0
  115. package/templates/dark-pro.html +41 -0
  116. package/templates/default.html +56 -0
  117. package/templates/github.html +67 -0
  118. package/test/browser-harness.js +125 -0
  119. package/test/dispatch-bench.js +74 -0
  120. package/test/helpers/setup.js +45 -0
  121. package/test/integration/admin.test.js +108 -0
  122. package/test/integration/auth.test.js +93 -0
  123. package/test/integration/categories.test.js +103 -0
  124. package/test/integration/cli.test.js +310 -0
  125. package/test/integration/content-templates.test.js +147 -0
  126. package/test/integration/files-security.test.js +248 -0
  127. package/test/integration/files.test.js +139 -0
  128. package/test/integration/share.test.js +79 -0
  129. package/test/integration/skills.test.js +104 -0
  130. package/test/integration/tags.test.js +84 -0
  131. package/test/integration/tokens.test.js +89 -0
  132. package/test/integration/users.test.js +138 -0
  133. package/test/mcp-harness.js +152 -0
  134. package/test/perf-bench.js +108 -0
  135. package/test/perf-harness.js +198 -0
  136. package/test/run-server.sh +15 -0
  137. package/test/unit/cli-args.test.js +88 -0
  138. package/test/unit/cli-config.test.js +89 -0
  139. package/test/unit/crypto.test.js +100 -0
  140. package/test/unit/fts.test.js +52 -0
  141. package/test/unit/render-cache.test.js +76 -0
  142. package/test/unit/util.test.js +81 -0
  143. package/test/unit/zip.test.js +164 -0
@@ -0,0 +1,247 @@
1
+ ---
2
+ name: jpage-upload
3
+ description: 当用户要"上传到即页"、"生成预览链接"、"查看已上传文件",或要求生成 HTML/Markdown 页面、报告、可视化、笔记等内容时,生成后自动上传到即页并返回预览链接。
4
+ ---
5
+
6
+ # 核心规则
7
+
8
+ 凡是用户要求生成 HTML 或 Markdown 内容(页面、报告、笔记、简历、可视化、文档等),**一律生成完整内容后调用 `upload_file` 上传到即页**,返回预览 URL。不要只输出代码块让用户自己复制。
9
+
10
+ > **性能提示**:上传**本地已有的大文件或 ZIP**时,不要走 `upload_file`——它会把整包内容 base64 编码后作为 tool 参数流经模型 token 流(膨胀 33% + 逐字生成输出 token),几十 MB 的包要等几分钟且极费 token。改用 **curl 打 multipart 端点直连 REST**,二进制流式上传,见下文「⚡ 上传性能」一节。模型刚生成的 HTML/MD(**无论大小**)仍直接用 `upload_file`——大 HTML 慢在「生成」不在「上传」。
11
+
12
+ # 触发场景
13
+
14
+ 以下场景均应生成内容并上传到即页:
15
+
16
+ - 用户明确说"上传到即页"、"发到即页"、"生成链接"
17
+ - 用户要求生成 HTML 页面、网页、落地页、仪表板、报告
18
+ - 用户要求生成 Markdown 笔记、文档、README
19
+ - 用户要求抓取网页并生成 HTML/Markdown 保存
20
+ - 用户要求创建简历、名片、个人主页、作品展示页
21
+ - 用户要求生成数据可视化、图表页面、SVG 画布
22
+ - 用户要求将代码片段转为可预览的 HTML 展示页
23
+ - 用户要求创建测试页面、Demo 页、原型页
24
+ - 用户要求生成邮件模板、通知模板
25
+ - 用户要求生成任何形式的可在线预览的文档
26
+
27
+ # 内容生成规范
28
+
29
+ ## HTML 文件
30
+
31
+ 生成的 HTML 必须**完全自包含**(单个文件,无外部依赖),以确保在即页预览中正确渲染:
32
+
33
+ - 必须包含 `<!DOCTYPE html>`、`<html>`、`<head>`、`<body>` 完整结构
34
+ - 必须包含 `<meta charset="UTF-8">` 和 `<meta name="viewport" content="width=device-width, initial-scale=1.0">`
35
+ - CSS 使用 `<style>` 内联,不引用外部样式表(Tailwind CDN 等公共 CDN 可用)
36
+ - JS 使用 `<script>` 内联,不引用外部脚本(公共 CDN 库如 Chart.js、D3 等可用)
37
+ - 图片使用 data URI 或在线图片 URL,不依赖本地文件
38
+ - 中文字体使用系统字体栈:`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif`
39
+
40
+ ## Markdown 文件
41
+
42
+ 即页的 Markdown 渲染引擎支持以下增强特性,可放心使用:
43
+
44
+ - **代码高亮**:所有语言的代码块均自动高亮(highlight.js)
45
+ - **数学公式**:行内 `$...$`,块级 `$$...$$`(KaTeX)
46
+ - **Mermaid 图表**:` ```mermaid ` 代码块自动渲染为流程图/时序图等
47
+ - **GFM 扩展**:表格、任务列表、删除线、自动链接
48
+
49
+ # ⚡ 上传性能(大文件 / ZIP 必看)
50
+
51
+ `upload_file` 的 `content` 是字符串参数,ZIP 时是整包 base64。把大段 base64 当 tool 参数传会流经模型 token 流,**极慢且昂贵**。按内容来源选上传方式:
52
+
53
+ ## 本地已有文件(尤其 ZIP 或 >1MB)→ curl multipart,别用 upload_file
54
+
55
+ 有 Bash 能力(Claude Code)时,直接打 REST multipart 端点,二进制流式上传,base64 完全不进模型。两种等价方式,**优先用 `jpage` CLI**(封装好了 token/base 解析与输出格式),手动 `curl` 是底层兜底:
56
+
57
+ ### 方式一:`jpage` CLI(推荐,需先 `npm i -g @code2rich/jpage`)
58
+
59
+ ```bash
60
+ # token 优先级:--token > JPAGE_TOKEN > MCP_TOKEN(环境变量或项目 .env 自动读取)
61
+ # base 优先级:--base > JPAGE_BASE > 默认 http://localhost:8858
62
+ jpage upload ./site.zip --public
63
+
64
+ # 覆盖更新已有文件(按 id,自动版本备份)
65
+ jpage upload ./x.html --overwrite 42
66
+
67
+ # 其余常用:
68
+ jpage ls --kw 季度 --limit 5 # 列出文件
69
+ jpage cat 8 # 输出文件内容
70
+ jpage url 8 # 打印 /s/:key
71
+ jpage tags 8 add Q3,财报 # 追加标签
72
+ jpage rm 8 --yes # 删除
73
+ ```
74
+
75
+ ### 方式二:curl multipart(底层兜底,无 CLI 时)
76
+
77
+ ```bash
78
+ # token 三选一:.env 里的 MCP_TOKEN / .mcp.json 里 Authorization 的 Bearer / 用户给的 jp_ 用户 token
79
+ TOKEN=$(grep -E '^MCP_TOKEN=' .env 2>/dev/null | cut -d= -f2-)
80
+ [ -z "$TOKEN" ] && TOKEN=$(grep -oE 'Bearer [A-Za-z0-9_]+' .mcp.json 2>/dev/null | head -1 | awk '{print $2}')
81
+ [ -z "$TOKEN" ] && { echo "需要 MCP_TOKEN 或 jp_ token,请用户提供"; exit 1; }
82
+ BASE="${JPAGE_BASE:-http://localhost:8858}"
83
+
84
+ curl -sS -X POST "$BASE/api/files/upload" \
85
+ -H "Authorization: Bearer $TOKEN" \
86
+ -F "file=@./site.zip" \
87
+ -F "isPublic=true"
88
+ ```
89
+
90
+ - bundle(网站包)还是 batch(多个独立文件)由服务端按内容自动判定,返回结构与 `upload_file` 一致(含 `id`、`share_key`、预览 `/s/:key`)
91
+ - 单个 HTML/MD 文件已在磁盘时同样用这条:`-F "file=@./note.md"`
92
+ - 覆盖更新已有文件内容:multipart 打 `POST "$BASE/api/files/:id/overwrite"`(自动版本备份)
93
+ - 纯 MCP 客户端(如 Claude Desktop 无 Bash)才退回 `upload_file`,且尽量别传大 ZIP
94
+
95
+ ## 模型现场生成的多文件站点 → Write 写盘 → zip → 上传
96
+
97
+ 不要把每个资源 base64 塞进 `upload_file`。先用 Write 工具把文件写到磁盘、Bash 打包,再上传(CLI 或 curl 二选一):
98
+
99
+ ```bash
100
+ zip -r site.zip index.html assets/
101
+ jpage upload site.zip --public # 或退回上一节的 curl multipart
102
+ ```
103
+
104
+ ## 模型刚生成的 HTML/MD(含大文件)→ 直接 upload_file
105
+
106
+ 内容本就在模型输出里,`upload_file` 让这些 token 只发一遍——返回值不含 content(`{id, original_name, file_type, size, share_key, ...}`),不会回灌进上下文。**注意:大 HTML 慢在「模型生成这些 token」,不在「上传」**——换 curl 也救不了,因为内容还得由模型逐字写出来;真要提速超大文件,靠拆成多次 Write 增量写盘,而非换上传通道。
107
+
108
+ 判断标准只有一条:内容是「此刻生成、已在上下文」还是「磁盘上已存在的文件」——前者用 `upload_file`,后者用 curl。
109
+
110
+ # 工作流
111
+
112
+ ## 场景一:新建内容并上传
113
+
114
+ 最常用的流程——用户要求生成内容,直接上传。
115
+
116
+ ```
117
+ 1. 根据用户需求生成完整的 HTML 或 Markdown 内容
118
+ 2. 调用 upload_file:
119
+ - name: 带扩展名的文件名(如 "data-report.html"、"meeting-notes.md")
120
+ - content: 完整正文(UTF-8 字符串)
121
+ - isPublic: 默认 true(除非用户明确要求私有)
122
+ 3. 向用户展示返回的 url 链接
123
+ ```
124
+
125
+ **upload_file 返回结构**:
126
+ ```json
127
+ {
128
+ "id": 42,
129
+ "original_name": "data-report.html",
130
+ "file_type": "html",
131
+ "size": 12345,
132
+ "is_public": 1,
133
+ "share_key": "abc12345",
134
+ "url": "http://jpage.example.com/s/abc12345"
135
+ }
136
+ ```
137
+
138
+ **示例对话**:
139
+ - 用户:"帮我做一个销售数据仪表板"
140
+ - AI:生成完整 HTML → 调 `upload_file(name="sales-dashboard.html", content=...)` → 展示 URL
141
+
142
+ ## 场景二:读取已有文件 → 修改 → 覆盖更新
143
+
144
+ 用户要求修改已上传的文件。
145
+
146
+ ```
147
+ 1. 调 list_files 查看文件列表,找到目标文件 id
148
+ 2. 调 get_file_content(id=目标id) 读取当前内容
149
+ 3. 根据用户要求修改内容
150
+ 4. 调 upload_file(name=原文件名, content=修改后内容, overwriteFileId=目标id)
151
+ 5. 告知用户已更新
152
+ ```
153
+
154
+ **注意**:使用 `overwriteFileId` 会自动将旧版本存入版本历史,无需先删除再上传。
155
+
156
+ ## 场景三:查看已上传文件
157
+
158
+ 用户想看即页上有什么文件。
159
+
160
+ ```
161
+ 1. 调 list_files 返回文件列表
162
+ 2. 向用户展示文件摘要(文件名、类型、大小、公开/私有)
163
+ ```
164
+
165
+ **list_files 返回结构**(每个文件):
166
+ ```json
167
+ {
168
+ "id": 42,
169
+ "original_name": "report.html",
170
+ "file_type": "html",
171
+ "size": 12345,
172
+ "is_public": 1,
173
+ "created_at": "2026-06-08T10:30:00.000Z",
174
+ "share_key": "abc12345",
175
+ "starred": false,
176
+ "category_id": 1
177
+ }
178
+ ```
179
+
180
+ ## 场景四:管理标签与分类
181
+
182
+ 用户要求对文件进行组织管理。
183
+
184
+ ```
185
+ # 查看现有标签/分类
186
+ 调 list_tags → 展示所有标签及文件数
187
+ 调 list_categories → 展示所有分类及文件数
188
+
189
+ # 上传时直接打标签
190
+ upload_file(name="Q3报告.html", content=..., tags=["报告", "Q3", "财务"])
191
+
192
+ # 给已有文件设置标签
193
+ add_tags_to_file(fileId=42, tags=["重要", "待审核"])
194
+
195
+ # 创建分类并归档文件
196
+ create_category(name="2026年报告") → 拿到 categoryId
197
+ set_file_category(fileId=42, categoryId=分类id)
198
+ ```
199
+
200
+ ## 场景五:版本历史管理
201
+
202
+ 用户想查看或恢复文件的历史版本。
203
+
204
+ ```
205
+ # 查看版本历史
206
+ list_file_versions(fileId=42)
207
+ → 返回当前版本信息 + 所有历史版本列表
208
+
209
+ # 恢复到指定版本
210
+ restore_file_version(fileId=42, version=3)
211
+ → 当前版本自动备份,版本3的内容成为新当前版本
212
+ ```
213
+
214
+ ## 场景六:获取分享链接
215
+
216
+ 用户想获取文件的分享链接。
217
+
218
+ ```
219
+ 调 get_file_url(id=42)
220
+ → 返回 { id: 42, url: "http://jpage.example.com/s/abc12345" }
221
+ ```
222
+
223
+ 短链接格式 `/s/:key` 是最佳分享方式,公开文件无需登录即可访问。
224
+
225
+ ## 删除文件
226
+
227
+ ```
228
+ 调 delete_file(id=42)
229
+ ```
230
+
231
+ **必须先向用户确认再执行删除**,此操作不可撤销。
232
+
233
+ # 常见错误处理
234
+
235
+ | 错误信息 | 原因 | 处理方式 |
236
+ |---|---|---|
237
+ | `不支持的文件扩展名` | 文件名后缀不在允许列表中 | 确保文件名为 `.html`、`.htm`、`.md` 或 `.markdown` |
238
+ | `文件过大` | 内容超过 50MB | 拆分内容或压缩 |
239
+ | `文件不存在` | 使用了无效的文件 ID | 重新 list_files 获取有效 ID |
240
+ | `无权操作此文件` | 非所有者且非 admin | 告知用户权限不足 |
241
+
242
+ # 文件命名建议
243
+
244
+ AI 生成文件名时,遵循以下原则:
245
+ - 使用有意义的中文名或英文命(如 `销售报告-Q3.html`、`meeting-notes-2026-06.md`)
246
+ - 必须带正确的扩展名(`.html` / `.md`)
247
+ - 避免特殊字符(`/`、`\`、`:`、`*`、`?`)
@@ -0,0 +1,135 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const archiver = require('archiver');
4
+ const logger = require('./logger');
5
+
6
+ const SKILLS_DIR = path.join(__dirname, 'skills');
7
+
8
+ function safeListDirs(root) {
9
+ try {
10
+ return fs.readdirSync(root, { withFileTypes: true })
11
+ .filter(d => d.isDirectory())
12
+ .map(d => d.name);
13
+ } catch (e) {
14
+ if (e.code === 'ENOENT') return [];
15
+ throw e;
16
+ }
17
+ }
18
+
19
+ function walkFiles(root) {
20
+ const result = [];
21
+ const stack = [''];
22
+ while (stack.length) {
23
+ const rel = stack.pop();
24
+ const abs = path.join(root, rel);
25
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const childRel = rel ? path.posix.join(rel, entry.name) : entry.name;
28
+ if (entry.isDirectory()) {
29
+ stack.push(childRel);
30
+ } else if (entry.isFile()) {
31
+ const stat = fs.statSync(path.join(abs, entry.name));
32
+ result.push({ relPath: childRel, absPath: path.join(abs, entry.name), size: stat.size });
33
+ }
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+
39
+ function parseFrontmatter(text) {
40
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
41
+ if (!m) return { meta: {}, body: text };
42
+ const meta = {};
43
+ for (const line of m[1].split(/\r?\n/)) {
44
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
45
+ if (kv) {
46
+ let v = kv[2].trim();
47
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
48
+ v = v.slice(1, -1);
49
+ }
50
+ meta[kv[1]] = v;
51
+ }
52
+ }
53
+ return { meta, body: m[2] };
54
+ }
55
+
56
+ function dirSize(root) {
57
+ let total = 0;
58
+ for (const f of walkFiles(root)) total += f.size;
59
+ return total;
60
+ }
61
+
62
+ function readSkillMeta(skillName) {
63
+ const skillRoot = path.join(SKILLS_DIR, skillName);
64
+ if (!fs.existsSync(skillRoot) || !fs.statSync(skillRoot).isDirectory()) return null;
65
+ const skillMdPath = path.join(skillRoot, 'SKILL.md');
66
+ let parsed = { meta: {}, body: '' };
67
+ if (fs.existsSync(skillMdPath)) {
68
+ parsed = parseFrontmatter(fs.readFileSync(skillMdPath, 'utf-8'));
69
+ }
70
+ const meta = parsed.meta;
71
+ if (!meta.name) meta.name = skillName;
72
+ const files = walkFiles(skillRoot);
73
+ const installMdPath = path.join(skillRoot, 'INSTALL.md');
74
+ let installBody = '';
75
+ if (fs.existsSync(installMdPath)) {
76
+ installBody = fs.readFileSync(installMdPath, 'utf-8');
77
+ }
78
+ return {
79
+ name: skillName,
80
+ title: meta.name,
81
+ description: meta.description || '',
82
+ version: meta.version || '',
83
+ author: meta.author || '',
84
+ fileCount: files.length,
85
+ totalSize: files.reduce((s, f) => s + f.size, 0),
86
+ files: files.map(f => f.relPath),
87
+ body: parsed.body,
88
+ installBody,
89
+ };
90
+ }
91
+
92
+ function listSkills() {
93
+ const names = safeListDirs(SKILLS_DIR);
94
+ const skills = [];
95
+ for (const name of names) {
96
+ const skillRoot = path.join(SKILLS_DIR, name);
97
+ const skillMd = path.join(skillRoot, 'SKILL.md');
98
+ if (!fs.existsSync(skillMd)) continue;
99
+ const meta = readSkillMeta(name);
100
+ if (meta) {
101
+ skills.push({
102
+ name: meta.name,
103
+ title: meta.title,
104
+ description: meta.description,
105
+ version: meta.version,
106
+ author: meta.author,
107
+ fileCount: meta.fileCount,
108
+ totalSize: meta.totalSize,
109
+ });
110
+ }
111
+ }
112
+ return skills;
113
+ }
114
+
115
+ function getSkill(name) {
116
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) return null;
117
+ return readSkillMeta(name);
118
+ }
119
+
120
+ function createZipStream(name) {
121
+ const skill = getSkill(name);
122
+ if (!skill) return null;
123
+ const skillRoot = path.join(SKILLS_DIR, name);
124
+ const archive = archiver('zip', { zlib: { level: 9 } });
125
+ archive.on('warning', (err) => {
126
+ if (err.code !== 'ENOENT') logger.error({ type: 'app', message: 'archiver 警告', error: err.message });
127
+ });
128
+ archive.on('error', (err) => {
129
+ logger.error({ type: 'app', message: 'archiver 错误', error: err.message });
130
+ });
131
+ archive.directory(skillRoot, name);
132
+ return archive;
133
+ }
134
+
135
+ module.exports = { listSkills, getSkill, createZipStream, SKILLS_DIR };
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+ <link rel="stylesheet" href="{{katex_css_url}}">
8
+ <link rel="stylesheet" href="{{hljs_css_url}}/{{hljs_theme}}.min.css">
9
+ <style>
10
+ @import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Noto+Serif+SC:wght@400;600;700&display=swap');
11
+ body { font-family: 'Crimson Pro', 'Noto Serif SC', Georgia, 'Times New Roman', serif; max-width: 800px; margin: 60px auto; padding: 0 40px; line-height: 1.8; color: #2c2c2c; background: #fdfdfd; font-size: 17px; }
12
+ h1 { font-size: 1.8em; font-weight: 700; color: #1a1a1a; text-align: center; margin-top: 2em; margin-bottom: 0.8em; padding-bottom: 0.3em; border-bottom: 2px solid #333; }
13
+ h2 { font-size: 1.4em; font-weight: 600; color: #1a1a1a; margin-top: 1.8em; margin-bottom: 0.6em; }
14
+ h3 { font-size: 1.15em; font-weight: 600; color: #333; margin-top: 1.5em; margin-bottom: 0.5em; }
15
+ h4 { font-size: 1em; font-weight: 600; color: #444; margin-top: 1.2em; margin-bottom: 0.4em; }
16
+ p { margin-top: 0; margin-bottom: 1em; text-align: justify; }
17
+ pre { background: #f5f2eb; padding: 20px; border-radius: 3px; overflow-x: auto; border: 1px solid #e0dbd2; font-size: 0.88em; line-height: 1.5; }
18
+ pre code { background: none; padding: 0; }
19
+ code { font-family: 'Courier New', Courier, monospace; font-size: 0.88em; }
20
+ :not(pre) > code { background: #f0ece3; padding: 2px 5px; border-radius: 2px; border: 1px solid #e0dbd2; }
21
+ blockquote { border-left: 3px solid #8b7355; margin: 1em 0; padding: 0.5em 1.2em; color: #5a5a5a; background: #faf8f3; font-style: italic; }
22
+ table { border-collapse: collapse; width: 100%; margin: 1.5em 0; font-size: 0.95em; }
23
+ th, td { border: 1px solid #c9c2b5; padding: 10px 14px; text-align: left; }
24
+ th { background: #f0ece3; font-weight: 600; }
25
+ img { max-width: 100%; height: auto; display: block; margin: 1.5em auto; }
26
+ a { color: #5b3a1a; text-decoration: underline; text-decoration-color: #c9b99a; text-underline-offset: 2px; }
27
+ a:hover { color: #3d2510; text-decoration-color: #8b7355; }
28
+ hr { border: none; border-top: 1px solid #c9c2b5; margin: 2em 0; }
29
+ ul, ol { padding-left: 1.8em; margin-bottom: 1em; }
30
+ .katex-display { margin: 1.2em 0; overflow-x: auto; overflow-y: hidden; }
31
+ pre.mermaid { background: #ffffff; text-align: center; border: none; }
32
+ .katex-error { color: #a63d40; background: #fdf0ef; padding: 1px 4px; border-radius: 2px; }
33
+ </style>
34
+ </head>
35
+ <body>{{content}}
36
+ <script src="{{mermaid_js_url}}"></script>
37
+ <script>
38
+ mermaid.initialize({ startOnLoad: true, securityLevel: 'strict', theme: 'default' });
39
+ </script>
40
+ </body>
41
+ </html>
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+ <link rel="stylesheet" href="{{katex_css_url}}">
8
+ <link rel="stylesheet" href="{{hljs_css_url}}/{{hljs_theme}}.min.css">
9
+ <style>
10
+ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif; max-width: 920px; margin: 0 auto; padding: 40px 24px; line-height: 1.75; color: #cdd6f4; background: #1e1e2e; }
11
+ h1 { font-size: 2em; font-weight: 700; color: #cdd6f4; margin-top: 1.5em; margin-bottom: 0.6em; padding-bottom: 0.3em; border-bottom: 1px solid #45475a; }
12
+ h2 { font-size: 1.5em; font-weight: 600; color: #cdd6f4; margin-top: 1.5em; margin-bottom: 0.5em; }
13
+ h3 { font-size: 1.2em; font-weight: 600; color: #bac2de; margin-top: 1.3em; margin-bottom: 0.4em; }
14
+ h4 { font-size: 1em; font-weight: 600; color: #a6adc8; }
15
+ p { margin-top: 0; margin-bottom: 1em; }
16
+ pre { background: #181825; padding: 16px; border-radius: 8px; overflow-x: auto; border: 1px solid #313244; }
17
+ pre code { background: none; padding: 0; }
18
+ code { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-size: 0.88em; }
19
+ :not(pre) > code { background: #313244; color: #f38ba8; padding: 3px 7px; border-radius: 4px; }
20
+ blockquote { border-left: 3px solid #89b4fa; margin: 1em 0; padding: 0.5em 1em; color: #a6adc8; background: rgba(137, 180, 250, 0.06); }
21
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
22
+ th, td { border: 1px solid #45475a; padding: 10px 14px; text-align: left; }
23
+ th { background: #181825; color: #cdd6f4; }
24
+ tr:nth-child(even) { background: rgba(49, 50, 68, 0.5); }
25
+ img { max-width: 100%; height: auto; border-radius: 4px; }
26
+ a { color: #89b4fa; text-decoration: none; }
27
+ a:hover { color: #b4befe; text-decoration: underline; }
28
+ hr { border: none; border-top: 1px solid #45475a; margin: 2em 0; }
29
+ ul, ol { padding-left: 2em; }
30
+ .katex-display { margin: 1em 0; overflow-x: auto; overflow-y: hidden; }
31
+ pre.mermaid { background: #1e1e2e; text-align: center; border: none; }
32
+ .katex-error { color: #f38ba8; background: rgba(243, 139, 168, 0.1); padding: 2px 5px; border-radius: 3px; }
33
+ </style>
34
+ </head>
35
+ <body>{{content}}
36
+ <script src="{{mermaid_js_url}}"></script>
37
+ <script>
38
+ mermaid.initialize({ startOnLoad: true, securityLevel: 'strict', theme: 'dark' });
39
+ </script>
40
+ </body>
41
+ </html>
@@ -0,0 +1,56 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+ <link rel="stylesheet" href="{{katex_css_url}}">
8
+ <link rel="stylesheet" href="{{hljs_css_url}}/{{hljs_theme}}.min.css" media="(prefers-color-scheme: light)">
9
+ <link rel="stylesheet" href="{{hljs_css_url}}/{{hljs_theme}}-dark.min.css" media="(prefers-color-scheme: dark)">
10
+ <style>
11
+ :root { color-scheme: light dark; }
12
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; line-height: 1.7; color: #24292f; background: #ffffff; }
13
+ h1, h2, h3, h4 { color: #1f2328; margin-top: 1.5em; margin-bottom: 0.6em; }
14
+ pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
15
+ pre code { background: none; padding: 0; }
16
+ code { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-size: 0.9em; }
17
+ :not(pre) > code { background: rgba(175, 184, 193, 0.2); padding: 2px 6px; border-radius: 3px; }
18
+ blockquote { border-left: 4px solid #d0d7de; margin: 0; padding-left: 16px; color: #57606a; }
19
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
20
+ th, td { border: 1px solid #d0d7de; padding: 8px 12px; text-align: left; }
21
+ th { background: #f6f8fa; }
22
+ img { max-width: 100%; height: auto; }
23
+ a { color: #0969da; text-decoration: none; }
24
+ a:hover { text-decoration: underline; }
25
+ .katex-display { margin: 1em 0; overflow-x: auto; overflow-y: hidden; }
26
+ pre.mermaid { background: #ffffff; color: #1f2328; text-align: center; }
27
+ .katex-error { color: #cf222e; background: #ffebe9; padding: 1px 4px; border-radius: 3px; }
28
+ @media (prefers-color-scheme: dark) {
29
+ body { color: #e6edf3; background: #0d1117; }
30
+ h1, h2, h3, h4 { color: #f0f6fc; }
31
+ pre { background: #161b22; }
32
+ blockquote { border-left-color: #3d444d; color: #9198a1; }
33
+ th, td { border-color: #3d444d; }
34
+ th { background: #161b22; }
35
+ a { color: #2f81f7; }
36
+ pre.mermaid { background: #0d1117; color: #e6edf3; }
37
+ }
38
+ </style>
39
+ </head>
40
+ <body>{{content}}
41
+ <script src="{{mermaid_js_url}}"></script>
42
+ <script>
43
+ (function() {
44
+ function initMermaid() {
45
+ var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
46
+ mermaid.initialize({ startOnLoad: true, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
47
+ }
48
+ if (document.readyState === 'loading') {
49
+ document.addEventListener('DOMContentLoaded', initMermaid);
50
+ } else {
51
+ initMermaid();
52
+ }
53
+ })();
54
+ </script>
55
+ </body>
56
+ </html>
@@ -0,0 +1,67 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+ <link rel="stylesheet" href="{{katex_css_url}}">
8
+ <link rel="stylesheet" href="{{hljs_css_url}}/{{hljs_theme}}.min.css" media="(prefers-color-scheme: light)">
9
+ <link rel="stylesheet" href="{{hljs_css_url}}/{{hljs_theme}}-dark.min.css" media="(prefers-color-scheme: dark)">
10
+ <style>
11
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif; max-width: 980px; margin: 0 auto; padding: 45px 20px; line-height: 1.6; color: #1f2328; background: #ffffff; }
12
+ .markdown-body { box-sizing: border-box; min-width: 200px; }
13
+ h1 { font-size: 2em; font-weight: 700; border-bottom: 1px solid #d0d7de; padding-bottom: .3em; margin-top: 24px; margin-bottom: 16px; }
14
+ h2 { font-size: 1.5em; font-weight: 600; border-bottom: 1px solid #d0d7de; padding-bottom: .3em; margin-top: 24px; margin-bottom: 16px; }
15
+ h3 { font-size: 1.25em; font-weight: 600; margin-top: 24px; margin-bottom: 16px; }
16
+ h4 { font-size: 1em; font-weight: 600; margin-top: 24px; margin-bottom: 16px; }
17
+ p { margin-top: 0; margin-bottom: 16px; }
18
+ pre { background: #f6f8fa; border-radius: 6px; padding: 16px; overflow-x: auto; font-size: 85%; line-height: 1.45; }
19
+ pre code { background: none; padding: 0; font-size: 100%; }
20
+ code { font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; font-size: 85%; }
21
+ :not(pre) > code { background: rgba(175, 184, 193, 0.2); padding: .2em .4em; border-radius: 6px; font-size: 85%; }
22
+ blockquote { border-left: .25em solid #d0d7de; margin: 0 0 16px 0; padding: 0 1em; color: #57606a; }
23
+ table { border-collapse: collapse; width: 100%; margin: 16px 0; overflow: hidden; }
24
+ th, td { border: 1px solid #d0d7de; padding: 6px 13px; }
25
+ th { font-weight: 600; background: #f6f8fa; }
26
+ tr:nth-child(even) { background: #f6f8fa; }
27
+ img { max-width: 100%; height: auto; box-sizing: border-box; }
28
+ a { color: #0969da; text-decoration: none; }
29
+ a:hover { text-decoration: underline; }
30
+ hr { border: 0; border-top: 1px solid #d0d7de; margin: 24px 0; }
31
+ ul, ol { margin-top: 0; margin-bottom: 16px; padding-left: 2em; }
32
+ .katex-display { margin: 16px 0; overflow-x: auto; overflow-y: hidden; }
33
+ pre.mermaid { background: #ffffff; text-align: center; }
34
+ .katex-error { color: #cf222e; background: #ffebe9; padding: .2em .4em; border-radius: 6px; }
35
+ @media (prefers-color-scheme: dark) {
36
+ body { color: #e6edf3; background: #0d1117; }
37
+ h1, h2 { border-bottom-color: #3d444d; }
38
+ pre { background: #161b22; }
39
+ blockquote { border-left-color: #3d444d; color: #9198a1; }
40
+ th, td { border-color: #3d444d; }
41
+ th { background: #161b22; }
42
+ tr:nth-child(even) { background: #161b22; }
43
+ :not(pre) > code { background: rgba(110, 118, 129, 0.4); }
44
+ a { color: #2f81f7; }
45
+ hr { border-top-color: #3d444d; }
46
+ pre.mermaid { background: #0d1117; }
47
+ .katex-error { background: rgba(248, 81, 73, 0.15); }
48
+ }
49
+ </style>
50
+ </head>
51
+ <body><div class="markdown-body">{{content}}</div>
52
+ <script src="{{mermaid_js_url}}"></script>
53
+ <script>
54
+ (function() {
55
+ function initMermaid() {
56
+ var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
57
+ mermaid.initialize({ startOnLoad: true, securityLevel: 'strict', theme: dark ? 'dark' : 'default' });
58
+ }
59
+ if (document.readyState === 'loading') {
60
+ document.addEventListener('DOMContentLoaded', initMermaid);
61
+ } else {
62
+ initMermaid();
63
+ }
64
+ })();
65
+ </script>
66
+ </body>
67
+ </html>