@wenyan-md/cli 1.0.11 → 2.0.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.
package/README.docker.md CHANGED
@@ -125,6 +125,37 @@ WECHAT_APP_SECRET=yyy
125
125
  - Runtime: Node.js (bundled)
126
126
  - Architecture: `linux/amd64`, `linux/arm64`
127
127
 
128
+ ## Server Mode
129
+
130
+ Deploy on a cloud server with fixed IP to solve WeChat API whitelist requirements:
131
+
132
+ ```bash
133
+ docker run -d --name wenyan-server \
134
+ -p 3000:3000 \
135
+ --env-file .env \
136
+ caol64/wenyan-cli \
137
+ serve --port 3000
138
+ ```
139
+
140
+ Then call the REST API from your local machine:
141
+
142
+ ```bash
143
+ # Health check
144
+ curl http://your-server-ip:3000/health
145
+
146
+ # Render
147
+ curl -X POST http://your-server-ip:3000/render \
148
+ -H "Content-Type: application/json" \
149
+ -d '{"content": "# Hello World", "theme": "default"}'
150
+
151
+ # Publish
152
+ curl -X POST http://your-server-ip:3000/publish \
153
+ -H "Content-Type: application/json" \
154
+ -d '{"file": "/mnt/host-downloads/article.md"}'
155
+ ```
156
+
157
+ > **Note:** Add your server's public IP to WeChat Official Account whitelist once, and it works permanently.
158
+
128
159
  ## License
129
160
 
130
161
  Apache License Version 2.0
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <div align="center">
2
- <img alt = "logo" src="https://raw.githubusercontent.com/caol64/wenyan/main/Data/256-mac.png" />
2
+ <img alt="logo" src="https://raw.githubusercontent.com/caol64/wenyan/main/Data/256-mac.png" width="128" />
3
3
  </div>
4
4
 
5
5
  # 文颜 CLI
@@ -27,215 +27,64 @@
27
27
 
28
28
  - [macOS App Store 版](https://github.com/caol64/wenyan) - MAC 桌面应用
29
29
  - [跨平台桌面版](https://github.com/caol64/wenyan-pc) - Windows/Linux
30
- - 👉 [CLI 版本](https://github.com/caol64/wenyan-cli) - 本项目
30
+ - 👉[CLI 版本](https://github.com/caol64/wenyan-cli) - 本项目
31
31
  - [MCP 版本](https://github.com/caol64/wenyan-mcp) - AI 自动发文
32
- - [核心库](https://github.com/caol64/wenyan-core) - 嵌入 Node / Web 项目
33
32
 
34
- ## 安装方式
33
+ ## 特性
35
34
 
36
- ### 方式一:npm(推荐)
35
+ - 一键发布 Markdown 到微信公众号草稿箱
36
+ - 自动上传本地图片与封面
37
+ - 支持远程 Server 发布(绕过 IP 白名单限制)
38
+ - 内置多套精美排版主题
39
+ - 支持自定义主题
40
+ - 可作为 CI/CD 自动发文工具
41
+ - 可集成 AI Agent 自动发布
37
42
 
38
- ```bash
39
- npm install -g @wenyan-md/cli
40
- ```
41
-
42
- 安装完成后即可使用:
43
-
44
- ```bash
45
- wenyan --help
46
- ```
47
-
48
- ### 方式二:Docker(无需 Node 环境)
49
-
50
- 如果你不想在本地安装 Node.js,也可以直接使用 Docker。
51
-
52
- **拉取镜像**
53
-
54
- ```bash
55
- docker pull caol64/wenyan-cli
56
- ```
57
-
58
- **查看帮助**
43
+ ## 快速开始
59
44
 
60
45
  ```bash
61
- docker run --rm caol64/wenyan-cli
46
+ # 安装
47
+ npm install -g @wenyan-md/cli
48
+ # 发布文章到公众号
49
+ wenyan publish -f article.md
62
50
  ```
63
51
 
64
- **发布文章示例**
65
-
66
- ```bash
67
- docker run --rm \
68
- --env-file .env.test \
69
- -e HOST_FILE_PATH=$(pwd) \
70
- -v $(pwd):/mnt/host-downloads \
71
- caol64/wenyan-cli \
72
- publish -f ./test/publish.md -t phycat
73
- ```
74
-
75
- > 说明:
76
- >
77
- > - 使用 `-e` 传入环境变量
78
- > - 使用 `-v` 挂载本地 Markdown 文件
79
- > - 容器启动即执行 `wenyan` 命令
80
-
81
- ## 基本用法
82
-
83
- CLI 主命令:
52
+ ## 命令概览
84
53
 
85
54
  ```bash
86
55
  wenyan <command> [options]
87
56
  ```
88
57
 
89
- 目前支持的子命令有
90
- - `publish` 排版并发布到公众号草稿箱
91
- - `render` 仅排版,用做测试
92
- - `theme` 主题管理
93
-
94
- ## 子命令
95
-
96
- ### `publish`
97
-
98
- 将 Markdown 转换为适配微信公众号的富文本 HTML,并上传到草稿箱。
99
-
100
- #### 参数
101
-
102
- - `<input-content>`
103
-
104
- Markdown 内容,可以:
105
-
106
- - 直接作为参数传入
107
- - 通过 stdin 管道输入
108
-
109
- #### 常用选项
110
-
111
- - `-t <theme-id>`:主题id(默认 `default`),可以是内置主题,也可以是通过`theme --add`添加的自定义主题
112
- - [内置主题](https://github.com/caol64/wenyan-core/tree/main/src/assets/themes)
113
- - `-h <highlight-theme-id>`:代码高亮主题(默认 `solarized-light`)
114
- - atom-one-dark / atom-one-light / dracula / github-dark / github / monokai / solarized-dark / solarized-light / xcode
115
- - `--no-mac-style`:关闭代码块 Mac 风格
116
- - `--no-footnote`:关闭链接转脚注
117
- - `-f <path>`:指定本地 Markdown 文件路径
118
- - `-c <path>`:指定临时自定义主题路径,优先级大于`-t`
119
-
120
- #### 使用示例
121
-
122
- 直接传入内容:
123
-
124
- ```bash
125
- wenyan publish "# Hello, Wenyan" -t lapis -h solarized-light
126
- ```
127
-
128
- 从管道读取:
129
-
130
- ```bash
131
- cat example.md | wenyan publish -t lapis -h solarized-light --no-mac-style
132
- ```
133
-
134
- 从文件读取:
135
-
136
- ```bash
137
- wenyan publish -f "./example.md" -t lapis -h solarized-light --no-mac-style
138
- ```
139
-
140
- ### `theme`
141
-
142
- 主题管理,浏览内置主题、添加/删除自定义主题。
143
-
144
- #### 参数
145
-
146
- 无。
147
-
148
- #### 常用选项
149
-
150
- - `-l`:列出所有可用主题
151
- - `--add`:添加自定义主题(永久)
152
- - `--name <name>`:主题名称
153
- - `--path <path>`:主题路径(本地或网络)
154
- - `--rm <name>`:删除自定义主题
155
-
156
- #### 使用示例
157
-
158
- 列出可用主题:
159
-
160
- ```bash
161
- wenyan theme -l
162
- ```
163
-
164
- 安装自定义主题
165
-
166
- ```bash
167
- wenyan theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
168
- ```
169
-
170
- 删除自定义主题
171
-
172
- ```bash
173
- wenyan theme --rm new-theme
174
- ```
175
-
176
- ## 使用自定义主题
177
-
178
- 你可以通过两种途径使用自定义主题:
179
-
180
- - 不安装直接使用
181
-
182
- ```bash
183
- wenyan publish -f "./example.md" -c "/path/to/theme" -h solarized-light --no-mac-style
184
- ```
185
-
186
- - 先安装再使用:
58
+ | 命令 | 说明 |
59
+ | ------- | --------- |
60
+ | [publish](docs/publish.md) | 发布文章 |
61
+ | render | 渲染 HTML |
62
+ | [theme](docs/theme.md) | 管理主题 |
63
+ | [serve](docs/server.md) | 启动 Server |
187
64
 
188
- ```bash
189
- wenyan theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
190
- wenyan publish -f "./example.md" -t new-theme -h solarized-light --no-mac-style
191
- ```
65
+ ## 概念
192
66
 
193
- 区别在于,安装后的主题永久有效。
67
+ ### 内容输入
194
68
 
195
- ## 关于图片自动上传
69
+ 内容输入是指如何把 Markdown 文章分发给 `wenyan-cli`,支持以下四种方式:
196
70
 
197
- 支持以下图片来源:
71
+ | 方式 | 示例 | 说明 |
72
+ | ------- | --------- |--------- |
73
+ | 本地路径(推荐) | `wenyan publish -f article.md` |`cli`直接读取磁盘上的文章 |
74
+ | URL | `wenyan publish -f http://test.md` |`cli`直接读取网络上的文章 |
75
+ | 参数 | `wenyan publish "# 文章"` |适用于快速发布短内容 |
76
+ | 管道 | `cat article.md \| wenyan publish` |适用于 CI/CD,脚本批量发布 |
198
77
 
199
- - 本地路径(如:`/Users/xxx/image.jpg`)
200
- - 网络路径(如:`https://example.com/image.jpg`)
78
+ ### 环境变量配置
201
79
 
202
- ## 环境变量配置
203
-
204
- 部分功能(如发布微信公众号)需要配置以下环境变量:
80
+ > [!IMPORTANT]
81
+ >
82
+ > 请确保运行文颜的机器已配置如下环境变量,否则上传接口将调用失败。
205
83
 
206
84
  - `WECHAT_APP_ID`
207
85
  - `WECHAT_APP_SECRET`
208
86
 
209
- ### macOS / Linux
210
-
211
- 临时使用:
212
-
213
- ```bash
214
- WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy wenyan publish "your markdown"
215
- ```
216
-
217
- 永久配置(推荐):
218
-
219
- ```bash
220
- export WECHAT_APP_ID=xxx
221
- export WECHAT_APP_SECRET=yyy
222
- ```
223
-
224
- ### Windows (PowerShell)
225
-
226
- 临时使用:
227
-
228
- ```powershell
229
- $env:WECHAT_APP_ID="xxx"
230
- $env:WECHAT_APP_SECRET="yyy"
231
- wenyan publish example.md
232
- ```
233
-
234
- 永久设置(在环境变量里添加):
235
-
236
- 控制面板 → 系统和安全 → 系统 → 高级系统设置 → 环境变量 → 添加 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。
237
-
238
- ## 微信公众号 IP 白名单
87
+ ### 微信公众号 IP 白名单
239
88
 
240
89
  > [!IMPORTANT]
241
90
  >
@@ -243,9 +92,9 @@ wenyan publish example.md
243
92
 
244
93
  配置说明文档:[https://yuzhi.tech/docs/wenyan/upload](https://yuzhi.tech/docs/wenyan/upload)
245
94
 
246
- ## Markdown Frontmatter 说明(必读)
95
+ ### 文章格式
247
96
 
248
- 为了正确上传文章,每篇 Markdown 顶部需要包含 frontmatter
97
+ 为了正确上传文章,每篇 Markdown 顶部需要包含一段 `frontmatter`:
249
98
 
250
99
  ```md
251
100
  ---
@@ -265,23 +114,46 @@ source_url: http://
265
114
  - `author` 文章作者
266
115
  - `source_url` 原文地址
267
116
 
268
- ## 示例文章格式
117
+ **[示例文章](tests/publish.md)**
269
118
 
270
- ```md
271
- ---
272
- title: 在本地跑一个大语言模型(2) - 给模型提供外部知识库
273
- cover: /Users/lei/Downloads/result_image.jpg
274
- ---
119
+ ### 文内图片和文章封面
120
+
121
+ 把文章发布到公众号之前,文颜会按照微信要求自动处理文章内的所有图片,将其上传到公众号素材库。目前文颜对于以下两种图片都能很好的支持:
122
+
123
+ - 本地硬盘绝对路径(如:`/Users/xxx/image.jpg`)
124
+ - 网络路径(如:`https://example.com/image.jpg`)
125
+
126
+ 仅当“内容输入”方式为“本地路径”时,以下路径也能完美支持:
127
+
128
+ - 当前文章的相对路径(如:`./assets/image.png`)
275
129
 
276
- 在[上一篇文章](https://babyno.top/posts/2024/02/running-a-large-language-model-locally/)中,我们展示了如何在本地运行大型语言模型。本篇将介绍如何让模型从外部知识库中检索定制数据,提升答题准确率,让它看起来更“智能”。
130
+ ## Server 模式
277
131
 
278
- ## 准备模型
132
+ 相较于纯本地运行的**本地模式(Local Mode)**,`wenyan-cli`还提供了 **远程客户端模式(Client–Server Mode)**。两种模式运行效果完全一致,你可以根据运行环境和网络条件选择最合适的方式。
279
133
 
280
- 访问 `Ollama` 的模型页面,搜索 `qwen`,我们使用支持中文语义的“[通义千问](https://ollama.com/library/qwen:7b)”模型进行实验。
134
+ 在本地模式下,CLI 直接调用微信公众号 API 完成图片上传和草稿发布。
281
135
 
282
- ![](https://mmbiz.qpic.cn/mmbiz_jpg/Jsq9IicjScDVUjkPc6O22ZMvmaZUzof5bLDjMyLg2HeAXd0icTvlqtL7oiarSlOicTtiaiacIxpVOV1EeMKl96PhRPPw/640?wx_fmt=jpeg)
136
+ ```mermaid
137
+ flowchart LR
138
+ CLI[Wenyan CLI] --> Wechat[公众号 API]
283
139
  ```
284
140
 
141
+ 在远程客户端模式下,CLI 作为客户端,将发布请求发送到部署在云服务器上的 Wenyan Server,由 Server 完成微信公众号 API 调用。
142
+
143
+ ```mermaid
144
+ flowchart LR
145
+ CLI[Wenyan CLI] --> Server[Wenyan Server] --> Wechat[公众号 API]
146
+ ```
147
+
148
+ **适用于:**
149
+
150
+ * 无本地固定 IP,需频繁添加IP 白名单的用户
151
+ * 需团队协作的用户
152
+ * 支持 CI/CD 自动发布
153
+ * 支持 AI Agent 自动发布
154
+
155
+ **[Server 模式部署](docs/server.md)**
156
+
285
157
  ## 赞助
286
158
 
287
159
  如果你觉得文颜对你有帮助,可以给我家猫咪买点罐头 ❤️
package/dist/cli.js CHANGED
@@ -1,4 +1,129 @@
1
1
  #!/usr/bin/env node
2
- import { createProgram } from "./index.js";
2
+ import { Command } from "commander";
3
+ import pkg from "../package.json" with { type: "json" };
4
+ import { addTheme, listThemes, prepareRenderContext, removeTheme, renderAndPublish, renderAndPublishToServer, } from "@wenyan-md/core/wrapper";
5
+ import { getInputContent } from "./utils.js";
6
+ export function createProgram(version = pkg.version) {
7
+ const program = new Command();
8
+ program
9
+ .name("wenyan")
10
+ .description("A CLI for WenYan Markdown Render.")
11
+ .version(version, "-v, --version", "output the current version")
12
+ .action(() => {
13
+ program.outputHelp();
14
+ });
15
+ const addCommonOptions = (cmd) => {
16
+ return cmd
17
+ .argument("[input-content]", "markdown content (string input)")
18
+ .option("-f, --file <path>", "read markdown content from local file or web URL")
19
+ .option("-t, --theme <theme-id>", "ID of the theme to use", "default")
20
+ .option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
21
+ .option("-c, --custom-theme <path>", "path to custom theme CSS file")
22
+ .option("--mac-style", "display codeblock with mac style", true)
23
+ .option("--no-mac-style", "disable mac style")
24
+ .option("--footnote", "convert link to footnote", true)
25
+ .option("--no-footnote", "disable footnote");
26
+ };
27
+ const pubCmd = program
28
+ .command("publish")
29
+ .description("Render a markdown file to styled HTML and publish to wechat GZH");
30
+ // 先添加公共选项,再追加 publish 专属选项
31
+ addCommonOptions(pubCmd)
32
+ .option("--server <url>", "Server URL to publish through (e.g. https://api.yourdomain.com)")
33
+ .option("--api-key <apiKey>", "API key for the remote server")
34
+ .action(async (inputContent, options) => {
35
+ await runCommandWrapper(async () => {
36
+ // 如果传入了 --server,则走客户端(远程)模式
37
+ if (options.server) {
38
+ options.clientVersion = version; // 将 CLI 版本传递给服务器,便于调试和兼容性处理
39
+ const mediaId = await renderAndPublishToServer(inputContent, options, getInputContent);
40
+ console.log(`发布成功,Media ID: ${mediaId}`);
41
+ }
42
+ else {
43
+ // 走原有的本地直接发布模式
44
+ const mediaId = await renderAndPublish(inputContent, options, getInputContent);
45
+ console.log(`发布成功,Media ID: ${mediaId}`);
46
+ }
47
+ });
48
+ });
49
+ const renderCmd = program.command("render").description("Render a markdown file to styled HTML");
50
+ addCommonOptions(renderCmd).action(async (inputContent, options) => {
51
+ await runCommandWrapper(async () => {
52
+ const { gzhContent } = await prepareRenderContext(inputContent, options, getInputContent);
53
+ console.log(gzhContent.content);
54
+ });
55
+ });
56
+ program
57
+ .command("theme")
58
+ .description("Manage themes")
59
+ .option("-l, --list", "List all available themes")
60
+ .option("--add", "Add a new custom theme")
61
+ .option("--name <name>", "Name of the new custom theme")
62
+ .option("--path <path>", "Path to the new custom theme CSS file")
63
+ .option("--rm <name>", "Name of the custom theme to remove")
64
+ .action(async (options) => {
65
+ await runCommandWrapper(async () => {
66
+ const { list, add, name, path, rm } = options;
67
+ if (list) {
68
+ const themes = await listThemes();
69
+ console.log("内置主题:");
70
+ themes
71
+ .filter((theme) => theme.isBuiltin)
72
+ .forEach((theme) => {
73
+ console.log(`- ${theme.id}: ${theme.description ?? ""}`);
74
+ });
75
+ const customThemes = themes.filter((theme) => !theme.isBuiltin);
76
+ if (customThemes.length > 0) {
77
+ console.log("\n自定义主题:");
78
+ customThemes.forEach((theme) => {
79
+ console.log(`- ${theme.id}: ${theme.description ?? ""}`);
80
+ });
81
+ }
82
+ return;
83
+ }
84
+ if (add) {
85
+ await addTheme(name, path);
86
+ console.log(`主题 "${name}" 已添加`);
87
+ return;
88
+ }
89
+ if (rm) {
90
+ await removeTheme(rm);
91
+ console.log(`主题 "${rm}" 已删除`);
92
+ }
93
+ });
94
+ });
95
+ program
96
+ .command("serve")
97
+ .description("Start a server to provide HTTP API for rendering and publishing")
98
+ .option("-p, --port <port>", "Port to listen on (default: 3000)", "3000")
99
+ .option("--api-key <apiKey>", "API key for authentication")
100
+ .action(async (options) => {
101
+ try {
102
+ const { serveCommand } = await import("./commands/serve.js");
103
+ const port = options.port ? parseInt(options.port, 10) : 3000;
104
+ await serveCommand({ port, version, apiKey: options.apiKey });
105
+ }
106
+ catch (error) {
107
+ console.error(error.message);
108
+ process.exit(1);
109
+ }
110
+ });
111
+ return program;
112
+ }
113
+ // --- 统一的错误处理包装器 ---
114
+ async function runCommandWrapper(action) {
115
+ try {
116
+ await action();
117
+ }
118
+ catch (error) {
119
+ if (error instanceof Error) {
120
+ console.error(error.message);
121
+ }
122
+ else {
123
+ console.error("An unexpected error occurred:", error);
124
+ }
125
+ process.exit(1);
126
+ }
127
+ }
3
128
  const program = createProgram();
4
129
  program.parse(process.argv);
@@ -0,0 +1,225 @@
1
+ import express from "express";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { configDir } from "@wenyan-md/core/wrapper";
6
+ import multer from "multer";
7
+ import { publishToWechatDraft } from "@wenyan-md/core/publish";
8
+ class AppError extends Error {
9
+ message;
10
+ constructor(message) {
11
+ super(message);
12
+ this.message = message;
13
+ this.name = "AppError";
14
+ }
15
+ }
16
+ const UPLOAD_TTL_MS = 10 * 60 * 1000; // 10 minutes
17
+ const UPLOAD_DIR = path.join(configDir, "uploads");
18
+ export async function serveCommand(options) {
19
+ // 确保临时目录存在
20
+ await fs.mkdir(UPLOAD_DIR, { recursive: true });
21
+ // 服务启动时立即执行一次后台清理
22
+ cleanupOldUploads();
23
+ // 定期清理过期的上传文件
24
+ setInterval(cleanupOldUploads, UPLOAD_TTL_MS).unref();
25
+ const app = express();
26
+ const port = options.port || 3000;
27
+ const auth = createAuthHandler(options);
28
+ app.use(express.json({ limit: "10mb" }));
29
+ const storage = multer.diskStorage({
30
+ destination: (req, file, cb) => {
31
+ cb(null, UPLOAD_DIR);
32
+ },
33
+ filename: (req, file, cb) => {
34
+ const fileId = crypto.randomUUID();
35
+ const ext = file.originalname.split(".").pop() || "";
36
+ cb(null, ext ? `${fileId}.${ext}` : fileId);
37
+ },
38
+ });
39
+ const upload = multer({
40
+ storage,
41
+ limits: {
42
+ fileSize: 10 * 1024 * 1024, // 10MB
43
+ },
44
+ fileFilter: (req, file, cb) => {
45
+ const ext = file.originalname.split(".").pop()?.toLowerCase();
46
+ // 1. 定义允许的图片类型
47
+ const allowedImageTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
48
+ const allowedImageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
49
+ // 2. 分别判断文件大类
50
+ const isImage = allowedImageTypes.includes(file.mimetype) || (ext && allowedImageExts.includes(ext));
51
+ const isMarkdown = ext === "md" || file.mimetype === "text/markdown" || file.mimetype === "text/plain";
52
+ const isCss = ext === "css" || file.mimetype === "text/css";
53
+ const isJson = ext === "json" || file.mimetype === "application/json";
54
+ // 3. 综合放行逻辑
55
+ if (isImage || isMarkdown || isCss || isJson) {
56
+ cb(null, true);
57
+ }
58
+ else {
59
+ cb(new AppError("不支持的文件类型,仅支持图片、Markdown、CSS 和 JSON 文件"));
60
+ }
61
+ },
62
+ });
63
+ // 健康检查
64
+ app.get("/health", (_req, res) => {
65
+ res.json({ status: "ok", service: "wenyan-cli", version: options.version || "unknown" });
66
+ });
67
+ // 鉴权探针
68
+ app.get("/verify", auth, (_req, res) => {
69
+ res.json({ success: true, message: "Authorized" });
70
+ });
71
+ // 发布接口 - 读取 json 文件内容并发布
72
+ app.post("/publish", auth, async (req, res) => {
73
+ const body = req.body;
74
+ validateRequest(body);
75
+ // 根据 fileId 去找刚上传的 json 文件并读取内容
76
+ const files = await fs.readdir(UPLOAD_DIR);
77
+ const matchedFile = files.find((f) => f === body.fileId);
78
+ if (!matchedFile) {
79
+ throw new AppError(`文件不存在或已过期,请重新上传 (ID: ${body.fileId})`);
80
+ }
81
+ // 简单的防呆校验,防止直接提交纯图片的 fileId 到发布接口
82
+ const ext = path.extname(matchedFile).toLowerCase();
83
+ if (ext !== ".json") {
84
+ throw new AppError("请提供 JSON 文件的 fileId,不能直接发布图片文件");
85
+ }
86
+ // 找到上传文件并提取文本内容
87
+ const filePath = path.join(UPLOAD_DIR, matchedFile);
88
+ const fileContent = await fs.readFile(filePath, "utf-8");
89
+ const gzhContent = JSON.parse(fileContent);
90
+ if (!gzhContent.title)
91
+ throw new AppError("未能找到文章标题");
92
+ // 公共的 asset:// 替换逻辑
93
+ const resolveAssetPath = (assetUrl) => {
94
+ const assetFileId = assetUrl.replace("asset://", "");
95
+ const matchedAsset = files.find((f) => f === assetFileId || path.parse(f).name === assetFileId);
96
+ return matchedAsset ? path.join(UPLOAD_DIR, matchedAsset) : assetUrl;
97
+ };
98
+ // 替换 HTML 内容里的 asset://
99
+ gzhContent.content = gzhContent.content.replace(/(<img\b[^>]*?\bsrc\s*=\s*["'])(asset:\/\/[^"']+)(["'])/gi, (_match, prefix, assetUrl, suffix) => prefix + resolveAssetPath(assetUrl) + suffix);
100
+ // 替换封面里的 asset://
101
+ if (gzhContent.cover && gzhContent.cover.startsWith("asset://")) {
102
+ gzhContent.cover = resolveAssetPath(gzhContent.cover);
103
+ }
104
+ const data = await publishToWechatDraft({
105
+ title: gzhContent.title,
106
+ content: gzhContent.content,
107
+ cover: gzhContent.cover,
108
+ author: gzhContent.author,
109
+ source_url: gzhContent.source_url,
110
+ });
111
+ if (data.media_id) {
112
+ res.json({
113
+ media_id: data.media_id,
114
+ });
115
+ }
116
+ else {
117
+ throw new AppError(`发布到微信公众号失败,\n${data}`);
118
+ }
119
+ });
120
+ // 上传接口
121
+ app.post("/upload", auth, upload.single("file"), async (req, res) => {
122
+ if (!req.file) {
123
+ throw new AppError("未找到上传的文件");
124
+ }
125
+ const newFilename = req.file.filename;
126
+ res.json({
127
+ success: true,
128
+ data: {
129
+ fileId: newFilename,
130
+ originalFilename: req.file.originalname,
131
+ mimetype: req.file.mimetype,
132
+ size: req.file.size,
133
+ },
134
+ });
135
+ });
136
+ app.use(errorHandler);
137
+ return new Promise((resolve, reject) => {
138
+ const server = app.listen(port, () => {
139
+ console.log(`文颜 Server 已启动,监听端口 ${port}`);
140
+ console.log(`健康检查:http://localhost:${port}/health`);
141
+ console.log(`鉴权探针:http://localhost:${port}/verify`);
142
+ console.log(`发布接口:POST http://localhost:${port}/publish`);
143
+ console.log(`上传接口:POST http://localhost:${port}/upload`);
144
+ });
145
+ server.on("error", (err) => {
146
+ if (err.code === "EADDRINUSE") {
147
+ console.error(`端口 ${port} 已被占用`);
148
+ reject(new Error(`端口 ${port} 已被占用`));
149
+ }
150
+ else {
151
+ reject(err);
152
+ }
153
+ });
154
+ process.on("SIGINT", () => {
155
+ console.log("\n正在关闭服务器...");
156
+ server.close(() => {
157
+ console.log("服务器已关闭");
158
+ resolve();
159
+ });
160
+ });
161
+ process.on("SIGTERM", () => {
162
+ server.close(() => resolve());
163
+ });
164
+ });
165
+ }
166
+ function errorHandler(error, _req, res, next) {
167
+ if (res.headersSent) {
168
+ return next(error);
169
+ }
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ // 修复:multer 抛出的文件限制错误(如超出大小),应判断为客户端 400 错误
172
+ const isAppError = error instanceof AppError;
173
+ const isMulterError = error.name === "MulterError";
174
+ const statusCode = isAppError || isMulterError ? 400 : 500;
175
+ if (statusCode === 500) {
176
+ console.error("[Server Error]:", error);
177
+ }
178
+ res.status(statusCode).json({
179
+ code: -1,
180
+ desc: message,
181
+ });
182
+ }
183
+ function createAuthHandler(config) {
184
+ return (req, res, next) => {
185
+ if (!config.apiKey) {
186
+ return next();
187
+ }
188
+ const clientApiKey = req.headers["x-api-key"];
189
+ if (clientApiKey === config.apiKey) {
190
+ next();
191
+ }
192
+ else {
193
+ res.status(401).json({
194
+ code: -1,
195
+ desc: "Unauthorized: Invalid API Key",
196
+ });
197
+ }
198
+ };
199
+ }
200
+ function validateRequest(req) {
201
+ if (!req.fileId) {
202
+ throw new AppError("缺少必要参数:fileId");
203
+ }
204
+ }
205
+ async function cleanupOldUploads() {
206
+ try {
207
+ const files = await fs.readdir(UPLOAD_DIR);
208
+ const now = Date.now();
209
+ for (const file of files) {
210
+ const filePath = path.join(UPLOAD_DIR, file);
211
+ try {
212
+ const stats = await fs.stat(filePath);
213
+ if (now - stats.mtimeMs > UPLOAD_TTL_MS) {
214
+ await fs.unlink(filePath);
215
+ }
216
+ }
217
+ catch (e) {
218
+ // 忽略单个文件处理错误
219
+ }
220
+ }
221
+ }
222
+ catch (e) {
223
+ console.error("Cleanup task error:", e);
224
+ }
225
+ }
package/dist/index.js CHANGED
@@ -1,43 +1 @@
1
- import { Command } from "commander";
2
- import { publishCommand } from "./commands/publish.js";
3
- import { renderCommand } from "./commands/render.js";
4
- import pkg from "../package.json" with { type: "json" };
5
- import { themeCommand } from "./commands/theme.js";
6
- export function createProgram(version = pkg.version) {
7
- const program = new Command();
8
- program
9
- .name("wenyan")
10
- .description("A CLI for WenYan Markdown Render.")
11
- .version(version, "-v, --version", "output the current version")
12
- .action(() => {
13
- program.outputHelp();
14
- });
15
- const addCommonOptions = (cmd) => {
16
- return cmd
17
- .argument("[input-content]", "markdown content (string input)")
18
- .option("-f, --file <path>", "read markdown content from local file")
19
- .option("-t, --theme <theme-id>", "ID of the theme to use", "default")
20
- .option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
21
- .option("-c, --custom-theme <path>", "path to custom theme CSS file")
22
- .option("--mac-style", "display codeblock with mac style", true)
23
- .option("--no-mac-style", "disable mac style")
24
- .option("--footnote", "convert link to footnote", true)
25
- .option("--no-footnote", "disable footnote");
26
- };
27
- const pubCmd = program
28
- .command("publish")
29
- .description("Render a markdown file to styled HTML and publish to wechat GZH");
30
- addCommonOptions(pubCmd).action(publishCommand);
31
- const renderCmd = program.command("render").description("Render a markdown file to styled HTML");
32
- addCommonOptions(renderCmd).action(renderCommand);
33
- program
34
- .command("theme")
35
- .description("Manage themes")
36
- .option("-l, --list", "List all available themes")
37
- .option("--add", "Add a new custom theme")
38
- .option("--name <name>", "Name of the new custom theme")
39
- .option("--path <path>", "Path to the new custom theme CSS file")
40
- .option("--rm <name>", "Name of the custom theme to remove")
41
- .action(themeCommand);
42
- return program;
43
- }
1
+ export {};
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { Command } from "commander";
3
+ export declare function createProgram(version?: string): Command;
@@ -0,0 +1,6 @@
1
+ export interface ServeOptions {
2
+ port?: number;
3
+ version?: string;
4
+ apiKey?: string;
5
+ }
6
+ export declare function serveCommand(options: ServeOptions): Promise<void>;
@@ -1,2 +1 @@
1
- import { Command } from "commander";
2
- export declare function createProgram(version?: string): Command;
1
+ export {};
@@ -1,2 +1,5 @@
1
1
  export declare function readStdin(): Promise<string>;
2
- export declare function getNormalizeFilePath(inputPath: string): string;
2
+ export declare function getInputContent(inputContent?: string, file?: string): Promise<{
3
+ content: string;
4
+ absoluteDirPath: string | undefined;
5
+ }>;
package/dist/utils.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { getNormalizeFilePath } from "@wenyan-md/core/wrapper";
2
4
  export async function readStdin() {
3
5
  return new Promise((resolve, reject) => {
4
6
  let data = "";
@@ -8,29 +10,21 @@ export async function readStdin() {
8
10
  process.stdin.on("error", reject);
9
11
  });
10
12
  }
11
- /**
12
- * 路径标准化工具函数
13
- * Windows 的反斜杠 \ 转换为正斜杠 /,并去除末尾斜杠
14
- * 目的:在 Linux 容器内也能正确处理 Windows 路径字符串
15
- */
16
- function normalizePath(p) {
17
- return p.replace(/\\/g, "/").replace(/\/+$/, "");
18
- }
19
- export function getNormalizeFilePath(inputPath) {
20
- const isContainer = !!process.env.CONTAINERIZED;
21
- if (isContainer) {
22
- const hostFilePath = normalizePath(process.env.HOST_FILE_PATH || "");
23
- const containerFilePath = normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads");
24
- let relativePart = normalizePath(inputPath);
25
- if (relativePart.startsWith(hostFilePath)) {
26
- relativePart = relativePart.slice(hostFilePath.length);
27
- }
28
- if (!relativePart.startsWith("/")) {
29
- relativePart = "/" + relativePart;
30
- }
31
- return containerFilePath + relativePart;
13
+ export async function getInputContent(inputContent, file) {
14
+ let absoluteDirPath = undefined;
15
+ // 1. 尝试从 Stdin 读取
16
+ if (!inputContent && !process.stdin.isTTY) {
17
+ inputContent = await readStdin();
18
+ }
19
+ // 2. 尝试从文件读取
20
+ if (!inputContent && file) {
21
+ const normalizePath = getNormalizeFilePath(file);
22
+ inputContent = await fs.readFile(normalizePath, "utf-8");
23
+ absoluteDirPath = path.dirname(normalizePath);
32
24
  }
33
- else {
34
- return path.resolve(inputPath);
25
+ // 3. 校验输入
26
+ if (!inputContent) {
27
+ throw new Error("missing input-content (no argument, no stdin, and no file).");
35
28
  }
29
+ return { content: inputContent, absoluteDirPath };
36
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenyan-md/cli",
3
- "version": "1.0.11",
3
+ "version": "2.0.1",
4
4
  "description": "A CLI tool for Wenyan markdown rendering & publishing",
5
5
  "author": "Lei <caol64@gmail.com> (https://github.com/caol64)",
6
6
  "license": "Apache-2.0",
@@ -31,13 +31,17 @@
31
31
  "url": "https://github.com/caol64/wenyan-cli/issues"
32
32
  },
33
33
  "dependencies": {
34
- "@wenyan-md/core": "^2.0.5",
34
+ "@wenyan-md/core": "^2.0.8",
35
35
  "commander": "^14.0.0",
36
+ "express": "^5.2.1",
36
37
  "form-data-encoder": "^4.1.0",
37
38
  "formdata-node": "^6.0.3",
38
- "jsdom": "^27.4.0"
39
+ "jsdom": "^27.4.0",
40
+ "multer": "^2.1.0"
39
41
  },
40
42
  "devDependencies": {
43
+ "@types/express": "^5.0.6",
44
+ "@types/multer": "^2.0.0",
41
45
  "@types/node": "^24.3.0",
42
46
  "dotenv-cli": "^10.0.0",
43
47
  "tsx": "^4.21.0",
@@ -48,10 +52,12 @@
48
52
  "scripts": {
49
53
  "build": "tsc",
50
54
  "upgrade:core": "pnpm update @wenyan-md/core",
55
+ "server": "pnpm build && pnpm dotenv -e .env.test -- node ./dist/cli.js serve",
51
56
  "test:bin": "pnpm build && node ./dist/cli.js render -f tests/publish.md -c tests/manhua.css --no-mac-style",
52
57
  "test:cli": "vitest run tests/cli.test.ts",
53
58
  "test:render": "vitest run tests/render.test.ts",
54
59
  "test:publish": "vitest run tests/publish.test.ts",
55
- "test:realPublish": "pnpm build && pnpm dotenv -e .env.test -- node ./dist/cli.js publish -f tests/publish.md -c tests/manhua.css --no-mac-style"
60
+ "test:realPublish": "pnpm build && pnpm dotenv -e .env.test -- node ./dist/cli.js publish -f tests/publish.md -c tests/manhua.css --no-mac-style",
61
+ "test:serverPublish": "pnpm build && node ./dist/cli.js publish -f tests/publish.md -c tests/manhua.css --no-mac-style --server http://localhost:3000"
56
62
  }
57
63
  }
@@ -1,27 +0,0 @@
1
- import { publishToWechatDraft } from "@wenyan-md/core/publish";
2
- import { prepareRenderContext, runCommandWrapper } from "./render.js";
3
- export async function publishCommand(inputContent, options) {
4
- await runCommandWrapper(async () => {
5
- const { gzhContent, absoluteDirPath } = await prepareRenderContext(inputContent, options);
6
- if (!gzhContent.title)
7
- throw new Error("Error: 未能找到文章标题");
8
- if (!gzhContent.cover)
9
- throw new Error("Error: 未能找到文章封面");
10
- const data = await publishToWechatDraft({
11
- title: gzhContent.title,
12
- content: gzhContent.content,
13
- cover: gzhContent.cover,
14
- author: gzhContent.author,
15
- source_url: gzhContent.source_url,
16
- }, {
17
- relativePath: absoluteDirPath,
18
- });
19
- if (data.media_id) {
20
- console.log(`上传成功,media_id: ${data.media_id}`);
21
- }
22
- else {
23
- console.error(`上传失败,\n${data}`);
24
- process.exit(1);
25
- }
26
- });
27
- }
@@ -1,71 +0,0 @@
1
- import { configStore, renderStyledContent } from "@wenyan-md/core/wrapper";
2
- import { getNormalizeFilePath, readStdin } from "../utils.js";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- // --- 处理输入源、文件路径和主题 ---
6
- export async function prepareRenderContext(inputContent, options) {
7
- const { file, theme, customTheme, highlight, macStyle, footnote } = options;
8
- let absoluteDirPath = undefined;
9
- // 1. 尝试从 Stdin 读取
10
- if (!inputContent && !process.stdin.isTTY) {
11
- inputContent = await readStdin();
12
- }
13
- // 2. 尝试从文件读取
14
- if (!inputContent && file) {
15
- const normalizePath = getNormalizeFilePath(file);
16
- inputContent = await fs.readFile(normalizePath, "utf-8");
17
- absoluteDirPath = path.dirname(normalizePath);
18
- }
19
- // 3. 校验输入
20
- if (!inputContent) {
21
- throw new Error("Error: missing input-content (no argument, no stdin, and no file).");
22
- }
23
- let handledCustomTheme = customTheme;
24
- // 4. 当用户传入自定义主题路径时,优先级最高
25
- if (customTheme) {
26
- const normalizePath = getNormalizeFilePath(customTheme);
27
- handledCustomTheme = await fs.readFile(normalizePath, "utf-8");
28
- }
29
- else if (theme) {
30
- // 否则尝试读取配置中的自定义主题
31
- handledCustomTheme = configStore.getThemeById(theme);
32
- }
33
- if (!handledCustomTheme && !theme) {
34
- throw new Error(`Error: theme "${theme}" not found.`);
35
- }
36
- // 5. 执行核心渲染
37
- const gzhContent = await renderStyledContent(inputContent, {
38
- themeId: theme,
39
- hlThemeId: highlight,
40
- isMacStyle: macStyle,
41
- isAddFootnote: footnote,
42
- themeCss: handledCustomTheme,
43
- });
44
- return { gzhContent, absoluteDirPath };
45
- }
46
- // --- 统一的错误处理包装器 ---
47
- export async function runCommandWrapper(action) {
48
- try {
49
- await action();
50
- }
51
- catch (error) {
52
- if (error instanceof Error) {
53
- if (error.message.startsWith("Error:")) {
54
- console.error(error.message);
55
- }
56
- else {
57
- console.error("An unexpected error occurred:", error.message);
58
- }
59
- }
60
- else {
61
- console.error("An unexpected error occurred:", error);
62
- }
63
- process.exit(1);
64
- }
65
- }
66
- export async function renderCommand(inputContent, options) {
67
- await runCommandWrapper(async () => {
68
- const { gzhContent } = await prepareRenderContext(inputContent, options);
69
- console.log(gzhContent.content);
70
- });
71
- }
@@ -1,78 +0,0 @@
1
- import { getAllGzhThemes } from "@wenyan-md/core";
2
- import { getNormalizeFilePath } from "../utils.js";
3
- import fs from "node:fs/promises";
4
- import { configStore } from "@wenyan-md/core/wrapper";
5
- export async function themeCommand(options) {
6
- const { list, add, name, path, rm } = options;
7
- if (list) {
8
- listThemes();
9
- return;
10
- }
11
- if (add) {
12
- await addTheme(name, path);
13
- return;
14
- }
15
- if (rm) {
16
- await removeTheme(rm);
17
- return;
18
- }
19
- }
20
- function listThemes() {
21
- const themes = getAllGzhThemes();
22
- console.log("\n内置主题:");
23
- themes.forEach((theme) => console.log(`- ${theme.meta.id}: ${theme.meta.description}`));
24
- const customThemes = configStore.getThemes();
25
- if (customThemes.length > 0) {
26
- console.log("\n自定义主题:");
27
- customThemes.forEach((theme) => {
28
- console.log(`- ${theme.id}: ${theme.description ?? ""}`);
29
- });
30
- }
31
- console.log("");
32
- }
33
- async function addTheme(name, path) {
34
- if (!name || !path) {
35
- console.log("❌ 添加主题时必须提供名称(name)和路径(path)\n");
36
- return;
37
- }
38
- if (checkThemeExists(name) || checkCustomThemeExists(name)) {
39
- console.log(`❌ 主题 "${name}" 已存在\n`);
40
- return;
41
- }
42
- if (path.startsWith("http")) {
43
- console.log(`⏳ 正在从远程获取主题: ${path} ...`);
44
- const response = await fetch(path);
45
- if (!response.ok) {
46
- console.log(`❌ 无法从远程获取主题: ${response.statusText}\n`);
47
- return;
48
- }
49
- const content = await response.text();
50
- configStore.addThemeToConfig(name, content);
51
- }
52
- else {
53
- const normalizePath = getNormalizeFilePath(path);
54
- const content = await fs.readFile(normalizePath, "utf-8");
55
- configStore.addThemeToConfig(name, content);
56
- }
57
- console.log(`✅ 主题 "${name}" 已添加\n`);
58
- }
59
- async function removeTheme(name) {
60
- if (checkThemeExists(name)) {
61
- console.log(`❌ 默认主题 "${name}" 不能删除\n`);
62
- return;
63
- }
64
- if (!checkCustomThemeExists(name)) {
65
- console.log(`❌ 自定义主题 "${name}" 不存在\n`);
66
- return;
67
- }
68
- configStore.deleteThemeFromConfig(name);
69
- console.log(`✅ 主题 "${name}" 已删除\n`);
70
- }
71
- function checkThemeExists(themeId) {
72
- const themes = getAllGzhThemes();
73
- return themes.some((theme) => theme.meta.id === themeId);
74
- }
75
- function checkCustomThemeExists(themeId) {
76
- const customThemes = configStore.getThemes();
77
- return customThemes.some((theme) => theme.id === themeId);
78
- }
@@ -1,2 +0,0 @@
1
- import { RenderOptions } from "../types.js";
2
- export declare function publishCommand(inputContent: string | undefined, options: RenderOptions): Promise<void>;
@@ -1,7 +0,0 @@
1
- import { RenderOptions } from "../types.js";
2
- export declare function prepareRenderContext(inputContent: string | undefined, options: RenderOptions): Promise<{
3
- gzhContent: import("@wenyan-md/core/wrapper").StyledContent;
4
- absoluteDirPath: string | undefined;
5
- }>;
6
- export declare function runCommandWrapper(action: () => Promise<void>): Promise<void>;
7
- export declare function renderCommand(inputContent: string | undefined, options: RenderOptions): Promise<void>;
@@ -1,2 +0,0 @@
1
- import { ThemeOptions } from "../types.js";
2
- export declare function themeCommand(options: ThemeOptions): Promise<void>;
@@ -1,15 +0,0 @@
1
- export interface RenderOptions {
2
- file?: string;
3
- theme?: string;
4
- customTheme?: string;
5
- highlight: string;
6
- macStyle: boolean;
7
- footnote: boolean;
8
- }
9
- export interface ThemeOptions {
10
- list?: boolean;
11
- add?: boolean;
12
- name?: string;
13
- path?: string;
14
- rm?: string;
15
- }
package/dist/types.js DELETED
@@ -1 +0,0 @@
1
- export {};